From 6c632a574d5d59a0746303f5e51bd3aae547d5ee Mon Sep 17 00:00:00 2001 From: karthickmk2001 Date: Wed, 11 Nov 2026 20:42:48 +0000 Subject: [PATCH 1/2] feat(runtime): evidence format, storage and secfoo runtime ingest (#53) NDJSON session/load/hit events as specified in #53, a JSON Schema, three new tables (runtime_sessions, runtime_evidence with an FK to sca_findings, runtime_packages), and secfoo runtime ingest. Validation and the idempotent upsert live in secfoo/runtime/evidence.py so the live ingest API (#66) can reuse them. Malformed lines and unknown fingerprints are skipped and reported, never abort; re-ingesting a file replaces running totals instead of double-counting; unknown fields, concrete URLs and non file:line frames never reach the database. Closes #53 Co-Authored-By: Claude Opus 5.5 --- docs/REFERENCE.md | 58 ++++ src/secfoo/cli.py | 43 +++ src/secfoo/runtime/__init__.py | 5 + src/secfoo/runtime/evidence.py | 338 ++++++++++++++++++++++++ src/secfoo/runtime/evidence.schema.json | 74 ++++++ src/secfoo/storage/repository.py | 22 ++ src/secfoo/storage/schema.sql | 68 +++++ tests/test_runtime/test_evidence.py | 291 ++++++++++++++++++++ 8 files changed, 899 insertions(+) create mode 100644 src/secfoo/runtime/__init__.py create mode 100644 src/secfoo/runtime/evidence.py create mode 100644 src/secfoo/runtime/evidence.schema.json create mode 100644 tests/test_runtime/test_evidence.py diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 387ae8e..920a2ed 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -350,6 +350,64 @@ score. CWE tags use `external/cwe/cwe-N`. Findings without a CWE still get a rul `--no-locations` removes the same structured location fields as JSONL and omits SARIF locations; free-text finding titles and descriptions are not redacted. +### Runtime evidence + +Runtime evidence records what a running app actually did with a vulnerable +dependency: whether it was loaded, whether a watched vulnerable function was +called, and on which route. It is **opt-in**: nothing produces it unless you +deploy a probe or sensor, or import a coverage file. Without it, SCA findings +work exactly as before. Design rules and roadmap: issue #51. + +```bash +secfoo runtime ingest events.ndjson --project payments # project id, name, or scanned path/URL +``` + +`ingest` stores the events against the project's SCA findings. It skips +malformed lines and hits for unknown findings, lists each one with its line +number, and still stores the rest; it exits `1` if anything was skipped. +Ingesting the same file twice updates rows instead of double-counting. + +**Event file:** NDJSON, one JSON object per line. A `session` line, then any +number of `load` and `hit` lines that reference it by `session` id. A later +file may add hits to a session stored earlier. The JSON Schema is +[`src/secfoo/runtime/evidence.schema.json`](../src/secfoo/runtime/evidence.schema.json). + +```json +{"type":"session","id":"","language":"python","runtime":"3.12.4","source":"sensor","started_at":"2026-11-08T10:00:00Z","ended_at":"2026-11-08T16:00:00Z","app":"payments-api","env":"prod","instance":"pod-1","sensor_version":"0.4.0","app_functions_total":1840,"app_functions_executed":1362} +{"type":"load","session":"","ecosystem":"PyPI","package":"pyyaml","version":"5.3","path":"/app/.venv/lib/python3.12/site-packages/yaml","first_seen":"2026-11-08T10:00:01Z"} +{"type":"hit","session":"","finding_fingerprint":"","symbol":"yaml.load","route":"POST /upload","auth":"anonymous","count":312,"first_seen":"2026-11-08T10:05:00Z","last_seen":"2026-11-08T15:59:00Z","entry_frame":"app/routes/upload.py:42","stack_sample":["app/routes/upload.py:42","app/services/parse.py:17"]} +``` + +| Line | Required | Optional | +|---|---|---| +| `session` | `id` (producer's own UUID), `language` (`python` `node` `jvm` `dotnet` `go` `rust` `ruby` `php` `native`), `source` (`sensor` or `coverage`), `started_at` | `runtime`, `ended_at`, `app`, `env`, `instance`, `sensor_version`, `app_functions_total`, `app_functions_executed` | +| `load` | `session`, `ecosystem` (OSV name), `package`, `version` | `path`, `first_seen` | +| `hit` | `session`, `finding_fingerprint`, `symbol`, `count` (≥ 1) | `route`, `auth` (`anonymous` or `authenticated`), `first_seen`, `last_seen`, `entry_frame`, `stack_sample` | + +- Timestamps are ISO 8601. +- A hit's `count` is the session's **running total** for its (fingerprint, + symbol, route), so a producer can flush the same session repeatedly and + the stored value is replaced, never summed. +- `route` is a template (`POST /upload`), `job:`, `startup` or + `unknown`, never a concrete URL. Values with a scheme (`://`), query string + or fragment are rejected. +- `entry_frame` and `stack_sample` (at most 50 frames) are `file:line` frames + only. Producers drop frames outside the app (site-packages, node_modules) + except the vulnerable function itself. +- Only the fields above are stored. Anything else on a line is ignored and + never written, so argument values, request bodies and headers can't reach + the database even if a producer sends them. +- `app_functions_total` / `app_functions_executed` record how much of the + app the observed traffic covered. "Not observed" is always shown with this + coverage, never as "not reachable". + +**Storage** (in `schema.sql`, added automatically to existing stores): +`runtime_sessions` (one row per session), `runtime_evidence` (per session, +finding, symbol and route, with a foreign key to `sca_findings`) and +`runtime_packages` (loaded inventory per session). Validation and storage +live in `secfoo/runtime/evidence.py` (`ingest_events`), which the live +ingest API will reuse. + ## Install Pick whichever fits how you work — they're all the same tool underneath. diff --git a/src/secfoo/cli.py b/src/secfoo/cli.py index 7262435..1c33579 100644 --- a/src/secfoo/cli.py +++ b/src/secfoo/cli.py @@ -45,6 +45,8 @@ app.add_typer(mcp_app, name="mcp") app.add_typer(memory_app, name="memory") app.add_typer(findings_app, name="findings") +runtime_app = typer.Typer(name="runtime", help="Runtime evidence for SCA findings (opt-in; see docs/REFERENCE.md).") +app.add_typer(runtime_app, name="runtime") app.add_typer(config_app, name="config") app.add_typer(assessment_app, name="assessment") app.add_typer(exception_app, name="exception") @@ -1513,6 +1515,47 @@ def findings_export( err_console.print(f"Wrote {len(records)} finding(s) to {output}.") +@runtime_app.command(name="ingest") +def runtime_ingest( + events: Annotated[ + Path, typer.Argument(exists=True, dir_okay=False, readable=True, help="NDJSON event file.") + ], + project: str = typer.Option(..., "--project", help="Project id, name, or path/URL it was scanned as."), +) -> None: + """Store runtime evidence (session / load / hit lines) against a project's SCA findings. + + Malformed lines and hits for unknown findings are skipped and listed; + the rest of the file is still stored. Ingesting the same file again + updates rows rather than double-counting. Exits 1 if any line was skipped. + """ + from secfoo.runtime.evidence import ingest_file + + with RunRepository() as repo: + project_id = repo.resolve_project_ref(project) + if project_id is None: + err_console.print( + f"[red]Error:[/] no single project matches {project!r}. " + "Use the project id from `secfoo list`, its name, or the path/URL it was scanned as." + ) + raise typer.Exit(code=1) + report = ingest_file(repo.connection, project_id, events) + console.print( + f"Stored {report.sessions} session(s), {report.loads} package load(s), " + f"{report.hits} hit(s) from {events}." + ) + if report.unknown_fingerprints: + err_console.print( + f"[yellow]Warning:[/] {len(report.unknown_fingerprints)} finding fingerprint(s) aren't SCA findings " + "of this project, so their hits were skipped. Was the watchlist built for another project?" + ) + for number, reason in report.skipped[:20]: + err_console.print(f" line {number}: {reason}") + if len(report.skipped) > 20: + err_console.print(f" ... and {len(report.skipped) - 20} more skipped line(s)") + if report.skipped: + raise typer.Exit(code=1) + + MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" diff --git a/src/secfoo/runtime/__init__.py b/src/secfoo/runtime/__init__.py new file mode 100644 index 0000000..a43d5ed --- /dev/null +++ b/src/secfoo/runtime/__init__.py @@ -0,0 +1,5 @@ +"""Runtime evidence for SCA findings (#51). + +`evidence` holds the shared event format (#53) that probes, the coverage +importer and the live sensor all write, plus its validation and storage. +""" diff --git a/src/secfoo/runtime/evidence.py b/src/secfoo/runtime/evidence.py new file mode 100644 index 0000000..49cee00 --- /dev/null +++ b/src/secfoo/runtime/evidence.py @@ -0,0 +1,338 @@ +"""Runtime evidence events (#53): validate NDJSON lines and store them. + +This is the one contract that every producer writes -- the Python and Node +probes (#55, #56), the coverage importer (#54) and the live sensor via the +ingest API (#66/#67). The file format and the live API share it, so both +call `ingest_events()`. + +An event file is NDJSON. Each line is one object with a `type`: + + {"type":"session","id":"","language":"python","source":"sensor","started_at":"..."} + {"type":"load","session":"","ecosystem":"PyPI","package":"pyyaml","version":"5.3"} + {"type":"hit","session":"","finding_fingerprint":"...","symbol":"yaml.load","count":312} + +The full field list is in docs/REFERENCE.md ("Runtime evidence") and in +evidence.schema.json next to this module. + +Ingest rules: + +- A malformed line is skipped and reported with its line number. It never + aborts the rest of the file. +- A `hit` whose `finding_fingerprint` isn't an SCA finding of the project is + skipped with a warning (it can't attach to anything). +- Idempotent: a session is keyed by its own `id`, and a hit by (session, + fingerprint, symbol, route). A hit's `count` is that session's running + total for the key, so a re-ingest -- or a later flush of the same session + -- replaces the row instead of adding to it. +- Privacy (#51 rule 3): only the fields below are stored. Unknown fields + are ignored, never persisted. Routes that look like concrete URLs (a + scheme or query string) and stack frames that aren't `file:line` are + rejected. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from collections.abc import Iterable, Iterator +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +LANGUAGES = frozenset({"python", "node", "jvm", "dotnet", "go", "rust", "ruby", "php", "native"}) +SOURCES = frozenset({"sensor", "coverage"}) +AUTH_STATES = frozenset({"anonymous", "authenticated"}) + +MAX_LINE_BYTES = 64 * 1024 +MAX_TEXT = 1024 +MAX_STACK_FRAMES = 50 + +_FRAME_RE = re.compile(rf"^\S.{{0,{MAX_TEXT - 8}}}:\d+$") +_CONCRETE_URL_RE = re.compile(r"://|[?#]") + + +class EvidenceError(ValueError): + """One line failed validation. The message says which field and why.""" + + +@dataclass +class IngestReport: + sessions: int = 0 + loads: int = 0 + hits: int = 0 + skipped: list[tuple[int, str]] = field(default_factory=list) # (line number, reason) + unknown_fingerprints: set[str] = field(default_factory=set) + + @property + def stored(self) -> int: + return self.sessions + self.loads + self.hits + + +# ---------- validation ---------- + + +def _text(event: dict, key: str, *, required: bool = False) -> str | None: + value = event.get(key) + if value is None: + if required: + raise EvidenceError(f"missing required field {key!r}") + return None + if not isinstance(value, str) or not value.strip(): + raise EvidenceError(f"{key!r} must be a non-empty string") + if len(value) > MAX_TEXT: + raise EvidenceError(f"{key!r} is longer than {MAX_TEXT} characters") + return value + + +def _timestamp(event: dict, key: str, *, required: bool = False) -> str | None: + value = _text(event, key, required=required) + if value is None: + return None + try: + # Python 3.10's fromisoformat doesn't accept a trailing "Z". + datetime.fromisoformat(value[:-1] + "+00:00" if value.endswith("Z") else value) + except ValueError: + raise EvidenceError(f"{key!r} must be an ISO 8601 timestamp, got {value!r}") from None + return value + + +def _count(event: dict, key: str, *, required: bool = False, minimum: int = 0) -> int | None: + value = event.get(key) + if value is None: + if required: + raise EvidenceError(f"missing required field {key!r}") + return None + if isinstance(value, bool) or not isinstance(value, int) or value < minimum: + raise EvidenceError(f"{key!r} must be an integer >= {minimum}") + return value + + +def _frame(value: object, key: str) -> str: + if not isinstance(value, str) or not _FRAME_RE.match(value): + raise EvidenceError(f"{key!r} frames must be 'file:line', never variables or arguments") + return value + + +def validate_event(event: object) -> dict: + """The stored fields of one event, or EvidenceError. Unknown fields are + dropped here, so nothing outside the contract can reach the database.""" + if not isinstance(event, dict): + raise EvidenceError("each line must be a JSON object") + kind = event.get("type") + if kind == "session": + language = _text(event, "language", required=True) + if language not in LANGUAGES: + raise EvidenceError(f"'language' must be one of {', '.join(sorted(LANGUAGES))}") + source = _text(event, "source", required=True) + if source not in SOURCES: + raise EvidenceError("'source' must be 'sensor' or 'coverage'") + total = _count(event, "app_functions_total") + executed = _count(event, "app_functions_executed") + if total is not None and executed is not None and executed > total: + raise EvidenceError("'app_functions_executed' can't exceed 'app_functions_total'") + return { + "type": "session", + "id": _text(event, "id", required=True), + "language": language, + "runtime": _text(event, "runtime"), + "source": source, + "app": _text(event, "app"), + "env": _text(event, "env"), + "instance": _text(event, "instance"), + "sensor_version": _text(event, "sensor_version"), + "started_at": _timestamp(event, "started_at", required=True), + "ended_at": _timestamp(event, "ended_at"), + "app_functions_total": total, + "app_functions_executed": executed, + } + if kind == "load": + return { + "type": "load", + "session": _text(event, "session", required=True), + "ecosystem": _text(event, "ecosystem", required=True), + "package": _text(event, "package", required=True), + "version": _text(event, "version", required=True), + "path": _text(event, "path"), + "first_seen": _timestamp(event, "first_seen"), + } + if kind == "hit": + route = _text(event, "route") + if route is not None and _CONCRETE_URL_RE.search(route): + raise EvidenceError("'route' must be a template like 'POST /upload', not a concrete URL") + auth = _text(event, "auth") + if auth is not None and auth not in AUTH_STATES: + raise EvidenceError("'auth' must be 'anonymous' or 'authenticated'") + stack = event.get("stack_sample") + if stack is not None: + if not isinstance(stack, list) or len(stack) > MAX_STACK_FRAMES: + raise EvidenceError(f"'stack_sample' must be a list of at most {MAX_STACK_FRAMES} frames") + stack = [_frame(frame, "stack_sample") for frame in stack] + entry = event.get("entry_frame") + return { + "type": "hit", + "session": _text(event, "session", required=True), + "finding_fingerprint": _text(event, "finding_fingerprint", required=True), + "symbol": _text(event, "symbol", required=True), + "route": route or "", + "auth": auth, + "count": _count(event, "count", required=True, minimum=1), + "first_seen": _timestamp(event, "first_seen"), + "last_seen": _timestamp(event, "last_seen"), + "entry_frame": None if entry is None else _frame(entry, "entry_frame"), + "stack_sample": stack, + } + raise EvidenceError("'type' must be 'session', 'load' or 'hit'") + + +def parse_lines(lines: Iterable[str]) -> Iterator[tuple[int, dict | None, str | None]]: + """(line number, validated event or None, error or None) per non-blank line.""" + for number, raw in enumerate(lines, start=1): + if not raw.strip(): + continue + if len(raw.encode("utf-8", errors="replace")) > MAX_LINE_BYTES: + yield number, None, f"line is longer than {MAX_LINE_BYTES} bytes" + continue + try: + yield number, validate_event(json.loads(raw)), None + except json.JSONDecodeError as exc: + yield number, None, f"not valid JSON ({exc.msg})" + except EvidenceError as exc: + yield number, None, str(exc) + + +# ---------- storage ---------- + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _session_id(conn: sqlite3.Connection, project_id: int, session_uuid: str) -> int | None: + row = conn.execute( + "SELECT id FROM runtime_sessions WHERE project_id = ? AND session_uuid = ?", (project_id, session_uuid) + ).fetchone() + return row[0] if row else None + + +def _store_session(conn: sqlite3.Connection, project_id: int, event: dict) -> int: + conn.execute( + """ + INSERT INTO runtime_sessions ( + project_id, session_uuid, language, runtime, source, app, env, instance, sensor_version, + started_at, ended_at, app_functions_total, app_functions_executed, ingested_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (project_id, session_uuid) DO UPDATE SET + language = excluded.language, runtime = excluded.runtime, source = excluded.source, + app = excluded.app, env = excluded.env, instance = excluded.instance, + sensor_version = excluded.sensor_version, started_at = excluded.started_at, + ended_at = excluded.ended_at, app_functions_total = excluded.app_functions_total, + app_functions_executed = excluded.app_functions_executed, ingested_at = excluded.ingested_at + """, + ( + project_id, event["id"], event["language"], event["runtime"], event["source"], event["app"], + event["env"], event["instance"], event["sensor_version"], event["started_at"], event["ended_at"], + event["app_functions_total"], event["app_functions_executed"], _now(), + ), + ) + session_id = _session_id(conn, project_id, event["id"]) + assert session_id is not None # nosec B101 -- just upserted + return session_id + + +def _store_load(conn: sqlite3.Connection, project_id: int, session_id: int, event: dict) -> None: + conn.execute( + """ + INSERT INTO runtime_packages (project_id, session_id, ecosystem, package, version, path, first_seen) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (session_id, ecosystem, package, version) DO UPDATE SET + path = excluded.path, + first_seen = COALESCE(MIN(runtime_packages.first_seen, excluded.first_seen), + runtime_packages.first_seen, excluded.first_seen) + """, + (project_id, session_id, event["ecosystem"], event["package"], event["version"], event["path"], + event["first_seen"]), + ) + + +def _store_hit(conn: sqlite3.Connection, project_id: int, session_id: int, event: dict) -> None: + conn.execute( + """ + INSERT INTO runtime_evidence ( + project_id, session_id, finding_fingerprint, symbol, route, auth, count, + first_seen, last_seen, entry_frame, stack_sample + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (session_id, finding_fingerprint, symbol, route) DO UPDATE SET + count = excluded.count, + auth = COALESCE(excluded.auth, runtime_evidence.auth), + first_seen = COALESCE(MIN(runtime_evidence.first_seen, excluded.first_seen), + runtime_evidence.first_seen, excluded.first_seen), + last_seen = COALESCE(MAX(runtime_evidence.last_seen, excluded.last_seen), + runtime_evidence.last_seen, excluded.last_seen), + entry_frame = COALESCE(excluded.entry_frame, runtime_evidence.entry_frame), + stack_sample = COALESCE(excluded.stack_sample, runtime_evidence.stack_sample) + """, + ( + project_id, session_id, event["finding_fingerprint"], event["symbol"], event["route"], event["auth"], + event["count"], event["first_seen"], event["last_seen"], event["entry_frame"], + None if event["stack_sample"] is None else json.dumps(event["stack_sample"]), + ), + ) + + +def ingest_events(conn: sqlite3.Connection, project_id: int, lines: Iterable[str]) -> IngestReport: + """Validate and store every line, in one transaction. Bad lines are + skipped and listed in the report; they never stop the others.""" + report = IngestReport() + sessions: dict[str, int] = {} + known_fingerprints: dict[str, bool] = {} + + def session_for(uuid: str) -> int | None: + if uuid not in sessions: + found = _session_id(conn, project_id, uuid) # from an earlier ingest + if found is None: + return None + sessions[uuid] = found + return sessions[uuid] + + def is_finding(fingerprint: str) -> bool: + if fingerprint not in known_fingerprints: + known_fingerprints[fingerprint] = conn.execute( + "SELECT 1 FROM sca_findings WHERE project_id = ? AND fingerprint = ?", (project_id, fingerprint) + ).fetchone() is not None + return known_fingerprints[fingerprint] + + try: + for number, event, error in parse_lines(lines): + if event is None: + report.skipped.append((number, error or "invalid line")) + continue + if event["type"] == "session": + sessions[event["id"]] = _store_session(conn, project_id, event) + report.sessions += 1 + continue + session_id = session_for(event["session"]) + if session_id is None: + report.skipped.append((number, f"unknown session {event['session']!r} (no session line for it)")) + continue + if event["type"] == "load": + _store_load(conn, project_id, session_id, event) + report.loads += 1 + elif is_finding(event["finding_fingerprint"]): + _store_hit(conn, project_id, session_id, event) + report.hits += 1 + else: + report.unknown_fingerprints.add(event["finding_fingerprint"]) + report.skipped.append( + (number, f"finding_fingerprint {event['finding_fingerprint']!r} is not an SCA finding of this project") + ) + conn.commit() + except BaseException: + conn.rollback() + raise + return report + + +def ingest_file(conn: sqlite3.Connection, project_id: int, path: Path) -> IngestReport: + with path.open(encoding="utf-8", errors="replace") as handle: + return ingest_events(conn, project_id, handle) diff --git a/src/secfoo/runtime/evidence.schema.json b/src/secfoo/runtime/evidence.schema.json new file mode 100644 index 0000000..6e9aa5b --- /dev/null +++ b/src/secfoo/runtime/evidence.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/secfoo-com/secfoo/blob/main/src/secfoo/runtime/evidence.schema.json", + "title": "Secfoo runtime evidence event (one NDJSON line)", + "description": "One line of a runtime evidence file (#53). secfoo/runtime/evidence.py:validate_event enforces the same rules; tests/test_runtime/test_evidence.py checks the two agree. Unknown fields are allowed for forward compatibility but are never stored.", + "oneOf": [ + {"$ref": "#/$defs/session"}, + {"$ref": "#/$defs/load"}, + {"$ref": "#/$defs/hit"} + ], + "$defs": { + "text": {"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "\\S"}, + "timestamp": {"type": "string", "minLength": 1, "maxLength": 1024, "format": "date-time"}, + "frame": { + "description": "file:line only, never local variables or arguments.", + "type": "string", + "pattern": "^\\S.*:\\d+$", + "maxLength": 1024 + }, + "session": { + "type": "object", + "required": ["type", "id", "language", "source", "started_at"], + "properties": { + "type": {"const": "session"}, + "id": {"$ref": "#/$defs/text", "description": "The producer's own session id (a UUID). Re-ingesting the same id updates, never duplicates."}, + "language": {"enum": ["python", "node", "jvm", "dotnet", "go", "rust", "ruby", "php", "native"]}, + "runtime": {"$ref": "#/$defs/text", "description": "Runtime version, e.g. 3.12.4."}, + "source": {"enum": ["sensor", "coverage"]}, + "app": {"$ref": "#/$defs/text"}, + "env": {"$ref": "#/$defs/text"}, + "instance": {"$ref": "#/$defs/text", "description": "Host or pod name."}, + "sensor_version": {"$ref": "#/$defs/text"}, + "started_at": {"$ref": "#/$defs/timestamp"}, + "ended_at": {"$ref": "#/$defs/timestamp"}, + "app_functions_total": {"type": "integer", "minimum": 0}, + "app_functions_executed": {"type": "integer", "minimum": 0} + } + }, + "load": { + "type": "object", + "required": ["type", "session", "ecosystem", "package", "version"], + "properties": { + "type": {"const": "load"}, + "session": {"$ref": "#/$defs/text"}, + "ecosystem": {"$ref": "#/$defs/text", "description": "OSV ecosystem name: PyPI, npm, Maven, NuGet, Go, crates.io, RubyGems, Packagist."}, + "package": {"$ref": "#/$defs/text"}, + "version": {"$ref": "#/$defs/text"}, + "path": {"$ref": "#/$defs/text"}, + "first_seen": {"$ref": "#/$defs/timestamp"} + } + }, + "hit": { + "type": "object", + "required": ["type", "session", "finding_fingerprint", "symbol", "count"], + "properties": { + "type": {"const": "hit"}, + "session": {"$ref": "#/$defs/text"}, + "finding_fingerprint": {"$ref": "#/$defs/text", "description": "sca_findings.fingerprint of the project."}, + "symbol": {"$ref": "#/$defs/text", "description": "Watched symbol in the #52 notation, or the package name for package-granularity entries."}, + "route": { + "description": "Route template ('POST /upload'), job:, startup or unknown. Never a concrete URL.", + "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^[^?#]*$", + "not": {"pattern": "://"} + }, + "auth": {"enum": ["anonymous", "authenticated"]}, + "count": {"type": "integer", "minimum": 1, "description": "The session's running total for this (fingerprint, symbol, route)."}, + "first_seen": {"$ref": "#/$defs/timestamp"}, + "last_seen": {"$ref": "#/$defs/timestamp"}, + "entry_frame": {"$ref": "#/$defs/frame"}, + "stack_sample": {"type": "array", "maxItems": 50, "items": {"$ref": "#/$defs/frame"}} + } + } + } +} diff --git a/src/secfoo/storage/repository.py b/src/secfoo/storage/repository.py index aa08b5a..d428f80 100644 --- a/src/secfoo/storage/repository.py +++ b/src/secfoo/storage/repository.py @@ -363,6 +363,28 @@ def complete_run( ) self._conn.commit() + def resolve_project_ref(self, ref: str) -> int | None: + """A project id from what a user would type for `--project`: its + numeric id, its identifier (path / repository URL), or its display + name. None when nothing matches, or when a display name is shared + by several projects (the caller should ask for the id instead).""" + ref = ref.strip() + if ref.isdigit(): + row = self._conn.execute("SELECT id FROM projects WHERE id = ?", (int(ref),)).fetchone() + if row: + return row["id"] + found = self.find_project_id(ref) + if found is not None: + return found + rows = self._conn.execute("SELECT id FROM projects WHERE display_name = ?", (ref,)).fetchall() + return rows[0]["id"] if len(rows) == 1 else None + + @property + def connection(self) -> sqlite3.Connection: + """The underlying connection, for modules that own a table group's + writes end to end (runtime/evidence.py) and need one transaction.""" + return self._conn + def find_project_id(self, identifier: str) -> int | None: """The id of an existing project, without creating one (unlike upsert_project). Used to check for a repeat scan before running.""" diff --git a/src/secfoo/storage/schema.sql b/src/secfoo/storage/schema.sql index cb9587b..9156e81 100644 --- a/src/secfoo/storage/schema.sql +++ b/src/secfoo/storage/schema.sql @@ -302,3 +302,71 @@ CREATE TABLE IF NOT EXISTS secret_findings ( ); CREATE INDEX IF NOT EXISTS idx_secret_findings_project_status ON secret_findings(project_id, status); + +-- Runtime evidence for SCA findings (#51 / #53): what a probe, sensor or +-- imported coverage file observed in a running app. Written only by +-- runtime/evidence.py (`secfoo runtime ingest`, later the live API in #66), +-- which validates every line first. Never holds argument values, request +-- bodies, concrete URLs or user identity -- see the design rules in #51. +-- +-- One row per observation session (a process run, a coverage file). +-- `session_uuid` is the producer's own id, so re-ingesting the same file +-- updates rows instead of adding duplicates. +CREATE TABLE IF NOT EXISTS runtime_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + session_uuid TEXT NOT NULL, + language TEXT NOT NULL, -- python / node / jvm / dotnet / go / rust / ruby / php / native + runtime TEXT, -- runtime version, e.g. "3.12.4" + source TEXT NOT NULL CHECK (source IN ('sensor','coverage')), + app TEXT, + env TEXT, + instance TEXT, + sensor_version TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + app_functions_total INTEGER, -- with _executed, the coverage behind any "not observed" + app_functions_executed INTEGER, + ingested_at TEXT NOT NULL, + UNIQUE (project_id, session_uuid) +); + +-- A watched vulnerable symbol (or package, for package-granularity +-- watchlist entries) seen during a session, aggregated per route. `route` +-- is a template ("POST /upload") or job: / startup / unknown, never +-- a concrete URL; '' when the producer has no request context. +CREATE TABLE IF NOT EXISTS runtime_evidence ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + session_id INTEGER NOT NULL REFERENCES runtime_sessions(id), + finding_fingerprint TEXT NOT NULL, + symbol TEXT NOT NULL, + route TEXT NOT NULL DEFAULT '', + auth TEXT CHECK (auth IS NULL OR auth IN ('anonymous','authenticated')), + count INTEGER NOT NULL CHECK (count > 0), + first_seen TEXT, + last_seen TEXT, + entry_frame TEXT, -- file:line, app frames only + stack_sample TEXT, -- JSON array of file:line frames, never locals or arguments + FOREIGN KEY (project_id, finding_fingerprint) REFERENCES sca_findings(project_id, fingerprint), + UNIQUE (session_id, finding_fingerprint, symbol, route) +); + +CREATE INDEX IF NOT EXISTS idx_runtime_evidence_finding ON runtime_evidence(project_id, finding_fingerprint); + +-- Packages loaded in a running process (#68's inventory), per session, so +-- "Loaded" can be shown and version drift against the scanned lockfile +-- detected. +CREATE TABLE IF NOT EXISTS runtime_packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + session_id INTEGER NOT NULL REFERENCES runtime_sessions(id), + ecosystem TEXT NOT NULL, + package TEXT NOT NULL, + version TEXT NOT NULL, + path TEXT, + first_seen TEXT, + UNIQUE (session_id, ecosystem, package, version) +); + +CREATE INDEX IF NOT EXISTS idx_runtime_packages_project ON runtime_packages(project_id, ecosystem, package); diff --git a/tests/test_runtime/test_evidence.py b/tests/test_runtime/test_evidence.py new file mode 100644 index 0000000..4cb7601 --- /dev/null +++ b/tests/test_runtime/test_evidence.py @@ -0,0 +1,291 @@ +"""#53: runtime evidence format, storage and `secfoo runtime ingest`.""" +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from secfoo.cli import app +from secfoo.runtime import evidence +from secfoo.runtime.evidence import ( + EvidenceError, + ingest_events, + ingest_file, + validate_event, +) +from secfoo.storage.repository import RunRepository + +FP = "sca:pypi:pyyaml:GHSA-xxxx" +OTHER_FP = "sca:npm:lodash:GHSA-yyyy" +SESSION = "8f8a1c2e-0000-4000-8000-000000000001" + +SESSION_LINE = { + "type": "session", "id": SESSION, "language": "python", "runtime": "3.12.4", "source": "sensor", + "started_at": "2026-11-08T10:00:00Z", "ended_at": "2026-11-08T16:00:00Z", "app": "payments-api", + "env": "prod", "instance": "pod-1", "sensor_version": "0.4.0", + "app_functions_total": 1840, "app_functions_executed": 1362, +} +LOAD_LINE = { + "type": "load", "session": SESSION, "ecosystem": "PyPI", "package": "pyyaml", "version": "5.3", + "path": "/app/.venv/lib/python3.12/site-packages/yaml", "first_seen": "2026-11-08T10:00:01Z", +} +HIT_LINE = { + "type": "hit", "session": SESSION, "finding_fingerprint": FP, "symbol": "yaml.load", "route": "POST /upload", + "auth": "anonymous", "count": 312, "first_seen": "2026-11-08T10:05:00Z", "last_seen": "2026-11-08T15:59:00Z", + "entry_frame": "app/routes/upload.py:42", "stack_sample": ["app/routes/upload.py:42", "app/services/parse.py:17"], +} + + +def _lines(*events) -> list[str]: + return [json.dumps(e) for e in events] + + +@pytest.fixture +def repo(tmp_path): + with RunRepository(db_path=tmp_path / "db.sqlite") as repo: + yield repo + + +@pytest.fixture +def project_id(repo): + pid = repo.upsert_project("https://github.com/acme/payments", "payments", "github") + run_uuid = repo.create_run( + project_id=pid, skill_id="sca-reachability", skill_name="SCA", agent_id="claude", confluence_urls=[] + ) + run_id = repo.get_run(run_uuid).id + repo.upsert_sca_finding(project_id=pid, fingerprint=FP, run_id=run_id, current_ref="D1", package="pyyaml", + severity="high", version="5.3", ecosystem="PyPI", reachability="Not reachable") + return pid + + +def _rows(repo, table): + return [dict(r) for r in repo.connection.execute(f"SELECT * FROM {table}").fetchall()] # nosec B608 -- test + + +# ---------- a valid file ---------- + + +def test_valid_file_stores_session_load_and_hit(repo, project_id): + report = ingest_events(repo.connection, project_id, _lines(SESSION_LINE, LOAD_LINE, HIT_LINE)) + + assert (report.sessions, report.loads, report.hits, report.skipped) == (1, 1, 1, []) + [session] = _rows(repo, "runtime_sessions") + assert session["session_uuid"] == SESSION and session["source"] == "sensor" + assert (session["app_functions_total"], session["app_functions_executed"]) == (1840, 1362) + [package] = _rows(repo, "runtime_packages") + assert (package["ecosystem"], package["package"], package["version"]) == ("PyPI", "pyyaml", "5.3") + [hit] = _rows(repo, "runtime_evidence") + assert hit["finding_fingerprint"] == FP and hit["route"] == "POST /upload" and hit["count"] == 312 + assert json.loads(hit["stack_sample"]) == HIT_LINE["stack_sample"] + + +def test_optional_fields_can_be_left_out(repo, project_id): + minimal = _lines( + {"type": "session", "id": "s1", "language": "node", "source": "coverage", "started_at": "2026-11-08T10:00:00"}, + {"type": "hit", "session": "s1", "finding_fingerprint": FP, "symbol": "pyyaml", "count": 1}, + ) + report = ingest_events(repo.connection, project_id, minimal) + assert report.skipped == [] and report.hits == 1 + assert _rows(repo, "runtime_evidence")[0]["route"] == "" # no request context + + +# ---------- malformed lines: skip and report, never abort ---------- + + +def test_malformed_lines_are_skipped_and_reported_but_the_rest_is_stored(repo, project_id): + lines = [ + json.dumps(SESSION_LINE), + "{not json", + json.dumps({**HIT_LINE, "count": 0}), + json.dumps({"type": "bogus"}), + "", # blank lines are ignored, not errors + json.dumps(LOAD_LINE), + json.dumps(HIT_LINE), + ] + report = ingest_events(repo.connection, project_id, lines) + + assert (report.sessions, report.loads, report.hits) == (1, 1, 1) + assert [n for n, _ in report.skipped] == [2, 3, 4] + reasons = dict(report.skipped) + assert "not valid JSON" in reasons[2] + assert "'count'" in reasons[3] + assert "'type'" in reasons[4] + + +def test_hit_for_an_unknown_fingerprint_is_skipped_with_a_warning(repo, project_id): + report = ingest_events( + repo.connection, project_id, _lines(SESSION_LINE, {**HIT_LINE, "finding_fingerprint": OTHER_FP}) + ) + assert report.hits == 0 and report.unknown_fingerprints == {OTHER_FP} + assert "not an SCA finding of this project" in report.skipped[0][1] + + +def test_hit_before_any_session_line_is_skipped(repo, project_id): + report = ingest_events(repo.connection, project_id, _lines(HIT_LINE)) + assert report.hits == 0 and "unknown session" in report.skipped[0][1] + + +def test_findings_of_another_project_do_not_count(repo, project_id): + other = repo.upsert_project("/elsewhere", "elsewhere", "local") + report = ingest_events(repo.connection, other, _lines({**SESSION_LINE}, HIT_LINE)) + assert report.hits == 0 and report.unknown_fingerprints == {FP} + + +# ---------- idempotent re-ingest ---------- + + +def test_reingesting_the_same_file_does_not_double_count(repo, project_id): + lines = _lines(SESSION_LINE, LOAD_LINE, HIT_LINE) + ingest_events(repo.connection, project_id, lines) + ingest_events(repo.connection, project_id, lines) + + assert len(_rows(repo, "runtime_sessions")) == 1 + assert len(_rows(repo, "runtime_packages")) == 1 + [hit] = _rows(repo, "runtime_evidence") + assert hit["count"] == 312 + + +def test_a_later_flush_of_the_same_session_replaces_the_running_total(repo, project_id): + ingest_events(repo.connection, project_id, _lines(SESSION_LINE, HIT_LINE)) + later = {**HIT_LINE, "count": 400, "last_seen": "2026-11-08T17:00:00Z", "first_seen": "2026-11-08T11:00:00Z"} + # The session line isn't repeated: hits attach to a session stored earlier. + ingest_events(repo.connection, project_id, _lines(later)) + + [hit] = _rows(repo, "runtime_evidence") + assert hit["count"] == 400 + assert hit["first_seen"] == "2026-11-08T10:05:00Z" # earliest kept + assert hit["last_seen"] == "2026-11-08T17:00:00Z" # latest kept + + +def test_each_route_is_its_own_row(repo, project_id): + ingest_events(repo.connection, project_id, _lines(SESSION_LINE, HIT_LINE, {**HIT_LINE, "route": "GET /export"})) + assert sorted(r["route"] for r in _rows(repo, "runtime_evidence")) == ["GET /export", "POST /upload"] + + +# ---------- privacy (#51 rule 3) ---------- + + +@pytest.mark.parametrize("route", ["https://shop.example/upload", "POST /upload?token=abc", "GET /a#frag"]) +def test_concrete_urls_are_rejected_as_routes(route): + with pytest.raises(EvidenceError, match="template"): + validate_event({**HIT_LINE, "route": route}) + + +@pytest.mark.parametrize("stack", [["password=hunter2"], [{"frame": "a.py:1"}], ["a.py:1"] * 51, "a.py:1"]) +def test_stack_samples_must_be_file_line_frames(stack): + with pytest.raises(EvidenceError, match="stack_sample"): + validate_event({**HIT_LINE, "stack_sample": stack}) + + +def test_unknown_fields_are_never_stored(repo, project_id): + sneaky = {**HIT_LINE, "args": ["s3cr3t"], "request_body": "card=4111"} + ingest_events(repo.connection, project_id, _lines(SESSION_LINE, sneaky)) + dumped = json.dumps(_rows(repo, "runtime_evidence")) + assert "s3cr3t" not in dumped and "4111" not in dumped + + +@pytest.mark.parametrize( + "event, field", + [ + ({**SESSION_LINE, "language": "cobol"}, "language"), + ({**SESSION_LINE, "source": "guess"}, "source"), + ({**SESSION_LINE, "started_at": "yesterday"}, "started_at"), + ({**SESSION_LINE, "app_functions_executed": 2000}, "app_functions_executed"), + ({**HIT_LINE, "auth": "admin"}, "auth"), + ({**HIT_LINE, "count": True}, "count"), + ({k: v for k, v in LOAD_LINE.items() if k != "version"}, "version"), + ], +) +def test_invalid_fields_name_the_field(event, field): + with pytest.raises(EvidenceError, match=field): + validate_event(event) + + +# ---------- the JSON Schema says the same thing as the validator ---------- + + +def test_json_schema_agrees_with_the_validator(): + jsonschema = pytest.importorskip("jsonschema") + schema = json.loads((Path(evidence.__file__).parent / "evidence.schema.json").read_text(encoding="utf-8")) + jsonschema.Draft202012Validator.check_schema(schema) + validator = jsonschema.Draft202012Validator(schema) + for good in (SESSION_LINE, LOAD_LINE, HIT_LINE): + validate_event(good) + assert validator.is_valid(good), list(validator.iter_errors(good)) + for bad in ( + {**HIT_LINE, "route": "POST /upload?x=1"}, + {**HIT_LINE, "count": 0}, + {**HIT_LINE, "stack_sample": ["no-line-number"]}, + {**SESSION_LINE, "language": "cobol"}, + {"type": "load", "session": SESSION}, + ): + assert not validator.is_valid(bad) + with pytest.raises(EvidenceError): + validate_event(bad) + + +# ---------- migration: works on an existing database ---------- + + +def test_tables_are_added_to_a_database_created_before_they_existed(tmp_path): + db = tmp_path / "old.sqlite" + with RunRepository(db_path=db): + pass + conn = sqlite3.connect(db) + for table in ("runtime_evidence", "runtime_packages", "runtime_sessions"): + conn.execute(f"DROP TABLE {table}") # simulate a store from before #53 + conn.commit() + conn.close() + + with RunRepository(db_path=db) as repo: + tables = {r[0] for r in repo.connection.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert {"runtime_sessions", "runtime_evidence", "runtime_packages"} <= tables + + +# ---------- CLI ---------- + + +def test_cli_ingest_reports_counts_and_skipped_lines(tmp_path, monkeypatch): + db = tmp_path / "db.sqlite" + monkeypatch.setattr("secfoo.cli.RunRepository", lambda: RunRepository(db_path=db)) + with RunRepository(db_path=db) as repo: + pid = repo.upsert_project("https://github.com/acme/payments", "payments", "github") + run_id = repo.get_run(repo.create_run( + project_id=pid, skill_id="sca-reachability", skill_name="SCA", agent_id="claude", confluence_urls=[] + )).id + repo.upsert_sca_finding(project_id=pid, fingerprint=FP, run_id=run_id, current_ref="D1", + package="pyyaml", severity="high") + events = tmp_path / "events.ndjson" + events.write_text("\n".join(_lines(SESSION_LINE, LOAD_LINE, HIT_LINE)) + "\n{broken\n", encoding="utf-8") + + result = CliRunner().invoke(app, ["runtime", "ingest", str(events), "--project", "payments"]) + + assert result.exit_code == 1 # one line was skipped + assert "Stored 1 session(s), 1 package load(s), 1 hit(s)" in result.output + assert "line 4: not valid JSON" in result.output + + clean = tmp_path / "clean.ndjson" + clean.write_text("\n".join(_lines(SESSION_LINE, HIT_LINE)), encoding="utf-8") + again = CliRunner().invoke(app, ["runtime", "ingest", str(clean), "--project", str(pid)]) + assert again.exit_code == 0, again.output + with RunRepository(db_path=db) as repo: + assert repo.connection.execute("SELECT count FROM runtime_evidence").fetchone()[0] == 312 + + +def test_cli_ingest_unknown_project(tmp_path, monkeypatch): + db = tmp_path / "db.sqlite" + monkeypatch.setattr("secfoo.cli.RunRepository", lambda: RunRepository(db_path=db)) + events = tmp_path / "events.ndjson" + events.write_text(json.dumps(SESSION_LINE), encoding="utf-8") + result = CliRunner().invoke(app, ["runtime", "ingest", str(events), "--project", "nope"]) + assert result.exit_code == 1 + assert "no single project matches 'nope'" in result.output + + +def test_ingest_file_reads_a_path(repo, project_id, tmp_path): + path = tmp_path / "e.ndjson" + path.write_text("\n".join(_lines(SESSION_LINE, HIT_LINE)), encoding="utf-8") + assert ingest_file(repo.connection, project_id, path).hits == 1 From d6bcd694755758c412ca96d057db809d6c5b6105 Mon Sep 17 00:00:00 2001 From: karthickmk2001 Date: Wed, 11 Nov 2026 21:05:02 +0000 Subject: [PATCH 2/2] feat(sca): show runtime evidence in the SCA dashboard (#57) Each SCA finding gets a runtime state from #53's tables, strongest first: reached from traffic, executed, loaded (with version drift), not loaded / not observed (always shown with coverage % and observation time), or no runtime data. The vulnerabilities list gains a Runtime column and highlights findings the static scan called Not reachable whose code ran; the detail page shows sessions, routes with auth state, entry frames and stack samples; the overview counts runtime-confirmed findings once runtime data exists. Existing badge styles and color tokens only. Closes #57 Co-Authored-By: Claude Opus 5.5 --- docs/REFERENCE.md | 19 ++ docs/assets/sca-runtime.jpg | Bin 0 -> 70467 bytes src/secfoo/web/sca_dashboard.py | 35 +++ src/secfoo/web/sca_runtime.py | 205 +++++++++++++++++ src/secfoo/web/static/css/dashboard.css | 6 + .../templates/activities/sca_dashboard.html | 13 ++ .../activities/sca_vulnerabilities_list.html | 9 +- .../activities/sca_vulnerability_detail.html | 59 +++++ tests/test_web/test_sca_runtime.py | 216 ++++++++++++++++++ 9 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 docs/assets/sca-runtime.jpg create mode 100644 src/secfoo/web/sca_runtime.py create mode 100644 tests/test_web/test_sca_runtime.py diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 920a2ed..6bc6717 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -408,6 +408,25 @@ finding, symbol and route, with a foreign key to `sca_findings`) and live in `secfoo/runtime/evidence.py` (`ingest_events`), which the live ingest API will reuse. +**In the dashboard.** Once a project has runtime data, the SCA +vulnerabilities list gets a **Runtime** column. Each finding shows the +strongest state seen across the projects it affects: + +| State | Meaning | +|---|---| +| Reached from traffic | A watched symbol ran while handling a request route (shows route, auth state and hits). | +| Executed | It ran, with no request context (startup, a job, or no route). | +| Loaded | The package was in memory, but no watched symbol was called. Flags version drift when the loaded version differs from the scanned one. | +| Not loaded / Not observed | Sessions exist, nothing was seen for this finding. Always shown with the coverage % and observation time. | +| No runtime data | No sensor or coverage import for the project. | + +A finding the static scan rated **Not reachable** whose code was Executed or +Reached is highlighted as a conflict. The vulnerability page shows sessions, +routes, entry frames and stack samples. The SCA overview counts +runtime-confirmed findings, but only once there is runtime data. + +![SCA vulnerabilities list with the Runtime column and a highlighted conflict](assets/sca-runtime.jpg) + ## Install Pick whichever fits how you work — they're all the same tool underneath. diff --git a/docs/assets/sca-runtime.jpg b/docs/assets/sca-runtime.jpg new file mode 100644 index 0000000000000000000000000000000000000000..689f0bd3baad24bc46715fbcd8626cf90353fe4b GIT binary patch literal 70467 zcmdSBby!v1);GL2jdVzDP^6_4q)U(nK~hPP?hXY6k?sZw>F$nAcXzYtkPbmYP~ck| z&pG$4=Q+>!ynj63dwq+G#hi1EHRo@PS!0ed7B@3D%OG4C326xs7z_e|fgjM#DtJa( zR8&Vn>4k*!bMapfdO*O1^cV!Pv~sXhl6*#~rmjJWy8P=6_%mI7dmH%Q|8N2Io=w4P z2Z6>}{sYZ_SB!38Xs-_tdqobo^VqlVBVUe&uAb-IAe|+7vfbh@|ULn6k1XFp0@GpbMUuzz+Mv>qyDDY@)0g=km+qI$Xd~?9HNoEfb(H&V% zfUay3(|KS-J(^?g_4x3M$Tc@fX*{C%|JdUH#sksNmy6TqZ&Rby(P*i@?4i~_e0q== zAn?-RJ|~N5Aaf1=yTUCY)7q#`W07yN5C8vMz+kTf@o@7O@E?`QrJ_QhqWrIK=+=co zOv*%ExnE5(+%%IeCDn`GKm|9KtLJuc0>6FV=zU7oA*WnMEHR%}6x(F`F(TVr_(imfE*m7BMjOKB)H4w zorvFn8ZqzRfQD~CqYGTm zQQhb>l(Ss9wQE*-nXRg*GAO6ezaC49ac?dNf|olIahBil{Q3CE+I>k)RgV?BRkdWo zH<>}K&OH7pvFjWr(*y$^4Rx0bMD#j=H=w;Al~Jpllbli9xcW#j_;r#f8#!F{0b}di zhdMN%NDxv~D)_mPae?z1o(k3JU7*fa(%!uR6S_EokvuiS_8Y;xg`0NsvrS((QY0bT z+P>vYDabJshel2W)MT674UcPie#X?wkva>Mvu>c4)r(eI#p$YPRrq78ldsOerr&CJ zVEllVWLZ2KZF=CVu%=quHLR?vrzjrlnNW1p3rW#)BrRIVE02nPwze|#A~^jlY(}sc z#w<}?{K;6)1O0P%xBS+MPbe8t`9T@z9KSYnR=_##d&`2)gqxAGh^`&~Vc+79SxxWH z#=TwQ+qNkgS9vWAa#pn7y?39;B~cp~d~7B!=KAp}K`L=8ihx2)OchB?SWN8pc0b># z*<|Ny-bzL~OQ>YUyKM~7kYZsF1^*q`tbo&dqtLV}k!%yRdKgO_WKRC-&NtT&KU{FW zB!2%~(-bDC^yd~-t1XTc zvE|bp)n?hwyLy>~AsI!1BuR0xb)O&KfNaw;@vNThm&thA9Z^|PEgbnP@>Py8LSEu2ZoJ2u(GHUiF)>)Z zh}+pQV~20Or5?>_#&*q@B*sKr5`I6UP!*+B;j}Rf)@~i=Fq~@Vs?%AlHZ_}5d?9}x zH_JR4fTSmU-Rg?B3%1SGa8UW~bMM}z=lmCwWk+b*cAe&sv9y0spy+f?KN zj+LqG+|`o2nTCDRydwLJMFUt0*3toOOb<<0BGI*Zxhwavu|PYJL0FLKbsuF{=}y9| z$y$N;Fr@s;BG&;Iw6t-s-W}t0wr?@_%F#&c^R0(yZ?}!@rB7rn-?T5eF6i79)FLT* zUTA=mliRoY+^0krtEwp4lY?xYawrTxu$V01TMK32vr#5`bC?E*H-Nke+4wXDlzCUn zk{eTA`|K3@Xu);XF2!e+Q>E7XNqGV*)ysi)3elmAN9pn}!?9oMYdbSH<;GWj$jA(- zqO&}(E;O`7W$~(jdPi9@ix(~C?srquhLlU3HF0B94RRyB^(?)MM`o|Ei6REJweJCe z=f0}2Gd35b`k4f z5`9rwf4IzlI%wPr?Z{R8uC@a+s(XVe$6iSaUe_#c*4t$FA~b)P9r0C803SLKs4flm zsYc{6?4|fpGZE%eLey5qEWR-&F(pJ-Lr*}h3HeA1;{bhixEE72pLRB?kL(7qYAJGiUeecFj0 zl=z&~uP0G1Iv*d&Forlj&av&IeZu`faJsR$JP|iNCy>{uFejAv!^`s?(1(_&{B?e` zLc&2@w$jG&L;5!zbo`^SZO3zus}t--jm3SYSK4BZ0!^35QHZOhwdP#Zi>D23xtmhY zu~aEY%4B%WX)>4CVldrBAd848soW-wJ(GGr^*og21XS5nxFFU@?3`a}xTdv<3cdzt zbCg$hOgB|kk=EP!eq0z%*u+Zd&>@_=po)|nKPaJn9*J82L`!;rdBnm2@r8>8KPOKo zjhaJDxQf!ih+V~XT8R15-UeMuffx0#@NzaL?%Sk4XB88!3P1Pc)Q7qXuUavzws7csz zD^`tsjv%G6iHF8naWHS1Ll#t}Lsw!v`==xQ;sKSoH!g6e84l zP@i&0e*>y#V2C1ZTg?efe=ZXeK1dqNOTjPtx`kB>YfqSLo<+di)a0ucS8@GYT5|^2 zePs&jHTxA)Vr(1xxvZw~8R7)@)|b+v!J~ZHpM`g%O&_P(y=9K*JQ!lhhH9|5LC%;N z&t91_mKJ6BjIL0Mi9VyT zffeQPNQ^~XHH3)z9tGV4nZ9k!1U}>Eqtsn>zKiQ7?y^0riPV9sdkN}^e(j2M=yc>G zvq_W|p-!O#YS^0gW7lhgVh{pVqEYErikNpN%7>AKSv&F{JQm`rYR)%dj+_-inuDU| zhM9cjO?sl&WwRNl&)x@7X|I2?v1y)4@x^S?(ic@u@`c*Kv*heZv)HjC!f$LxU90Hz z3kfYC)>mA920L3^4`&>oY*Ti{ldsRlDu1VZRY~8D}A-Z*3OKY2QoL2$+a(8J+9HLbT@A}p`N#b%IiQQ}4<=vyX zbQ&KeOP2W-)-jk9h7cK=&zt4*t&_J$FQTZA!kniTR|};GqkqyOR`3*=1&gQM;PdJS z^$FsMCN8!g3mf~fAswLuT?=PZ{dq#9VZ+t6PVAH;%+rgTk|I0f;UTWI3RPki#B<%v71lom!3;Q5UctNWTmE)gY2|pJ1M(MF9%3OeZ>iML-PhoB1Jf!gRBNv!Hk^5{|}l!v-Z`}n)N9LZBTRlPs{5xl-1F^PSfpd2igjmGmrsp$L2xn zy0(?hq&2Iorl#Md>VYYRD&euU#zxOnY^?`|+%OAoP+{eEJBK4~xmxmlNGELB;ekG< zn97gF-SWeG)K=aL-QgNMcoDVEo+jFaM^Z=B7#1V4L5&1G)k8!z^W`0B3E3_T89trC zaWWUuq#9({pAYR1?t}(DV*M%9R7|oQ`|+rp(Wyl`COIDeaU`>~IY*L(Ti;Tix|Cru zqlB0`GgQ`sy-i=p|G>)9?tIrE(}h#(NjE=6x|5L~uQv?lrGM_zI=e$GNyEvU%3(_; zQ7#M39J<1`L~rytb!z%V`(omUZ&ivimzj~O%*DeQDug$?>uv#{FJ$Ud=XU6#6IAOf z=}n=bcZ?Ml3i}>4qf$#dCI8G|I6t$~W_;UED`1_#G7w7a;OteNyc=r9*zqqN7fDPc zog#sadfldWK;90dF|w+{iRag@aj)%&#NLpG_?~D&Uv^?hn_(7CkD+%3w`1og))Msm zj>kJDJGo!ahvlfrr#^jR+rL)|HO_F~y_>B)#$Fk@&CEG^z!)f#%ary+xtHyYt?6Kr zH?D;Ub{R&J+{bbO`Sd0Y7q`Py({^%+gMmIxS1h5SeWETLId$2)qKvbMvK#M<7G!jG z@hz-Idr^7|vMqjiDZPar8!SRLs`iKLRjbfh66uVIVx#h!nVgFuHZF11ku%eetze7^zB6cRZ;{jSq}-T5Y5%0DneqK35n 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�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 literal 0 HcmV?d00001 diff --git a/src/secfoo/web/sca_dashboard.py b/src/secfoo/web/sca_dashboard.py index 1cb2018..542ca0d 100644 --- a/src/secfoo/web/sca_dashboard.py +++ b/src/secfoo/web/sca_dashboard.py @@ -21,10 +21,12 @@ extract_cve_by_dependency_id, extract_narrative_by_dependency_id, extract_risk_register_rows, + findings_with_fingerprints, inventory_lookup, resolve_ecosystem, ) from secfoo.storage.repository import RunRepository +from secfoo.web import sca_runtime from secfoo.web.dashboard_common import read_report _read = read_report @@ -66,6 +68,10 @@ class ScaVulnerability: reachability_evidence: str | None = None recommendation: str | None = None affected_projects: list[dict] = field(default_factory=list) + # #57: strongest runtime state across affected projects (each project's + # own state is on its affected_projects entry as "runtime"). + runtime: sca_runtime.RuntimeStatus = field(default_factory=sca_runtime.RuntimeStatus) + runtime_conflict: bool = False @dataclass @@ -80,6 +86,12 @@ class ScaDashboard: vulnerabilities_by_key: dict[str, ScaVulnerability] = field(default_factory=dict) unmapped_ecosystem_count: int = 0 osv_unreachable: bool = False + # #57: vulnerabilities runtime evidence confirmed, and any where it + # contradicts a static "Not reachable". + runtime_reached_count: int = 0 + runtime_executed_count: int = 0 + runtime_conflict_count: int = 0 + has_runtime_data: bool = False def _is_worse_reachability(candidate: str, current: str) -> bool: @@ -113,6 +125,9 @@ def build(repo: RunRepository) -> ScaDashboard: continue inventory_by_package_version = inventory_lookup(text) + # The persisted sca_findings fingerprint per Risk Register row, which + # runtime evidence (#53) attaches to. + fingerprint_by_id = {r["id"]: r["fingerprint"] for r in findings_with_fingerprints(text)} cve_by_id = extract_cve_by_dependency_id(text) narrative_by_id = extract_narrative_by_dependency_id(text) @@ -130,6 +145,7 @@ def build(repo: RunRepository) -> ScaDashboard: "project_id": project["project_id"], "project_display_name": project["project_display_name"], "dependency_id": row["id"], + "fingerprint": fingerprint_by_id.get(row["id"]), "package": package, "version": version, "issue": row["issue"].strip(), @@ -210,6 +226,14 @@ def build(repo: RunRepository) -> ScaDashboard: if _is_worse_reachability(finding["reachability"], vuln.reachability): vuln.reachability = finding["reachability"] + runtime = sca_runtime.status_for( + repo.connection, + project_id=finding["project_id"], + fingerprint=finding["fingerprint"], + package=finding["package"], + ecosystem=finding["ecosystem_osv"], + scanned_version=finding["version"], + ) vuln.affected_projects.append( { "project_id": finding["project_id"], @@ -218,9 +242,15 @@ def build(repo: RunRepository) -> ScaDashboard: "severity": finding["severity"], "reachability": finding["reachability"], "fixed_in": finding["fixed_in"], + "runtime": runtime, + "runtime_conflict": sca_runtime.is_conflict(finding["reachability"], runtime), } ) + for vuln in vulnerabilities.values(): + vuln.runtime = sca_runtime.strongest([p["runtime"] for p in vuln.affected_projects]) + vuln.runtime_conflict = any(p["runtime_conflict"] for p in vuln.affected_projects) + dash.vulnerabilities = sorted( vulnerabilities.values(), key=lambda v: (-_SEVERITY_RANK.get(v.severity.strip().lower(), -1), v.package.lower()), @@ -250,4 +280,9 @@ def build(repo: RunRepository) -> ScaDashboard: } dash.ecosystem_counts = ecosystem_counter.most_common() + dash.runtime_reached_count = sum(1 for v in dash.vulnerabilities if v.runtime.state == "reached") + dash.runtime_executed_count = sum(1 for v in dash.vulnerabilities if v.runtime.state == "executed") + dash.runtime_conflict_count = sum(1 for v in dash.vulnerabilities if v.runtime_conflict) + dash.has_runtime_data = any(v.runtime.state != "none" for v in dash.vulnerabilities) + return dash diff --git a/src/secfoo/web/sca_runtime.py b/src/secfoo/web/sca_runtime.py new file mode 100644 index 0000000..c6c5e2e --- /dev/null +++ b/src/secfoo/web/sca_runtime.py @@ -0,0 +1,205 @@ +"""Runtime evidence for the SCA dashboard (#57), read from #53's tables. + +Each (project, SCA finding) gets one state from the evidence ladder in #51, +strongest first: + + reached -- a watched symbol was called while handling a request route + executed -- a watched symbol was called, but with no request context + loaded -- the package was in memory, but no watched symbol was called + not_loaded / not_observed -- sessions exist, nothing seen for this finding + none -- no runtime data for the project at all + +"Not observed" never means "not reachable" (#51 rule 4): those states always +carry the coverage and observation window they were measured over. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime + +# Strongest first; the list row shows the strongest state across projects. +STATES = ("reached", "executed", "loaded", "not_loaded", "not_observed", "none") +_RANK = {state: i for i, state in enumerate(STATES)} +LABELS = { + "reached": "Reached from traffic", + "executed": "Executed", + "loaded": "Loaded", + "not_loaded": "Not loaded", + "not_observed": "Not observed", + "none": "No runtime data", +} +# Existing badge styles only (design-system/components/badges.css). +BADGES = { + "reached": "badge--error", + "executed": "badge--warning", + "loaded": "badge--primary", + "not_loaded": "badge--teal", + "not_observed": "badge--neutral", + "none": "badge--neutral", +} +# A hit on one of these "routes" had no inbound request behind it. +_NO_REQUEST_ROUTES = ("", "unknown", "startup") + + +def _is_request_route(route: str) -> bool: + return route not in _NO_REQUEST_ROUTES and not route.startswith("job:") + + +def _parse(ts: str | None) -> datetime | None: + if not ts: + return None + try: + return datetime.fromisoformat(ts[:-1] + "+00:00" if ts.endswith("Z") else ts) + except ValueError: + return None + + +@dataclass +class Coverage: + sessions: int = 0 + functions_total: int = 0 + functions_executed: int = 0 + observed_hours: float = 0.0 + + @property + def percent(self) -> int | None: + if not self.functions_total: + return None + return round(self.functions_executed / self.functions_total * 100) + + @property + def hours_label(self) -> str: + if self.observed_hours >= 48: + return f"{self.observed_hours / 24:.0f}d" + if self.observed_hours >= 1: + return f"{self.observed_hours:.0f}h" + return f"{self.observed_hours * 60:.0f}m" + + +@dataclass +class RuntimeStatus: + state: str = "none" + hits: int = 0 + last_seen: str | None = None + routes: list[dict] = field(default_factory=list) # route, auth, count -- request routes only + evidence: list[dict] = field(default_factory=list) # every hit row, with its session + loaded_versions: list[str] = field(default_factory=list) + version_drift: bool = False + coverage: Coverage = field(default_factory=Coverage) + sources: list[str] = field(default_factory=list) # sensor / coverage + + @property + def label(self) -> str: + return LABELS[self.state] + + @property + def badge(self) -> str: + return BADGES[self.state] + + @property + def summary(self) -> str: + """One-line cell text, e.g. 'Reached · POST /upload (anonymous) · 312'.""" + cov = self.coverage + if self.state == "reached": + top = self.routes[0] + more = f" +{len(self.routes) - 1} more" if len(self.routes) > 1 else "" + auth = f" ({top['auth']})" if top["auth"] else "" + return f"Reached · {top['route']}{auth} · {top['count']:,}{more}" + if self.state == "executed": + return f"Executed · {self.hits:,} hits" + (f" · last {self.last_seen[:10]}" if self.last_seen else "") + if self.state == "loaded": + return "Loaded · no watched call" + (" · version drift" if self.version_drift else "") + if self.state in ("not_loaded", "not_observed"): + pct = f"{cov.percent}% coverage" if cov.percent is not None else "coverage unknown" + return f"{LABELS[self.state]} · {pct} · {cov.hours_label}" + return "No runtime data" + + +def _coverage(conn, project_id: int) -> tuple[Coverage, list[str], bool]: + rows = conn.execute( + "SELECT source, started_at, ended_at, app_functions_total, app_functions_executed, " + "EXISTS (SELECT 1 FROM runtime_packages p WHERE p.session_id = s.id) AS has_inventory " + "FROM runtime_sessions s WHERE project_id = ?", + (project_id,), + ).fetchall() + cov = Coverage(sessions=len(rows)) + sources: set[str] = set() + has_inventory = False + for source, started, ended, total, executed, inventory in rows: + sources.add(source) + has_inventory = has_inventory or bool(inventory) + if total and executed is not None: + cov.functions_total += total + cov.functions_executed += executed + start, end = _parse(started), _parse(ended) + if start and end and end > start: + cov.observed_hours += (end - start).total_seconds() / 3600 + return cov, sorted(sources), has_inventory + + +def status_for(conn, *, project_id: int, fingerprint: str | None, package: str, + ecosystem: str | None, scanned_version: str | None) -> RuntimeStatus: + """The runtime state of one SCA finding in one project.""" + status = RuntimeStatus() + status.coverage, status.sources, has_inventory = _coverage(conn, project_id) + if not status.coverage.sessions: + return status # "none": no sensor, no import -- SCA works exactly as before + + if fingerprint: + rows = conn.execute( + "SELECT e.symbol, e.route, e.auth, e.count, e.first_seen, e.last_seen, e.entry_frame, e.stack_sample, " + "s.app, s.env, s.instance, s.source, s.language, s.started_at, s.ended_at " + "FROM runtime_evidence e JOIN runtime_sessions s ON s.id = e.session_id " + "WHERE e.project_id = ? AND e.finding_fingerprint = ? ORDER BY e.count DESC", + (project_id, fingerprint), + ).fetchall() + routes: dict[tuple[str, str | None], int] = {} + for (symbol, route, auth, count, first_seen, last_seen, entry_frame, stack, app, env, instance, source, + language, started_at, ended_at) in rows: + status.hits += count + if last_seen and (status.last_seen is None or last_seen > status.last_seen): + status.last_seen = last_seen + if _is_request_route(route): + routes[(route, auth)] = routes.get((route, auth), 0) + count + status.evidence.append({ + "symbol": symbol, "route": route or "—", "auth": auth, "count": count, + "first_seen": first_seen, "last_seen": last_seen, "entry_frame": entry_frame, + "stack_sample": json.loads(stack) if stack else [], + "app": app, "env": env, "instance": instance, "source": source, "language": language, + "started_at": started_at, "ended_at": ended_at, + }) + status.routes = [ + {"route": r, "auth": a, "count": c} + for (r, a), c in sorted(routes.items(), key=lambda item: -item[1]) + ] + if status.routes: + status.state = "reached" + return status + if status.hits: + status.state = "executed" + return status + + loaded = conn.execute( + "SELECT DISTINCT version FROM runtime_packages WHERE project_id = ? AND lower(package) = lower(?) " + "AND (? IS NULL OR ecosystem = ?)", + (project_id, package, ecosystem, ecosystem), + ).fetchall() + status.loaded_versions = sorted(v for (v,) in loaded) + if status.loaded_versions: + status.state = "loaded" + status.version_drift = bool(scanned_version) and scanned_version not in status.loaded_versions + return status + status.state = "not_loaded" if has_inventory else "not_observed" + return status + + +def strongest(statuses: list[RuntimeStatus]) -> RuntimeStatus: + return min(statuses, key=lambda s: _RANK[s.state]) if statuses else RuntimeStatus() + + +def is_conflict(static_reachability: str, status: RuntimeStatus) -> bool: + """The case runtime evidence exists to catch: the static scan said + "Not reachable", but the code actually ran.""" + return static_reachability.strip().lower() == "not reachable" and status.state in ("reached", "executed") diff --git a/src/secfoo/web/static/css/dashboard.css b/src/secfoo/web/static/css/dashboard.css index 51ad39d..b4e9832 100644 --- a/src/secfoo/web/static/css/dashboard.css +++ b/src/secfoo/web/static/css/dashboard.css @@ -412,3 +412,9 @@ tr.run-row:hover { background-color: var(--color-primary-50); } .stride-key { display: inline-flex; align-items: center; gap: var(--space-1); } .stride-key__swatch { width: 0.75rem; height: 0.75rem; border-radius: 3px; display: inline-block; } + +/* #57: an SCA row whose static verdict is "Not reachable" but whose code ran + at runtime -- the case runtime evidence exists to catch. Existing error + tokens only, no new colors. */ +.table tr.runtime-conflict > td { background-color: var(--color-error-bg); } +.table tr.runtime-conflict > td:first-child { box-shadow: inset 3px 0 0 var(--color-error); } diff --git a/src/secfoo/web/templates/activities/sca_dashboard.html b/src/secfoo/web/templates/activities/sca_dashboard.html index 0b96b55..b3356e6 100644 --- a/src/secfoo/web/templates/activities/sca_dashboard.html +++ b/src/secfoo/web/templates/activities/sca_dashboard.html @@ -42,6 +42,19 @@ {{ sca_dash.reachability_counts.reachable }} Reachable + {% if sca_dash.has_runtime_data %} + {% set runtime_confirmed = sca_dash.runtime_reached_count + sca_dash.runtime_executed_count %} +

+ {% call info_tip("Runtime-confirmed reachable") %} +

Vulnerabilities whose vulnerable code actually ran in a deployed app, from a runtime sensor or an imported coverage file.

+

Reached from traffic means it ran while handling a request. Executed means it ran, with no request context.

+ {% endcall %} + {{ runtime_confirmed }} + + Runtime-confirmed reachable: {{ sca_dash.runtime_reached_count }} reached from traffic, {{ sca_dash.runtime_executed_count }} executed{% if sca_dash.runtime_conflict_count %}. {{ sca_dash.runtime_conflict_count }} contradict{{ "s" if sca_dash.runtime_conflict_count == 1 }} a static "Not reachable"{% endif %} + +
+ {% endif %}
{% call info_tip("Unrecognized ecosystem") %}

Findings whose package ecosystem OSV.dev does not recognize, so they could not be checked against a known advisory.

diff --git a/src/secfoo/web/templates/activities/sca_vulnerabilities_list.html b/src/secfoo/web/templates/activities/sca_vulnerabilities_list.html index a488282..162a670 100644 --- a/src/secfoo/web/templates/activities/sca_vulnerabilities_list.html +++ b/src/secfoo/web/templates/activities/sca_vulnerabilities_list.html @@ -25,12 +25,12 @@

All vulnerabilities

SeverityCVE / IssuePackageVersion - ReachabilityFixed inApplications + ReachabilityRuntimeFixed inApplications {% for v in vulnerabilities %} - + {{ v.severity }} {% if v.cve_id %}{{ v.cve_id }}{% else %}unverified {{ v.issue }}{% endif %} @@ -38,6 +38,11 @@

All vulnerabilities

{{ v.package }} {{ v.version }} {{ v.reachability }} + + {{ v.runtime.label }} + {% if v.runtime.state != "none" %}{{ v.runtime.summary }}{% endif %} + {% if v.runtime_conflict %}Conflicts with static verdict{% endif %} + {{ v.fixed_in }} {{ v.affected_projects | length }} diff --git a/src/secfoo/web/templates/activities/sca_vulnerability_detail.html b/src/secfoo/web/templates/activities/sca_vulnerability_detail.html index f3c2ae1..1447020 100644 --- a/src/secfoo/web/templates/activities/sca_vulnerability_detail.html +++ b/src/secfoo/web/templates/activities/sca_vulnerability_detail.html @@ -56,6 +56,65 @@

Risk & recommendation

{% endif %} + {% if vulnerability.runtime_conflict %} + + {% endif %} + +
+

Runtime evidence

+ {% for p in vulnerability.affected_projects %} + {% set rt = p.runtime %} +
+
+ {{ p.project_display_name }} + {{ rt.label }} + {% if p.runtime_conflict %}Conflicts with static verdict{% endif %} + {% if rt.sources %}Source: {{ rt.sources | join(", ") }}{% endif %} +
+ {% if rt.state == "none" %} +

No runtime sensor or coverage import for this project yet. See secfoo runtime ingest.

+ {% elif rt.state in ("not_loaded", "not_observed") %} +

+ {{ rt.summary }} across {{ rt.coverage.sessions }} session(s). + This is not proof the code is unreachable: traffic only exercised + {% if rt.coverage.percent is not none %}{{ rt.coverage.percent }}%{% else %}an unknown share{% endif %} of the app. +

+ {% elif rt.state == "loaded" %} +

+ Loaded version{{ "s" if rt.loaded_versions | length > 1 }}: {{ rt.loaded_versions | join(", ") }}. + {% if rt.version_drift %}Version drift: production runs a different version than the {{ vulnerability.version }} that was scanned.{% endif %} + No watched vulnerable function was called. +

+ {% else %} +
+ + + + {% for e in rt.evidence %} + + + + + + + + + + {% if e.stack_sample %} + + {% endif %} + {% endfor %} + +
SymbolRouteAuthHitsLast seenApp / envEntry frame
{{ e.symbol }}{{ e.route }}{{ e.auth or "—" }}{{ "{:,}".format(e.count) }}{{ (e.last_seen or "—")[:19] }}{{ e.app or "—" }}{% if e.env %} / {{ e.env }}{% endif %}{% if e.entry_frame %}{{ e.entry_frame }}{% else %}—{% endif %}
Stack sample: {% for f in e.stack_sample %}{{ f }}{% if not loop.last %} ← {% endif %}{% endfor %}
+
+ {% endif %} +
+ {% endfor %} +
+

Affected applications

{% if vulnerability.affected_projects %}
diff --git a/tests/test_web/test_sca_runtime.py b/tests/test_web/test_sca_runtime.py new file mode 100644 index 0000000..e6846b8 --- /dev/null +++ b/tests/test_web/test_sca_runtime.py @@ -0,0 +1,216 @@ +"""#57: runtime evidence in the SCA dashboard -- every runtime state and the conflict highlight.""" +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from secfoo.report.sca import findings_with_fingerprints +from secfoo.runtime.evidence import ingest_events +from secfoo.storage.repository import RunRepository +from secfoo.web import sca_dashboard, sca_runtime +from secfoo.web.app import create_app + +REPORT = """\ +# SCA Report — Reachability & Upgrade Triage + +## 1. Executive Summary +Test summary. **Overall risk rating:** High + +## 2. Dependency Inventory + +| Package | Version | Direct/Transitive | Ecosystem | Source manifest | +|---------|---------|--------------------|-----------|------------------| +| pyyaml | 5.3 | Direct | Python | requirements.txt | + +## 4. Risk Register + +| ID | Package | Version | Issue | Severity | Reachability | Fixed in | +|----|---------|---------|-------|----------|---------------|----------| +| D1 | pyyaml | 5.3 | Arbitrary code execution in full_load | High | {reachability} | 5.4 | + +## 5. Detailed Findings + +### [HIGH] D1: pyyaml@5.3 — code execution +- **Package / version:** pyyaml 5.3, **CVE:** unverified, **Severity:** High +- **Reachability:** {reachability} +- **Recommendation:** upgrade to 5.4 +""" + +SESSION = {"type": "session", "id": "s-1", "language": "python", "source": "sensor", + "started_at": "2026-11-08T10:00:00Z", "ended_at": "2026-11-08T16:00:00Z", + "app": "payments-api", "env": "prod", "app_functions_total": 1000, "app_functions_executed": 740} + + +@pytest.fixture +def store(tmp_path, monkeypatch): + db_path = tmp_path / "db.sqlite" + monkeypatch.setattr("secfoo.storage.repository.DB_PATH", db_path) + monkeypatch.setattr("secfoo.storage.repository.ensure_store_dirs", lambda: None) + monkeypatch.setattr("secfoo.web.sca_dashboard.osv.enrich_lookups", lambda repo, keys, **kw: False) + return tmp_path + + +def _seed(tmp_path, *, reachability="Not reachable", events=()): + """One project, one SCA finding (persisted the way the runner does it), + and optional runtime events ingested through #53's real code path.""" + text = REPORT.format(reachability=reachability) + with RunRepository() as repo: + pid = repo.upsert_project("https://github.com/acme/payments", "payments", "github") + aid = repo.create_assessment(project_id=pid, assessment_type="internal", status="ready") + run_uuid = repo.create_run(project_id=pid, skill_id="sca-reachability", skill_name="SCA", + agent_id="claude", confluence_urls=[], assessment_id=aid) + path = tmp_path / f"{run_uuid}.md" + path.write_text(text, encoding="utf-8") + repo.complete_run(run_uuid, status="success", exit_code=0, duration_seconds=1.0, report_path=str(path), + prompt_path=None, stderr_excerpt=None, high_count=1) + [row] = findings_with_fingerprints(text) + repo.upsert_sca_finding(project_id=pid, fingerprint=row["fingerprint"], run_id=repo.get_run(run_uuid).id, + current_ref="D1", package="pyyaml", severity="High", version="5.3", + ecosystem="PyPI", reachability=reachability) + if events: + lines = [json.dumps({**e, "finding_fingerprint": row["fingerprint"]} if e["type"] == "hit" else e) + for e in events] + report = ingest_events(repo.connection, pid, lines) + assert report.skipped == [], report.skipped + return row["fingerprint"] + + +def _hit(**overrides): + return {"type": "hit", "session": "s-1", "symbol": "yaml.full_load", "count": 312, + "last_seen": "2026-11-08T15:59:00Z", **overrides} + + +def _vuln(): + with RunRepository() as repo: + [vuln] = sca_dashboard.build(repo).vulnerabilities + return vuln + + +# ---------- each state of the evidence ladder ---------- + + +def test_no_runtime_data(store): + _seed(store) + vuln = _vuln() + assert vuln.runtime.state == "none" and vuln.runtime.label == "No runtime data" + assert not vuln.runtime_conflict + + +def test_reached_from_traffic_shows_route_auth_and_count(store): + _seed(store, events=[SESSION, _hit(route="POST /upload", auth="anonymous")]) + rt = _vuln().runtime + assert rt.state == "reached" + assert rt.summary == "Reached · POST /upload (anonymous) · 312" + + +def test_hits_without_request_context_are_executed(store): + _seed(store, events=[SESSION, _hit(route="startup", count=4), _hit(route="job:nightly-import", count=9)]) + rt = _vuln().runtime + assert rt.state == "executed" and rt.hits == 13 + assert rt.summary.startswith("Executed · 13 hits · last 2026-11-08") + + +def test_loaded_package_with_no_watched_call_and_version_drift(store): + _seed(store, events=[SESSION, {"type": "load", "session": "s-1", "ecosystem": "PyPI", + "package": "PyYAML", "version": "5.4.1"}]) + rt = _vuln().runtime + assert rt.state == "loaded" and rt.loaded_versions == ["5.4.1"] + assert rt.version_drift # production runs 5.4.1, the scan saw 5.3 + + +def test_not_loaded_when_the_inventory_lacks_the_package_shows_coverage(store): + _seed(store, events=[SESSION, {"type": "load", "session": "s-1", "ecosystem": "PyPI", + "package": "requests", "version": "2.31.0"}]) + rt = _vuln().runtime + assert rt.state == "not_loaded" + assert rt.summary == "Not loaded · 74% coverage · 6h" # never shown without its coverage + + +def test_not_observed_when_sessions_have_no_inventory(store): + _seed(store, events=[SESSION]) + rt = _vuln().runtime + assert rt.state == "not_observed" and rt.summary == "Not observed · 74% coverage · 6h" + + +def test_strongest_state_wins_across_projects(): + reached, loaded = sca_runtime.RuntimeStatus(state="reached"), sca_runtime.RuntimeStatus(state="loaded") + assert sca_runtime.strongest([loaded, reached]) is reached + assert sca_runtime.strongest([]).state == "none" + + +# ---------- the conflict highlight ---------- + + +@pytest.mark.parametrize("route", ["POST /upload", "startup"]) +def test_not_reachable_but_ran_is_a_conflict(store, route): + _seed(store, reachability="Not reachable", events=[SESSION, _hit(route=route)]) + assert _vuln().runtime_conflict + + +def test_reachable_and_ran_is_not_a_conflict(store): + _seed(store, reachability="Reachable", events=[SESSION, _hit(route="POST /upload")]) + assert not _vuln().runtime_conflict + + +def test_not_reachable_and_only_loaded_is_not_a_conflict(store): + _seed(store, reachability="Not reachable", events=[SESSION, {"type": "load", "session": "s-1", + "ecosystem": "PyPI", "package": "pyyaml", "version": "5.3"}]) + assert not _vuln().runtime_conflict + + +# ---------- rendered pages ---------- + + +def test_list_page_shows_runtime_column_and_highlights_the_conflict(store): + _seed(store, events=[SESSION, _hit(route="POST /upload", auth="anonymous")]) + html = TestClient(create_app()).get("/activities/sca-reachability/vulnerabilities").text + assert "Runtime" in html + assert 'data-runtime-state="reached"' in html + assert "Reached · POST /upload (anonymous) · 312" in html + assert 'class="run-row runtime-conflict"' in html + assert "Conflicts with static verdict" in html + + +def test_list_page_without_runtime_data_says_so_and_has_no_highlight(store): + _seed(store) + html = TestClient(create_app()).get("/activities/sca-reachability/vulnerabilities").text + assert 'data-runtime-state="none"' in html and "No runtime data" in html + assert "runtime-conflict" not in html + + +def test_detail_page_shows_sessions_routes_frames_and_the_conflict(store): + _seed(store, events=[SESSION, _hit(route="POST /upload", auth="anonymous", entry_frame="app/routes/upload.py:42", + stack_sample=["app/routes/upload.py:42", "app/services/parse.py:17"])]) + client = TestClient(create_app()) + key = _vuln().key + html = client.get(f"/activities/sca-reachability/vulnerabilities/{key}").text + assert "Runtime evidence contradicts the static verdict" in html + assert "yaml.full_load" in html and "POST /upload" in html and "anonymous" in html + assert "payments-api / prod" in html + assert "app/routes/upload.py:42" in html and "app/services/parse.py:17" in html + assert "Source: sensor" in html + + +def test_detail_page_not_observed_never_claims_unreachable(store): + _seed(store, events=[SESSION]) + key = _vuln().key + html = TestClient(create_app()).get(f"/activities/sca-reachability/vulnerabilities/{key}").text + assert "This is not proof the code is unreachable" in html and "74%" in html + + +def test_overview_counts_runtime_confirmed_only_when_there_is_runtime_data(store): + _seed(store) + client = TestClient(create_app()) + assert 'id="runtime-confirmed"' not in client.get("/activities/sca-reachability").text + + with RunRepository() as repo: + [finding] = repo.list_sca_findings() + ingest_events(repo.connection, finding.project_id, [ + json.dumps(SESSION), json.dumps({**_hit(route="POST /upload"), "finding_fingerprint": finding.fingerprint}), + ]) + html = client.get("/activities/sca-reachability").text + assert 'id="runtime-confirmed"' in html + assert "1 reached from traffic, 0 executed" in html + assert '1 contradicts a static "Not reachable"' in html