Skip to main content

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.

Least privilege

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​

  1. 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.ransomware and see whether any of yours are flagged.
  2. Add the deadline. k.due is 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.
  3. Scan something you actually run. You have real images on UBNT01 from Module 6: Gitea, and whatever else you added. docker images lists 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.