From 0d47952611198ef6b1163f366dc03922d20b1475 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 17 Apr 2024 09:42:04 +0200 Subject: Adding upstream version 7.94+git20230807.3be01efb1+dfsg. Signed-off-by: Daniel Baumann --- scripts/http-git.nse | 309 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 scripts/http-git.nse (limited to 'scripts/http-git.nse') diff --git a/scripts/http-git.nse b/scripts/http-git.nse new file mode 100644 index 0000000..26bb22a --- /dev/null +++ b/scripts/http-git.nse @@ -0,0 +1,309 @@ +local http = require("http") +local shortport = require("shortport") +local stdnse = require("stdnse") +local string = require("string") +local table = require("table") + +description = [[ +Checks for a Git repository found in a website's document root +/.git/) and retrieves as much repo information as +possible, including language/framework, remotes, last commit +message, and repository description. +]] + +--- +-- @output +-- PORT STATE SERVICE REASON +-- 80/tcp open http syn-ack +-- | http-git: +-- | 127.0.0.1:80/.git/ +-- | Git repository found! +-- | .git/config matched patterns 'passw' +-- | Repository description: Unnamed repository; edit this file 'description' to name the... +-- | Remotes: +-- | http://github.com/someuser/somerepo +-- | Project type: Ruby on Rails web application (guessed from .git/info/exclude) +-- | 127.0.0.1:80/damagedrepository/.git/ +-- |_ Potential Git repository found (found 2/6 expected files) +-- +-- @args http-git.root URL path to search for a .git directory. Default: / +-- +-- @xmloutput +-- +--
+-- http://github.com/anotherperson/anotherepo +--
+-- +--
+-- JBoss Java web application +-- Java application +--
+-- +-- A nice repository +-- +-- false +-- true +-- true +-- true +-- false +--
+-- +--
+-- passw +--
+-- +-- + +categories = { "default", "safe", "vuln" } +author = "Alex Weber" +license = "Same as Nmap--See https://nmap.org/book/man-legal.html" +portrule = shortport.http + +-- We consider 200 to mean "okay, file exists and we received its contents". +local STATUS_OK = 200 +-- Long strings (like a repository's description) will be truncated to this +-- number of characters in normal output. +local TRUNC_LENGTH = 60 + +function action(host, port) + local out + + -- We can accept a single root, or a table of roots to try + local root_arg = stdnse.get_script_args("http-git.root") + local roots + if type(root_arg) == "table" then + roots = root_arg + elseif type(root_arg) == "string" or type(root_arg) == "number" then + roots = { tostring(root_arg) } + elseif root_arg == nil then -- if we didn't get an argument + roots = { "/" } + end + + -- Try each root in succession + for _, root in ipairs(roots) do + root = tostring(root) + root = root or '/' + + -- Put a forward slash on the beginning and end of the root, if none was + -- provided. We will print this, so the user will know that we've mangled it + if not string.find(root, "/$") then -- if there is no slash at the end + root = root .. "/" + end + if not string.find(root, "^/") then -- if there is no slash at the beginning + root = "/" .. root + end + + -- If we can't get a valid /.git/HEAD, don't even bother continuing + -- We could try for /.git/, but we will not get a 200 if directory + -- listings are disallowed. + local resp = http.get(host, port, root .. ".git/HEAD") + local sha1_pattern = "^%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x%x" + if resp.status == STATUS_OK and ( resp.body:match("^ref: ") or resp.body:match(sha1_pattern) ) then + out = out or {} + local replies = {} + -- This function returns true if we got a 200 OK when + -- fetching 'filename' from the server + local function ok(filename) + return (replies[filename].status == STATUS_OK) + end + -- These are files that are small, very common, and don't + -- require zlib to read + -- These files are created by creating and using the repository, + -- or by popular development frameworks. + local repo = { + ".gitignore", + ".git/COMMIT_EDITMSG", + ".git/config", + ".git/description", + ".git/info/exclude", + } + + local pl_requests = {} -- pl_requests = pipelined requests (temp) + -- Go through all of the filenames and do an HTTP GET + for _, name in ipairs(repo) do -- for every filename + http.pipeline_add(root .. name, nil, pl_requests) + end + -- Do the requests. + replies = http.pipeline_go(host, port, pl_requests) + if replies == nil then + stdnse.debug1("pipeline_go() error. Aborting.") + return nil + end + + for i, reply in ipairs(replies) do + -- We want this to be indexed by filename, not an integer, so we convert it + -- We added to the pipeline in the same order as the filenames, so this is safe. + replies[repo[i]] = reply -- create index by filename + replies[i] = nil -- delete integer-indexed entry + end + + -- Mark each file that we tried to get as 'found' (true) or 'not found' (false). + local location = host.ip .. ":" .. port.number .. root .. ".git/" + out[location] = {} + -- A nice shortcut + local loc = out[location] + loc["files-found"] = {} + for name, _ in pairs(replies) do + loc["files-found"][name] = ok(name) + end + + -- Look through all the repo files we grabbed and see if we can find anything interesting. + local interesting = { "bug", "key", "passw", "pw", "user", "secret", "uid" } + for name, reply in pairs(replies) do + if ok(name) then + for _, pattern in ipairs(interesting) do + if string.match(reply.body, pattern) then + -- A Lua idiom - don't create this table until we actually have something to put in it + loc["interesting-matches"] = loc["interesting-matches"] or {} + loc["interesting-matches"][name] = loc["interesting-matches"][name] or {} + table.insert(loc["interesting-matches"][name], pattern) + end + end + end + end + + if ok(".git/COMMIT_EDITMSG") then + loc["last-commit-message"] = replies[".git/COMMIT_EDITMSG"].body + end + + if ok(".git/description") then + loc["repository-description"] = replies[".git/description"].body + end + + -- .git/config contains a list of remotes, so we try to extract them. + if ok(".git/config") then + local config = replies[".git/config"].body + local remotes = {} + + -- Try to extract URLs of all remotes. + for url in string.gmatch(config, "\n%s*url%s*=%s*(%S*/%S*)") do + table.insert(remotes, url) + end + + for _, url in ipairs(remotes) do + loc["remotes"] = loc["remotes"] or {} + table.insert(loc["remotes"], url) + end + end + + -- These are files that are used by Git to determine what files to ignore. + -- We use this list to make the loop below (used to determine what kind of + -- application is in the repository) more generic. + local ignorefiles = { + ".gitignore", + ".git/info/exclude", + } + local fingerprints = { + -- Many of these taken from https://github.com/github/gitignore + { "%.scala_dependencies", "Scala application" }, + { "npm%-debug%.log", "node.js application" }, + { "joomla%.xml", "Joomla! site" }, + { "jboss/server", "JBoss Java web application" }, + { "wp%-%*%.php", "WordPress site" }, + { "app/config/database%.php", "CakePHP web application" }, + { "sites/default/settings%.php", "Drupal site" }, + { "local_settings%.py", "Django web application" }, + { "/%.bundle", "Ruby on Rails web application" }, -- More specific matches (MyFaces > JSF > Java) on top + { "%.py[dco]", "Python application" }, + { "%.jsp", "JSP web application" }, + { "%.bundle", "Ruby application" }, + { "%.class", "Java application" }, + { "%.php", "PHP application" }, + } + -- The XML produced here is divided by ignorefile and is sorted from first to last + -- in order of specificity. e.g. All JBoss applications are Java applications, + -- but not all Java applications are JBoss. In that case, JBoss and Java will + -- be output, but JBoss will be listed first. + for _, file in ipairs(ignorefiles) do + if ok(file) then -- We only test all fingerprints if we got the file. + for _, fingerprint in ipairs(fingerprints) do + if string.match(replies[file].body, fingerprint[1]) then + loc["project-type"] = loc["project-type"] or {} + loc["project-type"][file] = loc["project-type"][file] or {} + table.insert(loc["project-type"][file], fingerprint[2]) + end + end + end + end + end + end + + -- If we didn't get anything, we return early. No point doing the + -- normal formatting! + if out == nil then + return nil + end + + -- Truncate to TRUNC_LENGTH characters and replace control characters (newlines, etc) with spaces. + local function summarize(str) + str = stdnse.string_or_blank(str, "") + local original_length = #str + str = string.sub(str, 1, TRUNC_LENGTH) + str = string.gsub(str, "%c", " ") + if original_length > TRUNC_LENGTH then + str = str .. "..." + end + return str + end + + -- We convert the full output to pretty output for -oN + local normalout + for location, info in pairs(out) do + normalout = normalout or {} + -- This table gets converted to a string format_output, and then inserted into the 'normalout' table + local new = {} + -- Headings for each place we found a repo + new["name"] = location + + -- How sure are we that this is a Git repository? + local count = { tried = 0, ok = 0 } + for _, found in pairs(info["files-found"]) do + count.tried = count.tried + 1 + if found then count.ok = count.ok + 1 end + end + + -- If 3 or more of the files we were looking for are not on the server, + -- we are less confident that we got a real Git repository + if count.tried - count.ok <= 2 then + table.insert(new, "Git repository found!") + else -- We already got .git/HEAD, so we add 1 to 'tried' and 'ok' + table.insert(new, "Potential Git repository found (found " .. (count.ok + 1) .. "/" .. (count.tried + 1) .. " expected files)") + end + + -- Show what patterns matched what files + for name, matches in pairs(info["interesting-matches"] or {}) do + table.insert(new, ("%s matched patterns '%s'"):format(name, table.concat(matches, "' '"))) + end + + if info["repository-description"] then + table.insert(new, "Repository description: " .. summarize(info["repository-description"])) + end + + if info["last-commit-message"] then + table.insert(new, "Last commit message: " .. summarize(info["last-commit-message"])) + end + + -- If we found any remotes in .git/config, process them now + if info["remotes"] then + local old_name = info["remotes"]["name"] -- in case 'name' is a remote + info["remotes"]["name"] = "Remotes:" + -- Remove the newline from format_output's output - it looks funny with it + local temp = string.gsub(stdnse.format_output(true, info["remotes"]), "^\n", "") + -- using 'temp' here because gsub() has multiple return values that insert() will try + -- to use, and I don't know of a better way to prevent that ;) + table.insert(new, temp) + info["remotes"]["name"] = old_name + end + + -- Take the first guessed project type from each ignorefile + if info["project-type"] then + for name, types in pairs(info["project-type"]) do + table.insert(new, "Project type: " .. types[1] .. " (guessed from " .. name .. ")") + end + end + -- Insert this location's information. + table.insert(normalout, new) + end + + return out, stdnse.format_output(true, normalout) +end -- cgit v1.2.3