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:

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 through HubEpssProvider over /api/vulnerabilities/v1/epss/*: Epss:Source accepts only hub, which is also the default (src/Scanner/__Libraries/StellaOps.Scanner.Core/Epss/IEpssProvider.cs), and the release bundle sets Epss__Source: hub and Epss__HubBaseAddress for both hosts. Migration 002_remove_mishomed_epss.sql removed the scanner.epss_* tables, so the schema sketches, the Scanner-side ingest/enrichment/signal jobs (EpssIngestJob, EpssEnrichmentJob, EpssSignalJob, withdrawn to src/__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 are POST /api/v1/epss/current, GET /api/v1/epss/current/{cveId}, GET /api/v1/epss/history/{cveId} and GET /api/v1/epss/status; each answer carries source: vulnerabilities-hub. Re-verify with grep -rn "HubSourceValue\|AddEpssProvider" src/Scanner --include=*.cs.


1. Design Principles

1.1 EPSS as Probabilistic Signal

Signal TypeNatureSource
CVSS v4Deterministic impactNVD, vendor
EPSSProbabilistic threatFIRST daily feeds
VEXVendor intentVendor statements
Runtime contextActual exposureStellaOps scanner

Rule: EPSS modulates confidence, never asserts truth.

1.2 Architectural Constraints

  1. Append-only time-series: Never overwrite historical EPSS data
  2. Deterministic replay: Every scan stores the EPSS snapshot reference used
  3. Idempotent ingestion: Safe to re-run for same date
  4. Postgres as source of truth: Valkey is optional cache only
  5. 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

FlagValueMeaning
NEW_SCORED0x01CVE newly scored (not in previous day)
CROSSED_HIGH0x02Score crossed above high threshold
CROSSED_LOW0x04Score crossed below high threshold
BIG_JUMP_UP0x08Delta > 0.10 upward
BIG_JUMP_DOWN0x10Delta > 0.10 downward
TOP_PERCENTILE0x20Entered 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) │                         │
│  └──────────────┘     └──────────────┘                         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
ComponentResponsibility
SchedulerTriggers daily EPSS import job
ConcelierDownloads/imports EPSS, stores facts, computes delta, emits events
ScannerAttaches EPSS-at-scan as immutable evidence, uses for scoring
ExcititorCreates VEX tasks when EPSS is high and VEX missing
NotifySends 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:

4.2 Ingestion Steps

  1. Scheduler triggers daily job for date D
  2. Download epss_scores-D.csv.gz
  3. Decompress stream
  4. Parse header comment for model version/date
  5. Validate scores in [0,1], monotonic percentile
  6. Bulk load into TEMP staging table
  7. Transaction:
    • Insert epss_import_runs
    • Insert into epss_scores partition
    • Compute epss_changes by comparing staging vs epss_current
    • Upsert epss_current
    • Enqueue epss.updated event
  8. Commit

4.3 Air-Gap Import

Accept local bundle containing:

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:

5.3 Efficient Delta Targeting

On epss.updated(D):

  1. Read epss_changes where flags indicate material change
  2. Find impacted vulnerability instances by CVE
  3. Update only those instances
  4. Emit vuln.priority.changed only if band crossed

6. Notification Policy

6.1 Default Thresholds

ThresholdDefaultDescription
HighPercentile0.95Top 5% of all CVEs
HighScore0.5050% exploitation probability
BigJumpDelta0.10Meaningful daily change

6.2 Trigger Conditions

  1. Newly scored CVE in inventory AND percentile >= HighPercentile
  2. Existing CVE crosses above HighPercentile or HighScore
  3. 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

EndpointDescription
GET /epss/current?cve=...Bulk lookup current EPSS
GET /epss/history?cve=...&days=180Historical time-series
GET /epss/top?order=epss&limit=100Top CVEs by score
GET /epss/changes?date=...Daily change report

8.2 UI Requirements

For each vulnerability instance:


9. Implementation Checklist

Phase 1: Data Foundation

Phase 2: Integration

Phase 3: Enrichment

Phase 4: UI/UX

Phase 5: Operations


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)
IEpssProviderEpssProvider over the scanner-owned epss_* tables (Valkey-cached)HubEpssProvider over /api/vulnerabilities/v1/epss/*
IEpssRepository / IEpssRawRepository / IEpssSignalRepositoryPostgres implementationsfail-closed guards — every call throws
EpssIngestJob / EpssEnrichmentJob / EpssSignalJobregisteredwithdrawn (PCD-48)
source field on an EPSS readpostgresvulnerabilities-hub

Operational notes:

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, EpssEnrichmentJob and EpssSignalJob, their Epss:Ingest, Epss:Enrichment and Epss:Signal sections, the online and bundle sources and the signal publisher are frozen under src/__Obsoleted/Scanner/. They were never registered, and they could not run: the scanner.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 is Epss:Source and the Epss:Hub* keys above.

10.7 Performance


11. Anti-Patterns to Avoid

Anti-PatternWhy It’s Wrong
Storing only latest EPSSBreaks auditability and replay
Mixing EPSS into CVE tableEPSS is signal, not vulnerability data
Treating EPSS as severityEPSS is probability, not impact
Alerting on every daily fluctuationCreates alert fatigue
Recomputing EPSS internallyUse FIRST’s authoritative data