An exposed .env file is a database password, a mail credential and an API key in a single GET request. An open directory listing is a map of everything you never linked. A stock welcome page is a free statement of which software you run. None of it breaks the site, which is exactly why it survives. This page documents the 6 checks in the CtrlOps Content Exposure audit, in the order the audit runs them.
Every threshold below is transcribed from the audit script itself. The distinguishing feature of this audit is that it probes rather than guesses: it requests the paths in question over HTTP and grades the status code, then falls back to what is on disk and in the config.
Key takeaways
Six checks, and the grading is built around one distinction: what is reachable right now versus what is one config change away from being reachable.
- One is rated HIGH: hidden file exposure, which is the one that hands over credentials. Four are MEDIUM and one is LOW.
- Two need root: the dotfile check and the backup file check, because both walk document roots that are not world-readable.
- Two checks cannot fail. Status endpoints and default pages warn at most, because both have legitimate configurations that look identical from outside.
- Served beats configured. Checks 1, 2 and 4 all reserve FAIL for the case where the audit actually got the bad response, and drop to WARN when the config allows it but the probe did not reproduce it.
- The whole audit takes about 14 seconds when CtrlOps runs it over your existing SSH connection. It is the longest web server audit, because it makes real HTTP requests.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Directory listing | MEDIUM | Browsable file tree | No |
| 2 | Hidden file exposure | HIGH | .git history or .env downloadable | Yes |
| 3 | Backup files | MEDIUM | Editor and deploy leftovers served as text | Yes |
| 4 | HTTP methods | MEDIUM | TRACE answering, write methods advertised | No |
| 5 | Status endpoints | MEDIUM | Live request data with no access rule | No |
| 6 | Default pages | LOW | Distribution welcome page still live | No |
server_name as the Host header. That is the right way to reach your real vhost from the server itself, and it means a path blocked at a CDN or an external firewall still shows as reachable here. Read a finding on this page as "the origin serves this", which is the layer you control directly.Check 1: Is directory listing enabled anywhere?
With directory listing enabled, a request for a path with no index file returns a browsable index of everything in it. That turns any directory you forgot to link into a discoverable one: old exports, a test/ folder, the PDF a client sent last year.
The audit counts autoindex on in the Nginx config and Options ... Indexes in Apache, excluding -Indexes, then checks the live response body for a rendered listing. The two signals together decide the result.
How to check manually
grep -rn 'autoindex' /etc/nginx/ | grep -v '#'
grep -rn 'Options.*Indexes' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
curl -s http://127.0.0.1/ | grep -iE '<title>Index of|<h1>Index of'| Result | When | What it means |
|---|---|---|
| PASS | Neither autoindex on nor Options Indexes is present | Directory listing is disabled. |
| WARN | The config enables listing but the probe saw no listing | Which server and how many directives are named. An index file is masking it today, and one missing index.html exposes the tree. |
| FAIL | The config enables listing and the server is serving one right now | Visitors can browse the tree and find files you never linked. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
The warning branch exists because Debian ships Options Indexes for /var/www by default, and on most hosts every directory has an index file, so nothing is ever listed. That is a real finding, just not an active one: the protection is an index file rather than a setting.
How to fix it
autoindex off;<Directory /var/www/>
Options -Indexes +FollowSymLinks
</Directory>Then sudo nginx -t && sudo systemctl reload nginx, or sudo apachectl configtest && sudo systemctl reload apache2.
Check 2: Are .git or .env reachable over HTTP?
An exposed .env gives up every credential the application uses. An exposed .git directory gives up the entire source history, including the credentials somebody committed once and removed in the next commit. Automated scanners request both on every host they touch, which is why this is the only HIGH severity check in this audit.
The audit does two things: it looks for .git and .env inside the document roots on disk, and it requests /.git/HEAD and /.env over HTTP and reads the status code. Then it checks whether the config denies dotfile requests at all.
How to check manually
# Is it served
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/.git/HEAD
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/.env
# Is it on disk
sudo ls -la /var/www/html/.git /var/www/html/.env 2>/dev/null
# Does the config deny dotfiles
grep -rnE 'location[[:space:]]*~[^{]*/\\\.' /etc/nginx/ 2>/dev/null | grep -v '#'
grep -rnE 'FilesMatch[[:space:]]*"\^\\\.' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'| Result | When | What it means |
|---|---|---|
| PASS | Neither path is served and neither is in the web root | Two distinct passes. One says the config also denies dotfile requests; the other says nothing was found but suggests adding an explicit deny rule anyway. |
| WARN | The files exist in the web root but the probe did not get them | The paths found on disk are named. One config change exposes them, so move them out of the web root. |
| FAIL | Either path answered 200 over HTTP | The exact path is named. Source history or credentials are downloadable right now. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
Note the scope: the check looks for .git and .env specifically, not every dotfile. Those two cover the overwhelming majority of real incidents, and a broad dotfile sweep produces noise from .well-known and editor metadata that buries the finding that matters.
How to fix it
location ~ /\. {
deny all;
return 404;
}
# ACME validation has to stay reachable
location ^~ /.well-known/ {
allow all;
}<FilesMatch "^\.">
Require all denied
</FilesMatch>
<Directory "/var/www/html/.well-known">
Require all granted
</Directory>The deny rule is the safety net. The actual fix is that .git and .env should not be inside the document root in the first place: deploy from a build artifact, or point the root at a public/ subdirectory so the repository and the environment file sit above it.
/.env is not proof you are safe. The file may be one directory up, reachable at a path the probe did not try, or served by a different vhost. If this check warns because the files exist on disk, move them, rather than relying on the deny rule holding through every future config change.Check 3: Are backup or archive files sitting in the web root?
wp-config.php.bak is your database password served as plain text, because the server has no handler for .bak and falls back to sending the bytes. These files arrive from editors writing swap and backup copies, from deploy scripts taking a safety copy, and from somebody duplicating a config before an edit.
The audit searches the document roots to a depth of three and splits the result in two. Editor and dump leftovers are a failure. Archives are a warning, because a .zip in a downloads directory is frequently deliberate.
How to check manually
sudo find /var/www -maxdepth 3 -type f \
\( -name '*.bak' -o -name '*.old' -o -name '*.save' -o -name '*.sql' -o -name '*~' -o -name '*.orig' \) 2>/dev/null
sudo find /var/www -maxdepth 3 -type f \
\( -name '*.zip' -o -name '*.tar.gz' -o -name '*.tgz' \) 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | No backup, dump or archive files in the document roots | Nothing matched either pattern set. |
| WARN | Only archives were found: zip, tar.gz or tgz | Up to five are named. Fine if they are intentional downloads, otherwise they are a full-source leak. |
| FAIL | Editor or dump leftovers were found: bak, old, save, sql, orig or tilde files | Up to five are named. These are never meant to be served and often contain credentials or full source. |
| SKIP | No document root was identified in the config | Nothing to evaluate. |
The split matters when you read the report. A failure here is unambiguous: nothing generates a .bak on purpose in a served directory. A warning needs your judgement, because a release tarball published for download looks the same to find as a full source archive somebody forgot.
How to fix it
# Review first, then delete
sudo find /var/www -maxdepth 3 -type f \( -name '*.bak' -o -name '*~' -o -name '*.save' \) -print
sudo find /var/www -maxdepth 3 -type f \( -name '*.bak' -o -name '*~' -o -name '*.save' \) -deleteThen add the safety net, so the next one is not served while you wait to notice it:
location ~* \.(bak|old|orig|save|swp|tmp|dist|sql)$ {
deny all;
return 404;
}<FilesMatch "\.(bak|old|orig|save|swp|tmp|dist|sql)$">
Require all denied
</FilesMatch>Check 4: Are dangerous HTTP methods answered?
TRACE echoes the request back to the client, headers included, which is the mechanism behind cross-site tracing: a script that cannot read an HttpOnly cookie directly can sometimes get the server to read it back. PUT and DELETE handled at the web server layer let a client write or remove files outright.
The audit sends a TRACE request and reads the status, sends an OPTIONS request and reads the Allow header, and checks whether Apache has an explicit TraceEnable off.
How to check manually
curl -s -o /dev/null -w '%{http_code}\n' -X TRACE http://127.0.0.1/
curl -sI -X OPTIONS http://127.0.0.1/ | grep -i '^allow:'
grep -rni 'TraceEnable' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'| Result | When | What it means |
|---|---|---|
| PASS | TRACE is not answered and no write methods are advertised | Nothing to change. |
| WARN | The Allow header advertises PUT, DELETE or TRACE, or Apache has no explicit TraceEnable off | Two distinct warnings. The first names the advertised methods. The second is a configuration gap rather than an observed behaviour, and it exists because the Apache default has changed between versions. |
| FAIL | TRACE responded 200 | Cross-Site Tracing can read headers a script should never see. Set TraceEnable off. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
The Allow header is read from an OPTIONS request specifically, because a normal GET does not return one. If your application answers OPTIONS itself, for CORS preflight, what you see here is the application's answer rather than the web server's.
How to fix it
# Global, outside any vhost
TraceEnable off# nginx never implements TRACE, but you can narrow the method set
location / {
limit_except GET HEAD POST {
deny all;
}
proxy_pass http://app;
}If your API genuinely needs PUT, PATCH and DELETE, handle them in the application and leave the web server passing them through. The finding is about methods the web server itself answers, not about your REST routes.
TraceEnable off is a one-line change with no legitimate cost. Nothing in a modern stack uses TRACE, PCI DSS and OWASP have both called for disabling it for years, and the Apache default has been different enough across versions that stating it explicitly is worth the line.Check 5: Are status endpoints reachable without a restriction?
Apache's /server-status shows every request currently being processed, complete with URLs and query strings. /server-info dumps the effective configuration. Nginx's stub_status is tamer but still reports connection counts. All three are useful to you and to anyone doing reconnaissance.
The audit requests /server-status, /server-info, /nginx_status and /status, then checks whether the config contains any access restriction: a Require local, a Require ip, an allow 127.0.0.1 or a deny all.
How to check manually
for p in /server-status /server-info /nginx_status /status; do
printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1$p)"
done
grep -rniE 'Require[[:space:]]+(local|ip)|allow[[:space:]]+127\.0\.0\.1|deny[[:space:]]+all' \
/etc/nginx/ /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'| Result | When | What it means |
|---|---|---|
| PASS | Endpoints answered but the config restricts them, a status handler is configured with restrictions, or no status handler exists at all | Three distinct passes. The first is the important one: a 200 from localhost is what Require local is supposed to do, so it is evidence the handler exists rather than that it is exposed. |
| WARN | Endpoints answered with no restriction in the config, or a status handler is configured with no visible restriction | Two distinct warnings, and the endpoints found are named. Scope them to localhost. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
This check has no failure branch, and the reason is the probe's position. It runs on the server, so it is always the local client that Require local is designed to permit. A 200 here proves the endpoint exists; only the absence of an access rule tells you anyone else could reach it.
How to fix it
<Location "/server-status">
SetHandler server-status
Require ip 127.0.0.1 ::1
# Require ip 10.0.0.0/8
</Location>location = /nginx_status {
stub_status;
allow 127.0.0.1;
allow ::1;
deny all;
}Keep the endpoint. Prometheus, Netdata and every uptime tool you might add later will want it. Scope it to loopback and your monitoring subnet, and confirm from outside the host that it is refused.
curl -I https://yourdomain.com/server-status from your laptop: a 403 or 404 is what you want to see.Check 6: Is the stock welcome page still being served?
"Welcome to nginx!" and the Apache2 Ubuntu Default Page tell a scanner which server and which distribution family this host runs, before anything else is tried. On their own they are low value. Combined with a leaked version number they narrow a CVE search to a single result.
The more practical problem is what a live default page means: an unmatched hostname is landing on the distribution vhost, which is usually a sign that a site's server block is not matching the way its author expected.
The audit looks for the stock page text in the live response body, then falls back to checking whether Apache's 000-default.conf is still enabled.
How to check manually
curl -s http://127.0.0.1/ | grep -iE 'Welcome to nginx|Apache2 (Ubuntu|Debian) Default Page|It works!|Test Page for the'
ls -la /etc/apache2/sites-enabled/000-default.conf 2>/dev/null
ls -la /etc/nginx/sites-enabled/default 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | No stock welcome page in the response and no distribution default vhost enabled | Nothing to change. |
| WARN | The stock page is being served, or the default vhost is still enabled | Two distinct warnings. The first means an unmatched request is landing on it right now. The second is the latent version: the vhost is enabled but something else is answering. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
How to fix it
sudo a2dissite 000-default
sudo systemctl reload apache2
# nginx
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginxBetter than removing it, replace it with a catch-all that refuses unmatched hostnames:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}That closes the connection without a response, which also stops your server answering for domains somebody has pointed at your IP without asking.
What this audit does not cover
This audit reads what the server exposes over HTTP and what sits in the document roots. It does not check:
- HTTP response headers. HSTS, CSP, frame protection and cookie flags are the Security Headers audit.
- TLS. Protocol versions, ciphers, certificate expiry and the HTTPS redirect are the TLS Configuration audit.
- Permissions and limits. Config file modes, web root ownership, request caps and rate limiting are the Configuration Hardening audit.
- Version disclosure. The
Serverheader and pending patches are the Identification and Patching audit. - Every dotfile and every path. The probe checks
.gitand.envat the document root, four status endpoints and the root page. It is a sweep for the paths that are actually attacked, not a crawl of your site. - What a CDN blocks. The probe runs on the server, so anything your edge refuses still shows as reachable here.
The manual audit problem at scale
Doing this by hand means grepping config directories, running a find across every web root, curling half a dozen paths, and remembering that TRACE needs -X TRACE and Allow only comes back on OPTIONS. On one server it is fifteen to twenty minutes. On ten it is most of a morning.
The part that goes wrong is the vhost again. Curling 127.0.0.1 lands on the default server block, so a manual check of /.env can return 404 from a vhost that is not the one hosting the application, while the real site serves it happily.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 6 checks | About 2 hours | About 2 minutes |
| Path probing | Curl each path per host | Probed with the right Host header automatically |
| Web root discovery | Grep for root and DocumentRoot | Extracted from the effective config |
| Get a scored report | Not available | Automatic |
| Re-audit after fixing | Another 2 hours | Another 2 minutes |
How CtrlOps runs all 6 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Content Exposure audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.
Watch it run
The recording below walks through the SSH and Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select Content Exposure
Choose it from the Web Server category. All 6 checks are listed with their descriptions, severity levels and the estimated run time, about 14 seconds in total. Two of them need root, and the UI says so before you run anything.
Step 3: Run it
The audit runs over your existing SSH session. Results stream in as they complete. This audit makes real HTTP requests to loopback, which is why it takes a few seconds longer than the others in the category.
Step 4: Read the report
You get a summary containing:
- A hardening score out of 100
- A severity breakdown: how many HIGH, MEDIUM and LOW findings
- Pass, warning, failed and skipped counts
- A findings table naming the exact paths, files and endpoints it found
Step 5: Fix what failed
Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters here because the fix for check 3 is a find -delete across your web root, and that is a command worth reading twice.
Step 6: Re-run and compare
Run the same audit again after reloading and watch the score move. This is the audit to run after every deployment: a .bak file, a left-behind archive and a newly enabled default vhost all arrive through ordinary release work rather than through mistakes.
Conclusion
Every finding on this page is something the server is willing to hand out to anyone who asks for it. None of them is an exploit on its own. Together they are the reconnaissance phase of somebody else's attack, done for them and cached.
One check here is HIGH severity, and it is the one worth running today: a reachable .git or .env is not a step towards compromise, it is the credentials. The CtrlOps Security Audit runs all 6 in about 14 seconds per server, probing the real vhost rather than the default one.
The neighbouring checklists cover the other web server layers: security headers, TLS configuration, configuration hardening, identification and patching and logging and monitoring.
Frequently asked questions
It is any case where the server volunteers internal detail to an unauthenticated visitor: a browsable directory tree, a downloadable .env or .git, a backup file served as text, a live status page, or a default page naming the software. None of it is an exploit. All of it shortens the work of finding one.
On Nginx, set autoindex off; in the relevant block, then sudo nginx -t && sudo systemctl reload nginx. On Apache, use Options -Indexes in the <Directory> block, then sudo apachectl configtest && sudo systemctl reload apache2. Verify by requesting a directory that has no index file: it should return 403 rather than a listing.
Request it: curl -s -o /dev/null -w '%{http_code}' https://yourdomain.com/.env. A 200 means it is downloadable right now. Also check whether it exists at all under the document root with sudo ls -la /var/www/html/.env, because a file that is present but currently blocked is one config change from being served.
Because those two are what automated scanners request on every host, and they are the two whose contents are immediately useful: full source history and a complete credential set. A broad sweep for every dotfile produces noise from .well-known and editor metadata, and the finding that matters gets lost in it.
TRACE reflects the request back to the client, including headers. Combined with a scripting flaw, that historically let an attacker read headers the browser would not expose to script, including cookies marked HttpOnly. Nothing in a modern application uses it, so TraceEnable off on Apache is free. Nginx never implemented TRACE at all.
Yes, to a depth of three inside every document root it found in the config. It splits the result: editor and dump leftovers such as .bak, .old, .save, .sql, .orig and tilde files are a failure, while .zip, .tar.gz and .tgz archives are a warning, because those are routinely published on purpose from a downloads directory.
Because the probe runs on the server itself, and a properly configured Require local is supposed to answer a local client with a 200. Response code alone cannot distinguish a correctly scoped endpoint from an open one, so the audit warns only when it finds no access restriction in the config at all. Verify from outside the host to be certain.
Worker state, uptime, request rates, and the full URL of every request currently in flight, including query strings. That last part is the problem: admin paths, API endpoints and any token that travels in a URL are all visible to whoever loads the page. Restrict it to loopback and your monitoring subnet rather than removing it.
Two reasons. It confirms the software and often the distribution to a scanner, which narrows a CVE search. More practically, a live default page means an unmatched hostname is landing on the distribution vhost, which usually indicates that a real site's server block is not matching the way its author intended.
On hosts with no web server, and on containerised deployments where the config is baked into an image: scan the image in CI instead, because fixing the running container does not change the next deploy. It is also origin-only, so anything your CDN blocks still appears reachable here.
After every deployment, and monthly regardless. Checks 3 and 6 track things that arrive through ordinary release work: an editor swap file, a forgotten archive, a default vhost re-enabled by a package upgrade. Check 2 is worth running on a schedule rather than after changes, because an exposed .env is the one finding where hours matter.