CVE review workflow¶
When the security pipeline finds vulnerabilities in a package, two outcomes are possible: automatic rejection (for CVEs that trigger a block policy) or human review (for CVEs that trigger a review policy). This page explains the review path — why it exists, who participates, what information drives the decision, and what happens technically when a decision is made.
The problem with "block everything"¶
The instinct to block any package with a high-severity CVE is understandable. The problem is that severity classifications were designed to describe the worst-case impact of a vulnerability in isolation, not its practical risk in a specific environment.
A CVE with CVSS 9.8 in a library your application calls for string formatting — but only with inputs that your application never provides in the vulnerable form — is not the same risk as a CVSS 9.8 in a library that parses untrusted network input from the internet. Both get the same score. A block-everything policy treats them identically.
The practical consequences of overly aggressive automatic blocking are:
- False positives cause bypass pressure. When legitimate packages are blocked too often, operators seek workarounds. The answer to "this scanner keeps blocking our deployments" should not be "skip the scanner" — but that is the direction friction pushes.
- Accepted business risks cannot be expressed. Some CVEs affect components that are not present in your deployment (e.g., a vulnerability in a Windows code path inside a cross-platform library). Some risks are explicitly accepted by the CISO with compensating controls. A binary block provides no mechanism to record these decisions.
- Business continuity requires nuance. A critical security patch for one vulnerability may ship in a package that contains a different unpatched medium CVE. Blocking the entire package prevents the critical patch from deploying.
The review queue solves this by separating the detection signal from the response. The pipeline flags the issue; a human makes the call; the audit trail captures the reasoning.
The review queue concept¶
When a package's CVE scan produces matches that trigger a review policy at the applicable severity level, the pipeline sets cve_status: pending_review. The package binary is moved to /repos/pool/ and a manifest is created — it is stored, not discarded — but it is not registered in the APT dists/ index. The package cannot be installed by apt until a decision is made.
This is meaningfully different from rejection (quarantine). A quarantined package failed a hard check and is presumed unsafe. A pending_review package passed all format, integrity, and AV checks — it is not known to be malicious — but it carries CVEs that policy says require human judgment.
The review queue is visible in the Repod UI to users with admin, maintainer, or auditor roles. The queue shows all packages awaiting a decision, sorted by SLA deadline and EPSS risk score.
Who reviews¶
Repod enforces a separation of duties between the people who can upload packages and the people who can approve them for deployment.
| Role | Can upload | Can view review queue | Can approve / reject | Can read audit log |
|---|---|---|---|---|
reader |
No | No | No | No |
uploader |
Yes | No | No | No |
maintainer |
Yes | Yes | Yes | Yes |
auditor |
No | Yes | No | Yes |
admin |
Yes | Yes | Yes | Yes |
maintainer and admin can approve or reject packages in the review queue. auditor has read-only visibility — they can examine the CVE details and the queue state for compliance purposes, but they cannot make a decision. uploader cannot view the queue at all: the person who uploads a package cannot be the same person who approves it for deployment.
In practice, the auditor role is designed for your security team (CISO and their analysts) to monitor the queue without being able to act unilaterally, while maintainer and admin are the designated decision makers.
Full workflow diagram¶
sequenceDiagram
actor Dev as Developer / CI
participant API as Backend API
participant Pipeline as Validation pipeline
participant Queue as Review queue
actor Admin as Admin (CISO)
participant APT as APT repository
Dev->>API: POST /upload/ (package.deb)
API->>Pipeline: run_validation_pipeline()
Pipeline-->>Pipeline: Steps 1–6 (format, SHA256, AV, CVE...)
Pipeline-->>API: cve_status: pending_review
API->>Queue: Save manifest (status: pending_review)
API-->>Dev: 200 OK — pending CVE review
Note over Queue: Package stored in pool/<br/>but NOT in dists/
API--)Admin: Webhook / email notification
Admin->>Queue: GET /security/packages-posture
Admin->>Queue: Review CVE details, EPSS, KEV flags, SLA
alt Admin approves
Admin->>API: POST /security/packages/{name}/{version}/decide<br/>action: accept_risk, justification: "..."
API->>Queue: Save decision JSON → /repos/security/decisions/
API->>APT: reprepro.add_package() → reprepro includedeb
APT-->>APT: Regenerate dists/, sign InRelease
API->>Queue: Update manifest status: accepted_risk
API->>API: Audit log: SECURITY_DECISION / SUCCESS
APT-->>Dev: Package available via apt install
else Admin rejects
Admin->>API: POST /security/packages/{name}/{version}/decide<br/>action: reject, justification: "..."
API->>Queue: Save decision JSON → /repos/security/decisions/
API->>Queue: Move binary to staging/quarantine/
API->>Queue: Update manifest status: quarantined
API->>API: Audit log: SECURITY_DECISION / SUCCESS
Note over APT: Package never enters dists/
end
What the CISO sees¶
For each package in the review queue, the UI presents a structured breakdown of every CVE finding. The information is sourced from the manifest's cve_results array, which is populated during the Grype scan and enriched with EPSS and CISA KEV data.
| Field | Source | What it tells you |
|---|---|---|
| CVE ID | Grype / NVD | The canonical identifier — links to NVD and vendor advisories |
| Severity | CVSS base score category | Critical / High / Medium / Low / Negligible |
| CVSS score + vector | NVD | The numeric score and the attack vector breakdown (AV, AC, PR, UI, S, C, I, A) |
| EPSS probability | FIRST.org (daily) | 0–100% likelihood of exploitation in 30 days |
| CISA KEV | CISA catalog | Flag: this CVE is being actively exploited in the wild right now |
| Affected component | Grype artifact match | The specific library or binary inside the .deb that carries the vulnerability |
| Fix available | Grype fix state | fixed (with version), not-fixed, or wont-fix |
| SLA remaining | Computed from policy | Days until the review deadline, color-coded (green / amber / red) |
The display is sorted by a composite risk score: KEV-flagged CVEs appear first, then sorted by EPSS descending, then by CVSS. A CVSS 9.8 with EPSS 0.003 will appear below a CVSS 7.2 with EPSS 0.94 because the probability-adjusted risk is higher for the latter.
EPSS explained¶
EPSS — the Exploit Prediction Scoring System — is a daily score published by FIRST.org for every CVE in the NVD. It answers a different question than CVSS.
- CVSS asks: how bad could this be if exploited? (impact-oriented, static)
- EPSS asks: how likely is this to be exploited in the next 30 days? (probability-oriented, dynamic)
The score is a float from 0.0 to 1.0, representing a probability. FIRST.org derives it from a machine learning model trained on real-world exploitation evidence, vulnerability characteristics, and threat intelligence feeds.
Why EPSS is more actionable than CVSS alone:
Consider two CVEs found in the same package:
| CVSS score | EPSS score | Interpretation | |
|---|---|---|---|
| CVE-A | 9.8 (Critical) | 0.003 (0.3%) | Severe theoretical impact, but almost never targeted in practice — likely too complex or too narrow to exploit reliably |
| CVE-B | 7.2 (High) | 0.94 (94%) | Moderately severe but actively being exploited by many threat actors right now |
A CVSS-only policy blocks CVE-A and flags CVE-B at a lower priority. An EPSS-aware review treats CVE-B as the urgent item. Repod presents both signals together so the reviewer can make this distinction without needing to manually cross-reference external databases.
Repod fetches EPSS scores via the FIRST.org API and caches them for 24 hours in /repos/security/epss_cache.json. Scores are updated on each pipeline run for any CVE not already in the fresh cache.
CISA KEV explained¶
The CISA Known Exploited Vulnerabilities (KEV) catalog is a curated list maintained by the US Cybersecurity and Infrastructure Security Agency. A CVE appears in KEV when CISA has confirmed evidence of active exploitation in the wild — not theoretical exploitation, not proof-of-concept code, but observed attacks.
CISA updates the catalog continuously as new exploitation evidence is confirmed. The catalog includes a dateAdded field (when exploitation was first confirmed) and a dueDate field (the mandatory remediation deadline for US federal agencies under Binding Operational Directive 22-01).
Why KEV is the most urgent signal Repod can show:
EPSS is a prediction. KEV is a fact. A CVE in the KEV catalog has been observed being used by real attackers against real systems. For the purpose of the Repod review queue, a KEV flag on any CVE — regardless of CVSS or EPSS score — should be treated as the highest-priority review item.
Repod fetches the full KEV JSON feed and caches it at /repos/security/kev_cache.json with a 24-hour TTL. During enrichment, each CVE ID from the Grype scan is checked against the KEV set. If there is a match, in_kev: true is set on the CVE record in the manifest, and the CVE is flagged with a visual indicator in the review queue.
In air-gapped environments, the KEV cache populated during the last internet-connected run remains available until it expires. After expiration, KEV flags are omitted from enrichment output rather than treated as false negatives — the cache miss is logged.
Making a decision¶
Every decision in the review queue requires a mandatory justification text. The UI will not submit the form without it, and the API enforces a non-empty justification field. This is not a UX nuisance — it is the mechanism that makes the audit trail legally meaningful.
The available decision types map to distinct lifecycle outcomes:
| Action | Technical outcome | Justification example |
|---|---|---|
accept_risk |
Package promoted to APT index; decision recorded with optional expiry date | "CVE-2024-1234 affects the TLS 1.0 code path which is disabled in our deployment configuration. Risk accepted until patch available." |
exception |
Same as accept_risk, with a mandatory expiry date | "Temporary exception for Project X rollout — network isolation compensates. Valid until 2026-08-01." |
upgrade_required |
Package withheld; sets a target version; triggers SLA countdown | "Package must be upgraded to 3.0.8 which patches this CVE. Deploying the patched version is required." |
reject |
Package moved to quarantine; permanent — no expiry | "CVE-2023-0286 is KEV-flagged with EPSS 14%. Rejection is mandatory." |
accept_risk and exception decisions can be given an expiry (in days). When the expiry date passes, the SLA scheduler automatically reverts the package status to pending_review and notifies via webhook/email. This forces a periodic re-evaluation of accepted risks rather than allowing them to accumulate silently.
Both approval and rejection are logged as SECURITY_DECISION events in the daily JSONL audit file. The decision is also persisted as a JSON file in /repos/security/decisions/<name>_<version>_<arch>.json — separate from the audit log — so it can be queried by the scheduler and the review queue display without parsing log files.
SLA tracking¶
Each severity level can have a configurable SLA: the number of days within which a pending_review package must receive a decision. The SLA countdown is visible in the review queue and in the package detail view.
The SLA alert scheduler runs daily at 08:00. It checks all active decisions for expiry and packages in pending_review for SLA breaches. Packages where the SLA is within 7 days receive a warning notification (webhook + email). Expired decisions are reverted to pending_review automatically.
| State | Visual indicator |
|---|---|
| SLA > 7 days remaining | Green — no action required yet |
| SLA ≤ 7 days remaining | Amber — review recommended soon |
| SLA expired | Red — overdue; escalation required |
| No SLA configured | No indicator |
After the decision¶
When approved (accept_risk or exception):
The backend calls services/reprepro.py:add_package() with the package filename and target distribution — the single entry point for reprepro includedeb, shared with the direct upload and internet-import paths. It runs reprepro includedeb <distribution> <path> directly against the shared /repos/ volume. Reprepro adds the package to the APT index, regenerates Packages.gz, updates the Release file, and re-signs InRelease using the GPG key at /repos/gnupg. The manifest's status field is updated to accepted_risk or exception. From this point, apt update && apt install <package> will find and install the package.
When rejected:
The binary file — which was stored in /repos/pool/ during the pending_review period — is moved to /repos/staging/quarantine/<name>_<version>_<arch>.deb. The manifest's status is updated to quarantined. The package never appears in dists/ and cannot be installed via APT. The decision JSON in /repos/security/decisions/ records the rejection permanently.
In both cases, a webhook notification is sent to the configured URL (if webhook_enabled: true in settings) and an email notification is sent to the configured addresses.
Multi-format coverage (Maven, PyPI, npm)¶
Everything described above — the review queue, the decision_records model,
the mandatory justification, SLA tracking, the audit trail — applies
identically to Maven, PyPI, and npm artifacts. A decision is keyed by
(package, version, arch) with no notion of package format baked into the
schema, so the exact same POST /security/packages/{name}/{version}/decide
endpoint, the exact same review queue UI, and the exact same
accept_risk/exception/reject/upgrade_required actions apply to a
.jar, a wheel, or a tarball exactly as they do to a .deb or .rpm.
Two mechanical differences are worth understanding, because the visibility mechanism differs from APT/RPM even though the decision mechanism doesn't:
- No separate "add to repo tool" step. APT/RPM have a distinct moment
where
reprepro/createrepo_cpublishes a package into the distribution tree — that's the step apending_reviewpackage skips. Maven/PyPI/npm don't have an equivalent tool-level publish step; the artifact is already in storage the moment it's uploaded. Visibility is instead controlled directly by the manifest'sstatusfield: any listing or download endpoint (the PyPI Simple API, the npm packument, a direct Maven GAV download) checks the status and returns 404 for anything inpending_review,quarantined, orupgrade_required— the artifact physically exists in storage but is unreachable through any client (pip,npm,mvn) until a decision resolves it. - Maven's index is a generated file, not a live query. PyPI's Simple API
page and npm's packument are computed fresh from the database on every
request, so a status change takes effect immediately.
maven-metadata.xmlis a static file written to storage — accepting or rejecting a Maven artifact triggers an explicit regeneration of that file so the version listmvn/gradlesee stays in sync with the decision.
First-party scan scope. The Grype scan described throughout this page
only ever looks at the artifact's own bytes — the jar/wheel/tarball you
uploaded — never its declared dependencies. A CVE in a library that gets
pulled in later, at the consumer's build time (npm install, mvn
resolving a version range, pip resolving a requirement), is invisible to
that scan. This is a separate, distinct gap, closed by the dependency scan
described next.
Dependency CVE scan (npm, Maven, PyPI)¶
For npm, Maven, and PyPI, repod runs a second, independent check against the
package's declared dependencies — not the artifact's own bytes, and not a
full transitive resolution (no npm ls/mvn dependency:tree-equivalent
tree-walk), just the direct dependencies as declared:
- npm —
package.json'sdependenciesobject, taken directly from the publish payload (or from registry import). - Maven — the
.pomfile's<dependencies>block (excluding<dependencyManagement>, which pins versions for sub-modules rather than declaring a real dependency of the artifact itself). - PyPI — the
requires_distfields from atwine upload, or from the PyPI JSON API on import. Manual upload with no accompanying dependency metadata is not scanned — there is nothing to scan without opening the archive.
Each declared dependency name is queried against OSV.dev,
a free, keyless public vulnerability database, by package name only — no
version-range matching. This is deliberate and over-inclusive by design:
matching a declared range (^1.2.3, a Maven version inherited from a
parent POM, a PEP 508 specifier) against a precise resolved version is
unreliable, and the project's standing principle is that over-signaling is
safe while under-signaling risks hiding a real vulnerability. The result is
every vulnerability ever published for that package name in that ecosystem
— the human reviewer is responsible for judging whether the version actually
in use is affected.
Feeds the same cve_policy, with one deliberate downgrade. A dependency
finding is evaluated against the identical severity → action mapping used
for the package's own Grype scan, and a review-or-worse result flips the
package to pending_review in the same RSSI queue described throughout this
page — no separate review path, no new state. The one difference: a block
verdict from a dependency finding is always downgraded to review, never an
outright rejection. Because the match is by name only, a meaningful share of
"Critical" hits turn out to be a version already fixed upstream — hard-
blocking a legitimate publish on that noisy a signal would be
disproportionate. A dependency finding can only ever add a review the
package wouldn't otherwise have had — it never downgrades or overrides a
block already decided by the package's own Grype scan.
Stored separately from cve_results (which covers the package's own code)
in a dedicated dependency_cve_scan field, shown in its own section of the
CVE review UI — never merged with the first-party findings, since they are
answering a different question. A network failure while querying OSV.dev
never blocks the publish: the dependency scan simply stays empty for that
package, exactly as if it predated this feature.
Beyond the upload-time scan: dual-scan and CVE re-matching¶
The Grype scan at upload/import time is a single snapshot: one engine, scanning the artifact's own bytes, once. Two further mechanisms extend coverage over time, and they are complementary rather than redundant — each closes a different gap:
| Mechanism | What changes | Scope | Trigger |
|---|---|---|---|
| Dependency scan (above) | Different scan target — declared dependencies instead of the artifact itself | npm, Maven, PyPI | At publish/import time |
| Dual-scan | Different engine — Trivy as a second opinion alongside Grype | SaaS only, opt-in | Periodic sweep of already-published packages |
| CVE re-matching | Same engine, re-run as its vulnerability database changes over time | All package formats, every edition, enabled by default | Daily, automatically |
Dual-scan: Grype + Trivy cross-verification (SaaS only)¶
No CVE scanner is exhaustive — different engines have different matching
blind spots, and repod does not claim Grype's initial scan catches
everything. In SaaS deployments, a second independent engine, Trivy, re-scans
already-published packages (deb, rpm, apk, Maven, PyPI, npm — OCI images are
excluded, since they live in the separate Zot registry rather than
pool/-equivalent storage) on a daily periodic sweep. Trivy runs in server
mode against a dedicated sidecar so its vulnerability database stays loaded
in memory across scans — proportionate to shared SaaS compute, not run in
Enterprise on-premise or Community deployments, and opt-in even within SaaS.
Dual-scan never runs in the upload's blocking path — it only re-examines
packages already marked validated, so it can never add latency to an
upload or import. This is correlation, not replacement: Grype's original
cve_results are never discarded. Each CVE entry gains a detected_by
field — ["grype"], ["trivy"], or ["grype", "trivy"] — visible in the
UI as a badge on each CVE row. A CVE both engines agree on is shown
understated (it isn't new information); a CVE Trivy alone found is the
signal that actually matters.
Only a finding with detected_by == ["trivy"] — something Grype's original
scan missed entirely — can trigger a new policy decision. A CVE Trivy
re-confirms that Grype already found is not new information and never
re-triggers a review, however severe. As with the dependency scan, a
Trivy-only block verdict is downgraded to review: a package already
published can't be retroactively hard-blocked, only sent back to the RSSI
queue. The raw Trivy output and last-scan timestamp are kept in a separate
trivy_scan field for audit, alongside the correlated cve_results.
CVE re-matching via stored SBOM¶
A package is scanned by Grype exactly once, at upload or import time. If a CVE is published for one of its components the next day, nothing previously detected it — dual-scan's Trivy cross-check is SaaS-only and opt-in, and in any case is a different engine looking at the same one-time snapshot. CVE re-matching closes this gap directly: it re-runs the same engine, Grype, against its own vulnerability database — refreshed daily regardless — for every already-published package, across all seven supported formats (deb, rpm, apk, Maven, PyPI, npm, OCI) and every edition, including Community.
The key mechanical detail: re-matching never re-opens or re-extracts the
original package file. A CycloneDX SBOM is captured as a byproduct of the
original Grype scan (Grype can emit both the vulnerability report and an
SBOM in a single pass) and stored on disk. Re-matching runs grype
sbom:<stored-file>, which re-applies the current vulnerability database
against that stored component list — a local operation, measurably cheaper
than a full scan since no binary extraction is involved.
cve_results is refreshed wholesale with the new match set on every
re-match, but only CVE IDs absent from the previous scan are evaluated
against cve_policy to decide whether the package needs review. A
previously-known CVE that stops matching (for example, because the Grype
database corrected a prior false positive) never retroactively re-triggers
a decision — it simply drops out of the refreshed list. As with dual-scan,
a policy breach found this way — whether the configured action is block
or review — routes the package to the same RSSI pending_review queue;
there is no automatic quarantine action here, since re-matching only ever
touches a package that is already published.
Unlike dual-scan, CVE re-matching runs by default, daily, in every deployment — it is not opt-in. A package with no stored SBOM (published before this feature existed, or where SBOM capture failed) is simply skipped, not treated as an error.
Application package posture (separate from Fleet Compliance)¶
Repod's "Fleet Compliance" metric (GET /compliance/summary) is — and
remains — a machine-centric number: the percentage of managed machines
free of active CVEs, computed from inventory_cve joined against
inventory_clients. A Maven/PyPI/npm artifact is never "installed" on a
machine Repod manages — it's consumed by mvn/pip/npm on a developer
workstation or a CI runner that Repod has no visibility into. Folding
these artifacts into Fleet Compliance would conflate two genuinely
different questions, so they aren't.
Instead, GET /compliance/summary/packages reports a separate posture
indicator over the repository's application packages: how many published
Maven/PyPI/npm artifacts exist, broken down by format, how many carry a
Critical or High severity finding, and how many are currently sitting in
the review queue. Treat it as "supply-chain security posture" alongside,
not folded into, "fleet compliance."
Promotion (between repositories, not environments)¶
For APT/RPM, "promotion" moves a package between distributions that
typically represent deployment stages (e.g. jammy-testing → jammy).
For Maven/PyPI/npm, there is no equivalent notion of a machine environment
to promote into — these artifacts aren't deployed to machines at all.
What does carry over is the idea of a curated repository: promoting a
Maven/PyPI/npm artifact copies it (and its checksums) from one repository/
index/namespace to another — for example, from an internally-tested
repository into the one your CI/CD actually points at for releases.
POST /maven/repositories/{name}/promote, POST
/pypi/repositories/{name}/promote, and POST /npm/repositories/{name}/promote
each take the package name, version, and target repository. Promotion:
- Re-checks the target repository's content filters (a package allowed in the source repository isn't automatically allowed in the target).
- Refuses to promote anything still in
pending_revieworquarantined— promotion cannot be used to route around a pending security decision. - Copies the artifact bytes and re-derives checksums at the destination (never trusts checksums computed for the source location).
- For Maven specifically, regenerates the destination's
maven-metadata.xmlso the promoted version becomes resolvable.
Audit trail¶
Every decision is captured as a structured JSONL entry in the daily audit file at /repos/audit/YYYY-MM-DD.jsonl. The following is a real example from a production Repod instance — a CVE approval with mandatory justification:
{
"timestamp": "2026-05-11T15:12:58.443791+00:00",
"action": "SECURITY_DECISION",
"user": "admin",
"result": "SUCCESS",
"package": "openssl",
"version": "3.0.2-0ubuntu1",
"detail": "Action : accept_risk | Justification : CVEs corrigées dans la prochaine mise à jour planifiée. Risque acceptable en environnement contrôlé. | Expire : 2026-06-10T15:12:57.916475+00:00"
}
And a corresponding rejection:
{
"timestamp": "2026-05-11T15:15:29.397702+00:00",
"action": "SECURITY_DECISION",
"user": "admin",
"result": "SUCCESS",
"package": "libssl3",
"version": "3.0.2-0ubuntu1",
"detail": "Action : reject | Justification : CVE-2023-0286 est activement exploitée (KEV CISA) avec EPSS 14%. Rejet immédiat. | Expire : jamais"
}
The audit log is append-only at the filesystem level — no API endpoint can modify or delete entries. Each day creates a new file; the backend never opens a previous day's file for writing. For SIEM integration, the JSONL format is directly consumable by Elasticsearch, Splunk, and most log aggregation platforms.
Querying the audit trail
The GET /artifacts/audit/logs endpoint returns recent entries in reverse chronological order. Use GET /artifacts/audit/package/<name> to retrieve the complete history of a specific package across all dates — including every upload attempt, CVE decision, quarantine event, and deletion.