Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 8 min read

Reading CISA's KEV due dates against BOD 26-04's Table 1 in Python

On 10 June 2026 CISA stopped giving every entry in its Known Exploited Vulnerabilities (KEV) catalog the same kind of deadline. BOD 26-04, which revoked BOD 22-01 (the directive that created the catalog), sets federal ci

On 10 June 2026 CISA stopped giving every entry in its Known Exploited Vulnerabilities (KEV) catalog the same kind of deadline. BOD 26-04, which revoked BOD 22-01 (the directive that created the catalog), sets federal civilian agencies a remediation timeline from four questions. Is the asset publicly exposed? Is the CVE in the KEV? Can an adversary automate every step of the exploit? Does exploitation give partial or total control? The answers come out as 3 days, 3 days plus forensic triage, 14 days, 60 days, or "fix on system upgrade".

The KEV JSON still carries one dueDate per CVE, plus a newer forensicTriage field. Three of the four inputs to the table are public: the directive says CISA publishes KEV status, Automatable and Technical Impact for every CVE ID "through services such as the Vulnrichment Program". So I wanted to know two things. Is the catalog's single date just the table read with "publicly exposed: yes"? And can you compute it yourself from public data? Mostly yes. The exceptions are the useful part.

Everything below ran on 5 November 2026 against catalog version 2026.11.04 (1,768 entries), with the Python standard library only.

Table 1 as data

The directive publishes Table 1 only as images. Here it is as a dict, in the directive's row order:

import collections, json, pathlib, time, urllib.error, urllib.request
from datetime import date

# BOD 26-04 Table 1, keyed (publicly exposed, in the KEV, automatable, technical impact)
TABLE_1 = {
    (True,  True,  True,  "total"):   "3 days & forensic triage",
    (True,  True,  True,  "partial"): "3 days",
    (True,  True,  False, "total"):   "3 days & forensic triage",
    (True,  True,  False, "partial"): "14 days",
    (True,  False, True,  "total"):   "3 days",
    (True,  False, True,  "partial"): "14 days",
    (True,  False, False, "total"):   "14 days",
    (True,  False, False, "partial"): "60 days",
    (False, True,  True,  "total"):   "3 days & forensic triage",
    (False, True,  True,  "partial"): "14 days",
    (False, True,  False, "total"):   "14 days",
    (False, True,  False, "partial"): "14 days",
    (False, False, True,  "total"):   "60 days",
    (False, False, True,  "partial"): "60 days",
    (False, False, False, "total"):   "Fix on system upgrade",
    (False, False, False, "partial"): "Fix on system upgrade",
}

For anything already in the catalog, "in the KEV" is always yes, so only eight rows matter, four per exposure value. The directive also says the timelines "are dynamic as facts change": take a server off the internet and its row changes. A catalog entry has one date for every agency's every asset, so at best it can match one exposure value.

Getting CISA's SSVC values

Vulnrichment is a public GitHub repository, cisagov/vulnrichment, holding CVE records with CISA's own Authorized Data Publisher container added. Files sit under the year and a thousands bucket, so CVE-2024-27834 lives at 2024/27xxx/CVE-2024-27834.json. The decision values are in the container whose shortName is CISA-ADP, as a metric of type ssvc whose options are a list of one-key dicts:

RAW = "https://raw.githubusercontent.com/cisagov/vulnrichment/develop"
CACHE = pathlib.Path("vulnrichment-cache")
CACHE.mkdir(exist_ok=True)

def record_path(cve_id):
    _, year, num = cve_id.split("-")
    return f"{year}/{num[:-3]}xxx/{cve_id}.json"

def fetch_record(cve_id, ref="develop"):
    cached = CACHE / f"{cve_id}.{ref[:12]}.json"
    if cached.exists():
        return json.loads(cached.read_text())
    url = f"https://raw.githubusercontent.com/cisagov/vulnrichment/{ref}/{record_path(cve_id)}"
    try:
        with urllib.request.urlopen(url, timeout=20) as r:
            body = r.read()
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None
        raise
    cached.write_bytes(body)
    time.sleep(0.2)
    return json.loads(body)

def ssvc(record):
    for adp in record["containers"].get("adp", []):
        if adp["providerMetadata"].get("shortName") != "CISA-ADP":
            continue
        for metric in adp.get("metrics", []):
            other = metric.get("other", {})
            if other.get("type") == "ssvc":
                opts = {}
                for option in other["content"]["options"]:
                    opts.update(option)
                opts["timestamp"] = other["content"]["timestamp"]
                return opts
    return None

For CVE-2024-27834, an Apple entry added to the catalog on 4 November, that returns Exploitation active, Automatable no, Technical Impact partial, with a timestamp of 2026-11-04T20:14:40Z.

Comparing every entry added since 10 June

Download the catalog first:

curl -sS -A "Mozilla/5.0" -o kev.json \
  https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json

Then read each entry's due date as a gap in days, look up its SSVC values, and ask Table 1 what the "publicly exposed: yes" row says:

kev = json.load(open("kev.json"))
since = date(2026, 6, 10)
groups = collections.Counter()
mismatches = []
total = 0
for e in kev["vulnerabilities"]:
    added = date.fromisoformat(e["dateAdded"])
    if added < since:
        continue
    total += 1
    gap = (date.fromisoformat(e["dueDate"]) - added).days
    triage = e.get("forensicTriage")
    opts = ssvc(fetch_record(e["cveID"]))
    auto, impact = opts["Automatable"] == "yes", opts["Technical Impact"]
    expected = TABLE_1[(True, True, auto, impact)]
    ok = gap == int(expected.split()[0]) and triage == ("Yes" if "forensic" in expected else "No")
    if not ok:
        mismatches.append(e)
        groups[(opts["Automatable"], impact, gap, triage)] += 1

print(f"catalog {kev['catalogVersion']}: {total} entries added since {since}")
print(f"match the publicly-exposed row: {total - len(mismatches)}, mismatch: {len(mismatches)}")
for (auto, impact, gap, triage), n in groups.most_common():
    print(f"  automatable={auto:<3} impact={impact:<7} catalog: {gap:>2} days, forensicTriage={triage}  x{n}")

The e.get("forensicTriage") is deliberate. CISA's published schema lists eight required fields per entry, and forensicTriage is not one of them. It describes the field as "'Yes' if this vulnerability requires forensic triage per BOD 26-04". Every entry in the 4 November file has it (1,671 No, 97 Yes), but a parser that indexes it directly is relying on something the schema does not promise.

The output:

catalog 2026.11.04: 151 entries added since 2026-06-10
match the publicly-exposed row: 114, mismatch: 37
  automatable=no  impact=total   catalog: 14 days, forensicTriage=No  x17
  automatable=yes impact=total   catalog:  3 days, forensicTriage=No  x11
  automatable=yes impact=partial catalog: 14 days, forensicTriage=No  x6
  automatable=no  impact=total   catalog:  3 days, forensicTriage=No  x2
  automatable=yes impact=partial catalog:  3 days, forensicTriage=Yes  x1

114 of 151 is a strong majority. The 37 fall into three shapes and one stray.

Seventeen entries are not automatable with total control, and the catalog gives 14 days. The exposed row for that pair says 3 days and forensic triage. Fourteen days is what the table gives the not publicly exposed row. But 37 other entries with the same SSVC pair got 3 days and triage, so no single exposure assumption explains the catalog.

Six entries are automatable with partial control, and the catalog gives 14 days. Again the not-exposed row, where the exposed row says 3 days.

Thirteen entries have the right 3 days but forensicTriage set to No while SSVC says total control. Eleven of those thirteen were added in June. The first Yes anywhere in the catalog is on an entry added 25 June, so the early June entries may predate CISA using the field. That is an inference from the dates; I found nothing that says so.

One entry is automatable with partial control and has forensic triage set to Yes. Its 3 days are right; the table asks for triage only with total control.

The values move after the entry is added

Thirteen of the 37 mismatches have an SSVC timestamp later than the KEV dateAdded, which means CISA edited the decision after the entry went in. The repository's git history shows what the values were on the day. GitHub's REST API allows 60 unauthenticated requests an hour, enough for thirteen files:

def ssvc_history(cve_id):
    url = ("https://api.github.com/repos/cisagov/vulnrichment/commits"
           f"?path={record_path(cve_id)}&per_page=100")
    req = urllib.request.Request(url, headers={"User-Agent": "kev-check"})
    with urllib.request.urlopen(req, timeout=20) as r:
        commits = json.load(r)
    for c in reversed(commits):
        opts = ssvc(fetch_record(cve_id, ref=c["sha"]))
        yield c["commit"]["committer"]["date"][:16], opts["Automatable"], opts["Technical Impact"]

prev = None
for when, auto, impact in ssvc_history("CVE-2015-5477"):
    if (auto, impact) != prev:
        print(when, auto, impact)
    prev = (auto, impact)
2026-10-08T18:17 yes partial
2026-11-04T00:17 yes total

CVE-2015-5477 is an ISC BIND bug from 2015 that CISA added on 8 October with a 3-day due date and no forensic triage. On 8 October its record said automatable, partial control: row 2, 3 days, no triage. The catalog was right on the day. On 4 November the technical impact became total, and today's Table 1 answer for it would add forensic triage. The catalog published later that day still says No.

Two more mismatches clear up the same way: CVE-2015-3246 and CVE-2026-48710 were both "not automatable, partial" when added (row 4, 14 days, which is what the catalog says) and were re-rated later. Five of the thirteen histories have a change committed on 1 October, which looks like a batch re-assessment. The other ten still do not match even with the values from the day. CVE-2026-42018, for example, was automatable with partial control when added and got 14 days, where the exposed row says 3.

So, as far as public data lets me check: the catalog's dueDate usually equals Table 1 read as "publicly exposed: yes", it is fixed when the entry is added, and Vulnrichment keeps moving afterwards. For a minority of entries the date encodes something I cannot reproduce from public data. I am not calling those errors. CISA may weigh things that never reach Vulnrichment, and I compared against the values I could see.

Computing your own deadline instead

The directive binds federal civilian agencies only. For everyone else the KEV date is a signal. If you patch anything, Table 1 is still a decent triage rule because it asks about your asset, which the catalog cannot know. This is the function I would keep:

def timeline(cve_id, publicly_exposed, in_kev=True):
    opts = ssvc(fetch_record(cve_id))
    if opts is None:
        return "no CISA SSVC record yet"
    key = (publicly_exposed, in_kev, opts["Automatable"] == "yes", opts["Technical Impact"])
    return TABLE_1[key]

for exposed in (True, False):
    print("CVE-2026-34486", "exposed" if exposed else "internal", "->", timeline("CVE-2026-34486", exposed))
CVE-2026-34486 exposed -> 3 days
CVE-2026-34486 internal -> 14 days

CVE-2026-34486 is an Apache Tomcat entry added on 4 August. The catalog gives it one date, 3 days, which is the right answer for a Tomcat that faces the internet and 11 days earlier than the table asks for one that does not. In a real inventory, in_kev should come from the catalog file rather than a default, and the result should be recomputed whenever the SSVC timestamp changes.

What this does not show

  • I compared against Vulnrichment values as of 5 November, plus git history for thirteen files. I did not trace the history of the other 138.
  • The catalog's dueDate is one date per CVE. The directive's deadline depends on each asset's exposure and starts at whichever comes first, the KEV addition or the agency finding the bug on its own asset.
  • KEV inclusion means CISA has evidence of exploitation. The entries give no date or victim for it, and nothing here says which bugs are being exploited this week.
  • Table 1 came from the image on the directive page, typed in by hand. Check the dict against the image before you rely on it.

Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.