Scanner Advisory + EPSS Freshness Gate
Sprint:
docs-archive/implplan/2026-05-17-completed-sprints/SPRINT_20260512_041_Scanner_advisory_epss_airgap_fallbacks.md; feeder rebuilt bydocs-archive/implplan/SPRINT_20260927_003_Services_runtime_registration_defects.md(PCD-46). Audience: SREs, release engineers, and operators running the Scanner WebService. Status: Active.
Implementation status (2026-09-27, PCD-46).
scanner-webregisters the gate, the tracker, theX-StellaOps-Feed-Statusmiddleware andHubFeedFreshnessRefresher, the tracker’s only feeder. The refresher reads the Vulnerabilities hub and records the age of the hub’s data. The former feeder,AdvisoryClient(a client for the retired Concelier linkset API that no host registered), and its bundle store are withdrawn tosrc/__Obsoleted/Scanner/. The advisory feed id changed fromconceliertoadvisory. Re-verify withgrep -rn "RecordSuccess" src/Scanner --include=*.cs.
1. What this gate does
IAdvisoryFreshnessGate is a process-wide service inside the Scanner WebService that classifies the freshness of each tracked feed into one of three states:
| Status | Meaning |
|---|---|
fresh | The feed’s newest recorded data timestamp is inside the fresh window (default 24h). |
stale | It is older than the fresh window but inside the stale window (default 7d). |
missing | Nothing was ever recorded, or the newest data is older than the stale window. |
Tracked feeds (defaults):
| Feed id | Recorded value | Source |
|---|---|---|
advisory | The newest lastCompletedAt across the hub’s source ingest generations. | GET /api/vulnerabilities/v1/sources |
epss | The end of the UTC day of the hub’s latest EPSS model date (a model date is one day of scores, current until that day ends). | GET /api/vulnerabilities/v1/epss/model-date |
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 include afeedsarray:[{ "feedId": "advisory", "status": "stale", "lastUpdated": "2026-05-11T03:00:00Z" }, ...].
The header and the health payload are the only surfaces. Feed freshness does not change reachability confidence: the AdvisoryFreshnessConfidenceCapPolicy (IConfidenceCapPolicy) that would have capped it at Likely for a non-fresh feed had no production caller and was withdrawn on 2026-09-29 (SPRINT_20260928_003 PCD-150).
2. How freshness is recorded
HubFeedFreshnessRefresher (a hosted service in scanner-web) calls HubFeedFreshnessClient at start-up and then every scanner:advisoryFreshness:hubRefreshInterval (default 5 minutes, minimum 10 seconds). The client:
- reaches the hub at
Epss:HubBaseAddresswith the same client-credentials identity, scope (Epss:HubScope, defaultadvisory:read aoc:verify) and tenant (Epss:HubTenant) as the hub EPSS reader. Both routes requireadvisory:read; no additional scope is requested. - records a feed only from a data timestamp in the hub’s answer. Reaching the hub is not freshness: a failed, refused (401, 403, 5xx) or empty answer (no completed generation, a
nullmodel date) records nothing, and the feed ages tostaleand thenmissing. - never moves a feed backwards: the tracker keeps the newest timestamp per feed.
When Authority is disabled on the host there is no token client, every poll fails, and both feeds report missing.
3. Fail-OPEN, by design
Scanner default posture is fail-secure (deny on missing inputs). For the advisory and EPSS feed dependency the gate deliberately deviates (Decision D-OPEN-01):
- A scanner that hard-fails whenever the hub’s ingest lags blocks the release pipeline for an upstream issue the operator cannot fix in-flight.
- The response header and the health payload make the degraded posture visible to every consumer.
A scan therefore always completes even when every feed is missing. The operator’s job is to monitor the header or the health endpoint and restore the hub’s ingestion when status drifts.
4. Configuration
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
hubRefreshInterval: "00:05:00" # default 5 minutes
trackedFeeds: # default: [advisory, epss]
- advisory
- epss
The hub address and identity come from the Epss section (Epss:HubBaseAddress, Epss:HubScope, Epss:HubTenant), which scanner-web already requires for EPSS.
Constraints: freshWindow must be non-negative and staleWindow must be greater than or equal to freshWindow. The gate validates these at construction time and throws ArgumentOutOfRangeException, so a bad value fails at startup.
5. 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.
- Worst-wins across the configured feeds:
missing>stale>fresh.
Consumers (CI gates, dashboards) MUST treat an absent header as missing.
6. Health endpoint payload
{
"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": "advisory", "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 nothing was recorded; otherwise it is the hub data timestamp described in section 1.
7. Recovery procedure
7.1 advisory is stale or missing
- Check the hub:
GET <hub>/api/vulnerabilities/v1/sources(scopeadvisory:read). Each source reportslastCompletedAt; the feed follows the newest one. - If no source completed recently, the hub’s ingestion is behind: see the Vulnerabilities dossier (
docs/modules/vulnerabilities/) for source health and ingest runs. - If the hub is current but the feed is not, look in the scanner-web log for
Hub freshness read for feed ... failedorHub sources read answered 401. The hub address (Epss:HubBaseAddress) or the client-credentials identity (Epss:HubScope,Epss:HubTenant) is wrong. - The next poll (at most
hubRefreshIntervallater) records the new value; confirm via/healthz.
7.2 epss is stale or missing
GET <hub>/api/vulnerabilities/v1/epss/model-date returns the latest model date, or null when the hub holds no EPSS data. EPSS ingestion belongs to the Vulnerabilities hub; a model date more than one day old means the hub has not imported the latest EPSS release.
7.3 Air-gap deployments
Air-gapped estates load advisory and EPSS data into the Vulnerabilities hub. The feeds then follow the imported data’s timestamps like any other ingest; there is no scanner-side bundle path.
8. Determinism and time
- All age comparisons use the DI-injected
TimeProvider; the recorded values are the hub’s data timestamps, never the scanner’s clock. - The tracker keeps the most recent timestamp per feed; an older value is a no-op.
- A future timestamp (clock skew, or an EPSS model day that has not ended) is treated as
fresh. The actuallastUpdatedvalue is still exposed via the health endpoint.
9. References
- Library:
src/Scanner/__Libraries/StellaOps.Scanner.Advisory/AdvisoryFreshness.cs - Refresher and hub client:
src/Scanner/StellaOps.Scanner.WebService/Services/Advisory/ - Middleware:
src/Scanner/StellaOps.Scanner.WebService/Middleware/AdvisoryFeedStatusHeaderMiddleware.cs - EPSS integration architecture:
docs/modules/scanner/epss-integration.md - Withdrawn client:
src/__Obsoleted/Scanner/__Libraries/StellaOps.Scanner.Advisory/PCD-46/ - Withdrawn confidence cap:
src/__Obsoleted/Scanner/__Libraries/StellaOps.Scanner.Advisory/PCD-150/
