EUVD Processor — Design Document
1. Overview
Purpose: Mirror the ENISA EU Vulnerability Database (EUVD) catalog into CVEMetadata/CVEAlias independently of the CVE identifiers we already track. The catalog is walked directly via /api/search, so EUVD records whose primary identifier is not a CVE (PWNO, GHSA-only entries, vendor IDs) are still ingested.
There are two binaries in this design:
| Binary | Schedule | Purpose |
|---|---|---|
cmd/euvd-json-processor | Daily, ECS Fargate | Delta sweep of records updated since the previous run. |
cmd/euvd-json-backfill | Local-only (just go-euvd-json-backfill) | Resumable full-catalog walk for one-time/periodic completeness. |
Both share internal/euvd/ for HTTP, retry, and per-record write logic.
Data source: GET https://euvdservices.enisa.europa.eu/api/search (no auth).
API notes:
- Returns
{ items: Record[], total: int }. Catalog is ~373k records (productionCVEMetadatacount forsource='euvd', 2026-08). - Max
size=100per page;pageis zero-indexed. - Date filter:
fromUpdatedDate=YYYY-MM-DD&toUpdatedDate=YYYY-MM-DD(and the equivalentfromDate/toDateondatePublished). - Other supported filters (unused here):
fromScore/toScore,fromEpss/toEpss,product,vendor,assigner,exploited,text. - Item date format:
"Jan 2, 2006, 3:04:05 PM"(US locale); RFC3339 accepted as fallback. - CVSS vector field:
baseScoreVector. - No
titlefield in response. - Rate-limit / server-error responses (429, 5xx, 403) → exponential backoff (5 retries, base 1s, cap 60s, ±50% jitter).
Schedule: Daily at 03:00 UTC (cron cron(0 3 * * ? *) — Terraform EventBridge, unchanged from prior design).
Timeout: 60 minutes (daily processor); soft deadline derived from EXPECTED_DURATION_MINUTES - 10. The backfill has no deadline at all — per AGENTS.md “Backfill Must Not Have a Deadline”, just go-euvd-json-backfill unsets EXPECTED_DURATION_MINUTES and the binary applies no soft cap, so a full walk runs to completion.
Resources: 512 CPU units, 1024 MB memory.
What it reads:
- Read replica:
BulkDataDumpTracker(cursor for delta sweep / resume marker for backfill). - EUVD
/api/searchendpoint.
What it writes:
CVEMetadata(source=euvd— EUVD records; minimal stubs for classified alias targets only when the target row is missing).CVEAlias(linking EUVD IDs to recognised aliases).BulkDataDumpTracker(source='euvd'for delta cursor;source='euvd_backfill'for backfill resume page).- S3 archive / quarantine (see S3 Persistence below).
What it deliberately does NOT write — worth stating up front, because the
low field coverage on source='euvd' rows looks like a defect and is not:
| Column / table | Why it is empty |
|---|---|
CVEMetadata.title | /api/search carries no title field. Writing nothing is correct; only this binary writes to (euvdId, 'euvd'), so there is nothing to preserve with a COALESCE trick either. |
CVEProblemType | /api/search carries no CWE. |
CVEAffected, CVEMetadataReferences | Never written by this design. Rows under source='euvd' are residue from the earlier CVE-driven processor. |
CVEDescription / CVEMetric | These SHOULD be written and are not. internal/euvd/types.go parses description, baseScore and baseScoreVersion off every item and WriteRecord discards all three — only baseScoreVector survives, as CVEMetadata.vectorString. Fetched over the wire on every record and thrown away. |
The product of this processor is therefore the alias bridge — EUVD ⇄
CVE / GHSA / PWNO identity edges no other feed publishes — plus the CVSS vector
string. Judge its efficacy on CVEAlias coverage and vectorString fill rate,
not on title coverage.
Environment variables:
DATABASE_URL— required.DATABASE_URL_READ— optional.EXPECTED_DURATION_MINUTES— optional soft-deadline budget.
2. Business Logic
Daily delta sweep — euvd-json-processor
- Window — read
BulkDataDumpTracker(source='euvd').lastProcessedAt:fromUpdatedDate = max(tracker.lastProcessedAt - 24h, now - 7d)(overlap absorbs clock skew; cap bounds catch-up after long outages).toUpdatedDate = now(UTC date).- First-ever run (no tracker row) →
now - 7d.
- Walk —
GET /api/search?size=100&page=N&fromUpdatedDate=…&toUpdatedDate=…starting atpage=0. Capturetotalfrom page 0 to bound the loop. Stop whenlen(items) < 100OR(page+1)*100 >= total. 200 ms courtesy delay between pages. - Per record — see “Per-record write” below.
- Tracker — on clean completion,
UpsertTracker("euvd", "", recordsProcessed)stampslastProcessedAt = now.UnixMilli(). - Circuit breaker — abort after 5 consecutive non-retriable
/api/searcherrors. Tracker is not stamped on abort, so the next run re-covers the same window.
No CVE-driven query of CVEMetadata; no enisaid?id= per-record fetches; no RESERVED placeholders are written. Legacy RESERVED rows from the prior CVE-driven design are left in place — they are simply not produced any more.
Backfill — euvd-json-backfill (local-only)
- Resume — read
BulkDataDumpTracker(source='euvd_backfill').totalCVEs(interpreted as the next page to walk).--start-pageoverrides;--max-pagescaps the walk length for testing. - Walk —
GET /api/search?size=100&page=N(no date filter). On each successful page,UpsertTracker("euvd_backfill", "", page+1)to persist progress. - No soft deadline — the backfill applies no runtime cap (AGENTS.md “Backfill Must Not Have a Deadline”), and the justfile recipe unsets
EXPECTED_DURATION_MINUTES. Progress is durable per page, so interrupting the run and re-invoking resumes from the last completed page. - End of catalog — when
(page+1)*100 >= totalor page returns empty, resettotalCVEs = 0and stamplastProcessedAt = now. The user re-runs manually when a fresh full re-walk is desired.
Per-record write (shared)
For each EUVD Record:
db.UpsertCVEMetadata(CVEMetadataRow{ cveID: rec.id, source: "euvd", state: "PUBLISHED", dataVersion: "1.0", datePublished, dateUpdated, vectorString }). Title is omitted: EUVD has no title field, and only this binary writes to(rec.id, "euvd")so there is nothing to preserve.- Parse
rec.aliases(newline/whitespace/comma/semicolon separated). For each entry, classify by prefix:CVE-…→cve.orgGHSA-…→ghsaPWNO-…→pwnoEUVD-…→euvd- anything else → skip (debug-logged). We don’t stub-create rows for identifier schemes we don’t ingest.
- For each classified alias
(aliasID, aliasSource):db.EnsureMinimalCVEMetadata(aliasID, aliasSource)to satisfy the FK target.- Compute canonical direction via
db.CanonicalAliasDirection(rec.id, "euvd", aliasID, aliasSource). INSERT INTO "CVEAlias" (...) VALUES (...) ON CONFLICT DO NOTHINGwithdiscoveredFrom='euvd'.
Bundle suppression is applied locally before step 3: an EUVD record carrying more
than one CVE--prefixed alias is co-listing CVEs, not asserting alternative
identifiers for one vulnerability, so all its CVE aliases are dropped.
Non-CVE aliases (GHSA, PWNO, EUVD) are kept.
Known deviation — this is the one place in the codebase that writes
CVEAliasby hand. AGENTS.md (“Alias Writes —CVEAliasis the source of truth”) requires every alias write to go throughdb.InsertAliases.internal/euvd/writer.goissues theINSERTitself. It does canonicalise direction and it does apply bundle suppression, but it skips the helper’s same-cveId cross-source peer backfill — the edges that link(cveId, 'euvd')to every other source already carrying that same id. EUVD records therefore get their CVE/GHSA/PWNO bridges but not their peer-source edges. Fixing this means callingdb.InsertAliases(ctx, tx, rec.ID, "euvd", aliases, logger)instead.
All writes happen inside a db.WithTx transaction with a per-record savepoint, so a single bad item rolls back only its own writes.
Idempotency
UpsertCVEMetadata increments fetchCount on conflict; alias inserts are ON CONFLICT DO NOTHING. Re-running either binary is safe.
3. Architecture Diagram
Record / SearchResponse] CLIENT[client.go
Client.Search + retry] WRITER[writer.go
WriteRecord = upsert + aliases] end subgraph "cmd/euvd-json-processor (daily)" PMAIN[main.go
computeWindowStart -> runDelta] end subgraph "cmd/euvd-json-backfill (local)" BMAIN[main.go
resolveStartPage -> runBackfill] end subgraph "internal/db" UPSERT[UpsertCVEMetadata] ENSURE[EnsureMinimalCVEMetadata] CANON[CanonicalAliasDirection] TRACKER[GetTracker / UpsertTracker] end PMAIN --> CLIENT BMAIN --> CLIENT PMAIN --> WRITER BMAIN --> WRITER WRITER --> UPSERT WRITER --> ENSURE WRITER --> CANON PMAIN --> TRACKER BMAIN --> TRACKER
4. Deployment Diagram
go-ecr-deploy.yml] -->|push ARM64 image| ECR[ECR: vdb-manager
tag: euvd-json-processor-sha-xxx] ECR --> TASKDEF[ECS Task Definition
go-euvd-json-processor] TASKDEF --> EB[EventBridge Schedule
vdb-euvd-json-processor
cron 0 3 * * ? *] EB -->|trigger| FARGATE[ECS Fargate Task
vdb-scheduler cluster
ARM64 ap-southeast-2] FARGATE --> CW[CloudWatch Logs
/ecs/vdb-scheduler/euvd-json-processor] FARGATE -->|HTTPS GET pages| EUVD[euvdservices.enisa.europa.eu
/api/search] FARGATE --> READ[RDS Read Replica
BulkDataDumpTracker] FARGATE --> WRITE[RDS Write Proxy
CVEMetadata + CVEAlias + BulkDataDumpTracker] DEV[Developer laptop
just go-euvd-json-backfill] -->|HTTPS GET pages| EUVD DEV --> WRITE
5. Processing Flow — Delta sweep
fromUpdatedDate / toUpdatedDate] WIN --> LOOP[For page = 0,1,2…] LOOP --> SEARCH[GET /api/search?page=N&size=100&fromUpdatedDate=…&toUpdatedDate=…] SEARCH -->|429/5xx/403| RETRY[exp backoff x5] RETRY --> SEARCH SEARCH -->|error x5 final| ABORT[circuit breaker — exit 0
no tracker stamp] SEARCH -->|200| BATCH[WithTx: WriteRecord per item
upsert CVEMetadata + aliases] BATCH --> CHECK{len < 100
OR page*100 >= total?} CHECK -->|no| LOOP CHECK -->|yes| STAMP[UpsertTracker source=euvd
lastProcessedAt = now] STAMP --> DONE([Exit 0])
The backfill flow is identical except that there is no date filter, the loop start page comes from tracker.totalCVEs, the tracker is updated per page (resume cursor) rather than once at the end, and the soft-deadline exit short-circuits the loop without resetting state.
6. Data Mapping
Legacy RESERVED rows from the prior CVE-driven design (source='euvd' AND state='RESERVED') are not produced by this design. They remain in the table; no DELETE migration is part of this change.
S3 Persistence
- Archive path:
euvd/files/{sha256}/{filename}✓ - Quarantine path:
failed-feeds/euvd-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.