- Add RateLimitConfig for configuration management with YAML binding support. - Introduce RateLimitDecision to encapsulate the result of rate limit checks. - Implement RateLimitMetrics for OpenTelemetry metrics tracking. - Create RateLimitMiddleware for enforcing rate limits on incoming requests. - Develop RateLimitService to orchestrate instance and environment rate limit checks. - Add RateLimitServiceCollectionExtensions for dependency injection registration.
23 KiB
EPSS v4 Integration Guide
Overview
EPSS (Exploit Prediction Scoring System) v4 is a machine learning-based vulnerability scoring system developed by FIRST.org that predicts the probability a CVE will be exploited in the wild within the next 30 days. StellaOps integrates EPSS as a probabilistic threat signal alongside CVSS v4's deterministic severity assessment, enabling more accurate vulnerability prioritization.
Key Concepts:
- EPSS Score: Probability (0.0-1.0) that a CVE will be exploited in next 30 days
- EPSS Percentile: Ranking (0.0-1.0) of this CVE relative to all scored CVEs
- Model Date: Date for which EPSS scores were computed
- Immutable at-scan: EPSS evidence captured at scan time never changes (deterministic replay)
- Current EPSS: Live projection for triage (updated daily)
How EPSS Works
EPSS uses machine learning to predict exploitation probability based on:
- Vulnerability Characteristics: CVSS metrics, CWE, affected products
- Social Signals: Twitter/GitHub mentions, security blog posts
- Exploit Database Entries: Exploit-DB, Metasploit, etc.
- Historical Exploitation: Past exploitation patterns
EPSS is updated daily by FIRST.org based on fresh threat intelligence.
EPSS vs CVSS
| Dimension | CVSS v4 | EPSS v4 |
|---|---|---|
| Nature | Deterministic severity | Probabilistic threat |
| Scale | 0.0-10.0 (severity) | 0.0-1.0 (probability) |
| Update Frequency | Static (per CVE version) | Daily (live threat data) |
| Purpose | Impact assessment | Likelihood assessment |
| Source | Vendor/NVD | FIRST.org ML model |
Example:
-
CVE-2024-1234: CVSS 9.8 (Critical) + EPSS 0.01 (1st percentile)
- Interpretation: Severe impact if exploited, but very unlikely to be exploited
- Priority: Medium (deprioritize despite high CVSS)
-
CVE-2024-5678: CVSS 6.5 (Medium) + EPSS 0.95 (98th percentile)
- Interpretation: Moderate impact, but actively being exploited
- Priority: High (escalate despite moderate CVSS)
Architecture Overview
Data Flow
┌────────────────────────────────────────────────────────────────┐
│ EPSS Data Lifecycle in StellaOps │
└────────────────────────────────────────────────────────────────┘
1. INGESTION (Daily 00:05 UTC)
┌───────────────────┐
│ FIRST.org │ Daily CSV: epss_scores-YYYY-MM-DD.csv.gz
│ (300k CVEs) │ ~15MB compressed
└────────┬──────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ Concelier: EpssIngestJob │
│ - Download/Import CSV │
│ - Parse (handle # comment, validate bounds) │
│ - Bulk insert: epss_scores (partitioned by month) │
│ - Compute delta: epss_changes (flags for enrichment) │
│ - Upsert: epss_current (latest projection) │
│ - Emit event: "epss.updated" │
└────────┬──────────────────────────────────────────────────┘
│
▼
[PostgreSQL: concelier.epss_*]
│
├─────────────────────────────┐
│ │
▼ ▼
2. AT-SCAN CAPTURE (Immutable Evidence)
┌────────────────────────────────────────────────────────────┐
│ Scanner: On new scan │
│ - Bulk query: epss_current for CVE list │
│ - Store immutable evidence: │
│ * epss_score_at_scan │
│ * epss_percentile_at_scan │
│ * epss_model_date_at_scan │
│ * epss_import_run_id_at_scan │
│ - Use in lattice decision (SR→CR if EPSS≥90th) │
└─────────────────────────────────────────────────────────────┘
3. LIVE ENRICHMENT (Existing Findings)
┌─────────────────────────────────────────────────────────────┐
│ Concelier: EpssEnrichmentJob (on "epss.updated") │
│ - Read: epss_changes WHERE flags IN (CROSSED_HIGH, BIG_JUMP)│
│ - Find impacted: vuln_instance_triage BY cve_id │
│ - Update: current_epss_score, current_epss_percentile │
│ - If priority band changed → emit "vuln.priority.changed" │
└────────┬────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Notify: On "vuln.priority.changed" │
│ - Check tenant notification rules │
│ - Send: Slack / Email / Teams / In-app │
│ - Payload: EPSS delta, threshold crossed │
└─────────────────────────────────────────────────────────────┘
4. POLICY SCORING
┌─────────────────────────────────────────────────────────────┐
│ Policy Engine: Risk Score Formula │
│ risk_score = (cvss/10) + epss_bonus + kev_bonus + reach_mult│
│ │
│ EPSS Bonus (Simple Profile): │
│ - Percentile ≥99th: +10% │
│ - Percentile ≥90th: +5% │
│ - Percentile ≥50th: +2% │
│ - Percentile <50th: 0% │
│ │
│ VEX Lattice Rules: │
│ - SR + EPSS≥90th → Escalate to CR (Confirmed Reachable) │
│ - DV + EPSS≥95th → Flag for review (vendor denial) │
│ - U + EPSS≥95th → Prioritize for reachability analysis │
└─────────────────────────────────────────────────────────────┘
Database Schema
Location: concelier database
epss_import_runs (Provenance)
Tracks each EPSS import with full provenance for audit trail.
CREATE TABLE concelier.epss_import_runs (
import_run_id UUID PRIMARY KEY,
model_date DATE NOT NULL UNIQUE,
source_uri TEXT NOT NULL,
file_sha256 TEXT NOT NULL,
row_count INT NOT NULL,
model_version_tag TEXT NULL,
published_date DATE NULL,
status TEXT NOT NULL, -- IN_PROGRESS, SUCCEEDED, FAILED
created_at TIMESTAMPTZ NOT NULL
);
epss_scores (Time-Series, Partitioned)
Immutable append-only history of daily EPSS scores.
CREATE TABLE concelier.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,
PRIMARY KEY (model_date, cve_id)
) PARTITION BY RANGE (model_date);
Partitions: Monthly (e.g., epss_scores_2025_12)
epss_current (Latest Projection)
Materialized view of latest EPSS score per CVE for fast lookups.
CREATE TABLE concelier.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,
updated_at TIMESTAMPTZ NOT NULL
);
Usage: Scanner bulk queries this table for new scans.
epss_changes (Delta Tracking, Partitioned)
Tracks material EPSS changes for targeted enrichment.
CREATE TABLE concelier.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,
delta_percentile DOUBLE PRECISION NULL,
flags INT NOT NULL, -- Bitmask
PRIMARY KEY (model_date, cve_id)
) PARTITION BY RANGE (model_date);
Flags (bitmask):
1= NEW_SCORED (CVE newly appeared)2= CROSSED_HIGH (percentile ≥95th)4= BIG_JUMP (|Δscore| ≥0.10)8= DROPPED_LOW (percentile <50th)16= SCORE_INCREASED32= SCORE_DECREASED
Configuration
Scheduler Configuration
File: etc/scheduler.yaml
scheduler:
jobs:
- name: epss.ingest
schedule: "0 5 0 * * *" # Daily at 00:05 UTC
worker: concelier
args:
source: online
date: null # Auto: yesterday
timeout: 600s
retry:
max_attempts: 3
backoff: exponential
Concelier Configuration
File: etc/concelier.yaml
concelier:
epss:
enabled: true
online_source:
base_url: "https://epss.empiricalsecurity.com/"
url_pattern: "epss_scores-{date:yyyy-MM-dd}.csv.gz"
timeout: 180s
bundle_source:
path: "/opt/stellaops/bundles/epss/"
thresholds:
high_percentile: 0.95 # Top 5%
high_score: 0.50 # 50% probability
big_jump_delta: 0.10 # 10 percentage points
low_percentile: 0.50 # Median
enrichment:
enabled: true
batch_size: 1000
flags_to_process:
- NEW_SCORED
- CROSSED_HIGH
- BIG_JUMP
Scanner Configuration
File: etc/scanner.yaml
scanner:
epss:
enabled: true
provider: postgres
cache_ttl: 3600
fallback_on_missing: unknown # Options: unknown, zero, skip
Policy Configuration
File: etc/policy.yaml
policy:
scoring:
epss:
enabled: true
profile: simple # Options: simple, advanced, custom
simple_bonuses:
percentile_99: 0.10 # +10%
percentile_90: 0.05 # +5%
percentile_50: 0.02 # +2%
lattice:
epss_escalation:
enabled: true
sr_to_cr_threshold: 0.90 # SR→CR if EPSS≥90th percentile
Daily Operation
Automated Ingestion
EPSS data is ingested automatically daily at 00:05 UTC via Scheduler.
Workflow:
- Scheduler triggers
epss.ingestjob at 00:05 UTC - Concelier downloads
epss_scores-YYYY-MM-DD.csv.gzfrom FIRST.org - CSV parsed (comment line → metadata, rows → scores)
- Bulk insert into
epss_scorespartition (NpgsqlBinaryImporter) - Compute delta:
epss_changes(compare vsepss_current) - Upsert
epss_current(latest projection) - Emit
epss.updatedevent - Enrichment job updates impacted vulnerability instances
- Notifications sent if priority bands changed
Monitoring:
# Check latest model date
stellaops epss status
# Output:
# EPSS Status:
# Latest Model Date: 2025-12-16
# Import Time: 2025-12-17 00:07:32 UTC
# CVE Count: 231,417
# Staleness: FRESH (1 day)
Manual Triggering
# Trigger manual ingest (force re-import)
stellaops concelier job trigger epss.ingest --date 2025-12-16 --force
# Backfill historical data (last 30 days)
stellaops epss backfill --from 2025-11-17 --to 2025-12-16
Air-Gapped Operation
Bundle Structure
EPSS data for offline deployments is packaged in risk bundles:
risk-bundle-2025-12-16/
├── manifest.json
├── epss/
│ ├── epss_scores-2025-12-16.csv.zst # ZSTD compressed
│ └── epss_metadata.json
├── kev/
│ └── kev-catalog.json
└── signatures/
└── bundle.dsse.json
EPSS Metadata
File: epss/epss_metadata.json
{
"model_date": "2025-12-16",
"model_version": "v2025.12.16",
"published_date": "2025-12-16",
"row_count": 231417,
"sha256": "abc123...",
"source_uri": "https://epss.empiricalsecurity.com/epss_scores-2025-12-16.csv.gz",
"created_at": "2025-12-16T00:00:00Z"
}
Import Procedure
# 1. Transfer bundle to air-gapped system
scp risk-bundle-2025-12-16.tar.zst airgap-host:/opt/stellaops/bundles/
# 2. Import bundle
stellaops offline import --bundle /opt/stellaops/bundles/risk-bundle-2025-12-16.tar.zst
# 3. Verify import
stellaops epss status
# Output:
# EPSS Status:
# Latest Model Date: 2025-12-16
# Source: bundle://risk-bundle-2025-12-16
# CVE Count: 231,417
# Staleness: ACCEPTABLE (within 7 days)
Update Cadence
Recommended:
- Online: Daily (automatic)
- Air-gapped: Weekly (manual bundle import)
Staleness Thresholds:
- FRESH: ≤1 day
- ACCEPTABLE: ≤7 days
- STALE: ≤14 days
- VERY_STALE: >14 days (alert, fallback to CVSS-only)
Scanner Integration
EPSS Evidence in Scan Findings
Every scan finding includes immutable EPSS-at-scan evidence:
{
"finding_id": "CVE-2024-12345-pkg:npm/lodash@4.17.21",
"cve_id": "CVE-2024-12345",
"product": "pkg:npm/lodash@4.17.21",
"scan_id": "scan-abc123",
"scan_timestamp": "2025-12-17T10:30:00Z",
"evidence": {
"cvss_v4": {
"vector_string": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H",
"base_score": 9.3,
"severity": "CRITICAL"
},
"epss_at_scan": {
"epss_score": 0.42357,
"percentile": 0.88234,
"model_date": "2025-12-16",
"import_run_id": "550e8400-e29b-41d4-a716-446655440000"
},
"epss_current": {
"epss_score": 0.45123,
"percentile": 0.89456,
"model_date": "2025-12-17",
"delta_score": 0.02766,
"delta_percentile": 0.01222,
"trend": "RISING"
}
}
}
Key Points:
- epss_at_scan: Immutable, captured at scan time (deterministic replay)
- epss_current: Mutable, updated daily for live triage
- Replay: Historical scans always use
epss_at_scanfor consistent policy evaluation
Bulk Query Optimization
Scanner queries EPSS for all CVEs in a single database call:
SELECT cve_id, epss_score, percentile, model_date, import_run_id
FROM concelier.epss_current
WHERE cve_id = ANY(@cve_ids);
Performance: <500ms for 10k CVEs (P95)
Policy Engine Integration
Risk Score Formula
Simple Profile:
risk_score = (cvss_base / 10) + epss_bonus + kev_bonus
EPSS Bonus Table:
| EPSS Percentile | Bonus | Rationale |
|---|---|---|
| ≥99th | +10% | Top 1% most likely to be exploited |
| ≥90th | +5% | Top 10% high exploitation probability |
| ≥50th | +2% | Above median moderate risk |
| <50th | 0% | Below median no bonus |
Advanced Profile:
Adds:
- KEV synergy: If in KEV catalog → multiply EPSS bonus by 1.5
- Uncertainty penalty: Missing EPSS → -5%
- Temporal decay: EPSS >30 days stale → reduce bonus by 50%
VEX Lattice Rules
Escalation:
- SR (Static Reachable) + EPSS≥90th → Auto-escalate to CR (Confirmed Reachable)
- Rationale: High exploit probability warrants confirmation
Review Flags:
- DV (Denied by Vendor VEX) + EPSS≥95th → Flag for manual review
- Rationale: Vendor denial contradicted by active exploitation signals
Prioritization:
- U (Unknown) + EPSS≥95th → Prioritize for reachability analysis
- Rationale: High exploit probability justifies effort
SPL (Stella Policy Language) Syntax
# Custom policy using EPSS
rules:
- name: high_epss_escalation
condition: |
epss.percentile >= 0.95 AND
lattice.state == "SR" AND
runtime.exposed == true
action: escalate_to_cr
reason: "High EPSS (top 5%) + Static Reachable + Runtime Exposed"
- name: epss_trend_alert
condition: |
epss.delta_score >= 0.10 AND
cvss.base_score >= 7.0
action: notify
channels: [slack, email]
reason: "EPSS jumped by 10+ points (was {epss.old_score}, now {epss.new_score})"
Available Fields:
epss.score- Current EPSS score (0.0-1.0)epss.percentile- Current percentile (0.0-1.0)epss.model_date- Model dateepss.delta_score- Change vs previous scanepss.trend- RISING, FALLING, STABLEepss.at_scan.score- Immutable score at scan timeepss.at_scan.percentile- Immutable percentile at scan time
Notification Integration
Event: vuln.priority.changed
Emitted when EPSS change causes priority band shift.
Payload:
{
"event_type": "vuln.priority.changed",
"vulnerability_id": "CVE-2024-12345",
"product_key": "pkg:npm/lodash@4.17.21",
"old_priority_band": "medium",
"new_priority_band": "high",
"reason": "EPSS percentile crossed 95th (was 88th, now 96th)",
"epss_change": {
"old_score": 0.42,
"new_score": 0.78,
"delta_score": 0.36,
"old_percentile": 0.88,
"new_percentile": 0.96,
"model_date": "2025-12-16"
}
}
Notification Rules
File: etc/notify.yaml
notify:
rules:
- name: epss_crossed_high
event_type: vuln.priority.changed
condition: "payload.epss_change.new_percentile >= 0.95"
channels: [slack, email]
template: epss_high_alert
digest: false # Immediate
- name: epss_big_jump
event_type: vuln.priority.changed
condition: "payload.epss_change.delta_score >= 0.10"
channels: [slack]
template: epss_rising_threat
digest: true
digest_time: "09:00" # Daily digest at 9 AM
Slack Template Example
🚨 **High EPSS Alert**
**CVE**: CVE-2024-12345
**Product**: pkg:npm/lodash@4.17.21
**EPSS**: 0.78 (96th percentile) ⬆️ from 0.42 (88th percentile)
**Delta**: +0.36 (36 percentage points)
**Priority**: Medium → **High**
**Action Required**: Review and prioritize remediation.
[View in StellaOps →](https://stellaops.example.com/vulns/CVE-2024-12345)
Troubleshooting
EPSS Data Not Available
Symptom: Scans show "EPSS: N/A"
Diagnosis:
# Check EPSS status
stellaops epss status
# Check import runs
stellaops concelier jobs list --type epss.ingest --limit 10
Resolution:
-
No imports: Trigger manual ingest
stellaops concelier job trigger epss.ingest -
Import failed: Check logs
stellaops concelier logs --job-id <id> --level ERROR -
FIRST.org down: Use air-gapped bundle
stellaops offline import --bundle /path/to/risk-bundle.tar.zst
Stale EPSS Data
Symptom: UI shows "EPSS stale (14 days)"
Diagnosis:
SELECT * FROM concelier.epss_model_staleness;
-- Output: days_stale: 14, staleness_status: STALE
Resolution:
-
Online: Check scheduler job status
stellaops scheduler jobs status epss.ingest -
Air-gapped: Import fresh bundle
stellaops offline import --bundle /path/to/latest-bundle.tar.zst -
Fallback: Disable EPSS temporarily (uses CVSS-only)
# etc/scanner.yaml scanner: epss: enabled: false
High Memory Usage During Ingest
Symptom: Concelier worker OOM during EPSS ingest
Diagnosis:
# Check memory metrics
stellaops metrics query 'process_resident_memory_bytes{service="concelier"}'
Resolution:
-
Increase worker memory limit:
# Kubernetes deployment resources: limits: memory: 1Gi # Was 512Mi -
Verify streaming parser (should not load full CSV into memory):
# Check logs for "EPSS CSV parsed: rows_yielded=" stellaops concelier logs --job-type epss.ingest | grep "CSV parsed"
Best Practices
1. Combine Signals (Never Use EPSS Alone)
❌ Don't: if epss > 0.95 then CRITICAL
✅ Do: if cvss >= 8.0 AND epss >= 0.95 AND runtime_exposed then CRITICAL
2. Review High EPSS Manually
Manually review vulnerabilities with EPSS ≥95th percentile, especially if:
- CVSS is low (<7.0) but EPSS is high
- Vendor VEX denies exploitability but EPSS is high
3. Track Trends
Monitor EPSS changes over time:
- Rising EPSS → increasing threat
- Falling EPSS → threat subsiding
4. Update Regularly
- Online: Daily (automatic)
- Air-gapped: Weekly minimum, daily preferred
5. Verify During Audits
For compliance audits, use EPSS-at-scan (immutable) not current EPSS:
SELECT epss_score_at_scan, epss_model_date_at_scan
FROM scan_findings
WHERE scan_id = 'audit-scan-20251217';
API Reference
Query Current EPSS
# Single CVE
stellaops epss get CVE-2024-12345
# Output:
# CVE-2024-12345
# Score: 0.42357 (42.4% probability)
# Percentile: 88.2th
# Model Date: 2025-12-16
# Status: FRESH
Batch Query
# From file
stellaops epss batch --file cves.txt --output epss-scores.json
# cves.txt:
# CVE-2024-1
# CVE-2024-2
# CVE-2024-3
Query History
# Last 180 days
stellaops epss history CVE-2024-12345 --days 180 --format csv
# Output: epss-history-CVE-2024-12345.csv
# model_date,epss_score,percentile
# 2025-12-17,0.45123,0.89456
# 2025-12-16,0.42357,0.88234
# ...
Top CVEs by EPSS
# Top 100
stellaops epss top --limit 100 --format table
# Output:
# Rank | CVE | Score | Percentile | CVSS
# -----|---------------|--------|------------|------
# 1 | CVE-2024-9999 | 0.9872 | 99.9th | 9.8
# 2 | CVE-2024-8888 | 0.9654 | 99.8th | 8.1
# ...
References
- FIRST EPSS Homepage: https://www.first.org/epss/
- EPSS Data & Stats: https://www.first.org/epss/data_stats
- EPSS API Docs: https://www.first.org/epss/api
- CVSS v4.0 Spec: https://www.first.org/cvss/v4.0/specification-document
- StellaOps Policy Guide:
docs/policy/overview.md - StellaOps Reachability Guide:
docs/modules/scanner/reachability.md
Last Updated: 2025-12-17 Version: 1.0 Maintainer: StellaOps Security Team