SSD Nodes Learn 🎉 VPS od $5.50/mies.
Przewodniki Matt ConnorAutor: Matt Connor

Ataki na łańcuch dostaw npm w aplikacjach Node.js

Analiza mechanizmów infekcji przez skrypty postinstall, typosquatting oraz przejęte pakiety. Dowiedz się, jak proces wdrażania na VPS pozwala na uruchomienie złośliwego kodu npm.

Czym jest atak na łańcuch dostaw npm na serwerze

Atak na łańcuch dostaw npm dociera do serwera za pośrednictwem pakietu, który został wybrany do instalacji. Nie wiąże się to z otwartym portem ani etapem wykorzystania luki. npm (node package manager) instaluje kod, a instalacja kodu oznacza jego uruchomienie. W rezultacie niewielka aplikacja Node pobiera kilkaset pakietów, których zawartość nie została zweryfikowana, a każdy z nich może w dowolnej chwili opublikować nową wersję.

Proces wdrażania pobiera złośliwą wersję, ponieważ polecenie instalacji wymusza pobranie najnowszej pasującej wersji. Kod ten uruchamia się następnie z uprawnieniami użytkownika, który wywołał instalację. Wszystkie poniższe konsekwencje wynikają z tych dwóch faktów.

Wzorce ataków uszeregowano według częstotliwości występowania w przypadku wdrażania jednej aplikacji Node na jednym serwerze VPS. Kolejność ta różni się od tej, którą stosują duże przedsiębiorstwa, ponieważ dysponują one wewnętrznym rejestrem, zespołem weryfikacyjnym oraz kopią publicznego rejestru. W tym przypadku podstawowym narzędziem jest skrypt wdrożeniowy.

Scenariusz 1: konto opiekuna pakietu zostaje przejęte i publikuje poprawkę

Rejestr npm nie pozwala na zmianę zawartości wersji, która już istnieje. Atakujący, który wyłudzi dane dostępowe opiekuna lub ukradnie token publikacji, nie może zatem nadpisać 4.18.2. Publikuje on 4.18.3.

Sprawdź swój plik package.json. Wiersz taki jak "express": "^4.18.2" nie oznacza wersji 4.18.2. Znak karetki oznacza „dowolną wersję 4.x równą lub wyższą od tej”, a ~4.18.2 oznacza „dowolną wersję 4.18.x”. Polecenie npm install rozwiązuje ten zakres w momencie uruchomienia, więc ten sam commit git, wdrożony dwukrotnie tego samego popołudnia, może zainstalować dwa różne zestawy kodu. Ta luka stanowi powierzchnię ataku. Aby ją wykorzystać, nie trzeba przejmować żadnego elementu Twojej maszyny.

Złośliwe wydania są zazwyczaj zgłaszane i usuwane, ale usunięcie następuje dopiero po tym, jak użytkownicy zdążą je zainstalować. Każdy, kto przeprowadził wdrożenie w tym oknie czasowym, posiada ten kod na dysku. Potok CI/CD, który rozwiązuje zakresy przy każdym uruchomieniu, wchodzi w to okno automatycznie, kilka razy w tygodniu, bez świadomej decyzji użytkownika.

Shape 2: an install script runs as the user doing the deploy

A package's package.json can declare preinstall, install, postinstall and prepare in its scripts block. npm runs them during installation. They are not sandboxed and nobody reviews them. They are shell commands running as the user who typed the install command, in that user's home directory, with that user's network access and the full environment of that shell.

So the useful question is not what the package can do. It is what that user can read. On a normal deploy box the answer includes ~/.npmrc holding a registry token, ~/.ssh/id_ed25519 used as a deploy key for SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json, and every exported variable in the shell, which is where DATABASE_URL usually lives.

A payload like this needs no persistence and no privilege escalation. It reads a few files, sends them to a host over HTTPS, and exits with status 0. You see nothing, because npm hides install-script output by default. Turn that off and watch what actually runs:

npm ci --foreground-scripts

foreground-scripts shares standard input, output and error with the npm process, so build scripts print into your terminal instead of into a buffer npm discards when the install succeeds.

Wariant 3: typosquatting i nazwa, której nie wpisano dokładnie

Typosquatting polega na opublikowaniu pakietu pod nazwą zbliżoną do popularnego odpowiednika, w oczekiwaniu na błędnie wpisaną lub wklejoną komendę instalacji. Mechanizm ataku opiera się na komendzie, a nie na kodzie, dlatego plik blokady (lockfile) nie zapewnia ochrony: po jednorazowym dodaniu błędnej nazwy, plik blokady będzie ją wiernie utrzymywał.

Wariantem, który dotyka zespoły zamiast pojedynczych użytkowników, jest dependency confusion. Wewnętrzny pakiet nosi nazwę billing-utils i znajduje się w prywatnym rejestrze. Jeśli w publicznym rejestrze nie istnieje pakiet o nazwie billing-utils, każdy może go tam opublikować. npm rozwiązuje nazwy bez zakresu (unscoped) względem domyślnego rejestru publicznego, więc publiczna kopia może zostać wybrana zamiast prywatnej. Rozwiązaniem jest użycie własnego zakresu (scope) oraz mapowania rejestru dla tego zakresu w pliku .npmrc:

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

Teraz pakiet @yourorg/billing-utils będzie zawsze pobierany wyłącznie z tego hosta, ponieważ mapowanie zakresu do rejestru jest sprawdzane przed domyślnym rejestrem. Wewnętrzna nazwa bez zakresu nie posiada takiego mapowania, więc nie jest chroniona.

Przed dodaniem jakiejkolwiek nowej zależności należy przyjrzeć się samemu pakietowi, zamiast polegać wyłącznie na odznace liczby pobrań:

npm view some-lib repository.url maintainers time.created time.modified

Pakiet utworzony w zeszłym miesiącu, opublikowany przez konto, którego nie można powiązać z publicznym repozytorium, stanowi inne zagrożenie niż pakiet z sześcioletnią historią. Żaden z tych faktów nie jest dowodem bezpieczeństwa. Oba są jednak łatwe do zweryfikowania.

Wariant 4: zależność, której właściciel zmienił się bez rozgłosu

Opiekunowie przekazują pakiety innym osobom. Ktoś rezygnuje z powodu wypalenia, zgłasza się nieznajomy, uprawnienia do publikacji zostają przeniesione, a projekty zależne nie otrzymują żadnego powiadomienia. Nic nie zostaje naruszone. Zaufanie, którym obdarzyłeś pakiet w 2021 roku, znajduje się teraz w rękach innej osoby.

Jest to najwolniejszy wariant i najtrudniejszy do wykrycia; nie istnieje polecenie, które bezpośrednio udziela odpowiedzi na to zagrożenie. Dwa działania pozwalają ograniczyć ryzyko. Przed przyjęciem pakietu sprawdź, kto ma uprawnienia do publikacji, używając linii npm view powyżej. Następnie, gdy pakiet, od którego faktycznie zależy Twój projekt, zmienia właściciela, przeanalizuj różnice w kodzie:

npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3

Pierwsza forma wyświetla tylko nazwy zmienionych plików, co jest wystarczająco szybkie, aby wykonywać to przy każdej aktualizacji istotnego pakietu. Warto w całości przeczytać poprawkę (patch release), która modyfikuje skrypt budowania, dodaje plik w katalogu głównym pakietu lub edytuje blok scripts, zanim trafi ona na Twój serwer.

Budowanie z zatwierdzonego pliku blokady za pomocą npm ci

package-lock.json rejestruje dokładną wersję każdego pakietu w drzewie, adres URL, z którego pochodzi, skrót integralności sha512 każdego archiwum tarball oraz informację, który pakiet go wymagał. Należy go zatwierdzić w repozytorium. Jest to jedyny plik określający, co faktycznie zostało przetestowane.

Następnie należy przeprowadzić instalację za pomocą npm ci, nigdy npm install, na każdej maszynie, która nie jest laptopem programisty:

npm ci --omit=dev --ignore-scripts

npm ci różni się od npm install w sposób, który ma tutaj kluczowe znaczenie. Wymaga istnienia pliku blokady. Usuwa istniejący katalog node_modules przed rozpoczęciem pracy, dzięki czemu pozostałości po wcześniejszym wdrożeniu nie przenikną do bieżącego. Nigdy nie zapisuje zmian w package.json ani w pliku blokady, więc instalacja nie może po cichu zaktualizować wersji pakietu. Jeśli plik blokady i package.json są ze sobą sprzeczne, proces kończy się błędem zamiast próbować rozwiązać różnice.

Ten błąd jest funkcją, a nie niedogodnością. Oznacza to, że zmiana zależności musi zostać wprowadzona jako zatwierdzony commit, który ktoś sprawdził, a nie jako efekt uboczny wdrożenia o godzinie 02:00.

Skrót integralności jest sprawdzany przy każdym pobieraniu. Archiwum tarball, którego bajty nie zgadzają się z zapisanym skrótem, powoduje przerwanie instalacji z błędem code EINTEGRITY zamiast rozpakowania. Należy precyzyjnie określić, co to daje: potwierdza, że otrzymany plik jest tym samym plikiem, który został przypięty w pliku blokady. Jest to ta sama gwarancja, którą zapewnia weryfikacja pobrań za pomocą sum kontrolnych, i ma te same ograniczenia. Nie mówi nic o tym, czy przypięta wersja była złośliwa w momencie publikacji.

Jeden szczegół dotyczący --omit=dev: pakiety te są nadal rozwiązywane i zapisywane w pliku blokady. Po prostu nie są umieszczane na dysku. Mniejsza liczba pakietów na dysku oznacza mniej skryptów instalacyjnych i mniej kodu ładowanego w czasie wykonywania, więc warto to robić. Nie usuwa to zależności z drzewa projektu.

Traktuj skrypty instalacyjne jak kod i naucz się je blokować

Skrypty instalacyjne można wyłączyć. Umieść poniższą treść w pliku .npmrc projektu i zatwierdź ją w repozytorium obok pliku blokady:

ignore-scripts=true
save-exact=true

Opcja ignore-scripts=true blokuje wykonywanie skryptów zadeklarowanych w zależnościach przez npm. Parametr save-exact=true sprawia, że npm install some-lib zapisuje 1.4.2 do pliku package.json zamiast ^1.4.2, dzięki czemu zakresy wersji nie trafiają przypadkowo do manifestu.

To ustawienie powoduje błędy, dlatego przed jego włączeniem należy zrozumieć mechanizm działania. Pakiety kompilujące natywne dodatki lub pobierające gotowe pliki binarne wykonują te operacje w skryptach instalacyjnych. Przy wyłączonych skryptach instalacja kończy się powodzeniem, a błąd pojawia się później, w czasie wykonywania, jako moduł, który nie może załadować swojego pliku wiążącego. Rozwiązaniem jest lista dozwolonych pakietów:

npm ci --ignore-scripts
npm rebuild better-sqlite3

Opcja npm rebuild <package> uruchamia skrypty budowania tylko dla wskazanego pakietu. W ten sposób podejmujesz decyzję dla każdego pakietu z osobna, zamiast przyznawać nieograniczone uprawnienia do wykonywania kodu setkom nieznanych autorów.

Aby sprawdzić, jak szeroki jest obecnie zakres przyznanych uprawnień, zapytaj npm:

npm query ":attr(scripts, [postinstall])"

Polecenie to wyświetli każdy pakiet w zainstalowanym drzewie zależności, który zawiera skrypt postinstall. W typowej aplikacji lista ta jest krótsza, niż można by oczekiwać, co czyni stosowanie listy dozwolonych rozwiązaniem praktycznym.

Oddzielenie procesu budowania od procesu obsługi ruchu

Użytkownik wdrażający (deploy user) musi posiadać uprawnienia do zapisu w node_modules. Proces obsługujący żądania HTTP nie potrzebuje takich uprawnień. Jeśli oba zadania wykonuje to samo konto, kod uruchamiany podczas instalacji może nadpisać kod obsługujący użytkowników, a kod uruchamiany w czasie pracy aplikacji może zrobić to samo.

Należy je rozdzielić. Budowanie powinno odbywać się na jednym koncie, a serwowanie na innym. Katalog z kodem źródłowym powinien być dostępny tylko do odczytu dla konta serwującego:

sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp

Następnie należy wymusić to ograniczenie za pomocą systemd. Utwórz plik /etc/systemd/system/nodeapp.service:

[Unit]
Description=Node application
After=network-online.target

[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

Opcja ProtectSystem=strict montuje cały system plików jako tylko do odczytu dla tej usługi, z wyjątkiem /dev, /proc, /sys oraz ścieżek wymienionych w ReadWritePaths. Próba zapisu przez aplikację do node_modules zakończy się błędem EROFS: read-only file system, który można odnaleźć w dziennikach w ciągu minuty. Opcja NoExecPaths obejmuje katalog z przesyłanymi plikami: usługa może tam zapisywać pliki, ale jądro systemu odmawia ich wykonywania. Ta opcja wymaga systemd w wersji 249 lub nowszej; Ubuntu 24.04 dostarcza wersję 255.

W tym pliku jednostki należy unikać dwóch pułapek. Po pierwsze, nie dodawaj MemoryDenyWriteExecute=yes. Pojawia się ona w większości list zaostrzania zabezpieczeń systemd, ale uniemożliwia uruchomienie Node, ponieważ V8 kompiluje JavaScript do kodu maszynowego w czasie wykonywania i wymaga stron pamięci, które są jednocześnie zapisywalne i wykonywalne. Po drugie, pobierz ścieżkę ExecStart z command -v node. Jeśli Node został zainstalowany za pomocą menedżera wersji, znajduje się on w katalogu domowym użytkownika wdrażającego. W takim przypadku ProtectHome=yes ukryje ten katalog przed usługą, a jednostka natychmiast zakończy działanie z błędem status=203/EXEC i wpisem w dzienniku informującym o braku możliwości zlokalizowania pliku wykonywalnego.

Zweryfikuj wynik zamiast polegać wyłącznie na pliku:

sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probe

Polecenie systemd-analyze security wyświetla wszystkie ustawienia zabezpieczeń wraz z ich stopniem ekspozycji, co pozwala sprawdzić, które z nich nadal korzystają z wartości domyślnych. Polecenie touch powinno zakończyć się błędem Permission denied, ponieważ użytkownik nodeapp nie jest właścicielem żadnego pliku w current. Jeśli polecenie zakończy się powodzeniem, oznacza to, że uprawnienia do plików są nieprawidłowe, a ustawienia systemd jedynie maskują ten problem.

Uwaga dotycząca EnvironmentFile: systemd odczytuje ten plik jako root, zanim przełączy się na użytkownika User=nodeapp, więc plik ten może być własnością root:root z uprawnieniami 600. Aplikacja nadal otrzyma zmienne środowiskowe. Każdy, kto posiada dostęp do powłoki jako nodeapp, nadal może odczytać je z /proc/<pid>/environ, więc zabezpieczenie to chroni sekret w spoczynku, a nie działający proces.

Przechowywanie poświadczeń wdrożeniowych poza środowiskiem budowania

Skrypty instalacyjne dziedziczą środowisko. Ten jeden fakt powinien decydować o miejscu budowania aplikacji.

Najbezpieczniejszym rozwiązaniem jest budowanie w miejscu innym niż serwer produkcyjny i skopiowanie gotowego katalogu. Maszyna budująca posiada wtedy jedynie token rejestru z uprawnieniami tylko do odczytu. Brak w niej klucza SSH do wdrożeń, klucza dostępu do chmury, hasła do bazy danych czy loginu do rejestru kontenerów.

npm token create --read-only

Token z uprawnieniami tylko do odczytu pozwala pobierać pakiety, ale nie umożliwia ich publikowania. W przypadku jego kradzieży ze środowiska budowania, skutkiem jest jedynie możliwość pobierania publicznych pakietów.

Jeśli budowanie na serwerze jest konieczne, należy wykonywać je jako użytkownik deploy z celowo ograniczonym środowiskiem, a sekrety wykonawcze przechowywać w /etc/nodeapp/env, do których deploy nie ma dostępu. To samo rozumowanie dotyczy samodzielnie hostowanej automatyzacji budowania: samodzielnie hostowany runner GitHub Actions przechowuje tokeny i wykonuje dowolny opublikowany kod przy każdym zadaniu, co czyni go maszyną o najwyższej wartości w małym wdrożeniu. Każdy program, którego nie napisałeś, a który otrzymuje dostęp do całego środowiska, należy do tej samej kategorii. Dlatego utrzymywanie sekretów poza środowiskiem agenta AI jest tym samym problemem, w którym pośredniczy inny program.

Przypinanie lub vendoring zależności, których nie można poddać audytowi

Przypięta zależność to taka, której wersja nie ulegnie zmianie bez wykonania commita. Zatwierdzony plik blokady (lockfile) zapewnia to dla całego drzewa zależności. Dwa przypadki wymagają jednak dodatkowych działań.

Pierwszym z nich są zależności przechodnie. Nie masz kontroli nad tym, od czego zależą Twoje pakiety. overrides w package.json wymusza określoną wersję w dowolnym miejscu drzewa:

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

Uruchom npm install jednokrotnie po dodaniu wpisu, aby plik blokady zarejestrował wynik, a następnie wykonaj commit obu plików.

Drugim przypadkiem jest pakiet, którego nie możesz poddać audytowi, a którego nie możesz usunąć. Zastosuj vendoring. npm pack pobiera dokładnie ten sam plik tarball, który serwowałby rejestr, a zależność file: instaluje pakiet z Twojej kopii:

npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/
{
  "dependencies": {
    "some-lib": "file:vendor/some-lib-1.4.2.tgz"
  }
}

Plik tarball znajduje się teraz w Twoim repozytorium i nie zmieni się bez Twojej wiedzy. Od tego momentu bierzesz na siebie odpowiedzialność za jego aktualizacje, dlatego stosuj tę metodę tylko w przypadku małych, porzuconych pakietów, od których jesteś zależny, a nie dla głównego frameworka webowego.

Istnieje również okres karencji, który nie wiąże się z żadnymi kosztami:

npm install --before=2026-08-01

Opcja before przebudowuje drzewo, używając wyłącznie wersji opublikowanych w tym dniu lub wcześniej. Ustaw datę o tydzień lub dwa wstecz podczas odświeżania zależności, aby uniknąć okresu, w którym wadliwe wydanie jest dostępne, ale jeszcze nie zostało zgłoszone. Jest to narzędzie mało precyzyjne, ponieważ wstrzymuje również istotne poprawki bezpieczeństwa. Używaj go do ustalenia zakresów wersji, zapoznaj się ze zmianami, a następnie wykonaj commit pliku blokady.

Jak sprawdzić, która wersja została faktycznie wydana?

Plik blokady w git wskazuje, co powinno zostać zainstalowane. Stan dysku wskazuje, co jest zainstalowane. Tylko ten drugi jest wiarygodnym dowodem.

npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"

npm ls odczytuje node_modules, więc raportuje stan faktyczny, a nie zamierzenia zawarte w pliku blokady. Linia node -e odczytuje zainstalowany manifest według ścieżki, co działa nawet dla pakietów, których pole exports blokuje importy podścieżek, i wypisuje jedną wersję bez rysowania drzewa zależności.

Aby przeprowadzić drugą część porównania, należy odczytać git:

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

Należy utrwalić powiązanie między tymi dwoma stanami, umieszczając commit w strukturze wdrożeniowej. Wydanie należy umieścić w /srv/nodeapp/releases/<short commit sha> i wskazać na nie za pomocą dowiązania symbolicznego /srv/nodeapp/current. Odpowiedź na pytanie „co jest aktualnie uruchomione” staje się readlink /srv/nodeapp/current i jest dostępna o godzinie 03:00 dla osoby, która nie przeprowadzała wdrożenia.

Na koniec należy sprawdzić, co potwierdza rejestr:

npm audit signatures

To polecenie weryfikuje podpisy rejestru dla pakietów w zainstalowanym drzewie oraz sprawdza atestacje pochodzenia dla pakietów, które je posiadają. Pochodzenie wiąże opublikowane archiwum tarball z publicznym procesem ciągłej integracji (CI), w którym zostało wytworzone. Zweryfikowana atestacja oznacza, że kod można powiązać z konkretnym commitem, a nie z nieznanym komputerem lokalnym. Pokrycie nie jest uniwersalne, dlatego brak atestacji należy interpretować jako „brak informacji”, a nie jako „wadliwy pakiet”.

Jak postępować po wdrożeniu wadliwej wersji na serwer

Należy wyjść od tego, co zostało uruchomione i na jakim koncie użytkownika.

Jeśli kod został wykonany podczas instalacji, należy założyć, że wszystkie pliki dostępne dla użytkownika budującego zostały przejęte. Należy zrotować token rejestru, klucze SSH w tym katalogu domowym, poświadczenia chmurowe oraz każdy sekret wyeksportowany w tej powłoce. Rotacja jest jedynym uczciwym rozwiązaniem, ponieważ nie da się udowodnić, że plik nie został odczytany.

Jeśli kod został wykonany w czasie działania usługi na koncie z ograniczonymi uprawnieniami, zakres dostępu jest znacznie mniejszy: obejmuje zmienne środowiskowe aplikacji oraz wszystko, do czego ma ona dostęp przez sieć. To jest główny argument za uruchamianiem usług jako użytkownicy bez uprawnień root na VPS. Nie zapobiega to naruszeniu bezpieczeństwa. Decyduje natomiast o tym, jak dużą część maszyny obejmie naruszenie i czy przetrwa ono restart.

Następnie należy przeprowadzić przebudowę zamiast czyszczenia. Usuń node_modules, przypnij dotknięty pakiet do wersji niższej niż wadliwa w package.json, uruchom npm install jednokrotnie, aby zaktualizować plik blokady, zatwierdź zmiany i wdróż za pomocą npm ci. Nie należy naprawiać drzewa plików w miejscu. Nie da się wyliczyć wszystkich elementów, których dotknął skrypt instalacyjny.

Należy również zapisać przedział czasowy: od pierwszego wdrożenia, które mogło pobrać wadliwą wersję, do wdrożenia, które ją usunęło. Ten zakres wskazuje, które własne logi należy przejrzeć; jest to możliwe do ustalenia tylko wtedy, gdy wydania są nazwane zgodnie z identyfikatorami commitów.

Czego te metody nie naprawiają

Plik blokady (lockfile) nie czyni zależności bezpieczną. Zamienia on moment zaakceptowania zależności w datowaną, przejrzaną decyzję, zamiast pozostawiać ją jako efekt uboczny wdrożenia. Każda z powyższych praktyk dokonuje tej samej konwersji: z przypadku w świadomy wybór.

npm audit nie stanowi tutaj ochrony. Narzędzie to porównuje drzewo zależności z bazą danych zgłoszonych podatności, więc wykrywa problemy, które zostały już opublikowane i nazwane. Atak na łańcuch dostaw pozostaje nienazwany przez cały okres swojej skuteczności. Uruchamiaj npm audit w celu wykrycia starych, znanych błędów, ale nie oczekuj od niego informacji o wydaniu, które pojawiło się cztery godziny temu.

Zmniejszenie liczby zależności pomaga bardziej niż jakiekolwiek narzędzie opisane w tym przewodniku, choć jest to najmniej popularna porada. Każdy pakiet, którego nie dodasz, to jeden wydawca mniej, który może zostać poddany phishingowi w Twoim imieniu, oraz jeden skrypt instalacyjny mniej, który nigdy nie uruchomi się na koncie użytkownika wdrażającego.

Żaden z tych problemów nie dotyczy wyłącznie npm. Te same cztery schematy mają zastosowanie do PyPI, RubyGems, obrazów kontenerów oraz menedżera pakietów Twojej dystrybucji. W npm zjawisko to jest najbardziej widoczne, ponieważ drzewa zależności są najgłębsze, a skrypty instalacyjne uruchamiają się domyślnie. To, w jakim stopniu otaczająca maszyna podlega Twojej ochronie, zależy od miejsca jej uruchomienia, co stanowi część szerszego pytania o bezpieczeństwo hostingu VPS.

FAQ

Czy npm ci chroni przed zainfekowaną paczką npm?

Chroni przed zmianą wersji bez wiedzy użytkownika. npm ci instaluje dokładnie to, co zapisano w package-lock.json, weryfikuje każdy plik tarball pod kątem sumy kontrolnej sha512 i przerywa działanie z błędem, jeśli package.json nie zgadza się z plikiem blokady, zamiast próbować rozwiązać różnice. Nie gwarantuje to jednak bezpieczeństwa przypiętej wersji. Jeśli w repozytorium zostanie zatwierdzony plik blokady wskazujący na złośliwą wersję, npm ci wiernie zainstaluje ją na każdym serwerze przy każdej operacji.

Czy należy ustawić ignore-scripts=true dla wszystkiego?

Należy to ustawić, a następnie stworzyć listę dozwolonych elementów. ignore-scripts=true w pliku .npmrc projektu blokuje uruchamianie skryptów instalacyjnych zależności, co eliminuje najprostszą drogę od złośliwej paczki do poświadczeń użytkownika wdrażającego aplikację. Paczki kompilujące natywne dodatki lub pobierające gotowe pliki binarne faktycznie wymagają skryptów, a przy ich wyłączeniu zakończą działanie błędem braku pliku powiązania w czasie wykonywania, zamiast w czasie instalacji. Należy uruchomić npm ci --ignore-scripts, a następnie npm rebuild <package> dla tych kilku paczek, które uznano za zaufane. npm query ":attr(scripts, [postinstall])" pokazuje, ile ich jest w rzeczywistości.

Jak sprawdzić, która wersja paczki została faktycznie zainstalowana na serwerze?

Należy sprawdzić zawartość dysku, a nie plik blokady. npm ls <package> raportuje stan faktyczny w node_modules, a node -e "console.log(require('./node_modules/<package>/package.json').version)" wyświetla tylko ciąg wersji. Plik blokady w git odpowiada na inne pytanie: co powinno zostać zainstalowane. Porównanie tych dwóch źródeł jest kluczowe. Wdrażanie aplikacji w katalogu nazwanym zgodnie z commit git pozwala zachować dostęp do obu informacji nawet po miesiącach.

Czy npm audit wykrywa ataki na łańcuch dostaw?

Nie. npm audit porównuje drzewo zależności z bazą zgłoszonych podatności, więc wykrywa tylko problemy, które zostały już opublikowane i otrzymały identyfikator. Złośliwe wydanie pozostaje niezgłoszone przez godziny lub dni, w których instalacja stanowi zagrożenie. npm audit signatures jest bardziej użytecznym poleceniem: weryfikuje podpisy rejestru w zainstalowanym drzewie i sprawdza atestacje pochodzenia, jeśli zostały wygenerowane przez wydawcę. Pozwala to potwierdzić, że plik tarball pochodzi z publicznego procesu budowania, a nie z nieznanej maszyny.

Dlaczego uruchamianie aplikacji jako użytkownik bez uprawnień ma znaczenie, skoro atak następuje w czasie instalacji?

Ponieważ oba rodzaje awarii mają różny zasięg, a obrona musi obejmować oba scenariusze. Kod uruchamiany w czasie instalacji działa z uprawnieniami użytkownika wdrażającego i może odczytać jego klucze SSH, tokeny rejestru oraz poświadczenia chmurowe. Kod uruchamiany w czasie pracy aplikacji działa jako konto usługi, a dzięki User=nodeapp, ProtectSystem=strict i braku poświadczeń na dysku, jego zasięg ogranicza się do środowiska aplikacji i jej bazy danych. Rozdzielenie kont oznacza również, że proces obsługujący ruch sieciowy nie może nadpisać node_modules, więc skutki naruszenia bezpieczeństwa w czasie pracy znikają po restarcie, zamiast stać się trwałe.