[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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b8ac793c8f
commit
3d0bec1f7f
@@ -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**.
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user