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 by docs-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-web registers the gate, the tracker, the X-StellaOps-Feed-Status middleware and HubFeedFreshnessRefresher, 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 to src/__Obsoleted/Scanner/. The advisory feed id changed from concelier to advisory. Re-verify with grep -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:

StatusMeaning
freshThe feed’s newest recorded data timestamp is inside the fresh window (default 24h).
staleIt is older than the fresh window but inside the stale window (default 7d).
missingNothing was ever recorded, or the newest data is older than the stale window.

Tracked feeds (defaults):

Feed idRecorded valueSource
advisoryThe newest lastCompletedAt across the hub’s source ingest generations.GET /api/vulnerabilities/v1/sources
epssThe 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:

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:

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 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

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                          }
  ]
}

7. Recovery procedure

7.1 advisory is stale or missing

  1. Check the hub: GET <hub>/api/vulnerabilities/v1/sources (scope advisory:read). Each source reports lastCompletedAt; the feed follows the newest one.
  2. If no source completed recently, the hub’s ingestion is behind: see the Vulnerabilities dossier (docs/modules/vulnerabilities/) for source health and ingest runs.
  3. If the hub is current but the feed is not, look in the scanner-web log for Hub freshness read for feed ... failed or Hub sources read answered 401. The hub address (Epss:HubBaseAddress) or the client-credentials identity (Epss:HubScope, Epss:HubTenant) is wrong.
  4. The next poll (at most hubRefreshInterval later) 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


9. References