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


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

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:

ReferenceResolved?Why
Exploit-DB entry (exploit-db.com/exploits/N, EDB-ID: N)YesTransitive 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 onlySee 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:

FieldMeaning
namedAdvisoriesIdentifiers the row names that no advisory in the corpus matches
hasBodyWhether a proof-of-concept body is stored and readable in the console
crossRefsResolvedHow many cross-references became links on this pass
unresolvedCrossRefskind: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.

InputResult
XPL-2026-1GCVE-110-XPL-2026-1, year 2026, sequence 1
GCVE-110-XPL-2026-1same — a pasted whole identifier is stripped
xpl-2026-1same — normalised to upper case
XPL-1refused; no year to record
XPL-26-1refused; 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

FlagDefaultPurpose
--limit0Stop after N exploits; 0 means until exhausted or the deadline
--wave500Exploits selected per page
--sources(all)Comma-separated Exploit.source filter
--afterResume the keyset cursor
--recovertrueFetch bodies from S3 and resolve cross-references
--queuetrueQueue what remains for admin review
--dry-runfalseReport 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”.

just argument order. just recipe KEY=VALUE passes 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.