Scanner Advisory + EPSS Freshness Gate

Sprint: docs/implplan/SPRINT_20260512_041_Scanner_advisory_epss_airgap_fallbacks.md Audience: SREs, release engineers, and operators running the Scanner WebService. Status: Active (introduced 2026-05-12).


1. What this gate does

IAdvisoryFreshnessGate is a process-wide service inside the Scanner WebService that classifies the freshness of each upstream advisory feed (Concelier linksets, EPSS scores, offline air-gap bundles) into one of three states:

StatusMeaningEffect on reachability slice confidence
freshThe feed produced a successful refresh inside the fresh window (default 24h).No cap. Confidence may be Confirmed.
staleThe last refresh is older than 24h but inside the stale window (default 7d).Capped at Likely.
missingThe feed has never produced a refresh, or its last refresh is older than 7d.Capped at Likely.

The gate is read by:


2. Fail-OPEN, by design

Scanner default posture is fail-secure (deny on missing inputs). For the advisory + EPSS feed dependency we deliberately deviate. The rationale is documented in the sprint (Decision D-OPEN-01):

A scan therefore always completes even when every feed is missing. The operator’s job is to monitor the header / health endpoint and re-seed the feed when status drifts.


3. Configuration

The fresh and stale windows are operator-tunable via scanner.yaml (or the matching SCANNER__ADVISORYFRESHNESS__* environment variables):

scanner:
  advisoryFreshness:
    freshWindow: "1.00:00:00"   # default 24h
    staleWindow: "7.00:00:00"   # default 7d
    trackedFeeds:               # default: [concelier, epss]
      - concelier
      - epss

Constraints:

The gate validates these at construction time and throws ArgumentOutOfRangeException if violated; configuration changes therefore fail fast at startup, not at first request.


4. Header contract

X-StellaOps-Feed-Status: fresh
X-StellaOps-Feed-Status: stale
X-StellaOps-Feed-Status: missing

Consumers (CI gates, dashboards) MUST treat a missing header as missing (fail-OPEN behaviour: do not assume a degraded scanner is healthy).


5. Health endpoint payload

/healthz and /readyz documents grow a new feeds array:

{
  "status": "healthy",
  "startedAt": "2026-05-12T11:00:00+00:00",
  "capturedAt": "2026-05-12T12:00:00+00:00",
  "uptimeSeconds": 3600,
  "telemetry": { "enabled": true, "logging": true, "metrics": true, "tracing": true },
  "feeds": [
    { "feedId": "concelier", "status": "fresh",   "lastUpdated": "2026-05-12T10:00:00+00:00" },
    { "feedId": "epss",      "status": "missing", "lastUpdated": null                          }
  ]
}

6. Recovery procedure

6.1 Status is stale

Most common cause: the Concelier service is up but its background ingestion loop has missed one or more cycles. Steps:

  1. Verify: hit <concelier-base>/healthz from the scanner host and confirm Concelier itself is healthy.
  2. Force a refresh: the next successful GetCveSymbolsAsync call from the scanner stamps the freshness tracker. The simplest trigger is any scan that asks for a CVE; alternatively, an operator may hit a known linkset endpoint directly through Concelier and re-run a small scan.
  3. Re-check: refresh /healthz and confirm the concelier feed status has moved back to fresh.

6.2 Status is missing (online deployment)

The feed has never been observed, or has not produced a refresh in more than seven days. Likely causes and fixes:

CauseFix
Scanner just started, no scan has triggered a fetchRun any scan that references a CVE. The next successful call stamps freshness.
scanner:advisory:enabled is falseSet to true in scanner.yaml, restart the WebService.
scanner:advisory:baseUrl is unset / wrongPoint it at the Concelier WebService base URL; restart.
Network egress to Concelier is blockedRestore connectivity; re-run a scan; verify via the health endpoint.
Concelier itself is downRestore Concelier, then trigger any scan to record freshness.

6.3 Status is missing (air-gap deployment)

In an air-gap topology the operator imports an offline advisory bundle:

  1. On a connected system, generate the bundle following docs/modules/scanner/epss-integration.md §4.3.
  2. Copy the bundle to the configured path on the scanner host.
  3. Configure FileAdvisoryBundleStore (typically via scanner:advisory:bundlePath) to point at the bundle file.
  4. Restart the scanner OR run any scan that references a CVE present in the bundle. The bundle-store hit stamps freshness for the bundle feed id.
  5. Verify via /healthz that the bundle feed (or your configured tracked feed) reports fresh.

6.4 EPSS feed specifically

The EPSS ingestion lives in the Scanner Worker, not the WebService. The WebService records EPSS freshness only via callers that explicitly invoke IAdvisoryFreshnessTracker.RecordSuccess("epss", ...) after a successful ingest. Until that wiring lands (cross-module integration deferred to the follow-up sprint), the EPSS feed will report missing on the WebService side even when EPSS data is current. Operators may safely treat the X-StellaOps-Feed-Status header as authoritative for the Concelier feed and consult epss_import_runs for EPSS ingestion health.


7. Determinism and time


8. References