Skip to content

Walk through a CVE remediation

What you'll learn:

  • How a package that trips your CVE policy ends up in pending_review instead of silently publishing
  • How to read the finding as a reviewer would (severity, EPSS, KEV)
  • How to record an RSSI decision and see the outcome take effect

Time: ~15 minutes Prerequisites: Repod running locally with the OCI registry overlay enabled (docker compose -f docker-compose.yaml -f docker-compose.oci.yml up -d), curl, jq, an admin account

What's real here, and what isn't

Everything in this tutorial — the import, the review queue, the decision endpoint, the outcome — is the real pipeline; nothing about the mechanism is simulated. What we can't guarantee from a documentation page is which CVE, if any, Grype's vulnerability database will report for a given image on the day you run this — that depends on what's been published since the image was built and when Grype's database was last refreshed on your instance. So instead of naming a specific CVE ID (which could easily be stale or wrong by the time you read this), Step 1 temporarily relaxes your CVE policy so that whatever Grype finds — even a single Medium-severity match — is guaranteed to route through the review queue. The CVE details you'll actually see are real findings from your own Grype database, not fabricated ones.


Step 1 — Make this reproducible: route every finding to review

By default, cve_policy blocks critical findings outright (they never reach the review queue at all) and only routes high to review. To guarantee this walkthrough reaches the review queue regardless of what an old image actually turns up, temporarily set every severity down to review:

TOKEN=$(curl -s -X POST http://YOUR_HOST:8000/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"YourPassword1!"}' \
  | jq -r .access_token)

curl -s -X PATCH http://YOUR_HOST:8000/api/v1/settings/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cve_policy": {"critical": "review", "high": "review", "medium": "review"}}' \
  | jq '.cve_policy'

Expected response:

{
  "critical": "review",
  "high": "review",
  "medium": "review",
  "low": "allow",
  "negligible": "allow",
  "sla_critical_days": 0,
  "sla_high_days": 30,
  "sla_medium_days": 90,
  "auto_enrich": true
}

PATCH /settings/ deep-merges — only the keys you send are changed, so sla_*/auto_enrich stay as they were. Remember to revert this at the end (Step 7) — outside a tutorial, blocking nothing at critical isn't a policy you want left on.


Step 2 — Import a package likely to have findings

A package built recently, from a maintained base, may well come back clean — which would make for a short, uninteresting tutorial. Instead, import a Docker Hub image old enough that some CVE has almost certainly been published against its bundled packages since it was built:

curl -s -X POST http://YOUR_HOST:8000/api/v1/oci/import \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"image": "python", "tag": "3.6.0", "repository": "cve-walkthrough"}' \
  | jq .

The Importer runs the same synchronous pipeline as a native upload: download, ClamAV, Grype, then your CVE policy decides the outcome. Expected response (with Step 1's relaxed policy in effect):

{
  "status": "pending_review",
  "name": "python",
  "version": "3.6.0",
  "message": "en attente de révision RSSI (non publiée)",
  "steps": [
    { "name": "antivirus", "passed": true, "message": "ClamAV — clean" },
    { "name": "cve", "passed": true, "message": "Grype — N vulnerabilities found, policy: review" }
  ]
}

Got \"status\": \"added\" instead?

Grype genuinely found nothing to flag for this exact tag on your instance's current database — rare for python:3.6.0, but possible if your Grype DB is freshly refreshed and unusually generous, or if the tag was already imported clean earlier. Pick an even older tag (e.g. python:2.7.0 or node:8.0.0) and re-run this step.

The image was not pushed to the registry — pending_review means the bytes exist only in a temporary staging area during the scan; nothing is servable from docker pull until a decision is made.


Step 3 — Look at it from the review queue

This is what a maintainer or admin sees in Security → Review Queue:

curl -s -H "Authorization: Bearer $TOKEN" \
  "http://YOUR_HOST:8000/api/v1/security/review-queue" | jq '.packages[] | select(.name=="python")'

Expected shape (your actual counts and worst severity will vary):

{
  "name": "python",
  "version": "3.6.0",
  "arch": "linux_amd64",
  "distribution": "cve-walkthrough",
  "pkg_format": "oci",
  "status": "pending_review",
  "worst_severity": "High",
  "cve_counts": { "critical": 0, "high": 2, "medium": 5, "low": 3, "negligible": 0, "unknown": 0 },
  "total_cve": 10,
  "kev_count": 0,
  "high_epss_count": 1,
  "cve_results": [ "... full per-CVE detail, see Step 4 ..." ],
  "decision": null,
  "sla": { "has_sla": false }
}

Note the arch field (linux_amd64 for an amd64 image) — you'll need it for the two calls below, since OCI manifests are keyed by platform, not a fixed architecture string.


Step 4 — Review the actual findings

curl -s -H "Authorization: Bearer $TOKEN" \
  "http://YOUR_HOST:8000/api/v1/security/packages/python/3.6.0/cve?arch=linux_amd64" | jq .

Each entry in cve_results looks like this (a real, individual finding from your Grype database — id/severity/description will be whatever Grype actually matched):

{
  "id": "CVE-XXXX-XXXXX",
  "severity": "High",
  "cvss": 7.5,
  "description": "...",
  "package_name": "...",
  "package_version": "...",
  "fix_state": "fixed",
  "fix_versions": ["..."],
  "urls": ["https://..."],
  "epss_percent": "1.2%",
  "epss_label": "Faible",
  "in_kev": false
}
  • epss_percent — FIRST.org's predicted probability this CVE is exploited in the wild in the next 30 days
  • in_kev — whether CISA's Known Exploited Vulnerabilities catalog lists this CVE as actively exploited (a much stronger signal than severity alone)
  • fix_state — whether upstream has already published a fixed version

This is exactly the information a reviewer weighs before deciding.


Step 5 — Record the RSSI decision

Four actions are available: accept_risk (publish anyway, risk accepted), exception (same, but with an expiry — expires_in_days), reject (quarantine, never publish), and upgrade_required (blocked until a target version). For this walkthrough, accept the risk — a reasonable call for a demo image you're about to delete anyway:

curl -s -X POST http://YOUR_HOST:8000/api/v1/security/packages/python/3.6.0/decide \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "accept_risk",
    "justification": "Tutorial walkthrough — demo image, not used in production.",
    "arch": "linux_amd64"
  }' | jq .

Expected response:

{
  "status": "ok",
  "package": "python",
  "version": "3.6.0",
  "action": "accept_risk",
  "new_status": "accepted_risk",
  "decision": {
    "action": "accept_risk",
    "justification": "Tutorial walkthrough — demo image, not used in production.",
    "decided_by": "admin",
    "...": "..."
  },
  "message": "python accepté avec risque — publié dans cve-walkthrough"
}

justification is mandatory — the endpoint returns 400 without one. This matters for audit: every decision is permanently tied to who made it, when, and why.


Step 6 — See the outcome take effect

accept_risk on an imported (not-yet-published) image does something real: Repod re-pulls the exact same digest it scanned in Step 2 and pushes it to the registry — the decision is what actually makes it servable.

docker pull YOUR_HOST:5000/cve-walkthrough/python:3.6.0

(If you haven't already configured insecure-registries for local testing, see Push your first container image Step 1 first.)

Expected output ends with:

Status: Downloaded newer image for YOUR_HOST:5000/cve-walkthrough/python:3.6.0

Confirm the manifest agrees:

curl -s -H "Authorization: Bearer $TOKEN" \
  "http://YOUR_HOST:8000/api/v1/security/packages/python/3.6.0/decision?arch=linux_amd64" | jq '.status, .decision.action'

"accepted_risk"
"accept_risk"

Had you run reject instead, the image would stay permanently unpublished (for an OCI import, reject discards the content — there's no pool-style quarantine to restore from later; you'd re-import from the source to try again).


Step 7 — Clean up

Remove the demo image, and put your CVE policy back the way it was (defaults shown — adjust if you'd customized it before this tutorial):

curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "http://YOUR_HOST:8000/api/v1/oci/repositories/cve-walkthrough/tags/3.6.0"

curl -s -X PATCH http://YOUR_HOST:8000/api/v1/settings/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cve_policy": {"critical": "block", "high": "review", "medium": "warn"}}' \
  | jq '.cve_policy'

docker rmi YOUR_HOST:5000/cve-walkthrough/python:3.6.0

Troubleshooting

Step 2 returns \"status\": \"blocked\" even after Step 1

Double-check the PATCH /settings/ response in Step 1 actually shows critical/high/medium all set to \"review\" — a typo in the JSON body silently leaves the default in place for the misspelled key (deep-merge only touches keys you send correctly).

Step 3's review queue is empty

GET /review-queue only lists packages with status in pending_review/blocked — if Step 2 returned \"status\": \"added\", there's nothing to review yet. See the note under Step 2.

decide returns 409 'Ce paquet n'est pas en révision'

A decision was already recorded for this exact name/version/arch (re-running Step 5 twice, for example) — check GET /security/packages/python/3.6.0/decision?arch=linux_amd64 to see the existing decision.


What you just did

  • Made a controlled CVE-policy change so the walkthrough works deterministically, regardless of what any specific image happens to have today
  • Imported a real public image through the full validated pipeline (ClamAV + Grype) and watched a policy match route it to pending_review
  • Read a real finding's severity, EPSS score, and KEV status the way a reviewer would
  • Recorded an accept_risk decision with a mandatory justification, and watched it actually publish the previously-withheld image

Next steps