Skip to content

Enable dual-scan (Trivy cross-check)

Dual-scan runs Trivy as a second, independent vulnerability engine against packages Grype already published, to surface what Grype's initial scan missed. This guide covers enabling it and reading its results — for why it exists as a separate periodic sweep rather than a second blocking scan at upload time, see services/dual_scan.py's module docstring, summarized below.

SaaS only

Dual-scan is available only in Repod SaaS. It requires the depot-trivy sidecar container, which exists only in docker-compose.saas.yml — there is no equivalent overlay for EE on-premise or CE, and the feature has no effect if enabled anywhere else. services/dual_scan.py:is_enabled() checks DEPLOYMENT_MODE=saas first and returns False unconditionally otherwise, regardless of what settings.json says.


1. Prerequisites

  • Deployment mode must be saas (DEPLOYMENT_MODE=saas), with the depot-trivy service running (docker-compose.saas.yml starts it automatically — no separate setup script).
  • admin role, to change settings.json["dual_scan"].
  • The dual_scan license feature must be entitled on the tenant's plan (see PLANS in services/tenant_manager.py).

2. What it does — and doesn't do

  • Trivy never runs in the upload/import path. Grype alone still decides accept / pending_review / block at upload time — unchanged. Running two full scans synchronously per upload would double upload latency for no benefit.
  • Trivy only runs in a periodic background sweep (dual_scan_daily cron) over packages already in status = "validated" — deb, rpm, apk, Maven, PyPI, and npm artifacts (OCI is excluded; its images are scanned in full by Grype already via oci-dir:, so there's no equivalent Trivy-only gap for it).
  • Each CVE in a scanned package's cve_results gains a detected_by field: ["grype"], ["trivy"], or ["grype", "trivy"]. Grype's own results are never discarded or overwritten.
  • Only a CVE with detected_by == ["trivy"] — genuinely new information Grype missed — can change a package's fate. A CVE both engines already agree on never re-triggers a decision, even if it's Critical.
  • A ["trivy"]-only finding that breaches cve_policy flips the package to pending_review and routes to the same RSSI decision queue as any other CVE decision (POST /security/packages/{name}/{version}/decide) — no new review mechanism. block is downgraded to review here, never an outright re-rejection: a Trivy-only match is name/version-based and noisier than Grype's own findings, so hard-blocking an already-published package on it would be disproportionate.

3. Enable it

curl -s -X PATCH http://localhost:8000/api/v1/settings/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "dual_scan": {
      "enabled": true,
      "hour": 5,
      "minute": 15,
      "max_artifacts_per_run": 50,
      "max_runtime_minutes": 30
    }
  }'
Field Default Meaning
enabled false Must be explicitly set true — opt-in, same convention as mirror/backup/drift.
hour / minute 5 / 15 UTC time the dual_scan_daily cron job runs.
max_artifacts_per_run 50 Caps how many validated packages are re-scanned per run, oldest-rescanned-first.
max_runtime_minutes 30 Sweep stops picking up new artifacts past this wall-clock budget, even if the artifact cap hasn't been reached.

Confirm the schedule took effect:

curl -s http://localhost:8000/api/v1/settings/ \
  -H "Authorization: Bearer $TOKEN" | jq .dual_scan

4. Read detected_by in the Security page

Open a package's CVE detail view (Security page → package → CVE list). Each CVE row carries a small badge once it has been through a dual-scan pass:

  • "Grype + Trivy" (grey, understated) — both engines independently found this CVE. This is confirmation, not new information — deliberately styled as the less attention-grabbing badge.
  • "Trivy uniquement" (violet) — found only by the second-opinion scan, absent from Grype's original result. This is the actionable signal: it's what triggers a policy re-evaluation and, if severe enough under cve_policy, a pending_review state.

A package never touched by a dual scan (not yet reached by the sweep, or the feature was disabled at the time it was published) shows no detected_by field at all and no badge — this is expected, not an error, for any package published before dual-scan was enabled or still waiting its turn in the sweep.

The raw Trivy output and last-scan timestamp are kept separately on the manifest (trivy_scan.matches, trivy_scan.scanned_at) for audit, alongside trivy_scan.divergent_cve_ids — the exact list of CVE IDs that were Trivy-only at the last pass.


5. Worked example

  1. A .deb is uploaded and published normally — Grype finds nothing above the configured cve_policy threshold, package lands validated.
  2. The next day, the Trivy vulnerability database picks up a CVE for one of the package's bundled libraries that Grype's own database hasn't matched yet.
  3. At 05:15 UTC, dual_scan_daily reaches this package in its sweep (assuming it's within max_artifacts_per_run for that run — older, longer-unscanned packages are prioritized).
  4. Trivy reports the CVE; it's absent from the package's existing cve_results, so it lands in trivy_only.
  5. If the CVE's severity maps to review or block in cve_policy, the package flips to pending_review, a dual_scan validation step is appended to the manifest, and an audit log entry (DUAL_SCAN, PENDING_REVIEW) is written.
  6. The package now shows up in the RSSI review queue exactly like any upload-time pending_review package, with a ["trivy"]-only badge on the flagged CVE in the Security page.

Verify it worked

  1. GET /settings/ shows dual_scan.enabled: true with the schedule you set.
  2. Wait for (or manually trigger, via a maintenance window) the next dual_scan_daily run, then check the backend logs:
    docker compose logs backend-api | grep dual_scan
    
  3. Pick a package that was validated before the sweep ran and confirm its manifest snapshot now has a trivy_scan section:
    curl -s "http://localhost:8000/api/v1/artifacts/mypackage/versions/1.2.3/snapshot?arch=amd64" \
      -H "Authorization: Bearer $TOKEN" | jq '.trivy_scan, .cve_results[].detected_by'
    
  4. If Trivy was unreachable during a run, the manifest gets trivy_scan.error = "trivy indisponible" and cve_results is left untouched — this is the fail-soft path, not a bug: a second engine being temporarily down never blocks or corrupts the existing Grype-based result.