Vulnetix KEV Processor — Design Document
1. Overview
Purpose: Derive the Vulnetix Known-Exploited-Vulnerabilities list — an independent, evidence-driven KEV catalogue for CVEs that have demonstrated exploitation evidence but are not yet listed by an authority catalogue: CISA KEV or ENISA EU KEV. VulnCheck KEV is not an authority for this purpose (it is a vendor catalogue that carries all of CISA plus its own additions), so it neither excludes a CVE from this list nor withholds one from view.
Scheduled since 2026-08-10. Between 2026-04-23 and 2026-08-10 this processor had no EventBridge schedule (the
terraform/go-schedules.tfmodule was missing), so the live list was a single manual backfill of 270 rows frozen atlastEvaluatedAt = 2026-04-23. The schedule now exists and isENABLEDatcron(30 10 * * ? *); the KEV Watch trend shows daily additions from 2026-08-17 on.A second consequence outlived the outage: the 270 April rows were admitted under the old rule (268 of them on CIRCL sightings that asserted nothing) and, because the processor never deleted, stayed listed with April due dates after the 2026-08-10 rule change. See Idempotency & de-listing for the evidence-based prune that now removes them on a complete run.
Data sources (all read-only, in-DB):
CrowdSecSighting— CrowdSec honeypotsExploit— CIRCL vulnerability-lookup sightings (source='circl-sighting'), which carry MISP-taxonomy types.internal/circl.MapMISPSightingTypegives each type its own category, so polarity is readable:exploited→weaponised(Tier A),confirmed→sighting_confirmed(Tier B), and the denials —not exploited→sighting_refuted,not confirmed→sighting_unconfirmed— plus the patch-status types are never read as evidence. An unrecognised future type falls through to the inertsighting, so it cannot silently start qualifying CVEs.This mapping previously collapsed everything except
seenandexploitedinto onesightingvalue, and this processor selected exactly that value as evidence of exploitation — matching the denials while missingexploitedentirely.scripts/backfill-circl-sighting-categories.sqlrepairs rows written under the old mapping, recovering the type fromExploit.title, which embeds it verbatim.Exploitalso supplies weaponisation, keyed onsource:metasploit,nuclei,nuclei-template,nmap-nse, plusgpzfor the Project Zero 0-day feed.MsrcAdvisory— Microsoft’sisExploitedflagCVEAiInWildExploitation,CVEAiAssistedExploit— curated in-the-wild and demonstrated-exploit recordsYaraRuleCVE— CVE-tagged YARA rulesShadowserverExploitedVulnerability+ShadowserverAnomaly— Shadowserver Foundation honeypots + anomaly surgesSnortRuleCVE⋈SnortRule(enabled rules only) +CVEMetadataReferences(nucleiPath,exploitDbVerified, metasploit URLs) — weaponisationCVEMetric— CVSS scoresCVEAffected+CVEAffectedVersion— fix-availability detection (mirrorsvdb-api/internal/handler/v2_registry_fixes.gologic)CVEMetadata+CVEDescription— vendor / product / description fallbacksKev(sourceCISA / enisa) — authority KEV exclusion list.VulnCheckKEVCVEwas part of this until 2026-09-07 and is not any more
Schedule: daily at 10:30 UTC (cron_match = "30 10 * * *" in task-manager.toml,
terraform/go-schedules.tf module.vulnetix_kev_processor).
Timeout: 45 minutes (expected_duration_minutes = 45; a measured production run spent 883 of
888 seconds in the candidate query, so 30 left no margin on a busy read replica)
Resources: 512 CPU units, 1024 MB memory (reads a handful of mid-size tables + one large one via indexed predicates)
Source of truth: this page. scripts/task-manager.toml points design_doc here. The
// Design: scripts/go-processors/vulnetix-kev-processor.design.md comment at the top of main.go
names a file that does not exist, as do the analytics/notebooks/11_exploits_sightings.ipynb and
analytics/sql/24_vulnetix_kev_candidates.sql references that previously appeared in this document
— there is no analytics/ directory in the repository. main.go is the only executable
specification of the qualifying logic.
What it writes:
Kevrows withsource = 'vulnetix'— the official KEV row shape so consumers (API, CLI, website) hit one table.VulnetixKevrows — analysis metadata (qualifyingreasons[]enum +lastEvaluatedAt). FK’d toKev(cveID, source='vulnetix')withON DELETE CASCADE.BulkDataDumpTrackerrow forsource='vulnetix_kev'withfrequency=86400(24h).latestSha256is repurposed as a resume checkpoint (last-processed CVE ID).SummaryLogrow per run —label='vulnetix_kev_run',value=qualified,metadataas JSONB. Upserts on(label, timestamp)so same-timestamp re-runs overwrite.
Startup guard: zombieguard.CheckFromEnv(…, 60*time.Minute, …) runs before anything else, so a
duplicate task that outlives its window is terminated rather than competing for the write pool.
Environment variables:
DATABASE_URL— required (write pool)DATABASE_URL_READ— optional (read replica for candidate scan)VULNETIX_KEV_MODE—incremental(default) /resume/backfillVULNETIX_KEV_FORCE—1to bypass the freshness trackerVULNETIX_KEV_DRY_RUN—1to evaluate and report without writingVULNETIX_KEV_PRUNE_OVERRIDE—1to lift the prune size guard for one deliberate backfill after a rule change (ignored in every other mode; never lifts the completeness checks). See Idempotency & de-listing.EXPECTED_DURATION_MINUTES— optional (task definition sets 45; soft deadline fires atvalue − 5, default 25 minutes when unset in a scheduled mode)SNS_TOPIC_ARN— forwarded bynotify.New; errors and overtime flush to Slack via SNS
No API keys.
Run modes
| Mode | Purpose | Freshness skip | Candidate anchor | Soft-deadline |
|---|---|---|---|---|
incremental | EventBridge daily run | yes — tracker < 23h short-circuits | all qualifying CVEs | 25 min |
resume | Manual catch-up after outage | no | CVEs lexicographically greater than BulkDataDumpTracker.latestSha256 | 25 min |
backfill | Fresh-DB seed / forced re-eval | no | all qualifying CVEs | none — runs to completion |
Backfill and resume modes are manual-only — they are never scheduled to EventBridge. Invoke via the just go-vulnetix-kev-{resume,backfill} recipes (each also takes TARGET=prod|local).
On EventBridge, mode=incremental is the only value used; the environment-variable default is incremental even if the task definition omits it.
2. Business Logic
The qualifying rule
A path must assert exploitation, not exploitability. Capability to exploit is not evidence that anyone has: a Metasploit module means an attacker could, not that one did. So a weaponisation artefact never puts a CVE on this list by itself.
A CVE enters Vulnetix KEV iff it is not already in CISA KEV or ENISA EU KEV and:
qualifies = tierA
OR (tierB_gated AND tierC)
OR (distinct tierB sensor networks >= 2)
| Tier | Meaning | Qualifies alone? |
|---|---|---|
| A — Direct assertion | A named party states the CVE is exploited in the wild | Yes |
| B — Sensor observation | Honeypot/telemetry saw attacks, subject to a quality gate | Only with a second signal |
| C — Weaponisation artefact | A working exploit or detection signature exists | Never alone |
| D — Predictive | A model estimates likelihood | Never — context only |
Tier A — direct assertion
| Signal | Detection | Reason |
|---|---|---|
| Microsoft “Exploitation Detected” | MsrcAdvisory.isExploited = TRUE | vendor_exploited |
| Google Project Zero 0-day ITW | Exploit source='gpz' AND category='0day-itw' | zero_day_itw |
| Curated in-the-wild observation | CVEAiInWildExploitation | reported_itw |
| Demonstrated working exploit | CVEAiAssistedExploit.success = TRUE | demonstrated_exploit |
| Named ransomware campaign | Shadowserver knownRansomwareCampaignUse, affirmative only | known_ransomware |
| Mass exploitation | count30dAvg >= 50 | shadowserver_sighting |
Mass exploitation sits in Tier A despite being a sensor reading: at that scale the breadth of the observation is itself the assertion.
Tier B — sensor observation (gated)
| Signal | Gate | Reason |
|---|---|---|
| CrowdSec | ≥ 3 distinct IPs, backgroundNoiseScore <= 5, no FP reports, lastSeen within 90 days | crowdsec_sighting |
| Shadowserver sustained | count30dAvg >= 5 | shadowserver_sighting |
| Shadowserver surge | percentChange >= 100 and nChange >= 10 and count1d >= 10 | shadowserver_surge |
CIRCL/MISP type confirmed | Exploit.category = 'sighting_confirmed' | misp_sighting |
CIRCL/MISP type exploited | Exploit.category = 'weaponised' | misp_sighting |
Sustained and surge are the same sensor network. They count as one vote, or a single fleet would
corroborate itself. The two CIRCL polarities are likewise one vote. multi_source_sighting is emitted
only when ≥ 2 distinct networks agree.
CIRCL
exploitedmoved from Tier A to Tier B on 2026-09-07. The sightings are community submissions to vulnerability-lookup; the submitter is parsed but not stored, and the source URL (Exploit.originalUrl) is the only provenance kept. On 2026-09-06, 1,795 of the 2,484 live entries rested on one of these sightings and nothing else. Reading it as an observation to corroborate rather than an assertion to trust is the conservative call; thecircl_exploited_source_hostssection ofscripts/vulnetix-kev-calibration.sqlis the input for any future decision to let a trusted-source subset qualify alone again.
Tier C — weaponisation artefact (never alone)
Keyed on Exploit.source, not Exploit.category: NULL is that column’s largest bucket, and
nuclei, metasploit, github-poc and packetstorm all write no category at all.
| Artefact | Detection | Reason |
|---|---|---|
| Metasploit module | Exploit.source='metasploit' or a metasploit reference URL | metasploit_module |
| Nuclei template | Exploit.source IN ('nuclei','nuclei-template') or CVEMetadataReferences.nucleiPath | nuclei_template |
| Snort/Suricata rule | SnortRuleCVE ⋈ SnortRule where disabled = FALSE | snort_rule |
| Nmap NSE script | Exploit.source='nmap-nse' AND category='exploit' | nse_script |
| YARA rule | YaraRuleCVE | yara_rule |
| Verified ExploitDB entry | CVEMetadataReferences.exploitDbVerified <> 0 | exploitdb_verified (its own label since 2026-09-07; it used to ride under metasploit_module) |
Tier D — decorative
critical_cvss (MAX(CVEMetric.baseScore, Shadowserver.vulnerabilityScore) >= 9.0) is emitted by
reasons() and appears in no qualifying path. It exists so the GUI can show severity beside the
evidence. Any consumer presenting reasons[] as “why this CVE is listed” overstates it — the
array labels the evidence, it is not a record of which rule admitted the entry.
Thresholds
Every value is a methodology decision. scripts/vulnetix-kev-calibration.sql reports each one
against the live distribution; run it before changing any of them.
| Constant | Value | Rationale |
|---|---|---|
ssMassExploitation | 50 | Breadth sufficient to be self-evidencing |
ssSustained | 5 | A repeated observation, not a stray probe |
surgeMinPercent | 100 | A doubling |
surgeMinAbsolute | 10 | New hosts — a percentage alone cannot tell 1→2 from 5000→10000 |
surgeMinCurrent | 10 | Current hosts, same reason |
crowdsecMinDistinctIPs | 3 | One IP is a scanner; several origins is a pattern |
crowdsecMaxNoise | 5 | CrowdSec’s own mass-scanner discount |
crowdsecRecencyDays | 90 | KEV is about current exploitation |
criticalCVSSThreshold | 9.0 | Decorative only |
The ransomware test is affirmative, not merely non-empty:
LOWER(TRIM(x)) NOT IN ('no','none','unknown','no known','not known','n/a','false','0'). A substring
test cannot do this — “Known” and “Unknown” differ by a prefix and both contain “no”.
Rehearsing a rule change
Set VULNETIX_KEV_DRY_RUN=1 (or pass -dry-run) to evaluate the whole candidate set and report the
qualifying count and reason distribution without writing. The membership of this list is a
published claim; a rule change can move it by hundreds of entries, so rehearse first and compare.
critical_cvss appears in reasons() but in no qualifying path — it never puts a CVE on the
list, it only annotates one that qualified some other way (about 60% of live rows carry it). Any
UI that presents reasons[] as “why this CVE is listed” overstates it; the website’s charts and
chips leave it out of “evidence” counts for that reason.
What the live list rests on. On 2026-09-06, of 2,484 rows, 2,306 carried
misp_sightingand 1,795 carried no other evidence signal: three quarters of the list stood on a single CIRCL vulnerability-lookup sighting of typeexploited, which Tier A then admitted alone. On 2026-09-07 that sighting moved to Tier B and a backfill re-evaluated the whole candidate set: 4,718 candidates, 442 qualified, 2,037 rows pruned (the CIRCL-only entries plus the ~250 April rows the old rule had admitted), 478 seconds. Reason distribution after the change:misp_sighting351,critical_cvss302 (context),snort_rule221,nuclei_template151,nse_script61,crowdsec_sighting60,metasploit_module32,demonstrated_exploit20,shadowserver_sighting14,yara_rule13,shadowserver_surge5,multi_source_sighting3.demonstrated_exploit(CVEAiAssistedExploit.success, a frozen 26-row academic seed) stays Tier A by decision; it is a capability result and the next review should weigh that.Later on 2026-09-07 VulnCheck KEV left the authority set (it carries all of CISA plus its own additions, so it had been disqualifying every candidate it listed). The backfill after that change re-evaluated 4,744 candidates: 1,506 qualified, 0 pruned, 376 seconds. Listing dates span 2010-04-09 to 2026-09-01. Reason distribution after:
nuclei_template1,072,critical_cvss1,048 (context),misp_sighting1,004,snort_rule767,shadowserver_sighting730,multi_source_sighting625,shadowserver_surge540,crowdsec_sighting479,metasploit_module71,nse_script69,demonstrated_exploit23,yara_rule22,zero_day_itw4,vendor_exploited3,reported_itw1.
Kev row derivation (source=‘vulnetix’)
| Column | Derivation |
|---|---|
cveID | candidate CVE |
source | 'vulnetix' (constant) |
vendorProject | Shadowserver vendor → else CVEMetadata.affectedVendor → else 'unknown' |
product | Shadowserver product → else CVEMetadata.affectedProduct → else 'unknown' |
vulnerabilityName | chooseTitle() — a 13-tier ranked fallback, see below |
shortDescription | Longest English CVEDescription.value → else empty |
requiredAction | Three branches (see below) |
dueDate | dateAdded + 48h (2 calendar days) if snort_rule ∈ reasons, else dateAdded + 5 business days (Mon–Fri, skipping Sat/Sun in UTC). On re-evaluation the offset is re-anchored onto the stored dateAdded ("Kev"."dateAdded" + (EXCLUDED."dueDate" - EXCLUDED."fetchedAt")); it used to be recomputed from each run’s clock, so no entry still being evaluated could ever be overdue |
knownRansomwareCampaignUse | Shadowserver knownRansomwareCampaignUse (nullable) |
fetchedAt | Run start timestamp (ms) |
dateAdded | Earliest exploitation evidence (ms): the smallest date across the Tier A / B signals present — MsrcAdvisory.initialReleaseDate, GPZ / CIRCL Exploit.datePublished, CVEAiInWildExploitation.advisoryDate, CVEAiAssistedExploit.exploitDate, CrowdSecSighting.firstSeen, ShadowserverExploitedVulnerability.observationDate, ShadowserverAnomaly.anomalyDate — clamped to the run clock and floored at 1999-01-01; the run clock only when no signal carries a date. On re-evaluation LEAST(stored, new): older evidence backdates, nothing postdates, so every run agrees. Tier C dates never count: a weapon’s publication is not the date anyone was attacked. Until 2026-09-07 this was the run clock, which stamped the whole catalogue with its backfill dates |
createdAt / updatedAt | Run start timestamp (ms); createdAt not updated on conflict |
vulnerabilityName — title selection
chooseTitle() (main.go) walks a ranked list and takes the first candidate that survives
cleaning, because most sources hand back a bare advisory identifier rather than prose:
CVEMetadata.titlefromcve.org/mitre(CNA-assigned)CVEMetadata.titlefromcisa_adp(CISA Vulnrichment)CVEMetadata.titlefromnist-nvdBugBountySubmission.title— longest title across reports naming the CVECVEMetadataReferences.vlTitle(VulnerabilityLab)CVEMetadataReferences.titleonvendor-advisory/patch/mitigationCVEMetadataReferences.titleonthird-party-advisoryCVEMetadata.titlefrom any other source (excludinggithub/ghsa)SnortRule.msg— longest rule message- First sentence of the longest English
CVEDescription CVEMetadata.titlefromgithub/ghsa(last resort — usually justGHSA-…)- Synthesised
"<vendor> <product> vulnerability" - The bare CVE ID
A candidate is rejected when it is empty or matches identifierLikeRE — a bare CVE-…, GHSA-…,
ZDI-…, RHSA-…, USN-…, DSA-…, openSUSE-SU-…, BDU:…, KB… and ~20 other advisory-code
formats. Accepted candidates pass through cleanTitle(), which strips a leading identifier prefix
([GHSA-xxxx-xxxx-xxxx] Real title → Real title), collapses internal whitespace, trims trailing
punctuation, and caps the result at 220 characters on a word boundary with an ellipsis.
Steps 1–9 are ordered by trustworthiness of the author, not by availability. Step 11 sits below the description lede deliberately: a GHSA title is more often an identifier than prose.
requiredAction branches
Fix-availability is determined from the CVEAffected / CVEAffectedVersion join used by the vdb-api registry-fixes handler: status='unaffected' OR lessThan IS NOT NULL OR lessThanOrEqual IS NOT NULL.
| Condition | requiredAction |
|---|---|
| Fix available (any affected row has a fixed-version marker) | “Apply the vendor-provided patch for all affected versions.” |
| No fix + Snort rule exists | “Deploy the available Snort/Suricata IDS rule and isolate exposed instances until a vendor patch is released.” |
| No fix + no rule | “Isolate exposed instances and restrict network access until a vendor patch is released.” |
Idempotency & de-listing
Kevupsert usesON CONFLICT ("cveID", source) DO UPDATEon every field exceptdateAdded/createdAt;dueDateis re-anchored onto the storeddateAdded(see the derivation table).VulnetixKevupsert usesON CONFLICT ("cveID") DO UPDATEonreasons,lastEvaluatedAt,updatedAt.Writes are batched: qualifying candidates accumulate and flush every 500 rows through one
pgx.Batch, with both statements for a CVE in the same batch so theVulnetixKev → Kevforeign key is satisfied by ordering within it.Evidence-based prune, complete runs only. After a run that evaluated the whole candidate set,
pruneStaledeletes everysource='vulnetix'row (and itsVulnetixKevmetadata) whose CVE no longer qualifies on evidence — including CVEs that dropped out of the candidate set. The run stats andSummaryLogcarrykeptandpruned.pruneAllowedrefuses when any of these hold: mode isresume, dry run, soft deadline hit, context cancelled, any batch flush failed, nothing kept, or the run would keep less than half of the stored rows. The last one is the outage guard: a candidate query that silently lost an upstream table looks exactly like mass de-listing, so it raises aRecordError(“prune refused”) and leaves the rows alone. A deliberate rule change that de-lists more than half is rolled out withVULNETIX_KEV_PRUNE_OVERRIDE=1on a manualbackfillrun (just go-vulnetix-kev-backfill TARGET=prod), after a dry run has shown the expectedqualifiedcount; the override is ignored inincrementalandresumeso a scheduled run can never inherit it.
Authority hand-off is a soft delete at read time, not a prune. An entry that CISA or ENISA has since ratified still qualifies on evidence, so the prune keeps the row: it is the record that Vulnetix listed the CVE first, and the KEV Watch statistics, trend and source comparison keep counting it. What changes is what a customer is served: every catalogue surface withholds the Vulnetix record once an authority lists the CVE.
vdb-api/v2/vulnetix-kevand/v2/kevexclude authority-listed CVEs in their own queries.vdb-siteKEV Watch withholds the Vulnetix record from the catalogue rows, the detail page, the recent feed and the org-overlap rows (kev_served), while/kev/stats,/kev/trendand/kev/source-comparisonkeep the row for analysis.The unconditional end-of-run
DELETEthat preceded all of this removed everysource='vulnetix'row not re-evaluated during that run, so a soft-deadline truncation at 10% left a list 90% shorter, andresumemode deleted exactly what the previous run had just written. Its removal then produced the opposite failure — 270 rows admitted under the old rule stayed listed for a month after the rule changed — which is what the guarded prune above resolves.Consequence: any new consumer of
Kev(source='vulnetix')must apply the authority exclusion itself, or it will serve entries that have already graduated.
Soft-deadline handling
Two independent mechanisms stop a long run, and the loop condition checks both:
notify.Notifier.SetOvertimeCancel(cancel)cancels the context on overtime — observed asctx.Err() != nil.- An in-process
softDeadlinecomputed at startup:EXPECTED_DURATION_MINUTES − 5, defaulting to 25 minutes when the variable is unset. Not applied inbackfillmode, which runs to completion.
On hit, the loop breaks and the run:
- Records
RecordError("soft deadline reached after N of M CVEs …")so the completion event carries a partial-run warning through to Slack. - Persists the last processed CVE ID to
BulkDataDumpTracker.latestSha256viadb.UpsertTracker, so a subsequentresumerun can pick up from there. - Flushes any pending upsert batch, so work already done is not lost.
- Writes the
SummaryLogrow withsoftDeadlineHit: true. - Notifies completion (not error) with the truncated count — the soft-deadline warning shows up as amber in Slack, not red.
A truncated run is now merely incomplete rather than destructive: with pruning gone, the entries it did not reach are simply left as they were.
Candidate ordering
Ordering comes from ORDER BY c.cve in candidateSQL, which keeps the resume checkpoint
deterministic in one Postgres sort pass.
This replaced a hand-written insertion sort in Go, justified by a comment claiming the data was
“already near-sorted from the query” — but the query had no ORDER BY and ended in a multi-way
UNION, so row order was whatever the planner emitted. That made it O(n²) over a ~30-field struct
on adversarial ordering.
Error handling
- Per-CVE errors (
requiredActioncomputation, upsert failure) are collected vianotify.RecordErrorand logged atWarn; the run continues. - Database-level errors (connection loss, fatal constraint violation on the tracker) call
notify.Erroredand exit non-zero so EventBridge retries. - All messages flow to Slack via
SNS_TOPIC_ARNwhen set (EventBridge/ECS runtime); no-op locally.
3. Operations
Local invocation
just go-vulnetix-kev # incremental, against local DB via ../saas/.env
just go-vulnetix-kev-resume # resume from last checkpoint
just go-vulnetix-kev-backfill # full re-evaluation, extended deadline
just go-vulnetix-kev TARGET=prod # incremental against production (use sparingly)
just go-vulnetix-kev-backfill TARGET=prod
EventBridge
Schedule name: go-vulnetix-kev-processor. Cron: cron(30 10 * * ? *). No args.
Observability
CloudWatchlog group/ecs/vdb-scheduler/vulnetix-kev-processor— structured JSON.SummaryLogrow appended each run withlabel='vulnetix_kev_run',value=qualified(not processed), andmetadatacontaining{mode, processed, qualified, skipped, reasonsDistribution, softDeadlineHit, elapsedSeconds}.- Slack:
task.started/task.completed/task.errored/task.overtimevia SNS Slack notifier. A completion withRecordError’d messages surfaces amber; a hard exit surfaces red.
⚠ Staleness is invisible to the health check.
scripts/processor-health-check.sqlmapsvulnetix-kev-processorto source'vulnetix'— a string it shares withcrowdsec-json-processor,scanner-processor,scan-watchdogandsummary-processor. The check reports the latest timestamp across every table a source writes, so those actively-running processors keep'vulnetix'looking fresh no matter how long this one has been dead. That is why a 105-day outage went unreported.The processor’s own tracker uses
source='vulnetix_kev', which the health check does not map at all. Keying the check onBulkDataDumpTracker('vulnetix_kev'), or onMAX("updatedAt") FROM "Kev" WHERE source='vulnetix', would surface it.
S3 Persistence
- Archive: ⚠ Not yet implemented — requires record reconstruction (DB row → canonical JSON).
- Quarantine: ⚠ Not yet implemented — same reason.
- Likely reasons when implemented:
enrich-error
This is an enrichment processor; it reads from CVEMetadata rather than ingesting raw feeds, so there is no original payload to archive verbatim. See S3 Persistence Contract § Processors whose unit-of-work is not a file.