Get listed

Vulnerability management: ticket, owner, and a retest

A teal-bound playbook with one unstamped coral ticket beside it.

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-0142 is 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-team is 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.

Close is a function of retestResult. Missing owner blocks the clock, not the scanner.
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.

SignalWhat it setsWhat it does not do
KEV due dateThe clock on that CVEProve your host is hit
CVSS baseA vendor severityName an owner
EPSSA probability sketchReplace a retest
Authenticated pluginThe open evidenceClose the ticket
Changelog / VEXA not-affected noteSkip 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.

  1. Pick one open row on invoice-api-prod. Confirm it has ticketId, ownerId, and pasted plugin output.
  2. If ownerId is a group that cannot merge, rewrite it to the on-call that can.
  3. After the change, rerun the same plugin with the same scan account. Save the job id as retestId.
  4. Run can_close against the JSON. Expect a refusal until retestResult is pass or a documented not-affected.
  5. 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.