NIST NVD Modified Processor — Design Document
1. Overview
Purpose: Fetch CVEs last-modified since the previous run from the NIST NVD
REST API 2.0 and store them directly to CVEMetadata, then AI-enrich as many as
the run’s time budget allows.
Data source: NVD REST API 2.0 —
https://services.nvd.nist.gov/rest/json/cves/2.0, queried with a
lastModStartDate/lastModEndDate window (nvd.FetchModified).
An NVD_API_KEY (that exact name, main.go:57) would raise the rate limit from
5 requests / 30s to 50, which internal/nvd/api.go:20-21 implements as a 6.5s
vs 0.7s inter-page sleep. It is not set anywhere — no terraform secret or
environment entry defines it, and every ECS run logs "hasAPIKey":false.
Schedule: Every 6 hours at 03:30 UTC, cron(30 3/6 * * ? *). Staggered 3
hours behind nist-nvd-recent (00:30) so the two never run at once — they write
overlapping source="nist-nvd" rows and would otherwise contend on row locks.
Timeout: 165 minutes (EXPECTED_DURATION_MINUTES).
Resources: 256 CPU units, 1024 MB memory.
What it reads:
- NVD REST API 2.0 (
lastModStartDate/lastModEndDate, paginated) - Read replica:
BulkDataDumpTrackerfornist_nvd_modified
What it writes:
CVEMetadataand its child tables (source="nist-nvd"), directly — there is no intermediate queue. Viaprocessor.StoreCVESourceDatathat meansCVEMetadata,CVEAlias,CVEDescription,CVEMetadataReferences,CVEMetric,CVEProblemTypeandCVEAffected.BulkDataDumpTracker(nist_nvd_modified), only when the ingest completed.- S3 archive and quarantine objects — see the S3 Persistence section.
Environment variables:
DATABASE_URL— requiredDATABASE_URL_READ— optionalNVD_API_KEY— optional (rate-limit relief); currently unset in ECSEXPECTED_DURATION_MINUTES— the run budget. When unset the code still applies a hardcoded 3-hour soft deadline (main.go:62), so even thego-nist-nvd-modified-json-backfillrecipe — which deliberately unsets the variable — is bounded.
2. Business Logic
Lookback window
The modified window is anchored to the tracker, not a fixed feed:
- Start at
tracker.lastProcessedAt − 1 hour(the overlap guards against gaps between runs). - Clamp the start to at most 7 days ago (
maxLookback). - On a first run (no tracker) default to the last 8 hours.
- End at
now.
Two-phase: ingest, then enrich
Identical discipline to nist-nvd-recent:
- Fetch the modified window from the REST API (
nvd.FetchModified, paginated, per-page retry). - Phase 1 — ingest. Store every fetched record to
CVEMetadata(+ children) in batches of 100, each record in its own savepoint. No AI calls. - Advance the tracker only if the ingest completed. A soft-deadline truncation leaves the tracker unchanged, so the window is refetched next run instead of being skipped.
- Phase 2 — enrich.
enricher.RunBatchUntil(ctx, targets, softDeadline)once over the records ingested this run, stopping at the soft deadline.
Recent vs Modified
- modified (this processor): CVEs by last-modified date since the tracker.
- recent (
nist-nvd-recent): CVEs by publication date in the last 8 days.
Both write source="nist-nvd"; the staggered schedules keep them from
contending. The third member, nist-nvd-year-json-processor, reads the bulk
year feeds and is the only one that can reach a CVE outside these rolling
windows.
This processor is the one that keeps old CVEs current: an NVD revision to a
2019 CVE (CVSS re-score, CWE assignment, new reference, CPE change) appears only
in the last-modified feed, never in the publication feed. That is why the
maxLookback clamp matters — a gap longer than 7 days is not recoverable from
this feed and needs a year backfill.
No per-record change detection
internal/nvd/mapper.go never populates CVESourceData.SourceFileHash, so
CVEMetadata.sourceFileHash is NULL for all 372,743 nist-nvd rows and there
is nothing for db.LoadProcessedHashes to resume from. Every record the API
returns is re-stored in full. For this processor the impact is bounded — the
upstream only lists genuinely-changed CVEs — but combined with
nist-nvd-recent’s fixed 8-day re-ingest it pushes freshly published CVEs to an
average fetchCount of 19.8 (production, 2026-08-06).
3. Architecture Diagram
4. Processing Flow
window = lastProcessedAt-1h, clamped 7d] TRK --> FETCH[FetchModified
REST 2.0, paginated] FETCH -->|error| FAIL FETCH -->|ok| INGEST[Phase 1: store every record
batches of 100, per-record savepoint] INGEST --> DL{soft deadline hit mid-ingest?} DL -->|yes| SKIPTRK[leave tracker unchanged
window refetched next run] DL -->|no| UPTRK[UpsertTracker nist_nvd_modified] SKIPTRK --> ENRICH UPTRK --> ENRICH[Phase 2: RunBatchUntil
enrich until soft deadline] ENRICH --> DONE([task.completed])
5. Data Mapping
6. AI Enrichment (aienrich)
Enrichment runs in Phase 2, after the whole window is ingested — not inside
the store loop. The processor collects an aienrich.Target per stored record and
calls enricher.RunBatchUntil(ctx, targets, softDeadline) once, which stops at
the soft deadline and the process-wide inference budget. Records not reached are
un-enriched until a later run. See the
aienrich overview.
Disable globally by omitting PIX_INFERENCE_ENABLED from this task’s environment
block in terraform/go-schedules.tf.
Enrichment, not ingest, is what this task spends its money on. Observed run
task/nist-nvd-modified-json-processor/54f2030c381547a4a57b0835358d8956
(2026-08-06 03:30 UTC): the API returned 103 records, Phase 1 stored all 103
in under a second, and the run then took 100.7 minutes to enrich those 103
(duration_seconds: 6041.7, enriched: 103, enrichDeferred: 0) — about 58
seconds per CVE, much of it context deadline exceeded against the Cloudflare AI
Gateway. The preceding run (21:30 UTC) ran 150 minutes and its log ends on an
enrichment warning with no complete line and no Outcome metric at all, i.e.
it did not exit cleanly. Sizing and alarm thresholds derived from wall-clock
duration here are measuring the gateway, not NVD.
S3 Persistence
Compliant. The generator previously printed “Not used” here because
scripts/docs/s3-status.yaml recorded status: none for this slug; that entry
was wrong (cf. ORCH-09) and has been corrected.
- Archive:
nvd/files/{sha256}/{cveId}.json—uploader.Archiveonce per committed record,sourceSlug = "nvd"(main.go:30). - Quarantine:
failed-feeds/nist-nvd-modified-json-processor/{YYYY-MM-DD}/{reason}/{cveId}.json. - Failure reasons emitted:
map-error(nocveIdresolved) andstore-error.
Skipped entirely when S3_BUCKET_NAME is unset — uploader stays nil and both
methods are nil-receiver no-ops.