Concelier connector: EPSS

Sprint 20260505_040 — docs/implplan/SPRINT_20260505_040_Concelier_epss_csv_endpoint_default.md Source: 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:

EndpointAuthUse
https://epss.empiricalsecurity.com/epss_scores-YYYY-MM-DD.csv.gzNone — publicBulk daily snapshot (gzip CSV, ~2.1 MB, ~330k rows)
https://epss.empiricalsecurity.com/epss_scores-current.csv.gzNone — publicAlways-redirects-to-latest alias
https://epss.cyentia.com/epss_scores-current.csv.gzNone — publicLegacy mirror (same payload)
https://api.first.org/data/v1/epssOptional API key for rate limitsPer-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:

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