NIS2, ISO 27001 & evidenceintermediate5 min read

Proving ISO 27001 A.8.8 with logs instead of a Word document: managing technical vulnerabilities

Control A.8.8 requires you to identify, assess and address vulnerabilities in a timely way. Most organisations prove that with a policy and a screenshot of a scanner. An auditor wants to see the chain: when the CVE became known, when you saw it, what you decided, when it was closed. This is how to pull those four timestamps automatically from your servers and turn them into a metric — MTTR per severity — you can show every quarter.

Contents
  1. The four timestamps
  2. Step 1: T1 and T3 from your scan history
  3. Step 2: T0 from the source
  4. Step 3: T2 — recording the decision
  5. Step 4: the metric — MTTR per severity
  6. Step 5: the quarterly evidence
  7. Pitfalls
  8. What you still don't have
  9. How monsys does it
  10. FAQ

ISO 27001:2022, Annex A control 8.8, Management of technical vulnerabilities: "Information about technical vulnerabilities of information systems in use shall be obtained, the organization's exposure to such vulnerabilities shall be evaluated and appropriate measures shall be taken." Three verbs — obtain, evaluate, take measures — and an auditor tests all three. A policy in Word proves you want to. A scanner screenshot proves you looked on one day. What convinces an auditor is a table: four dates per vulnerability, and from that a lead time you set yourself and meet.

The four timestamps

#MomentSourceProves
T0Vulnerability publishedUSN/DSA date, OSV published— (external)
T1You knewYour scan history: first tsv in which the CVE appearsobtain (A.8.8 "obtained")
T2You decidedDecision line: patch / mitigate / accept, with a nameevaluate ("evaluated")
T3Closeddpkg.log installation of the fix, or first scan without the CVEtake measures ("measures taken")

Two derived numbers: T1 − T0 (how fast you notice) and T3 − T1 (how fast you resolve it, the MTTR). The first you want under 7 days; the second is your own norm per severity — and the auditor tests whether you meet it, not whether it's strict.

Step 1: T1 and T3 from your scan history

The weekly scan from finding CVEs with OSV.dev keeps a tsv per run in /var/lib/cve-scan/. That's your source for T1 (first occurrence) and T3 (first absence after occurrence):

sudo tee /usr/local/sbin/cve-lifecycle.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Reads all scans in /var/lib/cve-scan/*.tsv (column 1 = CVE) and prints per CVE: first_seen, last_seen, closed
D=/var/lib/cve-scan
for f in "$D"/*.tsv; do d=$(basename "$f" .tsv); cut -f1 "$f" | grep '^CVE-' | sed "s/$/\t$d/"; done \
| sort -k1,1 -k2,2 \
| awk -F'\t' '
  { if (!($1 in first)) first[$1]=$2; last[$1]=$2 }
  END {
    # the latest scan date determines whether a CVE is still open
    for (c in last) printf "%s\t%s\t%s\n", c, first[c], last[c]
  }' > /tmp/lifecycle.tsv
latest=$(ls -1 "$D"/*.tsv | tail -1 | xargs -n1 basename | sed 's/.tsv//')
awk -F'\t' -v latest="$latest" '{ status = ($3==latest) ? "open" : "closed"; print $0"\t"status }' /tmp/lifecycle.tsv
EOF
sudo chmod 0755 /usr/local/sbin/cve-lifecycle.sh
sudo /usr/local/sbin/cve-lifecycle.sh | column -t | head
# CVE-2026-1234  2026-07-07  2026-08-04  closed   ← T1=07-07, T3≈08-04 (+ scan interval)
# CVE-2026-5678  2026-08-11  2026-09-15  open

T3 from scans has an inaccuracy of one scan interval (weekly: up to 7 days). dpkg.log is more precise:

# When was the package fixing CVE-2026-1234 installed?
pkg=openssl; fixver=3.0.13-0ubuntu3.5
grep -h " upgrade $pkg[: ]" /var/log/dpkg.log* | grep "$fixver" | awk '{print $1, $2}' | head -1
# 2026-08-02 03:14:07   ← T3 exact

Combine both: T3 = dpkg date if known, otherwise the first scan without the CVE.

Step 2: T0 from the source

# Ubuntu USN/OSV: publication date per CVE
curl -s "https://api.osv.dev/v1/vulns/UBUNTU-CVE-2026-1234" | jq -r '.published[0:10]'
# Debian: the security tracker JSON has no date per CVE; use the DSA date or NVD:
curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2026-1234" | jq -r '.vulnerabilities[0].cve.published[0:10]'

Build a lookup table from that (cve\tpublished) which you extend on every lifecycle run; the NVD API is rate-limited to ~5 requests per 30 s without a key, so cache.

Step 3: T2 — recording the decision

This is the step no tool does for you, and the step A.8.8 revolves around ("exposure evaluated"). One line per CVE, in a file you keep:

sudo install -d /srv/inventory && sudo touch /srv/inventory/cve-decisions.tsv
# cve  date  decision  who  reason
printf 'CVE-2026-1234\t2026-07-08\tpatch\tj.peeters\tfix in -security, rolled out via unattended-upgrades\n' | sudo tee -a /srv/inventory/cve-decisions.tsv
printf 'CVE-2026-5678\t2026-08-12\taccept\tj.peeters\tlibxml2 XInclude not reachable; VEX not_affected; reassess at release 2026.10\n' | sudo tee -a /srv/inventory/cve-decisions.tsv
printf 'CVE-2026-9012\t2026-08-12\tmitigate\tm.dubois\tno fix; WAF rule 4471 blocks the path; ticket OPS-812 for upgrade\n' | sudo tee -a /srv/inventory/cve-decisions.tsv

Three decisions are enough: patch, mitigate, accept. An accept without a reason isn't a decision; an accept of a KEV CVE is a red flag you'd better explain before the auditor asks. An accept belongs with a VEX statement on the product.

Step 4: the metric — MTTR per severity

Join everything and compute per quarter:

sudo tee /usr/local/sbin/cve-mttr.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Combines lifecycle (T1/T3), published (T0), decisions (T2) and severity (frozen at first detection) into one table + KPIs.
D=/var/lib/cve-scan; Q=${1:-$(date +%Y-Q$(( ($(date +%-m)-1)/3+1 )))}
/usr/local/sbin/cve-lifecycle.sh > /tmp/lc.tsv
# severity + KEV per CVE, frozen at the FIRST scan it appeared in (column 6 = severity, column 3 = KEV in the OSV guide)
cat "$D"/*.tsv | awk -F'\t' '$1 ~ /^CVE-/ && !($1 in s) { s[$1]=($6?$6:"unknown"); k[$1]=$3 } END { for (c in s) print c"\t"s[c]"\t"k[c] }' | sort > /tmp/sev.tsv
join -t $'\t' <(sort /tmp/lc.tsv) <(sort /tmp/sev.tsv) 2>/dev/null \
| join -t $'\t' -a1 - <(sort /srv/inventory/cve-published.tsv 2>/dev/null) \
| join -t $'\t' -a1 - <(sort /srv/inventory/cve-decisions.tsv 2>/dev/null) \
> "/srv/inventory/cve-mttr-$Q.tsv"

awk -F'\t' -v q="$Q" '
  function days(a,b,  c1,c2){ gsub(/-/," ",a); gsub(/-/," ",b); c1=mktime(a" 0 0 0"); c2=mktime(b" 0 0 0"); return (c2-c1)/86400 }
  $4=="closed" { n[$5]++; s[$5]+=days($2,$3); if ($7!="") { d[$5]++; sd[$5]+=days($7,$2) } }
  $4=="open"   { o[$5]++ }
  END {
    print "quarter", q
    printf "%-10s %6s %8s %10s %8s\n", "severity", "closed", "MTTR(d)", "detect(d)", "open"
    for (sev in n) printf "%-10s %6d %8.1f %10s %8d\n", sev, n[sev], s[sev]/n[sev], (d[sev]? sprintf("%.1f", sd[sev]/d[sev]) : "-"), o[sev]
    for (sev in o) if (!(sev in n)) printf "%-10s %6d %8s %10s %8d\n", sev, 0, "-", "-", o[sev]
  }' "/srv/inventory/cve-mttr-$Q.tsv"
EOF
sudo chmod 0755 /usr/local/sbin/cve-mttr.sh
sudo /usr/local/sbin/cve-mttr.sh
# quarter 2026-Q3
# severity   closed  MTTR(d)  detect(d)     open
# CRITICAL        4      3.2        1.5        0
# HIGH           19      9.8        2.1        2
# MEDIUM         51     24.0        3.0       14

MTTR(d) is T3 − T1 on average; detect(d) is T1 − T0. Put your own norm next to it (for example: CRITICAL ≤ 7 d, HIGH ≤ 30 d, MEDIUM ≤ 90 d) and the quarterly report writes itself: met or not, and if not, which CVEs were the outliers and why (from cve-decisions.tsv).

Step 5: the quarterly evidence

Q=$(date +%Y-Q$(( ($(date +%-m)-1)/3+1 )))
R=/srv/evidence/a88-$Q; sudo install -d "$R"
sudo /usr/local/sbin/cve-mttr.sh "$Q" | sudo tee "$R/kpi.txt"
sudo cp "/srv/inventory/cve-mttr-$Q.tsv" /srv/inventory/cve-decisions.tsv "$R/"
sudo cp /var/lib/cve-scan/*.tsv "$R/" 2>/dev/null          # the raw scans of the quarter
( cd "$R" && sudo sha256sum * > MANIFEST.sha256 )
sudo ssh-keygen -Y sign -f /etc/evidence/key -n a88 "$R/MANIFEST.sha256"

What the auditor gets: the KPI table, the table per CVE with four timestamps and the decision, the raw scans it was derived from, and a signature proving nothing has been changed since. That covers "obtained" (scans), "evaluated" (decisions) and "measures taken" (T3 + dpkg.log) — the three verbs of A.8.8 — and doubles as evidence for NIS2 Art. 21(2)(e).

Pitfalls

  • Scan gaps. A week without a scan (server off, cron broken) shifts T1 and T3. Also keep a line per scan "scan executed on …, N packages" — otherwise absence of a CVE can't be distinguished from absence of a scan.
  • CVEs that disappear without a patch. A package that gets removed takes its CVEs out of the scan: T3 without a fix. That's legitimate (remove is a measure), but record it as a decision.
  • Severity that changes. NVD adjusts scores. Freeze the severity at T1 in your table, otherwise a CVE shifts category between quarters.
  • Norms nobody approved. "CRITICAL ≤ 7 days" must be in your policy and signed off by management; otherwise the KPI is an opinion. One paragraph in the security policy suffices.
  • OS packages only. A.8.8 covers all "information systems in use": application dependencies, container images, firmware too. Start with OS packages, expand, and write in the scope what you don't cover yet.

What you still don't have

  • Fleet-wide. The scripts work per host. A CVE open on five hosts and closed on four has five lifecycles; the KPI across the fleet is a join you have to write yourself.
  • Continuous T3. Weekly scans give T3 to within a week; dpkg.log is exact but manual to link.
  • Decisions in one place. cve-decisions.tsv per host or central? Central, with a host column — and then it's become a database.

How monsys does it

monsys keeps the full lifecycle per CVE, per host and per application: first detection, publication date from the source, the decision (with VEX status and who made it), and the moment the CVE disappeared — with a resolution log that isn't overwritten on rescan, so MTTR stays measurable retroactively. The KPIs per severity are in the Trust Score (patch_hygiene) and in the monthly audit pack, with control ISO 27001 A.8.8 as a coverage percentage. A quarter in which the norm isn't met you see before the review, not in it.

FAQ

Which MTTR norm does an auditor expect?

No fixed one. ISO 27001 requires that you define a norm yourself, fitting your risk, and demonstrate that you meet it or explain deviations. Common in the industry: critical ≤ 7–14 days, high ≤ 30, medium ≤ 90. KEV CVEs: as fast as possible, usually ≤ 72 hours.

Isn't a scanner report evidence?

It proves "obtained" on one day. It doesn't prove you evaluated, nor that you acted, nor how long that took. The table with four timestamps per CVE plus the decision lines covers all three.

How long do I keep this?

At least three years (two audit cycles plus margin); the raw scans you may compress after a year, the KPI tables and decisions not. For NIS2 the same term applies as for other technical evidence.

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.