This workflow follows the Agent → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "llm-network-triage",
"nodes": [
{
"parameters": {
"jsCode": "const items = $input.all();\nconst results = [];\n\nconst suricataRaw = $('Format & Clustering Suricata').all();\nconst zeekRaw = $('Format & Clustering Zeek').first();\n\nlet suricataIdx = 0;\n\nfor (const item of items) {\n let parsed;\n try {\n const raw = (item.json.output || '')\n .replace(/```json/g, '')\n .replace(/```/g, '')\n .trim();\n parsed = JSON.parse(raw);\n } catch (e) {\n parsed = {\n verdict: 'unknown',\n confidence: 0,\n indicators: [],\n reasoning: 'Failed to parse: ' + (item.json.output || 'no output')\n };\n }\n\n if (!parsed.source) {\n parsed.source = item.json.source || 'unknown';\n }\n\n // Restore raw logs directly from source nodes\n if (parsed.source === 'suricata') {\n const srcItem = suricataRaw[suricataIdx];\n parsed._raw_logs = srcItem?.json._raw_logs || srcItem?.json.logs || '';\n suricataIdx++;\n } else if (parsed.source === 'zeek') {\n parsed._raw_logs = zeekRaw?.json._raw_logs || zeekRaw?.json.logs || '';\n }\n\n const merged = { ...item.json, ...parsed };\n delete merged.output;\n results.push({ json: merged });\n}\n\nreturn results;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1168,
800
],
"id": "b933ee22-bbcc-42df-a246-199bada55382",
"name": "Parse Agent Output"
},
{
"parameters": {
"promptType": "define",
"text": "=You are a senior SOC analyst. Two specialized agents have pre-triaged network logs for a specific security event.\n\n=== Event Context ===\nEvent ID: {{ $json.event_id }}\nAttack Type: {{ $json.group_type }}\nSource IPs ({{ $json.src_ip_count }}): {{ $json.src_ips }}\nDestination IPs ({{ $json.dst_ip_count }}): {{ $json.dst_ips }}\nTotal Alerts: {{ $json.alert_count }}\n\n=== Triage Agent Findings ===\nVerdict: {{ $json.verdict }} (confidence: {{ $json.confidence }})\nIndicators: {{ $json.indicators }}\nReasoning: {{ $json.reasoning }}\n\n=== Raw Correlated Evidence ===\nZeek Logs:\n{{ $json.zeek_logs }}\n\nSeverity classification rules \u2014 follow STRICTLY:\n\nCRITICAL (must meet ALL of):\n - Active compromise confirmed (successful login / shell / encryption started)\n - OR known APT / ransomware / botnet infrastructure confirmed by threat intel\n - AND corroborated with high confidence\n\nHIGH (must meet ALL of):\n - Clear attack confirmed by triage agent\n - No confirmed successful compromise yet\n - Significant scope: external origin, known malicious signature, or >100 attempts\n\nMEDIUM (any of):\n - Single-source confirmation only\n - Low-velocity attack (slow brute force, slow scan, <10 events in window)\n - Attack confirmed but limited blast radius (internal origin, single target)\n - Probing / enumeration without exploitation attempt\n\nLOW:\n - Anomalous but ambiguous\n - Single weak indicator, no corroboration\n - Could have legitimate explanation\n\nAdditional rules:\n - DDoS / flood attacks (group_type=ddos) without service confirmed down = HIGH not CRITICAL\n - DDoS / flood with confirmed service degradation or outage = CRITICAL\n - DDoS with src_ip_count > 50 = escalate severity by one level\n - Successful authentication after brute force = always CRITICAL\n - Exfiltration to known APT infrastructure = always CRITICAL\n - Probing / scanning only (no exploitation) = MEDIUM at most\n - group_type=ddos: summarise by target IP not individual sources\n\nYour tasks:\n1. Analyse the triage agent findings and raw evidence\n2. Apply severity rules strictly\n3. Generate a structured incident report\n\nReply ONLY with valid JSON, no markdown:\n{\n \"incident_id\": \"{{ $json.event_id }}\",\n \"group_type\": \"{{ $json.group_type }}\",\n \"severity\": \"Low|Medium|High|Critical\",\n \"verdict\": \"suspicious|malicious\",\n \"confidence\": 0.95,\n \"summary\": \"2-3 sentence executive summary\",\n \"affected_src_ips\": \"{{ $json.src_ips }}\",\n \"affected_dst_ips\": \"{{ $json.dst_ips }}\",\n \"indicators_of_compromise\": [],\n \"correlated_timeline\": [],\n \"mitre_attack\": [\n {\"id\": \"T1190\", \"name\": \"technique name\", \"tactic\": \"tactic name\", \"evidence\": \"what in the logs triggered this\"}\n ],\n \"victim_hosts\": [],\n \"attacker_infrastructure\": [],\n \"recommended_actions\": [],\n \"justification\": \"detailed reasoning\",\n}",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
1632,
800
],
"id": "d9dd39da-dbee-4aa8-86cc-268f6cc9a2bf",
"name": "Central Agent",
"retryOnFail": true,
"maxTries": 5
},
{
"parameters": {
"modelName": "models/gemini-3.1-flash-lite-preview",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
"typeVersion": 1,
"position": [
1680,
1024
],
"id": "1b297ae9-6732-4a50-be23-57e3886fc1e9",
"name": "Google Gemini Chat Model2",
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const raw = ($json.output || '')\n .replace(/```json/g, '')\n .replace(/```/g, '')\n .trim();\n\nlet report;\ntry {\n report = JSON.parse(raw);\n} catch(e) {\n return { json: { response: raw } };\n}\n\nconst eventId = $json.event_id || report.incident_id || `EVT-${Date.now()}`;\nconst groupType = $json.group_type || report.group_type || 'unknown';\nconst srcIPs = $json.src_ips || report.affected_src_ips || 'unknown';\nconst dstIPs = $json.dst_ips || report.affected_dst_ips || 'unknown';\n\n// Restore suricata_alerts and zeek_logs directly from Event Grouping\n// Central Agent is an AI Agent node and drops all upstream fields\nconst eventGroupingItems = $('Event Grouping').all();\nconst currentEventId = eventId;\nconst matchedEvent = eventGroupingItems.find(\n i => i.json.event_id === currentEventId\n);\nconst suricataAlerts = matchedEvent?.json.suricata_alerts || '';\nconst zeekLogs = matchedEvent?.json.zeek_logs || '';\n\ntry {\n report = JSON.parse(raw);\n} catch(e) {\n return { json: { response: raw } };\n}\n\nconst toList = (val) => {\n if (!val) return [];\n if (Array.isArray(val)) return val;\n if (typeof val === 'string') {\n try { const p = JSON.parse(val); if (Array.isArray(p)) return p; } catch(e) {}\n return val.split('\\n').map(s => s.trim()).filter(Boolean);\n }\n return [];\n};\n\nconst iocs = toList(report.indicators_of_compromise);\nconst timeline = toList(report.correlated_timeline);\nconst actions = toList(report.recommended_actions);\nconst mitre = toList(report.mitre_attack);\nconst victims = toList(report.victim_hosts);\nconst attackInfra = toList(report.attacker_infrastructure);\n\n// Format MITRE techniques\nconst mitreText = mitre.length > 0\n ? mitre.map(t => {\n const id = t.id || '?';\n const name = t.name || '?';\n const tactic = t.tactic || '?';\n const evidence = t.evidence || '';\n return `\u2022 ${id} \u2014 ${name} [${tactic}]${evidence ? `\\n Evidence: ${evidence}` : ''}`;\n }).join('\\n')\n : '\u2022 No MITRE techniques mapped';\n\nconst text = `\n\ud83d\udea8 INCIDENT REPORT \u2014 ${eventId}\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\nSeverity : ${report.severity}\nVerdict : ${report.verdict}\nConfidence : ${((report.confidence || 0) * 100).toFixed(0)}%\nAttack Type : ${groupType}\nSource IPs : ${srcIPs}\nTarget IPs : ${dstIPs}\n${victims.length > 0 ? `Victim Hosts : ${victims.join(', ')}` : ''}\n${attackInfra.length > 0 ? `Attacker Infra: ${attackInfra.join(', ')}` : ''}\n\n\ud83d\udccb Summary\n${report.summary || 'No summary available.'}\n\n\ud83c\udfaf MITRE ATT&CK Mapping\n${mitreText}\n\n\ud83d\udd0d Indicators of Compromise\n${iocs.map(i => `\u2022 ${i}`).join('\\n') || '\u2022 None identified'}\n\n\ud83d\udcc5 Correlated Timeline\n${timeline.map(t => `\u2022 ${t}`).join('\\n') || '\u2022 No timeline data'}\n\n\u2705 Recommended Actions\n${actions.map(a => `\u2022 ${a}`).join('\\n') || '\u2022 Continue monitoring'}\n\n\ud83e\udde0 Justification\n${report.justification || 'No justification provided.'}\n`.trim();\n\nconst fs = require('fs');\nconst timestamp = new Date().toISOString().replace(/[:.]/g, '-');\nconst savePath = `/home/node/reports/${eventId}_${timestamp}.txt`;\nfs.mkdirSync('/home/node/reports', { recursive: true });\nfs.writeFileSync(savePath, text, 'utf8');\n\nconsole.log('suricata_alerts:', $json.suricata_alerts ? $json.suricata_alerts.substring(0, 100) : 'EMPTY');\nconsole.log('zeek_logs:', $json.zeek_logs ? $json.zeek_logs.substring(0, 100) : 'EMPTY');\n\nreturn { json: {\n response: text,\n savedPath: savePath,\n verdict: report.verdict || 'unknown',\n severity: report.severity || 'unknown',\n incidentId: eventId,\n groupType: groupType,\n mitreTechs: mitre.map(t => t.id).filter(Boolean),\n suricata_alerts: suricataAlerts,\n zeek_logs: zeekLogs,\n} };"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1984,
800
],
"id": "9c82d6d1-ab21-4f87-8df9-ff2ee15073ea",
"name": "Format Report"
},
{
"parameters": {
"promptType": "define",
"text": "=Source: {{ $json.source }}\nLogs:\n{{ $json.logs }}\n\nYou are a network security analyst specializing in Zeek network logs.\nAnalyze the above Zeek logs carefully.\n\nClassification rules (strictly follow):\n- \"benign\": normal traffic, no anomalies\n- \"suspicious\": anomalous BUT only single indicator, low volume, or no corroboration\n- \"malicious\": multiple corroborating indicators, clear attack pattern, no legitimate explanation\n\nConfidence scoring:\n- 0.9-1.0: multiple strong indicators confirmed\n- 0.7-0.89: clear indicators but some ambiguity\n- 0.5-0.69: anomalous but could be legitimate\n- below 0.5: insufficient evidence\n\nReply ONLY with valid JSON, no markdown:\n{\n \"source\": \"zeek\",\n \"verdict\": \"benign|suspicious|malicious\",\n \"confidence\": 0.95,\n \"indicators\": [\"list of key IOCs\"],\n \"reasoning\": \"brief explanation\",\n \"mitre_techniques\": [\n {\"id\": \"T1021.001\", \"name\": \"Remote Desktop Protocol\", \"tactic\": \"Lateral Movement\"}\n ]\n}",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
592,
1008
],
"id": "63e8e92f-e668-498b-a9c8-14b0fe6eeca7",
"name": "Zeek Triage Agent",
"retryOnFail": true
},
{
"parameters": {
"promptType": "define",
"text": "=Source: {{ $json.source }}\nLogs:\n{{ $json.logs }}\n\nYou are a network security analyst specializing in Suricata IDS alerts.\nAnalyze the above Suricata alerts carefully.\n\nClassification rules (strictly follow):\n- \"benign\": false positive or benign activity, no real threat\n- \"suspicious\": anomalous BUT alert priority is low (3), single rule match, or ambiguous\n- \"malicious\": high priority alert (1-2), multiple rule matches, confirmed attack signature\n\nConfidence scoring:\n- 0.9-1.0: priority 1 alert, multiple signatures confirmed\n- 0.7-0.89: priority 2 alert or single strong signature\n- 0.5-0.69: priority 3 alert or ambiguous match\n- below 0.5: insufficient evidence\n\nReply ONLY with valid JSON, no markdown:\n{\n \"source\": \"suricata\",\n \"verdict\": \"benign|suspicious|malicious\",\n \"confidence\": 0.95,\n \"indicators\": [\"list of key IOCs\"],\n \"reasoning\": \"brief explanation\",\n \"mitre_techniques\": [\n {\"id\": \"T1190\", \"name\": \"Exploit Public-Facing Application\", \"tactic\": \"Initial Access\"}\n ]\n}",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
592,
496
],
"id": "2cec3c8c-15f9-4b5e-9032-8583483a012c",
"name": "Suricata Triage Agent",
"retryOnFail": true
},
{
"parameters": {
"modelName": "models/gemini-3.1-flash-lite-preview",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
"typeVersion": 1,
"position": [
672,
720
],
"id": "3e677dad-bfe9-4908-abc2-16afea396d3b",
"name": "Google Gemini Chat Model",
"retryOnFail": true,
"maxTries": 5,
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"modelName": "models/gemini-3-flash-preview",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
"typeVersion": 1,
"position": [
672,
1232
],
"id": "0d7fbc69-a09f-447a-bee4-4dd527333569",
"name": "Google Gemini Chat Model3",
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes"
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
-304,
800
],
"id": "95398283-767b-4778-8977-d98dd638078a",
"name": "Schedule Trigger"
},
{
"parameters": {
"jsCode": "const now = new Date();\nconst fiveMinAgo = new Date(now.getTime() - 60 * 60 * 1000);\n\nreturn [{\n json: {\n from: fiveMinAgo.toISOString(),\n to: now.toISOString()\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-80,
800
],
"id": "b68daa03-c9a9-4796-92b2-f8cc4be2b9c4",
"name": "Build Time Range"
},
{
"parameters": {
"method": "POST",
"url": "https://100.67.226.34:9200/.ds-logs-suricata.alerts-so-*/_search",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Authorization",
"value": "ApiKey <your_api_key_here>"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"size\": 100,\n \"query\": {\n \"range\": {\n \"@timestamp\": {\n \"gte\": \"{{ $json.from }}\",\n \"lte\": \"{{ $json.to }}\"\n }\n }\n },\n \"_source\": [\n \"@timestamp\",\n \"rule.severity\",\n \"rule.name\",\n \"rule.category\",\n \"source.ip\",\n \"source.port\",\n \"destination.ip\",\n \"destination.port\",\n \"network.transport\",\n \"source.message\"\n ],\n \"sort\": [{\"@timestamp\": {\"order\": \"desc\"}}]\n}",
"options": {
"allowUnauthorizedCerts": true
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
144,
608
],
"id": "e786555c-d0c7-4608-b78f-b705ad97a133",
"name": "Get Suricata Alerts"
},
{
"parameters": {
"method": "POST",
"url": "https://100.67.226.34:9200/.ds-logs-zeek-so-*/_search",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "ApiKey <your_api_key_here>"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"size\": 200,\n \"query\": {\n \"bool\": {\n \"must\": [\n {\n \"range\": {\n \"@timestamp\": {\n \"gte\": \"{{ $('Build Time Range').first().json.from }}\",\n \"lte\": \"{{ $('Build Time Range').first().json.to }}\"\n }\n }\n },\n {\n \"terms\": {\n \"container.id\": [\n \"conn.log\",\n \"dns.log\",\n \"http.log\",\n \"ssl.log\",\n \"notice.log\",\n \"weird.log\",\n \"kerberos.log\",\n \"rdp.log\",\n \"smb_files.log\",\n \"smb_mapping.log\",\n \"dce_rpc.log\"\n ]\n }\n }\n ]\n }\n },\n \"_source\": [\n \"@timestamp\",\n \"container.id\",\n \"source.ip\",\n \"source.port\",\n \"source.geo.country_iso_code\",\n \"destination.ip\",\n \"destination.port\",\n \"network.transport\",\n \"network.protocol\",\n \"network.bytes\",\n \"event.duration\",\n \"zeek\",\n \"dns\",\n \"http\",\n \"tls\",\n \"url\",\n \"notice\",\n \"weird\",\n \"kerberos\",\n \"rdp\",\n \"smb\"\n ],\n \"sort\": [{\"@timestamp\": {\"order\": \"desc\"}}]\n}",
"options": {
"allowUnauthorizedCerts": true
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
144,
1008
],
"id": "94597437-a60f-473c-a2ea-b7117dc18353",
"name": "Get Zeek Logs"
},
{
"parameters": {
"jsCode": "// \u2500\u2500 FORMAT ZEEK NODE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Mode: Run Once for All Items\n// Aggregates Zeek logs by type before passing to Zeek Triage Agent\n\nconst zeekHits = $input.first().json.hits?.hits || [];\n\nif (zeekHits.length === 0) {\n return [{ json: { source: 'zeek', logs: 'No Zeek logs in this time window.' } }];\n}\n\n// \u2500\u2500 1. CONN.LOG \u2014 aggregate by src+dst+port+proto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst connAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'conn.log')) {\n const s = h._source;\n const key = `${s.source?.ip}->${s.destination?.ip}:${s.destination?.port}/${s.network?.transport}`;\n if (!connAgg[key]) {\n connAgg[key] = {\n src: s.source?.ip || '?',\n src_country: s.source?.geo?.country_iso_code || '',\n dst: s.destination?.ip || '?',\n dst_port: s.destination?.port || '?',\n proto: s.network?.transport || '?',\n service: s.network?.protocol || '',\n count: 0,\n total_bytes: 0,\n durations: [],\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const c = connAgg[key];\n c.count++;\n c.total_bytes += s.network?.bytes || 0;\n if (s.event?.duration) c.durations.push(s.event.duration);\n if (s['@timestamp'] < c.first_seen) c.first_seen = s['@timestamp'];\n if (s['@timestamp'] > c.last_seen) c.last_seen = s['@timestamp'];\n}\nconst connLines = Object.values(connAgg).map(c => {\n const avgDur = c.durations.length\n ? (c.durations.reduce((a, b) => a + b, 0) / c.durations.length).toFixed(4)\n : 'N/A';\n const country = c.src_country ? `(${c.src_country})` : '';\n return `src=${c.src}${country} dst=${c.dst}:${c.dst_port} proto=${c.proto} service=${c.service} count=${c.count} bytes=${c.total_bytes} avg_dur=${avgDur}s first=${c.first_seen} last=${c.last_seen}`;\n});\n\n// \u2500\u2500 2. DNS.LOG \u2014 aggregate by src+query \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst dnsAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'dns.log')) {\n const s = h._source;\n const z = s.zeek?.dns || s.dns || {};\n const key = `${s.source?.ip}|${z.query || '?'}`;\n if (!dnsAgg[key]) {\n dnsAgg[key] = {\n src: s.source?.ip || '?',\n query: z.query || '?',\n qtype: z.qtype_name || '?',\n rcodes: new Set(),\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const d = dnsAgg[key];\n d.count++;\n if (z.rcode_name) d.rcodes.add(z.rcode_name);\n if (s['@timestamp'] < d.first_seen) d.first_seen = s['@timestamp'];\n if (s['@timestamp'] > d.last_seen) d.last_seen = s['@timestamp'];\n}\nconst dnsLines = Object.values(dnsAgg).map(d =>\n `src=${d.src} query=\"${d.query}\" type=${d.qtype} rcodes=${[...d.rcodes].join(',')} count=${d.count} first=${d.first_seen} last=${d.last_seen}`\n);\n\n// \u2500\u2500 3. HTTP.LOG \u2014 aggregate by src+dst+method+uri \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst httpAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'http.log')) {\n const s = h._source;\n const z = s.zeek?.http || s.http || {};\n const uri = z.uri || s.url?.original || '?';\n const method = z.method || '?';\n const key = `${s.source?.ip}->${s.destination?.ip}|${method}|${uri}`;\n if (!httpAgg[key]) {\n httpAgg[key] = {\n src: s.source?.ip || '?',\n dst: s.destination?.ip || '?',\n dst_port: s.destination?.port || '?',\n method,\n uri,\n status_codes: new Set(),\n user_agents: new Set(),\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const hh = httpAgg[key];\n hh.count++;\n if (z.status_code) hh.status_codes.add(z.status_code);\n if (z.user_agent) hh.user_agents.add(z.user_agent.substring(0, 50));\n if (s['@timestamp'] < hh.first_seen) hh.first_seen = s['@timestamp'];\n if (s['@timestamp'] > hh.last_seen) hh.last_seen = s['@timestamp'];\n}\nconst httpLines = Object.values(httpAgg).map(h =>\n `src=${h.src} dst=${h.dst}:${h.dst_port} method=${h.method} uri=\"${h.uri}\" status=[${[...h.status_codes].join(',')}] count=${h.count} ua=\"${[...h.user_agents][0] || '?'}\" first=${h.first_seen} last=${h.last_seen}`\n);\n\n// \u2500\u2500 4. SSL.LOG \u2014 aggregate by src+dst+sni \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sslAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'ssl.log')) {\n const s = h._source;\n const z = s.zeek?.ssl || s.tls || {};\n const sni = z.server_name || s.tls?.client?.server_name || '?';\n const key = `${s.source?.ip}->${s.destination?.ip}:${s.destination?.port}|${sni}`;\n if (!sslAgg[key]) {\n sslAgg[key] = {\n src: s.source?.ip || '?',\n dst: s.destination?.ip || '?',\n dst_port: s.destination?.port || '?',\n sni,\n version: z.version || s.tls?.version_protocol || '?',\n cert_valid: z.validation_status || '?',\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const ss = sslAgg[key];\n ss.count++;\n if (s['@timestamp'] < ss.first_seen) ss.first_seen = s['@timestamp'];\n if (s['@timestamp'] > ss.last_seen) ss.last_seen = s['@timestamp'];\n}\nconst sslLines = Object.values(sslAgg).map(ss =>\n `src=${ss.src} dst=${ss.dst}:${ss.dst_port} sni=\"${ss.sni}\" version=${ss.version} cert_valid=${ss.cert_valid} count=${ss.count} first=${ss.first_seen} last=${ss.last_seen}`\n);\n\n// \u2500\u2500 5. NOTICE.LOG \u2014 aggregate by note type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst noticeAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'notice.log')) {\n const s = h._source;\n const z = s.zeek?.notice || s.notice || {};\n const note = z.note || z.note_type || s.event?.action || '?';\n const src = s.source?.ip || '?';\n const key = `${src}|${note}`;\n if (!noticeAgg[key]) {\n noticeAgg[key] = {\n src,\n dst: s.destination?.ip || '?',\n note,\n msg: z.message || z.msg || '',\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const n = noticeAgg[key];\n n.count++;\n if (s['@timestamp'] < n.first_seen) n.first_seen = s['@timestamp'];\n if (s['@timestamp'] > n.last_seen) n.last_seen = s['@timestamp'];\n}\nconst noticeLines = Object.values(noticeAgg).map(n =>\n `src=${n.src} dst=${n.dst} note=\"${n.note}\" msg=\"${n.msg.substring(0, 100)}\" count=${n.count} first=${n.first_seen} last=${n.last_seen}`\n);\n\n// \u2500\u2500 6. WEIRD.LOG \u2014 aggregate by weird type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst weirdAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'weird.log')) {\n const s = h._source;\n const z = s.zeek?.weird || s.weird || {};\n const name = z.name || '?';\n const key = `${s.source?.ip}|${name}`;\n if (!weirdAgg[key]) {\n weirdAgg[key] = {\n src: s.source?.ip || '?',\n dst: s.destination?.ip || '?',\n name,\n addl: z.addl || '',\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const w = weirdAgg[key];\n w.count++;\n if (s['@timestamp'] < w.first_seen) w.first_seen = s['@timestamp'];\n if (s['@timestamp'] > w.last_seen) w.last_seen = s['@timestamp'];\n}\nconst weirdLines = Object.values(weirdAgg).map(w =>\n `src=${w.src} dst=${w.dst} weird=\"${w.name}\" detail=\"${w.addl}\" count=${w.count} first=${w.first_seen} last=${w.last_seen}`\n);\n\n// \u2500\u2500 7. KERBEROS.LOG \u2014 aggregate by src+client+service \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst kerbAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'kerberos.log')) {\n const s = h._source;\n const z = s.zeek?.kerberos || s.kerberos || {};\n const key = `${s.source?.ip}|${z.client || '?'}|${z.service || '?'}`;\n if (!kerbAgg[key]) {\n kerbAgg[key] = {\n src: s.source?.ip || '?',\n dst: s.destination?.ip || '?',\n req_type: z.request_type || '?',\n client: z.client || '?',\n service: z.service || '?',\n success_count: 0,\n fail_count: 0,\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const k = kerbAgg[key];\n k.count++;\n if (z.success === true) k.success_count++;\n if (z.success === false) k.fail_count++;\n if (s['@timestamp'] < k.first_seen) k.first_seen = s['@timestamp'];\n if (s['@timestamp'] > k.last_seen) k.last_seen = s['@timestamp'];\n}\nconst kerbLines = Object.values(kerbAgg).map(k =>\n `src=${k.src} dst=${k.dst} req_type=${k.req_type} client=\"${k.client}\" service=\"${k.service}\" success=${k.success_count} fail=${k.fail_count} count=${k.count} first=${k.first_seen} last=${k.last_seen}`\n);\n\n// \u2500\u2500 8. RDP.LOG \u2014 aggregate by src+dst \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst rdpAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'rdp.log')) {\n const s = h._source;\n const z = s.zeek?.rdp || s.rdp || {};\n const key = `${s.source?.ip}->${s.destination?.ip}`;\n if (!rdpAgg[key]) {\n rdpAgg[key] = {\n src: s.source?.ip || '?',\n dst: s.destination?.ip || '?',\n dst_port: s.destination?.port || '?',\n cookie: z.cookie || '',\n success_count: 0,\n fail_count: 0,\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const r = rdpAgg[key];\n r.count++;\n if (z.auth_success === true) r.success_count++;\n if (z.auth_success === false) r.fail_count++;\n if (s['@timestamp'] < r.first_seen) r.first_seen = s['@timestamp'];\n if (s['@timestamp'] > r.last_seen) r.last_seen = s['@timestamp'];\n}\nconst rdpLines = Object.values(rdpAgg).map(r =>\n `src=${r.src} dst=${r.dst}:${r.dst_port} cookie=\"${r.cookie}\" auth_success=${r.success_count} auth_fail=${r.fail_count} count=${r.count} first=${r.first_seen} last=${r.last_seen}`\n);\n\n// \u2500\u2500 9. SMB_FILES.LOG \u2014 aggregate by src+dst+action \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst smbAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'smb_files.log')) {\n const s = h._source;\n const z = s.zeek?.smb_files || s.smb || {};\n const key = `${s.source?.ip}->${s.destination?.ip}|${z.action || '?'}`;\n if (!smbAgg[key]) {\n smbAgg[key] = {\n src: s.source?.ip || '?',\n dst: s.destination?.ip || '?',\n action: z.action || '?',\n paths: new Set(),\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const sm = smbAgg[key];\n sm.count++;\n if (z.path) sm.paths.add(z.path);\n if (s['@timestamp'] < sm.first_seen) sm.first_seen = s['@timestamp'];\n if (s['@timestamp'] > sm.last_seen) sm.last_seen = s['@timestamp'];\n}\nconst smbLines = Object.values(smbAgg).map(sm =>\n `src=${sm.src} dst=${sm.dst} action=${sm.action} paths=[${[...sm.paths].slice(0,3).join(',')}] count=${sm.count} first=${sm.first_seen} last=${sm.last_seen}`\n);\n\n// \u2500\u2500 10. DCE_RPC.LOG \u2014 aggregate by src+dst+endpoint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst rpcAgg = {};\nfor (const h of zeekHits.filter(h => h._source.container?.id === 'dce_rpc.log')) {\n const s = h._source;\n const z = s.zeek?.dce_rpc || s.dce_rpc || {};\n const key = `${s.source?.ip}->${s.destination?.ip}|${z.endpoint || '?'}|${z.operation || '?'}`;\n if (!rpcAgg[key]) {\n rpcAgg[key] = {\n src: s.source?.ip || '?',\n dst: s.destination?.ip || '?',\n endpoint: z.endpoint || '?',\n operation: z.operation || '?',\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n const rp = rpcAgg[key];\n rp.count++;\n if (s['@timestamp'] < rp.first_seen) rp.first_seen = s['@timestamp'];\n if (s['@timestamp'] > rp.last_seen) rp.last_seen = s['@timestamp'];\n}\nconst rpcLines = Object.values(rpcAgg).map(rp =>\n `src=${rp.src} dst=${rp.dst} endpoint=\"${rp.endpoint}\" operation=\"${rp.operation}\" count=${rp.count} first=${rp.first_seen} last=${rp.last_seen}`\n);\n\n// \u2500\u2500 Assemble final text \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sections = [\n { name: 'notice', lines: noticeLines },\n { name: 'weird', lines: weirdLines },\n { name: 'conn', lines: connLines },\n { name: 'dns', lines: dnsLines },\n { name: 'http', lines: httpLines },\n { name: 'ssl', lines: sslLines },\n { name: 'kerberos', lines: kerbLines },\n { name: 'rdp', lines: rdpLines },\n { name: 'smb_files', lines: smbLines },\n { name: 'dce_rpc', lines: rpcLines },\n].filter(sec => sec.lines.length > 0);\n\nconst zeekText = sections.length === 0\n ? 'No relevant Zeek logs in this time window.'\n : sections.map(sec =>\n `=== ${sec.name} (${sec.lines.length} unique flows) ===\\n` + sec.lines.join('\\n')\n ).join('\\n\\n');\n\nreturn [{ json: {\n source: 'zeek',\n event_group: 'all',\n logs: zeekText,\n _raw_logs: zeekText,\n}}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
368,
1008
],
"id": "9094c831-dbc6-4a24-b9cb-dcd302d9f9b9",
"name": "Format & Clustering Zeek"
},
{
"parameters": {
"jsCode": "// \u2500\u2500 FORMAT SURICATA NODE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Aggregates Suricata alerts before passing to Suricata Triage Agent\n\nconst suricataHits = $input.first().json.hits?.hits || [];\n\nif (suricataHits.length === 0) {\n return [{ json: { source: 'suricata', logs: 'No Suricata alerts in this time window.' } }];\n}\n\n// \u2500\u2500 Aggregate by src_ip + dst_ip + dst_port + signature \u2500\u2500\u2500\u2500\u2500\u2500\nconst agg = {};\n\nfor (const h of suricataHits) {\n const s = h._source;\n const signature = s.rule?.name || '?';\n const key = `${s.source?.ip}->${s.destination?.ip}:${s.destination?.port}|${signature}`;\n\n if (!agg[key]) {\n agg[key] = {\n src: s.source?.ip || '?',\n src_port: s.source?.port || '?',\n dst: s.destination?.ip || '?',\n dst_port: s.destination?.port || '?',\n proto: s.network?.transport || '?',\n app_proto: s.network?.protocol || '',\n signature,\n category: s.rule?.category || '?',\n severity: s.rule?.severity || 99,\n count: 0,\n first_seen: s['@timestamp'],\n last_seen: s['@timestamp'],\n };\n }\n\n const a = agg[key];\n a.count++;\n\n // Keep highest severity (lowest number = most severe in Suricata)\n if ((s.rule?.severity || 99) < a.severity) {\n a.severity = s.rule?.severity;\n }\n\n if (s['@timestamp'] < a.first_seen) a.first_seen = s['@timestamp'];\n if (s['@timestamp'] > a.last_seen) a.last_seen = s['@timestamp'];\n}\n\n// \u2500\u2500 Sort by severity then count \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sorted = Object.values(agg).sort((a, b) => {\n if (a.severity !== b.severity) return a.severity - b.severity;\n return b.count - a.count;\n});\n\n// \u2500\u2500 Summary stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst totalAlerts = suricataHits.length;\nconst uniqueFlows = sorted.length;\nconst uniqueSrcIPs = new Set(sorted.map(a => a.src)).size;\nconst criticalCount = sorted.filter(a => a.severity === 1).length;\nconst highCount = sorted.filter(a => a.severity === 2).length;\nconst mediumCount = sorted.filter(a => a.severity === 3).length;\n\nconst summary = [\n `Total alerts: ${totalAlerts}`,\n `Unique src IPs: ${uniqueSrcIPs}`,\n `Unique flow+signature combos: ${uniqueFlows}`,\n `By severity \u2014 Critical(1): ${criticalCount} High(2): ${highCount} Medium(3): ${mediumCount}`,\n].join(' | ');\n\n// \u2500\u2500 Format each aggregated alert \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst alertLines = sorted.map(a => {\n const appProto = a.app_proto ? ` app=${a.app_proto}` : '';\n return `src=${a.src} dst=${a.dst}:${a.dst_port} proto=${a.proto}${appProto} severity=${a.severity} count=${a.count} signature=\"${a.signature}\" category=\"${a.category}\" first=${a.first_seen} last=${a.last_seen}`;\n});\n\nconst suricataText = `=== SUMMARY ===\\n${summary}\\n\\n=== ALERTS (aggregated, sorted by severity) ===\\n${alertLines.join('\\n')}`;\n\n// \u2500\u2500 \u5728\u73b0\u6709\u805a\u5408\u4ee3\u7801\u672b\u5c3e\u66ff\u6362 return \u8bed\u53e5 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n// \u7edf\u8ba1\u6bcf\u4e2a dst_ip \u88ab\u591a\u5c11\u4e2a\u4e0d\u540c src_ip \u653b\u51fb\nconst dstSrcCount = {};\nfor (const a of Object.values(agg)) {\n if (!dstSrcCount[a.dst]) dstSrcCount[a.dst] = new Set();\n dstSrcCount[a.dst].add(a.src);\n}\nconst DDOS_THRESHOLD = 5;\nconst ddosTargets = new Set(\n Object.entries(dstSrcCount)\n .filter(([dst, srcs]) => srcs.size >= DDOS_THRESHOLD)\n .map(([dst]) => dst)\n);\n\n// \u6309\u4e8b\u4ef6\u5206\u7ec4\nconst eventMap = {};\nfor (const a of sorted) {\n let groupKey, groupType;\n if (ddosTargets.has(a.dst)) {\n groupKey = `ddos-${a.dst}`;\n groupType = 'ddos';\n } else {\n groupKey = `src-${a.src}`;\n groupType = 'single_source';\n }\n\n if (!eventMap[groupKey]) {\n eventMap[groupKey] = {\n group_type: groupType,\n src_ips: new Set(),\n dst_ips: new Set(),\n min_severity: a.severity,\n alert_count: 0,\n alert_lines: [],\n };\n }\n const ev = eventMap[groupKey];\n ev.src_ips.add(a.src);\n ev.dst_ips.add(a.dst);\n ev.alert_count += a.count;\n if (a.severity < ev.min_severity) ev.min_severity = a.severity;\n ev.alert_lines.push(\n `src=${a.src} dst=${a.dst}:${a.dst_port} proto=${a.proto} severity=${a.severity} count=${a.count} signature=\"${a.signature}\" category=\"${a.category}\" first=${a.first_seen} last=${a.last_seen}`\n );\n}\n\n// \u6bcf\u4e2a\u4e8b\u4ef6\u8f93\u51fa\u4e00\u4e2a item\nif (Object.keys(eventMap).length === 0) {\n return [{ json: { source: 'suricata', logs: 'No Suricata alerts.', event_group: 'none', needsEscalation: false } }];\n}\n\nreturn Object.entries(eventMap).map(([groupKey, ev]) => ({\n json: {\n source: 'suricata',\n event_group: groupKey,\n group_type: ev.group_type,\n src_ips: [...ev.src_ips].join(', '),\n dst_ips: [...ev.dst_ips].join(', '),\n src_ip_count: ev.src_ips.size,\n dst_ip_count: ev.dst_ips.size,\n alert_count: ev.alert_count,\n min_severity: ev.min_severity,\n needsEscalation: true,\n logs: `=== SURICATA ALERTS (${ev.group_type}) ===\\n` +\n `src_ips: ${[...ev.src_ips].join(', ')} | dst_ips: ${[...ev.dst_ips].join(', ')} | total_alerts: ${ev.alert_count}\\n\\n` +\n ev.alert_lines.join('\\n'),\n _raw_logs: ev.alert_lines.join('\\n'),\n }\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
368,
608
],
"id": "07df1c50-7452-4f0e-96d2-fea36eba8e6f",
"name": "Format & Clustering Suricata"
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst zeekItem = items.find(i => i.json.source === 'zeek');\nconst suricataItems = items.filter(i => i.json.source === 'suricata');\n\n// Get raw logs from _raw_logs field (set in Format & Clustering nodes)\nconst zeekLogs = zeekItem?.json._raw_logs || zeekItem?.json.logs || 'No Zeek logs.';\nconst zeekLines = zeekLogs.split('\\n');\n\nif (suricataItems.length === 0) {\n return [{ json: { needsEscalation: false, summary: 'All benign.' } }];\n}\n\nconst internalRe = /^(10\\.|172\\.(1[6-9]|2\\d|3[01])\\.|192\\.168\\.|127\\.|169\\.254\\.)/;\n\nconst BENIGN_RANGES = [\n /^20\\.(1[89]|[2-9]\\d|1[0-9]{2}|2[0-4]\\d|25[0-5])\\./,\n /^23\\.(5[6-9]|[6-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\./,\n /^104\\.(1[2-9]\\d|2[0-4]\\d|25[0-5])\\./,\n /^13\\./,\n /^52\\./,\n /^54\\./,\n];\n\nconst isBenignIP = (ip) => BENIGN_RANGES.some(re => re.test(ip));\n\nconst toList = (val) => {\n if (!val) return [];\n if (Array.isArray(val)) return val;\n if (typeof val === 'string') {\n try { const p = JSON.parse(val); if (Array.isArray(p)) return p; } catch(e) {}\n return val.split('\\n').map(s => s.trim()).filter(Boolean);\n }\n return [];\n};\n\n// Step 1: Build initial events from suricata items\nconst events = suricataItems.map((item, idx) => {\n const s = item.json;\n\n // Use _raw_logs (set in Format & Clustering Suricata) as the raw alert text\n const rawSuricataAlerts = s._raw_logs || s.logs || '';\n\n const text = JSON.stringify(s.indicators || []) + ' ' + (s.reasoning || '');\n const ipMatches = text.match(/\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b/g) || [];\n const uniqueIPs = [...new Set(ipMatches)];\n\n let srcIPs = uniqueIPs.filter(ip => !internalRe.test(ip) && !isBenignIP(ip));\n let dstIPs = uniqueIPs.filter(ip => internalRe.test(ip));\n\n // Fallback: extract from raw alerts if reasoning yielded nothing\n if (srcIPs.length === 0) {\n const alertIPs = [...new Set(rawSuricataAlerts.match(/\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b/g) || [])];\n srcIPs = alertIPs.filter(ip => !internalRe.test(ip) && !isBenignIP(ip));\n dstIPs = alertIPs.filter(ip => internalRe.test(ip));\n }\n\n const allIPs = [...new Set([...srcIPs, ...dstIPs])];\n\n const relatedZeek = zeekLines.filter(line =>\n allIPs.some(ip =>\n line.includes(`src=${ip}`) ||\n line.includes(`src=${ip}(`) ||\n line.includes(`dst=${ip}:`) ||\n line.includes(`dst=${ip} `)\n )\n );\n\n return {\n event_id: `EVT-${Date.now()}-${idx}`,\n source: 'suricata',\n verdict: s.verdict,\n confidence: s.confidence,\n indicators: toList(s.indicators),\n reasoning: s.reasoning || '',\n mitre_techniques: toList(s.mitre_techniques),\n src_ips_set: new Set(srcIPs),\n dst_ips_set: new Set(dstIPs),\n alert_count: s.alert_count || 1,\n min_severity: s.min_severity || 99,\n suricata_alerts: rawSuricataAlerts,\n zeek_logs: relatedZeek.length > 0 ? relatedZeek.join('\\n') : 'No correlated Zeek logs.',\n needsEscalation: s.verdict === 'malicious' || s.verdict === 'suspicious',\n };\n});\n\n// Step 2: Merge events sharing dst IPs (same victim = same incident)\nconst used = new Set();\nconst merged = [];\n\nfor (let i = 0; i < events.length; i++) {\n if (used.has(i)) continue;\n\n const base = {\n ...events[i],\n src_ips_set: new Set(events[i].src_ips_set),\n dst_ips_set: new Set(events[i].dst_ips_set),\n indicators: [...events[i].indicators],\n mitre_techniques: [...events[i].mitre_techniques],\n };\n\n for (let j = i + 1; j < events.length; j++) {\n if (used.has(j)) continue;\n const other = events[j];\n\n const overlap = [...base.dst_ips_set].some(ip => other.dst_ips_set.has(ip));\n if (overlap) {\n other.src_ips_set.forEach(ip => base.src_ips_set.add(ip));\n other.dst_ips_set.forEach(ip => base.dst_ips_set.add(ip));\n\n base.alert_count += other.alert_count;\n base.min_severity = Math.min(base.min_severity, other.min_severity);\n base.needsEscalation = base.needsEscalation || other.needsEscalation;\n\n // Merge suricata alerts and zeek logs (append, not overwrite)\n base.suricata_alerts += '\\n' + other.suricata_alerts;\n base.zeek_logs += '\\n' + other.zeek_logs;\n\n // Merge indicators (deduplicated)\n toList(other.indicators).forEach(ind => {\n if (!base.indicators.includes(ind)) base.indicators.push(ind);\n });\n\n // Merge reasoning (append with source label)\n if (other.reasoning) {\n base.reasoning += `\\n\\n[Correlated event from ${[...other.src_ips_set].join(', ')}]\\n` + other.reasoning;\n }\n\n // Merge MITRE techniques (deduplicated by ID)\n const existingIds = new Set(base.mitre_techniques.map(t => t.id));\n toList(other.mitre_techniques).forEach(t => {\n if (t.id && !existingIds.has(t.id)) {\n base.mitre_techniques.push(t);\n existingIds.add(t.id);\n }\n });\n\n // Keep highest confidence verdict\n if ((other.confidence || 0) > (base.confidence || 0)) {\n base.confidence = other.confidence;\n base.verdict = other.verdict;\n }\n\n used.add(j);\n }\n }\n\n merged.push(base);\n used.add(i);\n}\n\n// Step 3: Finalise and output\nreturn merged.map(ev => ({\n json: {\n event_id: ev.event_id,\n source: 'suricata',\n verdict: ev.verdict,\n confidence: ev.confidence,\n indicators: ev.indicators,\n reasoning: ev.reasoning,\n mitre_techniques: ev.mitre_techniques,\n src_ips: [...ev.src_ips_set].join(', ') || 'unknown',\n dst_ips: [...ev.dst_ips_set].join(', ') || 'unknown',\n src_ip_count: ev.src_ips_set.size,\n dst_ip_count: ev.dst_ips_set.size,\n alert_count: ev.alert_count,\n min_severity: ev.min_severity,\n group_type: ev.src_ips_set.size > 1 ? 'multi_source' : 'single_source',\n suricata_alerts: ev.suricata_alerts,\n zeek_logs: ev.zeek_logs,\n needsEscalation: ev.needsEscalation,\n }\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1392,
800
],
"id": "2bccaa2c-506a-4377-8b33-247c60395c2e",
"name": "Event Grouping"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
944,
800
],
"id": "709f4ecc-a5e6-4d36-b699-7a5845daf3fe",
"name": "Merge"
},
{
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:3001/api/reports",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "incident_id",
"value": "={{ $json.incidentId }}"
},
{
"name": "severity",
"value": "={{ $json.severity }}"
},
{
"name": "verdict",
"value": "={{ $json.verdict }}"
},
{
"name": "group_type",
"value": "={{ $json.groupType }}"
},
{
"name": "response",
"value": "={{ $json.response }}"
},
{
"name": "saved_path",
"value": "={{ $json.savedPath }}"
},
{
"name": "timestamp",
"value": "={{ $now.toISO() }}"
},
{
"name": "suricata_alerts",
"value": "={{ $json.suricata_alerts }}"
},
{
"name": "zeek_logs",
"value": "={{ $json.zeek_logs }}"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2192,
800
],
"id": "979a2309-547a-4c03-8b50-186c4a811878",
"name": "HTTP Request"
}
],
"connections": {
"Parse Agent Output": {
"main": [
[
{
"node": "Event Grouping",
"type": "main",
"index": 0
}
]
]
},
"Google Gemini Chat Model2": {
"ai_languageModel": [
[
{
"node": "Central Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Central Agent": {
"main": [
[
{
"node": "Format Report",
"type": "main",
"index": 0
}
]
]
},
"Format Report": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
},
"Google Gemini Chat Model": {
"ai_languageModel": [
[
{
"node": "Suricata Triage Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Google Gemini Chat Model3": {
"ai_languageModel": [
[
{
"node": "Zeek Triage Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Suricata Triage Agent": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Zeek Triage Agent": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Build Time Range",
"type": "main",
"index": 0
}
]
]
},
"Build Time Range": {
"main": [
[
{
"node": "Get Zeek Logs",
"type": "main",
"index": 0
},
{
"node": "Get Suricata Alerts",
"type": "main",
"index": 0
}
]
]
},
"Get Zeek Logs": {
"main": [
[
{
"node": "Format & Clustering Zeek",
"type": "main",
"index": 0
}
]
]
},
"Get Suricata Alerts": {
"main": [
[
{
"node": "Format & Clustering Suricata",
"type": "main",
"index": 0
}
]
]
},
"Format & Clustering Zeek": {
"main": [
[
{
"node": "Zeek Triage Agent",
"type": "main",
"index": 0
}
]
]
},
"Format & Clustering Suricata": {
"main": [
[
{
"node": "Suricata Triage Agent",
"type": "main",
"index": 0
}
]
]
},
"Event Grouping": {
"main": [
[
{
"node": "Central Agent",
"type": "main",
"index": 0
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Parse Agent Output",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "613713dc-d27f-450a-8db6-000538eec59d",
"meta": {
"templateCredsSetupCompleted": true
},
"id": "24m1UtXdhLUhjB0m",
"tags": []
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googlePalmApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
llm-network-triage. Uses agent, lmChatGoogleGemini, httpRequest. Scheduled trigger; 17 nodes.
Source: https://github.com/Flora1003Xu/Network-Traffic-Triage-System/blob/cba8c228b9f21900f641009b8053a4c6171ed309/n8n/n8n-workflow.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
LinkedIn_Job_Hunt_and_Cover_Letter. Uses outputParserStructured, outputParserAutofixing, googleDrive, agent. Scheduled trigger; 85 nodes.
Automatically scan major financial newswires for biotech catalyst events, score them with AI sentiment analysis, and surface ranked trade candidates — all without manual monitoring.
Author: Nguyen Thieu Toan Category: Community & Knowledge Automation Tags: Telegram, Reddit, n8n Forum, AI Summarization, Gemini, Groq
This workflow is for beauty salons who want consistent, high‑quality social media content without writing every post manually. It also suits agencies and automation builders who manage multiple beauty
The Multi-Model Agency Content Engine is a high-performance editorial system designed for agencies. It solves the "blank page" problem by alternating between real-world social proof and strategic expe