Files
rengine/shared/models/compare.py
T
Yogesh OjhaandClaude Opus 5 3f6302870c feat(scans): compare two runs, and prove they are comparable first
reNgine could say what was new against a target's whole history, and 2.x could
say `[new, removed]` for subdomains. Neither answers the question an operator
actually asks after a scan finishes: what changed since that run, the named one
I am comparing against.

The hard part is not the diff. Two StandardRengine runs of vulnweb.com in this
instance, eighteen minutes apart, went from 14 findings to 0. A set diff reports
"14 findings resolved, 12 high". The truth is `vulnerability_scan.max_minutes`
went 0 to 2, and the coverage rows record 57,100 requests over 368s against four
hosts, then 27,582 over 120s against seven. The delta belongs to the run.

So comparability is stated before any delta, per dimension: like for like,
settings differ, runs differ in depth, not scanned. Stage settings and the runs'
own setup are both compared — engine, context, auth type, intensity, proxy
(as set/none, never the URL), scope exclusions, protocol, crawl. Facets that
change what a run can find raise the verdict; threads and rate limits do not.
Runs that ran between the pair are counted, a cancelled run in the pair lowers
the verdict, and when the chosen pair is not like for like the nearest run that
would be is offered.

Identity is read off the per-scan unique constraints already on each table —
`subdomains.name`, `endpoints.signature`, `ports.(ip, number, protocol)`,
`ip_addresses.ip`, `vulnerabilities.fingerprint` — so the diff key is not a new
invention. Four verbs: appeared, changed, disappeared, unchanged. Disappearance
is only asserted when the producing stage finished cleanly; dnsx drops 20-60% of
names silently, so a partial run's missing rows are unconfirmed, a bucket of
their own. A dimension one run did not produce reports not scanned, never
everything removed. EPSS and KEV movement is Class D and is counted apart.

A disappeared row does not exist in the later run, so a query scoped to it can
never return one. Rather than teach five grammars an `is:added` flag, the
service flips the scope per verb, and every column, sort key and detail sheet
keeps working. Rank is computed once as a SQL CASE and the signal name is
recovered from the rank, so the order and the label cannot disagree.

Nothing is stored: a 425k-row full outer join measures 66 ms, the whole endpoint
about 31 ms. No table, no migration.

Direction is a shape — solid, split, hollow rails — not green-and-red. New
attack surface is not good news. Diff mode keeps the git glyphs for the same
reason it colours them from the `--code-*` ramp: in a diff `+` is syntax. That
mode is generated once on the server and shown through `code-block`, so the
screen and the clipboard cannot drift.

Eligibility is explained, never hidden. `REFUSAL_REASON` is one definition
mirrored in `config/compare.ts`, so the disabled button's hint, the alert page
and the API error say the same sentence, and an impossible pair reached by URL
lands on an alert that lists the target's runs to pick from.

`recheck.py` now reads its host field list from the shared definitions, so the
product holds one answer to "did this host change".

Also fixes the scan hero, which said "No change since the previous scan" while
measuring only hosts. Beside a page reporting 21 changes that read as a
contradiction; it now says what it measured.

Vulnerability rows are one per finding rather than grouped by check, and
comparing against a run that is still going is not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAVgTQe8K3EvGzPo5HVmKF
2026-09-12 06:18:23 +05:30

169 lines
3.9 KiB
Python

import uuid
from datetime import datetime
from pydantic import BaseModel, Field
from shared.definitions.compare import Comparability
class RunSide(BaseModel):
"""One of the two runs."""
scan_id: uuid.UUID
engine_name: str
context_name: str | None = None
intensity: str = ""
scope: str = ""
status: str
started_at: datetime | None = None
completed_at: datetime | None = None
duration_seconds: float | None = None
stages_ran: int = 0
stages_planned: int = 0
counts: dict[str, int] = Field(default_factory=dict)
class StageDiff(BaseModel):
name: str
title: str
baseline: str | None = None
current: str | None = None
class SettingDiff(BaseModel):
stage: str
title: str
field: str
label: str
before: str | None = None
after: str | None = None
class RunDifference(BaseModel):
"""One way the two runs were set up differently."""
key: str
label: str
baseline: str | None = None
current: str | None = None
material: bool = True
class CoverageLine(BaseModel):
"""One measured account of a run."""
label: str
baseline: str | None = None
current: str | None = None
class DimensionVerdict(BaseModel):
dimension: str
comparability: str = Comparability.LIKE_FOR_LIKE.value
covered_baseline: bool = True
covered_current: bool = True
compared: bool = True
confirmed: bool = True
note: str = ""
stages: list[str] = Field(default_factory=list)
settings: list[SettingDiff] = Field(default_factory=list)
coverage: list[CoverageLine] = Field(default_factory=list)
class DimensionDelta(BaseModel):
dimension: str
label: str
noun: str
noun_plural: str
verdict: DimensionVerdict
total_baseline: int = 0
total_current: int = 0
appeared: int = 0
changed: int = 0
disappeared: int = 0
unconfirmed: int = 0
unchanged: int = 0
intel_moved: int = 0
@property
def listed(self) -> int:
return self.appeared + self.changed + self.disappeared + self.unconfirmed
class ChangeField(BaseModel):
field: str
label: str
before: str | None = None
after: str | None = None
tone: str = "neutral"
class ScreenshotPair(BaseModel):
baseline: str | None = None
current: str | None = None
class ChangeRow(BaseModel):
key: str
dimension: str
verb: str
signal: str
rank: int = 99
title: str
subtitle: str = ""
scan_id: uuid.UUID
fields: list[ChangeField] = Field(default_factory=list)
severity: str | None = None
status: int | None = None
port: int | None = None
is_kev: bool = False
sensitive: bool = False
screenshots: ScreenshotPair | None = None
class ChangeRows(BaseModel):
dimension: str
items: list[ChangeRow] = Field(default_factory=list)
total: int = 0
page: int = 1
size: int = 50
class ScanComparison(BaseModel):
target_id: uuid.UUID
target_value: str
target_type: str
baseline: RunSide
current: RunSide
dimensions: list[DimensionDelta] = Field(default_factory=list)
stage_diff: list[StageDiff] = Field(default_factory=list)
setting_diff: list[SettingDiff] = Field(default_factory=list)
settings_identical: int = 0
run_diff: list[RunDifference] = Field(default_factory=list)
runs_between: int = 0
comparability: str = Comparability.LIKE_FOR_LIKE.value
headline: str = ""
summary: str = ""
changes_total: int = 0
live: bool = False
suggestion: "ComparableRun | None" = None
generated_at: datetime
class ComparableRun(BaseModel):
"""A run this one can be put beside."""
scan_id: uuid.UUID
engine_name: str
status: str
scope: str
started_at: datetime | None = None
duration_seconds: float | None = None
counts: dict[str, int] = Field(default_factory=dict)
dimensions: list[str] = Field(default_factory=list)
comparable: bool = True
reason: str = ""
ScanComparison.model_rebuild()