CIRCL Vulnerability Lookup Processor — Design Document
1. Overview
Purpose: Query the CIRCL Vulnerability Lookup API for CVEs in our database, storing the main CVE record plus cross-referenced advisory records (linked) and community sightings. Linked records from distribution maintainers (SUSE, CERT-FR, NVD, GHSA, FSTEC) carry authoritative affected product and fix version information.
Data source: CIRCL Vulnerability Lookup API — https://vulnerability.circl.lu/api/vulnerability/{CVE-ID}?with_sightings=true&with_linked=true
API notes:
- Response format: Standard CVE Record v5.1 + extension fields (
vulnerability-lookup:linked,vulnerability-lookup:sightings) - Not-found response: 404 Not Found
- Rate limit response: 429 Too Many Requests — exponential backoff
- Server error response: 5xx — retried with same backoff
- No authentication required (public API)
Schedule: Runs daily at 01:00 UTC (cron(0 1 * * ? *) — Terraform EventBridge)
Timeout: 300 minutes
Resources: 256 CPU units, 1024 MB memory
What it reads:
- Read replica:
CVEMetadata(find CVEs needing CIRCL lookup, existing CIRCL records) - Read replica:
CVEAlias(find linked CIRCL counterparts) - CIRCL API (public, no auth)
What it writes:
CVEMetadata(source=circl— main CVE record + linked advisory records)CVEDescription,CVEMetadataReferences,CVEMetric,CVEProblemType,CVEAffected,CVEAffectedVersion(full relation tables for all records)CVEAlias(linking advisory IDs to CVE IDs)Exploit(source=circl-sighting— community sightings)ExploitCVE(linking sightings to CVE records)
Environment variables:
DATABASE_URL— requiredDATABASE_URL_READ— optional
2. Business Logic
Two-Phase Strategy Per Run
Phase A — New CVEs (phaseALimit = 5000):
- Find CVE-prefix records from
CVEMetadatawhere no CIRCL counterpart exists (circl_linkedCTEAS MATERIALIZED+ LEFT JOIN anti-join) - Ordered by
datePublished DESC(newest first) - For each:
GET /api/vulnerability/{CVE-ID}?with_sightings=true&with_linked=true- 200 response: store main CVE record + linked advisories + sightings (all in one transaction)
- 404 / 204 / body without
cveMetadata: create astate='RESERVED'placeholder row undersource='circl'and calldb.InsertAliases(cveID, "circl", nil)so the same-cveIdcross-source edges are backfilled. The placeholder is what stops the same CVE being re-queried on every subsequent run.
- Circuit breaker: abort the phase after
circuitBreakerThreshold = 10consecutive network errors (main.go:318; the log line reports the constant, so no drift) - Sustained HTTP 429 past the retry budget is counted as
rateLimited, not an error: CIRCL is reachable, the record is simply deferred to the next run. It never trips the circuit breaker and never pages.
Phase B — Reprocess Oldest (phaseBLimit = 250):
- Find the oldest
source='circl'records bylastFetchedAt ASC NULLS FIRST(rides the(source, lastFetchedAt)composite index) - At most one alias per row is resolved via a
LEFT JOIN LATERALonCVEAlias. This replaced an earlierROW_NUMBER()window pass over the whole ~750k-rowcirclsource, which heldLWLock:BufferMappingfor 5+ minutes per run and starved the read replica. - Re-query the API using the linked CVE ID when the row’s own id is not
CVE-* - On any fetch failure the row’s
lastFetchedAtis bumped andfetchCountincremented so it rotates to the back of the queue regardless of failure class - Circuit breaker: 10 consecutive network errors
- Skipped entirely if Phase A’s circuit breaker fired with zero API successes
Linked Record Processing
The vulnerability-lookup:linked field maps source keys to arrays of [id, record] tuples. Each record is stored as a full CVEMetadata record with all relation tables:
| Source Key | Format | Mapper Used |
|---|---|---|
fkie_nvd | NVD JSON 2.0 | nvd.MapNVDRecord() (wrapped in {"cve":...}) |
github | GHSA/OSV schema | osv.ParseJSON() + osv.MapAdvisory() |
certfr_avis | CERT-FR advisory | certfr.MapAdvisory() |
fstec | Russian FSTEC BDU | fstec.MapAdvisory() |
csaf_* | CSAF 2.0 | Custom CSAF extractor (title, notes, product_tree, remediations) |
| Other | Generic | Extract title/dates/descriptions from common fields |
All linked records are stored with source='circl'.
Duplicate IDs (same advisory in different case, e.g., CERTFR-2024-AVI-0518 and certfr-2024-avi-0518) are normalised to uppercase and deduplicated.
What this actually produces at scale — and where it falls short
Because every linked document becomes its own CVEMetadata row, circl is the largest
single source in production. Composition as of this audit (748,435 rows):
| Slice | Rows | With title |
|---|---|---|
CVE-* (the CVE the lookup was keyed on) | 345,748 | 259,860 |
GSD-* linked docs (generic mapper) | 211,858 | 4 |
CNVD-* linked docs | 80,209 | 80,209 |
VAR-* (VARIoT) linked docs (generic mapper) | 28,121 | 0 |
RHSA / SUSE / OPENSUSE / BDU:* / WID / MSRC_CVE / ICSA / JVNDB / CISCO / … | ~55,000 | ~complete |
PYSEC-* linked docs (generic mapper) | 2,938 | 90 |
state='RESERVED' Phase-A placeholders | 3,516 | 0 (by design — FK anchors) |
Two consequences worth knowing before reading any coverage metric off this source:
mapGenericLinkedis a husk producer. It only reads top-levelid/title/summary/description/published(internal/circl/parser.go:467-520). GSD, VARIoT and PYSEC linked records nest their content (gsd.osvSchema,namespaces["cve.org"], …), so 211,858 GSD rows land with no title, no description, no reference, no affected product and no CVSS — onlyrawDataJSON. Sampled 200 GSD rows: 0CVEDescription, 0CVEMetadataReferences, 0CVEAffected. That single cluster is what drags the source’s title coverage down to ~67%; the data is present in the archived raw payload but is never extracted.source='circl'is not provenance for ~403k rows. Linked docs are stored undercircl, not under the publisher that wrote them, so CNVD/RHSA/SUSE/BDU/PYSEC/BIT advisories exist twice: once under their own processor’s source and again here. Anything that counts advisories per source, or treatssourceas “who published this”, must exclude the non-CVE-*slice ofcircl.
Bundle suppression on linked records
A linked document is often a rollup — a CERT-FR bulletin, a SUSE/openSUSE/CERT-Bund CSAF advisory or an FSTEC BDU entry that lists many unrelated CVEs. Co-listing on one document is bundling, not aliasing: it does not make those CVEs aliases of each other, and writing the edges anyway fans out transitive false chains through the advisory hub.
storeLinkedEntry therefore detects bundling — either the entry mapped to more than one
record, or a single record carrying more than one distinct CVE-* alias — and when it
fires:
- each record’s
Aliasesis trimmed to at most[parentCveID](co-listed CVEs dropped); - the explicit parent↔record
db.InsertAliasescall is skipped.
Non-bundled records keep the parent↔record edge. Every alias write in this processor
goes through db.InsertAliases; there are no raw CVEAlias inserts.
Sighting Processing (MISP Taxonomy)
The vulnerability-lookup:sightings array contains community observations. Types follow the MISP sighting taxonomy:
| MISP Type | Exploit Category | Rationale |
|---|---|---|
seen | social_mention | Mentioned on social media or blog |
exploited | weaponised | Actively exploited in the wild |
not exploited | sighting | No exploitation observed |
confirmed | sighting | Vulnerability confirmed |
not confirmed | sighting | Reported but unconfirmed |
patched | sighting | Fix available |
not patched | sighting | No fix yet |
Each sighting is stored as an Exploit record (exploitId=UUID, source=circl-sighting) with an ExploitCVE junction.
Rate Limiting & Retry
200 ms sleep between API calls as a courtesy delay. On 429 / 5xx / stall the fetch is
retried up to maxRetries = 5 times with jittered exponential backoff (1s → 2s → 4s →
8s → 16s) capped at maxBackoffSecs = 300. When the 429 carries a Retry-After header
(delta-seconds or HTTP-date) the upstream-suggested wait wins over the schedule, still
capped at 300 s so a rogue header cannot pin the run for hours.
Transport is pinned to HTTP/1.1 (httpclient.NewHTTP1) because HTTP/2 stream
flow-control stalls on the high-latency ap-southeast-2 → Europe path. Client.Timeout
is 0; each fetch gets its own 90 s context rooted at context.Background() so one slow
CVE cannot cascade-cancel unrelated requests, plus a 30 s idle watchdog
(activityReader) that cancels a body transfer receiving no bytes.
Deadline
The soft deadline is EXPECTED_DURATION_MINUTES − 10 (290 min on the 300-min ECS
budget). It falls back to a hardcoded 50 minutes when the env var is unset — which
is the state just go-circl-json-backfill creates, since the recipe unsets it. A local
backfill is therefore silently truncated at 50 minutes and will not get through a 5000-
record Phase A. This contradicts the backfill-has-no-deadline rule; the deadline should
be left at its zero value when the env var is absent.
DB work (processInTx, upsertReserved) deliberately uses context.Background() so a
soft-deadline cancellation cannot abort a transaction mid-write.
Idempotency
All writes use ON CONFLICT DO UPDATE or ON CONFLICT DO NOTHING. Safe to re-run.
3. Architecture Diagram
4. Deployment Diagram
go-circl-json-processor] TASKDEF --> EB[EventBridge Schedule
cron 0 1 * * ? *] EB -->|trigger daily| FARGATE[ECS Fargate
vdb-scheduler cluster] FARGATE -->|HTTPS GET per CVE| CIRCL[vulnerability.circl.lu
/api/vulnerability/CVE-ID] FARGATE --> READ[RDS Read Replica] FARGATE --> WRITE[RDS Write Proxy]
5. Processing Flow
CVEs with no source=circl
LIMIT 5000] PA_QUERY --> PA_LOOP[For each candidate CVE] PA_LOOP --> PA_API[GET /api/vulnerability/CVE-ID
?with_sightings=true&with_linked=true] PA_API -->|error x10| PA_CB[Circuit breaker — abort] PA_API -->|200| PA_TX[Transaction:
1. StoreRecordWithSource
2. processLinkedRecords
3. processSightings] PA_API -->|404| PA_RES[upsertReserved + InsertAliases] PA_TX --> PA_LOOP PA_RES --> PA_LOOP PA_LOOP -->|done| PB_QUERY[Phase B: Query oldest
source=circl LIMIT 250] PA_CB --> PB_CHECK{Any Phase A successes?} PB_CHECK -->|no| DONE([Exit 0]) PB_CHECK -->|yes| PB_QUERY PB_QUERY --> PB_LOOP[For each CIRCL record] PB_LOOP --> PB_API[GET API with linked CVE-ID] PB_API -->|200| PB_TX[Update record + linked + sightings] PB_API -->|404| PB_BUMP[Bump lastFetchedAt] PB_API -->|error x10| PB_CB[Circuit breaker — abort] PB_TX --> PB_LOOP PB_BUMP --> PB_LOOP PB_LOOP -->|done| DONE PB_CB --> DONE
6. Data Mapping
S3 Persistence
- Archive path:
circl/files/{sha256}/{filename}✓ - Quarantine path:
failed-feeds/circl-json-processor/{YYYY-MM-DD}/{reason}/{filename}✓ - Failure reasons emitted:
store-error
Uses s3client.Uploader from internal/s3client/uploader.go. Skipped when S3_BUCKET_NAME is unset (local dev).
See the S3 Persistence Contract for the full reason taxonomy.