Files
vulncheck/tests/test_urs_bulk_scoring.py
T
vulncheck e7ad196d0e perf(urs): score the fleet in one pass instead of once per asset
Feldmeldung: Dashboard und Compliance-Seite brauchen seit dem Einspielen
der Mittwochs-Commits acht Sekunden statt drei. Die Logs zeigen, woran es
liegt, und es sind nicht die Mittwochs-Commits: von allen Calls beider
Seiten ist genau einer langsam, /api/v1/compliance/urs braucht 4,9 bis 6,0
Sekunden, alles andere zusammen unter 300 ms. Beide Seiten haengen ihr
gesamtes Promise.all daran, also wartet die ganze Seite mit.

list_urs hat pro Asset gescort: eine Asset-Abfrage, eine Vulnerability-
Abfrage, eine Result-Abfrage, dann pro Policy ein Lazy-Load der Checks
plus eine Impact-Abfrage. Nachgemessen mit einer 120-Host-Flotte, drei
Policies, 120 Checks: 961 Statements fuer einen Aufruf. Auf SQLite im
selben Prozess sind das 600 ms und faellt niemandem auf; gegen ein
Postgres ueber einen Socket sind es bei 3 ms Round-Trip 5,3 Sekunden —
exakt der gemessene Wert.

Deshalb war es vorher schnell und ist es jetzt nicht: der teure Zweig
zaehlt erst, seit SCA-Daten und Impact-Bewertungen geladen sind. Ohne
gecachte Checks kehrt _weighted_score_for_policy sofort zum gespeicherten
Score zurueck und die zwei Abfragen pro Policy entfallen. Wer URS und
Compliance anbindet, schaltet den Pfad scharf.

compute_urs_bulk() macht denselben Score in fuenf Abfragen, unabhaengig
von der Flottengroesse: Assets, offene Vulnerabilities als Spaltenabfrage,
Results, deren Checks, die Impact-Tabelle. Der Lesepfad schreibt dabei
nicht mehr — bisher hat compute_ass weighted_score auf den geladenen Zeilen
gesetzt, was ein GET per Autoflush zu UPDATEs gebracht hat. Persistiert
wird nur noch, wo es gewollt ist (recompute-all und der naechtliche Job).
compute_avs/compute_ass/compute_urs bleiben als duenne Wrapper ueber
denselben Code, damit Einzel- und Massenpfad nicht auseinanderlaufen
koennen. Die CPR-Formel steht jetzt als cpr_score() auf Modulebene, sodass
der Massenpfad sie ueber eine Spaltenabfrage laufen lassen kann, statt pro
Finding ein ORM-Objekt zu bauen — eine Quelle, keine zweite Kopie.

Dabei aufgefallen: /compliance/assets hat get_compliance_summary()
aufgerufen, das Ergebnis weggeworfen und dieselbe Aggregation nochmal
inline gemacht — beide Kopien mit einer Hostname-Abfrage pro Asset in der
Schleife. Zwei N+1-Schleifen fuer eine Liste. Jetzt eine gemeinsame
_asset_rollups(), zwei Abfragen.

Messung 265 Assets, 3 ms Round-Trip: 2121 Statements / 11,7 s → 6
Statements / 0,18 s, Ergebnis Zeile fuer Zeile identisch.

- tests/test_urs_bulk_scoring.py pinnt Statement-Zahl unabhaengig von der
  Flottengroesse, Gleichheit mit dem Einzelpfad in allen drei avs_modes,
  und dass ein Lesen nichts schreibt
- tests/test_compliance_rollup_queries.py pinnt dasselbe fuer das Rollup
2026-08-28 14:24:02 +02:00

208 lines
8.1 KiB
Python

"""URS scores the fleet in one pass — run: python tests/test_urs_bulk_scoring.py
/compliance/urs is on the critical path of two pages: the dashboard and
the compliance page both block their whole Promise.all on it. It scored
asset by asset — per asset one Asset lookup, one vulnerability query, one
result query, then a lazy checks load plus an impact query per policy. On
localhost that is invisible; against a Postgres over a socket a 120-host
fleet with SCA data loaded issued ~960 statements and put five seconds in
front of every page load. Field report: 3 s before URS/compliance data
existed, 8 s after.
Pinned here: the statement count does not grow with the fleet, the bulk
pass returns exactly what the per-asset path returned, and a read never
writes.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
from datetime import datetime # noqa: E402
from sqlalchemy import create_engine, event # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
import app.models # noqa: F401,E402 (registers every mapper)
from app.models.base import Base # noqa: E402
from app.models.asset import Asset, AssetSource # noqa: E402
from app.models.compliance import ( # noqa: E402
ComplianceCheck, ComplianceImpact, ComplianceResult,
)
from app.models.vulnerability import ( # noqa: E402
Vulnerability, VulnerabilitySeverity, VulnerabilityStatus,
)
from app.services.urs_service import compute_urs, compute_urs_bulk # noqa: E402
CHECK_IDS = ["1.1.1", "1.1.2", "1.1.3", "1.1.4"]
def _db(hosts=12):
"""A fleet covering every branch the scorer has to fall back through."""
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
# Impacts for three of the four checks — the fourth exercises the
# neutral-50 default; the "other" benchmark exercises the cross-OS
# fallback when the policy has no exact match.
db.add(ComplianceImpact(cis_id="1.1.1", benchmark="cis_win11", impact=100))
db.add(ComplianceImpact(cis_id="1.1.2", benchmark="cis_win11", impact=10))
db.add(ComplianceImpact(cis_id="1.1.3", benchmark="cis_ubuntu22_04", impact=80))
for i in range(hosts):
asset = Asset(
hostname=f"host-{i}",
ip_address="10.0.0.1",
source=AssetSource.MANUAL,
criticality=["low", "normal", "high", "critical"][i % 4],
)
db.add(asset)
db.flush()
# Every fourth host has no findings at all → AVS None.
if i % 4 != 3:
db.add(Vulnerability(
asset_id=asset.id, cve_id=f"CVE-2026-{i}00",
status=VulnerabilityStatus.open,
severity=VulnerabilitySeverity.critical,
cvss_score=9.8, epss_score=0.4, epss_percentile=0.9,
detected_at=datetime.utcnow(),
))
# No CVSS: the severity floor has to carry it.
db.add(Vulnerability(
asset_id=asset.id, cve_id=f"CVE-2026-{i}01",
status=VulnerabilityStatus.open,
severity=VulnerabilitySeverity.high,
cvss_score=None, epss_score=0.01, epss_percentile=None,
detected_at=datetime.utcnow(),
))
# Patched findings must not count.
db.add(Vulnerability(
asset_id=asset.id, cve_id=f"CVE-2026-{i}02",
status=VulnerabilityStatus.patched,
severity=VulnerabilitySeverity.critical,
cvss_score=10.0, epss_score=0.9, epss_percentile=0.99,
detected_at=datetime.utcnow(),
))
# Policy with cached checks → impact-weighted.
weighted = ComplianceResult(
asset_id=asset.id, policy_id="cis_win11", total_checks=4,
pass_count=2, fail_count=1, score=66.67,
)
db.add(weighted)
db.flush()
for check_id, outcome in zip(
CHECK_IDS, ["passed", "failed", "passed", "not applicable"]
):
db.add(ComplianceCheck(
result_id=weighted.id, check_id=check_id, result=outcome,
))
# Policy without cached checks → the stored score stands.
db.add(ComplianceResult(
asset_id=asset.id, policy_id="wazuh_default_policy",
total_checks=10, pass_count=5, fail_count=5, score=50.0,
))
# Every third host also carries a policy whose checks carry no
# impact rating at all → falls back to the stored score.
if i % 3 == 0:
unrated = ComplianceResult(
asset_id=asset.id, policy_id="pol_unrated", total_checks=1,
pass_count=1, fail_count=0, score=100.0,
)
db.add(unrated)
db.flush()
db.add(ComplianceCheck(
result_id=unrated.id, check_id="9.9.9", result="passed",
))
db.commit()
return engine, db
def _counting(engine):
stmts = []
event.listen(
engine, "before_cursor_execute",
lambda conn, cur, stmt, *a: stmts.append(stmt),
)
return stmts
def test_bulk_matches_per_asset():
"""The bulk pass is the per-asset path, not an approximation of it."""
for mode in ("hybrid", "avg", "max"):
engine, db = _db()
bulk = compute_urs_bulk(db, None, avs_mode=mode, persist_snapshot=False)
for asset_id in list(bulk):
db.expire_all()
one = compute_urs(
db, asset_id, avs_mode=mode, persist_snapshot=False,
)
assert bulk[asset_id] == one, (
f"{mode} asset={asset_id}: bulk {bulk[asset_id]} != {one}"
)
scored = [r for r in bulk.values() if r["urs"] is not None]
assert scored, "fixture produced no scores — the test proves nothing"
print("✅ bulk pass matches the per-asset result in every avs_mode")
def test_statement_count_is_flat():
"""The whole point: statements must not scale with the fleet."""
counts = {}
for hosts in (12, 120):
engine, db = _db(hosts=hosts)
stmts = _counting(engine)
rows = compute_urs_bulk(db, None, persist_snapshot=False)
assert len(rows) == hosts
counts[hosts] = len(stmts)
assert counts[12] == counts[120], (
f"statement count grew with the fleet: {counts}"
)
assert counts[120] <= 8, f"expected a handful of queries, got {counts[120]}"
print(f"✅ {counts[120]} statements for 120 hosts, same as for 12")
def test_read_path_writes_nothing():
"""A GET must not UPDATE — the old path rewrote weighted_score."""
engine, db = _db()
stmts = _counting(engine)
compute_urs_bulk(db, None, persist_snapshot=False)
db.flush()
written = [s for s in stmts if s.lstrip()[:6].upper() in ("UPDATE", "INSERT")]
assert not written, f"read path issued writes: {written[:3]}"
print("✅ scoring for display leaves the compliance rows untouched")
def test_snapshot_still_persists():
"""persist_snapshot=True keeps writing today's row and weighted_score."""
from app.models.compliance import AssetRiskSnapshot
engine, db = _db(hosts=4)
scored = compute_urs_bulk(db, None, persist_snapshot=True)
snaps = db.query(AssetRiskSnapshot).all()
assert len(snaps) == len(scored), f"{len(snaps)} snapshots for {len(scored)} assets"
assert {s.urs for s in snaps} == {r["urs"] for r in scored.values()}
weighted = (
db.query(ComplianceResult)
.filter(ComplianceResult.policy_id == "cis_win11")
.first()
)
# 1.1.1 passed (100) + 1.1.2 failed (10) + 1.1.3 passed (80, cross-OS
# benchmark fallback); 1.1.4 is not applicable and drops out.
assert weighted.weighted_score == round(100.0 * 180 / 190, 2), (
weighted.weighted_score
)
print("✅ the persisting path still snapshots and stores weighted_score")
if __name__ == "__main__":
test_bulk_matches_per_asset()
test_statement_count_is_flat()
test_read_path_writes_nothing()
test_snapshot_still_persists()
print("\nAll URS bulk-scoring tests passed.")