What it does
OpenCVE tells you that a CVE is relevant, but a CVE ID alone isn't actionable. This workflow takes the ID and gathers the context you need to prioritize: CVSS score from NVD and EPSS exploitation probability — and writes it all to your dashboard so you have one place to triage from.
Flow overview
Webhook validates → parallel enrichment → merge → build record → upsert.
Node-by-node
| # | Node | Type | Function |
|---|---|---|---|
| 1 | Webhook | webhook | POST endpoint /webhook/opencve-cve that OpenCVE calls |
| 2 | Validate Secret | if | Checks a secret header so only OpenCVE can trigger it |
| 3 | NVD Lookup | httpRequest | Fetches CVSS + description from the NVD CVE API 2.0 |
| 4 | EPSS Score | httpRequest | Fetches the EPSS probability from FIRST.org |
| 5 | Merge | merge | Combines the two enrichment sources (combine all) |
| 6 | Build Record | code | Assembles the fields into one flat dashboard object |
| 7 | Upsert | postgres | INSERT … ON CONFLICT updates existing CVE rows |
Importable JSON
n8n importPaste into n8n via Workflows → ⋯ → Import from clipboard. Replace YOUR_SECRET, the Postgres credential and the field paths so they match your setup.
{
"name": "OpenCVE Enrichment",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "opencve-cve",
"responseMode": "lastNode",
"options": {}
},
"name": "Webhook (OpenCVE)",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
300
]
},
{
"parameters": {
"conditions": {
"options": {},
"conditions": [
{
"leftValue": "={{ $json.headers['x-webhook-secret'] }}",
"rightValue": "YOUR_SECRET",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
}
},
"name": "Validate Secret",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
460,
300
]
},
{
"parameters": {
"url": "=https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={{ $json.body.cve_id }}",
"options": {}
},
"name": "NVD Lookup",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
680,
240
]
},
{
"parameters": {
"url": "=https://api.first.org/data/v1/epss?cve={{ $json.body.cve_id }}",
"options": {}
},
"name": "EPSS Score",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
680,
380
]
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineAll",
"options": {}
},
"name": "Merge Enrichment",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
900,
300
]
},
{
"parameters": {
"jsCode": "// Assemble enriched CVE object for the dashboard\nconst cve = $input.first().json;\nreturn [{ json: {\n cve_id: cve.body?.cve_id ?? cve.cve_id,\n cvss: cve.vulnerabilities?.[0]?.cve?.metrics?.cvssMetricV31?.[0]?.cvssData?.baseScore ?? null,\n epss: cve.data?.[0]?.epss ?? null,\n description: cve.vulnerabilities?.[0]?.cve?.descriptions?.find(d=>d.lang==='en')?.value ?? '',\n published: cve.vulnerabilities?.[0]?.cve?.published ?? null,\n enriched_at: new Date().toISOString()\n}}];"
},
"name": "Build Record",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
300
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO cves (cve_id, cvss, epss, description, published, enriched_at) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (cve_id) DO UPDATE SET cvss=EXCLUDED.cvss, epss=EXCLUDED.epss, enriched_at=EXCLUDED.enriched_at;",
"options": {}
},
"name": "Upsert Dashboard DB",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1340,
300
]
}
],
"connections": {
"Webhook (OpenCVE)": {
"main": [
[
{
"node": "Validate Secret",
"type": "main",
"index": 0
}
]
]
},
"Validate Secret": {
"main": [
[
{
"node": "NVD Lookup",
"type": "main",
"index": 0
},
{
"node": "EPSS Score",
"type": "main",
"index": 0
}
]
]
},
"NVD Lookup": {
"main": [
[
{
"node": "Merge Enrichment",
"type": "main",
"index": 0
}
]
]
},
"EPSS Score": {
"main": [
[
{
"node": "Merge Enrichment",
"type": "main",
"index": 1
}
]
]
},
"Merge Enrichment": {
"main": [
[
{
"node": "Build Record",
"type": "main",
"index": 0
}
]
]
},
"Build Record": {
"main": [
[
{
"node": "Upsert Dashboard DB",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
Dashboard table
Minimal Postgres table that the Upsert node writes to. Adjust the fields to suit your dashboard.
CREATE TABLE cves (
cve_id TEXT PRIMARY KEY,
cvss NUMERIC,
epss NUMERIC,
description TEXT,
published TIMESTAMPTZ,
enriched_at TIMESTAMPTZ DEFAULT now()
);
INSERT … ON CONFLICT).Credentials
| Credential | Setup |
|---|---|
| Webhook-secret | Any strong string; set in the OpenCVE webhook as a header and validated in node 2 |
| NVD API key | Free from nvd.nist.gov; add as a header on the NVD node for a higher rate limit |
| Postgres | n8n Postgres credential to your dashboard database |
Tuning & extensions
| Extension | Effect |
|---|---|
| CISA KEV | Add a lookup against CISA's Known Exploited Vulnerabilities — the strongest prioritization signal |
| Severity-filter | Drop/flag CVEs below a CVSS or EPSS threshold before upsert |
| Alerting | Add a Slack/email node on a KEV match or CVSS ≥ 9.0 |
| Affected products | Store the CPE/product list from NVD so the dashboard can filter on your environment |
| Retry | Set retry-on-fail on the HTTP nodes against NVD/EPSS timeouts |
| Idempotens | ON CONFLICT DO UPDATE ensures repeated webhooks for the same CVE don't create duplicates |