Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<uuid>","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":"<uuid>","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":"<uuid>","finding_fingerprint":"<sca_findings.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:<name>`, `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.
Expand Down
43 changes: 43 additions & 0 deletions src/secfoo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"


Expand Down
5 changes: 5 additions & 0 deletions src/secfoo/runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Loading
Loading