A command-line utility that compares two vulnerability scan exports and identifies exactly what changed between them. Designed for security engineers, vulnerability managers, and DevSecOps teams who need to track remediation progress across successive scans.
Most vulnerability scanners provide web-based dashboards for trending, but those require an active scanner connection and often lack the precision needed for audit-grade reporting. Common workarounds -- dumping both exports to CSV and running diff, or manually cross-referencing spreadsheets -- are error-prone and do not distinguish between a vulnerability that was actually fixed and one that simply disappeared because the host was not rescanned.
Vuln Scan Diff fills that gap by providing offline, local comparison of scan exports with a strict distinction between FIXED and UNVERIFIED findings, credential-failure detection, severity change tracking, and host-change analysis -- all without sending data to any external service.
| Scanner | Format | Extension |
|---|---|---|
| Tenable Nessus | .nessus XML export |
.nessus |
| Qualys VMDR | CSV vulnerability export | .csv |
Scanner type is auto-detected from file extension and content, or can be specified explicitly with --scanner.
pipx (recommended):
pipx install vuln-scan-diffvenv + pip:
python -m venv .venv
source .venv/bin/activate
pip install vuln-scan-diffRequires Python 3.11 or later.
vuln-diff compare OLD_SCAN NEW_SCAN [options]
| Option | Description |
|---|---|
OLD_SCAN |
Path to the previous (older) scan export |
NEW_SCAN |
Path to the latest (newer) scan export |
--scanner, -s |
Scanner type: auto, nessus, or qualys (default: auto) |
--output, -o |
Generate an Excel report at this path |
--json, -j |
Generate a JSON report at this path |
--html |
Generate an HTML report at this path |
--host-key |
Host identification strategy: auto, hostname, ip, asset-id (default: auto) |
--severity |
Comma-separated severity filter for output (e.g. critical,high) |
--include-informational |
Include informational findings in results |
--fail-on-new-critical |
Exit with code 3 if new critical findings are detected |
--no-color |
Disable colored terminal output |
--verbose |
Show additional details (all new findings, severity decreases) |
vuln-diff summary SCAN_FILE [options]
| Option | Description |
|---|---|
SCAN_FILE |
Path to a vulnerability scan export |
--scanner, -s |
Scanner type (default: auto) |
--no-color |
Disable colored terminal output |
Displays host counts, findings by severity and category, most vulnerable hosts, most common findings, OS distribution, and credentialed scan status.
vuln-diff credentials SCAN_FILE [options]
| Option | Description |
|---|---|
SCAN_FILE |
Path to a vulnerability scan export |
--scanner, -s |
Scanner type (default: auto) |
--no-color |
Disable colored terminal output |
Analyzes each host for signs of failed or missing credentialed scanning and reports which hosts have credential problems, with supporting evidence.
Basic comparison:
$ vuln-diff compare old.nessus new.nessus
Vulnerability Scan Comparison
Previous scan: old.nessus
Latest scan: new.nessus
Scanner: nessus
Hosts
Previous: 4
Latest: 5
New: 1
Missing: 0
Credential issues: 5
Findings
New: 12
Fixed: 7
Still open: 43
Unverified: 3
Severity changed: 2
New findings by severity
Critical: 1
High: 3
Medium: 5
Low: 3
New Critical & High Findings
Host Plugin ID Plugin Name Severity Port CVEs
10.0.1.50 (db01) 12345 SSL Certificate Expired Critical 443 CVE-2024-1234
10.0.1.12 (web03) 98765 Apache Struts2 RCE High 8080 CVE-2023-50164, CVE-2023-50148
10.0.1.30 (app02) 54321 OpenSSH Key Exchange High 22 CVE-2023-48795
Generate all report formats:
$ vuln-diff compare old.nessus new.nessus -o report.xlsx -j report.json --html report.html
Qualys CSV comparison:
$ vuln-diff compare --scanner qualys old_scan.csv new_scan.csv --output qualys_diff.xlsx
CI/CD gate with JSON output:
$ vuln-diff compare prev.nessus latest.nessus --json diff.json --fail-on-new-critical
$ echo $?
3
Summarize a single scan:
$ vuln-diff summary scan.nessus
Check credential status:
$ vuln-diff credentials scan.nessus
Every finding in the previous scan is matched against the latest scan using a stable identity key (see Finding Identity Logic below). Based on whether a match is found, findings are classified into these categories:
| Classification | Meaning |
|---|---|
| New | The finding identity exists in the latest scan but not in the previous scan. |
| Fixed | The finding identity exists in the previous scan but not in the latest, and the host was successfully rescanned with credentials. |
| Still Open | The same finding identity is present in both scans with no severity change. |
| Unverified | The finding is absent from the latest scan, but the tool cannot confirm remediation (see FIXED vs UNVERIFIED below). |
| Severity Changed | The finding identity exists in both scans but the severity level differs. Tracked as increased or decreased with CVSS details. |
| Reopened | A subset of New -- the finding carries historical metadata (last_fixed date) suggesting it was previously remediated and has reappeared. Flagged with low confidence. |
Hosts are also tracked: New, Missing, Possibly Renamed (matched by MAC or IP), and Possible Duplicates (shared IP or MAC in the same scan).
A finding's identity is computed as:
host_identity | protocol | port | plugin_id
Where host_identity is derived from the host identification strategy (--host-key):
- auto (default): asset ID > MAC address > FQDN > hostname > IP (picks the most stable available identifier)
- hostname: normalized hostname or FQDN
- ip: normalized IP address
- asset-id: scanner-assigned asset ID
Why not CVE-only? A single CVE can appear on multiple hosts, ports, and services. A single plugin can report multiple CVEs. Matching by CVE alone would produce false positives when the same CVE affects different services or hosts. The host+port+protocol+pluginID composite key ensures that a finding is tracked at the level of a specific vulnerability on a specific service of a specific host -- which is what matters for remediation tracking.
This is the core distinction that sets Vuln Scan Diff apart from simple diff tools.
FIXED is assigned only when all of the following are true:
- The host is present in the latest scan.
- The host shows no signs of credential failures.
- The latest scan was authenticated (not uncredentialed).
- The scan does not appear incomplete (e.g., the host had findings before but zero now, or the scan duration was extremely short).
UNVERIFIED is assigned when any of these conditions are not met. Specific reasons include:
- The host is missing entirely from the latest scan.
- Credentialed scanning failed or was disabled for the host.
- The latest scan was unauthenticated and the finding was medium severity or higher.
- The host had findings in the previous scan but zero findings in the latest scan (suggesting an incomplete scan rather than full remediation).
- The scan duration for the host was extremely short.
This tool does not determine that a vulnerability is fixed merely because it is absent from the latest export.
UNVERIFIED findings require manual review before they can be considered remediated. They are surfaced separately in the terminal report, Excel report, HTML report, and JSON output so they are never accidentally counted as fixed.
Generated with --output report.xlsx. The workbook contains 10 worksheets:
| Sheet | Contents |
|---|---|
| Executive Summary | Host counts, finding deltas, severity breakdown, credentialed scan stats |
| New Findings | All new findings with host, plugin, severity, port, CVEs, CVSS, synopsis, solution |
| Fixed Findings | All confirmed-fixed findings with the same columns |
| Still Open | All findings present in both scans with unchanged severity |
| Unverified | All findings that cannot be confirmed fixed, with unverified reasons |
| Severity Changes | Findings whose severity increased or decreased, with previous/current CVSS |
| New Hosts | Hosts appearing in the latest scan but not the previous |
| Missing Hosts | Hosts present in the previous scan but absent from the latest |
| Credential Issues | Hosts with detected credential scanning failures and evidence |
| All Current Findings | Complete inventory of all findings in the latest scan |
All cells are formatted with professional styling (severity-colored headers, wrapped text, auto-sized columns) and every value is sanitized against formula injection.
Generated with --json report.json. Structure:
{
"metadata": {
"tool_version": "1.0.0",
"scanner": "nessus",
"previous_scan": "old.nessus",
"latest_scan": "new.nessus",
"generated_at": "2025-01-15T10:30:00+00:00"
},
"summary": {
"previous_hosts": 4,
"latest_hosts": 5,
"new_hosts": 1,
"missing_hosts": 0,
"new_findings": 12,
"fixed_findings": 7,
"still_open": 43,
"unverified": 3,
"severity_changed": 2,
"credential_issues": 5,
"new_critical_findings": 1
},
"findings": {
"new": [
{
"host_ip": "10.0.1.50",
"host_hostname": "db01",
"plugin_id": "12345",
"plugin_name": "SSL Certificate Expired",
"severity": "critical",
"port": "443",
"protocol": "tcp",
"cves": ["CVE-2024-1234"],
"cvss_v2": null,
"cvss_v3": 9.8
}
],
"fixed": [...],
"still_open": [...],
"unverified": [...],
"severity_changed": [
{
"host_ip": "10.0.1.12",
"host_hostname": "web03",
"plugin_id": "54321",
"plugin_name": "Apache HTTP Server Version",
"severity": "high",
"port": "443",
"protocol": "tcp",
"cves": [],
"cvss_v2": null,
"cvss_v3": 7.5,
"previous_severity": "medium",
"current_severity": "high",
"direction": "increased"
}
]
},
"hosts": {
"new": [...],
"missing": [...],
"possible_renames": [...],
"credential_issues": [
{
"host_ip": "10.0.1.30",
"host_hostname": "app02",
"status": "likely",
"evidence": ["Authentication Failure - Local Checks Not Run"]
}
]
}
}The JSON output is designed for CI/CD pipeline consumption. The --fail-on-new-critical flag provides a simple exit-code mechanism for build gates.
Generated with --html report.html. Features:
- Self-contained: Single file with embedded CSS and inline JavaScript. No external dependencies, no CDN, no analytics, no network requests.
- Offline: Opens directly in any browser without an internet connection.
- Searchable: Each table has a search box for filtering by host, plugin, CVE, etc.
- Filterable: Severity filter buttons (All, Critical, High, Medium, Low, Informational) on each findings table.
- Expandable details: Click to reveal synopsis, solution, and description for each finding.
- Printable: Print stylesheet hides interactive controls and shows all rows.
- Security: All scanner-controlled content is HTML-escaped to prevent injection.
Vuln Scan Diff is designed to handle sensitive vulnerability data safely:
- No network access: The tool never makes network requests. All processing is local.
- No telemetry: No usage data, scan contents, or metadata are sent anywhere.
- XXE protection: Nessus XML is parsed with
defusedxml, which blocks entity expansion, DTD processing, and other XML attack vectors. - Formula injection prevention: All values written to Excel cells are sanitized -- strings starting with
=,+,-, or@are prefixed with a single quote. - HTML escaping: All scanner-controlled content in HTML reports is escaped with
html.escape(quote=True). - Secret redaction: Plugin output displayed in verbose terminal mode is scanned for common secret patterns (passwords, API keys, bearer tokens, private keys, AWS keys) and replaced with
[REDACTED].
- Streaming XML parsing: Nessus
.nessusfiles are parsed withiterparse-- elements are processed and cleared incrementally, so only one host's worth of data is in memory at a time. A 2 GB.nessusfile does not require 2 GB of RAM. - Handles large scans: Tested with 100,000+ findings and 10,000+ hosts.
- Qualys CSV: Processed line by line using Python's
csvmodule.
- Scanner type must be the same for both files. Comparing a Nessus export against a Qualys export is not supported.
- Host rename detection relies on MAC and IP matching. If both changed between scans, the host will appear as both missing and new.
- Credential analysis is rule-based and uses Nessus-specific heuristics (plugin IDs, plugin families, output patterns). It may produce false positives or miss subtle credential failures.
- The
last_fixeddate used for reopened-finding detection is only available if the scanner includes it in the export. - Port-agnostic findings (findings not associated with a specific port) use an empty port component in the identity key.
- Severity normalization maps scanner-specific severity labels (e.g., Nessus severity 0-4, Qualys 1-5) to a common scale but cannot account for custom severity thresholds configured in the scanner.
- The tool compares two point-in-time exports. It does not access scanner databases or trend data.
git clone https://github.com/your-org/vuln-scan-diff.git
cd vuln-scan-diff
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"pytest
pytest --cov=src/vuln_scan_diff
pytest -m "not slow" # skip slow testsThe test suite contains 249 tests across 10 modules, using synthetic fixture files (small .nessus XML and Qualys CSV files) -- no real scan data is needed to run the tests.
- Fork the repository.
- Create a feature branch.
- Write tests for new functionality.
- Ensure all tests pass with
pytest. - Submit a pull request.
Please keep changes focused and ensure backward compatibility with existing CLI flags and report formats.
MIT License. See LICENSE for the full text.