Design: Exploit CVE Relink Processor
Overview
An Exploit row with no ExploitCVE junction is unreachable. No Finding shows
it, GET /v2/vuln/{id}/exploits never returns it, and no defence derived from
it can be found by advisory. 45% of the corpus (31,542 of 69,977) was in that
state.
ECS task name: go-exploit-cve-relink-processor
Schedule: cron(45 3 * * ? *) — daily, trailing the ingest cluster
Backfill: just go-exploit-cve-relink-processor prod
Why the links were lost
ExploitCVE carries a composite FK (cveId, source) onto CVEMetadata, so a
junction row cannot exist unless the advisory does. Ingest attempts that write
exactly once. When the advisory is not yet present the resolver returns no
sources, the insert loop body never runs, and nothing logs it. The PoC is
orphaned permanently, because the ingest path also marks the source file
processed and will not revisit it.
PoCs routinely appear before their advisory is published, so this accumulates.
Three passes, in order of confidence
1. Re-link
The row already names an advisory in cveIds. Resolution is retried through
db.ResolveExploitCVESourcesOrQuery — the same helper every ingest path uses,
so the sweep can never create a link ingest would not have created. It is a
retry of a dropped write, not a new linking policy.
Ids are shape-checked with cveid.Plausible first, so a malformed identifier
never reaches the database.
A reserved identifier is still an identifier
An id that resolves to no advisory is not automatically a dead end. MITRE
allocates a CVE id to a CNA at reservation time and the record is published
later — sometimes much later, sometimes never. CVE-2026-54519 answers 200 on
MITRE’s /api/cve-id/ with state: RESERVED while being absent from cvelistV5
and NVD, both of which carry published records only. The researcher who holds
the reservation routinely publishes the PoC first, which is exactly the shape
this sweep exists to repair.
So when db.MintableReservation accepts the id, the sweep writes a minimal
CVEMetadata record with state = 'RESERVED' and links against it rather than
reporting it absent. The record is written under source cve.org — the same key
mitre-cve-json-processor upserts — so when the CNA publishes, the state flips
to PUBLISHED and the title and dates fill in on that row. The junction
written here keeps pointing at the right advisory and no second repair is needed.
Only CVE ids qualify. GitHub publishes no reservation step, so a GHSA that
resolves to nothing is withdrawn or invented and minting one would assert an
advisory that never existed; every other scheme is excluded for the same reason.
cveid.Plausible still gates the shape, so the invented ids it was added for
(CVE-9999-99999, impossible years, truncated sequences) are never minted.
db.PreviewExploitLinks applies the identical rule, so a dry run and the run it
previews cannot disagree about which ids become edges.
Known limitation: selection is all-or-nothing
db.LoadUnlinkedExploits selects on NOT EXISTS (any ExploitCVE for this exploit). An exploit naming three ids and linked to one therefore counts as
linked and is never looked at again, so the other two stay lost in exactly the
way this sweep exists to repair.
The ingest path no longer produces that state — resolveCVESources mints and
links every named id at store time — so this is a shrinking legacy, not an
ongoing leak. The 335 exploits already in it (1,105 plausible ids with no
junction) were repaired once by .repo/exploit_named_id_relink.sql. Widening
the selection predicate means parsing cveIds SQL-side, which the loader
deliberately avoids: the column is TEXT and one legacy non-JSON row would abort
a whole page rather than skip that row.
2. Recover from cross-references
The row names nothing, but the body may cite another database.
internal/exploitxref reads the body from S3 and looks for:
| Reference | Resolved? | Why |
|---|---|---|
Exploit-DB entry (exploit-db.com/exploits/N, EDB-ID: N) | Yes | Transitive through our own exploit-db rows, which are 0% unlinked. If we hold the entry we hold its advisories. |
Microsoft bulletin (MS06-049) | No — evidence only | See below. |
A recovered id is written back to cveIds so a later sweep does not repeat the
S3 fetch and the regex work.
Why Microsoft bulletins are not resolved. The obvious mapping — find the
CVEs whose reference URLs cite the bulletin — was implemented and run against
production. It produced garbage: MS08-067, the 2008 Windows Server Service
RCE, resolved to CVE-2021-44228 (Log4Shell) and a run of 2022 Zimbra CVEs.
Those are not near-misses. The rows hold the genuine learn.microsoft.com
bulletin URL attached to advisories it has nothing to do with, so
CVEMetadataReferences cannot be trusted for reverse resolution. Bulletins
appear in ~0.7% of archive bodies — far too little value to justify inventing
links at that error rate. The reference is surfaced to the reviewer instead.
3. Queue for review
Whatever is still unlinked goes to AdminReview under recordType
exploit-link. A reviewer either attaches the correct advisory or accepts that
the vulnerability was never assigned an identifier and mints a GCVE under
Vulnetix’s GNA (110).
A row naming a plausible CVE id no longer reaches this queue — pass 1 mints the
reservation and links it. What arrives here names nothing, names a GHSA or other
scheme that resolves to nothing, or names an id whose shape cveid.Plausible
rejects.
That last case is the common one and is not a failure. Measured across 300 randomly sampled archive bodies: zero contained a CVE and 274 (91%) contained no identifier of any kind. The archive sources publish unassigned bugs by design — shellcode, CMS plugin bugs, DoS proofs — and the vulnerability is described only by product, version and class.
recordHash binds the review to the content reviewed, so an exploit whose body
or identifiers later change returns to the queue rather than staying silently
accepted. An unchanged record never resets a reviewer’s decision.
An answered review leaves the queue
The reverse also holds. When a later pass links an exploit an earlier pass had
queued, the review is answered — a reviewer opening it would find the junction
already written and close it — so the sweep retires it itself via
db.ResolveAdminReviewQA (status: reviewed, reviewMethod: qa).
Without this the console keeps showing work that is done, and an operator cannot
tell a stale entry from a real backlog: when the reserved-identifier fix landed,
923 pending items named an exploit that by then carried an ExploitCVE row.
Only rows still in pending are touched, so a human decision
(human_approved, human_edited) is never overwritten and a re-run is a no-op.
What the reviewer is given
qaChecks carries the sweep’s findings so the reviewer does not repeat them:
| Field | Meaning |
|---|---|
namedAdvisories | Identifiers the row names that no advisory in the corpus matches |
hasBody | Whether a proof-of-concept body is stored and readable in the console |
crossRefsResolved | How many cross-references became links on this pass |
unresolvedCrossRefs | kind:value pairs found in the body that resolved to nothing — an ms-bulletin, or an Exploit-DB entry we do not hold. The strongest lead a human gets, and the reason extraction is kept even where resolution is not |
The queue lives at Resolve → Unlinked Exploits (admin only). Resolving an item either writes the junction rows for an advisory the reviewer names — every id checked against the corpus first, and refused rather than created if absent — or issues a Vulnetix identifier.
Minting an identifier
Only GCVE-110- is fixed. The reviewer assigns the rest, because the corpus
already carries BRLY-, OSM-, GENTOO-, CERTCC- and NPM- families under
this GNA, and a label the server picked would be wrong more often than right.
The body must read LABEL-YEAR-SEQUENCE. That is not decoration:
GcveIssuance.year and .sequenceNumber are NOT NULL integers, so a
free-form body would leave two structural columns to be invented. Parsing them
out of the id the reviewer assigns is what keeps the row honest.
| Input | Result |
|---|---|
XPL-2026-1 | GCVE-110-XPL-2026-1, year 2026, sequence 1 |
GCVE-110-XPL-2026-1 | same — a pasted whole identifier is stripped |
xpl-2026-1 | same — normalised to upper case |
XPL-1 | refused; no year to record |
XPL-26-1 | refused; the year must be four digits |
The identifier is taken as given rather than allocated from a running maximum,
and a collision is reported as one. Issuing a different id than the reviewer
asked for would be worse than refusing: they are recording a decision, not
requesting a number. The queue offers the next free XPL-<year>-<n> as a
starting value, which is a default in the box and nothing more.
Availability is checked as the reviewer types, through
GET /admin/reviews/exploit-link/dictionary?q=<body>. Because the body is free
text it can collide with an issuance or with an existing advisory, and finding
that out after writing a title, a description and ticking the confirmation is
finding out too late. The response says whether it is free, what holds it if
not (GcveIssuance or CVEMetadata), and the next free sequence for the
label and year the reviewer chose — not for a label they did not.
That check is advisory. The mint re-checks inside its own transaction, which is the only place a race between two reviewers can actually be settled.
CVEMetadata.cveId holds the bare body (XPL-2026-1) and GcveIssuance.gcveId
holds the prefixed form, matching the majority shape in the corpus and the
direction of the VVD prefix-strip migration.
No retry budget, deliberately
The sweep keeps no attempt counter and no cooldown. It is a pure function of current database state, so the day an advisory finally lands the next run links it. A retry budget would, by construction, permanently retire exactly the rows this exists to rescue.
Bounding is by keyset cursor over Exploit.uuid and a page limit only, so an
interrupted run resumes with --after=<cursor> rather than restarting.
Flags
| Flag | Default | Purpose |
|---|---|---|
--limit | 0 | Stop after N exploits; 0 means until exhausted or the deadline |
--wave | 500 | Exploits selected per page |
--sources | (all) | Comma-separated Exploit.source filter |
--after | — | Resume the keyset cursor |
--recover | true | Fetch bodies from S3 and resolve cross-references |
--queue | true | Queue what remains for admin review |
--dry-run | false | Report without writing |
S3_BUCKET_NAME is needed only for the recovery pass. Without it the run still
re-links and still queues, and says that recovery was skipped — silently
skipping it would look like “no cross-references exist”.
justargument order.just recipe KEY=VALUEpasses the literal string as a positional. Use positional order:just go-exploit-cve-relink-processor prod 200 500 '' '' true.
Verification
Always dry-run against production first. That is not ceremony: the dry run is what caught the Microsoft bulletin mis-resolution above, before a single false link was written.
just go-exploit-cve-relink-processor prod 300 500 '' '' true # dry run
just go-exploit-cve-relink-processor prod 200 # limited apply
Then confirm the linkage is real and reachable:
SELECT count(*) FROM "ExploitCVE" WHERE "createdAt" > <run start ms>;
and that GET /v2/vuln/{cveId}/exploits returns the newly linked exploit.