EPSS Integration Architecture
Advisory Source:
docs/product/advisories/16-Dec-2025 - Merging EPSS v4 with CVSS v4 Frameworks.md
Last Updated: 2025-12-17
Status: Approved for Implementation
Executive Summary
EPSS (Exploit Prediction Scoring System) is a probabilistic model that estimates the likelihood a given CVE will be exploited in the wild over the next ~30 days. This document defines how StellaOps integrates EPSS as a first-class risk signal.
Key Distinction:
- CVSS v4: Deterministic measurement of severity (0-10)
- EPSS: Dynamic, data-driven probability of exploitation (0-1)
EPSS does not replace CVSS or VEX—it provides complementary probabilistic threat intelligence.
Current implementation boundary - verified against commit
942a18727c(2026-09-26). The EPSS corpus belongs to the Vulnerabilities hub (ADR-039 P14). Scanner reads EPSS only throughHubEpssProviderover/api/vulnerabilities/v1/epss/*:Epss:Sourceaccepts onlyhub, which is also the default (src/Scanner/__Libraries/StellaOps.Scanner.Core/Epss/IEpssProvider.cs), and the release bundle setsEpss__Source: hubandEpss__HubBaseAddressfor both hosts. Migration002_remove_mishomed_epss.sqlremoved thescanner.epss_*tables, so the schema sketches, the Scanner-side ingest/enrichment/signal jobs (EpssIngestJob,EpssEnrichmentJob,EpssSignalJob, withdrawn tosrc/__Obsoleted/Scanner/on 2026-09-27, PCD-48) and the Scheduler/Concelier/Excititor event flow below are the original design, not current behaviour. Scheduler, Concelier and Excititor are retired services. The supported Scanner HTTP reads arePOST /api/v1/epss/current,GET /api/v1/epss/current/{cveId},GET /api/v1/epss/history/{cveId}andGET /api/v1/epss/status; each answer carriessource: vulnerabilities-hub. Re-verify withgrep -rn "HubSourceValue\|AddEpssProvider" src/Scanner --include=*.cs.
1. Design Principles
1.1 EPSS as Probabilistic Signal
| Signal Type | Nature | Source |
|---|---|---|
| CVSS v4 | Deterministic impact | NVD, vendor |
| EPSS | Probabilistic threat | FIRST daily feeds |
| VEX | Vendor intent | Vendor statements |
| Runtime context | Actual exposure | StellaOps scanner |
Rule: EPSS modulates confidence, never asserts truth.
1.2 Architectural Constraints
- Append-only time-series: Never overwrite historical EPSS data
- Deterministic replay: Every scan stores the EPSS snapshot reference used
- Idempotent ingestion: Safe to re-run for same date
- Postgres as source of truth: Valkey is optional cache only
- Air-gap compatible: Manual import via signed bundles
2. Data Model
2.1 Core Tables
Import Provenance
CREATE TABLE epss_import_runs (
import_run_id UUID PRIMARY KEY,
model_date DATE NOT NULL,
source_uri TEXT NOT NULL,
retrieved_at TIMESTAMPTZ NOT NULL,
file_sha256 TEXT NOT NULL,
decompressed_sha256 TEXT NULL,
row_count INT NOT NULL,
model_version_tag TEXT NULL,
published_date DATE NULL,
status TEXT NOT NULL, -- SUCCEEDED / FAILED
error TEXT NULL,
UNIQUE (model_date)
);
Time-Series Scores (Partitioned)
CREATE TABLE epss_scores (
model_date DATE NOT NULL,
cve_id TEXT NOT NULL,
epss_score DOUBLE PRECISION NOT NULL,
percentile DOUBLE PRECISION NOT NULL,
import_run_id UUID NOT NULL REFERENCES epss_import_runs(import_run_id),
PRIMARY KEY (model_date, cve_id)
) PARTITION BY RANGE (model_date);
Current Projection (Fast Lookup)
CREATE TABLE epss_current (
cve_id TEXT PRIMARY KEY,
epss_score DOUBLE PRECISION NOT NULL,
percentile DOUBLE PRECISION NOT NULL,
model_date DATE NOT NULL,
import_run_id UUID NOT NULL
);
CREATE INDEX idx_epss_current_score_desc ON epss_current (epss_score DESC);
CREATE INDEX idx_epss_current_percentile_desc ON epss_current (percentile DESC);
Change Detection
CREATE TABLE epss_changes (
model_date DATE NOT NULL,
cve_id TEXT NOT NULL,
old_score DOUBLE PRECISION NULL,
new_score DOUBLE PRECISION NOT NULL,
delta_score DOUBLE PRECISION NULL,
old_percentile DOUBLE PRECISION NULL,
new_percentile DOUBLE PRECISION NOT NULL,
flags INT NOT NULL, -- bitmask: NEW_SCORED, CROSSED_HIGH, BIG_JUMP
PRIMARY KEY (model_date, cve_id)
) PARTITION BY RANGE (model_date);
2.2 Flags Bitmask
| Flag | Value | Meaning |
|---|---|---|
| NEW_SCORED | 0x01 | CVE newly scored (not in previous day) |
| CROSSED_HIGH | 0x02 | Score crossed above high threshold |
| CROSSED_LOW | 0x04 | Score crossed below high threshold |
| BIG_JUMP_UP | 0x08 | Delta > 0.10 upward |
| BIG_JUMP_DOWN | 0x10 | Delta > 0.10 downward |
| TOP_PERCENTILE | 0x20 | Entered top 5% |
3. Service Architecture
3.1 Component Responsibilities
┌─────────────────────────────────────────────────────────────────┐
│ EPSS DATA FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Scheduler │────►│ Concelier │────►│ Scanner │ │
│ │ (triggers) │ │ (ingest) │ │ (evidence) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────┐ │ │
│ │ │ Postgres │◄───────────┘ │
│ │ │ (truth) │ │
│ │ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Notify │◄────│ Excititor │ │
│ │ (alerts) │ │ (VEX tasks) │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
| Component | Responsibility |
|---|---|
| Scheduler | Triggers daily EPSS import job |
| Concelier | Downloads/imports EPSS, stores facts, computes delta, emits events |
| Scanner | Attaches EPSS-at-scan as immutable evidence, uses for scoring |
| Excititor | Creates VEX tasks when EPSS is high and VEX missing |
| Notify | Sends alerts on priority changes |
3.2 Event Flow
Scheduler
→ epss.ingest(date)
→ Concelier (ingest)
→ epss.updated
→ Notify (optional daily summary)
→ Concelier (enrichment)
→ vuln.priority.changed
→ Notify (targeted alerts)
→ Excititor (VEX task creation)
4. Ingestion Pipeline
4.1 Data Source
FIRST publishes daily CSV snapshots at:
https://epss.empiricalsecurity.com/epss_scores-YYYY-MM-DD.csv.gz
Each file contains ~300k CVE records with:
cve- CVE IDepss- Score (0.00000–1.00000)percentile- Rank vs all CVEs
4.2 Ingestion Steps
- Scheduler triggers daily job for date D
- Download
epss_scores-D.csv.gz - Decompress stream
- Parse header comment for model version/date
- Validate scores in [0,1], monotonic percentile
- Bulk load into TEMP staging table
- Transaction:
- Insert
epss_import_runs - Insert into
epss_scorespartition - Compute
epss_changesby comparing staging vsepss_current - Upsert
epss_current - Enqueue
epss.updatedevent
- Insert
- Commit
4.3 Air-Gap Import
Accept local bundle containing:
epss_scores-YYYY-MM-DD.csv.gzmanifest.jsonwith sha256, source attribution, DSSE signature
Same pipeline, with source_uri = bundle://....
5. Enrichment Rules
5.1 New Scan Findings (Immutable)
Store EPSS “as-of” scan time:
public record ScanEpssEvidence
{
public double EpssScoreAtScan { get; init; }
public double EpssPercentileAtScan { get; init; }
public DateOnly EpssModelDateAtScan { get; init; }
public Guid EpssImportRunIdAtScan { get; init; }
}
This supports deterministic replay even if EPSS changes later.
5.2 Existing Findings (Live Triage)
Maintain mutable “current EPSS” on vulnerability instances:
- scan_finding_evidence: Immutable EPSS-at-scan
- vuln_instance_triage: Current EPSS + band (for live triage)
5.3 Efficient Delta Targeting
On epss.updated(D):
- Read
epss_changeswhere flags indicate material change - Find impacted vulnerability instances by CVE
- Update only those instances
- Emit
vuln.priority.changedonly if band crossed
6. Notification Policy
6.1 Default Thresholds
| Threshold | Default | Description |
|---|---|---|
| HighPercentile | 0.95 | Top 5% of all CVEs |
| HighScore | 0.50 | 50% exploitation probability |
| BigJumpDelta | 0.10 | Meaningful daily change |
6.2 Trigger Conditions
- Newly scored CVE in inventory AND
percentile >= HighPercentile - Existing CVE crosses above HighPercentile or HighScore
- Delta > BigJumpDelta AND CVE in runtime-exposed assets
All thresholds are org-configurable.
7. EPSS and VEX Decisions
EPSS is a probability signal, not a VEX trust tier or affectedness assertion. The retired PCR trust-lattice weights were never the shared consensus rules; no EPSS score changes a statement to not_affected.
Scanner obtains EPSS from the Vulnerabilities hub. Its VEX matching separately evaluates live statements with the shared consensus evaluator, scoped to the image’s distro release. Policy applies issuer tiers and tenant overlays in its gate projection. Use the VEX consensus runbook for configuration and the Policy architecture for release evaluation.
8. API Surface
8.1 Internal API Endpoints
| Endpoint | Description |
|---|---|
GET /epss/current?cve=... | Bulk lookup current EPSS |
GET /epss/history?cve=...&days=180 | Historical time-series |
GET /epss/top?order=epss&limit=100 | Top CVEs by score |
GET /epss/changes?date=... | Daily change report |
8.2 UI Requirements
For each vulnerability instance:
- EPSS score + percentile
- Model date
- Trend delta vs previous scan date
- Filter chips: “High EPSS”, “Rising EPSS”, “High CVSS + High EPSS”
- Evidence panel showing EPSS-at-scan vs current EPSS
9. Implementation Checklist
Phase 1: Data Foundation
- [ ] DB migrations: tables + partitions + indexes
- [ ] Concelier ingestion job: online download + bundle import
Phase 2: Integration
- [x] epss_current + epss_changes projection
- [x] Scanner.WebService: attach EPSS-at-scan evidence
- [x] Bulk lookup API (
/api/v1/epss/*)
Phase 3: Enrichment
- [x] Scanner Worker
EpssEnrichmentJobandEpssSignalJob(built, never registered; withdrawn 2026-09-27, PCD-48, with thescanner.epss_*tables gone)
Phase 4: UI/UX
- [ ] EPSS fields in vulnerability detail
- [ ] Filters and sort by exploit likelihood
- [ ] Trend visualization
Phase 5: Operations
- [x] Backfill tool (last 180 days)
- [x] Ops runbook: schedules, manual re-run, air-gap import
10. Operations Runbook
10.1 Configuration
Epss:Source — which backend serves EPSS (both scanner roles)
Epss:Source selects where the scanner reads EPSS from. It governs all four injection seams — IEpssProvider plus the three concrete repositories — and the scanner-owned EPSS jobs.
Epss:
Source: "hub" # the only supported value (default)
HubBaseAddress: null # REQUIRED when Source=hub, e.g. http://vulnerabilities.stella-ops.local
HubScope: "advisory:read aoc:verify" # scopes requested for hub calls
HubTenant: null # tenant for the token request + X-StellaOps-TenantId
postgres (removed; the tables were dropped by migration 002) | hub (only supported value) | |
|---|---|---|
IEpssProvider | EpssProvider over the scanner-owned epss_* tables (Valkey-cached) | HubEpssProvider over /api/vulnerabilities/v1/epss/* |
IEpssRepository / IEpssRawRepository / IEpssSignalRepository | Postgres implementations | fail-closed guards — every call throws |
EpssIngestJob / EpssEnrichmentJob / EpssSignalJob | registered | withdrawn (PCD-48) |
source field on an EPSS read | postgres | vulnerabilities-hub |
Operational notes:
hubwithoutHubBaseAddressfails the host at startup (options validation). It does not fall back to the scanner tables — a silent fallback is what would let an operator drop tables that are still serving.hubrequires an Authority client that can actually mint the token. Three requirements, all measured against the live issuer on 2026-08-16, all of which deny the token outright rather than degrade:aoc:verifymust accompanyadvisory:read. Authority answersinvalid_scope— “Scope ‘aoc:verify’ is required when requesting advisory/vex read scopes” — to a request foradvisory:readon its own. Hence the default pair.- A tenanted client must send
tenant, or the request is refused with “Tenant selection is required for this client.” SetEpss:HubTenant. - The client must not be DPoP sender-constrained.
StellaOps.Auth.Clientimplements no DPoP, so such a client cannot obtain any token through it — Authority answersinvalid_client, “DPoP proof is required.” An unauthenticated call 401s, andHubEpssProvidertreats a non-success response as “every CVE unknown” — a total EPSS outage that reads like an empty hub.
- A FOURTH requirement, and it is the HOST’s rather than the client’s (added 2026-08-18): the host must register a non-memory token cache.
AddStellaOpsAuthClientregistersIStellaOpsTokenCachewith a factory that throws outside Development/Testing, so a host that never registers one returns HTTP 500 on every EPSS route: “StellaOps auth client requires an explicit non-memory token cache … Environment: Production.” It throws at first use, not at startup — the container boots and reports healthy, and only the endpoint fails, so a health check cannot see it.Scanner.Workersatisfied this incidentally (it already calledAddStellaOpsFileTokenCachefor unrelated reasons), which is exactly why the worker’s green did not prove the seam forScanner.WebService; that host registered nothing and had to be fixed before its flip could work. On the lab estate both roles now satisfy all four:stellaops-scanner-worker, andstellaops-scanner-web— a dedicated non-DPoP client holding exactlyadvisory:read aoc:verify, seeded throughstandard.yamlbootstrapClientsand created 2026-08-18 under owner register Part 2 call 9. It replacedscanner-poe-dev, which satisfied none (DPoP-constrained,signer:signonly) and could mint no token for any scope, including its own. - The scanner writes no EPSS at all. The tables were dropped by migration 002 and the three jobs were withdrawn on 2026-09-27 (PCD-48); ingestion, change detection and air-gap import belong to the Vulnerabilities hub.
- The honest verification is the response, not the variable:
GET /api/v1/epss/current/{cveId}must report a non-postgressource, andGET /api/v1/epss/statusmust report the hub’s model date.
Sprint: SPRINT_20260816_002 EPS-1. Before it, this flag was inert — its branch sat in an AddEpssProvider overload with no production call sites.
Live status (2026-08-18): BOTH scanner roles run Epss:Source=hub on the lab estate. scanner-worker flipped 2026-08-17, scanner-web 2026-08-18. The verification above was executed rather than described: GET /api/v1/epss/current/CVE-2021-44228 returns {"score":0.99999,"modelDate":"2026-08-17","source":"vulnerabilities-hub"} where it returned "source":"postgres" / 2026-08-15 beforehand, and GET /api/v1/epss/status reports the hub’s 2026-08-17. The model date is the discriminator — the scanner-owned tables are frozen at 2026-08-15 (scanner.epss_current = 360,206 rows, ingest job no longer registered), so an 08-17 answer has only one possible source. Note the response stamp is the constant vulnerabilities-hub, not the literal string hub.
EPSS job configuration (withdrawn)
Withdrawn 2026-09-27 (SPRINT_20260927_003 PCD-48).
EpssIngestJob,EpssEnrichmentJobandEpssSignalJob, theirEpss:Ingest,Epss:EnrichmentandEpss:Signalsections, the online and bundle sources and the signal publisher are frozen undersrc/__Obsoleted/Scanner/. They were never registered, and they could not run: thescanner.epss_*tables are gone and the repositories they used are fail-closed guards. EPSS download, air-gap bundle import, change detection and their monitoring are Vulnerabilities hub operations; see the Vulnerabilities dossier (docs/modules/vulnerabilities/). Scanner’s only EPSS configuration isEpss:Sourceand theEpss:Hub*keys above.
10.7 Performance
- Local harness:
src/Scanner/__Benchmarks/StellaOps.Scanner.Storage.Epss.Perf/README.md - CI workflow:
.gitea/workflows/epss-ingest-perf.yml(nightly + manual, artifacts retained 90 days)
11. Anti-Patterns to Avoid
| Anti-Pattern | Why It’s Wrong |
|---|---|
| Storing only latest EPSS | Breaks auditability and replay |
| Mixing EPSS into CVE table | EPSS is signal, not vulnerability data |
| Treating EPSS as severity | EPSS is probability, not impact |
| Alerting on every daily fluctuation | Creates alert fatigue |
| Recomputing EPSS internally | Use FIRST’s authoritative data |
