Every export queried the vulnerabilities table raw, so all four disagreed with the dashboard they were exported from, in the same three ways: - Findings on decommissioned and inactive assets were counted. Those hosts are deliberately hidden on screen, and the gap grows with every machine retired. - EOL- and NESSUS-PLUGIN- pseudo-CVEs were counted as CVEs. They are real work items but they are not vulnerabilities, and they have their own surface in the UI. - The executive summary counted severities across ALL statuses, so a remediated estate still reported hundreds of "Critical Severity" — the patched ones. A reader takes those as outstanding work. They are now explicitly labelled and scoped to open findings. "Top Priority Risks" was the worst of it: a LIMIT 5 with no ORDER BY, so the database returned any five critical rows it liked under a heading promising the five that matter most. It now ranks by priority score, then CVSS. Two exports could also take the server down on a large estate. Patching Progress put every patched finding of the last 30 days in the table — one reconcile here closed 14703 at once — and the ISO report loaded every non-compliant finding into memory just to call len() on it, while printing 20. Both now count in the database and list a bounded, ordered page. The CSV streams in batches instead of materialising the whole file first. The scope rule lives in app/services/report_scope.py so the reports cannot drift apart again, and so it can be tested without the web stack — tests/test_report_scope.py asserts it against the emitted SQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
56 lines
1.9 KiB
Python
56 lines
1.9 KiB
Python
"""Report scope + ranking — run: python tests/test_report_scope.py
|
|
|
|
Two things the reports got wrong silently, so both are pinned here:
|
|
|
|
1. Scope. They queried the table raw, counting findings on decommissioned
|
|
assets and counting EOL-/NESSUS-PLUGIN- pseudo-CVEs as CVEs — so the
|
|
exported numbers disagreed with the dashboard for the same estate.
|
|
2. "Top Priority Risks" had a LIMIT and no ORDER BY, so the five rows under
|
|
that heading were whichever five the database felt like returning.
|
|
|
|
Checked by reading the emitted SQL — no database needed.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
|
from app.services.report_scope import scoped as _scoped
|
|
|
|
|
|
def _sql(q):
|
|
return str(q.statement.compile(compile_kwargs={"literal_binds": True})).lower()
|
|
|
|
|
|
def demo():
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import create_engine
|
|
engine = create_engine("sqlite://")
|
|
db = Session(engine)
|
|
|
|
sql = _sql(_scoped(db))
|
|
# Inactive/decommissioned assets are excluded, orphans (no asset) kept.
|
|
assert "left outer join assets" in sql
|
|
assert "active" in sql
|
|
assert "assets.id is null" in sql
|
|
# Pseudo-CVEs excluded by prefix.
|
|
assert "eol-" in sql and "nessus-plugin-" in sql
|
|
|
|
# The ranked query really carries an ORDER BY — the bug was its absence.
|
|
top = (_scoped(db)
|
|
.filter(Vulnerability.severity == VulnerabilitySeverity.critical,
|
|
Vulnerability.status == VulnerabilityStatus.open)
|
|
.order_by(Vulnerability.priority_score.desc().nullslast())
|
|
.limit(5))
|
|
tsql = _sql(top)
|
|
assert "order by" in tsql and "priority_score desc" in tsql
|
|
assert "limit 5" in tsql
|
|
|
|
db.close()
|
|
print("report scope OK")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
demo()
|