Vulnerability Prioritization Workflow: CVE Record to Ticket

A six-step patch-decision flow that takes a scanner finding from CVE record to ticket: check the record state, confirm you are affected, apply supplier VEX, read KEV and CISA's SSVC values, add EPSS, then let your exposure decide. Includes a tested Python script that writes the ticket fields.

View from the side of a passenger train of a railway switch where the tracks split, with a red point indicator, dry golden grassland and brown mountains behind
Photo by Paul on Unsplash

Scope. This is a workflow guide built on public specifications and feeds, plus one script we ran. On November 2, 2026 we read the CVE JSON schema (5.2.0), CISA’s Vulnrichment README, CERT/CC’s SSVC deployer documentation and decision table, the OpenVEX specification and the EPSS repository README, and ran the script below against the KEV catalog (version 2026.10.30), the EPSS file scored 2026-11-01 and CVE records from cvelistV5. The assets, exposure ratings and the VEX document in the test are invented for the demonstration. The policy choices in the script are ours, not a standard.

The workflow in one table

Our comparison of CVSS, EPSS and KEV explains what each signal measures. This guide is the plumbing: the order in which a finding from a scanner or SBOM passes through the data, and what lands in the ticket.

StepQuestionData you readPossible outcome
1Is the CVE record valid?cveMetadata.state in the CVE recordClose if REJECTED
2Do we run an affected version?containers.cna.affected: versions, defaultStatusClose if unaffected; escalate if unknown
3Has the supplier said we are not affected?VEX statement for this productClose with the VEX justification
4Is it being exploited?KEV entry; CISA ADP SSVC ExploitationSets the Exploitation input
5How likely is exploitation?EPSS epss, percentileRe-orders the non-exploited queue
6How exposed and important is this asset?Your inventory: System Exposure, Human ImpactPriority: Defer, Scheduled, Out-of-cycle, Immediate

Steps 1 to 3 remove work. Steps 4 to 6 order what remains.

Step 1: check the record state

A CVE record is either PUBLISHED or REJECTED (the schema’s two states), and published records can carry tags such as disputed. A rejected ID has no affected products to evaluate. In our test run, CVE-2026-23915 is a Fortinet-assigned ID that was rejected on January 20, 2026 with the reason “Not used”, so the finding closes. Our guide to CVE record states covers the tags and edge cases.

Step 2: confirm you are affected

The CNA’s affected block is an algorithm, not a list: walk versions, take the first match, otherwise use defaultStatus, and if that is missing the schema says the status is unknown. Scanner results that only match a product name skip this. Our explainer on CVE affected versions shows how lessThan, defaultStatus and versionType combine, with a tested evaluator. Keep three outcomes apart in the ticket: affected, unaffected, and unknown or undetermined. The last needs a person or the vendor advisory.

Step 3: apply supplier VEX

If the vulnerable component ships inside someone else’s product, the supplier may publish a VEX (Vulnerability Exploitability eXchange) statement. OpenVEX defines four statuses: not_affected (“No remediation is required regarding this vulnerability”), affected, fixed and under_investigation. A not_affected statement must carry a justification or an impact statement.

Two cautions. A VEX statement is about a specific product and version, so match on the product identifier, not on the CVE alone; a not_affected statement from an appliance vendor says nothing about your own services that use the same library. And read the justification before closing. Our guide to reading a VEX not-affected statement covers the five justifications and what to question.

Step 4: read the exploitation evidence

Two public sources answer “is this being exploited?”:

  • KEV. The CISA KEV JSON feed lists CVEs with evidence of exploitation, with dateAdded, dueDate, knownRansomwareCampaignUse and, on newer entries, forensicTriage. The catalog we used (version 2026.10.30, released October 30, 2026) had 1,766 entries.
  • CISA’s SSVC values in the CVE record. CISA’s Vulnrichment project writes three SSVC decision points (Exploitation, Automatable, Technical Impact) into a CISA-ADP container. Its README says CISA is “assessing new and recent CVEs”, and that it will not overwrite the CNA’s data. Each value set carries a timestamp. Our Vulnrichment guide shows where they sit and how to pull them with jq.

The SSVC documentation describes Exploitation as “the present state of exploitation”, not a prediction. A none from months ago can be stale, so a KEV listing should outrank an older ADP value. That is the rule our script applies.

Step 5: add EPSS for likelihood

For findings with no exploitation evidence, EPSS estimates “the probability [0-1] of exploitation in the wild in the next 30 days”, and percentile gives the share of scored CVEs with the same or a lower score. The file we used (model v2026.06.15, scored 2026-11-01) held 398,784 CVEs. EPSS is a ranking aid within the non-exploited queue. It does not replace step 4: in our run the KEV-listed FortiMail flaw scored 0.04573. See the EPSS data feed guide for the columns, model-version breaks and a scanner join.

Step 6: let exposure and impact decide

CERT/CC’s SSVC deployer model maps four inputs to a patch priority:

InputValuesWho supplies it
Exploitationnone, public poc, activeCISA ADP or KEV, or your own research
System Exposuresmall, controlled, openYou, per asset
Automatableno, yesCISA ADP, or your own analysis
Human Impactlow, medium, high, very highYou, per asset

The decision table (deployer_patch_application_priority_1_0_0.csv, 72 rows) returns one of four outcomes, which the deployer documentation defines as: Defer (“Do not act at present”), Scheduled (“Act during regularly scheduled maintenance time”), Out-of-cycle (“Act more quickly than usual… during the next available opportunity”) and Immediate (“Act immediately; focus all resources on applying the fix”). In the table, Immediate appears in 3 rows, all with active exploitation and open exposure. Our SSVC deployer explainer walks through the inputs, including where Human Impact is easy to get wrong.

One mapping detail: CISA’s ADP data writes poc, while the deployer table uses public poc. They are not identical definitions. CISA’s tree uses the older PoC value, which also covers privately attested exploitation and widespread hearsay; the newer Public PoC value covers only public exploit code or a well-known method. Our script maps one to the other, which can understate cases CISA marked poc on private evidence.

A tested script that writes the ticket fields

The script reads a findings CSV, the KEV JSON, the EPSS daily file, the SSVC deployer CSV, a folder of CVE records and an optional OpenVEX file, and prints one ticket row per finding. Python 3.10 or later, standard library only.

#!/usr/bin/env python3
"""Turn scanner findings into patch-decision ticket rows.

Inputs : findings.csv (asset,product,cve,exposure,human_impact)
         KEV JSON, EPSS daily CSV (.csv.gz), SSVC deployer table CSV,
         a directory of cvelistV5 records, optional OpenVEX file.
Output : tickets.csv on stdout. Python 3.10+, standard library only.
"""
import csv, gzip, json, sys, argparse, pathlib

def load_kev(path):
    data = json.load(open(path))
    return {v["cveID"].strip().upper(): v for v in data["vulnerabilities"]}

def load_epss(path):
    scores = {}
    with gzip.open(path, "rt") as fh:
        rows = (line for line in fh if not line.startswith("#"))
        for r in csv.DictReader(rows):
            scores[r["cve"]] = (float(r["epss"]), float(r["percentile"]))
    return scores

def load_deployer(path):
    rows = list(csv.reader(open(path)))
    return {tuple(r[1:5]): r[5] for r in rows[1:]}

def load_vex(path):
    out = {}
    if path:
        for s in json.load(open(path)).get("statements", []):
            for p in s.get("products", []):
                out[(s["vulnerability"]["name"].upper(), p["@id"])] = s
    return out

def cisa_ssvc(record):
    """Return CISA ADP SSVC values (Exploitation, Automatable, Technical Impact, timestamp)."""
    for adp in record["containers"].get("adp", []):
        for m in adp.get("metrics", []):
            other = m.get("other", {})
            if other.get("type") == "ssvc":
                c = other["content"]
                opts = {k: v for o in c["options"] for k, v in o.items()}
                return (opts.get("Exploitation"), opts.get("Automatable"),
                        opts.get("Technical Impact"), c.get("timestamp"))
    return (None, None, None, None)

def main():
    ap = argparse.ArgumentParser()
    for a in ("findings", "kev", "epss", "deployer", "records"):
        ap.add_argument(a)
    ap.add_argument("--vex")
    args = ap.parse_args()
    kev, epss = load_kev(args.kev), load_epss(args.epss)
    table, vex = load_deployer(args.deployer), load_vex(args.vex)
    out = csv.writer(sys.stdout)
    out.writerow(["asset", "cve", "record_state", "vex_status", "kev_dateAdded",
                  "kev_dueDate", "kev_ransomware", "cisa_exploitation", "cisa_automatable",
                  "cisa_technical_impact", "cisa_ssvc_timestamp", "epss", "epss_percentile",
                  "deployer_exploitation", "deployer_priority", "next_step"])
    for f in csv.DictReader(open(args.findings)):
        cve = f["cve"].strip().upper()
        rec_path = pathlib.Path(args.records) / f"{cve}.json"
        if not rec_path.exists():
            out.writerow([f["asset"], cve, "NO RECORD"] + [""] * 12 + ["check the ID"])
            continue
        rec = json.load(open(rec_path))
        state = rec["cveMetadata"]["state"]
        if state == "REJECTED":
            out.writerow([f["asset"], cve, state] + [""] * 12 + ["close: CVE ID rejected"])
            continue
        v = vex.get((cve, f.get("product", "")), {})  # VEX applies to one product, not every asset
        k = kev.get(cve, {})
        exp, auto, ti, ts = cisa_ssvc(rec)
        e, pct = epss.get(cve, ("", ""))
        # Our policy choice: a KEV listing counts as active exploitation, whatever
        # the (possibly older) CISA ADP snapshot says. CISA writes "poc"; the
        # deployer table writes "public poc".
        dexp = "active" if k else {"poc": "public poc"}.get(exp, exp)
        prio = ""
        if dexp and auto:
            prio = table.get((dexp, f["exposure"], auto, f["human_impact"]), "")
        if v.get("status") == "not_affected":
            step = "close: supplier VEX not_affected (" + v.get("justification", "no justification") + ")"
        elif not prio:
            step = "manual triage: no CISA SSVC data"
        else:
            step = prio
        out.writerow([f["asset"], cve, state, v.get("status", ""), k.get("dateAdded", ""),
                      k.get("dueDate", ""), k.get("knownRansomwareCampaignUse", ""),
                      exp or "", auto or "", ti or "", ts or "", e, pct, dexp or "", prio, step])

if __name__ == "__main__":
    main()

The test input (findings.csv) used invented assets with real CVE IDs:

asset,product,cve,exposure,human_impact
edge-mail-01,fortimail,CVE-2026-104286,open,high
k8s-node-17,linux-kernel,CVE-2026-46300,controlled,medium
build-runner-03,pkg:cargo/[email protected],CVE-2026-100419,small,medium
billing-api,pkg:maven/org.apache.logging.log4j/[email protected],CVE-2021-44228,open,very high
vendor-appliance-2,pkg:generic/[email protected],CVE-2021-44228,open,very high
legacy-scan,unknown,CVE-2026-23915,open,low

The VEX file is an invented OpenVEX document from an “Example Appliance Vendor” stating not_affected with justification vulnerable_code_not_present for CVE-2021-44228 in pkg:generic/[email protected]. We ran:

python3 patch_decision.py findings.csv known_exploited_vulnerabilities.json \
  epss_scores-2026-11-01.csv.gz deployer_patch_application_priority_1_0_0.csv \
  records/ --vex vendor.openvex.json > tickets.csv

The data came from raw.githubusercontent.com/cisagov/kev-data, the empiricalsec/epss_scores repository (2026/epss_scores-2026-11-01.csv.gz), CERTCC/SSVC (data/csv/ssvc/) and CVEProject/cvelistV5 at commit 451e97a. Output, with some columns dropped for width:

AssetCVEKEV addedCISA SSVC (E / A / TI)EPSS (percentile)Deployer priorityNext step
edge-mail-01CVE-2026-1042862026-10-01active / yes / total0.04573 (0.916)immediateimmediate
k8s-node-17CVE-2026-463002026-10-28active / no / total0.1613 (0.969)scheduledscheduled
build-runner-03CVE-2026-100419-poc / no / total0.00222 (0.129)scheduledscheduled
billing-apiCVE-2021-442282021-12-10active / yes / total0.99999 (1.0)immediateimmediate
vendor-appliance-2CVE-2021-442282021-12-10active / yes / total0.99999 (1.0)immediateclose: supplier VEX not_affected (vulnerable_code_not_present)
legacy-scanCVE-2026-23915----close: CVE ID rejected

Three things in that output are worth noticing:

  • The same CVE got two different answers (Log4Shell rows) because the VEX statement applies to one product only.
  • A KEV-listed kernel flaw came out Scheduled. CVE-2026-46300 is in KEV with forensicTriage: Yes and a three-day federal due date, but with controlled exposure, no automatability and medium Human Impact the deployer table says Scheduled. If you follow CISA’s directives, the due date wins. If you do not, the table is telling you the exposure is what drives urgency. Either way, keep both fields in the ticket, and consider a compromise check when forensicTriage is Yes; our BOD 26-04 explainer covers what that flag asks agencies to do.
  • EPSS and KEV disagreed on FortiMail, as expected: one estimates future observed activity, the other records evidence already seen.

What the ticket should hold

Store the inputs, not just the priority, so a reviewer can see why. A suggested field list (ours, not a standard):

FieldWhy
Asset, product identifier (purl or CPE), versionTies VEX and affected checks to the right thing
CVE ID, record state, record dateUpdatedDetects rejected IDs and record changes
Affected status and how it was decided“unknown” needs a different owner from “affected”
VEX status, justification, document author and timestampAudit trail for closures
KEV dateAdded, dueDate, knownRansomwareCampaignUse, forensicTriageExploitation evidence and any federal deadline
CISA SSVC Exploitation, Automatable, Technical Impact, timestampShows how fresh the assessment is
EPSS score, percentile, model version, score dateScores shift when the model changes
Your System Exposure and Human ImpactThe inputs only you can supply
Priority and data pull dateLets you re-run and compare

Keeping it current

The feeds move at different speeds: CISA updates KEV on weekdays, EPSS publishes daily, and CVE records and VEX documents change when their authors revise them. Re-run the flow on a schedule and on any change to a ticket’s inputs, and alert when a finding’s priority rises. Set your service-level targets per priority (for example, how many days “Out-of-cycle” means for you) in policy, not in the script; the deployer model does not define days.

Limits of this article

The script is a working example, not a product. It reads CVE records from a local folder (it does not fetch them), does not evaluate affected versions itself (pair it with a version check such as the evaluator in our affected-versions guide), handles OpenVEX JSON only, and uses one policy choice (KEV overrides an older CISA Exploitation value) that you may want to change. The four-value deployer inputs simplify real decisions: SSVC expects organizations to adapt trees to their context. We did not test it against commercial scanner exports.

Sources and verification

Important claimSourceStatus
CVE record states, affected algorithm, defaultStatus defaults to unknownCVE JSON schema (CVE_Record_Format.json, 5.2.0)Verified
CVE-2026-23915 rejected, reason “Not used”; SSVC values and timestamps for the test CVEscvelistV5, commit 451e97aVerified (read November 2, 2026)
Vulnrichment assesses “new and recent CVEs”, adds SSVC decision points, never overwrites CNA datacisagov/vulnrichment READMEVerified
KEV catalog 2026.10.30, 1,766 entries; dates and flags for the test CVEscisagov/kev-dataVerified
EPSS field definitions; file model v2026.06.15, 398,784 CVEs; scores for the test CVEsempiricalsec/epss_scoresVerified (computed from the file)
Deployer inputs, values, 72-row table, outcome definitions, active never DeferCERT/CC SSVC deployer tree and decision table CSVVerified
Exploitation is “the present state of exploitation”; PoC vs Public PoC definitionsSSVC Exploitation decision point and its source definitionsVerified
OpenVEX statuses and the justification requirementOpenVEX specificationVerified
Script outputScript above, Python 3.13, run November 2, 2026Verified

Frequently asked questions

What order should I check KEV, EPSS, CVSS and SSVC in?

Validate the CVE record and your affected status first, apply supplier VEX, then check exploitation evidence (KEV and CISA's SSVC Exploitation value), then EPSS for likelihood, and finally combine exploitation and automatability with your own exposure and impact inputs. CVSS describes severity; it is useful context in the ticket but should not decide order on its own.

Does a KEV listing always mean patch immediately?

For US federal civilian agencies, KEV entries carry due dates under CISA's binding directives. For everyone else it is strong exploitation evidence, not a deadline. In CERT/CC's deployer table, active exploitation never yields Defer, but it yields Scheduled in six of the 72 rows, all with low or medium Human Impact.

What should a vulnerability ticket contain?

At minimum: asset, product and version, CVE ID and record state, affected status and how it was decided, any VEX status and justification, KEV dateAdded and dueDate, CISA SSVC values with their timestamp, EPSS score, percentile and score date, your exposure and impact inputs, the resulting priority, and the date the data was pulled.

What if a CVE has no CISA SSVC data?

CISA says Vulnrichment covers new and recent CVEs it assesses, so many records have no CISA-ADP SSVC block. Treat that as missing data, not low risk: decide Exploitation yourself from KEV, the vendor advisory and threat reporting, and route the finding to manual triage.

Vulnerability ManagementKevEpssSsvcVexCvePatch Management