NVD Deep-Dive Processor — Design Document
1. Overview
Purpose: Quantify what NVD’s enrichment narrowing actually means in counted records. The website article asserts that “the source of truth no longer exists for 80% of the catalogue” and that “100% of newly-disclosed CVEs that don’t pass the US KEV / federal-software / EO 14028 test will arrive as text-only advisories”; this processor turns those assertions into numbers from the actual record set.
Schedule: Daily at 11:00 UTC (cron 0 11 * * ? *) — one hour after summary-processor so the bulk processors and the summary aggregations have settled.
Timeout: 45 minutes (EXPECTED_DURATION_MINUTES=45, soft deadline at 40 minutes).
Resources: 256 CPU units, 1024 MB memory. The rawDataJSON jsonb cast for the no-remediation predicate is the dominant memory cost; everything else is index-driven.
What it reads:
- Read replica:
CVEMetadata, filtered tostate = 'PUBLISHED' AND cveId LIKE 'CVE-%'(excludes GHSA, PYSEC, RUSTSEC, etc. — those don’t claim NVD enrichment and would skew the story). - Read replica:
CVEMetric(CVSS scores). - Read replica:
CVEAffected,CVEAffectedVersion(affected-product structure). - Read replica:
CVEADPjoined toAuthorizedDataPublisher(CISA ADP container presence). - Read replica:
BulkDataDumpTracker(freshness gate).
What it writes:
- Write endpoint: one
SummaryLogrow per run, keyed(label='nvd_deep_dive', timestamp)—ON CONFLICT (label, timestamp) DO UPDATE, so re-runs overwrite cleanly. - Write endpoint:
BulkDataDumpTrackerupsert for thenvd_deep_divetracker source — controls the freshness gate on subsequent runs.
Environment variables:
DATABASE_URL— required (write pool).DATABASE_URL_READ— optional read replica.SNS_TOPIC_ARN— optional; enables overtime / error SNS notifications viainternal/notify.EXPECTED_DURATION_MINUTES— optional (default 45); soft deadline at value − 5.READ_STATEMENT_TIMEOUT— set to35minfor this task. The master aggregation is an intentional full-dataset rollup that runs for minutes; this lifts the read pool’s protective 60 sstatement_timeout(internal/db/pool.go) so the query is not cancelled with SQLSTATE 57014, while keeping the 60 s cap everywhere else.
Trigger gates:
- ECS scheduled task (EventBridge cron
0 11 * * ? *). - Manual via
just go-nvd-deepdive-processor(uses.env) — bypasses the soft deadline. - Pass
-forceto bypass the daily freshness check.
2. Business Logic
For each published CVE record, the processor evaluates ten boolean predicates against the existing relational schema. Each predicate is a CASE WHEN <…> THEN 1 ELSE 0 END expression sharing the same missingPredicateSQL fragment, so the predicate semantics are identical across the three aggregation passes.
| # | Metric key (JSON / SQL flag) | Predicate | Rationale |
|---|---|---|---|
| 1 | missingCvss / missing_cvss | No CVEMetric row with non-null baseScore for this (cveId, source). | NVD’s narrowing means most new CVEs no longer carry an NVD-scored CVSS; many CNAs explicitly decline to score. |
| 2 | missingCpe / missing_cpe | CVEMetadata.cpesJSON is null/empty AND no CVEAffected.cpes field is populated. | CPE absence makes deterministic product-matching impossible. |
| 3 | missingCisaAdp / missing_cisa_adp | No CVEADP row joins to an AuthorizedDataPublisher with shortName = 'CISA-ADP'. | The most-visible “enrichment-of-last-resort” channel; absence means no CISA SSVC / CVSS / CWE supplement. |
| 4 | onlyAffectedVersions / only_affected_versions | At least one CVEAffectedVersion exists for the CVE, and every version row has status = 'affected', and no CVEAffected.defaultStatus = 'unaffected'. | The record names affected versions but never a fixed/unaffected boundary — there is no “upgrade to here” target. |
| 5 | noAffectedVersions / no_affected_versions | No CVEAffectedVersion rows for any CVEAffected parent of this CVE. | Affected products are claimed but no version detail exists. |
| 6 | noPackageName / no_package_name | No CVEAffected.packageName is set. | The CVE 5.1.1 schema permits vendor + product or collectionURL + packageName. If the latter is missing, ecosystem-package matching (npm, PyPI, etc.) is text-search only. |
| 7 | noProgramTarget / no_program_target | None of CVEAffected.programRoutines, .modules, .programFiles are populated for any CVEAffected row. | The “if you have the package installed, you must be vulnerable” assumption is rarely factual; without one of these, reachability is unknown by construction. |
| 8 | noRepo / no_repo | No CVEAffected.repo is populated. | Source-code location is unknown; consumers can’t tail commits or git blame for backports. |
| 9 | noPlatforms / no_platforms | No CVEAffected.platforms is populated. | Distribution ecosystem / runtime / architecture context is missing — the record can’t be filtered by deployment target. |
| 10 | noRemediation / no_remediation | CVEMetadata.rawDataJSON is null/empty or the JSON’s containers.cna.configurations, .workarounds, and .solutions paths are all absent. | The consumer is not told how to fix or work around the issue. |
Aggregation passes
Each predicate fires three times, against three different GROUP BY shapes:
- All-time — one bucket totalling every published CVE.
- Yearly — one bucket per
TO_CHAR(TO_TIMESTAMP(datePublished/1000.0), 'YYYY'), including a""-period bucket for records withoutdatePublished(older bulk imports). Full CVE history, ascending. - Monthly — one bucket per
TO_CHAR(TO_TIMESTAMP(datePublished/1000.0), 'YYYY-MM'). Capped at the last 36 months so the chart axis stays readable and covers the NVD-darkness era (Feb 2024 → present) with a meaningful pre-period baseline.
Output payload
The three aggregations are bundled into one JSON document stored verbatim in SummaryLog.metadata (JSONB):
{
"generatedAt": 1779279600000,
"allTime": { "totalRecords": 308920, "missingCvss": …, … },
"yearly": [ { "period": "1999", "totalRecords": 321, … }, …,
{ "period": "2026", "totalRecords": 25112, … } ],
"monthly": [ { "period": "2023-06", "totalRecords": 2300, … }, …,
{ "period": "2026-05", "totalRecords": 6153, … } ]
}
SummaryLog.value carries allTime.totalRecords as a headline number; SummaryLog.expr is "json".
3. Data flow
┌──────────────────────────────────────────────┐
│ EventBridge cron: 0 11 * * ? * │
└────────────────────┬─────────────────────────┘
▼
┌──────────────────────────────────────┐
│ ECS task: nvd-deepdive-processor │
│ 1. read replica connect │
│ 2. BulkDataDumpTracker freshness │
│ 3. computeAllTime (≤ 20 min) │
│ 4. computeYearly (≤ 20 min) │
│ 5. computeMonthly (≤ 20 min) │
│ 6. marshal Payload → JSON │
│ 7. INSERT SummaryLog (ON CONFLICT) │
│ 8. UpsertTracker │
└────────────────────┬─────────────────┘
▼
┌──────────────────────────────────────┐
│ vdb-api GET /v2/nvd-deep-dive │
│ reads latest SummaryLog row │
│ 24h in-memory cache, CDN cacheable │
└────────────────────┬─────────────────┘
▼
┌──────────────────────────────────────┐
│ website /articles/just-patch │
│ NVD deep-dive section: stat cards │
│ + yearly stacked bar + monthly bar │
└──────────────────────────────────────┘
4. Configuration surface
The processor name appears character-identical in all of the following per the blueprint’s “8-place rule”:
| Location | Value |
|---|---|
scripts/go-processors/cmd/{name}/ directory | nvd-deepdive-processor |
Containerfile.go-processors final-stage target | AS nvd-deepdive-processor |
| ECR image tag | go-nvd-deepdive-processor-${var.go_container_image_tag} |
| Terraform local + module | local.go_nvd_deepdive_processor_image / module "nvd_deepdive_processor" |
scripts/task-manager.toml key | [tasks.nvd-deepdive-processor] |
scripts/task-dashboard/cmd/ecr-build/targets.go | listed in simpleTargets |
.claude/hooks/post-push-ecr.sh TARGETS | listed |
justfile recipe | go-nvd-deepdive-processor |
scripts/processor-health-check{,-one}.sql lookup table | ('nvd-deepdive-processor', 'nvd_deep_dive') |
BulkDataDumpTracker.source for freshness tracking is nvd_deep_dive (underscored — Postgres-friendly key).
5. Run + verify
Local dry-run via the justfile (uses .env):
just go-nvd-deepdive-processor
# or against production read replica:
just go-nvd-deepdive-processor prod
Inspect the result:
SELECT label, timestamp, value, jsonb_pretty(metadata)
FROM "SummaryLog"
WHERE label = 'nvd_deep_dive'
ORDER BY timestamp DESC
LIMIT 1;
Expected shape: one row, value ≈ the total published-CVE count, metadata ≈ the JSON document above with non-zero counts in each missing* key.
End-to-end against the API:
TOKEN=$(curl -s https://www.vulnetix.com/api/vdb/token | jq -r .token)
curl -s -H "Authorization: Bearer $TOKEN" \
https://www.vulnetix.com/api/vdb/v2/nvd-deep-dive | jq '.allTime'
Until the first scheduled run completes, the endpoint returns HTTP 503 with {"success": false, "error": "NVD deep-dive not yet available, please retry shortly"} — exactly what the website’s article shows as a “Live data unavailable” skeleton.
6. Operational notes
- Backfill is not needed. There is no historical state; every run computes counts against the current full record set.
- Re-runs within the same
timestampoverwrite (ON CONFLICT (label, timestamp) DO UPDATE). Different daily timestamps coexist; the API + website always read the most recent. - The processor is read-heavy / write-tiny: ten predicates × three aggregations × one CTE = ~10s SQL plans; the only write is a single
SummaryLogrow. - No S3, no SNS payloads, no notification on success beyond the standard
notifier.Completed()call. Failures route throughinternal/notifyto Google Chat / Slack vianotify-dispatcher. - The predicates intentionally exclude rejected CVEs and non-CVE identifiers — see § 2 for the rationale.
S3 Persistence
Not used. This processor does not currently archive payloads or quarantine failures to S3. Per the S3 Persistence Contract this is non-compliant — see the compliance matrix for the implementation roadmap.
Expected paths when implemented:
- Archive:
nvd-deepdive/files/{sha256}/{filename} - Quarantine:
failed-feeds/nvd-deepdive-processor/{YYYY-MM-DD}/{reason}/{filename} - Likely reasons: (none documented)