What a NIS2 auditor really asks for: twelve pieces of evidence and the script that pulls them from your servers
No policy, no intentions: an auditor wants artefacts with a date. These are the twelve that come up in virtually every CyFun or NIS2 audit, with the command that produces each one, and an evidence-pack.sh that puts everything in one signed archive that can still be verified six months later.
Contents
An audit isn't an exam about what you know, it's a check of what you can show. The auditor has a list of controls, and for every control asks the same question: "what shows that?" The answer "we do that" scores zero. The answer "here is the output of 14 August, here that of 14 September, and this is the deviation we fixed back then" scores full marks. This article is the list of twelve artefacts you want to have ready, plus the script that collects them.
The twelve pieces of evidence
Per item: which control it covers (NIS2 Art. 21(2) and the ISO 27001:2022 counterpart), what the auditor wants to see, and the command.
1. Asset inventory with a date
NIS2 (a), (i) · ISO A.5.9 What: per host the OS, the kernel, the listening ports, the running services. Not "a CMDB" but a reproducible list.
hostname -f; date -Is; . /etc/os-release; echo "$PRETTY_NAME"; uname -r
ss -tulnH | awk '{print $1, $5}' | sort -u
systemctl list-units --type=service --state=running --no-legend | awk '{print $1}'
2. Patch status and lead time
NIS2 (e) · ISO A.8.8 What: which updates are pending, when the last one was installed, and how many days passed between publication and installation.
apt list --upgradable 2>/dev/null | grep -v '^Listing'
grep -h 'status installed' /var/log/dpkg.log* | awk '{print $1, $5}' | sort | tail -50
[ -f /var/run/reboot-required ] && { echo REBOOT-REQUIRED; cat /var/run/reboot-required.pkgs; }
systemctl is-enabled unattended-upgrades apt-daily-upgrade.timer 2>/dev/null
3. Open vulnerabilities, prioritised
NIS2 (d), (e) · ISO A.8.8 What: the CVE list of this host, with EPSS/KEV, and the decision per item (patch, mitigate, accept). See the OSV guide; the script there produces the tsv. The evidence is the tsv plus a line per accepted risk:
CVE-2026-1234 libxml2 accepted-risk 2026-09-01 "not reachable: lib only used by offline batch" approved: J. Peeters
4. Access: accounts, sudo, SSH keys
NIS2 (i) · ISO A.5.15, A.5.18, A.8.2 What: who can get in, with which rights, and when that was last reviewed.
awk -F: '$3>=1000 && $7!~/nologin|false/{print $1}' /etc/passwd
getent group sudo admin wheel 2>/dev/null
grep -rhE '^[^#].*ALL' /etc/sudoers /etc/sudoers.d/ 2>/dev/null
for d in /root /home/*; do [ -f "$d/.ssh/authorized_keys" ] && { echo "== $d"; awk '{print $1, $NF}' "$d/.ssh/authorized_keys"; }; done
journalctl -u ssh --since "365 days ago" --no-pager -o short-iso | grep -E 'Accepted (publickey|password)' | awk '{print $7, $1}' | sort -k1,1 -k2,2r | awk '!seen[$1]++' # last SSH login per user
The quarterly review is a separate line in your log: 2026-09-15 access-review web-01: 4 accounts, 1 removed (ex-employee), 0 unknown keys — J. Peeters.
5. MFA and authentication policy
NIS2 (j) · ISO A.5.17, A.8.5 What: that password login is off and that admin access requires a second factor.
sshd -T | grep -iE '^(passwordauthentication|permitrootlogin|authenticationmethods|kbdinteractiveauthentication) '
grep -l pam_google_authenticator /etc/pam.d/* 2>/dev/null
For an IdP (Entra, Keycloak, Authentik): the export of the MFA policy, with a date.
6. Logging and retention
NIS2 (b) · ISO A.8.15 What: that logs exist, how long they're kept, and that the clock is right.
journalctl --disk-usage
grep -hE '^(Storage|SystemMaxUse|MaxRetentionSec)' /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null
journalctl --list-boots | head -3 # oldest boot = how far back you can go
timedatectl show -p NTPSynchronized -p Timezone
7. Detection is active, and tested
NIS2 (b) · ISO A.8.16 What: which detections run (brute force, honeypots, integrity) and when they were last tested.
systemctl is-active fail2ban auditd 2>/dev/null
sudo fail2ban-client status sshd 2>/dev/null | grep -E 'Total (failed|banned)'
sudo auditctl -l 2>/dev/null | grep -c canary
Plus your test log: 2026-09-01 canary-test web-01: alert received after 3 min — OK. A detection without a test log is, to an auditor, a detection that might work.
8. Backup: recent, verifiable, restored
NIS2 (c) · ISO A.8.13 What: last successful backup, last integrity check, last restore test.
restic -r "$REPO" snapshots --latest 1 --json 2>/dev/null | jq -r '.[0] | "\(.time) \(.hostname) \(.paths|join(","))"'
tail -5 /srv/inventory/restore-tests.log 2>/dev/null
See verifying backups for the restore test script.
9. Cryptography: certificates and protocols
NIS2 (h) · ISO A.8.24 What: which certificates are running, when they expire, and that old protocols are off.
for port in $(ss -tlnH | awk '{sub(/.*:/,"",$4); print $4}' | sort -un); do
cert=$(echo | timeout 3 openssl s_client -connect "localhost:$port" 2>/dev/null \
| openssl x509 -noout -subject -enddate 2>/dev/null | paste -sd' ')
[ -n "$cert" ] && echo "port $port: $cert"
done
echo | openssl s_client -connect localhost:443 -tls1_1 2>&1 | grep -q 'Cipher is (NONE)' && echo "TLS1.1: off" || echo "TLS1.1: ON"
10. Firewall and network segmentation
NIS2 (g) · ISO A.8.20, A.8.22 What: default-deny inbound, and the list of open ports matching the inventory.
sudo ufw status verbose 2>/dev/null || sudo nft list ruleset 2>/dev/null | head -60
11. Configuration integrity
NIS2 (e) · ISO A.8.9 What: that critical configuration doesn't change unnoticed. The simplest form: a hash list you compare periodically.
sudo find /etc/ssh /etc/sudoers /etc/sudoers.d /etc/pam.d /etc/ufw /etc/cron.d -type f -exec sha256sum {} + | sort -k2 > /srv/inventory/$(hostname -s)-config.sha256
# Next time: what changed?
sha256sum -c --quiet /srv/inventory/$(hostname -s)-config.sha256 2>&1 | grep -v ': OK$'
12. Incident register and notification procedure
NIS2 (b), Art. 23 · ISO A.5.24–A.5.28 What: the list of incidents (including the small ones, including "false alarm"), with time of detection, assessment, action and — if it was significant — the time of notification to the CCB. This isn't a command; it's a table you maintain. Empty is suspicious; an auditor doesn't believe nothing happened in a year.
The script: evidence-pack.sh
Everything above in one archive per host, with a manifest of hashes and a signature proving the archive hasn't been modified since. We sign with ssh-keygen -Y (OpenSSH ≥ 8.0, so present everywhere) and a separate Ed25519 key used only for evidence.
# Once: an evidence key, not your personal SSH key
sudo install -d -m 0700 /etc/evidence
sudo ssh-keygen -q -t ed25519 -N '' -C 'evidence@'"$(hostname -s)" -f /etc/evidence/key
# You give the public key to the auditor (and put it in git):
cat /etc/evidence/key.pub
sudo tee /usr/local/sbin/evidence-pack.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
HOST=$(hostname -s); TS=$(date -u +%Y%m%dT%H%M%SZ)
OUT=/srv/evidence/$HOST-$TS; mkdir -p "$OUT"
run() { local name=$1; shift; { echo "# $name — $(date -Is) — $HOST"; "$@" 2>&1 || true; } > "$OUT/$name.txt"; }
run 01-assets bash -c 'hostname -f; . /etc/os-release; echo "$PRETTY_NAME"; uname -r; ss -tulnH | awk "{print \$1, \$5}" | sort -u; systemctl list-units --type=service --state=running --no-legend | awk "{print \$1}"'
run 02-patching bash -c 'apt list --upgradable 2>/dev/null | grep -v "^Listing"; grep -h "status installed" /var/log/dpkg.log* | awk "{print \$1, \$5}" | sort | tail -50; cat /var/run/reboot-required.pkgs 2>/dev/null; systemctl is-enabled unattended-upgrades apt-daily-upgrade.timer'
run 03-vulns bash -c 'ls -t /var/lib/cve-scan/*.tsv 2>/dev/null | head -1 | xargs -r cat'
run 04-access bash -c 'awk -F: "\$3>=1000 && \$7!~/nologin|false/{print \$1}" /etc/passwd; getent group sudo admin wheel; grep -rhE "^[^#].*ALL" /etc/sudoers /etc/sudoers.d/; for d in /root /home/*; do [ -f "$d/.ssh/authorized_keys" ] && { echo "== $d"; awk "{print \$1, \$NF}" "$d/.ssh/authorized_keys"; }; done; journalctl -u ssh --since "365 days ago" --no-pager -o short-iso | grep -E "Accepted (publickey|password)" | awk "{print \$7, \$1}" | sort -k1,1 -k2,2r | awk "!seen[\$1]++"'
run 05-auth bash -c 'sshd -T | grep -iE "^(passwordauthentication|permitrootlogin|authenticationmethods|kbdinteractiveauthentication) "; grep -l pam_google_authenticator /etc/pam.d/* 2>/dev/null'
run 06-logging bash -c 'journalctl --disk-usage; grep -hE "^(Storage|SystemMaxUse|MaxRetentionSec)" /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null; journalctl --list-boots | head -3; timedatectl show -p NTPSynchronized -p Timezone'
run 07-detection bash -c 'systemctl is-active fail2ban auditd; fail2ban-client status sshd 2>/dev/null | grep -E "Total (failed|banned)"; auditctl -l 2>/dev/null | grep -c canary; tail -20 /srv/inventory/detection-tests.log 2>/dev/null'
run 08-backup bash -c 'restic -r "${RESTIC_REPOSITORY:-/srv/backup}" snapshots --latest 1 --json 2>/dev/null | jq -r ".[0] | \"\(.time) \(.hostname) \(.paths|join(\",\"))\""; tail -5 /srv/inventory/restore-tests.log 2>/dev/null'
run 09-crypto bash -c 'for port in $(ss -tlnH | awk "{sub(/.*:/,\"\",\$4); print \$4}" | sort -un); do cert=$(echo | timeout 3 openssl s_client -connect "localhost:$port" 2>/dev/null | openssl x509 -noout -subject -enddate 2>/dev/null | paste -sd" "); [ -n "$cert" ] && echo "port $port: $cert"; done; true'
run 10-firewall bash -c 'ufw status verbose 2>/dev/null || nft list ruleset 2>/dev/null | head -60'
run 11-config-hash bash -c 'find /etc/ssh /etc/sudoers /etc/sudoers.d /etc/pam.d /etc/ufw /etc/cron.d -type f -exec sha256sum {} + 2>/dev/null | sort -k2'
run 12-incidents bash -c 'cat /srv/inventory/incident-register.md 2>/dev/null || echo "(no register found — create one)"'
# Manifest + signature
( cd "$OUT" && sha256sum *.txt > MANIFEST.sha256 )
ssh-keygen -Y sign -f /etc/evidence/key -n evidence "$OUT/MANIFEST.sha256"
tar -C /srv/evidence -czf "$OUT.tar.gz" "$(basename "$OUT")" && rm -rf "$OUT"
echo "$OUT.tar.gz"
EOF
sudo chmod 0755 /usr/local/sbin/evidence-pack.sh
sudo install -d /srv/evidence
echo '0 5 1 * * root /usr/local/sbin/evidence-pack.sh >> /var/log/evidence-pack.log 2>&1' | sudo tee /etc/cron.d/evidence-pack
An archive on the first of every month. Anyone can verify with the public key, without access to the server:
tar -xzf web-01-20260901T050000Z.tar.gz && cd web-01-20260901T050000Z
echo "evidence@web-01 $(cat key.pub)" > allowed_signers # key.pub received from the administrator
ssh-keygen -Y verify -f allowed_signers -I evidence@web-01 -n evidence -s MANIFEST.sha256.sig < MANIFEST.sha256
sha256sum -c MANIFEST.sha256
Two OKs: the manifest was signed by that host's key, and not a single file has changed since. That's the difference between "a directory of text files" and evidence.
Pitfalls
- Evidence on the server you're proving. If the server gets compromised, the archive sitting on it is worthless. Copy every archive immediately to another place (rsync to an evidence server, or an object store with object lock).
- The private key on the same host. Whoever is root can sign new archives. That's acceptable for "not modified afterwards", not for "not forged by root". If you want the latter, sign on a separate machine that fetches the archives.
- Timestamps without a time source. An auditor can ask how you know
2026-09-01T05:00:00Zis correct. NTP sync (evidence item 6) is the answer; for really hard proof there's RFC 3161 timestamping, but that's overkill for most SMEs. - Collecting everything, reading nothing. An archive nobody opens doesn't catch the deviation. Set a quarterly appointment: two archives side by side,
diff, explain deviations. - Leaving the incident register empty. "No incidents" is a red flag to an auditor. Fail2ban bans, a false honeypot alarm, an expired certificate you caught just in time: all lines in the register.
What you still don't have
- Fleet summary. Thirty archives per month don't answer "what percentage of my hosts had MFA enforced on 1 September?". That's a script across the archives — and then a spreadsheet.
- Continuity between snapshots. The archive of 1 September and that of 1 October say nothing about 15 September. A control that failed for ten days in between, you don't see.
- A verifiable chain. Every archive is signed separately. That an archive is missing (or replaced by an older one) proves nothing — for that you need a hash chain linking every archive to the previous one.
How monsys does it
monsys runs the evidence queries continuously across all hosts and stores the result in a transparency log: every entry contains the hash of the previous one, and every entry is Ed25519-signed with a per-tenant key. The monthly audit pack is a byte-stable JSONL.gz plus PDF with an offline verify.py — the auditor needs no account. Per NIS2, ISO 27001 and CRA control you see a coverage percentage, and a control that loses evidence raises an alert before the next audit. The Auditor Workbench builds a one-click ZIP with only the artefacts for the requested scope.
FAQ
Does an auditor accept home-made scripts as evidence?
Yes, provided the output has a date, is reproducible and cannot have been modified afterwards. Hence the manifest with hashes and the signature. What an auditor doesn't accept: a screenshot without a date, or a Word document describing what you "do".
How long do I have to keep evidence?
NIS2 itself names no term for technical evidence; the CCB can ask up to five years back in an investigation. ISO 27001 requires you to define a retention period. Practically: three years for monthly archives, and the incident register forever.
Should this be per host or per organisation?
Both. The artefacts are per host (that's where the configuration lives), but the auditor wants an organisation-wide answer ("all servers have MFA"). Collect per host, summarise per organisation, and keep both.
Written by the monsys team — sysadmins who do this every day.
Done it by hand? Let monsys keep it running.
Everything in this guide runs in monsys as a continuous check, with history, alerts and audit evidence. 5 servers free, EU-hosted in Belgium, installed in 60 seconds.