#!/usr/bin/env node /** * NIS2 Agile — Connettore di Discovery CLOUD AWS * * Elenca le risorse di un account AWS (EC2 / RDS / S3 + Security Group) e le * mappa in asset NIS2, poi le POSTa a NIS2 che le auto-popola nell'Inventario * (con scoring rilevanza NIS2) + Mappa Dipendenze + flussi ID.AM-03. * * Parallelo a `discovery-agent.mjs` (rete), ma per il CLOUD: invece di * scansionare CIDR via TCP, interroga le API AWS tramite la AWS CLI già * installata e configurata sull'host (catena credenziali standard). * * Node 18+ (usa fetch nativo). NESSUNA dipendenza npm. * Richiede la AWS CLI (`aws`) nel PATH e credenziali read-only valide. * * USO: * 1) In NIS2: Connettori Discovery → crea connettore (es. "AWS prod") → * copia l'API key. * 2) Configura le credenziali AWS (read-only) sull'host, ad es.: * export AWS_PROFILE=nis2-readonly (oppure AWS_ACCESS_KEY_ID/…) * export AWS_REGION=eu-south-1 * 3) Esegui: * NIS2_API_KEY=nis2_xxxx node discovery-agent-aws.mjs * * PERMESSI IAM MINIMI (read-only): * ec2:Describe* · rds:Describe* · s3:ListAllMyBuckets * * VARIABILI: * NIS2_API_KEY (obbligatoria, tranne in DRY_RUN) chiave del connettore (X-API-Key) * NIS2_API_URL default https://nis2.agile.software * SOURCE etichetta provenienza, default "aws" * RESOURCES csv risorse da raccogliere, default "ec2,rds,s3" (es. "ec2,s3") * AWS_REGION regione AWS (passata a --region; default = config CLI) * MAX_ITEMS tetto asset per esecuzione, default 2000 * DRY_RUN "1" = NON invia, stampa solo l'anteprima JSON */ import { execFile } from 'node:child_process'; const CFG = { apiKey: process.env.NIS2_API_KEY || '', apiUrl: (process.env.NIS2_API_URL || 'https://nis2.agile.software').replace(/\/+$/, ''), source: process.env.SOURCE || 'aws', resources: (process.env.RESOURCES || 'ec2,rds,s3').split(',').map(s => s.trim().toLowerCase()).filter(Boolean), region: (process.env.AWS_REGION || '').trim(), maxItems: parseInt(process.env.MAX_ITEMS || '2000', 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 (oppure usa DRY_RUN=1 per l\'anteprima).'); if (!CFG.resources.length) die('RESOURCES vuoto (es. RESOURCES=ec2,rds,s3).'); // ── Esecuzione di un comando AWS CLI con output JSON ───────────────────────── // Ritorna l'oggetto JSON parsato. In caso di errore lancia con messaggio chiaro. function awsJson(args) { const full = [...args, '--output', 'json']; if (CFG.region) full.push('--region', CFG.region); return new Promise((resolve, reject) => { execFile('aws', full, { maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => { if (err) { // ENOENT = la AWS CLI non è installata / non è nel PATH. if (err.code === 'ENOENT') { return reject(new Error("AWS CLI ('aws') non trovata nel PATH. Installa/configura la AWS CLI v2.")); } const detail = (stderr || err.message || '').toString().trim().split('\n').slice(0, 3).join(' '); return reject(new Error(`comando "aws ${args.join(' ')}" fallito: ${detail || 'errore sconosciuto'}`)); } const out = (stdout || '').trim(); if (!out) return resolve({}); try { resolve(JSON.parse(out)); } catch { reject(new Error(`output non-JSON da "aws ${args.join(' ')}"`)); } }); }); } // ── Helper estrazione tag/valori ───────────────────────────────────────────── function tagValue(tags, key) { if (!Array.isArray(tags)) return null; const t = tags.find(x => x && (x.Key === key || x.key === key)); return t ? (t.Value ?? t.value ?? null) : null; } function critEc2(instanceType, state) { // Euristica criticità: istanze grandi o in esecuzione = più critiche. if (state !== 'running') return 'low'; const t = (instanceType || '').toLowerCase(); if (/\.(\d*x?large|xlarge|metal)$/.test(t) || /\b(\dx?large|metal)\b/.test(t)) return 'high'; if (/\.(medium|large)$/.test(t)) return 'medium'; return 'low'; } // ── Raccolta EC2 instances → asset 'server' ────────────────────────────────── async function collectEc2(assets) { const data = await awsJson(['ec2', 'describe-instances']); const reservations = data.Reservations || []; let n = 0; for (const r of reservations) { for (const inst of (r.Instances || [])) { const id = inst.InstanceId; if (!id) continue; const state = inst.State?.Name || 'unknown'; const name = tagValue(inst.Tags, 'Name') || id; const pubIp = inst.PublicIpAddress || null; const privIp = inst.PrivateIpAddress || null; assets.push({ name, asset_type: 'server', external_ref: `aws:ec2:${id}`, ip_address: privIp || pubIp || null, internet_facing: pubIp ? 1 : 0, criticality: critEc2(inst.InstanceType, state), vendor: 'AWS', location: inst.Placement?.AvailabilityZone || CFG.region || 'aws', description: `EC2 ${inst.InstanceType || ''} (${state})${pubIp ? ' · public ' + pubIp : ''}${privIp ? ' · private ' + privIp : ''}`.trim(), }); n++; } } console.log(` · EC2: ${n} istanze`); return n; } // ── Raccolta RDS db instances → asset 'database' ───────────────────────────── async function collectRds(assets) { const data = await awsJson(['rds', 'describe-db-instances']); const list = data.DBInstances || []; let n = 0; for (const db of list) { const id = db.DBInstanceIdentifier; if (!id) continue; const pub = db.PubliclyAccessible === true; assets.push({ name: id, asset_type: 'database', external_ref: `aws:rds:${id}`, ip_address: db.Endpoint?.Address || null, internet_facing: pub ? 1 : 0, criticality: pub ? 'high' : 'medium', vendor: `AWS RDS ${db.Engine || ''}`.trim(), location: db.AvailabilityZone || CFG.region || 'aws', description: `RDS ${db.Engine || ''} ${db.EngineVersion || ''} · class ${db.DBInstanceClass || '?'} · ${db.DBInstanceStatus || ''}${pub ? ' · PUBLIC' : ''}`.trim(), }); n++; } console.log(` · RDS: ${n} database`); return n; } // ── Raccolta S3 buckets → asset 'storage' ──────────────────────────────────── async function collectS3(assets) { const data = await awsJson(['s3api', 'list-buckets']); const list = data.Buckets || []; let n = 0; for (const b of list) { const name = b.Name; if (!name) continue; assets.push({ name, asset_type: 'storage', external_ref: `aws:s3:${name}`, criticality: 'medium', vendor: 'AWS S3', location: CFG.region || 'global', description: `Bucket S3${b.CreationDate ? ' · creato ' + b.CreationDate : ''}`, }); n++; } console.log(` · S3: ${n} bucket`); return n; } // ── Flussi (ID.AM-03) dalle Security Group ingress aperte verso 0.0.0.0/0 ───── async function collectSecurityGroupFlows(flows) { let data; try { data = await awsJson(['ec2', 'describe-security-groups']); } catch (e) { // Non bloccante: i flussi sono opzionali. console.warn(` · SG: salto i flussi (${e.message})`); return 0; } const groups = data.SecurityGroups || []; let n = 0; for (const sg of groups) { const sgId = sg.GroupId || '?'; for (const perm of (sg.IpPermissions || [])) { const openV4 = (perm.IpRanges || []).some(r => r && r.CidrIp === '0.0.0.0/0'); const openV6 = (perm.Ipv6Ranges || []).some(r => r && r.CidrIpv6 === '::/0'); if (!openV4 && !openV6) continue; // -1 = "all protocols/ports"; normalizziamo a 0. const from = perm.FromPort; const port = (from === undefined || from === null || from === -1) ? 0 : Number(from); const proto = perm.IpProtocol === '-1' ? 'any' : (perm.IpProtocol || 'tcp'); flows.push({ src: '0.0.0.0/0', dst: sgId, port, protocol: proto, direction: 'inbound', external_ref: `aws:sg:${sgId}:${port}`, }); n++; } } console.log(` · SG: ${n} regole ingress aperte (0.0.0.0/0 o ::/0)`); return n; } (async () => { console.log(`☁️ Discovery AWS · risorse [${CFG.resources.join(',')}]${CFG.region ? ' · region ' + CFG.region : ''} · source=${CFG.source}`); const assets = []; const flows = []; try { if (CFG.resources.includes('ec2')) await collectEc2(assets); if (CFG.resources.includes('rds')) await collectRds(assets); if (CFG.resources.includes('s3')) await collectS3(assets); // I flussi richiedono comunque la lettura delle Security Group (servizio EC2). if (CFG.resources.includes('ec2')) await collectSecurityGroupFlows(flows); } catch (e) { die(e.message); } if (assets.length > CFG.maxItems) { console.warn(`⚠️ ${assets.length} asset > MAX_ITEMS=${CFG.maxItems}: invio i primi ${CFG.maxItems}.`); assets.length = CFG.maxItems; } if (!assets.length) { console.log('Nessuna risorsa AWS trovata. Fine.'); return; } if (CFG.dryRun) { console.log(`DRY_RUN — invierei ${assets.length} asset e ${flows.length} flussi:`); console.log(JSON.stringify({ source: 'aws', assets, flows }, null, 2)); return; } let res; try { 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: 'aws', assets, flows }), }); } catch (e) { die(`invio a NIS2 fallito (rete): ${e.message}`); } 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); } })();