Diego Betto
Foto di Kaffeebart su Unsplash

Diego Betto · 29 settembre 2026 · 13 min di lettura

Sicurezza della supply chain in Node.js e TypeScript: hardening delle dipendenze e pipeline CI/CD

Come proteggere un progetto Node.js dagli attacchi alla supply chain: script di installazione, typosquatting, lockfile, audit automatici, GitHub Actions, provenance, CSP e SRI.

Condividi:XLinkedInFacebookWhatsApp

Un progetto Node.js di medie dimensioni ha facilmente più di mille pacchetti in node_modules, scritti da centinaia di maintainer che non conosci. Ognuno di loro, al momento del npm install, può eseguire codice arbitrario sulla tua macchina e sul runner della tua CI — con accesso al file system, alle variabili d’ambiente e ai token che trova in giro. Il codice che scrivi tu è solo una piccola parte di quello che finisce in produzione.

Non è uno scenario teorico. A settembre 2025 l’account di un maintainer di pacchetti come chalk e debug — miliardi di download settimanali complessivi — è stato compromesso con una email di phishing, e versioni malevole sono finite sul registry per alcune ore. Pochi giorni dopo, il worm Shai-Hulud ha iniziato a propagarsi da solo: uno script di installazione rubava i token npm e GitHub della vittima e li usava per pubblicare versioni infette di altri pacchetti dello stesso maintainer. A marzo dello stesso anno era toccato a tj-actions/changed-files, una GitHub Action usata da decine di migliaia di repository, modificata per stampare i segreti della CI nei log.

Nessuna misura singola ti protegge da tutto. Ma una serie di accorgimenti concreti riduce drasticamente sia la probabilità di installare codice malevolo, sia i danni che può fare se succede.

I vettori d’attacco, in breve

Vettore Come funziona Contromisura principale
Script di installazione preinstall/postinstall eseguono codice appena il pacchetto viene installato Disabilitare gli script, allowlist esplicita
Typosquatting Pacchetti dal nome quasi identico (expres, lodahs, cross-env.js) Revisione delle nuove dipendenze, lockfile
Account takeover Il maintainer legittimo viene compromesso e pubblica una versione malevola Ritardo sulle nuove versioni, provenance
Dipendenze transitive La vulnerabilità sta tre livelli sotto, in un pacchetto che non hai mai scelto Audit automatici, aggiornamenti continui
Pipeline CI/CD Action compromesse, segreti esposti a codice non fidato SHA pinning, permessi minimi, segreti isolati
CDN a runtime Uno script esterno cambia contenuto dopo il deploy (il caso polyfill.io del 2024) Subresource Integrity e CSP

Vediamoli uno alla volta, partendo da quello con il miglior rapporto costo/beneficio.

1. Disabilitare gli script di installazione

La stragrande maggioranza dei pacchetti non ha bisogno di eseguire codice all’installazione. Quelli che ne hanno bisogno sono pochi e riconoscibili: binari nativi (esbuild, sharp, better-sqlite3, @tailwindcss/oxide), qualche tool che scarica un eseguibile. Per tutti gli altri, uno script postinstall è nella migliore delle ipotesi inutile, nella peggiore è esattamente il vettore usato da Shai-Hulud.

Con npm, gli script si disabilitano globalmente in .npmrc:

# .npmrc
ignore-scripts=true

e si riabilitano solo per i pacchetti che ne hanno davvero bisogno:

npm ci
npm rebuild esbuild sharp --ignore-scripts=false

⚠ Attenzione

ignore-scripts=true disabilita anche gli script del tuo package.json legati al ciclo di installazione (prepare, postinstall) — per esempio l’hook che installa Husky. I comandi lanciati esplicitamente con npm run continuano invece a funzionare normalmente.

pnpm va oltre: dalla versione 10 non esegue gli script di installazione delle dipendenze per default. Chi ne ha bisogno va autorizzato esplicitamente. Questa è la configurazione reale del repository di questo blog:

# pnpm-workspace.yaml
onlyBuiltDependencies:
  - "@tailwindcss/oxide"
  - esbuild
  - sharp

Quando aggiungi un pacchetto che vuole eseguire uno script, pnpm te lo segnala e il comando pnpm approve-builds ti chiede di decidere. È il modello giusto: negato per default, permesso per scelta. Bun segue la stessa filosofia con trustedDependencies, Yarn Berry con enableScripts: false.

2. Lockfile e installazioni riproducibili

Il lockfile è ciò che garantisce che la CI installi esattamente le versioni che hai testato, con gli stessi hash d’integrità. Due regole:

  • Committalo sempre, e trattane le modifiche come codice: in una pull request, un lockfile che cambia centinaia di righe per “aggiungere una piccola utility” merita uno sguardo.
  • In CI usa il comando che fallisce se il lockfile non corrisponde a package.json, invece di aggiornarlo in silenzio:
npm ci                          # npm
pnpm install --frozen-lockfile  # pnpm (default quando rileva un ambiente CI)
yarn install --immutable        # Yarn Berry

3. Non installare versioni appena pubblicate

Quasi tutti gli attacchi via account takeover vengono individuati e rimossi dal registry nel giro di ore o pochi giorni. Se il tuo progetto non installa mai una versione pubblicata da meno di qualche giorno, eviti di fatto l’intera categoria.

pnpm lo supporta nativamente:

# pnpm-workspace.yaml
# Minuti: 4320 = 3 giorni
minimumReleaseAge: 4320

Yarn ha un’opzione equivalente (npmMinimalAgeGate), Bun minimumReleaseAge in bunfig.toml. Con npm puoi ottenere un effetto simile con --before, che risolve le dipendenze come se fossi a una certa data. La stessa logica va applicata ai bot di aggiornamento — lo vediamo sotto.

💬 Opinione personale

Tre giorni sono un buon compromesso per quasi tutti i progetti. Il costo è ricevere le patch di sicurezza con qualche giorno di ritardo; per le vulnerabilità critiche puoi sempre aggiornare a mano e aggiungere un’eccezione per quel pacchetto (minimumReleaseAgeExclude in pnpm).

4. Prima di aggiungere una dipendenza

Il momento migliore per fermare un pacchetto malevolo è prima che entri nel lockfile. Qualche controllo rapido:

npm view nome-pacchetto          # maintainer, data di pubblicazione, repository
npm view nome-pacchetto versions # una sola versione pubblicata ieri? sospetto
npm view nome-pacchetto scripts  # ha un postinstall? perché?

Segnali d’allarme: nome molto simile a un pacchetto popolare, repository GitHub assente o che non corrisponde, pochissimi download ma un README copiato da un progetto famoso, script di installazione che scaricano qualcosa da un URL. Strumenti come Socket analizzano automaticamente questi segnali nelle pull request.

E la domanda più efficace di tutte: mi serve davvero? Una funzione di dieci righe scritta da te non ha maintainer da compromettere.

5. Audit automatici delle vulnerabilità

npm audit --omit=dev --audit-level=high
pnpm audit --prod --audit-level high

--omit=dev/--prod limita il controllo alle dipendenze che finiscono in produzione, e --audit-level fa fallire il comando solo sopra una certa gravità: senza questi due filtri l’audit diventa rumore che il team impara a ignorare.

Per una seconda fonte dati indipendente dal registry npm, OSV-Scanner di Google usa il database OSV e legge direttamente i lockfile:

osv-scanner scan source -r .

E per verificare che i pacchetti installati siano davvero quelli firmati dal registry — e, dove disponibili, le attestazioni di provenance che li legano al repository e alla build che li ha prodotti:

npm audit signatures

6. Aggiornamenti automatici, ma con un ritardo

Dependabot e Renovate tengono aggiornate le dipendenze, ma aprire una PR cinque minuti dopo la pubblicazione di una versione compromessa è esattamente ciò che non vuoi. Entrambi supportano un periodo di attesa:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 3
    groups:
      minor-and-patch:
        update-types: ["minor", "patch"]
// renovate.json
{
  "extends": ["config:recommended"],
  "minimumReleaseAge": "3 days",
  "internalChecksFilter": "strict"
}

Raggruppare minor e patch in una sola PR settimanale riduce la fatica da revisione — che è il vero motivo per cui gli aggiornamenti automatici vengono spesso mergiati senza guardarli.

7. Blindare la pipeline CI/CD

La CI è il bersaglio più appetibile: ha i token per pubblicare pacchetti, fare deploy, accedere al cloud. Un workflow GitHub Actions ragionevole:

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
  push:
    branches: [main]

# Permessi minimi per default: nessun job può scrivere sul repository
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      # Action fissate per SHA del commit, non per tag (i tag si possono spostare)
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm rebuild esbuild --ignore-scripts=false
      - run: npm audit --omit=dev --audit-level=high
      - run: npm test

I punti chiave:

  • permissions: contents: read al livello più alto. Il GITHUB_TOKEN di default può avere permessi di scrittura: se una dipendenza compromessa lo legge, può spingere commit o modificare release. I job che devono scrivere dichiarano esplicitamente cosa gli serve.
  • SHA pinning delle action. Il caso tj-actions/changed-files è stato possibile perché i tag di versione (@v45) sono stati spostati su un commit malevolo. Un SHA completo è immutabile. Dependabot sa aggiornare anche gli SHA, mantenendo il commento con la versione.
  • persist-credentials: false evita che il token resti salvato nella config git del workspace, dove qualunque processo successivo — incluso uno script di una dipendenza — potrebbe leggerlo.
  • Nessun segreto nello step di installazione. I segreti vanno passati come env solo agli step che li usano davvero, mai a livello di job o workflow.
  • Attenzione a pull_request_target. Gira con i permessi e i segreti del repository di destinazione: fare checkout ed eseguire il codice di una PR da un fork in questo contesto equivale a dare le chiavi a chiunque apra una PR.

8. Pubblicare pacchetti in modo sicuro

Se mantieni pacchetti su npm, il token di pubblicazione è la cosa che un attaccante vuole di più. Dopo gli incidenti del 2025, npm ha eliminato i vecchi token “classic” e spinge verso il trusted publishing: la CI si autentica sul registry tramite OIDC, con una credenziale temporanea valida solo per quella esecuzione. Nessun token a lungo termine da rubare.

# .github/workflows/publish.yml
on:
  release:
    types: [published]

permissions:
  contents: read
  id-token: write # necessario per OIDC

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 24
          registry-url: "https://registry.npmjs.org"
      - run: npm ci --ignore-scripts
      - run: npm run build
      - run: npm publish

Il collegamento tra pacchetto e workflow si configura una volta sola nelle impostazioni del pacchetto su npmjs.com. Con il trusted publishing npm genera automaticamente anche l’attestazione di provenance: chi installa il pacchetto può verificare da quale repository e da quale build è stato prodotto. Aggiungi l’autenticazione a due fattori sull’account, e l’obbligo di 2FA per la pubblicazione manuale.

9. Isolare l’esecuzione

Anche con tutte le misure sopra, parti dal presupposto che prima o poi del codice non fidato girerà. L’obiettivo diventa limitare cosa può vedere.

Installare le dipendenze in un container usa e getta, senza accesso alla tua home directory, alle chiavi SSH o al file ~/.npmrc con i token:

docker run --rm -v "$PWD":/app -w /app node:24 npm ci --ignore-scripts

In sviluppo, un dev container (VS Code, JetBrains) ottiene lo stesso effetto in modo permanente: node_modules vive dentro il container, i tuoi segreti fuori.

A runtime, il Permission Model di Node.js permette di limitare cosa può fare il processo:

node --permission \
  --allow-fs-read=/app \
  --allow-fs-write=/app/tmp \
  dist/server.js

Senza flag espliciti, il processo non può scrivere su disco, lanciare processi figli (--allow-child-process), creare worker (--allow-worker) né caricare addon nativi.

ℹ️ Nota

La documentazione di Node.js è esplicita: il Permission Model è una “cintura di sicurezza” contro errori e comportamenti inattesi, non una sandbox contro codice deliberatamente malevolo. Aggiunge un ostacolo, non sostituisce l’isolamento a livello di container o di sistema operativo.

10. Proteggere il runtime nel browser: SRI e CSP

La supply chain non finisce con la build. Se la tua pagina carica script da una CDN, il contenuto di quello script può cambiare dopo il deploy senza che tu tocchi una riga. È esattamente quello che è successo nel 2024 con polyfill.io: il dominio è passato di mano e ha iniziato a servire codice malevolo a centinaia di migliaia di siti.

Subresource Integrity lega lo script a un hash preciso del suo contenuto. Se il file cambia, il browser si rifiuta di eseguirlo:

<script
  src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lib.min.js"
  integrity="sha384-..."
  crossorigin="anonymous"
></script>

L’hash si genera così:

curl -s https://cdn.jsdelivr.net/npm/[email protected]/dist/lib.min.js \
  | openssl dgst -sha384 -binary | openssl base64 -A

SRI ha senso solo con URL versionati (@3.2.1, non @latest): un file che cambia legittimamente romperebbe il sito.

La Content Security Policy completa il quadro limitando da dove possono arrivare gli script e, soprattutto, verso dove possono inviare dati. Una direttiva connect-src restrittiva impedisce a un pacchetto compromesso finito nel bundle di esfiltrare dati verso un dominio dell’attaccante:

Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net; connect-src 'self' https://api.example.com

Ho approfondito la configurazione in Content Security Policy: guida pratica e nella parte avanzata su nonce, strict-dynamic e Trusted Types.

Checklist riassuntiva

  • Script di installazione disabilitati per default, allowlist esplicita per i pacchetti nativi
  • Lockfile committato, npm ci / --frozen-lockfile in CI
  • Ritardo minimo sulle nuove versioni (minimumReleaseAge, cooldown di Dependabot/Renovate)
  • Revisione di ogni nuova dipendenza prima dell’aggiunta
  • npm audit e/o OSV-Scanner in CI con soglia di gravità
  • npm audit signatures per verificare firme e provenance
  • GitHub Actions con permissions minimi, action fissate per SHA, persist-credentials: false
  • Segreti passati solo agli step che li usano
  • Pubblicazione tramite trusted publishing (OIDC), 2FA sugli account
  • Installazioni in container o dev container
  • SRI su ogni script esterno, CSP con script-src e connect-src restrittivi

Domande frequenti

❓ --ignore-scripts rompe il mio progetto?

Solo per i pacchetti che hanno davvero bisogno di uno script d’installazione, di solito quelli con binari nativi (esbuild, sharp, bcrypt, sqlite). Li riconosci perché falliscono all’avvio o al build: basta aggiungerli all’allowlist (npm rebuild <pacchetto> con npm, onlyBuiltDependencies con pnpm).

❓ npm audit è sufficiente?

No. npm audit trova vulnerabilità note e già segnalate: non rileva un pacchetto malevolo pubblicato da un’ora, né un typosquatting. È una misura utile ma reattiva, da affiancare a quelle preventive (script disabilitati, ritardo sulle nuove versioni, revisione delle dipendenze).

❓ Cos'è la provenance di un pacchetto npm?

È un’attestazione firmata che collega una versione pubblicata al repository sorgente, al commit e al workflow CI che l’hanno prodotta. Permette di verificare che il pacchetto sia stato costruito da quel codice e non caricato a mano da qualcuno con un token rubato. Si verifica con npm audit signatures.

❓ Perché fissare le GitHub Actions per SHA invece che per versione?

Perché un tag come @v4 può essere spostato su un commit diverso da chiunque abbia accesso in scrittura al repository dell’action — incluso un attaccante. Lo SHA completo di un commit è immutabile: esegui sempre esattamente il codice che hai revisionato.

Conclusioni

La sicurezza della supply chain non si risolve con uno strumento, ma con una serie di default più prudenti: codice di terze parti che non gira finché non lo autorizzi, versioni che non entrano finché non hanno qualche giorno di storia, pipeline che non hanno più permessi di quanti ne usino, credenziali che scadono da sole. Presi uno per uno sono cambiamenti piccoli — la maggior parte è una riga di configurazione. Insieme, trasformano un npm install da atto di fede a operazione controllata.

Se lavori con Node.js, tieni d’occhio anche le vulnerabilità del runtime stesso: ne ho parlato nell’articolo sulle security release di Node.js di gennaio 2026.

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Scritto da

Diego Betto

Co-Fondatore & CTO presso PAPION. Senior full-stack engineer specializzato in React, TypeScript, Node.js e sicurezza applicativa.