Walk through a CVE remediation¶
What you'll learn:
- How a package that trips your CVE policy ends up in
pending_reviewinstead 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 daysin_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.
(If you haven't already configured insecure-registries for local testing,
see Push your first container image Step 1
first.)
Expected output ends with:
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'
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_riskdecision with a mandatory justification, and watched it actually publish the previously-withheld image
Next steps¶
- Security pipeline explained → — the full 6-step validation pipeline and where CVE policy fits in
- CVE review workflow →
- Container Registry → — access control, retention policies, retroactive scanning of natively-pushed images