An application security audit reviews the TLS certificates, runtime versions and database access controls on your server to find the misconfigurations that expose your application layer. This page documents the 5 checks in the CtrlOps Application Security audit, in the order the audit runs them.
A hardened SSH config and a clean firewall mean nothing if your Let's Encrypt certificate expired two weeks ago or your MongoDB instance accepts connections without credentials. Every threshold below is transcribed from the audit script itself, not from general advice. Where the script passes a value, this page says it passes; where the script warns rather than fails, this page says so and explains why the distinction matters.
Key takeaways
Five checks cover the application-layer gaps that a firewall and an SSH audit cannot see. Each takes three to five minutes to verify by hand, which is manageable for a single server and painful at scale.
- Two checks are rated HIGH: SSL certificate expiry and database account exposure. If you only have five minutes, check those two.
- Two need root: reading Let's Encrypt certificates and probing database access. The other three run as any user.
- The whole audit takes about 10 seconds when CtrlOps runs it over your existing SSH connection, against roughly 15 minutes by hand.
- End-of-life runtimes receive zero security patches. PHP 7.x, Node 16, MySQL 5.6, PostgreSQL 11 and MongoDB 4.x are all past end-of-life and unfixable, not merely old.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | SSL certificate expiry | HIGH | Certificates expiring within 30 days | Yes |
| 2 | Certbot auto-renewal | MEDIUM | Missing certbot timer or cron job | No |
| 3 | End-of-life runtimes | MEDIUM | Runtimes that get no security patches | No |
| 4 | Database account exposure | HIGH | Anonymous or wildcard database access | Yes |
| 5 | Host resources | LOW | Platform, CPU, memory and disk inventory | No |
PATH, whether their processes are running, or whether their config files are present. Applications installed in non-standard paths, or running inside containers, are invisible to it.Check 1: Are any SSL certificates expiring within 30 days?
An expired SSL certificate takes your site offline for every browser that enforces HTTPS, which in 2026 is all of them. The 30-day window matters because Let's Encrypt certificates have a 90-day lifespan and are designed to renew at the 60-day mark. If a certificate is inside 30 days of expiry, the renewal process has already failed at least once.
The audit reads every certificate directory under /etc/letsencrypt/live/, prefers cert.pem and falls back to fullchain.pem, then runs openssl x509 -checkend 2592000 (30 days in seconds) against each one. Certificates managed by other providers or stored elsewhere are not covered.
How to check manually
# Every Let's Encrypt certificate on the host, with its expiry date
for d in /etc/letsencrypt/live/*/; do
c="${d}cert.pem"
[ -f "$c" ] || c="${d}fullchain.pem"
[ -f "$c" ] || continue
echo "$(basename "$d"): $(sudo openssl x509 -enddate -noout -in "$c")"
done| Result | When | What it means |
|---|---|---|
| PASS | Every readable certificate has more than 30 days left | Renewal is working. The count of certificates is reported. |
| FAIL | One or more certificates expire within 30 days | The affected domain names are listed. Renew now with certbot renew. |
| SKIP | No /etc/letsencrypt/live directory, no readable certificates in it, or root access was not available | If TLS terminates elsewhere (Cloudflare, a load balancer, a managed platform), check that platform instead. |
A SKIP is not a pass. The three SKIP reasons are reported separately for exactly this reason: "no letsencrypt directory" means the audit found nothing to measure, while "need root to read certificates" means there may well be certificates it could not open.
How to fix it
# Attempt renewal
sudo certbot renew
# If renewal reports nothing due, force it
sudo certbot renew --force-renewal
# Test the renewal path without touching the live certificate
sudo certbot renew --dry-runCheck 2: Is certbot renewal automated?
Certbot installed without a renewal timer or cron job means your certificates renew exactly when you remember to run the command. Let's Encrypt certificates expire every 90 days, and the recommended practice is to attempt renewal twice daily so that a transient failure has dozens of retries before it becomes an outage. Without automation, every certificate on the server is on a 90-day countdown to a downtime.
The audit looks for three things and passes if any one of them is present: an active certbot.timer, an active snap.certbot.renew.timer (Snap installations) or the file /etc/cron.d/certbot.
How to check manually
# systemd timers, packaged and snap
systemctl is-active certbot.timer
systemctl is-active snap.certbot.renew.timer
# cron job
ls /etc/cron.d/certbot| Result | When | What it means |
|---|---|---|
| PASS | A certbot renewal timer or cron job is in place | Certificates will attempt to renew on their own. |
| WARN | Certbot is installed but none of the three renewal mechanisms is present | Certificates will lapse unless you renew by hand. Enable the timer and test with certbot renew --dry-run. |
| SKIP | Certbot is not installed at all | If you use a different ACME client (acme.sh, lego, Caddy) verify its own renewal path separately. |
This warns rather than fails because a missing timer is not itself an exposure. It is a guarantee of a future one. Check 1 is the check that fails when that future arrives.
How to fix it
# Enable the systemd timer (the packaged path on Debian, Ubuntu and RHEL family)
sudo systemctl enable --now certbot.timer
# Or, on a host without systemd timers
echo "0 */12 * * * root certbot renew --quiet" | sudo tee /etc/cron.d/certbotsudo certbot renew --dry-run to confirm the renewal will succeed when the timer fires. A timer that runs and fails silently is worse than no timer at all, because it converts an open task into a false sense of safety. The audit can only see that a mechanism exists, not that it works.Check 3: Are any installed runtimes past end-of-life?
An end-of-life runtime receives no security patches from its maintainer. A vulnerability discovered tomorrow in PHP 7.4 will never be fixed, and the runtime stays vulnerable for as long as it runs on your server. This is not theoretical: known CVEs against EOL runtimes are the first thing an automated scanner tries after finding an open port.
The audit reads the installed version of nginx, Apache, PHP, Node.js, Docker, MySQL/MariaDB, PostgreSQL, MongoDB and Redis, reports the full stack, and fails on the specific version ranges that are past upstream end-of-life.
How to check manually
nginx -v 2>&1
php -v 2>/dev/null | head -1
node -v 2>/dev/null
docker --version 2>/dev/null
mysqld --version 2>/dev/null
psql --version 2>/dev/null
mongod --version 2>/dev/null | head -1
redis-server --version 2>/dev/nullThe audit flags these ranges as end-of-life:
| Runtime | Versions flagged as EOL |
|---|---|
| PHP | 5.x, 7.x, 8.0 |
| Node.js | v8, v10, v12, v14, v16 |
| MySQL / MariaDB | 5.5, 5.6 |
| PostgreSQL | 9.x, 10.x, 11.x |
| MongoDB | 3.x, 4.x |
| Redis | 2.x through 5.x |
| Result | When | What it means |
|---|---|---|
| PASS | At least one runtime was detected and none is past end-of-life | The full installed stack is reported. Still check each version against current CVE advisories. |
| FAIL | One or more detected runtimes fall inside the EOL ranges above | The EOL runtimes and the full stack are both listed. Upgrade to a supported release. |
| SKIP | No common application stack was detected on the host | The server may run applications the audit does not probe for, or run everything in containers. |
There is no WARN here on purpose. Reporting versions alone could only ever pass, which makes for a useless check. Being past end-of-life is a binary fact with a published date behind it, so it is treated as a finding in its own right rather than as something to go and look up.
How to fix it
# Example: PHP 7.4 to 8.2 on Ubuntu
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install php8.2
sudo a2dismod php7.4 && sudo a2enmod php8.2
sudo systemctl restart apache2
# Example: Node.js via nvm
nvm install 20
nvm alias default 20Check 4: Can anyone connect to your database without credentials?
A database reachable without credentials is the shortest path there is from network access to data breach. The audit detects which engines are present, then asks each of them one question: who can connect without credentials, and from where. Reachable without credentials is a FAIL. Credentialed but scoped to any host is a WARN.
Each engine has its own authentication model, and the audit adapts to each:
- MySQL/MariaDB: attempts a root login without a password from a non-root shell, then counts accounts with an empty username (anonymous users) and accounts whose host is
%(any host). - PostgreSQL: resolves the live
hba_filepath from the running server, strips comments, then counts lines ending intrust(no password at all) andhostlines whose address column is0.0.0.0/0,::/0orall. - MongoDB: reads
mongod.confforauthorization: enabled. Without it, every reachable client has full admin access to every database. - Redis: reads
redis.confforrequirepassor an ACL (useroraclfile), and separately reads thebindline. No credential plus a bind beyond loopback is the failure; no credential on loopback only is the warning.
The group reports the worst status across the engines actually present, and lists the clean ones alongside it.
How to check manually
# MySQL: passwordless root from a non-root shell, then anonymous and wildcard accounts
mysql -u root -e 'SELECT 1' 2>/dev/null
sudo mysql -N -e "SELECT COUNT(*) FROM mysql.user WHERE user=''"
sudo mysql -N -e "SELECT COUNT(*) FROM mysql.user WHERE host='%'"
# PostgreSQL: find the live pg_hba.conf, then read its rules
sudo -u postgres psql -X -tAc 'SHOW hba_file'
sudo grep -vE '^[[:space:]]*(#|$)' /etc/postgresql/*/main/pg_hba.conf
# MongoDB: authorization must be explicitly enabled
sudo grep 'authorization' /etc/mongod.conf
# Redis: a credential of some kind, and how far it is bound
sudo grep -E '^(requirepass|user |aclfile |bind )' /etc/redis/redis.conf| Result | When | What it means |
|---|---|---|
| PASS | Every detected engine has credentials and no wildcard host scope | The clean engines are listed by name, along with any that could not be audited. |
| WARN | Over-broad host scope: MySQL accounts on %, pg_hba entries on 0.0.0.0/0, ::/0 or all, or Redis with no password but bound to loopback only | Credentials exist but the blast radius is wider than it needs to be. Restrict to specific addresses, and give Redis a requirepass anyway. |
| FAIL | Reachable with no credentials at all: passwordless MySQL root, anonymous MySQL users, pg_hba trust entries, MongoDB without authorization, or Redis with no password while bound beyond loopback | Anyone who can reach the port has full access. Fix this before anything else on the page. |
| SKIP | An engine was detected but could not be audited (server down, root needed, config file not found), or no engine is installed | Each engine and the reason it was skipped are listed. A host with no database is a SKIP, not a PASS, because nothing was measured. |
The Redis distinction is worth reading twice. Loopback-only Redis with no password is a warning rather than a failure because an external attacker cannot reach it directly. It is still a warning rather than a pass because any local process, any compromised application on the same host, and any server-side request forgery in your web app all reach loopback without touching the network.
How to fix it
# MySQL: drops anonymous users and sets a root password
sudo mysql_secure_installation
# PostgreSQL: replace trust with scram-sha-256 in pg_hba.conf
# Change: local all all trust
# To: local all all scram-sha-256
sudo systemctl reload postgresql
# MongoDB: enable authorization in /etc/mongod.conf
# security:
# authorization: enabled
sudo systemctl restart mongod
# Redis: set a credential in redis.conf
# requirepass a_long_random_string_here
sudo systemctl restart redis127.0.0.1 without ever crossing the firewall. Authentication is the control that survives that, which is why this check probes credentials rather than reachability.Check 5: What are the host resources?
This is an inventory check, not a security finding. It reports the virtualization platform (KVM, Xen, VMware, bare metal), the CPU count, total memory and the size of the root disk. The values are not scored against a threshold. They are in the report so you can confirm the host matches the plan you are paying for and has the resources the applications on it expect.
How to check manually
systemd-detect-virt
nproc
free -m | awk '/^Mem:/{print $2}'
df -h / | awk 'NR==2{print $2}'| Result | When | What it means |
|---|---|---|
| PASS | At least one of CPU, memory or disk was readable | Informational. The platform and the three figures are reported for your review. |
| SKIP | None of nproc, free or df returned anything | The tools needed to read host resources are unavailable. Reading nothing is a failed measurement, not a healthy host, so it is deliberately not a PASS. |
Why it is in the audit
A server with 512MB of RAM running MySQL, Redis, Node.js and nginx is a server that swaps under load. A server that swaps responds slowly enough for a health check to time out, which cascades into a restart loop that looks exactly like an application bug. Knowing the resource envelope before you go chasing a phantom memory leak saves hours, and it is free to collect while the audit is already connected.
What this audit does not cover
Being explicit about scope is part of the point of publishing thresholds. This audit reads local configuration and probes database access from the host itself.
- Non-Let's Encrypt certificates. Check 1 reads
/etc/letsencrypt/live/only. Certificates from other CAs, in custom paths, or managed by Cloudflare, AWS ACM or another provider are not detected. - Application-level vulnerabilities. There is no DAST scan here, and no check for SQL injection, XSS or the rest of the OWASP Top 10. This audits the infrastructure the application runs on, not the application code.
- Container-internal runtimes. Check 3 detects runtimes on the host
PATH. A PHP 7.4 inside a Docker container is invisible to it. The Docker audits cover image and container concerns separately. - Database content and query patterns. Check 4 probes whether authentication exists, not whether the password is strong, whether privileges are least-privilege, or whether queries are parameterised.
- TLS protocol and cipher configuration. Whether nginx still offers TLS 1.0, or a weak cipher suite, belongs to the web server TLS audit. Check 1 is about the certificate's expiry date, not the handshake.
The manual audit problem at scale
Running these 5 checks on one server takes about 15 minutes, including reading each output and deciding whether it passed, warned or failed. For one server that is a manageable task. For 10 servers across staging and production it is two and a half hours of SSH sessions and hand-written notes.
The certificate checks are the worst offenders at scale. Certbot manages certificates per domain, so a server hosting 8 domains has 8 certificates to inspect individually. Each one means reading an expiry date, comparing it to today, and deciding whether 23 days is close enough to act on. Multiply that by 10 servers and you are comparing 80 dates by hand, on a task where being wrong once is an outage.
The database check is the second worst. Each engine has a different authentication model, a different config file format, and a different definition of "anonymous access." Checking MySQL, PostgreSQL and Redis on a single server means reading three configs and running three unrelated probe commands. On a host running all three, that one check takes five minutes by itself.
And, as with every audit, the real cost is not the first pass. It is the drift. A trust entry added to pg_hba.conf during a migration in March is still there in September unless something re-checks it. That is the same failure mode as managing multiple servers without a central view.
How CtrlOps runs all 5 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Application Security 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 & 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. The written steps that follow cover the same ground if you would rather read than watch.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select Application Security
Choose the Application Security audit from the catalog. All 5 checks are listed with their descriptions, severity levels and the estimated run time (about 10 seconds in total). Toggle off any check you want to skip.
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 you can sort and filter
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.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the hardening score move. That delta is the thing a spreadsheet never gave you: evidence that the fix landed.
For fleet-wide work, the Audit Reports view on the Home screen runs the same audit across every connected server at once. Each server gets its own report, and the scores line up side by side so you can see which box to open first.
| Task | By hand, 8 servers | CtrlOps, 8 servers |
|---|---|---|
| Run all 5 checks | About 2 hours | About 90 seconds |
| Get a scored report | Not available | Automatic |
| Export a PDF for a client | Not available | One click |
| Fix what failed | Copy-paste from your notes | AI-generated commands behind an approval gate |
| Re-audit after fixing | Another 2 hours | Another 90 seconds |
Conclusion
Securing the application layer of a Linux server does not have to be a manual chore. Checking SSL expiry dates, certbot timers, runtime versions and database credentials by hand across a fleet burns hours of SSH sessions and produces nothing you can hand to a client afterwards.
The CtrlOps Security Audit runs all 5 of these checks in about 10 seconds per server, agentlessly, with a hardening score, a client-ready PDF and AI-assisted commands behind an approval gate. Use it to catch the certificate that stopped renewing and the database that never had a password, before either of them becomes an incident.
Frequently asked questions
An application security audit on a Linux server checks the software stack sitting on top of the OS: SSL/TLS certificate validity and renewal automation, whether installed runtimes such as PHP, Node.js, MySQL, PostgreSQL, MongoDB and Redis are past end-of-life, whether any database accepts connections without credentials, and what resources the host actually has. It covers the application infrastructure, not the application code itself.
Run sudo openssl x509 -checkend 2592000 -noout -in /etc/letsencrypt/live/yourdomain/cert.pem. A non-zero exit code means the certificate expires within 30 days. For the exact date rather than a pass or fail, run sudo openssl x509 -enddate -noout -in /etc/letsencrypt/live/yourdomain/cert.pem. On a host with several domains, loop over /etc/letsencrypt/live/*/ so you do not miss one.
PHP 7.4 reached end-of-life on 28 November 2022. Since that date it receives no security patches from the PHP team, so any vulnerability found in it after that date stays unfixed permanently. The audit flags PHP 5.x, 7.x and 8.0. Upgrade to PHP 8.2 or 8.3 for active security support, and expect to make code changes for deprecated functions and removed extensions along the way.
Run sudo grep 'authorization' /etc/mongod.conf and look for security.authorization: enabled. If the line is missing or set to disabled, every client that can reach the MongoDB port has full admin access to every database on it. This is the single most common MongoDB exposure, and it is the mechanism behind the hundreds of thousands of MongoDB instances that have been found open and ransomed.
It depends on the bind address. Redis with no requirepass and no ACL, bound beyond loopback, is a FAIL: anyone who can route to the port owns the data. The same Redis bound to 127.0.0.1 only is a WARN, because an external attacker cannot reach it directly. It is still a warning rather than a pass, because any local process, any compromised app on the same host, and any SSRF bug in your web application all reach loopback without crossing the firewall.
No. CtrlOps audits the infrastructure layer: certificates, runtime versions, database access controls and host resources. It does not run DAST scans, review code, or check for OWASP Top 10 issues in your application. For application-layer testing, pair this audit with a DAST tool such as OWASP ZAP, or a SAST tool wired into your CI pipeline.
If the renewal timer fires but the renewal itself fails, because of a DNS change, port 80 being blocked, or a plugin misconfiguration, the certificate keeps counting down with no alert. You find out when a visitor hits the browser warning or a monitor catches the expired certificate. Run sudo certbot renew --dry-run monthly to verify the renewal path still works, and read /var/log/letsencrypt/letsencrypt.log after any failure. Check 1 exists precisely because check 2 passing does not prove renewal works.
Three of the four probes need privileged reads. MySQL socket authentication needs root to connect without a password, pg_hba.conf is owned by the postgres user and unreadable by others, and /etc/mongod.conf and /etc/redis/redis.conf are typically mode 640. Without root the audit reports SKIP for those engines and names the reason, rather than reporting a pass it did not earn.
Yes. Every check works on any Linux host reachable over SSH: EC2, DigitalOcean Droplets, Linode, Vultr, Hetzner, bare metal or a VPS from any other provider. The audit script detects the distribution family (Debian, Ubuntu, RHEL, Rocky, Alma, Amazon Linux, Fedora, Oracle, SUSE, Alpine) and adapts its commands accordingly.
If your applications run entirely on a managed PaaS such as Heroku, Railway or Render, or on a serverless platform such as Lambda or Cloud Functions, you do not control the runtime versions, the certificates or the database configuration, and this audit has nothing to read. It is for servers where you install and configure the application stack yourself.
Lynis installs on the host and runs a broad system audit spanning hundreds of checks across every layer. This audit runs agentlessly over SSH, covers 5 application-layer checks with published thresholds, and feeds the failures into a guided fix step with an approval gate. Lynis gives you breadth across the whole system; this gives you depth on the application stack plus remediation. They complement each other rather than compete.