guides/workflow: cve enrichment
Vulnerability management · n8n · OpenCVE → dashboard

CVE Enrichment
OpenCVE → dashboard

An n8n workflow that receives a webhook from OpenCVE when a subscribed CVE appears, enriches it with data from NVD and EPSS, and writes a combined record to your CVE dashboard database. A reference pattern with importable JSON.

n8n Reference template OpenCVE · NVD · EPSS
7nodes
Webhooktrigger from OpenCVE
Upsertto Postgres dashboard

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.

Prerequisites: OpenCVE running with a webhook notification, n8n, and a Postgres table for the dashboard. This is a reference pattern — verify node types against your n8n version.

Flow overview

Webhook validates → parallel enrichment → merge → build record → upsert.

webhookOpenCVE
ifValidate secret
httpNVD Lookup
httpEPSS Score
mergeMerge
codeBuild Record
postgresUpsert
Parallel enrichment: The NVD and EPSS lookups run simultaneously after secret validation and are combined in the Merge node (combine all). This keeps latency down compared to sequential calls.

Node-by-node

#NodeTypeFunction
1WebhookwebhookPOST endpoint /webhook/opencve-cve that OpenCVE calls
2Validate SecretifChecks a secret header so only OpenCVE can trigger it
3NVD LookuphttpRequestFetches CVSS + description from the NVD CVE API 2.0
4EPSS ScorehttpRequestFetches the EPSS probability from FIRST.org
5MergemergeCombines the two enrichment sources (combine all)
6Build RecordcodeAssembles the fields into one flat dashboard object
7UpsertpostgresINSERT … ON CONFLICT updates existing CVE rows
NVD rate limits: The NVD API has strict rate limits without an API key (a few calls per 30 sec). Request a free NVD API key and add it as a header if you expect many CVEs — otherwise you risk 403/429 errors. Consider a small wait/retry node under load.

Importable JSON

n8n import

Paste into n8n via Workflows → ⋯ → Import from clipboard. Replace YOUR_SECRET, the Postgres credential and the field paths so they match your setup.

cve_enrichment_workflow.json
{
  "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"
  }
}
After import — check: the webhook path matches the URL you set in OpenCVE, the secret header is real (not the placeholder), the Postgres credential is bound, and the JSON paths in Build Record fit the actual API responses (NVD's structure is deeply nested and can change). That parser code is a starting point — test against a real response.

Dashboard table

Minimal Postgres table that the Upsert node writes to. Adjust the fields to suit your dashboard.

schema.sql
CREATE TABLE cves (
  cve_id      TEXT PRIMARY KEY,
  cvss        NUMERIC,
  epss        NUMERIC,
  description TEXT,
  published   TIMESTAMPTZ,
  enriched_at TIMESTAMPTZ DEFAULT now()
);
You previously mentioned a SQLite-based CVE dashboard — so swap the Postgres node out for a SQLite/HTTP node, but keep the same upsert logic (INSERT … ON CONFLICT).

Credentials

CredentialSetup
Webhook-secretAny strong string; set in the OpenCVE webhook as a header and validated in node 2
NVD API keyFree from nvd.nist.gov; add as a header on the NVD node for a higher rate limit
Postgresn8n Postgres credential to your dashboard database

Tuning & extensions

ExtensionEffect
CISA KEVAdd a lookup against CISA's Known Exploited Vulnerabilities — the strongest prioritization signal
Severity-filterDrop/flag CVEs below a CVSS or EPSS threshold before upsert
AlertingAdd a Slack/email node on a KEV match or CVSS ≥ 9.0
Affected productsStore the CPE/product list from NVD so the dashboard can filter on your environment
RetrySet retry-on-fail on the HTTP nodes against NVD/EPSS timeouts
IdempotensON CONFLICT DO UPDATE ensures repeated webhooks for the same CVE don't create duplicates
The whole chain: OpenCVE monitors and triggers → this workflow enriches → the dashboard triages. News/articles run separately via the Miniflux news workflow.