██████╗██╗ ██╗███████╗ ██████╗ ██╗ ██████╗ ███████╗███████╗████████╗
██╔════╝██║ ██║██╔════╝ ██╔══██╗██║██╔════╝ ██╔════╝██╔════╝╚══██╔══╝
██║ ██║ ██║█████╗ ██║ ██║██║██║ ███╗█████╗ ███████╗ ██║
██║ ╚██╗ ██╔╝██╔══╝ ██║ ██║██║██║ ██║██╔══╝ ╚════██║ ██║
╚██████╗ ╚████╔╝ ███████╗ ██████╔╝██║╚██████╔╝███████╗███████║ ██║
╚═════╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝ ╚═╝
_
_ _ __ _ _ __ _ __ __ _ _ __ ___ _ _| |_
| '_/ _` | '_ \ '_ \/ _` | '_ \/ _ \ '_| _|
|_| \__,_| .__/ .__/\__,_| .__/\___/_| \__|
|_| |_| |_|
Agentic CVE triage that prioritises what will actually hurt you, and treats its own LLM boundary as an attack surface.
An autonomous pipeline that ingests fresh CVEs, enriches them with real exploitation signal (EPSS + CISA KEV), ranks them against asset context, and uses a guardrailed LLM agent to produce a tiered daily digest. Built originally as a workflow on Torq; this repository is a portable, open reference implementation plus the design and security reasoning behind it.
The differentiator is not the summarisation. It is
docs/threat-model.md: the pipeline threat-models itself against prompt injection, excessive agency, and triage-integrity failures, and brings detection engineering to bear on its own abuse. The step-by-step execution view is indocs/mechanisms.md.
A security team drowns in CVEs. Raw CVSS over-alerts (everything is "critical"), and a naive "summarise today's CVEs with an LLM" tool makes it worse: it launders third-party text through a model with no notion of exploitability, asset relevance, or its own trust boundary.
feed -> enrich (EPSS + KEV) -> prioritise (context-weighted: estate + VEX) -> guardrailed agent -> tiered digest
untrusted text deterministic, not the model LLM summarise-only data, not actions
Three decisions carry the design:
- Judgement is deterministic, not delegated to the model. KEV membership and EPSS probability decide priority in code (
cve_digest/prioritize.py); a KEV item always outranks a non-KEV one. The LLM never invents severity; it summarises what we already ranked. - Feed text is hostile by default. A CVE description (and its id) is attacker-authorable text that ends up inside a prompt. It is validated, sanitised, fenced, and confined to a DATA channel that can never become instructions (
cve_digest/guardrails.py). - The agent has no agency to abuse. It gets read-only enrichment lookups and returns schema-validated data. No write tools, no actions. Output that contradicts ground truth (e.g. a model downgrading a KEV item) is dropped to a human-review lane, not trusted.
Per-CVE branching (KEV items get deeper enrichment; P3 items get one deterministic line, no model call), bounded read-only tool use, and a self-check pass that re-validates output against the facts we hold and the CVE we asked about. A single mega-prompt cannot make those control-flow and integrity decisions; a plain script cannot summarise. The orchestration is the point. See docs/architecture.md.
| Signal | Source | Role |
|---|---|---|
| Known exploited | CISA KEV | Ground-truth override -> always P1 |
| Exploit probability (30d) | FIRST EPSS | Primary weight |
| Impact ceiling | CVSS base | Tie-context only |
| Estate presence | your SBOM, matched via OSV | Multiplier on non-KEV items |
| Exploitability in context | your VEX (OpenVEX / CycloneDX) | Assertion below KEV; suppresses non-KEV noise |
CVSS tells you how bad it could be. EPSS and KEV tell you how likely it is to hurt you now. Estate presence tells you whether it is even your problem.
This is the load-bearing decision of the whole project. CVSS scores a vulnerability's worst-case technical severity once, at disclosure, and never updates. Triage on CVSS alone and most of the catalogue reads "high" or "critical" - the digest just reproduces the firehose it was built to replace. EPSS answers the question triage actually needs: not how bad could this be but how likely is it to be exploited in the next 30 days. It is FIRST's machine-learning model, retrained daily on real exploitation evidence (public exploit code, sensor and honeypot telemetry, threat intelligence), so it moves as the threat landscape moves instead of staying frozen at the disclosure score.
KEV and EPSS are complementary halves. KEV is reactive and high-confidence: CISA lists a CVE only after exploitation in the wild is confirmed, so a hit is observed ground truth, and the pipeline treats it as an unconditional P1 override. EPSS is proactive and predictive: it flags what is likely to be weaponised before it reaches KEV, buying lead time to patch ahead of the attacker. Leaning on both is exactly why CVSS is demoted to tie-context and never sets a tier on its own.
The payoff is compression of the urgent queue. Published analyses of EPSS-based prioritisation report shrinking the "patch this now" set from the roughly 20-30% of vulnerabilities a high-CVSS threshold flags to about 2-5%, at little cost to real coverage (a figure from the EPSS literature, not something this pipeline measures). For a team facing a daily wall of CVEs, that compression is the difference between a digest people act on and one they learn to mute.
Presence matching goes through OSV rather than CPE, and that is deliberate. NVD matches on CPE, SBOMs carry package URLs, and there is no authoritative purl-to-CPE mapping; bridging the two means fuzzing the CPE string and accepting silent false positives. OSV is keyed the way an SBOM is, and its aliases field carries the CVE identifiers for the same vulnerability, giving an exact join key with no name translation anywhere in the path. Optional and fail-open: with no SBOM configured (CVE_DIGEST_SBOM), or with a failed lookup, every CVE classifies as unknown at a neutral weight and ranking is unchanged. Unknown is never treated as absent - OSV silence means a product sits outside its ecosystems, not that we lack it.
Estate presence answers is this component ours. VEX answers the sharper question: is the vulnerable path even reachable in how we ship it. An operator-supplied VEX document (OpenVEX or CycloneDX-embedded, path from CVE_DIGEST_VEX) carries a per-CVE status - not_affected, affected, fixed, under_investigation - and not_affected is the highest-value noise cut available, because it kills confirmed false positives that presence matching alone cannot: the vulnerable function is never called, the feature is compiled out, a config mitigates it.
The design point is where VEX sits in the trust order. A VEX statement is an assertion, not observed fact, so it is ranked strictly below KEV ground truth. It flows only through the non-KEV context weight, and KEV scoring ignores that weight, so no not_affected can demote a known-exploited CVE out of the top tier - an assertion never overrides observed exploitation in the wild. This is the same principle as "prompts are not controls", applied to a second input: the more authoritative signal wins by construction, not by configuration. Within its lane VEX does take precedence over OSV inference (affected overrides an estate absent down-rank, because a producer statement keyed to the product beats a name-based guess), and every applied status is surfaced in the digest and logged, so suppression is auditable, never silent. Fail-open both ways: a missing or malformed document changes nothing.
| Trust boundary | Control | Code |
|---|---|---|
| Feed ingestion (B1) | id validation + sanitize_feed_text + fenced DATA channel |
guardrails.py, agent.build_prompt |
| Model output (B2) | schema + bounds, KEV integrity pin, id self-check, review lane | guardrails.validate_output, agent.triage |
| Delivery (B3) | escape at render, per channel; trusted markup added only by us | digest.render, delivery.render_email, delivery.render_slack |
All guardrail events are logged so the run log is SIEM-ingestable. They span every layer: ingest (event=rejected, nvd_pages_exhausted), enrichment (epss_batch_failed, estate, osv_pages_exhausted, vex), the model boundary (injection_marker, kev_downgrade_dropped, schema_reject, cve_id_mismatch), and the review lane (unscored). A drop is never silent - it is a structured event a detection can fire on.
pip install -e ".[dev]" # runtime + dev (pytest, ruff, mypy)
export NVD_API_KEY=... # optional, raises the NVD rate limit
export LLM_API_KEY=... # your provider; read by agent.call_model (never hardcode)
# wire agent.call_model to your provider / self-hosted model / Torq orchestrator, then:
cve-digest # or: python -m cve_digest.run.env is supported (loaded via python-dotenv) and gitignored.
ruff check . && mypy cve_digest && pytest -qtests/ pins the load-bearing surface: sanitisation, render escaping (no live links/mentions), id validation, schema bounds, the KEV integrity pin and downgrade routing, the P3 no-model guarantee, KEV dominance, and an end-to-end build_digest run with no network. CI (.github/workflows/ci.yml) runs lint + type + tests on every push and PR; the scheduled digest job is separate and disabled until call_model is wired.
- Reference implementation. The
call_modellayer is a stub you wire to your provider or self-hosted model; the guardrail contract around it is the part that matters. It runs out of the box with no provider: the model call is isolated per item, so P1/P2 items route to the human-review lane (with a notice) instead of crashing, and P3 items still get deterministic lines. Wirecall_modelto turn the review lane back into summaries. - Estate weighting is only as good as your SBOM coverage, and OSV covers open-source ecosystems: commercial software and hardware in the daily feed will classify as
unknown. Expect that to be the common case. Without an SBOM, non-KEV ranking is exploitation-signal only. - The estate index is rebuilt from OSV on every run, with sequential record hydration; for a large container SBOM that is thousands of requests whose results change only when the SBOM does. Caching the index keyed by SBOM digest belongs to the planned state layer (dedup + watermark) rather than a one-off here.
- Exposure (is the affected instance internet-facing, is it production) is not modelled. An SBOM answers presence, not exposure; that axis needs cloud resource context and is not implemented here.
- EPSS/KEV cover breadth, not everything. Targeted or zero-day activity will not appear until the signal catches up. The digest is triage support, not a replacement for detection and hunting.
The codename is Rappaport, from Stanislaw Lem's novel His Master's Voice (Glos Pana), whose subject is a signal of unknown origin and the scientists trying to read it. Lem's argument runs through the whole book: the meaning a team extracts from an ambiguous message reflects the team more than the message, and no interpretation of it can be verified from the inside.
That is the discipline this pipeline enforces in code. A model's reading of a CVE
never overrides ground truth (the KEV integrity gate); provenance keeps measured
signal apart from inferred; and genuine doubt is routed to human review rather
than closed by projection. The two names sit together on purpose, as the banner shows: cve-digest is the
package and plainly what it produces, a triaged digest of CVEs; Rappaport is
the codename and the discipline described above.
Originally built as an agentic workflow on Torq. This repo is a clean, portable reimplementation and write-up. No proprietary workflow internals and no client-specific data are included.
Jerzy Siwecki - security engineering, detection, cloud posture, and AI-native security automation.
MIT. See LICENSE.