Concelier connector: EPSS
Sprint 20260505_040 —
docs/implplan/SPRINT_20260505_040_Concelier_epss_csv_endpoint_default.mdSource:src/Concelier/__Libraries/StellaOps.Concelier.Connector.Epss/
What EPSS is
The Exploit Prediction Scoring System is a free, daily-updated probabilistic score (0.0 – 1.0) of a CVE being exploited in the wild within the next 30 days. It is published by the FIRST.org EPSS SIG (Forum of Incident Response and Security Teams). Each row also carries a percentile that ranks the CVE against every other scored CVE on the same day — a percentile of 0.95 means the CVE is in the top 5% by exploitation likelihood.
The current EPSS corpus covers ~330,000 CVE identifiers (≈85% of NVD), all re-scored every UTC day around 12:00–14:00 UTC. Stella Ops reuses this signal as one of the inputs into the policy engine’s risk scoring (see src/Findings/__Libraries/StellaOps.Findings.RiskEngine).
Where the data comes from
FIRST publishes EPSS through two interfaces:
| Endpoint | Auth | Use |
|---|---|---|
https://epss.empiricalsecurity.com/epss_scores-YYYY-MM-DD.csv.gz | None — public | Bulk daily snapshot (gzip CSV, ~2.1 MB, ~330k rows) |
https://epss.empiricalsecurity.com/epss_scores-current.csv.gz | None — public | Always-redirects-to-latest alias |
https://epss.cyentia.com/epss_scores-current.csv.gz | None — public | Legacy mirror (same payload) |
https://api.first.org/data/v1/epss | Optional API key for rate limits | Per-CVE lookup, time-series, advanced filtering |
The Concelier connector uses the bulk CSV (no key required). The FIRST API is not consumed today. Operators who need richer query patterns (e.g. historical time series for one CVE) can still register at https://api.first.org/ for an API key, but the connector itself does not depend on it.
Why Stella Ops uses EPSS
EPSS is wired into the canonical advisory store as an overlay column (vuln.advisory_canonical.epss_score). The mapping path is:
fetch -> store gzip blob (raw_documents)
parse -> normalize header (model version, score date, row count)
map -> EpssMapper.ToObservation -> IEpssScoreStore.UpdateScoreAsync
-> UPDATE vuln.advisory_canonical SET epss_score = $score WHERE cve_id = $cve
This is a score overlay, not an advisory feed: rows in the CSV that have no matching canonical advisory are skipped (the UPDATE is a no-op, not an insert). Coverage therefore depends on which other connectors (NVD, GHSA, OSV, KEV, distro feeds) have already populated the canonical table.
The downstream consumers are:
- Policy engine: EPSS percentile is one of the EWS (Exploit Weighted Score) inputs. A CVE in the top decile (
>= 0.9percentile) escalates a finding even if its CVSS is moderate. - VulnExplorer / Findings UI: sorts and filters open findings by EPSS so triagers see “most likely to be exploited next month” first.
- Notify: high-EPSS deltas can fan out alerts.
Operator setup
Default (live, no key required)
The connector ships with sane defaults — no env vars or credentials needed.
# Concelier appsettings.json (or env: Concelier__Sources__Epss__*)
Concelier:
Epss:
BaseUri: "https://epss.empiricalsecurity.com/"
FetchCurrent: true # start from today, walk back through CatchUpDays
CatchUpDays: 7
HttpTimeout: "00:02:00"
MaxRetries: 3
UserAgent: "StellaOps.Concelier.Epss/1.0"
The connector iterates from “today (UTC)” back through CatchUpDays (default 7) days and picks the first dated snapshot that returns 200. FIRST publishes the daily file mid-UTC (typically 12–14 UTC), so for the first ~12 hours of a new UTC day the dated file for “today” does not yet exist. The connector correctly treats both 404 (not found) and 403 (forbidden — CDN’s response for “object does not exist yet”) as “fall through to the next candidate date”, so it always lands on the most recent published snapshot without operator intervention.
Air-gapped (bundle path)
For offline installs, drop the daily CSV(s) into a local directory and set:
Concelier:
Epss:
AirgapMode: true
BundlePath: "/var/lib/concelier/import/epss" # directory or single file
Files must be named epss_scores-YYYY-MM-DD.csv.gz to match what the connector asks for. An optional manifest.json co-located with the bundle can declare {name, modelVersion, sha256, rowCount} so the connector cross-checks the payload hash.
For Stella Ops compose installs the airgap import directory is bind-mounted at devops/compose/airgap-import/ on the host and /var/lib/concelier/import/ in the container. Drop EPSS files under devops/compose/airgap-import/epss/<file>.csv.gz and they appear immediately inside the container — no rebuild required.
Optional FIRST.org API key (not currently consumed)
If a future Concelier release adds the per-CVE API path, the key flows in via:
export Concelier__Sources__Epss__ApiKey="<key from https://api.first.org>"
(Today this option does not exist; documented here so we don’t reinvent the shape if/when we add it.)
Reset / re-fetch
If the source-state cursor or last_error ever drifts (e.g. after a network outage), reset it manually and the next scheduled fetch will re-acquire from scratch:
UPDATE vuln.source_states ss
SET cursor = NULL, last_error = NULL, error_count = 0
FROM vuln.sources s
WHERE ss.source_id = s.id AND s.key = 'epss';
The connector is idempotent — pulling the same epss_scores-<date>.csv.gz twice is a no-op (ETag + content hash).
References
- EPSS overview & methodology: https://www.first.org/epss/
- FIRST.org API (per-CVE / time-series): https://api.first.org/data/v1/
- Bulk CSV index: https://epss.empiricalsecurity.com/
- Connector source:
src/Concelier/__Libraries/StellaOps.Concelier.Connector.Epss/ - Mapper / persistence wiring:
EpssMapper.ToObservation→IEpssScoreStore.UpdateScoreAsync(Sprint 20260505_022 B-EPSS-002) - 403 fall-through fix: Sprint 20260505_040
