
A vulnerability playbook is the next action after a scanner writes a row, not a PDF of severity colors.
CISA’s Known Exploited Vulnerabilities list is useful because it names what is already being used. Your playbook has to say who patches, how fast, and what happens when the box cannot move this week.
The usual mistake is a ticket that says ‘fix criticals’ with no owner and no exception path. The catalog grows. The queue does not.
This page is how to turn a KEV row into a job, and the checks that tell you the playbook is actually running.
CISA’s Known Exploited Vulnerabilities catalog showed 1,674 rows when it on 22 August 2026. CVE-2026-73570 in Zimbra was added on 21 August with a due date of 24 August. A weekly scan CSV that has no owner and no retest id is a file.
Pair this loop with the lockfile vs install-time malware split for a package row, the Ubuntu hardening page for sshd or apt, and the secure coding checklist for a handler bug.
Three fields or it is not a playbook
A playbook here is a close loop for one finding. It is not an incident novel and not a product matrix. Three fields must exist before anyone talks about maturity:
- ticketId. One row per asset plus check.
VM-2026-0142is a ticket. A 400-row CSV is not. - ownerId. A person or a pager rotation that can change the package, the image, or the handler.
security-teamis not an owner. - retestId. The same plugin, the same credentials, the same hostname, run after the change. A Slack thumbs-up is not a retest.
NIST SP 800-40 Rev. 4, final on 6 April 2022, still names the last step as verifying the installation. The verify step is the retest. Everything before it is a queue.
FINDING plugin + asset + evidence | v TICKET VM-2026-0142 invoice-api-prod CVE or check id | v OWNER invoice-oncall can ship the image | v FIX patch | config | accept-with-expiry | v RETEST same plugin, same host, same creds pass -> close fail -> reopen, do not edit the first evidence
Open the ticket with evidence
Copy the scanner’s proof into the ticket on the day you accept the finding. Plugin id, output snippet, first-seen, last-seen, authenticated or not. If you cannot paste the proof, you do not have a finding yet. You have a banner.
One ticket per asset plus check. Ten hosts with the same CVE are ten rows, or one row with a host list and a close rule that names every host. A single epic titled “Q3 criticals” will not tell you which box still fails.
#.github/ISSUE_TEMPLATE/vm-finding.yml
name: VM finding
title: "[VM] {{package}} on {{asset}}"
labels: ["vm", "needs-owner"]
body:
- type: input
id: ticket_id
attributes:
label: ticketId
placeholder: VM-2026-0142
- type: input
id: asset
attributes:
label: asset
placeholder: invoice-api-prod
- type: input
id: check_id
attributes:
label: plugin or CVE
placeholder: ubuntu_linux_openssl or CVE-2023-0286
- type: textarea
id: evidence
attributes:
label: scanner evidence
description: Paste the authenticated plugin output. Not a screenshot of a dashboard tile.
- type: input
id: owner_id
attributes:
label: ownerId
placeholder: invoice-oncall
- type: input
id: due
attributes:
label: due
description: KEV due date if listed, else your SLA.
False positives stay on the ticket. Write the package string you actually run, the changelog line that names the CVE, and who signed that read. Deleting the scanner row is how the next weekly job reopens a ghost.
Name the person who can patch
The owner is the rotation that can merge the bump, rebuild the image, or change the config. Map invoice-api-prod to invoice-oncall in the CMDB before the scan lands. If that map is empty, the finding sits on the scanner account and ages.
Do not assign appsec unless appsec ships the change. Appsec can triage, set the due date, and refuse a close that has no retestId. The patch is still a product change.
# owners.yaml keep next to the inventory
assets:
invoice-api-prod:
ownerId: invoice-oncall
repo: org/invoice-api
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/invoice-api
bastion-prod:
ownerId: platform-oncall
repo: org/ubuntu-bastion
A finding with no ownerId after one business day is an inventory bug, not a scanner bug. Fix the map. Then the next CSV can page the right person.
Retest with the same plugin
The close rule is: run the check that opened the ticket, against the host that was named, with the same authentication the first run used. Unauthenticated after an authenticated open is a visibility drop, not a fix.
For a package row, rerun the plugin and print dpkg -s or rpm -q on that host. For a config row, rerun the plugin and show the file you changed. For a handler row, replay the request the report pasted, from a fixture you own. You are not walking a stranger’s site.
# close_gate.py refuse a close that cannot name the retest
REQUIRED = ("ticketId", "ownerId", "retestId", "retestResult")
def can_close(ticket):
for key in REQUIRED:
if not ticket.get(key):
return False, f"missing {key}"
if ticket["retestResult"] not in ("pass", "not-affected"):
return False, "retestResult must be pass or not-affected"
if ticket["retestResult"] == "not-affected" and not ticket.get("evidenceNote"):
return False, "not-affected needs the package or config proof"
return True, "ok"
When the installed package is a backport the plugin still flags, do not wait for the vendor map. Attach the changelog line and the not-affected note. That is the NovemberWhiskey case. The ticket closes on your evidence. The scanner can catch up later.
KEV is the clock, not CVSS
CISA issued Binding Operational Directive 26-04 on 10 June 2026. The KEV page now points at that directive. Federal civilian agencies must remediate catalog rows on the listed due dates. Everyone else can still use the catalog as the short list.
A KEV row on invoice-api-prod sets due from the catalog, not from a CVSS 9.8 that has no exploit in the wild. A critical that is not in the catalog still gets an owner and a retest. It does not jump the KEV queue unless you have a written reason.
| Signal | What it sets | What it does not do |
|---|---|---|
| KEV due date | The clock on that CVE | Prove your host is hit |
| CVSS base | A vendor severity | Name an owner |
| EPSS | A probability sketch | Replace a retest |
| Authenticated plugin | The open evidence | Close the ticket |
| Changelog / VEX | A not-affected note | Skip the owner field |
SP 800-40 Rev. 4 frames patching as preventive maintenance. The playbook is how that maintenance becomes a row that can fail. If you accept residual risk, write an expiry. An exception with no date is a silent close.
A SOAR catalog is not the loop
Vendors will sell you a playbook library with fifty YAML files named after MITRE techniques. That library does not assign invoice-oncall and it does not rerun plugin 12345.
Automation is fine after the three fields exist. A webhook that opens VM-2026-0142 from the scanner, a page to invoice-oncall, a job that reruns the plugin and comments retestId, a gate that blocks close: that is useful glue. A dashboard of unused response recipes is not.
If you already bought the glue, keep one recipe: open, assign, retest, close. Delete the rest until those four steps leave a log on every finding. Then add a KEV watcher that raises due. Then add a backport note template. Stop there until the queue is shorter than last month.
A ticket you can actually close
Here is VM-2026-0142 after a real week. Identifiers stay the same from template to gate.
ticket = {
"ticketId": "VM-2026-0142",
"asset": "invoice-api-prod",
"checkId": "ubuntu_linux_openssl",
"cve": "CVE-2023-0286",
"ownerId": "invoice-oncall",
"openedAt": "2026-08-10T09:12:00Z",
"due": "2026-08-24",
"evidence": "authenticated plugin, OpenSSL 3.0.2-0ubuntu1.6",
"fix": "apt upgrade openssl to 3.0.2-0ubuntu1.7, rebuild image sha256:b3f1",
"retestId": "nessus-scan-88421",
"retestResult": "not-affected",
"evidenceNote": "dpkg -s openssl Version: 3.0.2-0ubuntu1.7, USN names the CVE",
}
ok, reason = can_close(ticket)
# ok True
The fallback when the plugin still fires after 1.7 is already in can_close: not-affected plus evidenceNote. Do not invent a second closer that keys on dashboard color. The named fallback is that function.
def fallback_close(ticket):
if ticket.get("retestResult") == "pass":
return can_close(ticket)
if ticket.get("retestResult") == "not-affected" and ticket.get("evidenceNote"):
return can_close(ticket)
if ticket.get("retestResult") == "inconclusive":
return False, "keep due, fix reachability or auth"
return False, "reopen"
A dependency CVE in the lockfile is the other common row. That is homework on the lockfile page, not a SOAR story. Open VM-2026-0143, assign the repo owner, bump, run npm ci in CI, rerun that advisory id.
Prove your own loop
You are proving your tracker, on findings you already own.
- Pick one open row on
invoice-api-prod. Confirm it hasticketId,ownerId, and pasted plugin output. - If
ownerIdis a group that cannot merge, rewrite it to the on-call that can. - After the change, rerun the same plugin with the same scan account. Save the job id as
retestId. - Run
can_closeagainst the JSON. Expect a refusal untilretestResultispassor a documentednot-affected. - Grep the tracker for tickets closed last month with an empty retest field. Those are the ones to reopen.
python3 - <<'PY'
from close_gate import can_close
import json, pathlib
for path in pathlib.Path("tickets").glob("VM-*.json"):
ticket = json.loads(path.read_text())
ok, reason = can_close(ticket)
print(ticket["ticketId"], ok, reason)
PY
# Expect: VM-2026-0142 True ok
# Expect: any closed row without retestId -> False missing retestId
Reserved concurrency and WAF rules are not this page. This page is the row that can fail in public, on a date CISA already printed for the KEV subset.
Questions we keep getting
Can one ticket cover a whole CVE across the fleet?
Only if the close rule lists every host and every host has a retest. A fleet epic that closes when 80 percent of images moved will hide the 20 percent that did not. Prefer one row per asset, or a checklist the gate can count.
Do we need SOAR to run this?
No. A GitHub issue, a YAML owner map, and a script that refuses close are enough. Glue is optional after those three exist. A fifty-playbook catalog without ownerId is the thing this page is not.
What if the plugin cannot see the backport?
Close as not-affected with the package changelog. Leave the scanner finding suppressed with that note, not deleted. When the vendor map catches up, the suppression expires cleanly.



