From eb69a4f22e8ce99a018b476d6b6e92efbd78c320 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 27 Jun 2026 10:15:55 +0200 Subject: [PATCH] =?UTF-8?q?[FEAT]=20Discovery=20agent=20AWS=20(EC2/RDS/S3/?= =?UTF-8?q?SG=20=E2=86=92=20assets-ingest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connettore di discovery cloud AWS parallelo all'agente di rete: elenca EC2/RDS/S3 via AWS CLI (zero dipendenze npm) + flussi SG ingress aperti verso 0.0.0.0/0, mappa in asset NIS2 e POSTa a /api/services/assets-ingest (source=aws). Config via env (RESOURCES/AWS_REGION/MAX_ITEMS/DRY_RUN), gestione errori robusta (aws assente o comando fallito → messaggio chiaro). README aggiornato con sezione AWS + permessi IAM read-only minimi. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/README_discovery_agent.md | 77 +++++++++ scripts/discovery-agent-aws.mjs | 255 ++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 scripts/discovery-agent-aws.mjs diff --git a/scripts/README_discovery_agent.md b/scripts/README_discovery_agent.md index d1ff804..1e9b0c0 100644 --- a/scripts/README_discovery_agent.md +++ b/scripts/README_discovery_agent.md @@ -49,3 +49,80 @@ inviandole con lo stesso formato `assets-ingest` (`source: "aws"`, `external_ref - 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**. + +--- + +# Connettore AWS (`discovery-agent-aws.mjs`) + +Variante **cloud** dell'agente: invece di scansionare CIDR via TCP, elenca le risorse +di un account **AWS** (EC2 / RDS / S3 + Security Group) tramite la **AWS CLI** e le +mappa in asset NIS2, inviandole allo stesso `POST /api/services/assets-ingest` +(`source: "aws"`). Node 18+, **nessuna dipendenza** npm. + +## Mappatura risorse → asset +| Risorsa AWS | `asset_type` | `external_ref` | +|---|---|---| +| EC2 instance | `server` | `aws:ec2:` | +| RDS db instance | `database` | `aws:rds:` | +| S3 bucket | `storage` | `aws:s3:` | +| Security Group ingress aperta verso `0.0.0.0/0` / `::/0` | *flusso* `inbound` | `aws:sg::` | + +`name` EC2 = tag `Name` (fallback all'instance-id); `ip_address` = IP privato (o pubblico +se manca); `internet_facing=1` se l'istanza/RDS ha un endpoint pubblico. La criticità è +un'euristica (tipo/stato istanza, RDS pubblico). Lo **scoring rilevanza NIS2** lo calcola +comunque il backend. + +## Come funziona +1. In NIS2: **Connettori Discovery → Nuovo connettore** (es. *AWS prod*) → copia l'**API key**. +2. Configura le **credenziali AWS read-only** sull'host (catena standard: `AWS_PROFILE`, + `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, profilo `~/.aws/credentials`, ecc.). +3. Esegui: + ```bash + NIS2_API_KEY=nis2_xxxxxxxx AWS_REGION=eu-south-1 node discovery-agent-aws.mjs + ``` + +## Variabili +| Var | Default | Note | +|---|---|---| +| `NIS2_API_KEY` | — (obbligatoria, tranne in `DRY_RUN`) | chiave del connettore | +| `NIS2_API_URL` | `https://nis2.agile.software` | | +| `SOURCE` | `aws` | etichetta provenienza | +| `RESOURCES` | `ec2,rds,s3` | csv: quali risorse raccogliere (i flussi SG richiedono `ec2`) | +| `AWS_REGION` | — (config CLI) | regione AWS (passata a `--region`) | +| `MAX_ITEMS` | `2000` | tetto asset per esecuzione | +| `DRY_RUN` | — | `1` = non invia, stampa solo l'anteprima JSON | + +Le credenziali AWS **non** sono variabili di questo script: usa la catena standard della +AWS CLI (`aws sts get-caller-identity` per verificarle). + +## Esempi +```bash +# Anteprima (non invia) solo EC2 +DRY_RUN=1 RESOURCES=ec2 node discovery-agent-aws.mjs + +# Inventario completo EC2+RDS+S3 di una regione +NIS2_API_KEY=nis2_xxx AWS_REGION=eu-south-1 node discovery-agent-aws.mjs + +# Solo storage +NIS2_API_KEY=nis2_xxx RESOURCES=s3 node discovery-agent-aws.mjs +``` + +## Permessi IAM minimi (read-only) +Policy con sole letture (nessuna `Get`/lettura di oggetti, solo metadati/inventario): +```json +{ + "Version": "2012-10-17", + "Statement": [ + { "Effect": "Allow", "Action": ["ec2:Describe*", "rds:Describe*", "s3:ListAllMyBuckets"], "Resource": "*" } + ] +} +``` +- `ec2:Describe*` → EC2 instances + Security Group (flussi ingress). +- `rds:Describe*` → RDS db instances. +- `s3:ListAllMyBuckets` → elenco bucket S3 (solo nomi, nessun contenuto). + +## Note operative +- Se la AWS CLI non è installata, o un comando fallisce (credenziali/permessi), l'agente + termina con un **messaggio chiaro** (no stacktrace). I flussi Security Group sono opzionali: + se la `describe-security-groups` fallisce, l'agente continua con i soli asset. +- Crea un connettore distinto per account/regione: ognuno ha la sua API key e il suo storico run. diff --git a/scripts/discovery-agent-aws.mjs b/scripts/discovery-agent-aws.mjs new file mode 100644 index 0000000..91140fd --- /dev/null +++ b/scripts/discovery-agent-aws.mjs @@ -0,0 +1,255 @@ +#!/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); + } +})();