Reading scan results
How to compare two vulnerability scans from the command line
Find what was fixed, what is new and what is still open between two Nessus CSV exports with grep, cut, sort and comm, then check for the two things that make a finding look fixed when it isn't.
By Brandon Sooknanan, Founder, Varaxon Technologies · · 6 min read
Last month's scan had 9 findings and this month's has 5. Does that mean 4 were fixed? Not necessarily. This guide shows how to compare two Nessus CSV exports line by line, so you can see exactly which findings went away, which are new and which are still open, and then how to check whether the ones that went away were really fixed.
You need grep, cut, sort, comm and awk, which come with Linux and macOS, plus Python 3 for two optional steps. We ran every command on 9 November 2026 against two sample Nessus exports we built for this guide, one from October and one from November. The example output below comes from those files.
1. Turn each export into one line per finding
A finding is the same finding in both scans when the host, the Nessus plugin ID and the port match. The command below pulls those out of a default Nessus CSV, where Plugin ID is column 1, Risk is column 4, Host is column 5, Protocol is column 6 and Port is column 7. It skips plugins with a Risk of None, which are informational.
| What it does | Command |
|---|---|
| Make the list for the older scan | grep -E '^"[0-9]+",' october.csv | cut -d, -f1,4,5,6,7 | tr -d '"' | awk -F, '$2 != "None" {print $3","$1","$4"/"$5}' | LC_ALL=C sort -u > october.txt |
| Make the list for the newer scan | grep -E '^"[0-9]+",' november.csv | cut -d, -f1,4,5,6,7 | tr -d '"' | awk -F, '$2 != "None" {print $3","$1","$4"/"$5}' | LC_ALL=C sort -u > november.txt |
| Count the findings in each | wc -l october.txt november.txt |
Nessus puts long descriptions and plugin output on several lines inside quotes. The grep keeps only lines that start a new row with a quoted plugin ID, so those extra lines are dropped. cut is safe here because the first seven columns never contain commas. A line looks like 10.0.0.5,104743,tcp/443. Our October file had 9 findings and our November file had 5.
2. Split them into fixed, new and still open
comm compares two sorted lists and prints three columns: lines only in the first, lines only in the second, and lines in both. The flags hide the columns you don't want.
| What it does | Command |
|---|---|
| Gone from the newer scan (looks fixed) | LC_ALL=C comm -23 october.txt november.txt > fixed.txt |
| New in the newer scan | LC_ALL=C comm -13 october.txt november.txt > new.txt |
| In both scans (still open) | LC_ALL=C comm -12 october.txt november.txt > open.txt |
| Count each list | wc -l fixed.txt new.txt open.txt |
Our sample gave 5 findings gone, 1 new and 4 still open. The new one was the SSH Terrapin weakness (CVE-2023-48795) on 10.0.0.12. LC_ALL=C makes sort and comm use the same order. Without it, comm can complain that a file is not sorted.
3. Check for hosts that weren't scanned
If a host was switched off, moved or left out of the scan range, every finding on it disappears from the newer scan. That isn't a fix. List the hosts in each export and look for any that dropped out.
| What it does | Command |
|---|---|
| Hosts in the older scan | grep -E '^"[0-9]+",' october.csv | cut -d, -f5 | tr -d '"' | LC_ALL=C sort -u > october-hosts.txt |
| Hosts in the newer scan | grep -E '^"[0-9]+",' november.csv | cut -d, -f5 | tr -d '"' | LC_ALL=C sort -u > november-hosts.txt |
| Hosts that dropped out | LC_ALL=C comm -23 october-hosts.txt november-hosts.txt > gone-hosts.txt |
| Remove their findings from the fixed list | awk -F, 'NR==FNR {gone[$1]; next} !($1 in gone)' gone-hosts.txt fixed.txt > fixed-scanned.txt |
In our sample, 10.0.0.20 was in October's scan but not November's, so its two findings (TLS 1.0 and a self-signed certificate) only looked fixed. That left 3 findings in fixed-scanned.txt. Find out why the host is missing, and scan it again before you report on it.
4. Check that both scans logged in
A credentialed scan logs in to each host and checks installed packages and patches. If the login fails next time, those local checks don't run and their findings disappear, even though nothing was patched. Nessus records the result for each host in plugin 19506 (Nessus Scan Information), in a line that reads Credentialed checks : yes or no.
| What it does | Command |
|---|---|
| Print each host and whether credentialed checks ran | python3 -c 'import csv,sys; [print(r["Host"], l.split(":", 1)[1].strip()) for r in csv.DictReader(open(sys.argv[1], newline="")) if r["Plugin ID"] == "19506" for l in r["Plugin Output"].splitlines() if l.startswith("Credentialed checks")]' november.csv |
Run it on both files. In our sample, every host logged in during October, but 10.0.0.12 printed no in November. Its OpenSSH package finding needs a local check, so it went missing because the login failed, not because OpenSSH was updated. Fix the scan credentials and rescan before you count it. Credentialed vs non-credentialed scans covers why this matters.
5. Put names on the results
Plugin IDs are hard to read. This prints the risk, host and finding name for each line in a list. Use the older export for fixed findings and the newer export for new ones.
| What it does | Command |
|---|---|
| Name the findings that were really fixed | python3 -c 'import csv,sys; keys = {tuple(l.strip().split(",")) for l in open(sys.argv[2])}; rows = {(r["Host"], r["Plugin ID"], r["Protocol"] + "/" + r["Port"]): r for r in csv.DictReader(open(sys.argv[1], newline=""))}; [print(rows[k]["Risk"], k[0], rows[k]["Name"], sep="\t") for k in sorted(keys) if k in rows]' october.csv fixed-scanned.txt |
| Name the new findings | Run the same command with november.csv new.txt at the end. |
In our sample this listed three findings. Setting aside the OpenSSH finding on 10.0.0.12 from step 4, two were really fixed: MS17-010 (EternalBlue) on 10.0.0.10, rated Critical, and TLS 1.0 on 10.0.0.5. Five findings disappeared, but only two of them were fixed.
Putting it to use
- Compare scans from the same scanner with the same policy. A Nessus scan and a Qualys scan name and check issues differently, so their lists won't line up.
- Keep the dated lists with both exports. A list of what was fixed, with the scan it was confirmed in, is useful evidence for audits and client reviews.
- Run steps 3 and 4 every time. They catch the two most common reasons a finding looks fixed when it isn't.
- For other scanners, the same method works once you know which columns hold the host, the check ID and the port.
To see which of the new and still-open findings are being exploited, run them through how to check your scan results against CISA KEV. We built proof of fix into Varaxon Scan Hub to do this comparison on every report. It marks each finding as fixed, new or still open, and it doesn't count a finding as fixed when the host wasn't rescanned or the login failed. Every new account gets a 14-day free trial of Core, with no card needed. See sample reports.
Common questions
Why not just compare the finding counts?
Counts hide what changed. A month with 3 fixes and 3 new findings has the same count as a month where nothing happened. Counts also fall when a host is missed or a login fails.
Why does comm say a file is not in sorted order?
The lists were sorted with different locale settings. Make both with LC_ALL=C sort -u and run comm with LC_ALL=C too.
Does this work if I changed the Nessus CSV columns?
Yes, if you change the column numbers to match. Check the header row with head -1 october.csv. The two Python steps read columns by name, so they work whatever the order.
Turn your next scan export into a client-ready report.
Run a free pre-flight check