jak jsme na to přišli
Stalo se to při úplně běžném releasu ve všední den, na Next.js 15 appce s App Routerem, pár interními balíčky, API nad PostgreSQL a klasickou hromadou JavaScript dependencies, která roste potichu, zatímco se nikdo nedívá. Jeden pull request aktualizoval malý transitive package, nic zajímavého, jen lockfile diff plný churnu, který by většina týmů bez přemýšlení pustila dál, protože top-level package.json se skoro nehýbal. Právě to byl problém. Nebezpečný kód nepřišel přes zjevné rozhodnutí přidat dependency, přišel přes důvěru, že package manager udrží dependency graph nudný.
První užitečný signál přišel z CI jobu, který se během installu začal sekat a pak posílat outbound requesty na host, který nikdo neznal. Install kroky pouštíme v omezeném prostředí, což pomohlo, protože balíček zkoušel spustit postinstall script a stáhnout remote payload. Request selhal, a to dost nahlas. Kdyby stejný install běžel na méně omezeném runneru nebo na vývojářském laptopu se širším přístupem k interním tokenům, řešili bychom mnohem ošklivější incident.
Podezřelý balíček měl v package.json zhruba tohle:
1{2 "scripts": {3 "postinstall": "node ./dist/install.js"4 }5}
To samo o sobě ještě nic nedokazuje, spousta balíčků dělá v lifecycle hookách různé hlouposti. Jenže dist/install.js byl obfuskovaný, string arrays, hex escapes, dynamický Function(...), prostě obvyklý odpad. Uvnitř byl malý loader, který přes https stáhl druhý stage script, zapsal ho do temp cesty a spustil. To nebyla analytika. To nebyl download binárky. To bylo remote code execution během instalace dependencies.
Lockfile jsme diffovali okamžitě. Důležité řádky byly tak malé, že se v review snadno přehlédnou:
1- suspicious-lib@1.4.2:2- resolution: {integrity: sha512-old...}3+ suspicious-lib@1.4.3:4+ resolution: {integrity: sha512-new...}5+ hasBin: true6+ requiresBuild: true
requiresBuild: true u balíčku, který předtím žádný build step neměl, by mělo každého zarazit. Většinou nezarazí. Kvůli jednomu fieldu jsme pak psali postmortem.
jak velký to mohlo mít dopad
Co lidi u kompromitovaných balíčků podceňují, je timing. Aby to byl průšvih, nepotřebujete, aby si production kontejnery při startu dělaly npm install. Stačí jeden CI runner se zápisem do artifactů, jeden GitHub Actions token s default permissions, jeden build stroj, který injectuje SENTRY_AUTH_TOKEN, NEXT_PUBLIC_* hodnoty, cloud credentials nebo token do privátní package registry. V tu chvíli útočníka frontendový balíček už nezajímá, zajímá ho váš release pipeline.
Náš Next.js build měl úplně běžný footprint, next build generoval server bundle, konfiguraci image optimization, sahal na environment proměnné při kompilaci a deploy step pak posílal image dál. Malicious package se spustil ještě před tím vším. Kdyby loader prošel, mohl vysbírat environment proměnné, upravit emitnuté assety, patchnout next.config.ts, sáhnout do vygenerovaného server kódu pod .next/server nebo zapsat credential stealer do build artifactu, který by vypadal jako neškodný helper modul.
Jedna nepříjemná věc je, že Next.js appky mají tendenci rozmazávat runtime hranice víc, než si lidi připouštějí. Client kód, edge kód, server actions, route handlery, to všechno sedí v jednom repu, často v jednom buildu, často v jednom installu. Kompromitovaný balíček ve sdílené utility cestě tak může ovlivnit mnohem víc než statickou marketingovou stránku. V jednom interním review ve steezr jsme narazili na týmy, které tahaly markdown renderery, analytics helpery a malé AST utility do server i client bundlů, protože tree shaking to na první pohled zlevnil. Je to levné jen do chvíle, než někdo převezme account autora balíčku.
Nejtěsnější únik u nás nebyl browser compromise. Byl to CI compromise. Náš GitHub Actions workflow měl pořád širší oprávnění, než potřeboval:
1permissions:2 contents: write3 packages: write4 id-token: write
Tahleta konfigurace tam zůstala, protože to bylo před pár měsíci pohodlné. Pohodlí má tendenci zůstávat. Malicious install step na tom runneru měl dost prostoru na pivot do publishování balíčků nebo manipulace s releasem. Jednou jsme měli štěstí. Podruhé na něj nesázím.
diffy, na kterých záleželo
Jakmile jsme balíček izolovali, přestali jsme lockfile brát jako šum generovaný strojem a začali ho číst jako source code. Většina týmů tvrdí, že lockfile je posvátný, a pak ho reviewuje se stejnou péčí, jakou by věnovala minified vendor blobu. To je přesně obráceně. Lockfile je místo, kde je supply-chain kompromitace vidět.
Vytáhli jsme tři diffy. Za prvé package.json kompromitované verze, stažený přímo z registry tarballu. Za druhé výpis souborů v tarballu. Za třetí změnu v lockfile mezi posledním dobrým buildem a tím špatným. Diff balíčku řekl všechno dost rychle:
1{2 "name": "suspicious-lib",3- "version": "1.4.2",4+ "version": "1.4.3",5 "main": "dist/index.js",6+ "scripts": {7+ "postinstall": "node dist/install.js"8+ },9 "files": [10- "dist/index.js"11+ "dist/index.js",12+ "dist/install.js"13 ]14 }
Tohle se nikdy nemělo dostat přes review bez otázky. Nové lifecycle hooky v patch releasu jsou obrovská červená kontrolka. Nové executable soubory v dist/ taky. Lockfile pak ukázal rotaci integrity hashe, což je při změně verze normální, plus metadata naznačující execution během installu. Teď automaticky failujeme CI, pokud dependency přidá preinstall, install, postinstall, prepare nebo prepublishOnly, pokud není balíček na explicitním allowlistu.
Script, který jsme přidali, je záměrně přímočarý:
1node scripts/audit-lifecycle-hooks.mjs pnpm-lock.yaml allowlist/lifecycle-hooks.json
Vyřeší package tarbally, projde package.json, porovná je s verzovaným allowlistem a při nových hookách skončí non-zero. Žádná AI. Žádné fuzzy heuristiky. Jen deterministické zamítnutí.
Začali jsme taky generovat review artifact pro každý dependency PR, obyčejný textový souhrn přidaných balíčků, odebraných balíčků, nových scriptů, nových binárek, native addonů a změněných registries. Seniorní inženýr si přečte 40 řádků risk summary. 2 700 řádků lockfile churnu číst nebude, pokud už předem něco nesmrdí.
install musí být nuda
Oprava nebyla jedna silver bullet. Byla to sada nudných omezení, která výrazně ztěžují zneužití kódu spouštěného při installu. U existujících projektů, které ještě nepřešly dál, jsme sjednotili pnpm 8, a tam, kde repo podporuje novější pnpm, používáme novější verzi. Přísné flagy máme zapojené v local devu, CI i container buildech. Konkrétní command je méně důležitý než celkový postoj. Instally mají být reprodukovatelné, pokud možno offline a nepřátelské vůči nečekané mutaci.
V CI teď používáme:
1pnpm install --frozen-lockfile --ignore-scripts --prefer-offline
Pokud projekt opravdu potřebuje build scripty pro známý balíček, povolíme to v odděleném, úzce omezeném kroku až po validaci. Default na --ignore-scripts okamžitě zavře nejlínější útočnou cestu. V npm repozitářích používáme:
1npm ci --ignore-scripts --prefer-offline
Lidi namítají, že tím některé balíčky rozbijete. Dobře. Pak ty balíčky pojmenujte a obhajte v code review. Skryté spouštění scriptů během installu je příšerný default.
Immutable lockfile je bez debaty. CI musí failnout, pokud se lockfile změní. Docker buildy musí nejdřív kopírovat package.json a lockfile, udělat install a teprve potom zbytek souborů, protože chcete mít dependency graph připnutý dřív, než do hry vstoupí aplikační kód. V CI navíc blokujeme install commandy, které by lockfile přepisovaly, a kde to jde, buildy pouštíme v read-only workspace.
Package allowlisty pomáhají víc, než většina lidí čeká. Nemyslím obří seznam všech dependencies, to je jen divadlo. Myslím allowlist výjimek, balíčky, které smějí spouštět lifecycle hooky, balíčky, které smějí vozit native addony, registry, ze kterých se smí fetchovat. Všechno ostatní je defaultně deny. Jedna naše interní služba teď failne, pokud se balíček resolvne odkudkoli jinud než z https://registry.npmjs.org/ a našeho privátního scope.
Nudný install je dobrý install. Jakmile instalace balíčků působí dynamicky, chytře nebo magicky, polovinu boje jste už prohráli.
provenance a podpisy
Integrity hashe balíčků v lockfile chrání před některými typy manipulace, ale neříkají vám, jestli věc, kterou jste připnuli, vznikla v důvěryhodném build procesu. Tady pomáhá provenance. Ano, v JavaScript ekosystému je to pořád dost nevyrovnané, takže si musíte odpracovat víc, než byste chtěli.
Přidali jsme Sigstore verification pro balíčky, které publikujeme sami, a pro interní release procesy, které produkují artifacty spotřebovávané jinými aplikacemi. U container images a release bundlů ověřujeme v CI pomocí cosign ještě před promotion. Ořezaná kontrola vypadá takhle:
1cosign verify \2 --certificate-identity "https://github.com/steezr/portal/.github/workflows/release.yml@refs/heads/main" \3 --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \4 ghcr.io/steezr/portal-web:sha-2f4c9d1
Samo o sobě vás to před malicious upstream npm balíčkem nezachrání. Pořád to dává smysl, protože to zavírá mezeru mezi kompromitovaným build runnerem a důvěryhodným deployment artifactem. Pokud útočník upraví build mimo očekávanou workflow identitu, verification failne a deployment se zastaví.
U npm balíčků se podpora provenance zlepšila, ale ne natolik rovnoměrně, abych komukoli doporučil se na ni spoléhat jako na jedinou věc. Ověřujeme, co jde, zapisujeme metadata publishera a flagujeme změny v maintainerech, publish tooling i struktuře tarballu. Balíček, který najednou přepne z čistého source publish na neprůhledný prebuilt bundle, si zaslouží pozornost. Stejně tak balíčky, které začnou vozit soubory navíc, hlavně executable installery.
Jedno praktické pravidlo, které teď vynucujeme, je jednoduché: interní balíčky používané v našich Next.js appkách se musí publikovat z GitHub Actions s podpisem opřeným o OIDC a cesta k workflow souboru je připnutá v policy. Tím získáme konkrétní chain of custody. Současně to týmy donutí přestat publishovat z náhodných laptopů ve 23:40 těsně před launchi, což byl vždycky špatný zvyk a teď k tomu máme i bezpečnostní důvod.
utáhněte actions
GitHub Actions byla druhá půlka celého incidentu, protože malicious dependency je nebezpečná přesně úměrně tomu, kam runner dosáhne. Většina týmů nechává defaulty zbytečně otevřené a pak se tváří překvapeně, když kompromitovaný step umí přepsat tagy, pushnout balíčky, vydat cloud credentials nebo otevřít pull requesty, které si do repa propašují persistenci.
Utáhli jsme každý workflow. Default permissions jsou teď jen read-only, dokud job neprokáže, že potřebuje víc:
1permissions:2 contents: read
Joby, které publishují image, dostanou jen to nutné:
1permissions:2 contents: read3 packages: write4 id-token: write
Nic navíc. Žádný plošný write access u test jobů, žádné zděděné scopes tokenů jen proto, že byly ve starter template. Tajemství jsme přesunuli z workflow, které je nepotřebují, vypnuli jsme přístup nedůvěryhodných pull requestů k citlivým jobům, third-party actions připínáme na plné commit SHA a pull_request_target máme zakázaný kromě úzce reviewovaných případů. Ten event je past.
Jedno konkrétní opatření pomohlo hodně: install dependencies běží v jobu, který nemá deploy credentials, token na publish balíčků ani write oprávnění do repa. Build artifacty jdou dál teprve ve chvíli, kdy projdou policy checks. Když se malware při installu spustí v té první fázi, narazí do zdi, ne do otevřené chodby.
Důležitá jsou i runtime opatření. Production images buildíme jednou, pak je beze změny promotujeme a tam, kde to platforma umí, běží s read-only root filesystemem. Egress je omezený. Kontejnery nepotřebují sahat na libovolné hosty. Next.js servery nedostávají shell tools, pokud k tomu není opravdu dobrý důvod, a ten většinou není. Spousta doporučení pro supply-chain hardening končí u installu balíčků. Chytřejší přístup je počítat s tím, že jedna vrstva selže, a zařídit, aby další pivot bolel.
pravidla teď
Policy jsme sepsali ještě ten samý den, protože vágní bezpečnostní záměry se rozpadají rychle. Aktuální pravidla pro naše Next.js projekty jsou přímočará a mně to tak vyhovuje. Radši tvrdá pravidla než elegantní výjimky, na které si za půl roku nikdo nevzpomene.
Každé repo musí mít commitnutý lockfile, CI používá pnpm install --frozen-lockfile --ignore-scripts --prefer-offline nebo npm ci --ignore-scripts --prefer-offline, lifecycle hooky jsou zakázané, pokud nejsou na allowlistu, third-party GitHub Actions jsou připnuté přes SHA, workflow permissions defaultují na contents: read a dependency updates dostávají do pull requestu vygenerovaný risk summary. Interní balíčky se musí publikovat přes podepsané CI workflow. Buildy, které skripty opravdu potřebují, je spouštějí v druhé fázi bez secrets a s přesně zdokumentovanými názvy balíčků.
Přidali jsme i malou kontrolu, která chytá jednu z nejhloupějších a zároveň nejčastějších chyb, náhodný drift lockfilu, když někdo použije jiný package manager nebo jinou major verzi. Pokud packageManager v package.json říká pnpm, CI failne na změnách vygenerovaných npm. Pokud repo očekává npm, cizí pnpm-lock.yaml je tvrdý stop. Kombinace nástrojů dělá bordel v diffech a bordel v diffech schovává špatné věci.
Nic z toho není exotika. O to jde. Dá se to nasadit v normálním týmu, aniž by se delivery změnilo v nekonečný bezpečnostní obřad. Ve steezr pořád shipujeme rychle, pro startupy, interní systémy, AI-heavy produkty, nativní appky, prostě napříč celým spektrem, a rychlost to přežije úplně v pohodě, když máte rozumné defaulty. Co nepřežije, je fantazie, že npm install je neškodný setup krok. Je to remote code execution nad vaším build chainem zabalené do pohodlného commandu a přesně tak se k tomu musí přistupovat pokaždé.
