summaryrefslogtreecommitdiffstats
path: root/scripts/http-git.nse
blob: 26bb22a6eb80d3697fc9adad865e0fb0a579e92c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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/<something>) 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
-- <table key="127.0.0.1:80/.git/">
--   <table key="remotes">
--     <elem>http://github.com/anotherperson/anotherepo</elem>
--   </table>
--   <table key="project-type">
--     <table key=".git/info/exclude">
--       <elem>JBoss Java web application</elem>
--       <elem>Java application</elem>
--     </table>
--   </table>
--   <elem key="repository-description">A nice repository</elem>
--   <table key="files-found">
--     <elem key=".git/COMMIT_EDITMSG">false</elem>
--     <elem key=".git/info/exclude">true</elem>
--     <elem key=".git/config">true</elem>
--     <elem key=".git/description">true</elem>
--     <elem key=".gitignore">false</elem>
--   </table>
--   <table key="interesting-matches">
--     <table key=".git/config">
--       <elem>passw</elem>
--     </table>
--   </table>
-- </table>

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, "<unknown>")
    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