Skip to content

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.