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 | No | Yes |
auditor |
No | Yes | No | Yes |
admin |
Yes | Yes | Yes | Yes |
Only 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. This is separation of duties: 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 admin is the designated decision maker.
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–7 (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}/decision<br/>action: accept_risk, justification: "..."
API->>Queue: Save decision JSON → /repos/security/decisions/
API->>APT: add-deb.sh → 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}/decision<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 /scripts/add-deb.sh with the package path and target distribution. This script invokes reprepro includedeb <distribution> <path> inside the depot-apt volume context. 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.
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 /audit/logs endpoint returns recent entries in reverse chronological order. Use GET /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.