DREAD vs EPSS for Threat Prioritization: A Practical Implementation Guide
Legacy qualitative scoring fails to keep pace with daily exploit telemetry. This guide shows how to build a hybrid model that combines DREAD’s business-impact context with EPSS’s probabilistic exploit likelihood, then enforce the result as a hard gate in your CI/CD pipeline. It is part of the Threat Prioritization & Risk Scoring cluster, which sits within the broader Threat Modeling Fundamentals & Methodology practice. Teams working through attack surface mapping will find the composite scoring formula here integrates directly into the findings that surface during automated discovery.
Prerequisites
- Threat findings already identified via STRIDE framework implementation or equivalent
- CVE/CWE identifiers for any known-CVE findings; custom logic flaws flagged for manual DREAD scoring
- Python 3.10+ and the
requestslibrary, or a CI runner with internet access toapi.first.org - A version-controlled threat model repository for storing EPSS snapshots and scoring artifacts
Expected Outcomes
- A normalized DREAD score (0–10) for every threat in your model
- A composite risk score that multiplies EPSS probability by DREAD impact and a business-impact weight
- A GitHub Actions workflow that blocks PRs when composite risk exceeds defined thresholds
- Auditable scoring artifacts stored in Git for SOC 2, PCI-DSS, and ISO 27001 review
Step 1 — Normalize DREAD Scores from STRIDE Findings
After identifying threats with the STRIDE framework, map each finding to its five DREAD dimensions and produce a normalized 0–10 score. Use a rubric anchored to known reference threats so scores stay consistent across reviewers.
DREAD dimension rubric (1 = Low, 2 = Medium, 3 = High)
| Dimension | Score 1 | Score 2 | Score 3 |
|---|---|---|---|
| Damage | Minimal data exposure | Partial data loss or service disruption | Full system compromise, mass data exfiltration |
| Reproducibility | Requires rare preconditions | Reproducible with moderate effort | One-click or scripted |
| Exploitability | Nation-state researcher required | Skilled attacker with custom tooling | Script-kiddie, public PoC available |
| Affected Users | Single internal user | Team or department | All customers or global user base |
| Discoverability | Buried internal API | Requires authenticated enumeration | Public endpoint, indexed by scanners |
The normalization formula converts the 1–3 scale to 0–10:
DREAD_Raw = (D + R + E + A + Disc) / 5
DREAD_Normalized = DREAD_Raw × (10 / 3)
Anchor calibration: treat a public SQL injection endpoint as DREAD_Normalized = 9.2 and an authenticated, rate-limited IDOR as 5.8. Run calibration sessions every two weeks with security engineers and tech leads and enforce a ±0.5 variance tolerance across reviewers. For threats without a matching CVE — custom authorization bypasses, pricing logic flaws, IAM misconfigurations — assign a synthetic CWE-9999 placeholder and score DREAD with elevated Damage and Affected Users weights before feeding into the composite formula.
# scripts/dread_normalize.py
from dataclasses import dataclass
@dataclass
class DreadScores:
damage: int # 1-3
reproducibility: int # 1-3
exploitability: int # 1-3
affected_users: int # 1-3
discoverability: int # 1-3
def normalize_dread(scores: DreadScores) -> float:
"""Return a 0–10 normalized DREAD score."""
raw = (
scores.damage +
scores.reproducibility +
scores.exploitability +
scores.affected_users +
scores.discoverability
) / 5
return round(raw * (10 / 3), 2)
# Calibration anchor: SQLi on public endpoint
anchor = DreadScores(damage=3, reproducibility=3, exploitability=3,
affected_users=3, discoverability=3)
assert normalize_dread(anchor) == 10.0
# Custom logic flaw with no CVE — elevated damage and affected_users
custom_flaw = DreadScores(damage=3, reproducibility=2, exploitability=2,
affected_users=3, discoverability=1)
print(f"Custom flaw DREAD: {normalize_dread(custom_flaw)}") # 7.33
Step 2 — Build the Composite Formula with EPSS Enrichment
EPSS returns two values per CVE: epss (probability 0.0–1.0 of exploitation within 30 days) and percentile (rank relative to all tracked CVEs). The EPSS API at api.first.org is publicly accessible and requires no API key.
The composite formula combines both frameworks with a Business_Impact_Multiplier (BIM) derived from asset criticality tiers:
Composite_Risk = (EPSS × 10) × (DREAD_Normalized / 10) × BIM
| Asset Tier | Examples | BIM |
|---|---|---|
| Tier 1 — Core Revenue/Data | Payment processor, auth service, customer PII store | 1.5 |
| Tier 2 — Internal Operations | Admin portal, internal reporting API | 1.2 |
| Tier 3 — Non-Critical | Static asset CDN, marketing microsite | 0.8 |
For threats without a CVE match, default EPSS to 0.05 (baseline noise floor) and escalate to manual triage. For high-velocity environments apply exponential decay to weight recent telemetry:
Weighted_EPSS = EPSS × (1 − e^(−0.1 × days_since_update))
The full enrichment script that fetches EPSS, applies decay, and calculates composite risk:
# scripts/epss_enrichment.py
import requests
import math
import json
from datetime import datetime
EPSS_API_BASE = "https://api.first.org/data/v1/epss"
DECAY_LAMBDA = 0.1
NO_CVE_FALLBACK_EPSS = 0.05
BUSINESS_IMPACT_MULTIPLIERS = {
"tier1": 1.5,
"tier2": 1.2,
"tier3": 0.8,
}
def fetch_epss(cve_list: list[str]) -> list[dict]:
"""Fetch EPSS scores. The EPSS API requires no authentication."""
params = {"cve": ",".join(cve_list)}
resp = requests.get(EPSS_API_BASE, params=params, timeout=10)
resp.raise_for_status()
return resp.json().get("data", [])
def apply_decay(epss_score: float, days_old: int) -> float:
"""Weight recent telemetry higher via exponential decay."""
decay_factor = 1 - math.exp(-DECAY_LAMBDA * days_old)
return round(epss_score * decay_factor, 4)
def composite_risk(
epss: float,
dread_normalized: float,
asset_tier: str = "tier1",
) -> float:
bim = BUSINESS_IMPACT_MULTIPLIERS.get(asset_tier, 1.0)
score = (epss * 10) * (dread_normalized / 10) * bim
return round(min(score, 10.0), 2)
def enrich_findings(
cve_scores: list[dict],
dread_normalized: float,
asset_tier: str,
) -> str:
enriched = []
for item in cve_scores:
epss_raw = float(item["epss"])
days = (
datetime.now()
- datetime.strptime(item["date"], "%Y-%m-%d")
).days
epss_w = apply_decay(epss_raw, days)
risk = composite_risk(epss_w, dread_normalized, asset_tier)
enriched.append({
"cve": item["cve"],
"epss_raw": epss_raw,
"epss_weighted": epss_w,
"epss_percentile": float(item.get("percentile", 0)),
"dread_normalized": dread_normalized,
"composite_risk": risk,
"risk_tier": (
"CRITICAL" if risk >= 8.0
else "HIGH" if risk >= 6.0
else "MEDIUM" if risk >= 4.0
else "LOW"
),
})
return json.dumps(enriched, indent=2)
# Usage:
# scores = fetch_epss(["CVE-2024-3094", "CVE-2023-44487"])
# print(enrich_findings(scores, dread_normalized=7.33, asset_tier="tier1"))
The structured output schema, validated at merge time:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Enriched Threat Finding",
"type": "object",
"required": ["cve", "epss_raw", "epss_weighted", "dread_normalized", "composite_risk", "risk_tier"],
"properties": {
"cve": { "type": ["string", "null"], "pattern": "^CVE-\\d{4}-\\d{4,}$" },
"epss_raw": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
"epss_weighted": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
"epss_percentile": { "type": "number", "minimum": 0.0, "maximum": 100.0 },
"dread_normalized": { "type": "number", "minimum": 0.0, "maximum": 10.0 },
"composite_risk": { "type": "number", "minimum": 0.0, "maximum": 10.0 },
"risk_tier": { "type": "string", "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW"] }
}
}
Step 3 — Automate PR Gating with Daily EPSS Refresh
Configure a GitHub Actions workflow that runs on every pull request, fetches current EPSS scores for the CVEs in your threat model, calculates composite risk, and blocks the merge if any finding exceeds the CRITICAL or HIGH threshold.
Composite risk → merge policy mapping:
| Composite Score | Risk Tier | Merge Decision |
|---|---|---|
≥ 8.0 |
CRITICAL | Block; require security-team sign-off |
6.0–7.9 |
HIGH | Block; auto-create remediation ticket |
4.0–5.9 |
MEDIUM | Allow; SLA-bound fix required |
< 4.0 |
LOW | Allow; backlog tracking |
# .github/workflows/threat-gating.yml
name: Threat Prioritization & PR Gating
on:
pull_request:
branches: [main, develop]
schedule:
- cron: "0 6 * * *" # daily EPSS refresh at 06:00 UTC
jobs:
risk-assessment:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install requests pyyaml jsonschema
- name: Fetch EPSS scores
run: |
CVE_LIST=$(cat threat-model/cve-list.txt | tr '\n' ',' | sed 's/,$//')
curl -sG "https://api.first.org/data/v1/epss" \
--data-urlencode "cve=${CVE_LIST}" \
-o epss_results.json
- name: Enrich findings with composite scores
run: |
python scripts/epss_enrichment.py \
--epss epss_results.json \
--dread-rubric ./config/dread_rubric.json \
--asset-tier tier1 \
--output ./threat-model/enriched.json
- name: Validate output schema
run: |
python -c "
import json, jsonschema
schema = json.load(open('config/finding_schema.json'))
findings = json.load(open('threat-model/enriched.json'))
for f in findings:
jsonschema.validate(f, schema)
print('Schema validation passed')
"
- name: Block PR on CRITICAL or HIGH risk
run: |
BLOCKING=$(jq '[.[] | select(.composite_risk >= 6.0)] | length' \
./threat-model/enriched.json)
if [ "$BLOCKING" -gt 0 ]; then
jq '.[] | select(.composite_risk >= 6.0) |
"CVE: \(.cve) | Score: \(.composite_risk) | Tier: \(.risk_tier)"' \
./threat-model/enriched.json
echo "::error::One or more findings exceed the merge risk threshold."
exit 1
fi
- name: Commit refreshed scores
if: github.event_name == 'schedule'
run: |
git config user.name "threat-bot"
git config user.email "[email protected]"
git add threat-model/enriched.json
git diff --staged --quiet || \
git commit -m "chore: daily EPSS refresh $(date -u +%Y-%m-%d)"
git push
For long-lived branches, schedule the cron trigger to recalculate scores daily. EPSS values shift substantially as public exploit activity evolves — a finding that scored LOW on Monday can cross into HIGH by Thursday if a PoC drops.
Verification
Confirm the pipeline is working by running the enrichment script against a known CVE pair and checking the output JSON:
# Fetch a CVE with historically high EPSS (Log4Shell) and one with low
python scripts/epss_enrichment.py \
--cve-list "CVE-2021-44228,CVE-2023-11111" \
--dread-normalized 8.5 \
--asset-tier tier1 \
--output /tmp/test_enriched.json
# Assert that Log4Shell composite score is CRITICAL
jq '.[] | select(.cve == "CVE-2021-44228") | .risk_tier' /tmp/test_enriched.json
# Expected: "CRITICAL"
# Verify schema compliance
python -c "
import json, jsonschema
schema = json.load(open('config/finding_schema.json'))
findings = json.load(open('/tmp/test_enriched.json'))
for f in findings: jsonschema.validate(f, schema)
print('All findings pass schema validation')
"
In your CI logs, look for the line All findings pass schema validation before the gate check. If the EPSS API is rate-limited or unavailable, the fetch_epss function raises requests.HTTPError — catch this and fall back to the cached snapshot in threat-model/enriched.json rather than failing the entire pipeline.
Troubleshooting
| Failure | Diagnosis | Fix |
|---|---|---|
EPSS API returns 429 |
Rate limit exceeded; batch requests exceed ~50 CVEs/request | Split cve_list into 50-CVE chunks with a 1-second pause between requests |
Composite score is 0.0 for all findings |
EPSS returned no data for the CVE (too new or misspelled) | Check CVE ID format (CVE-YYYY-NNNNN); fall back to NO_CVE_FALLBACK_EPSS = 0.05 |
DREAD variance > 0.5 across reviewers |
Calibration rubric is ambiguous or anchor threats are stale | Re-run calibration workshop; update anchor threat list with current quarter’s examples |
PR gate blocks on MEDIUM findings |
Threshold misconfigured; BLOCKING jq filter is catching score ≥ 4.0 |
Change jq filter to select(.composite_risk >= 6.0) for HIGH+CRITICAL only |
Schema validation fails on custom logic flaw |
cve field is null but schema requires the CVE pattern |
Update schema: set "type": ["string", "null"] and skip pattern check when null |
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| SOC 2 CC6.1 | Logical access and vulnerability management | Composite scoring artifacts stored in version-controlled threat model |
| SOC 2 CC7.1 | Change management with risk review | PR gating workflow blocks high-risk merges; exception tickets in issue tracker |
| OWASP ASVS V14.2 | Dependency security | EPSS enrichment of SBOM CVEs triggers automatic SLA assignment |
| OWASP ASVS V1.1 | Secure design documentation | DREAD calibration rubric and STRIDE-to-DREAD mapping documented per sprint |
| NIST SP 800-40 Rev. 4 | Patch management prioritization | EPSS percentile + composite score drives patching SLA assignments |
| ISO 27001 A.8.8 | Management of technical vulnerabilities | Auditable JSON artifacts with scoring rationale stored ≥ 12 months |
| PCI-DSS 6.4 | Change control and pre-deployment review | Critical/High composite findings trigger mandatory pre-deployment security sign-off |
Frequently Asked Questions
Can EPSS completely replace DREAD in modern threat modeling?
No. EPSS measures exploit probability only and has no concept of business impact. A vulnerability scored EPSS = 0.9 against a non-critical static asset is less urgent than EPSS = 0.3 against your payment processor. The hybrid composite formula keeps both dimensions in play. Compliance frameworks including SOC 2 and ISO 27001 also require documented impact rationale, which EPSS alone cannot provide.
How should teams handle zero-day vulnerabilities with low or missing EPSS scores?
Apply fallback DREAD scoring with elevated Exploitability and Affected Users dimensions. Assign a temporary CRITICAL risk tier and route through a manual triage queue with a 24-hour SLA. Revisit the EPSS score daily as public telemetry accumulates — scores for high-profile zero-days can jump from 0.01 to 0.80 within 72 hours of a PoC release.
Does EPSS account for internal asset criticality or data classification?
No. EPSS is a population-level exploit probability metric trained on public internet telemetry. It has no knowledge of your asset tiers or regulatory exposure. Apply the Business_Impact_Multiplier derived from your data classification policy to translate raw EPSS into business-relevant priority.
How do composite scores map to compliance remediation SLAs?
Use this threshold-to-SLA table: composite ≥ 8.0 → immediate/24 h with executive notification; 6.0–7.9 → 14-day SLA with compensating control documented; 4.0–5.9 → 30-day SLA or risk acceptance; < 4.0 → 90-day backlog. Store each scoring decision, the EPSS snapshot date, and the assigned SLA in your version-controlled threat model so auditors can trace every risk treatment back to its data.
Related
- Threat Prioritization & Risk Scoring — parent cluster covering CVSS, EPSS, and DREAD framework selection
- How to Apply STRIDE to Microservices Architecture — the threat identification step that feeds findings into this scoring pipeline
- Automated Attack Surface Discovery with OWASP ZAP — surface CVE-bearing endpoints before applying composite scoring
- Threat Modeling Fundamentals & Methodology — grandparent pillar