{
  "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": []
}