Four import-ready n8n workflows that connect vulnfeed's CVE feed to Slack, email, Jira, and PagerDuty. Copy the JSON, import into n8n, configure your credentials — done.
https://vulnfeed.it/vulns.json — a public JSON array of {10k–15k} CVEs updated every 4 hours. No API key, no rate limits, CORS open. Fields: id, title, severity, score, epss_pct, badge, poc, published, affected, references.
$vars.SLACK_WEBHOOK, security@yourcompany.com, etc.) with real values via n8n → Settings → Variables or directly in each node.Runs every 4 hours, fetches vulnfeed, filters CVEs on the CISA Known Exploited Vulnerabilities list, and posts a formatted Slack message with CVE ID, severity, CVSS score, EPSS percentile, and a link to the CVE detail page. Skips silently if nothing new.
SLACK_WEBHOOK variable in n8n Settings → Variables to your Slack incoming webhook URL (Slack → Apps → Incoming Webhooks).badge === "ACTIVELY EXPLOITED" entries and formats up to 8 CVEs per message.&& v.severity === 'CRITICAL' to the filter.const vulns = $input.first().json;
const kev = vulns
.filter(v => v.badge === 'ACTIVELY EXPLOITED')
.sort((a, b) => (b.score || 0) - (a.score || 0))
.slice(0, 8);
if (kev.length === 0) return [];
const lines = kev.map(v => {
const score = v.score != null ? ` CVSS ${v.score.toFixed(1)}` : '';
const epss = v.epss_pct != null ? ` · EPSS ${v.epss_pct.toFixed(0)}%ile` : '';
const poc = v.poc ? ' · :warning: PoC public' : '';
return `• ** [${v.severity}${score}${epss}${poc}]\n ${(v.title || '').slice(0, 110)}`;
});
return [{
json: {
count: kev.length,
text: `:rotating_light: *${kev.length} CVE${kev.length > 1 ? 's' : ''} actively exploited right now* — \n\n` + lines.join('\n\n')
}
}];
{
"name": "vulnfeed — KEV Slack Alert (every 4h)",
"nodes": [
{
"parameters": {
"rule": { "interval": [{ "field": "hours", "hoursInterval": 4 }] }
},
"id": "sch-001", "name": "Every 4 hours",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2, "position": [240, 300]
},
{
"parameters": {
"url": "https://vulnfeed.it/vulns.json",
"options": { "response": { "response": { "responseFormat": "json" } } }
},
"id": "http-001", "name": "Fetch vulnfeed",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "position": [460, 300]
},
{
"parameters": { "jsCode": "const vulns = $input.first().json;\n\nconst kev = vulns\n .filter(v => v.badge === 'ACTIVELY EXPLOITED')\n .sort((a, b) => (b.score || 0) - (a.score || 0))\n .slice(0, 8);\n\nif (kev.length === 0) return [];\n\nconst lines = kev.map(v => {\n const score = v.score != null ? ` CVSS ${v.score.toFixed(1)}` : '';\n const epss = v.epss_pct != null ? ` \u00b7 EPSS ${v.epss_pct.toFixed(0)}%ile` : '';\n const poc = v.poc ? ' \u00b7 :warning: PoC public' : '';\n return `\u2022 ** [${v.severity}${score}${epss}${poc}]\\n ${(v.title || '').slice(0, 110)}`;\n});\n\nreturn [{\n json: {\n count: kev.length,\n text: `:rotating_light: *${kev.length} CVE${kev.length > 1 ? 's' : ''} actively exploited right now* \u2014 \\n\\n` + lines.join('\\n\\n')\n }\n}];" },
"id": "code-001", "name": "Filter KEV CVEs",
"type": "n8n-nodes-base.code",
"typeVersion": 2, "position": [680, 300]
},
{
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
"conditions": [{ "leftValue": "={{ $json.count }}", "rightValue": 0, "operator": { "type": "number", "operation": "gt" } }]
}
},
"id": "if-001", "name": "Has results?",
"type": "n8n-nodes-base.if",
"typeVersion": 2, "position": [900, 300]
},
{
"parameters": {
"method": "POST",
"url": "={{ $vars.SLACK_WEBHOOK }}",
"sendBody": true,
"bodyParameters": {
"parameters": [{ "name": "text", "value": "={{ $json.text }}" }]
},
"options": {}
},
"id": "http-002", "name": "Post to Slack",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "position": [1120, 220]
}
],
"connections": {
"Every 4 hours": { "main": [[{ "node": "Fetch vulnfeed", "type": "main", "index": 0 }]] },
"Fetch vulnfeed": { "main": [[{ "node": "Filter KEV CVEs", "type": "main", "index": 0 }]] },
"Filter KEV CVEs":{ "main": [[{ "node": "Has results?", "type": "main", "index": 0 }]] },
"Has results?": { "main": [[{ "node": "Post to Slack", "type": "main", "index": 0 }], []] }
},
"active": false,
"settings": { "executionOrder": "v1" },
"tags": [{ "name": "vulnfeed" }, { "name": "security" }]
}
Fires every Monday at 9am. Pulls all CVEs published in the last 7 days, sorts by severity and score, and sends a styled HTML email with a table of the top 20. Includes stat summary (total, critical, KEV, PoC) and links to the vulnfeed new-this-week page.
fromEmail and toEmail in the Send Email node. Use a comma-separated list for multiple recipients.&& (v.title||'').toLowerCase().includes('nginx') to the filter.const vulns = $input.first().json;
const now = new Date();
const cutoff = new Date(now - 7 * 86400000).toISOString().slice(0, 10);
const fresh = vulns
.filter(v => (v.published || '').slice(0, 10) >= cutoff)
.sort((a, b) => {
const sev = {CRITICAL:0, HIGH:1, MEDIUM:2, LOW:3};
return (sev[a.severity] ?? 4) - (sev[b.severity] ?? 4) || (b.score || 0) - (a.score || 0);
});
const nCrit = fresh.filter(v => v.severity === 'CRITICAL').length;
const nKev = fresh.filter(v => v.badge === 'ACTIVELY EXPLOITED').length;
const nPoc = fresh.filter(v => v.poc).length;
const rows = fresh.slice(0, 20).map(v => {
const score = v.score != null ? v.score.toFixed(1) : '—';
const epss = v.epss_pct != null ? v.epss_pct.toFixed(0) + '%' : '—';
const flags = [v.badge === 'ACTIVELY EXPLOITED' ? 'KEV' : '', v.poc ? 'PoC' : ''].filter(Boolean).join(', ');
return `
${v.id}
${(v.title || '').slice(0, 90)}
${v.severity}
${score}
${epss}
${flags}
`;
}).join('');
const html = `
vulnfeed Weekly — ${now.toISOString().slice(0,10)}
${fresh.length} new CVEs this week · ${nCrit} critical · ${nKev} actively exploited · ${nPoc} with public PoC
View full list · Patch now list · vulnfeed.it
`; return [{ json: { subject: \`vulnfeed Weekly: \${fresh.length} CVEs, \${nCrit} critical — \${now.toISOString().slice(0,10)}\`, html, count: fresh.length } }];{
"name": "vulnfeed — Weekly Email Digest (Monday 9am)",
"nodes": [
{
"parameters": {
"rule": { "interval": [{ "field": "weeks", "weeksInterval": 1, "triggerAtDay": [1], "triggerAtHour": 9, "triggerAtMinute": 0 }] }
},
"id": "sch-002", "name": "Monday 9am",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2, "position": [240, 300]
},
{
"parameters": { "url": "https://vulnfeed.it/vulns.json", "options": {} },
"id": "http-003", "name": "Fetch vulnfeed",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "position": [460, 300]
},
{
"parameters": { "jsCode": "const vulns = $input.first().json;\nconst now = new Date();\nconst cutoff = new Date(now - 7 * 86400000).toISOString().slice(0, 10);\n\nconst fresh = vulns\n .filter(v => (v.published || '').slice(0, 10) >= cutoff)\n .sort((a, b) => {\n const sev = {CRITICAL:0, HIGH:1, MEDIUM:2, LOW:3};\n return (sev[a.severity] ?? 4) - (sev[b.severity] ?? 4) || (b.score || 0) - (a.score || 0);\n });\n\nconst nCrit = fresh.filter(v => v.severity === 'CRITICAL').length;\nconst nKev = fresh.filter(v => v.badge === 'ACTIVELY EXPLOITED').length;\nconst nPoc = fresh.filter(v => v.poc).length;\n\nconst rows = fresh.slice(0, 20).map(v => {\n const score = v.score != null ? v.score.toFixed(1) : '\u2014';\n const epss = v.epss_pct != null ? v.epss_pct.toFixed(0) + '%' : '\u2014';\n const flags = [v.badge === 'ACTIVELY EXPLOITED' ? 'KEV' : '', v.poc ? 'PoC' : ''].filter(Boolean).join(', ');\n return `\n ${v.id} \n ${(v.title || '').slice(0, 90)} \n ${v.severity} \n ${score} \n ${epss} \n ${flags} \n `;\n}).join('');\n\nconst html = `\nvulnfeed Weekly \u2014 ${now.toISOString().slice(0,10)}
\n${fresh.length} new CVEs this week · ${nCrit} critical · ${nKev} actively exploited · ${nPoc} with public PoC
\n| CVE | \nTitle | \nSev | \nCVSS | \nEPSS | \nFlags | \n
|---|
\n View full list ·\n Patch now list ·\n vulnfeed.it\n
\n`;\n\nreturn [{ json: { subject: \\`vulnfeed Weekly: \\${fresh.length} CVEs, \\${nCrit} critical \u2014 \\${now.toISOString().slice(0,10)}\\`, html, count: fresh.length } }];" }, "id": "code-002", "name": "Build digest email", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [680, 300] }, { "parameters": { "fromEmail": "security@yourcompany.com", "toEmail": "team@yourcompany.com", "subject": "={{ $json.subject }}", "emailType": "html", "html": "={{ $json.html }}" }, "id": "mail-001", "name": "Send digest email", "type": "n8n-nodes-base.emailSend", "typeVersion": 2, "position": [900, 300] } ], "connections": { "Monday 9am": { "main": [[{ "node": "Fetch vulnfeed", "type": "main", "index": 0 }]] }, "Fetch vulnfeed": { "main": [[{ "node": "Build digest email", "type": "main", "index": 0 }]] }, "Build digest email": { "main": [[{ "node": "Send digest email","type": "main", "index": 0 }]] } }, "active": false, "settings": { "executionOrder": "v1" }, "tags": [{ "name": "vulnfeed" }, { "name": "security" }] }Checks every 4 hours for CRITICAL severity CVEs that are on the CISA KEV list. Creates one Jira issue per new CVE with severity, CVSS, EPSS, description, and a link to the vulnfeed CVE page. Skips CVEs already seen using n8n Variables as a simple state store.
"project": {"value": "SEC"} to your actual Jira project key.Task to Bug or a custom type if your project uses one for security issues.SEEN_IDS variable prevents duplicate tickets — create it in Settings → Variables with an initial value of []. Add a Set node after Jira creation to update it if needed.const vulns = $input.first().json;
const stored = JSON.parse($vars.SEEN_IDS || '[]');
const seenSet = new Set(stored);
const urgent = vulns.filter(v =>
v.severity === 'CRITICAL' &&
v.badge === 'ACTIVELY EXPLOITED' &&
!seenSet.has(v.id)
);
// Return one item per CVE so the Jira node loops over them
return urgent.map(v => ({
json: {
id: v.id,
title: v.title || v.id,
score: v.score,
epss: v.epss_pct,
url: `https://vulnfeed.it/cve/${v.id}.html`,
summary: `[${v.id}] ${(v.title || '').slice(0, 100)}`,
description: `*Severity:* ${v.severity} | *CVSS:* ${v.score ?? '—'} | *EPSS:* ${v.epss_pct != null ? v.epss_pct.toFixed(0) + '%ile' : '—'}\n\n` +
`*Status:* CISA KEV — actively exploited in the wild${v.poc ? ' · public PoC available' : ''}\n\n` +
`*Description:* ${(v.description || '').slice(0, 500)}\n\n` +
`*vulnfeed page:* ${`https://vulnfeed.it/cve/${v.id}.html`}\n` +
`*NVD:* ${v.url || ''}`
}
}));
{
"name": "vulnfeed — Jira Ticket for Critical KEV CVEs",
"nodes": [
{
"parameters": {
"rule": { "interval": [{ "field": "hours", "hoursInterval": 4 }] }
},
"id": "sch-003", "name": "Every 4 hours",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2, "position": [240, 300]
},
{
"parameters": { "url": "https://vulnfeed.it/vulns.json", "options": {} },
"id": "http-004", "name": "Fetch vulnfeed",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "position": [460, 300]
},
{
"parameters": { "jsCode": "const vulns = $input.first().json;\nconst stored = JSON.parse($vars.SEEN_IDS || '[]');\nconst seenSet = new Set(stored);\n\nconst urgent = vulns.filter(v =>\n v.severity === 'CRITICAL' &&\n v.badge === 'ACTIVELY EXPLOITED' &&\n !seenSet.has(v.id)\n);\n\n// Return one item per CVE so the Jira node loops over them\nreturn urgent.map(v => ({\n json: {\n id: v.id,\n title: v.title || v.id,\n score: v.score,\n epss: v.epss_pct,\n url: `https://vulnfeed.it/cve/${v.id}.html`,\n summary: `[${v.id}] ${(v.title || '').slice(0, 100)}`,\n description: `*Severity:* ${v.severity} | *CVSS:* ${v.score ?? '\u2014'} | *EPSS:* ${v.epss_pct != null ? v.epss_pct.toFixed(0) + '%ile' : '\u2014'}\\n\\n` +\n `*Status:* CISA KEV \u2014 actively exploited in the wild${v.poc ? ' \u00b7 public PoC available' : ''}\\n\\n` +\n `*Description:* ${(v.description || '').slice(0, 500)}\\n\\n` +\n `*vulnfeed page:* ${`https://vulnfeed.it/cve/${v.id}.html`}\\n` +\n `*NVD:* ${v.url || ''}`\n }\n}));" },
"id": "code-003", "name": "Filter new critical KEV",
"type": "n8n-nodes-base.code",
"typeVersion": 2, "position": [680, 300]
},
{
"parameters": {
"conditions": {
"conditions": [{ "leftValue": "={{ $input.all().length }}", "rightValue": 0, "operator": { "type": "number", "operation": "gt" } }]
}
},
"id": "if-002", "name": "New CVEs?",
"type": "n8n-nodes-base.if",
"typeVersion": 2, "position": [900, 300]
},
{
"parameters": {
"resource": "issue",
"operation": "create",
"project": { "value": "SEC" },
"issuetype": { "value": "Task" },
"summary": "={{ $json.summary }}",
"additionalFields": {
"description": "={{ $json.description }}",
"priority": { "id": "1" },
"labels": ["cve", "security", "kev"]
}
},
"id": "jira-001", "name": "Create Jira issue",
"type": "n8n-nodes-base.jira",
"typeVersion": 1, "position": [1120, 220]
}
],
"connections": {
"Every 4 hours": { "main": [[{ "node": "Fetch vulnfeed", "type": "main", "index": 0 }]] },
"Fetch vulnfeed": { "main": [[{ "node": "Filter new critical KEV","type": "main", "index": 0 }]] },
"Filter new critical KEV": { "main": [[{ "node": "New CVEs?", "type": "main", "index": 0 }]] },
"New CVEs?": { "main": [[{ "node": "Create Jira issue", "type": "main", "index": 0 }], []] }
},
"active": false,
"settings": { "executionOrder": "v1" },
"tags": [{ "name": "vulnfeed" }, { "name": "security" }]
}
Pages your on-call rotation when a CVE with CVSS ≥9.0 is either actively exploited (CISA KEV) or has a public PoC. Uses PagerDuty's Events API v2 with dedup_key = CVE ID so the same CVE won't fire duplicate alerts. Sends structured payload with severity, EPSS, affected component, and deep link.
PAGERDUTY_KEY variable in n8n Settings → Variables.dedup_key is set to the CVE ID — PagerDuty will suppress duplicate events for the same CVE until it's acknowledged.>= 9.0) or severity filter in the Code node to tune alert volume.const vulns = $input.first().json;
const critical = vulns
.filter(v => (v.score || 0) >= 9.0 && (v.badge === 'ACTIVELY EXPLOITED' || v.poc))
.sort((a, b) => (b.score || 0) - (a.score || 0))
.slice(0, 5);
return critical.map(v => ({
json: {
routing_key: $vars.PAGERDUTY_KEY,
event_action: 'trigger',
dedup_key: v.id,
payload: {
summary: `[vulnfeed] ${v.id} — ${v.severity} CVSS ${v.score} ${v.badge === 'ACTIVELY EXPLOITED' ? '(ACTIVELY EXPLOITED)' : '(PoC public)'}`,
source: 'vulnfeed.it',
severity: v.severity === 'CRITICAL' ? 'critical' : 'error',
component: (v.affected || [])[0] || 'unknown',
custom_details: {
cvss: v.score,
epss_pct: v.epss_pct,
kev: v.badge === 'ACTIVELY EXPLOITED',
poc: !!v.poc,
title: (v.title || '').slice(0, 200),
details: `https://vulnfeed.it/cve/${v.id}.html`
}
},
links: [{ href: `https://vulnfeed.it/cve/${v.id}.html`, text: 'vulnfeed CVE page' }]
}
}));
{
"name": "vulnfeed — PagerDuty alert for CVSS 9+ exploited",
"nodes": [
{
"parameters": {
"rule": { "interval": [{ "field": "hours", "hoursInterval": 4 }] }
},
"id": "sch-004", "name": "Every 4 hours",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2, "position": [240, 300]
},
{
"parameters": { "url": "https://vulnfeed.it/vulns.json", "options": {} },
"id": "http-005", "name": "Fetch vulnfeed",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "position": [460, 300]
},
{
"parameters": { "jsCode": "const vulns = $input.first().json;\n\nconst critical = vulns\n .filter(v => (v.score || 0) >= 9.0 && (v.badge === 'ACTIVELY EXPLOITED' || v.poc))\n .sort((a, b) => (b.score || 0) - (a.score || 0))\n .slice(0, 5);\n\nreturn critical.map(v => ({\n json: {\n routing_key: $vars.PAGERDUTY_KEY,\n event_action: 'trigger',\n dedup_key: v.id,\n payload: {\n summary: `[vulnfeed] ${v.id} \u2014 ${v.severity} CVSS ${v.score} ${v.badge === 'ACTIVELY EXPLOITED' ? '(ACTIVELY EXPLOITED)' : '(PoC public)'}`,\n source: 'vulnfeed.it',\n severity: v.severity === 'CRITICAL' ? 'critical' : 'error',\n component: (v.affected || [])[0] || 'unknown',\n custom_details: {\n cvss: v.score,\n epss_pct: v.epss_pct,\n kev: v.badge === 'ACTIVELY EXPLOITED',\n poc: !!v.poc,\n title: (v.title || '').slice(0, 200),\n details: `https://vulnfeed.it/cve/${v.id}.html`\n }\n },\n links: [{ href: `https://vulnfeed.it/cve/${v.id}.html`, text: 'vulnfeed CVE page' }]\n }\n}));" },
"id": "code-004", "name": "Build PagerDuty payloads",
"type": "n8n-nodes-base.code",
"typeVersion": 2, "position": [680, 300]
},
{
"parameters": {
"conditions": {
"conditions": [{ "leftValue": "={{ $input.all().length }}", "rightValue": 0, "operator": { "type": "number", "operation": "gt" } }]
}
},
"id": "if-003", "name": "Has alerts?",
"type": "n8n-nodes-base.if",
"typeVersion": 2, "position": [900, 300]
},
{
"parameters": {
"method": "POST",
"url": "https://events.pagerduty.com/v2/enqueue",
"sendHeaders": true,
"headerParameters": { "parameters": [{ "name": "Content-Type", "value": "application/json" }] },
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"id": "http-006", "name": "Send to PagerDuty",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "position": [1120, 220]
}
],
"connections": {
"Every 4 hours": { "main": [[{ "node": "Fetch vulnfeed", "type": "main", "index": 0 }]] },
"Fetch vulnfeed": { "main": [[{ "node": "Build PagerDuty payloads", "type": "main", "index": 0 }]] },
"Build PagerDuty payloads":{ "main": [[{ "node": "Has alerts?", "type": "main", "index": 0 }]] },
"Has alerts?": { "main": [[{ "node": "Send to PagerDuty", "type": "main", "index": 0 }], []] }
},
"active": false,
"settings": { "executionOrder": "v1" },
"tags": [{ "name": "vulnfeed" }, { "name": "security" }]
}
// Add to any Code node filter to match specific products
const MY_PRODUCTS = ['nginx', 'kubernetes', 'openssh', 'postgres', 'redis'];
const relevant = vulns.filter(v =>
MY_PRODUCTS.some(p =>
(v.title || '').toLowerCase().includes(p) ||
(v.affected || []).some(a => a.toLowerCase().includes(p))
)
);
// At the start of your Code node const seen = new Set(JSON.parse($vars.SEEN_CVE_IDS || '[]')); const fresh = vulns.filter(v => !seen.has(v.id)); // After processing, update the seen set (add a Set Variable node after) // Set SEEN_CVE_IDS = JSON.stringify([...seen, ...fresh.map(v => v.id)].slice(-500))
// EPSS percentile: 90 = top 10% most likely to be exploited // Good thresholds: 70 for "watch", 90 for "act now" const highRisk = vulns.filter(v => (v.epss_pct || 0) >= 90 && (v.score || 0) >= 7.0 ).sort((a, b) => (b.epss_pct || 0) - (a.epss_pct || 0));
// Teams uses Adaptive Cards via webhook — replace the Slack HTTP Request node with:
// Method: POST, URL: $vars.TEAMS_WEBHOOK
// Body (JSON):
{
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard", "version": "1.4",
"body": [
{"type":"TextBlock","text":"🚨 vulnfeed KEV Alert","weight":"Bolder","size":"Medium"},
{"type":"TextBlock","text":"={ $json.text }","wrap":true}
],
"actions": [{"type":"Action.OpenUrl","title":"View patch list","url":"https://vulnfeed.it/patch-now.html"}]
}
}]
}
vulnfeed JSON API: https://vulnfeed.it/vulns.json — open, no auth, updated every 4h.
More integrations: Grafana & Prometheus · AI agents · API docs