HardStacked
Share:

Auditing · misleading signals

Five green lights that lied: anatomy of a routine Linux checkup

2026-07-20 · HardStacked · 7 min read

A routine checkup on one of our hardened Linux machines. Final verdict: nothing to fix, nothing suspicious, hardening score stable. An uneventful hour, on the surface. Except that on the way to that all-green report, five perfectly reassuring signals tried to lie to us, and two very nearly got through.

Auditing is not collecting OKs. It is asking, in front of every number: what could be hiding the truth here? Here are the five traps from that round, in the order they showed up, each with the habit that defuses it.

1. A grep that returns nothing is not a zero

First reflex of the checkup: re-read the score from the last audit in the report file. The command runs, returns nothing. The tempting read: no line, no score, maybe this machine was never scanned. The true read: the file is readable by root only, and the grep died on a permission error, not on an absence of matches.

$ grep 'hardening_index' /var/log/lynis-report.dat $ echo $? 2 ← code 2: read error. "Nothing found" would have been 1. $ ls -l /var/log/lynis-report.dat -rw-r----- 1 root root 92779 Jul 19 20:10 /var/log/lynis-report.dat

Empty output has three possible causes: the pattern is absent, the file is unreadable, or the command itself failed. They look identical on screen and mean completely different things.

The habit

Absence of output is not data. Before concluding, echo $? and an ls -l on the target: grep cleanly separates "nothing found" (exit 1) from "could not read" (exit 2).

2. "Free" memory lies, "available" memory tells the truth

Second stop: memory. On an 8 GB machine, free shows 351 MiB free. The tempting read: the box is suffocating, kill something. The true read: the kernel deliberately uses idle RAM as disk cache, and that cache is handed back instantly the moment a program needs it.

$ free -h total used free shared buff/cache available Mem: 7.9Gi 5.5Gi 351Mi 264Mi 2.5Gi 2.4Gi ↑ the panic ↑ the truth

The only number that measures real pressure is available: the kernel's own estimate of what can be served without touching swap. Here, 2.4 GiB, 30 % of the machine. No pressure at all. A monitor keyed on free would have raised an alarm for nothing; worse, someone could have "freed" memory by killing a healthy service.

The habit

Memory alert thresholds are set on available (MemAvailable in /proc/meminfo), never on free. A healthy Linux box almost always shows a tiny free: it means the kernel is working, not drowning.

3. "inactive" also means "that name does not exist"

This one is our own mistake, and it stays in the article because it teaches. When it was time to check the machine's HIDS, we queried the service under its agent name: answer, inactive. For several minutes the HIDS was considered down. It was running perfectly: it runs in manager mode, under another unit name, and the one we were querying has never existed on this machine.

The trap is structural: systemctl is-active answers inactive for a unit that does not exist, with the same exit code as a genuinely stopped service (unit names genericized below):

$ systemctl is-active hids-agent.service inactive $ echo $? 3 ← identical to a stopped service. Yet this unit does not exist. $ systemctl list-unit-files | grep -i hids hids-manager.service enabled ← the real name. Running the whole time.

In an audit report, "the HIDS is down" and "I misspelled the HIDS" produce exactly the same output line. Only a second look tells them apart.

The habit

Before declaring a service dead, confirm the name exists: systemctl list-unit-files | grep -i <name>. An inactive on a name you never verified proves nothing at all.

4. Yesterday's file can carry a two-week-old number

Back to the score. Unable to read the official report without privileges, we hunted for a readable copy in our working archives, sorted by modification time, newest first. It announced a score of 70. The machine sits at 80. Ten points gone? No: the file had been copied the day before, but its content was two weeks old. Sorting by mtime had surfaced an old photograph in a brand-new frame.

$ ls -l archives/lynis-report-copy.dat -rw-r--r-- 1 user user 92779 Jul 19 00:24 archives/lynis-report-copy.dat ← "yesterday" $ grep '^report_datetime_start' archives/lynis-report-copy.dat report_datetime_start=2026-07-05 21:12:04 ← the content is two weeks old

Every copy, every backup, every misconfigured rsync refreshes an mtime without refreshing a single byte of meaning. On an audit number, the gap between the two can make you conclude a regression that never happened, or hide one that did.

The habit

Date the content, never the file. Serious reports carry their timestamp inside (report_datetime_start in Lynis); that field is the one that counts, and it is the first thing to read before quoting a number.

5. The score says the door is locked, not whether anyone rattled the handle

The hardening score was good, stable, with two warnings long documented as distribution false positives. The temptation at this point: close the case. But a score is a photograph of the configuration at rest. It says nothing about what actually happened on the machine. The second half of the checkup lives in three places the score never looks at.

First, the real surface. SSH keys found on disk raised an eyebrow: exposed surface? No. Keys make the machine a client that reaches out. The attack surface is a daemon that listens, and what it attracts once it listens, we have measured elsewhere:

$ ss -tln | grep ':22' $ ← nothing listens. Keys on disk, no open door. $ faillock --user admin admin: When Type Source Valid $ ← empty table: not one failed authentication.

Second, the failure counters. An empty faillock table and an auth journal where every privilege elevation traces back to a known action are living proof that nobody even tried. No score contains that information.

Third, the allowlists. The machine carries an exclusion list for its file integrity monitoring. Every line of an allowlist is a chosen blind spot: "stop warning me when this file changes". We re-read them one by one. Three justified themselves; the fourth required finding out who had changed what, and why. It was legitimate. But the only way to know was to ask the question.

The habit

End every checkup with the living half: the auth journal, the lockout counters, and a line-by-line re-read of every allowlist with the same question: "am I still fine being blind to this file?"

The four rules of a light you can trust

  1. Absence of output is not data. Exit code and permissions before any conclusion: empty-because-absent and empty-because-unreadable look identical on screen.
  2. Name exactly what you measure. Systemd unit, path, field: a verdict on a name you never verified is a verdict on nothing.
  3. Date the content, not the file. A fresh mtime does not refresh a number; every copy manufactures false freshness.
  4. Static score + living logs, always both. One says the door is locked, the other says whether anyone rattled the handle. Either one alone is half the truth.

What the round came to in the end

Nothing. And that is the best possible result: score stable at 80 (how that number is earned and lost, we measured fix by fix), two warnings long identified as false positives, an empty failure table, clean journals, allowlists revalidated line by line. Zero fixes applied.

But the five greens we ended up trusting are greens we first challenged. The same checkup taken at face value would have declared a running HIDS dead, a breathing machine suffocated, and a ten-point regression that never existed. Three false alarms, three possible "corrections" on a healthy system. Methodical doubt did not just prevent wrong conclusions: it prevented wrong actions.

The deeper lesson

None of these traps is exotic. Every one of them is defused by exactly one extra check: an exit code, a confirmed name, an internal timestamp. Exactly the checks you skip when the light is green and you want to move on.

That is why HardVps never reports a declared "success": every fix is applied with the original captured first, every restore is verified byte-exact by SHA-256, and the report shows the measured before/after delta, not a ticked box.

See what HardVps does →

On a Raspberry Pi? Start free with audit-pi, our open-source security audit.

Share: