[FEAT] Discovery agent AWS (EC2/RDS/S3/SG → assets-ingest)

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) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-27 10:15:55 +02:00
co-authored by Claude Opus 4.8
parent b787c327c1
commit eb69a4f22e
2 changed files with 332 additions and 0 deletions
+255
View File
@@ -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);
}
})();