1
0
mirror of https://github.com/nmap/nmap.git synced 2025-12-07 21:21:31 +00:00
Files
nmap/scripts/http-git.nse
david 8f3165f7a6 Don't substitute nonprinting characters in http-git.
NSE already escapes output everywhere. More importantly, the call to
gsub returns two values, which were being treated as structured output
and unstructured output. The second return value is an integer count of
substitutions, so what you would see in normal output was
|_http-git: 0
while what used to be normal output was going to XML.

Alex Weber noticed that the script was not working.
http://seclists.org/nmap-dev/2012/q4/397
2012-12-08 10:22:55 +00:00

266 lines
12 KiB
Lua

-- Checks for a Git repository found in a website's document root (GET /.git/<something> HTTP/1.1)
-- Gets as much information about the repository as possible, including language/framework, Github
-- username, last commit message, and repository description.
--
-- @output
-- PORT STATE SERVICE
-- 80/tcp open http
-- | http-git:
-- | Git repository found in web root
-- | Last commit message: This is my last commit...
-- | Repository description: Unnamed repository; edit this file 'description' to name the...
-- | GitHub remote: AlexWebr/nse (accessed over SSH)
-- | BitBucket remote: AlexWebr/nse (accessed over HTTP, pull-only)
-- |_ Based on the file '.gitignore', this is a Ruby on Rails application
--
-- Version 1.1
-- Created 27 June 2012 - written by Alex Weber <alexwebr@gmail.com>
local http = require("http")
local shortport = require("shortport")
local stdnse = require("stdnse")
local strbuf = require("strbuf")
local string = require("string")
local table = require("table")
description = [[ Checks for a Git repository found in a website's document root (/.git/<something>) then retrieves as much repo information as possible, including language/framework, Github username, last commit message, and repository description.
]]
categories = { "safe", "vuln", "default" }
author = "Alex Weber"
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"
function action(host, port)
-- All methods that we call on this table will be from the table library
local out = {}
setmetatable(out, {__index = table})
local repos_found = 0
-- 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
if not string.find(root, "^/") then -- if there is no slash at the beginning
root = "/" .. root
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
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
-- 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",
}
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)
if replies == nil then
stdnse.print_debug("%s: pipeline_go() error. Aborting.", SCRIPT_NAME)
return nil
end
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
local 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
-- 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
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
-- These are some popular / well-known Git hosting services and/or hosting services
-- that allow deployment via 'git push'
local popular_remotes = {
["github.com"] = "Source might be at https://github.com/<reponame>",
["gitorious.com"] = "Source might be at https://gitorious.com/<reponame>",
["bitbucket.org"] = "Source might be at https://bitbucket.org/<reponame>",
["heroku.com"] = "App might be deployed to http://<reponame>.herokuapp.com",
}
for _, url in ipairs(remotes) do
out:insert("Remote: " .. url)
local domain, reponame = string.match(url, "[@/]([%w._-]+)[:/]([%w._-]+/?[%w._-]+)")
local extrainfo = popular_remotes[domain]
-- Try and cut off the '.git' extension
reponame = string.match(reponame, "(.+)%.git") or reponame
if extrainfo then
out:insert(" -> " .. string.gsub(extrainfo, "<reponame>", reponame))
end
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
end
end
-- Replace non-printing characters with asterisks
if #out > 0 then return stdnse.format_output(true, out)
else return nil end
end