13.3 Hundreds of findings, and what to do first
You have several hundred findings and a working day. This lesson is how you decide what goes in it.
Lesson 2.3 promised you this moment. When you wrote kev-check.py I said
that "when vulnerability counts get overwhelming (and in Module 13 you'll see
scanners hand you hundreds per host), KEV is the shortlist that tells you
what to fix first." The count is now overwhelming, on schedule, and this is
the shortlist.
Lesson 6.9 made the other half of the promise: that "which of my machines has a finding that CISA lists as actively exploited" is a join between two tables, and a far better tool than scrolling a report. You are about to write that exact join.
First, look at the pile honestly
Still on UBNT01, in your tmux session:
# Count the findings by severity, straight out of the JSON.
# group_by needs sorted input, which .Severity gives us here by
# accident of the alphabet; the counts are what matter.
jq -r '[.Results[].Vulnerabilities[]] | group_by(.Severity)[] | "\(.[0].Severity) \(length)"' \
~/nginx-1.20-scan.json
Expect something close to this, though your exact numbers will differ because the databases update daily:
CRITICAL 33
HIGH 184
LOW 228
MEDIUM 307
UNKNOWN 20
The naive plan is to start at CRITICAL and work down. Thirty-three items, maybe a day and a half. It feels responsible. It is the wrong plan, and by the end of this lesson you will be able to say precisely why.
The question severity cannot answer
CVSS, from lesson 13.1, scores the flaw. It cannot know whether anybody has ever used it.
That distinction turns out to be enormous. The overwhelming majority of published vulnerabilities are never exploited by anyone, ever. They are real flaws, correctly scored, that no attacker has found worth the effort. A small minority are being used against real organisations right now.
CISA publishes the list of that small minority. The Known Exploited Vulnerabilities catalog, the same feed your Python script read in lesson 2.3, is not a severity ranking. It is a statement of fact: these have been observed in actual attacks.
That makes it the single best prioritisation input available, and it is free.
Get both lists into one place
You have findings in JSON and a catalog in JSON. Two lists that need comparing is a database question, which is what lesson 6.9 was for.
Download the catalog. No API key, no account:
curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
-o ~/kev.json
How you know it worked:
# The catalog's own count of its entries, and the date it was built.
# Expect a count in the low thousands and a recent date.
jq -r '"\(.count) entries, catalog version \(.catalogVersion)"' ~/kev.json
If that prints an error instead, the download failed and the file probably
contains an HTML error page. head -c 200 ~/kev.json will show you.
Flatten both to CSV, because that is what a database imports cleanly.
This is jq doing the same job awk did in lesson 2.2: pull named fields out
of each record, in order, one line each.
# Findings. The // "" means "if FixedVersion is missing, use an
# empty string". Without it, jq stops at the first finding that
# has no fix available.
jq -r '.Results[].Vulnerabilities[]
| [.VulnerabilityID, .PkgName, .InstalledVersion,
(.FixedVersion // ""), .Severity, (.Status // "")]
| @csv' ~/nginx-1.20-scan.json > ~/findings.csv
# The KEV catalog.
jq -r '.vulnerabilities[]
| [.cveID, .vendorProject, .product,
.knownRansomwareCampaignUse, .dueDate]
| @csv' ~/kev.json > ~/kev.csv
How you know it worked:
# Line counts. findings.csv should match your Total from 13.2,
# and kev.csv should match the count jq printed above.
wc -l ~/findings.csv ~/kev.csv
# And eyeball one line of each, to confirm the columns landed
# in the order you asked for.
head -1 ~/findings.csv
head -1 ~/kev.csv
Build the database
# Creates vuln.db in your home directory and loads both files.
# .mode csv tells SQLite how to read them; .import does the work.
sqlite3 ~/vuln.db <<'SQL'
CREATE TABLE findings(cve TEXT, package TEXT, installed TEXT,
fixed_in TEXT, severity TEXT, status TEXT);
CREATE TABLE kev(cve TEXT, vendor TEXT, product TEXT,
ransomware TEXT, due TEXT);
.mode csv
.import /home/sam/findings.csv findings
.import /home/sam/kev.csv kev
SQL
Substitute your own username for sam in those two paths. SQLite's
.import does not expand ~; it looks for a file literally named ~ and
prints Error: cannot open "~/findings.csv". The table is still created, just
empty, so the script carries on and every query afterwards returns nothing.
Read the output of that block rather than assuming it worked.
How you know it worked:
# Both tables, with row counts. Neither should be 0.
sqlite3 ~/vuln.db "SELECT 'findings: '||COUNT(*) FROM findings;
SELECT 'kev: '||COUNT(*) FROM kev;"
Expect the same two numbers wc -l gave you. A count of 0 means the path
was wrong, not that the data was empty; fix the path and re-run, after
rm ~/vuln.db so you start clean.
Now ask three questions
Question one: how bad does it look? The same count as before, in SQL.
sqlite3 -header -column ~/vuln.db \
"SELECT severity, COUNT(*) AS n FROM findings GROUP BY severity ORDER BY n DESC;"
severity n
-------- ---
MEDIUM 307
LOW 228
HIGH 184
CRITICAL 33
UNKNOWN 20
Question two: how many criticals could I even fix today? A finding with no available fix is not work, it is a decision, and lesson 13.8 deals with those.
sqlite3 -header -column ~/vuln.db \
"SELECT COUNT(*) AS critical_fixable FROM findings
WHERE severity='CRITICAL' AND status='fixed';"
critical_fixable
----------------
24
Twenty-four, not thirty-three. Nine of your "critical" items have no fix to apply.
Question three, the one that matters. Which of these is anybody actually exploiting? This is the join lesson 6.9 promised: match every finding against the catalog on the CVE, and keep only the rows that appear in both.
sqlite3 -header -column ~/vuln.db \
"SELECT DISTINCT f.cve, f.package, f.severity, f.fixed_in
FROM findings f
JOIN kev k ON f.cve = k.cve
ORDER BY f.package;"
cve package severity fixed_in
-------------- ------------- -------- ---------------------
CVE-2023-4911 libc-bin HIGH 2.31-13+deb11u7
CVE-2023-4911 libc6 HIGH 2.31-13+deb11u7
CVE-2025-27363 libfreetype6 HIGH 2.10.4+dfsg-1+deb11u2
CVE-2023-44487 libnghttp2-14 HIGH 1.43.0-1+deb11u1
CVE-2023-4863 libwebp6 HIGH 0.6.1-2.1+deb11u2
Five rows, four distinct vulnerabilities. That is your work queue.
Look at what just happened
sqlite3 ~/vuln.db \
"SELECT COUNT(*)||' findings' FROM findings;
SELECT COUNT(DISTINCT cve)||' distinct CVEs' FROM findings;
SELECT COUNT(DISTINCT f.cve)||' in KEV' FROM findings f JOIN kev k ON f.cve=k.cve;"
772 findings
500 distinct CVEs
4 in KEV
Seven hundred and seventy-two findings became four things to do. And notice what the four are:
Not one of them is CRITICAL. Every one is HIGH. If you had worked the severity queue from the top, you would have spent a day and a half on thirty-three critical findings that nobody has ever exploited, and the four being used in real attacks would have been sitting on page two, untouched, at the end of it.
That is the lesson. Say it in an interview and people will notice:
Severity tells you how bad a flaw is in theory. Exploitation tells you what is happening in practice. Prioritise on the second, then use the first to break ties.
The fix is usually smaller than the list
Look at the fixed_in column: all four have fixes available. You do not need
to patch four packages individually. Every one of them is a Debian package in
a base image that is years old.
# The current image, rather than the 2021 one.
docker pull nginx:latest
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v trivycache:/root/.cache \
aquasec/trivy:latest image --scanners vuln nginx:latest
Do not take my word for the improvement, or your own impression of the summary line. Measure it, the same way you measured the first image:
# Save the new scan the same way you saved the old one.
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v trivycache:/root/.cache \
aquasec/trivy:latest image --scanners vuln -f json nginx:latest \
> ~/nginx-latest-scan.json
# Flatten it, same jq as before.
jq -r '.Results[].Vulnerabilities[]
| [.VulnerabilityID, .PkgName, .InstalledVersion,
(.FixedVersion // ""), .Severity, (.Status // "")]
| @csv' ~/nginx-latest-scan.json > ~/findings-latest.csv
# Load it into a second table and compare the two directly.
# Substitute your username in the .import path again.
sqlite3 ~/vuln.db <<'SQL'
CREATE TABLE IF NOT EXISTS findings_new(cve TEXT, package TEXT, installed TEXT,
fixed_in TEXT, severity TEXT, status TEXT);
DELETE FROM findings_new;
.mode csv
.import /home/sam/findings-latest.csv findings_new
SQL
How you know it worked, and this single table is the point of the whole lesson:
sqlite3 -header -column ~/vuln.db "
SELECT 'nginx:1.20' AS image, COUNT(*) AS findings,
(SELECT COUNT(DISTINCT f.cve) FROM findings f JOIN kev k ON f.cve=k.cve) AS in_kev
FROM findings
UNION ALL
SELECT 'nginx:latest', COUNT(*),
(SELECT COUNT(DISTINCT n.cve) FROM findings_new n JOIN kev k ON n.cve=k.cve)
FROM findings_new;"
image findings in_kev
------------ -------- ------
nginx:1.20 772 4
nginx:latest 327 0
Your two findings numbers will differ from mine by a few, for the same
daily-database reason as before. What should hold is the shape: the newer
image has substantially fewer findings, and in_kev should be 0.
If your in_kev for the new image is not 0, that is genuinely interesting
rather than a mistake, and it means a vulnerability in the current image is
being actively exploited. Look at which one, with the join from before
against findings_new. That is a real finding and it belongs in your journal.
One upgrade, four exploited vulnerabilities gone. This is the most common real answer in container work, and it is why "how old is this base image" is a better first question than any individual CVE.
Worth noticing what you did not do. You did not need root, a credential, or an agent on anything. Reading a package list and comparing it to a public catalog is an unprivileged operation, and it answered the most important question in the module.
When a tool asks for more access than the question needs, that is worth a raised eyebrow. Sometimes the answer is genuinely yes, which is lesson 13.6.
Make it yours
- Add the ransomware column. The KEV catalog records whether a
vulnerability is known to be used in ransomware campaigns. Extend the join
to show
k.ransomwareand see whether any of yours are flagged. - Add the deadline.
k.dueis the date US federal agencies are required to have fixed it by. It is not binding on you, but it is a published opinion about urgency from people who see the incident data. - Scan something you actually run. You have real images on UBNT01 from
Module 6: Gitea, and whatever else you added.
docker imageslists them. Scan one and run the same three questions against it. That result is about your lab rather than a teaching example, and it belongs in your journal.
What you take from this
You turned 772 findings into 4 by asking a better question, and then fixed
all 4 with one upgrade. You also now have vuln.db, which lesson 13.5 loads
network findings into so you can ask the same question about whole machines
instead of one image.