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 (2026-07-19). Scanner Worker, not Concelier, owns the composed EPSS ingest/enrichment/signal jobs. It persists tenant-scoped epss_signal rows, but production currently registers only NullEpssSignalPublisher; no Router/Notify/Policy reanalysis delivery is composed. EpssChangeEventFactory, EpssUpdatedEventBuilder, and the scan-manifest tool/evidence fingerprint setters have no production callers. The supported 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. Event-flow and Concelier descriptions below remain target architecture until that cross-service boundary is implemented.


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. Trust Lattice Integration

7.1 Scoring Rule Example

IF cvss_base >= 8.0
AND epss_score >= 0.35
AND runtime_exposed = true
→ priority = IMMEDIATE_ATTENTION

7.2 Score Weights

FactorDefault WeightRange
CVSS0.250.0-1.0
EPSS0.250.0-1.0
Reachability0.250.0-1.0
Freshness0.150.0-1.0
Frequency0.100.0-1.0

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 jobs are configured via the Epss:* sections in Scanner Worker configuration:

Epss:
  Ingest:
    Enabled: true                    # Enable/disable the job
    Schedule: "0 5 0 * * *"         # Cron expression (default: 00:05 UTC daily)
    SourceType: "online"            # "online" or "bundle"
    BundlePath: null                # Path for air-gapped bundle import
    InitialDelay: "00:00:30"        # Wait before first run (30s)
    RetryDelay: "00:05:00"          # Delay between retries (5m)
    MaxRetries: 3                   # Maximum retry attempts
  Enrichment:
    Enabled: true                    # Enable/disable live triage enrichment
    PostIngestDelay: "00:01:00"     # Wait after ingest before enriching
    BatchSize: 1000                 # CVEs per batch
    HighPercentile: 0.99            # ≥ threshold => HIGH (and CrossedHigh flag)
    HighScore: 0.50                 # ≥ threshold => high score threshold
    BigJumpDelta: 0.10              # ≥ threshold => BIG_JUMP flag
    CriticalPercentile: 0.995       # ≥ threshold => CRITICAL
    MediumPercentile: 0.90          # ≥ threshold => MEDIUM
    FlagsToProcess: "NewScored,CrossedHigh,BigJumpUp,BigJumpDown" # Empty => process all
  Signal:
    Enabled: true                    # Enable/disable tenant-scoped signal generation
    PostEnrichmentDelay: "00:00:30" # Wait after enrichment before emitting signals
    BatchSize: 500                  # Signals per batch
    RetentionDays: 90               # Retention for epss_signal layer
    SuppressSignalsOnModelChange: true # Suppress per-CVE signals on model version changes

10.2 Online Mode (Connected)

The job automatically fetches EPSS data from FIRST.org at the scheduled time:

  1. Downloads https://epss.empiricalsecurity.com/epss_scores-YYYY-MM-DD.csv.gz
  2. Validates SHA256 hash
  3. Parses CSV and bulk inserts to epss_scores
  4. Computes delta against epss_current
  5. Updates epss_current projection
  6. Publishes epss.updated event

10.3 Air-Gap Mode (Bundle)

For offline deployments:

  1. Download EPSS CSV from FIRST.org on an internet-connected system
  2. Copy to the configured BundlePath location
  3. Set SourceType: "bundle" in configuration
  4. The job will read from the local file instead of fetching online

10.4 Manual Ingestion

There is currently no HTTP endpoint for one-shot ingestion. To force a run:

  1. Temporarily set Epss:Ingest:Schedule to 0 * * * * * and Epss:Ingest:InitialDelay to 00:00:00
  2. Restart Scanner Worker and wait for one ingest cycle
  3. Restore the normal schedule

Note: a successful ingest triggers EpssEnrichmentJob, which then triggers EpssSignalJob.

10.5 Troubleshooting

SymptomLikely CauseResolution
Job not runningEnabled: falseSet Enabled: true
Download failsNetwork/firewallCheck HTTPS egress to epss.empiricalsecurity.com
Parse errorsCorrupted fileRe-download, check SHA256
Enrichment/signals not runningStorage disabled or job disabledEnsure ScannerStorage:Postgres:ConnectionString is set and Epss:Enrichment:Enabled / Epss:Signal:Enabled are true
Slow ingestionLarge dataset / constrained IOExpect <120s for ~310k rows; confirm via the perf harness and compare against CI baseline
Duplicate runsIdempotentSafe - existing data preserved

10.6 Monitoring

Key metrics and traces:

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