[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:
co-authored by
Claude Opus 4.8
parent
b787c327c1
commit
eb69a4f22e
@@ -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:<instance-id>` |
|
||||
| RDS db instance | `database` | `aws:rds:<db-id>` |
|
||||
| S3 bucket | `storage` | `aws:s3:<bucket>` |
|
||||
| Security Group ingress aperta verso `0.0.0.0/0` / `::/0` | *flusso* `inbound` | `aws:sg:<sg-id>:<port>` |
|
||||
|
||||
`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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user