A CVE drops for Nginx, and the first twenty minutes go to working out which servers run the affected version. Meanwhile one host has an Apache nobody remembers installing, still listening, still unpatched. This page documents the 5 checks in the CtrlOps Identification and Patching audit, in the order the audit runs them.
Every threshold below is transcribed from the audit script itself. The audit answers two questions at once: what is actually running here, and how much of that can somebody else find out from outside.
Key takeaways
Five checks, and the two HIGH ones are the two that decide how bad an application bug gets to be.
- Two are rated HIGH: the worker process user and pending updates. One is MEDIUM, two are LOW.
- Only the config syntax test needs root, because
nginx -tandapachectl configtestread the full config tree. Everything else reads the process table, an HTTP response and package metadata. - Only the update check can fail on a patching basis, and only on apt. Debian and Ubuntu expose whether an update comes from a security pocket; on RHEL family, SUSE and Alpine every pending update is reported as a warning regardless of what it fixes.
- Version disclosure is LOW on purpose. Hiding the version slows automated scanning and protects nothing by itself. It is a filter, not a fix, and the fix is check 4.
- The whole audit takes about 9 seconds when CtrlOps runs it over your existing SSH connection. It is the fastest audit in the web server category.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Web server inventory | LOW | Engines, versions and running state | No |
| 2 | Version disclosure | LOW | A version number in the Server header | No |
| 3 | Worker process user | HIGH | Request handlers running as root | No |
| 4 | Web server updates | HIGH | Pending web server and OpenSSL patches | No |
| 5 | Config syntax | MEDIUM | A config that will fail the next reload | Yes |
apt update writes to disk. It reads the package lists already on the host, so a pass means "nothing pending according to metadata that may be a week old". Run sudo apt update or sudo dnf makecache yourself before trusting it.Check 1: What web servers are installed and running?
You cannot patch what you do not know is installed, and the server that causes trouble is usually the one nobody remembers. A host that has been through two migrations often has both Nginx and Apache present, one serving traffic and one idle, still bound to a port and still carrying whatever vulnerabilities its last version had.
The audit detects Nginx, Apache, Caddy and Lighttpd by binary and by running process, reads the version from each, and reports whether it is running or stopped. It also names the URL it managed to probe, which tells you what the rest of the audit was able to see.
How to check manually
nginx -v 2>&1; pgrep -x nginx >/dev/null && echo "nginx running"
apache2 -v 2>/dev/null || httpd -v 2>/dev/null
pgrep -x apache2 >/dev/null || pgrep -x httpd >/dev/null && echo "apache running"
ss -tlnp | grep -E ':80|:443|:8080|:8443'| Result | When | What it means |
|---|---|---|
| PASS | At least one web server was detected | Informational. Each engine is named with its version and whether it is running or stopped, plus the URL the audit probed. |
| SKIP | No web server detected on the host | Nothing to audit here. Every other check in this audit skips too. |
This check never warns or fails. It exists so the rest of the report can be read against a known stack, and so a second engine that nobody expected shows up in writing.
How to fix it
Nothing to fix. If the inventory names an engine you did not expect, decide whether it should be there:
sudo systemctl status apache2
sudo ss -tlnp | grep apache2
# If genuinely unused
sudo systemctl disable --now apache2
sudo apt purge apache2Check 2: Does the Server header leak the exact version?
By default both servers announce themselves in every response: Server: nginx/1.25.4 or Server: Apache/2.4.58 (Ubuntu). That is a CVE lookup handed to a scanner for free. Suppressing it does not make the server safer, it removes the shortcut that mass scanning depends on.
The audit reads the Server header from the live response and looks for a version-shaped string. Without a live response, it falls back to checking whether server_tokens off or ServerTokens Prod is configured.
How to check manually
curl -sI https://yourdomain.com | grep -i '^server:'
grep -rn 'server_tokens' /etc/nginx/ | grep -v '#'
grep -rni 'ServerTokens' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'| Result | When | What it means |
|---|---|---|
| PASS | The Server header carries no version, or suppression is configured | Two distinct passes. The live one quotes the header it saw; the config-only one names the directive it found. |
| WARN | The header contains a version number, or no suppression is configured and there was no live response to confirm | Two distinct warnings. The first quotes the leaked header. The second is a configuration gap the audit could not verify either way. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
There is no failure branch here, and that is deliberate. A visible version is an information leak with a real but modest effect, and treating it as a failure would put it alongside findings that are genuinely dangerous.
How to fix it
# In the http block
server_tokens off;ServerTokens Prod
ServerSignature OffServerTokens Prod reduces the header to Apache. ServerSignature Off removes the version from generated error pages, which is the other place it commonly appears. On Nginx, removing the product name entirely needs a third-party module or a patched build, and is rarely worth it.
Check 3: Do worker processes run as root?
Both servers start as root so they can bind ports 80 and 443, then drop to an unprivileged user for the processes that handle requests. If that drop does not happen, every request is processed with full system privileges, and an application-level bug becomes a root shell rather than a contained problem.
The audit reads the process table twice: Nginx workers by command line, and Apache by executable name specifically. That second detail exists because matching Apache on the command line also matched things like tail -f /var/log/apache2/access.log, and an unprivileged user in that list was enough to mask an Apache that was genuinely running entirely as root.
How to check manually
ps -eo user=,args= | grep '[n]ginx: worker' | awk '{print $1}' | sort -u
ps -eo user=,comm= | awk '$2=="apache2" || $2=="httpd" {print $1}' | sort -u
grep -rn '^user ' /etc/nginx/nginx.conf
grep -rn 'APACHE_RUN_USER' /etc/apache2/envvars 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | Workers are running as a non-root user, or the server is stopped and the config names an unprivileged user | Two distinct passes. The live one names the accounts it found. The stopped one names the configured user and says the server is not running. |
| FAIL | Nginx workers run as root, or Apache is running with no non-root process at all | The offending server is named. A request-handling bug becomes host root. |
| SKIP | No web server detected, or the server is not running and no unprivileged user is configured | Two distinct skips. The second asks you to start the server or check the user directive by hand. |
How to fix it
# First line of /etc/nginx/nginx.conf
user www-data; # or `nginx` on RHEL family# Debian and Ubuntu: /etc/apache2/envvars
export APACHE_RUN_USER=www-data
export APACHE_RUN_GROUP=www-data
# RHEL family: /etc/httpd/conf/httpd.conf
# User apache
# Group apacheA user change needs a full restart rather than a reload, because the master process sets it at startup:
sudo systemctl restart nginx
ps -eo user=,args= | grep '[n]ginx: worker'Check 4: Are web server or OpenSSL updates pending?
This is software that answers unauthenticated requests from the internet by design. A published vulnerability in Nginx, Apache or OpenSSL is being scanned for within hours, and the window between disclosure and mass exploitation is routinely shorter than a maintenance cycle.
The audit queries whichever package manager the host has, apt, dnf, yum, zypper or apk, for pending updates matching nginx, apache2, httpd, openssl and libssl. On apt it also reads whether those updates come from a security pocket.
How to check manually
# Debian and Ubuntu
sudo apt update
apt-get -s upgrade | grep '^Inst ' | grep -iE 'nginx|apache2|httpd|openssl|libssl'
# RHEL family
sudo dnf check-update | grep -iE 'nginx|httpd|openssl'| Result | When | What it means |
|---|---|---|
| PASS | No pending web server or OpenSSL updates | Reported per package manager, according to the metadata already on the host. |
| FAIL | Pending updates are marked as security updates | Only reachable on apt-based hosts, where the source is visible. The count is named. This software faces the internet directly. |
| WARN | Pending updates that are not identified as security updates | The count is named. On RHEL family, SUSE and Alpine every pending update lands here, because those queries do not separate security updates. |
| SKIP | No web server detected, or no supported package manager found | Nothing to evaluate. |
Two things to carry away. A warning on a Rocky or Alma host is not evidence that nothing security-related is waiting: run dnf updateinfo list security to find out. And openssl and libssl are included deliberately, because a TLS library flaw reaches you through the web server whether or not the web server itself was updated.
How to fix it
sudo apt update && sudo apt upgrade nginx openssl -y
sudo systemctl restart nginx
# RHEL family
sudo dnf update nginx openssl -y
sudo systemctl restart httpdRestart rather than reload after an OpenSSL update. A reload keeps the existing master process, and the old library stays mapped in memory until the process is replaced.
Check 5: Does the config pass its own syntax test?
A broken config does not stop a running server. The process keeps the configuration it loaded at startup and carries on serving, so everything looks fine. The failure arrives on the next reload, which is usually a certificate renewal at 3am or a reboot after a kernel update, and the site is down at the exact moment nobody is watching.
The audit runs nginx -t and apachectl configtest as root and reads the exit status.
How to check manually
sudo nginx -t
sudo apachectl configtest # or: sudo httpd -t| Result | When | What it means |
|---|---|---|
| PASS | The config test passes for every installed server | A reload will apply cleanly. |
| FAIL | A config test fails | The failing server is named. The running config and the config on disk have diverged, and the next reload will fail. |
| SKIP | No web server detected, or the audit account has no root | Two distinct skips. The second is the common one: the config test reads the whole tree, including files that are not world-readable. |
How to fix it
The test output names the file and line:
sudo nginx -t
# nginx: [emerg] unknown directive "proxy_passs" in /etc/nginx/sites-enabled/app.conf:12
sudo nano /etc/nginx/sites-enabled/app.conf
sudo nginx -t && sudo systemctl reload nginxThen stop it happening again by gating the reload on the test:
# In your deploy script
nginx -t || { echo "config test failed, not reloading"; exit 1; }
systemctl reload nginxnginx -t && systemctl reload nginx should be a single habit rather than two commands. The && is the whole fix: it makes a broken config a failed deployment instead of a scheduled outage.What this audit does not cover
This audit reads the process table, package metadata, one HTTP response and the config test result. 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.
- What the server exposes. Directory listing, dotfiles, backups and status endpoints are the Content Exposure audit.
- The rest of the host's patch state. Kernel, runtimes and every other package are the System Updates audit.
- Servers built from source. The update check reads package metadata, so an Nginx compiled by hand reports nothing pending no matter how old it is.
The manual audit problem at scale
Five checks on one host is ten to fifteen minutes: inventory the binaries, curl the header, read the process table, query the package manager, run the config test. Across ten servers with a mix of Debian and RHEL layouts, that is over two hours, and the part that goes wrong is the inventory.
The Apache nobody remembers installing is exactly the one a manual sweep misses, because you check what you expect to find. That is also the one still running an unpatched version.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 5 checks | About 90 minutes | About 70 seconds |
| Second engine detection | Whatever you remember to look for | Nginx, Apache, Caddy and Lighttpd every time |
| Package manager handling | Remember the right command per distro | Auto-detects apt, dnf, yum, zypper and apk |
| Get a scored report | Not available | Automatic |
| Re-audit after patching | Another 90 minutes | Another 70 seconds |
How CtrlOps runs all 5 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Identification and Patching 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 Identification & Patching
Choose it from the Web Server category. All 5 checks are listed with their descriptions, severity levels and the estimated run time, about 9 seconds in total. Only the config syntax check needs root.
Step 3: Run it
The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.
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 engines, versions, worker accounts and pending package counts
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 worker user fix needs a restart rather than a reload, and a restart drops every connection the server is currently holding.
Step 6: Re-run and compare
Run the same audit again after patching and watch the score move. Check 4 is the one to run weekly rather than after changes, because it goes from pass to fail with nothing on the server changing at all.
Conclusion
Two of these five describe how much damage an application bug gets to do, and both of them are usually correct on a well-built server and quietly wrong on one that has been through a migration. The other three are about knowing what you have before you need to know it in a hurry.
The inventory is the one to read even when everything passes, because the engine you did not expect is the one that will still be unpatched when the next advisory lands. The CtrlOps Security Audit runs all 5 in about 9 seconds per server.
The neighbouring checklists cover the other web server layers: security headers, TLS configuration, content exposure, configuration hardening and logging and monitoring.
Frequently asked questions
Send a HEAD request and read the header: curl -sI https://example.com | grep -i server. If it contains something like nginx/1.25.4 or Apache/2.4.58 (Ubuntu), version disclosure is on. Error pages are the other common leak, so also request a path that does not exist and look at the generated page.
Because the worker is the process that handles untrusted input. Running as www-data, a successful exploit is confined to what that account can reach. Running as root, the same exploit reads /etc/shadow, writes system files and installs packages. The master process needs root to bind ports 80 and 443; the workers never do.
Weekly at minimum, and immediately after any advisory for Nginx, Apache or OpenSSL. Subscribe to your distribution's security list so you hear about it rather than discovering it in a scan. Enable unattended security updates so patching does not depend on someone remembering, then verify the service actually restarted onto the new version.
Because apt exposes whether an update comes from a security pocket and the RHEL family query used here does not. On apt hosts the audit separates security updates, which fail, from ordinary ones, which warn. Elsewhere everything warns. A warning on Rocky is not proof nothing security-related is pending: check dnf updateinfo list security.
Marginally, and only against automated scanning. A scanner matching versions against a CVE list skips a host it cannot fingerprint from a header. A person will fire the exploit anyway and read the result. Treat it as noise reduction, and keep patching as the control that actually closes the vulnerability.
It parses the whole configuration tree including every include, validates directive syntax, confirms that referenced files such as certificates exist, and checks internal consistency. It does not test behaviour: a config can pass the test and still route traffic somewhere you did not intend. It catches typos, missing semicolons, unknown directives and broken include paths.
Because it needs root. nginx -t and apachectl configtest read the full config tree, including files that are deliberately not world-readable, so without sudo the audit reports a skip rather than guessing. Run the test by hand with sudo, or give the audit account the access it needs.
Yes, along with Caddy and Lighttpd. Detection is by binary and by running process, so an installed but stopped engine still appears in the inventory with its version and a stopped marker. That is usually the interesting finding: an idle server is still a package that needs patching.
Because matching Apache on the command line also matched unrelated processes whose arguments mention Apache paths, such as someone tailing an access log. One unprivileged user in that list was enough to mask an Apache genuinely running entirely as root, so the check matches on the executable name instead. Nginx workers are still matched on their distinctive nginx: worker command line.
On servers that run no web server, and on immutable infrastructure where hosts are replaced rather than patched: scan the image in CI instead, because updating a host that will be destroyed tomorrow changes nothing. It also cannot see a server built from source, since the update check reads package metadata.