“Git Repository Served by Web Server” means a scanner downloaded files from your site’s .git directory, which can let anyone rebuild your source code and commit history, including secrets committed in the past. Fix the exposed .git directory by deploying without it, denying dot-directories in nginx, Apache or IIS, rotating leaked credentials, and confirming /.git/HEAD returns 403 or 404.
What the scanner is actually detecting
Every tool below reports the same root cause: Git metadata sits inside the document root and the web server returns it like any other static file.
| Scanner | Finding title | ID | Published rating |
|---|---|---|---|
| Nessus | Git Repository Served by Web Server | Plugin 65702 | Medium (CVSS v3 5.3), family CGI abuses |
| Tenable Web App Scanning | Git Repository Detected | Plugin 112531 | Medium, family Data Exposure, CWE-538 |
| Acunetix | GIT Detected exposed | Not published as a numeric ID | High (CVSS 3.1 7.5), CWE-527 |
| ZAP | Source Code Disclosure – Git | Alert 41 (active, beta rules) | High, CWE-541 |
Tenable does not document which files plugin 65702 requests, and its published solution is to verify the listed repositories are served intentionally. Treat everything under the reported .git path as in scope.
ZAP’s rule is open source and stricter. For each page it scanned, it requests .git/index in that page’s directory and then in each parent directory, parses the index, fetches the Git object for the page’s own file, and alerts only when that stored content differs from the live response and looks like source code. A ZAP hit therefore means it recovered real source, such as raw PHP, but ZAP stays silent when no crawled page is tracked in the repository.
Real-world risk
Turning off directory listing does not help. Git stores content under predictable names: .git/HEAD points to a branch, the branch file holds a commit hash, and each object lives at a path derived from its hash. An attacker can walk from HEAD through commits and trees and download objects one at a time, and open-source tools automate this. If the repository also holds the files Git’s dumb HTTP transport uses (info/refs and objects/info/packs, generated by git update-server-info), a plain git clone of the URL works.
What leaks depends on the repository:
- Server-side source code, which makes further vulnerability hunting much easier.
- The full history, including configuration files and credentials that were deleted in later commits.
- The remote URL in .git/config, which sometimes embeds a username and access token for the code host.
- Developer names and email addresses from commit metadata, plus internal hostnames and paths.
The finding does not give code execution on its own. For a static site whose repository is already public, the impact is small; for a private application repository with secrets anywhere in its history, treat it as a credential exposure. Public web roots are exactly where this belongs in your external attack surface management scope.
How to confirm it on the host
Reproduce the request from outside. An exposed repository returns 200 for HEAD with a body like ref: refs/heads/main:
curl -s -o /dev/null -w '%{http_code}n' https://www.example.com/.git/HEAD
curl -s https://www.example.com/.git/HEAD
A 403 on /.git/ itself only means listing is off; the files inside can still be served. Then find every version control directory under your web roots:
sudo find /var/www /srv -type d ( -name .git -o -name .svn -o -name .hg ) -prune 2>/dev/null
On Windows, Git for Windows hides the .git folder by default, so add -Force:
Get-ChildItem -Path C:inetpub -Recurse -Force -Directory -Include .git,.svn,.hg -ErrorAction SilentlyContinue | Select-Object FullName
Check whether anyone downloaded it. With the default combined log format, field 9 is the status code:
sudo zgrep -h '/.git/' /var/log/nginx/access.log* | awk '$9 == 200' | awk '{print $1}' | sort | uniq -c | sort -rn | head
Use /var/log/apache2/ or /var/log/httpd/ for Apache.
How to fix it
Do all three steps. A web server rule alone leaves the repository one config mistake away from exposure, and neither removing nor blocking it un-leaks what was already downloaded.
1. Get the repository out of the document root
The durable fix is to deploy files, not a working copy:
# Export a commit as plain files (no .git inside)
git -C /srv/src/example archive --format=tar HEAD | sudo tar -x -C /var/www/example
# Or sync a checkout while excluding VCS metadata
sudo rsync -a --delete --exclude='.git' --exclude='.svn' --exclude='.hg' /srv/src/example/ /var/www/example/
Neither command removes a .git that is already in the target, and rsync does not delete excluded paths on the receiving side unless you add –delete-excluded. Move the existing one out of the web root and keep it until you are sure nothing needs it:
sudo mv /var/www/example/.git /root/example.git-removed
If your deployment runs git pull in the web root, switch to one of these patterns, or keep the repository elsewhere and use Git’s –git-dir and –work-tree options.
2a. nginx: deny dot-directories
Add this inside each server block:
location ~ /.(?!well-known) {
deny all;
}
This returns 403 for any path segment that starts with a dot, except .well-known (ACME challenges, security.txt), so it also covers .svn, .hg and .env. Order matters: per the nginx location documentation, regex locations are checked in the order they appear and the first match wins, and a longest-prefix location with ^~ skips regex checks entirely. Put the block above other regex locations and repeat it inside any ^~ location. return 404; works in place of deny all;, and location ~ /.(git|svn|hg)(/|$) is a narrower match if an application needs other dot-paths. Apply:
sudo nginx -t && sudo systemctl reload nginx
2b. Apache: vhost or .htaccess
In the virtual host (DirectoryMatch is valid only in server config and virtual host context):
<VirtualHost *:443>
ServerName example.com
DocumentRoot /var/www/example
<DirectoryMatch "/.(git|svn|hg)(/|$)">
Require all denied
</DirectoryMatch>
</VirtualHost>
Since Apache 2.3.9, DirectoryMatch affects only directories whose path matches, and this pattern matches /.git/ anywhere in the path, so subdirectories such as .git/objects are covered too. On shared hosting, use mod_alias in the web root’s .htaccess, which needs AllowOverride FileInfo (or All):
RedirectMatch 404 "/.(git|svn|hg)(/|$)"
RedirectMatch matches the URL path, and with a non-3xx status the target URL is omitted. Test and reload:
sudo apachectl configtest && sudo systemctl reload apache2 # httpd on RHEL-family
2c. IIS: request filtering hidden segments
Hidden segments block any URL containing that path segment and return 404, logged as substatus 404.8:
%windir%system32inetsrvappcmd.exe set config "Default Web Site" -section:system.webServer/security/requestFiltering /+"hiddenSegments.[segment='.git']"
Repeat for .svn and .hg, or add the equivalent to the site’s web.config:
<configuration>
<system.webServer>
<security>
<requestFiltering>
<hiddenSegments>
<add segment=".git" />
<add segment=".svn" />
<add segment=".hg" />
</hiddenSegments>
</requestFiltering>
</security>
</system.webServer>
</configuration>
3. Rotate anything the repository revealed
- In any clone of the same repository, list every path ever committed, not just what is in the current tree: git log –all –format= –name-only | sort -u
- Look for configuration files, key files and anything named like a secret, then rotate those credentials at their source: database users, SMTP accounts, API keys, signing secrets.
- Check the remote URL in the copy that was on the server: sudo grep -n ‘url =’ /root/example.git-removed/config. If it embeds a username and token, revoke the token at the code host.
- Rewriting history (for example with git filter-repo) does not help here: the attacker already has the old objects.
How to verify the fix and rescan
for p in /.git/HEAD /.git/config /.git/index /app/.git/HEAD; do
printf '%s ' "$p"; curl -s -o /dev/null -w '%{http_code}n' "https://www.example.com$p"
done
Every line should show 403 or 404, and the site should still load. If you use HTTP-01 certificates, run sudo certbot renew –dry-run to confirm .well-known still works. If a CDN sits in front, purge its cache for /.git/*, because a cached copy can keep answering with 200. Then rerun the same scan policy against the same hostname. For ZAP, spider first so the active rule has pages to test.
What can break and how to roll back
- Pull-based deployments. A cron job or hook that runs git pull in the web root fails once .git is gone. Switch to an export or work-tree deploy.
- Apps that read their own Git metadata. Code that shows the current commit by reading .git/HEAD from disk is unaffected by the web server rules, but breaks when the folder moves. Write the version into a file at build time instead.
- The nginx dotfile rule. Any application route that deliberately serves a path starting with a dot, other than .well-known, gets 403.
- Credential rotation. Workers, cron jobs and other servers sharing the same credentials fail until updated.
Back up web server configs before editing and roll back by restoring the file and reloading. If something critical depended on the repository, move it back with sudo mv /root/example.git-removed /var/www/example/.git while you fix the deployment. Never roll back credential rotation.
Common false positive reasons
- Catch-all routes. A single-page app or framework router answers /.git/HEAD with 200 and its HTML shell. A real HEAD file is one line, either ref: refs/heads/… or a bare commit hash.
- Different target. The scanner hit the origin IP or a default virtual host rather than the hostname you fixed, or the reverse.
- Stale CDN cache. The origin is fixed but an edge node still serves an old copy.
- Intentional repositories. A host that publishes repositories over HTTP by design is technically a true positive; record it as an accepted exception rather than disabling the check.
FAQ
Directory listing is off and /.git/ returns 403. Am I safe?
No. Scanners and attackers request known file names such as .git/HEAD and .git/index directly, which works without a listing.
Is blocking at the web server enough?
It closes the finding, but removing the repository from the document root is the real fix; the block is a safety net.
Do I have to rotate secrets if the logs show no 200 responses?
Only skip it if your logs cover the entire exposure window, including any CDN. Missing or rotated logs are not proof, so rotate.
Tracking this finding across many hosts
If you track this finding in SITEY, a self-hosted vulnerability management platform, results from its 16 scanner integrations land in one list, but duplicates are merged per scanner rather than across scanners, so the same exposure reported by two different tools stays as two entries. Nessus results come in as an exported .nessus file that you upload, and per-finding retest is available for Nessus, Acunetix and Burp results; for other scanners, rerun the scan. AI triage can suggest likely false positives, such as a catch-all route returning HTML, with supporting evidence, and a human makes the final call.