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 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_c publishes a package into the distribution tree — that's the step a pending_review package 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's status field: 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 in pending_review, quarantined, or upgrade_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.xml is a static file written to storage — accepting or rejecting a Maven artifact triggers an explicit regeneration of that file so the version list mvn/gradle see 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:

  • npmpackage.json's dependencies object, taken directly from the publish payload (or from registry import).
  • Maven — the .pom file's <dependencies> block (excluding <dependencyManagement>, which pins versions for sub-modules rather than declaring a real dependency of the artifact itself).
  • PyPI — the requires_dist fields from a twine 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-testingjammy). 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_review or quarantined — 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.xml so 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.