Files
nis2-agile/scripts/discovery-agent.mjs
T
DevEnv nis2-agileandClaude Opus 4.8 3d0bec1f7f [FEAT] Discovery agent di riferimento (scan rete → auto-popola NIS2) — Fase 3
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:<ip>) + 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) <noreply@anthropic.com>
2026-06-26 11:45:13 +02:00

174 lines
7.5 KiB
JavaScript

#!/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);
}
})();