What this check reads
The check runs a single ls -d against five fixed globs: /var/www/.git, /var/www/*/.git, /var/www/*/*/.git, /srv/www/*/.git and /usr/share/nginx/html/.git. Nothing inside a repository is opened, and no HTTP request is made; the presence of the directory on disk is the whole test. Whatever the globs expand to is truncated to the first three entries (head -3) and joined onto one line, so an empty result is the PASS and any entry at all is a WARN that names the paths it kept.
When it applies
Always runs - a plain ls -d on the disk, no root and no database needed. It looks only at these fixed globs: /var/www/.git, /var/www/*/.git, /var/www/*/*/.git, /srv/www/*/.git, /usr/share/nginx/html/.git, and reports at most three. Deeper checkouts and other roots are not matched. A .git file (the gitdir pointer left by worktrees and submodules) is matched, because ls -d applies no -type d filter, so one sitting at any of those five positions is reported exactly like a directory. This check looks at the disk only; whether the server actually serves /.git/ is the separate Hidden File Exposure web check - which is why the status here is WARN rather than FAIL.
What each result means
| Result | When | What it means |
|---|---|---|
| WARN | Any `.git` directory found under the common roots | A repository checkout is sitting in a served tree, and the message lists up to three of the paths found. |
| PASS | None | None of the five globs matched, so a checkout deeper in the tree or under a different web root would not have been seen. |
Why it matters
A .git directory inside a served tree lets anyone download /.git/HEAD, /.git/config and, with tools like git-dumper, reconstruct the complete repository including history and any credentials ever committed. Even with directory listing off, the object layout is predictable. Research on the Alexa top 1M found thousands of exposed repositories. OWASP WSTG-INFO-03 "Review Webserver Metafiles"; OWASP Top 10 A05:2021.
Why it fails, and when it is wrong
- "Deploy by
git pullin the document root" is the common cause. The web-server check Hidden File Exposure tests whether the server actually serves/.git/HEAD; this check only looks at the disk, which is why it is WARN. - Depth is limited to three levels under
/var/wwwand one under/srv/www. Deeper or non-standard roots are not checked. - A
.gitfile (gitdir pointer used by worktrees/submodules) is still listed:ls -dreports whatever the globs expand to, with no-type dfilter. Only one sitting outside the five globs is missed, and it is just as dangerous if served.
How to fix it
Find the directories
Reproduce the check to get the paths it kept, and widen the search if your document root is not one of the five globs it knows:
ls -d /var/www/.git /var/www/*/.git /var/www/*/*/.git /srv/www/*/.git /usr/share/nginx/html/.git 2>/dev/null
find / -xdev -maxdepth 6 -name .git -print 2>/dev/nullTest whether it is actually reachable
The check looks at the disk only, so ask the web server the question it cannot:
curl -sS -o /dev/null -w '%{http_code}\n' https://your-host/.git/HEADA 200 means the repository is downloadable with a tool like git-dumper, history included. A 403 or 404 means only the server config stands between you and that. If credentials were ever committed, rotate them either way: removing the file does not remove it from history, and anything exposed was fetchable for as long as it was served.
Block it at the web server
Block dotfiles as defence in depth, then reload the server:
location ~ /\.(?!well-known) { deny all; return 404; }RedirectMatch 404 /\.(?!well-known)
# or
<DirectoryMatch "^\.|\/\.">
Require all denied
</DirectoryMatch>Deploy so it cannot happen again
A deny rule is one config change away from being gone, so stop the checkout reaching the served tree at all. Deploy with a build step that copies only the artifacts (git archive, rsync --exclude .git, a CI deploy), or keep the checkout outside the root and symlink or point root at public/.
Verify the fix
# On disk - reproduce the check:
ls -d /var/www/.git /var/www/*/.git /var/www/*/*/.git /srv/www/*/.git /usr/share/nginx/html/.git 2>/dev/null
# expected: no output
# Over HTTP - the exposure that actually matters:
curl -sS -o /dev/null -w '%{http_code}\n' https://your-host/.git/HEAD
curl -sS -o /dev/null -w '%{http_code}\n' https://your-host/.git/config
# expected: 403 or 404 for both (200 means the repository is downloadable)Debugging
The repository is on disk but not reachable over HTTP, so the immediate risk is low. Keep the deny rule and move the checkout out of the served tree when convenient; a future handler or vhost change can expose it.
The checkout is outside the globs the check knows (deeper than two levels under /var/www, or a different root entirely). Find it with find / -xdev -maxdepth 6 -name .git -print 2>/dev/null and compare against your root/DocumentRoot values.
Worktrees and submodules leave a .git file containing gitdir: …. ls -d on these globs still lists it, but a deeper or differently-placed one is missed; it is just as dangerous if served. Search with find <webroot> -name .git (no -type d).
No. Anything ever committed (passwords, keys) was downloadable for as long as it was exposed. Rotate those credentials; deleting the directory does not undo the disclosure.
Sources
- OWASP WSTG-INFO-03 Review Webserver Metafiles for Information Leakage
- Internetwache: "Don't publicly expose .git" (analysis of Alexa 1M)
- git-dumper (demonstrates the attack)
- Git: git-archive for deployment
- nginx: location regex and deny
- Apache: RedirectMatch (mod_alias)
- GitHub: Removing sensitive data from a repository
How the script reads this
Next
Re-run the Credential Storage audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 4 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 4 Credential Storage fixes