Čtvrtého srpna ráno přišla zpráva, kterou si každý, kdo provozuje něco na Node.js, přečte s nepříjemným pocitem v žaludku: v npm běží aktivní červ. Kompromitovaný účet údržbáře rodiny balíčků keyv a cacheable, škodlivý preinstall hook, krádež přihlašovacích údajů a tokenů, republikace do dalších balíčků ukradenými tokeny. Sám sebe rozesílá dál. Elastic Security Labs ho pojmenovaly CHAINDROP, ostatní ho vedou jako třetí vlnu linie Shai-Hulud.
Prvních pět minut jsem strávil čtením. Další dvě hodiny něčím úplně jiným – a to je celý důvod, proč tenhle text píšu. Skoro všechno, co jsem ten den o incidentu četl, o hrozbě informovalo. Málo co odpovídalo na otázku, kterou jsem ve skutečnosti měl: co konkrétně musím zkontrolovat, aby věta „jsme čistí“ byla něco jiného než dojem?
Ukázalo se, že odpověď má sedm bodů. Šest z nich je nudných. Sedmý mě naučil něco, co jsem nečekal.
Co ten červ dělá
Stručně, protože technický rozbor už napsali lidé, kteří na to mají laboratoř. Útočník získal přístup ke GitHub účtu údržbáře a commitoval přímo do hlavní větve, pak hned vydal novou verzi. Publikační pipeline udělala zbytek. Balíček dostal preinstall skript setup.mjs, ten si po instalaci stáhne samostatný běhový soubor Bunu, spustí obfuskovanou druhou fázi a začne sbírat, co najde: cloudové přihlašovací údaje, tokeny do registrů, GitHub tokeny, připojovací řetězce k databázím, privátní klíče. Ukradenými publikačními tokeny pak trojanizuje další balíčky. Odtud „červ“.
Rozsah se v prvních hodinách přepisoval každou chvíli, a proto ho uvádím jako rozpětí, ne jako číslo: zdroje se pohybují mezi 400 a 450 balíčky a 1 300 až 2 250 zavirovanými verzemi, dohromady zhruba 1,3 až 2 miliardy stažení měsíčně (Elastic, StepSecurity, Aikido, Datadog). Kdo vám k prvnímu dni řekne přesné číslo, buď měřil později, nebo si vybral zdroj, který se mu hodil.
Jedna vlastnost je pro dnešek podstatnější než čísla: červ si zajišťuje perzistenci v hoocích vývojového prostředí. Nezůstane u toho, že něco ukradne. Snaží se zůstat.
Sedm kontrol
Tohle je postup, který jsem prošel – ve stejném pořadí, protože každý další krok kontroluje díru, kterou předchozí nechal otevřenou.
1. Lockfily. Hledal jsem známé zavirované verze a podezřelé scopy @cacheable/* a @servicetitan/* napříč všemi lockfily, které na stroji mám. Osm lockfilů, rodina se nevyskytuje vůbec. Kdyby to bylo všechno, byla by ta kontrola k ničemu – protože:
2. Co je opravdu nainstalované, a to uvnitř běžícího kontejneru. Lockfile je záměr, ne stav. Říká, co se mělo nainstalovat tehdy, kdy ho někdo naposledy zapsal. Co v kontejneru na disku leží dnes, je jiná otázka a odpovídá na ni jen ten kontejner. V jednom projektu tu rodinu balíčků skutečně mám; uvnitř běžícího kontejneru vyšlo keyv 4.5.4, flat-cache 4.0.1, file-entry-cache 8.0.0. Bezpečné verze, sedící na to, co tvrdil lockfile. Teprve tímhle druhým měřením ta věta o lockfilech začala něco znamenat.
3. setup.mjs. Soubor, který červ podstrkuje jako preinstall hook. Hledal jsem ho lokálně, na serveru i uvnitř kontejneru. Nula výskytů.
4. Bun. Červ si ho stahuje jako runtime pro druhou fázi. Nepatří do žádného z mých projektů, takže jeho pouhá přítomnost by byla nález sama o sobě. Není v PATH, není v ~/.bun, není na serveru, není v kontejneru.
5. ~/.npmrc. Cíl krádeže tokenů. Obsahuje jediný řádek, prefix=. Žádný token. Na serveru ten soubor vůbec neexistuje.
6. Hooky editoru a Claude Code. Tady je to zajímavé místo a přiznám se, že mě donutilo zpomalit. Červ si zajišťuje perzistenci právě tady – a já jsem do těch hooků ten samý den sám sahal. Cizí zásah by se schoval mezi moje vlastní změny; „vypadá to normálně“ by neznamenalo nic, protože ono to normálně vypadá vždycky. Musel jsem projít pracovní strom, registr hooků i posledních pět commitů a poznat každou změnu jako svoji. Ne „nic podezřelého“, ale „tohle jsem psal já, tohle taky, a tenhle commit je můj“. Kontrola, kterou nedokážete uzavřít jmenovitě, není kontrola.
7. npm audit --package-lock-only. Ověřil jsem si, že tenhle příkaz opravdu neinstaluje – pošle jen obsah lockfilu do oficiální advisory databáze a vrátí odpověď. To je důležitější, než to vypadá: v momentě, kdy máte resolver zmrazený, potřebujete kontrolu, která nesáhne na registry ani na node_modules. Tahle nesáhne.
Osa celé věci: audit se ptá databáze, skener zná odpověď
Tady je rozdíl, kvůli kterému mají smysl obě věci a ani jedna sama.
npm audit porovná váš lockfile s databází známých zranitelností. Zná jen to, co už je zapsané. Mezi okamžikem, kdy útočník publikuje zavirovanou verzi, a okamžikem, kdy se ta verze objeví v advisory databázi, uplyne čas – u tohohle útoku se první škodlivá verze objevila krátce po deváté hodině UTC a analýzy chodily v průběhu celého dne. V tom okně vám audit s naprostou jistotou řekne „čisto“. Nelže; prostě odpovídá na jinou otázku, než si myslíte, že jste položili.
IOC skener je opak. Napíšete do něj natvrdo seznam verzí a indikátorů z čerstvých hlášení a on se nikoho neptá – zná odpověď předem. Zato zná jen tu jednu odpověď: na útok, který v něm někdo popsal. Na cokoli dalšího je slepý.
Napsal jsem si tedy sto řádků v Pythonu, které projdou lockfily, hledají setup.mjs, dívají se na hooky a vypíšou nalezené verze celé rodiny. Nic chytrého. Za dvacet minut práce mám kontrolu, kterou můžu spustit znovu za týden a dostat srovnatelný výsledek – a srovnatelnost je tady ta hodnota, ne důmyslnost. Jedno měření nedokazuje nic. Dvě stejná měření o den později dokazují, že se nic nezměnilo.
Co mě na tom nejvíc překvapilo: provenance nepomohla
Šel jsem do toho s přesvědčením, které se ukázalo jako chybné, a považuji za poctivé to napsat, protože jsem ho měl ještě ve chvíli, kdy jsem začal psát tenhle článek.
Myslel jsem si, že škodlivé publikace půjde poznat podle chybějící vazby na OIDC – že podvodné vydání nebude mít podpis z důvěryhodné publikační cesty, a tím se prozradí. Zní to logicky. Není to pravda. Útočník commitoval do hlavní větve a nechal legitimní publikační pipeline údržbáře, aby ty zavirované verze vydala. Vyšly tedy s platnou provenance podepsanou GitHub Actions. StepSecurity to shrnuli větou, kterou jsem si opsal: „Provenance dokazuje, který commit byl sestaven. Nedokáže dokázat, že ten commit byl autorizovaný.“
Můj vlastní publikační postup na OIDC trusted publishing stojí a nemá žádný dlouhožijící token, který by šlo ukrást. To je pořád správně a nemíním to měnit. Ale znamená to o jednu věc míň, než jsem si myslel: chrání to můj token, nedělá to podvodné vydání viditelným. Byl to kus falešného klidu, který jsem měl v hlavě týdny, a všiml jsem si ho jen proto, že jsem si šel to tvrzení ověřit místo abych ho napsal.
Provozní omezení – a dvě místa, kde jsem ho porušil
Z výše uvedeného plyne jedno pravidlo, a je jednoduché: dokud není registry pročištěná, nespouštět npm install ani npm update. To jsou jediné cesty, jak resolvnout závislost na verzi, která ještě nikoho nenapadla označit. npm ci je v pořádku, protože instaluje přesně to, co je v lockfilu, a nic nedoresolvuje.
A teď ta část, kterou by článek o bezpečnosti normálně vynechal.
To pravidlo bylo během několika dní dvakrát porušeno. Jednou aktualizací nástroje z příkazové řádky přes npm install -g – nástroj se aktualizoval sám a o mém zmrazení nevěděl. Podruhé tím, že automatizovaný agent při úplně jiném úkolu povytáhl verze frameworku a přepsal lockfile; 2 280 řádků změn, žádný zlý úmysl, jen běžná práce.
Obojí dopadlo čistě. Ale dopadlo to čistě proto, že rodina keyv v těch stromech nebyla – ne proto, že by zafungovala nějaká zábrana. To je štěstí, ne proces.
Poučení má jednu větu a stojí za všechny předchozí odstavce dohromady: pravidlo, které existuje jen v hlavě jednoho člověka a v jeho poznámkách, není zábrana. Nástroje o něm nevědí. Agenti o něm nevědí. Kolegové o něm nevědí. Zábrana je ignore-scripts=true a min-release-age v .npmrc (podporuje npm 11.10 a novější), npm ci --ignore-scripts v CI, commitnutý lockfile, egress allowlist na CI runnerech. Tedy věci, které platí i ve chvíli, kdy si na ně nikdo nevzpomene. Na tom se všechny zdroje k tomuhle incidentu shodují a mají pravdu.
Těsné míjení, které stojí za zmínku
Týž den, kdy útok začal, jsem v jednom projektu pustil generování databázového klienta přes npx a build. Ani jedno neresolvuje závislosti – obojí použije to, co už v node_modules leží. Proto to bylo neškodné.
npm install by byl jiný příběh, a mezi těmi dvěma příkazy je z pohledu člověka u klávesnice rozdíl asi tři písmena. Z pohledu bezpečnosti je to celý rozdíl mezi incidentem a klidným večerem: jeden příkaz sahá na registry, druhý jen používá to, co už je stažené. Doporučuji si tenhle rozdíl jednou pořádně projít pro nástroje, které používáte denně. Já to udělal až po tomhle týdnu.
Co si z toho beru
Věta „jsme čistí“ je z devadesáti procent případů zkratka za „ničeho jsem si nevšiml“. Rozdíl mezi tím a skutečným tvrzením není v tom, jak sebejistě se pronese, ale v tom, jestli za ní stojí jmenovitý seznam toho, co bylo změřeno, kde, a čím. Lockfile a instalace. Lokálně, na serveru a v kontejneru. Známé indikátory a advisory databáze. Hooky, které jsem musel poznat jako svoje.
A druhá věc, méně příjemná: každá kontrola, kterou udělá člověk, je jednorázová. Ta moje platí pro 4. a 5. srpna a pro jeden opakovaný běh potom. Co platí i příští úterý, když si na to nikdo nevzpomene, jsou jen ty čtyři řádky konfigurace. Práci, kterou jsem popsal výše, jsem odvedl dobře – a přesně proto vím, že není řešení.