diff --git a/scripts/http-git.nse b/scripts/http-git.nse index 914f6ee60..e43e6969f 100644 --- a/scripts/http-git.nse +++ b/scripts/http-git.nse @@ -13,18 +13,16 @@ -- | BitBucket remote: AlexWebr/nse (accessed over HTTP, pull-only) -- |_ Based on the file '.gitignore', this is a Ruby on Rails application -- --- Version 1.0 +-- Version 1.1 -- Created 27 June 2012 - written by Alex Weber 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 (GET /.git/ HTTP/1.1) +local strbuf = require("strbuf") +local table = require("table") +description = [[ Checks for a Git repository found in a website's document root (GET /.git/ HTTP/1.1) Gets as much information about the repository as possible, including language/framework, Github username, last commit message, and repository description. ]] @@ -35,206 +33,248 @@ license = "Same as Nmap--See http://nmap.org/book/man-legal.html" portrule = shortport.http local STATUS_OK = 200 -- We consider 200 to mean "okay, file exists and we received its contents" -local out -- The string to return to Nmap -local replies = {} - --- Instead of concatenating everywhere --- ap is short for 'append' --- If second argument is nil or false, a new line is made --- for every call. With 'true', we append to --- to the most-recently ap()'ed line -local function ap(to_append, append_to_last_entry) - if not out then out = {} end - if append_to_last_entry then - local len = #out - out[len] = out[len] .. to_append - else - table.insert(out, to_append) - end -end - --- 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 function action(host, port) - -- If we can't get /.git/HEAD, don't even bother continuing - -- We could try for /.git/, but we will not get a 200 if directory - -- listings are disallowed. - if http.get(host, port, "/.git/HEAD").status == STATUS_OK then - -- 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 = { - ".git/config", - ".git/description", - ".git/info/exclude", - ".git/COMMIT_EDITMSG", - ".gitignore", - } + -- All methods that we call on this table will be from the table library + local out = {} + setmetatable(out, {__index = table}) + local repos_found = 0 - local count = { ok = 0, tried = 0 } - local prequests = {} - -- Go through all of the filenames and do an HTTP GET - for _, name in ipairs(repo) do -- for every filename - http.pipeline_add('/' .. name, nil, prequests) + -- 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 i, 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 - -- do the requests - replies = http.pipeline(host, port, prequests) - for i, reply in ipairs(replies) do - count.tried = count.tried + 1 - -- 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 - if reply.status == STATUS_OK then count.ok = count.ok + 1 end + if not string.find(root, "^/") then -- if there is no slash at the beginning + root = "/" .. root end - -- Tell the user that we found a repository, and indicate if - -- we didn't find all the files we were looking for. - if count.ok == count.tried then - ap("Git repository found in web root") - else -- if we didn't find all the files we were hoping to, we might not actually have a repo - ap("Potential Git repository found in web root") - ap(" (found " .. tostring(count.ok + 1) .. " of " .. tostring(count.tried + 1) .. " expected files)", true) - -- we already got /.git/HEAD, so add one to 'found' and 'expected' - end - - -- This function matches a table of words/regexes against a single string - -- This function is used immediately after it is declared - local function match_many(str, table_of_words) - local matched_string, lstr, t_to_return = false, string.lower(str), {} - for i, word in ipairs(table_of_words) do - matched_string = string.match(lstr, word) - if matched_string then table.insert(t_to_return , matched_string) end + -- If we can't get /.git/HEAD, don't even bother continuing + -- We could try for /.git/, but we will not get a 200 if directory + -- listings are disallowed. + if http.get(host, port, root .. ".git/HEAD").status == STATUS_OK then + if repos_found > 0 then + out:insert("") end - return t_to_return - end - -- Look through all the repo files we grabbed and see if we can find anything interesting - local interesting = { "bug", "passw", "pw", "user", "uid", "key", "secret" } - for name, reply in pairs(replies) do - if ok(name) then -- for all replies that were successful - local found_anything = false -- have we found anything yet? - for _, matched in ipairs(match_many(reply.body:lower(), interesting)) do -- match all files against 'interesting' - if not found_anything then -- if this is our first find, print filename and stuff - ap("Contents of '" .. name .. "' matched patterns '" .. matched .. "'") - found_anything = true - else ap(", '" .. matched .. "'", true) end -- if we found something already, tack this pattern onto the end - end -- If we matched anything, finish the line up - if found_anything then ap(" (case-insensitive)", true) end + repos_found = repos_found + 1 + 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 - end - - -- Given a longer plain-text string (a large text file, for example), append - -- a summary of it (the first 60 characters or the first line, whichever is shorter) - local function append_short_version(description, original_string) - local short = string.sub(original_string, 1, 60) -- trim the string first, in case it is huge - local no_newline = string.match(short, "(.-)\r?\n") -- we don't want such an open-ended regex on a potentially huge string - -- We try to cut off the newline if we can - local s = no_newline or short - ap(description .. ": " .. s) - -- If we cut off something, we want to put an ellipsis on the end - if #original_string > #s then - ap("...", true) - end - end - - -- Get the first line and trim to 60 characters, if we got a COMMIT_EDITMESSAGE - if ok(".git/COMMIT_EDITMSG") then - -- If there's no newline in the file (there usually is), this won't work. - -- Normally, it's a commit message followed by a bunch of commented-out lines (#). - append_short_version("Last commit message", replies[".git/COMMIT_EDITMSG"].body) - end - - -- Do the same thing as for description - get first line, truncate to 60 characters. - if ok(".git/description") then - append_short_version("Repository description", replies[".git/description"].body) - end - - -- This function will take a Git hosting service URL or a service - -- the allows deployment via Git and find out if there is an entry - -- for it in the configuration file - local function lookforremote(config, url, service, success_string) - -- Different combinations of separating characters in the remote can - -- indicate the access method - I know about SSH, HTTP, and Smart HTTP - local access1, access2, reponame = string.match( - config, "([@/])"..url.."([:/])([%w._-]+/?[%w._-]+)[%s$]") - if reponame then - -- Try and cut off the '.git' extension - reponame = string.match(reponame, "(.+)%.git") or reponame - ap(service .. " remote: " .. reponame) - -- git@github:Username... = SSH, https://github.com/Username... = HTTP{S} - -- ^ ^ We match on these ^ ^ - if access1 == "@" and access2 == "/" then - -- Smart HTTP uses regular HTTP urls, but includes 'username@github.com...' - ap(" (accessed over Smart HTTP)", true) - elseif access1 == "@" and access2 == ":" then - -- SSH syntax is like 'git@github.com:User/repo.git' - ap(" (accessed over SSH)", true) - elseif access1 == "/" and access2 == "/" then - -- 'Dumb' HTTP is read-only, looks like "https://github.com/User/repo.git" - ap(" (accessed over HTTP, pull-only)", true) - else - -- Not sure what / and : could be... perhaps regular, unencrypted Git protocol? - ap(" (can't determine access method)") - end - -- If we did find an entry for this service in the configuration, that might - -- mean something special (example - Heroku remotes might be deployed somewhere) - -- We replace '' with the reponame, with the URL, etc - if success_string then - local replace = { reponame = reponame, url = url, service = service } - ap(string.gsub(success_string, "<(.-)>", replace)) - end - end - end - - -- If we got /.git/config, we might find out things like the user's GitHub name, - -- if they have a Heroku remote, whether this is a bare repository or not (if it - -- is bare, that means it's likely a remote for other people), and in future - -- versions of Git when there are more than one repo format version, we will - -- display that too. - if ok(".git/config") then - -- These are some popular / well-known Git hosting services and/or hosting services - -- that allow deployment via 'git push' - local popular_remotes = { - { "github%.com", "GitHub" }, - { "gitorious%.com", "Gitorious" }, - { "bitbucket%.org", "BitBucket" }, - { "heroku%.com", "Heroku", "App might be deployed to http://.herokuapp.com" }, + -- 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 = { + ".git/config", + ".git/description", + ".git/info/exclude", + ".git/COMMIT_EDITMSG", + ".gitignore", } - -- Go through all of the popular remotes and look for it in the config file - for _, remote in ipairs(popular_remotes) do - lookforremote(replies[".git/config"].body, remote[1], remote[2], remote[3]) - 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 = { - { "/%.bundle", "Ruby on Rails application" }, -- More specific matches (MyFaces > JSF > Java) on top - { "%.py[co]", "Python application" }, - { "%.jsp", "JSP webapp" }, - { "%.class", "Java application" }, - } - local excludefile_that_matched = nil - local app = nil - -- We check every file against every fingerprint - for _, file in ipairs(ignorefiles) do - if ok(file) then -- we only test all fingerprints if we got the file - for i, fingerprint in ipairs(fingerprints) do - if string.match(replies[file].body, fingerprint[1]) then - ap("Based on the file '" .. file .. "', this is a " .. fingerprint[2]) - -- Based on the file '.gitignore', this is a Ruby on Rails application" - break -- we only want to print our first guess (the most specific one that matched) + local count = { ok = 0, tried = 0 } + local prequests = {} -- prequests = 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, prequests) + end + -- do the requests + replies = http.pipeline_go(host, port, prequests) + for i, reply in ipairs(replies) do + count.tried = count.tried + 1 + -- 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 + if reply.status == STATUS_OK then count.ok = count.ok + 1 end + end + + -- Tell the user that we found a repository, and indicate if + -- we didn't find all the files we were looking for. + local location = host.ip .. ":" .. port.number .. root .. ".git/" + if count.ok == count.tried then + out:insert("Git repository found at " .. location) + else -- if we didn't find all the files we were hoping to, we might not actually have a repo + out:insert("Potential Git repository found at " .. location .. " (found " .. + tostring(count.ok + 1) .. " of " .. tostring(count.tried + 1) .. " expected files)") + -- we already got /.git/HEAD, so add one to 'found' and 'expected' + end + + -- This function matches a table of words/regexes against a single string + -- This function is used immediately after it is declared + local function match_many(str, table_of_words) + local matched_string, lstr, t_to_return = false, string.lower(str), {} + for i, word in ipairs(table_of_words) do + matched_string = string.match(lstr, word) + if matched_string then table.insert(t_to_return , matched_string) end + end + return t_to_return + end + + -- Look through all the repo files we grabbed and see if we can find anything interesting + local interesting = { "bug", "passw", "pw", "user", "uid", "key", "secret" } + for name, reply in pairs(replies) do + if ok(name) then -- for all replies that were successful + local found_anything = false -- have we found anything yet? + local buf = strbuf.new() + for _, matched in ipairs(match_many(reply.body:lower(), interesting)) do -- match all files against 'interesting' + if not found_anything then -- if this is our first find, print filename and stuff + buf = (((((buf .. "Contents of '") .. name) .. "' matched patterns '") .. matched) .. "'") -- the '..' is right-associative :( + found_anything = true + else + buf = ((buf .. ", '" .. matched) .. "'") + end -- if we found something already, tack this pattern onto the end + end -- If we matched anything, finish the line up + if found_anything then + buf = buf .. " (case-insensitive match)" + out:insert(strbuf.dump(buf)) + end + end + end + + -- Given a longer plain-text string (a large text file, for example), append + -- a summary of it (the first 60 characters or the first line, whichever is shorter) + local function append_short_version(description, original_string) + local short = string.sub(original_string, 1, 60) -- trim the string first, in case it is huge + -- We try to cut off the newline if we can + local no_newline = string.match(short, "(.-)\r?\n") -- we don't want such an open-ended regex on a potentially huge string + s = no_newline or short + if #original_string > #s then + -- If we cut off something, we want to put an ellipsis on the end + s = description .. ": " .. s .. "..." + else + s = description .. ": " .. s + end + out:insert(s) + end + + -- Get the first line and trim to 60 characters, if we got a COMMIT_EDITMESSAGE + if ok(".git/COMMIT_EDITMSG") then + -- If there's no newline in the file (there usually is), this won't work. + -- Normally, it's a commit message followed by a bunch of commented-out lines (#). + append_short_version("Last commit message", replies[".git/COMMIT_EDITMSG"].body) + end + + -- Do the same thing as for description - get first line, truncate to 60 characters. + if ok(".git/description") then + append_short_version("Repository description", replies[".git/description"].body) + end + + -- This function will take a Git hosting service URL or a service + -- the allows deployment via Git and find out if there is an entry + -- for it in the configuration file + function lookforremote(config, url, service, success_string) + -- Different combinations of separating characters in the remote can + -- indicate the access method - I know about SSH, HTTP, and Smart HTTP + local access1, access2, reponame = string.match( + config, "([@/])"..url.."([:/])([%w._-]+/?[%w._-]+)[%s$]") + if reponame then + -- Try and cut off the '.git' extension + reponame = string.match(reponame, "(.+)%.git") or reponame + s = strbuf.new() + s = (((s .. service) .. " remote: ") .. reponame) + -- git@github:Username... = SSH, https://github.com/Username... = HTTP{S} + -- ^ ^ We match on these ^ ^ + if access1 == "@" and access2 == "/" then + -- Smart HTTP uses regular HTTP urls, but includes 'username@github.com...' + s = s .. " (accessed over Smart HTTP)" + elseif access1 == "@" and access2 == ":" then + -- SSH syntax is like 'git@github.com:User/repo.git' + s = s .. " (accessed over SSH)" + elseif access1 == "/" and access2 == "/" then + -- 'Dumb' HTTP is read-only, looks like "https://github.com/User/repo.git" + s = s .. " (accessed over HTTP, pull-only)" + else + -- Not sure what / and : could be... perhaps regular, unencrypted Git protocol? + s = s .. " (can't determine access method)" + end + out:insert(strbuf.dump(s)) + -- If we did find an entry for this service in the configuration, that might + -- mean something special (example - Heroku remotes might be deployed somewhere) + -- We replace '' with the reponame, with the URL, etc + if success_string then + local replace = { reponame = reponame, url = url, service = service } + local val = " -> " .. string.gsub(success_string, "<(.-)>", replace) + out:insert(val) + end + end + end + + -- If we got /.git/config, we might find out things like the user's GitHub name, + -- if they have a Heroku remote, whether this is a bare repository or not (if it + -- is bare, that means it's likely a remote for other people), and in future + -- versions of Git when there are more than one repo format version, we will + -- display that too. + if ok(".git/config") then + -- These are some popular / well-known Git hosting services and/or hosting services + -- that allow deployment via 'git push' + local popular_remotes = { + { "github%.com", "GitHub", "Source might be at https://github.com/" }, + { "gitorious%.com", "Gitorious", "Source might be at https://gitorious.com/" }, + { "bitbucket%.org", "BitBucket", "Source might be at https://bitbucket.org/" }, + { "heroku%.com", "Heroku", "App might be deployed to http://.herokuapp.com" }, + } + -- Go through all of the popular remotes and look for it in the config file + for _, remote in ipairs(popular_remotes) do + lookforremote(replies[".git/config"].body, remote[1], remote[2], remote[3]) + 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/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" }, + } + local excludefile_that_matched = nil + local app = nil + -- We check every file against every fingerprint + for _, file in ipairs(ignorefiles) do + if ok(file) then -- we only test all fingerprints if we got the file + for i, fingerprint in ipairs(fingerprints) do + if string.match(replies[file].body, fingerprint[1]) then + out:insert("Based on the file '" .. file .. "', this is a " .. fingerprint[2]) + -- Based on the file '.gitignore', this is a Ruby on Rails application" + break -- we only want to print our first guess (the most specific one that matched) + end end end end @@ -242,6 +282,6 @@ function action(host, port) end -- Replace non-printing characters with asterisks - if out then return string.gsub(stdnse.format_output(true, out), "[^%w%p%s]", "*") + if #out > 0 then return string.gsub(stdnse.format_output(true, out), "[^%w%p%s]", "*") else return nil end end