#!/usr/bin/env node /** * NIS2 Agile — Agente di Discovery (riferimento) * * Scansiona uno o più CIDR/IP della rete del cliente (TCP-connect, no root), * deduce gli asset e i flussi di rete, e li POSTa a NIS2 che li auto-popola * nell'Inventario (con scoring rilevanza NIS2) + Mappa Dipendenze + flussi ID.AM-03. * * Node 18+ (usa fetch/net/dns nativi). NESSUNA dipendenza npm. * * USO: * 1) In NIS2: Connettori Discovery → crea connettore → copia l'API key. * 2) Esegui sull'host del cliente (dentro la rete da mappare): * NIS2_API_KEY=nis2_xxxx TARGETS=192.168.1.0/24 node discovery-agent.mjs * * VARIABILI: * NIS2_API_KEY (obbligatoria) chiave del connettore (header X-API-Key) * NIS2_API_URL default https://nis2.agile.software * TARGETS CIDR/IP separati da virgola, es. "192.168.1.0/24,10.0.0.5" * PORTS porte TCP, default "22,80,443,445,3306,3389,5432,8080,8443" * SOURCE etichetta provenienza, default "network" * TIMEOUT_MS timeout per porta, default 400 * MAX_HOSTS tetto host scansionati, default 1024 * CONCURRENCY socket paralleli, default 64 * DRY_RUN "1" = non invia, stampa solo cosa troverebbe */ import net from 'node:net'; import dns from 'node:dns/promises'; import os from 'node:os'; const CFG = { apiKey: process.env.NIS2_API_KEY || '', apiUrl: (process.env.NIS2_API_URL || 'https://nis2.agile.software').replace(/\/+$/, ''), targets: (process.env.TARGETS || '').split(',').map(s => s.trim()).filter(Boolean), ports: (process.env.PORTS || '22,80,443,445,3306,3389,5432,8080,8443').split(',').map(p => parseInt(p, 10)).filter(Boolean), source: process.env.SOURCE || 'network', timeoutMs: parseInt(process.env.TIMEOUT_MS || '400', 10), maxHosts: parseInt(process.env.MAX_HOSTS || '1024', 10), concurrency: parseInt(process.env.CONCURRENCY || '64', 10), dryRun: process.env.DRY_RUN === '1', }; function die(msg) { console.error('ERRORE: ' + msg); process.exit(1); } if (!CFG.dryRun && !CFG.apiKey) die('NIS2_API_KEY mancante'); if (!CFG.targets.length) die('TARGETS mancante (es. TARGETS=192.168.1.0/24)'); // ── Espansione CIDR/IP → lista host ────────────────────────────────────────── function ipToInt(ip) { return ip.split('.').reduce((a, o) => (a << 8 >>> 0) + (parseInt(o, 10) & 255), 0) >>> 0; } function intToIp(n) { return [n >>> 24 & 255, n >>> 16 & 255, n >>> 8 & 255, n & 255].join('.'); } function expand(target) { if (!target.includes('/')) return [target]; const [base, bitsStr] = target.split('/'); const bits = parseInt(bitsStr, 10); if (bits < 8 || bits > 32) return []; const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; const start = (ipToInt(base) & mask) >>> 0; const count = 2 ** (32 - bits); const hosts = []; // salta network e broadcast per /<=30 const lo = bits <= 30 ? 1 : 0; const hi = bits <= 30 ? count - 1 : count; for (let i = lo; i < hi; i++) hosts.push(intToIp((start + i) >>> 0)); return hosts; } function isPrivate(ip) { const n = ipToInt(ip); return (n >= ipToInt('10.0.0.0') && n <= ipToInt('10.255.255.255')) || (n >= ipToInt('172.16.0.0') && n <= ipToInt('172.31.255.255')) || (n >= ipToInt('192.168.0.0') && n <= ipToInt('192.168.255.255')) || (n >= ipToInt('127.0.0.0') && n <= ipToInt('127.255.255.255')) || // loopback (n >= ipToInt('169.254.0.0') && n <= ipToInt('169.254.255.255')); // link-local } let hosts = [...new Set(CFG.targets.flatMap(expand))]; if (hosts.length > CFG.maxHosts) { console.warn(`⚠️ ${hosts.length} host > MAX_HOSTS=${CFG.maxHosts}: scansiono i primi ${CFG.maxHosts}.`); hosts = hosts.slice(0, CFG.maxHosts); } // ── Scan TCP-connect di un host (ritorna porte aperte) ─────────────────────── function probe(ip, port) { return new Promise(resolve => { const sock = new net.Socket(); let done = false; const finish = ok => { if (done) return; done = true; sock.destroy(); resolve(ok); }; sock.setTimeout(CFG.timeoutMs); sock.once('connect', () => finish(true)); sock.once('timeout', () => finish(false)); sock.once('error', () => finish(false)); sock.connect(port, ip); }); } async function scanHost(ip) { const open = []; for (const p of CFG.ports) { if (await probe(ip, p)) open.push(p); } return open; } // pool di concorrenza async function pool(items, worker, n) { const out = []; let idx = 0; const runners = Array.from({ length: Math.min(n, items.length) }, async () => { while (idx < items.length) { const i = idx++; out[i] = await worker(items[i], i); } }); await Promise.all(runners); return out; } // ── Euristiche tipo asset dalle porte ──────────────────────────────────────── function guessType(ports) { if (ports.includes(3306) || ports.includes(5432)) return 'database'; if (ports.includes(3389)) return 'workstation'; if (ports.includes(80) || ports.includes(443) || ports.includes(8080) || ports.includes(8443)) return 'server'; if (ports.includes(445)) return 'server'; return 'device'; } function guessCrit(ports, pub) { if (ports.includes(3306) || ports.includes(5432)) return 'high'; if (pub) return 'high'; if (ports.length >= 3) return 'medium'; return 'low'; } (async () => { const selfIp = (Object.values(os.networkInterfaces()).flat().find(i => i && i.family === 'IPv4' && !i.internal) || {}).address || 'agent-host'; console.log(`🔎 Discovery: ${hosts.length} host · porte [${CFG.ports.join(',')}] · timeout ${CFG.timeoutMs}ms · source=${CFG.source}`); const results = await pool(hosts, async ip => ({ ip, open: await scanHost(ip) }), CFG.concurrency); const up = results.filter(r => r.open.length); console.log(`✅ Host attivi: ${up.length}/${hosts.length}`); const assets = []; const flows = []; for (const { ip, open } of up) { let name = ip; try { const r = await dns.reverse(ip); if (r && r[0]) name = r[0]; } catch { /* no PTR */ } const pub = !isPrivate(ip); assets.push({ name, asset_type: guessType(open), ip_address: ip, external_ref: `net:${ip}`, criticality: guessCrit(open, pub), internet_facing: pub ? 1 : 0, description: `Rilevato da discovery agent — porte aperte: ${open.join(', ')}`, }); for (const port of open) { flows.push({ src: selfIp, dst: ip, port, protocol: 'tcp', direction: 'internal', external_ref: `net:${selfIp}>${ip}:${port}` }); } } if (!assets.length) { console.log('Nessun host attivo trovato. Fine.'); return; } if (CFG.dryRun) { console.log(`DRY_RUN — invierei ${assets.length} asset e ${flows.length} flussi:`); console.log(JSON.stringify({ source: CFG.source, assets, flows }, null, 2)); return; } const res = await fetch(`${CFG.apiUrl}/api/services/assets-ingest`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': CFG.apiKey }, body: JSON.stringify({ source: CFG.source, assets, flows }), }); const txt = await res.text(); if (res.ok) { let d = {}; try { d = JSON.parse(txt).data || {}; } catch { /* */ } console.log(`📤 Inviato a NIS2: HTTP ${res.status} — importati ${d.imported ?? '?'}, aggiornati ${d.updated ?? '?'}, rilevanti NIS2 ${d.relevant ?? '?'}, flussi ${d.flows_ingested ?? '?'}.`); } else { console.error(`❌ Ingest fallito: HTTP ${res.status} — ${txt.slice(0, 300)}`); process.exit(1); } })();