Skip to content

Repository files navigation

 ██████╗██╗   ██╗███████╗    ██████╗ ██╗ ██████╗ ███████╗███████╗████████╗
██╔════╝██║   ██║██╔════╝    ██╔══██╗██║██╔════╝ ██╔════╝██╔════╝╚══██╔══╝
██║     ██║   ██║█████╗      ██║  ██║██║██║  ███╗█████╗  ███████╗   ██║
██║     ╚██╗ ██╔╝██╔══╝      ██║  ██║██║██║   ██║██╔══╝  ╚════██║   ██║
╚██████╗ ╚████╔╝ ███████╗    ██████╔╝██║╚██████╔╝███████╗███████║   ██║
 ╚═════╝  ╚═══╝  ╚══════╝    ╚═════╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝   ╚═╝

                                                                         _
                                _ _ __ _ _ __ _ __  __ _ _ __  ___ _ _| |_
                              | '_/ _` | '_ \ '_ \/ _` | '_ \/ _ \ '_|  _|
                              |_| \__,_| .__/ .__/\__,_| .__/\___/_|  \__|
                                                       |_|  |_|        |_|

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 in docs/mechanisms.md.


The problem

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.

The approach

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:

  1. 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.
  2. 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).
  3. 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.

Why agentic, not a cron + script

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.

Prioritisation logic

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.

Why EPSS carries the weight, not CVSS

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.

VEX: exploitability in context, ranked below ground truth

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.

Security posture at a glance

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.

Quickstart

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.

Tests and CI

ruff check . && mypy cve_digest && pytest -q

tests/ 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.

Honest scope and limitations

  • Reference implementation. The call_model layer 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. Wire call_model to 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 name

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.

Provenance

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.

Author

Jerzy Siwecki - security engineering, detection, cloud posture, and AI-native security automation.

License

MIT. See LICENSE.

Releases

Contributors

Languages