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
- Absence of output is not data. Exit code and permissions before any
conclusion: empty-because-absent and empty-because-unreadable look identical on
screen.
- Name exactly what you measure. Systemd unit, path, field: a verdict on a name
you never verified is a verdict on nothing.
- Date the content, not the file. A fresh mtime does not refresh a number;
every copy manufactures false freshness.
- 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.