mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +02:00
One evidence axis runs across both findings dimensions
(shared/definitions/evidence.py): inferred < observed < corroborated <
proven. Software rows store inferred or corroborated, set in match_scan from
a host and CVE join to non-info, non-suppressed findings; vulnerabilities
store observed or proven, proven being an out-of-band interaction recorded at
write. Both grammars gain an evidence: field and the old is:proven becomes
is:recorded. GET /cves and GET /cves/{cve} answer "am I affected by X" across
both dimensions, with an MCP tool and a toolbox lookup beside them. Ladder
counts are count(*) FILTER over the same predicates the searches compile, so
a rung opens exactly the rows it counted; tests/grammar/test_evidence.py
asserts it. match_scan carries discovered_at forward through a
software_previous temp table and returns the newly exposed delta for the
nightly notification; nvd_corpus.load skips the download when the mirror
stamp is unchanged.
The Attack surface group had grown to eight items with three of them sharing
a word. Vulnerabilities is now one entry parenting three sibling routes
joined by a tab rail: Findings, Software and CVEs. The routes are unchanged,
so vuln_q and sw_q state, deep links and per-route caching survive, and
NavItem.match keeps the parent lit on the siblings. The Software dimension
label drops to "Software" while its rows keep the noun "software CVE".
The CVE index gets the layout its siblings have: coverage strip, a search bar
attached to the card, a severity rail, a filter row with sort and refresh,
ListHeader rows and pagination. components/search-bar.svelte is the
plain-text twin of QueryBar for surfaces with no grammar. GET /cves gained
page, size, sort, dir, severity, kev, ransomware and evidence and returns
severity_counts; severity and the top rung are resolved in SQL from
SEVERITY_RANK and EVIDENCE_RANK rather than in Python.
Counts verified against the rows they open: overview.cves 1,165 equals the
index total, the Software tab 91,039 equals coverage.findings, and the
severity rail 157/373/583/52 sums to 1,165. _INDEX_TOTAL now excludes the
triage-suppressed findings the listing already excluded, and the software
dimension is counted exactly rather than capped at COUNT_CAP, so the tab
matches the page. The distinct-CVE count is 10s with JIT on and 91ms under
SET LOCAL jit = off, leaving the overview at 137ms cold and 17ms warm.
Also fixes software-table, which passed a 1-based page to ResultsPagination
where every other table passes a 0-based one, so page one read
"Showing 51-100".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MirAGiQ2fSJJtikWi6YRNj
376 lines
12 KiB
Python
376 lines
12 KiB
Python
"""EPSS + CISA KEV: download, store, and re-rank every finding without rescanning."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import gzip
|
|
import json
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import func, select, text
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.orm import Session
|
|
|
|
from shared.definitions.threat_intel import (
|
|
FEEDS_BY_KIND,
|
|
STALE_AFTER_HOURS,
|
|
FeedKind,
|
|
FeedStatus,
|
|
)
|
|
from shared.logging import get_logger
|
|
from shared.models.threat_intel import EpssScore, KevEntry, ThreatFeed
|
|
from shared.services import nvd_corpus
|
|
from shared.utils.datetime import utc_now
|
|
from shared.utils.text import strip_control
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
DOWNLOAD_TIMEOUT = 180
|
|
MAX_FEED_BYTES = 128 * 1024 * 1024
|
|
USER_AGENT = "reNgine"
|
|
|
|
|
|
@contextmanager
|
|
def _download(url: str, name: str) -> Iterator[tuple[Path, int]]:
|
|
workdir = Path(tempfile.mkdtemp(prefix="threat_intel_"))
|
|
target = workdir / name
|
|
try:
|
|
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
|
|
with (
|
|
urllib.request.urlopen(request, timeout=DOWNLOAD_TIMEOUT) as response, # noqa: S310
|
|
target.open("wb") as handle,
|
|
):
|
|
copied = 0
|
|
while chunk := response.read(1 << 20):
|
|
copied += len(chunk)
|
|
if copied > MAX_FEED_BYTES:
|
|
msg = f"{name} exceeded {MAX_FEED_BYTES} bytes"
|
|
raise ValueError(msg)
|
|
handle.write(chunk)
|
|
yield target, copied
|
|
finally:
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
|
|
|
|
def _load_epss(session: Session, path: Path) -> tuple[int, str | None]:
|
|
"""The published CSV leads with a '#model_version' comment."""
|
|
clean = path.with_suffix(".clean.csv")
|
|
version: str | None = None
|
|
with gzip.open(path, "rt", encoding="utf-8", errors="replace") as src:
|
|
first = src.readline()
|
|
if first.startswith("#"):
|
|
version = first.lstrip("#").strip()[:100]
|
|
else:
|
|
src.seek(0)
|
|
with clean.open("w", encoding="utf-8") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
|
|
raw = session.connection().connection
|
|
cursor = raw.cursor()
|
|
cursor.execute("TRUNCATE TABLE epss_scores")
|
|
with clean.open("r", encoding="utf-8") as handle:
|
|
cursor.copy_expert(
|
|
"COPY epss_scores (cve, score, percentile) FROM STDIN WITH (FORMAT csv, HEADER true)",
|
|
handle,
|
|
)
|
|
cursor.execute("SELECT count(*) FROM epss_scores")
|
|
return int(cursor.fetchone()[0]), version
|
|
|
|
|
|
def _as_date(raw: str | None) -> date | None:
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return date.fromisoformat(raw[:10])
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _load_kev(session: Session, path: Path) -> tuple[int, str | None]:
|
|
payload = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
entries = payload.get("vulnerabilities") or []
|
|
version = str(payload.get("catalogVersion") or "")[:100] or None
|
|
|
|
session.execute(text("TRUNCATE TABLE kev_entries"))
|
|
rows = []
|
|
for item in entries:
|
|
cve = (item.get("cveID") or "").strip().upper()
|
|
if not cve:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"cve": cve[:30],
|
|
"vendor": strip_control(item.get("vendorProject") or "")[:200] or None,
|
|
"product": strip_control(item.get("product") or "")[:200] or None,
|
|
"name": strip_control(item.get("vulnerabilityName") or "")[:500]
|
|
or None,
|
|
"short_description": strip_control(item.get("shortDescription") or "")
|
|
or None,
|
|
"required_action": strip_control(item.get("requiredAction") or "")
|
|
or None,
|
|
"notes": strip_control(item.get("notes") or "") or None,
|
|
"cwes": [str(c)[:40] for c in (item.get("cwes") or [])][:20],
|
|
"known_ransomware": (
|
|
str(item.get("knownRansomwareCampaignUse") or "").lower() == "known"
|
|
),
|
|
"date_added": _as_date(item.get("dateAdded")),
|
|
"due_date": _as_date(item.get("dueDate")),
|
|
}
|
|
)
|
|
if rows:
|
|
session.execute(KevEntry.__table__.insert(), rows)
|
|
return len(rows), version
|
|
|
|
|
|
_LOADERS = {FeedKind.EPSS.value: _load_epss, FeedKind.KEV.value: _load_kev}
|
|
|
|
|
|
def _sync_single(session: Session, kind: str, spec) -> tuple[int, str | None, int]:
|
|
with _download(spec.url, f"{kind}.data") as (path, size):
|
|
rows, version = _LOADERS[kind](session, path)
|
|
return rows, version, size
|
|
|
|
|
|
def _mark(
|
|
session: Session,
|
|
kind: str,
|
|
*,
|
|
status: str,
|
|
rows: int = 0,
|
|
version: str | None = None,
|
|
size: int = 0,
|
|
duration_ms: int = 0,
|
|
error: str | None = None,
|
|
synced: bool = False,
|
|
) -> None:
|
|
now = utc_now()
|
|
values = {
|
|
"kind": kind,
|
|
"status": status,
|
|
"rows": rows,
|
|
"version": version,
|
|
"bytes": size,
|
|
"duration_ms": duration_ms,
|
|
"error": error,
|
|
"last_attempt_at": now,
|
|
"updated_at": now,
|
|
}
|
|
if synced:
|
|
values["last_synced_at"] = now
|
|
update = {k: v for k, v in values.items() if k != "kind"}
|
|
if not synced:
|
|
update.pop("last_synced_at", None)
|
|
session.execute(
|
|
pg_insert(ThreatFeed)
|
|
.values(**values)
|
|
.on_conflict_do_update(index_elements=[ThreatFeed.kind], set_=update)
|
|
)
|
|
|
|
|
|
def sync_feed(session: Session, kind: str) -> int:
|
|
"""Refresh one feed."""
|
|
spec = FEEDS_BY_KIND[kind]
|
|
started = time.monotonic()
|
|
_mark(session, kind, status=FeedStatus.SYNCING.value)
|
|
session.commit()
|
|
try:
|
|
if kind == FeedKind.NVD.value:
|
|
held = session.execute(
|
|
select(ThreatFeed.version).where(ThreatFeed.kind == kind)
|
|
).scalar()
|
|
rows, version, size = nvd_corpus.load(session, current_version=held)
|
|
else:
|
|
rows, version, size = _sync_single(session, kind, spec)
|
|
elapsed = int((time.monotonic() - started) * 1000)
|
|
_mark(
|
|
session,
|
|
kind,
|
|
status=FeedStatus.READY.value,
|
|
rows=rows,
|
|
version=version,
|
|
size=size,
|
|
duration_ms=elapsed,
|
|
synced=True,
|
|
)
|
|
session.commit()
|
|
logger.info("threat feed refreshed", feed=kind, rows=rows, version=version)
|
|
return rows
|
|
except Exception as exc:
|
|
session.rollback()
|
|
elapsed = int((time.monotonic() - started) * 1000)
|
|
_mark(
|
|
session,
|
|
kind,
|
|
status=FeedStatus.FAILED.value,
|
|
duration_ms=elapsed,
|
|
error=str(exc)[:500],
|
|
)
|
|
session.commit()
|
|
logger.warning("threat feed refresh failed", feed=kind, exc_info=True)
|
|
return 0
|
|
|
|
|
|
def sync_feeds(session: Session, kinds: list[str] | None = None) -> dict[str, int]:
|
|
wanted = kinds or list(FEEDS_BY_KIND)
|
|
return {kind: sync_feed(session, kind) for kind in wanted if kind in FEEDS_BY_KIND}
|
|
|
|
|
|
def auto_sync_enabled(session: Session) -> bool:
|
|
"""The nightly download is opt-out."""
|
|
try:
|
|
value = session.execute(
|
|
text("SELECT threat_intel_auto_sync FROM instance_settings LIMIT 1")
|
|
).scalar()
|
|
except Exception:
|
|
logger.debug("auto-sync setting unreadable, assuming on", exc_info=True)
|
|
return True
|
|
return True if value is None else bool(value)
|
|
|
|
|
|
def set_auto_sync(session: Session, enabled: bool) -> bool:
|
|
session.execute(
|
|
text(
|
|
"UPDATE instance_settings SET threat_intel_auto_sync = :v, updated_at = now()"
|
|
),
|
|
{"v": enabled},
|
|
)
|
|
session.commit()
|
|
return enabled
|
|
|
|
|
|
def feeds_ready(session: Session) -> bool:
|
|
return bool(
|
|
session.scalar(select(func.count()).select_from(EpssScore).limit(1))
|
|
) and bool(session.scalar(select(func.count()).select_from(KevEntry).limit(1)))
|
|
|
|
|
|
def feed_rows(session: Session) -> dict[str, int]:
|
|
"""Counted from each feed's own table, named by its spec."""
|
|
out: dict[str, int] = {}
|
|
for spec in FEEDS_BY_KIND.values():
|
|
try:
|
|
out[spec.kind] = int(
|
|
session.scalar(text(f"SELECT count(*) FROM {spec.rows_table}")) or 0 # noqa: S608
|
|
)
|
|
except Exception:
|
|
logger.warning("feed row count failed", feed=spec.kind, exc_info=True)
|
|
out[spec.kind] = 0
|
|
return out
|
|
|
|
|
|
def feed_status(feed: ThreatFeed | None, rows: int) -> str:
|
|
if feed is None or rows == 0:
|
|
return FeedStatus.EMPTY.value if feed is None else feed.status
|
|
if feed.status == FeedStatus.SYNCING.value:
|
|
return FeedStatus.SYNCING.value
|
|
if feed.status == FeedStatus.FAILED.value:
|
|
return FeedStatus.FAILED.value
|
|
if feed.last_synced_at is None:
|
|
return FeedStatus.EMPTY.value
|
|
age = (utc_now() - feed.last_synced_at).total_seconds() / 3600
|
|
return FeedStatus.STALE.value if age > STALE_AFTER_HOURS else FeedStatus.READY.value
|
|
|
|
|
|
def feed_age_hours(feed: ThreatFeed | None) -> float | None:
|
|
if feed is None or feed.last_synced_at is None:
|
|
return None
|
|
return round((utc_now() - feed.last_synced_at).total_seconds() / 3600, 1)
|
|
|
|
|
|
_JOINED_SQL = """
|
|
WITH exploded AS (
|
|
SELECT v.id, upper(btrim(c.cve)) AS cve
|
|
FROM vulnerabilities v
|
|
CROSS JOIN LATERAL jsonb_array_elements_text(v.cve_ids::jsonb) AS c(cve)
|
|
{scope}
|
|
)
|
|
SELECT x.id,
|
|
max(e.score) AS epss,
|
|
max(e.percentile) AS pct,
|
|
bool_or(k.cve IS NOT NULL) AS kev,
|
|
bool_or(coalesce(k.known_ransomware,false)) AS ransomware,
|
|
min(k.due_date) AS due_date
|
|
FROM exploded x
|
|
LEFT JOIN epss_scores e ON e.cve = x.cve
|
|
LEFT JOIN kev_entries k ON k.cve = x.cve
|
|
GROUP BY x.id
|
|
"""
|
|
|
|
|
|
def apply_intel(session: Session, *, scan_id=None, project_id=None) -> dict[str, int]:
|
|
"""Re-score findings from the feeds."""
|
|
scope = ""
|
|
params: dict = {"now": utc_now()}
|
|
if scan_id is not None:
|
|
scope = "WHERE v.scan_id = :scan_id"
|
|
params["scan_id"] = scan_id
|
|
elif project_id is not None:
|
|
scope = "WHERE v.project_id = :project_id"
|
|
params["project_id"] = project_id
|
|
|
|
joined = _JOINED_SQL.format(scope=scope)
|
|
session.execute(
|
|
text(f"CREATE TEMP TABLE _intel ON COMMIT DROP AS {joined}"), params
|
|
)
|
|
session.execute(text("CREATE INDEX ON _intel (id)"))
|
|
|
|
moved = session.execute(
|
|
text("""
|
|
SELECT count(*) FILTER (WHERE j.kev AND NOT v.is_kev) AS became_kev,
|
|
count(*) FILTER (WHERE v.epss_score IS DISTINCT FROM j.epss) AS epss_moved,
|
|
count(*) AS scanned
|
|
FROM _intel j JOIN vulnerabilities v ON v.id = j.id
|
|
""")
|
|
).one()
|
|
|
|
changed = session.execute(
|
|
text("""
|
|
UPDATE vulnerabilities v
|
|
SET epss_score = j.epss,
|
|
epss_percentile = j.pct,
|
|
is_kev = j.kev,
|
|
kev_ransomware = j.ransomware,
|
|
kev_due_date = j.due_date,
|
|
intel_at = :now
|
|
FROM _intel j
|
|
WHERE v.id = j.id
|
|
AND (v.epss_score IS DISTINCT FROM j.epss
|
|
OR v.epss_percentile IS DISTINCT FROM j.pct
|
|
OR v.is_kev IS DISTINCT FROM j.kev
|
|
OR v.kev_ransomware IS DISTINCT FROM j.ransomware
|
|
OR v.kev_due_date IS DISTINCT FROM j.due_date)
|
|
"""),
|
|
{"now": params["now"]},
|
|
).rowcount
|
|
session.commit()
|
|
return {
|
|
"scanned": int(moved.scanned or 0),
|
|
"changed": int(changed or 0),
|
|
"became_kev": int(moved.became_kev or 0),
|
|
"epss_moved": int(moved.epss_moved or 0),
|
|
}
|
|
|
|
|
|
def lookup(session: Session, cves: list[str]) -> dict[str, dict]:
|
|
"""Local feed data for a set of CVEs."""
|
|
keys = [c.strip().upper() for c in cves if c and c.strip()]
|
|
if not keys:
|
|
return {}
|
|
out: dict[str, dict] = {k: {"cve": k} for k in keys}
|
|
for row in session.execute(
|
|
select(EpssScore).where(EpssScore.cve.in_(keys))
|
|
).scalars():
|
|
out[row.cve]["epss_score"] = row.score
|
|
out[row.cve]["epss_percentile"] = row.percentile
|
|
for row in session.execute(
|
|
select(KevEntry).where(KevEntry.cve.in_(keys))
|
|
).scalars():
|
|
out[row.cve]["kev"] = row
|
|
return out
|