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 requests library, or a CI runner with internet access to api.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

DREAD vs EPSS Hybrid Scoring Flow Data flow from STRIDE threat identification through DREAD qualitative scoring and EPSS probabilistic scoring, merging into a composite risk score that feeds CI/CD gating. STRIDE Threat Findings DREAD Scoring Damage · Reproducibility Exploitability · Users · Discovery EPSS API Exploit probability 0.0–1.0 Updated daily · api.first.org Composite Risk EPSS × DREAD × BIM 0.0 – 10.0 scale BIM = Business Impact CI/CD Gate Block / Allow PR Trusted / controlled Scoring boundary Risk decision (untrusted input)

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