From 3d0bec1f7f33f4f704a1a1ddaa9e3e36a99b4193 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Fri, 26 Jun 2026 11:45:13 +0200 Subject: [PATCH] =?UTF-8?q?[FEAT]=20Discovery=20agent=20di=20riferimento?= =?UTF-8?q?=20(scan=20rete=20=E2=86=92=20auto-popola=20NIS2)=20=E2=80=94?= =?UTF-8?q?=20Fase=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/discovery-agent.mjs (Node 18+, zero dipendenze): scansiona CIDR/IP via TCP-connect (no root), reverse-DNS, deduce tipo asset + criticità dalle porte aperte, costruisce asset (external_ref=net:) + flussi di rete e li POSTa a /api/services/assets-ingest col X-API-Key del connettore. Concorrenza, MAX_HOSTS, DRY_RUN, isPrivate (RFC1918+loopback+link-local). + README_discovery_agent.md (uso, variabili, cron, multi-connettore, estensione cloud, sicurezza). Validato: node --check + DRY_RUN reale (listener temporaneo) → rileva porta aperta, costruisce asset+flusso corretti. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/README_discovery_agent.md | 51 +++++++++ scripts/discovery-agent.mjs | 173 ++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 scripts/README_discovery_agent.md create mode 100644 scripts/discovery-agent.mjs diff --git a/scripts/README_discovery_agent.md b/scripts/README_discovery_agent.md new file mode 100644 index 0000000..d1ff804 --- /dev/null +++ b/scripts/README_discovery_agent.md @@ -0,0 +1,51 @@ +# Agente di Discovery NIS2 (`discovery-agent.mjs`) + +Mappa automaticamente la rete del cliente e **auto-popola** l'Inventario NIS2 +(asset + scoring rilevanza NIS2 + Mappa Dipendenze + flussi di rete ID.AM-03). +Node 18+, **nessuna dipendenza** (usa `fetch`/`net`/`dns` nativi). + +## Come funziona +1. In NIS2 (utente org_admin): **Connettori Discovery → Nuovo connettore** → copia l'**API key** (mostrata una sola volta). +2. Esegui l'agente **dentro la rete da mappare** (un host qualunque del cliente): + ```bash + NIS2_API_KEY=nis2_xxxxxxxx TARGETS=192.168.1.0/24 node discovery-agent.mjs + ``` +3. L'agente scansiona i target (TCP-connect, no root), deduce host/porte/tipo asset e li + invia a `POST /api/services/assets-ingest`. NIS2 fa dedup (per IP), scoring e tracciamento + del run (visibile nel dettaglio del connettore). + +## Variabili +| Var | Default | Note | +|---|---|---| +| `NIS2_API_KEY` | — (obbligatoria) | chiave del connettore | +| `NIS2_API_URL` | `https://nis2.agile.software` | | +| `TARGETS` | — (obbligatoria) | CIDR/IP separati da virgola: `192.168.1.0/24,10.0.0.5` | +| `PORTS` | `22,80,443,445,3306,3389,5432,8080,8443` | porte TCP da sondare | +| `SOURCE` | `network` | etichetta provenienza (`network`/`aws`/…) | +| `TIMEOUT_MS` | `400` | timeout per porta | +| `MAX_HOSTS` | `1024` | tetto host per esecuzione | +| `CONCURRENCY` | `64` | socket paralleli | +| `DRY_RUN` | — | `1` = non invia, stampa solo l'anteprima JSON | + +## Esempi +```bash +# Anteprima senza inviare +DRY_RUN=1 TARGETS=10.0.0.0/28 node discovery-agent.mjs + +# Scansione + invio, porte e timeout custom +NIS2_API_KEY=nis2_xxx TARGETS=192.168.10.0/24 PORTS=22,80,443 TIMEOUT_MS=600 node discovery-agent.mjs + +# Pianificato (cron, ogni notte) +0 2 * * * NIS2_API_KEY=nis2_xxx TARGETS=192.168.0.0/23 node /opt/nis2/discovery-agent.mjs >> /var/log/nis2-discovery.log 2>&1 +``` + +## Più connettori per azienda +Crea un connettore per ciascuna sorgente (es. *Rete sede Roma*, *Rete sede Modena*, *AWS prod*): +ognuno ha la sua API key e il suo storico run. L'agente di rete copre i CIDR; per il cloud +(`aws`/`azure`/`gcp`) si estende lo stesso schema elencando le istanze via SDK del provider e +inviandole con lo stesso formato `assets-ingest` (`source: "aws"`, `external_ref: "aws:i-..."`). + +## Sicurezza +- L'API key ha **solo** scope `ingest:assets` (può solo aggiungere asset/flussi alla **sua** org). +- Esegui l'agente da un host fidato; ruota la chiave dal connettore se compromessa (**Rigenera chiave**). +- Scansiona **solo reti di tua proprietà / autorizzate**. diff --git a/scripts/discovery-agent.mjs b/scripts/discovery-agent.mjs new file mode 100644 index 0000000..970ebd4 --- /dev/null +++ b/scripts/discovery-agent.mjs @@ -0,0 +1,173 @@ +#!/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); + } +})();