Scanner Advisory + EPSS Freshness Gate
Sprint:
docs/implplan/SPRINT_20260512_041_Scanner_advisory_epss_airgap_fallbacks.mdAudience: 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:
| Status | Meaning | Effect on reachability slice confidence |
|---|---|---|
fresh | The feed produced a successful refresh inside the fresh window (default 24h). | No cap. Confidence may be Confirmed. |
stale | The last refresh is older than 24h but inside the stale window (default 7d). | Capped at Likely. |
missing | The feed has never produced a refresh, or its last refresh is older than 7d. | Capped at Likely. |
The gate is read by:
AdvisoryFeedStatusHeaderMiddleware, which stamps every outgoing response withX-StellaOps-Feed-Status: fresh|stale|missing(worst-wins across the tracked feeds).- The
/healthzand/readyzendpoints, whose JSON documents now include afeedsarray:[{ "feedId": "concelier", "status": "stale", "lastUpdated": "2026-05-11T03:00:00Z" }, ...]. - Reachability callers, via the
IConfidenceCapPolicycontract inStellaOps.Scanner.Advisory.
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 scanner that hard-fails on every Concelier hiccup blocks the release pipeline for an upstream issue the operator cannot fix in-flight.
- The response header and health payload make the degraded posture visible to every consumer, preserving auditability.
- Capping at
Likely(neverConfirmed) preserves the contract that high-confidence claims require fresh evidence.
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:
freshWindowmust be non-negative.staleWindowmust be greater than or equal tofreshWindow.
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
- Lowercase ASCII (CODE_OF_CONDUCT §6.4).
- Stamped on every response, including health, error, and unauthorized paths. The header is added before authentication so anonymous responses also carry it.
- Worst-wins across the configured feeds:
missing>stale>fresh.
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 }
]
}
feedIdis canonical lowercase.statusmatches the header wire format.lastUpdatedisnullwhen the feed has never produced a refresh; otherwise it is the UTC instant of the most recent successful refresh, taken from the injectedTimeProvider(noDateTime.UtcNowin production code).
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:
- Verify: hit
<concelier-base>/healthzfrom the scanner host and confirm Concelier itself is healthy. - Force a refresh: the next successful
GetCveSymbolsAsynccall 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. - Re-check: refresh
/healthzand confirm theconcelierfeed status has moved back tofresh.
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:
| Cause | Fix |
|---|---|
| Scanner just started, no scan has triggered a fetch | Run any scan that references a CVE. The next successful call stamps freshness. |
scanner:advisory:enabled is false | Set to true in scanner.yaml, restart the WebService. |
scanner:advisory:baseUrl is unset / wrong | Point it at the Concelier WebService base URL; restart. |
| Network egress to Concelier is blocked | Restore connectivity; re-run a scan; verify via the health endpoint. |
| Concelier itself is down | Restore 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:
- On a connected system, generate the bundle following
docs/modules/scanner/epss-integration.md§4.3. - Copy the bundle to the configured path on the scanner host.
- Configure
FileAdvisoryBundleStore(typically viascanner:advisory:bundlePath) to point at the bundle file. - Restart the scanner OR run any scan that references a CVE present in the bundle. The bundle-store hit stamps freshness for the
bundlefeed id. - Verify via
/healthzthat thebundlefeed (or your configured tracked feed) reportsfresh.
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
- All timestamp comparisons use the DI-injected
TimeProvider. Tests injectMicrosoft.Extensions.Time.Testing.FakeTimeProviderfor deterministic control; production injectsTimeProvider.System. - The tracker keeps the most-recent timestamp per feed; older
RecordSuccesscalls are no-ops. This is intentional: a failed refresh must not move the freshness clock backwards. - Clock skew (a tracker recording a future timestamp) is treated as
fresh. The actuallastUpdatedvalue is still exposed via the health endpoint so operators can spot the skew.
8. References
- Sprint plan:
docs/implplan/SPRINT_20260512_041_Scanner_advisory_epss_airgap_fallbacks.md - Library:
src/Scanner/__Libraries/StellaOps.Scanner.Advisory/AdvisoryFreshness.cs - Confidence cap:
src/Scanner/__Libraries/StellaOps.Scanner.Advisory/AdvisoryConfidenceCap.cs - Middleware:
src/Scanner/StellaOps.Scanner.WebService/Middleware/AdvisoryFeedStatusHeaderMiddleware.cs - EPSS integration architecture:
docs/modules/scanner/epss-integration.md - Module agents:
src/Scanner/__Libraries/StellaOps.Scanner.Advisory/AGENTS.md
