Il codice che installi è il codice che esegui
Quando lanci npm install scarichi byte scritti da sconosciuti, li scrivi nella tua cartella e, per impostazione predefinita, esegui gli script che portano con sé. Nel 2024 il caso più studiato non è nemmeno JavaScript: la scheda NVD di CVE-2024-3094 racconta che nei tarball upstream di xz, dalla versione 5.6.0, era stato inserito codice malevolo, estratto durante la build da un file di test camuffato; NVD gli assegna un punteggio CVSS 3.1 di 10,0.
Da qui quattro domande, e l'articolo risponde a ciascuna con una prova eseguita: che cosa c'è dentro il progetto (SBOM), che cosa è già noto come vulnerabile (npm audit), da dove arrivano i byte e chi garantisce che siano quelli attesi (lockfile e firme), che cosa viene eseguito all'installazione (script di ciclo di vita). Per l'OWASP Top 10:2025 l'insieme è la categoria A03:2025, Software Supply Chain Failures, che nel sondaggio della comunità OWASP, secondo la pagina della categoria, è risultata prima: il 50% dei rispondenti l'ha messa al primo posto.
Tutte le prove girano su progetti di prova creati in cartelle temporanee, con npm 11.6.3 e Node v24.11.0; per due passaggi (l'audit e il controllo di integrità) npm interroga in sola lettura il registro pubblico registry.npmjs.org, ed è l'unica rete usata. Ogni uscita riportata è identica in due esecuzioni consecutive. Le versioni e i risultati dell'audit sono quelli del 2026-11-13: l'elenco degli avvisi cambia quando ne vengono pubblicati di nuovi, ed è la ragione per cui l'audit va ripetuto, non memorizzato.
npm audit: che cosa dice e che cosa non può dire
Il progetto di prova ha una sola dipendenza, lodash@4.17.15, scelta perché vecchia. Lo script chiama npm audit --json con execFileSync (argomenti in array, nessuna shell) e stampa un riassunto ordinato. Una trappola in cui si cade al primo uso: npm audit esce con codice 1 quando trova vulnerabilità, e execFileSync lo tratta come un errore, per questo lo script recupera l'stdout dall'eccezione. Nell'integrazione continua è proprio questo codice di uscita a fermare la pipeline.
import { execFileSync } from 'node:child_process';
// Progetto di prova: un solo pacchetto, lodash 4.17.15, fissato nel package-lock.json
const run = (args) => {
try { return execFileSync('npm', args, { cwd: 'proj-audit', encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); }
catch (e) { return e.stdout; } // npm audit esce con codice 1 se trova vulnerabilità
};
const report = JSON.parse(run(['audit', '--json', '--cache', 'npm-cache']));
console.log('vulnerabilità:', JSON.stringify(report.metadata.vulnerabilities));
for (const [name, v] of Object.entries(report.vulnerabilities)) {
console.log(`${name}: gravità massima ${v.severity}, correzione disponibile: ${v.fixAvailable.version}`);
const advisories = v.via.filter((x) => typeof x === 'object').sort((a, b) => a.source - b.source);
for (const a of advisories) console.log(` ${a.severity.padEnd(8)} ${a.url.split('/').pop()} ${a.range.padEnd(16)} ${a.title}`);
}
// Output:
// vulnerabilità: {"info":0,"low":0,"moderate":0,"high":1,"critical":0,"total":1}
// lodash: gravità massima high, correzione disponibile: 4.18.1
// high GHSA-35jh-r3h4-6jhm <4.17.21 Command Injection in lodash
// high GHSA-p6mc-m468-83gw >=3.7.0 <4.17.19 Prototype Pollution in lodash
// moderate GHSA-29mw-wpgm-hmr9 >=4.0.0 <4.17.21 Regular Expression Denial of Service (ReDoS) in lodash
// high GHSA-r5fr-rjxr-66jc >=4.0.0 <=4.17.23 lodash vulnerable to Code Injection via `_.template` imports key names
// moderate GHSA-xxjr-mmjv-4gpg >=4.0.0 <=4.17.22 Lodash has Prototype Pollution Vulnerability in `_.unset`, `_.omit`, and `_.pullAt` functions
Le cinque righe sono cinque avvisi distinti sullo stesso pacchetto, ognuno con identificatore GHSA, intervallo di versioni colpite e gravità. Il dato che serve a decidere è fixAvailable: qui indica la versione 4.18.1, e isSemVerMajor è falso, quindi l'aggiornamento non dovrebbe rompere l'interfaccia. Quello che l'audit non dice è se il codice vulnerabile viene raggiunto dal tuo programma: un avviso su _.template pesa poco se non lo chiami mai, ma pesa molto se passi a _.template input dell'utente. Per questo la gravità riportata è un punto di partenza per il triage, non una priorità. Dalla documentazione npm risulta inoltre che con --audit-level si sceglie la soglia sotto cui il comando esce con 0: la soglia è una scelta di progetto da dichiarare nel repository, non un'abitudine.
- Il pacchetto vulnerabile è una dipendenza diretta o transitiva?
- Il codice colpito viene raggiunto con dati che controlla un utente?
- Esiste una versione corretta e l'aggiornamento è compatibile?
- Se non si può aggiornare, c'è una mitigazione e chi la rivede alla scadenza?
Le quattro domande vanno poste in quest'ordine perché ogni risposta restringe la successiva: se il pacchetto non è raggiunto da input esterno, l'urgenza scende e l'aggiornamento può aspettare il prossimo rilascio; se esiste la correzione compatibile, non c'è motivo di discutere. Il caso scomodo è l'ultimo, e va chiuso con una scadenza scritta, perché un'eccezione senza data diventa permanente.
SBOM: l'inventario che permette di rispondere in fretta
Un SBOM (Software Bill of Materials) è l'elenco leggibile dalle macchine di ciò che compone un software. npm sbom lo genera in due formati, CycloneDX e SPDX, e con --package-lock-only lo ricava dal lockfile senza leggere node_modules, quindi si può produrre nella pipeline prima dell'installazione. Lo script della prova li genera entrambi e stampa solo i campi stabili: il numero di serie e il timestamp cambiano a ogni esecuzione e sono stati lasciati fuori di proposito.
import { execFileSync } from 'node:child_process';
const sbom = (format) => JSON.parse(
execFileSync('npm', ['sbom', '--sbom-format', format, '--package-lock-only'], { cwd: 'proj-audit', encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
);
// CycloneDX: elenco di componenti con identificatore univoco (purl) e hash del tarball
const cdx = sbom('cyclonedx');
console.log('formato:', cdx.bomFormat, cdx.specVersion);
for (const c of cdx.components) {
console.log(`componente: ${c.name}@${c.version}`);
console.log(` purl: ${c.purl}`);
console.log(` hash: ${c.hashes?.map((h) => h.alg).join(',')}`);
}
console.log('dipendenze radice -> figli:', cdx.dependencies.map((d) => `${d['ref'].split('@')[0]}->${d.dependsOn.length}`).join(' '));
// SPDX: stesso inventario, altro formato
const spdx = sbom('spdx');
console.log('formato:', spdx.spdxVersion, '- pacchetti:', spdx.packages.map((p) => p.name).join(', '));
// Output:
// formato: CycloneDX 1.5
// componente: lodash@4.17.15
// purl: pkg:npm/lodash@4.17.15
// hash: SHA-512
// dipendenze radice -> figli: demo-audit->1 lodash->0
// formato: SPDX-2.3 - pacchetti: demo-audit, lodash
L'uscita mostra tre cose. Il formato (CycloneDX 1.5, SPDX 2.3) è quello prodotto da npm 11.6.3. Ogni componente ha un purl, pkg:npm/lodash@4.17.15, un identificatore che altri strumenti sanno incrociare con i database di vulnerabilità. Ha anche un hash SHA-512 del tarball. L'albero delle dipendenze (demo-audit->1, lodash->0) dice chi dipende da chi. Un SBOM non dice se un componente è vulnerabile, e non garantisce che i dati siano veri: è un inventario. Serve nel giorno in cui esce un avviso grave e la domanda è «quali dei nostri servizi lo contengono?»: con gli SBOM già archiviati si risponde con una ricerca invece che con una settimana di indagine.
Lockfile e integrità: i byte attesi, i byte arrivati
Il package-lock.json non fissa solo le versioni: per ogni pacchetto registra l'URL resolved e l'hash integrity del tarball. La documentazione di npm ci elenca le differenze rispetto a npm install: richiede un lockfile esistente, esce con errore se non coincide con il package.json invece di aggiornarlo, e non scrive mai né sul package.json né sul lockfile. Per la pipeline vale quindi una regola semplice: npm ci, mai npm install. Lo script sotto lo dimostra su un progetto con ms@2.1.3: genera il lockfile, ne altera l'hash come farebbe una pull request malevola e confronta l'esito di npm ci.
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const dir = mkdtempSync(join(tmpdir(), 'lab-int-'));
const npm = (args) => spawnSync('npm', [...args, '--cache', join(dir, '.cache'), '--no-audit', '--no-fund'], { cwd: dir, encoding: 'utf8' });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'demo-int', version: '1.0.0', private: true, dependencies: { ms: '2.1.3' } }));
npm(['install']); // genera package-lock.json con integrity
const lockPath = join(dir, 'package-lock.json');
const original = readFileSync(lockPath, 'utf8');
const lock = JSON.parse(original);
const entry = lock.packages['node_modules/ms'];
console.log('resolved :', entry.resolved);
console.log('integrity:', entry.integrity.slice(0, 14) + '...');
// Manomissione: cambiamo l'hash atteso, come farebbe un lockfile alterato in una pull request
entry.integrity = entry.integrity.slice(0, -6) + 'AAAAA=';
writeFileSync(lockPath, JSON.stringify(lock, null, 2));
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
const bad = npm(['ci']);
console.log('npm ci con lockfile manomesso -> exit', bad.status, '|', (bad.stderr.match(/EINTEGRITY/) ?? ['nessun EINTEGRITY'])[0]);
writeFileSync(lockPath, original); // lockfile originale
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
console.log('npm ci con lockfile originale -> exit', npm(['ci']).status);
// Le firme del registro: npm confronta ogni pacchetto installato con la chiave pubblicata dal registro
const sig = npm(['audit', 'signatures']).stdout.trim().split('\n').filter(Boolean).pop();
console.log('npm audit signatures ->', sig);
rmSync(dir, { recursive: true, force: true });
// Output:
// resolved : https://registry.npmjs.org/ms/-/ms-2.1.3.tgz
// integrity: sha512-6FlzubT...
// npm ci con lockfile manomesso -> exit 1 | EINTEGRITY
// npm ci con lockfile originale -> exit 0
// npm audit signatures -> 1 package has a verified registry signature
Con l'hash alterato npm ci esce con 1 e riporta EINTEGRITY: il tarball scaricato non corrisponde a quello dichiarato e l'installazione si ferma. Con il lockfile originale esce con 0, e npm audit signatures conferma che il pacchetto ha una firma valida del registro (documentazione npm: verifica anche le attestazioni di provenienza dove presenti). Il controllo protegge dalla manomissione del tarball dopo la pubblicazione e da un lockfile che diverge dai sorgenti, ma non dalla pubblicazione di una versione malevola da parte di un manutentore compromesso: in quel caso l'hash è quello giusto della versione sbagliata. Per questo il lockfile va letto nelle pull request, in particolare i cambi di host nel campo resolved.
Un attacco vicino è la dependency confusion: un nome interno non scopato che un attaccante registra sul registro pubblico. Qui non lo dimostro, perché richiederebbe di pubblicare un pacchetto. Il rimedio documentato da npm è lo scope: la guida spiega che ogni utente o organizzazione ha il proprio scope e solo il titolare può aggiungervi pacchetti, e che quindi non c'è rischio che qualcuno si prenda il nome prima di te. Dare ai pacchetti interni un nome @tuaorganizzazione/nome e associare quello scope al registro interno nel file .npmrc (riga di configurazione da adattare, non eseguita qui) chiude la strada.
Script di installazione: codice eseguito prima che tu l'abbia letto
Un pacchetto può dichiarare preinstall, install e postinstall, e npm li esegue sulla macchina di chi installa. Nella prova il pacchetto demo-postinstall è mio: il suo script scrive un file e annota quali variabili d'ambiente vede con nomi che sembrano segreti. Il processo figlio parte con un ambiente pulito, e vi ho messo una sola variabile finta, FAKE_CI_TOKEN, per rendere visibile l'esposizione senza mostrare nulla di reale. Poi lo stesso pacchetto viene installato due volte, con e senza --ignore-scripts.
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const root = mkdtempSync(join(tmpdir(), 'lab-scripts-'));
const run = (cwd, args, env = {}) =>
spawnSync('npm', [...args, '--cache', join(root, '.cache'), '--no-audit', '--no-fund'], {
cwd, encoding: 'utf8', env: { PATH: process.env.PATH, HOME: root, ...env } }); // ambiente pulito: niente variabili della macchina che esegue la prova
// Pacchetto "malevolo" di prova: il suo postinstall scrive un file e annota quali variabili d'ambiente vede
const pkg = join(root, 'pkg'); mkdirSync(pkg);
writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: 'demo-postinstall', version: '1.0.0', scripts: { postinstall: 'node hook.js' } }));
writeFileSync(join(pkg, 'hook.js'), `
const fs = require('fs'), path = require('path');
const seen = Object.keys(process.env).filter((k) => /TOKEN|SECRET|KEY/.test(k));
fs.writeFileSync(path.join(__dirname, 'ran.txt'), 'postinstall eseguito; variabili sensibili visibili: ' + seen.join(','));
`);
const tgz = run(pkg, ['pack', '--silent']).stdout.trim();
const attempt = (label, extra) => {
const app = mkdtempSync(join(root, 'app-'));
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0', private: true }));
run(app, ['install', join(pkg, tgz), ...extra], { FAKE_CI_TOKEN: 'valore-di-prova' });
const marker = join(app, 'node_modules', 'demo-postinstall', 'ran.txt');
console.log(label.padEnd(24), existsSync(marker) ? readFileSync(marker, 'utf8') : 'postinstall NON eseguito');
};
attempt('npm install', []);
attempt('npm install --ignore-scripts', ['--ignore-scripts']);
rmSync(root, { recursive: true, force: true });
// Output:
// npm install postinstall eseguito; variabili sensibili visibili: FAKE_CI_TOKEN
// npm install --ignore-scripts postinstall NON eseguito
La prima riga è il rischio concreto: lo script è stato eseguito durante l'installazione e ha avuto accesso alla variabile. In una pipeline quell'ambiente contiene spesso i token di pubblicazione e di deploy, ed è ciò che un pacchetto compromesso cercherebbe. La seconda riga mostra la contromisura: con --ignore-scripts lo script non parte. Il costo è reale: i pacchetti che compilano codice nativo o scaricano binari si installano a metà, e vanno poi ricostruiti in modo esplicito per i soli pacchetti che lo richiedono (npm rebuild seguito dal nome). Conviene partire dal rifiuto per tutti e riammettere uno per uno, invece del contrario.
Nomi simili e azioni mobili: due controlli che puoi scrivere in dieci righe
Il typosquatting sfrutta il refuso: lodahs al posto di lodash. Il primo controllo del listato calcola la distanza di edit, con trasposizione, tra il nome richiesto e un elenco di pacchetti noti, e segnala chi è a distanza 1 o 2 senza essere identico. È un'euristica, non un verdetto: produce falsi positivi (due pacchetti legittimi dal nome vicino) e falsi negativi (un nome malevolo lontano), e la lista dei pacchetti noti è di prova. Il secondo controllo riguarda GitHub Actions: la guida di GitHub consiglia di fissare le azioni a uno SHA completo, perché un tag può essere spostato e uno SHA no, e di dare al GITHUB_TOKEN il minimo dei permessi. Lo script legge un workflow di prova, scritto nel listato, e non esegue nulla.
// 1) Typosquatting: distanza di edit (con trasposizione) tra un nome richiesto e i pacchetti noti
const popular = ['lodash', 'express', 'react', 'axios', 'moment', 'chalk', 'commander', 'debug', 'typescript'];
function osa(a, b) { // optimal string alignment distance
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
for (let j = 0; j <= b.length; j++) d[0][j] = j;
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
}
return d[a.length][b.length];
}
const suspicious = (name) => popular.filter((p) => p !== name && osa(name, p) <= 2);
for (const name of ['lodash', 'lodahs', 'expresss', 'axois', 'my-internal-lib']) {
const near = suspicious(name);
console.log(name.padEnd(16), near.length ? `SOSPETTO, vicino a: ${near.join(', ')}` : 'ok');
}
// 2) GitHub Actions: ogni "uses:" deve puntare a uno SHA di 40 esadecimali, non a un tag che può essere spostato
const workflow = `
name: ci
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@0123456789abcdef0123456789abcdef01234567
- uses: docker://alpine:3.20
`;
const SHA = /^[0-9a-f]{40}$/;
for (const m of workflow.matchAll(/uses:\s*(\S+)/g)) {
const ref = m[1];
const version = ref.split('@')[1] ?? '';
const verdict = ref.startsWith('docker://') ? 'da verificare: immagine senza digest' : SHA.test(version) ? 'ok, fissato' : 'NON fissato (tag o ramo)';
console.log(ref.padEnd(64), verdict);
}
console.log('permissions di livello superiore dichiarate:', /^permissions:/m.test(workflow) ? 'sì' : 'NO (conviene dichiarare almeno contents: read)');
// Output:
// lodash ok
// lodahs SOSPETTO, vicino a: lodash
// expresss SOSPETTO, vicino a: express
// axois SOSPETTO, vicino a: axios
// my-internal-lib ok
// actions/checkout@v4 NON fissato (tag o ramo)
// actions/setup-node@0123456789abcdef0123456789abcdef01234567 ok, fissato
// docker://alpine:3.20 da verificare: immagine senza digest
// permissions di livello superiore dichiarate: NO (conviene dichiarare almeno contents: read)
I tre nomi con refuso vengono segnalati insieme al pacchetto da cui sembrano derivare; my-internal-lib e lodash passano. Nel workflow actions/checkout@v4 risulta non fissato, quello con 40 caratteri esadecimali risulta fissato (lo SHA del listato è inventato, serve solo alla prova), l'immagine docker:// non ha digest, e mancano le permissions di livello superiore. Una nota sull'attualità: la stessa guida GitHub descrive oggi anche i workflow dependency lockfiles, che registrano il commit esatto di ogni azione, comprese quelle transitive, e li definisce la protezione più forte, che sostituisce il fissaggio per SHA per chi li adotta. Lo SHA resta il minimo per chi non può.
Che cosa questi controlli non coprono
Nessuno di questi controlli vede un pacchetto malevolo non ancora segnalato: l'audit confronta con avvisi già pubblicati, l'SBOM fotografa, il lockfile garantisce che i byte siano quelli registrati. La difesa dal primo giorno di un attacco è la riduzione della superficie: meno dipendenze, aggiornamenti non automatici sull'ultima versione appena uscita, script disabilitati, pipeline con permessi minimi. Per approfondire il perimetro delle richieste e dei token c'è l'articolo precedente, API Security: OAuth 2.1, JWT e rate limiting; per i segreti che questi script potrebbero leggere, Secret management e rotazione delle credenziali; per il quadro d'insieme, OWASP Top 10 2025. Il successivo, Errori crittografici, passa a ciò che si fa con i segreti una volta in mano.
Che cosa sai fare adesso
Sai leggere un report di npm audit e decidere con quattro domande invece che con il colore della gravità. Sai produrre un SBOM CycloneDX o SPDX dal lockfile e dire a che cosa serve. Sai far fallire la pipeline su un lockfile manomesso con npm ci, controllare le firme del registro e riconoscere un'azione non fissata. Sai anche dove questi controlli finiscono: sono la prima linea, e non sostituiscono la lettura di ciò che si installa.
Domande frequenti
Qual è la differenza tra npm install e npm ci?
Secondo la documentazione npm, npm ci richiede un lockfile, esce con errore se non coincide con il package.json e non scrive mai né sul package.json né sul lockfile. È la scelta giusta per la pipeline.
Un SBOM dice se una dipendenza è vulnerabile?
No, è un inventario dei componenti con versioni e identificatori. Per sapere se un componente è vulnerabile lo si incrocia con un database di avvisi, per esempio con npm audit.
Conviene disattivare gli script di installazione?
Di norma sì, con --ignore-scripts, perché quegli script girano con l'ambiente di chi installa. Il costo è che i pacchetti con parti native vanno ricostruiti a mano uno per uno.
Che cos'è la dependency confusion?
È l'attacco in cui un nome di pacchetto interno, non scopato, viene registrato sul registro pubblico così che l'installazione scarichi quello dell'attaccante. Si previene con pacchetti scopati e con lo scope associato al registro interno.
Nella stessa serie trovi anche XSS, CSRF e CSP, SQL injection e validazione dell'input e sessioni e cookie.
Fonti
- OWASP Top 10:2025, A03 Software Supply Chain Failures - il nome della categoria e il dato sul sondaggio della comunità (pagina letta), consultata il 2026-11-13
- NVD: CVE-2024-3094 - descrizione sui tarball di xz dalla 5.6.0 e punteggio CVSS 3.1 di 10,0 (letta tramite l'API NVD 2.0), consultata il 2026-11-13
- npm CLI v11: npm audit - codici di uscita, audit-level e npm audit signatures, consultata il 2026-11-13
- npm CLI v11: npm sbom - i formati SPDX e CycloneDX e la modalità package-lock-only, consultata il 2026-11-13
- npm CLI v11: npm ci - le differenze rispetto a npm install, consultata il 2026-11-13
- npm CLI v11: scope - ogni utente o organizzazione ha il proprio scope e solo il titolare vi aggiunge pacchetti, consultata il 2026-11-13
- GitHub Docs: Secure use reference - fissaggio a SHA completo, workflow dependency lockfiles e permessi minimi del GITHUB_TOKEN (testo letto), consultata il 2026-11-13
I cinque listati sono stati eseguiti due volte ciascuno con uscita identica; l'audit e il controllo di integrità interrogano il registro pubblico in sola lettura. Non sono stati eseguiti: la dependency confusion, la configurazione .npmrc con registro interno e il workflow GitHub Actions (letto, non lanciato).

Commenti
Caricamento commenti...
Accedi per commentare