mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 15:21:30 +02:00
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
169 lines
3.9 KiB
Python
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()
|