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
77 changes: 77 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,83 @@ 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.

**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.
Expand Down
Binary file added docs/assets/sca-runtime.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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