[{"data":1,"prerenderedAt":452},["ShallowReactive",2],{"site":3,"writing-\u002Fwriting\u002Fan-install-policy-for-the-year-of-npm-worms":50,"writing-nav":439},{"id":4,"bookingLabel":5,"bookingUrl":6,"company":7,"contact":8,"extension":11,"hire":12,"legal":15,"location":16,"meta":17,"name":18,"nav":19,"owner":38,"social":39,"status":46,"stem":47,"tagline":48,"__hash__":49},"site\u002Fsite.yml","Book a call for what's next","https:\u002F\u002Fcal.com\u002Fanthive\u002Fintro","Ant Hive d.o.o.",{"heading":9,"intro":10},"Start with a call.","Thirty minutes, no slides. Tell me what you're building and when you need someone.","yml",{"heading":13,"intro":14},"If you're here to hire","Start with the problem you have. I'll tell you straight whether I'm the right person for it.","Ant Hive d.o.o., Braće Radića 41, Solin, Croatia. OIB 87072518531. MBS 05985889, Trgovački sud u Splitu. Share capital € 2.500, paid in full. Director: Ante Borzić.","Split, Croatia, CET",{},"Ant Hive",[20,23,26,29,32,35],{"label":21,"to":22},"Writing","\u002Fwriting",{"label":24,"to":25},"Work with me","\u002Fwork-with-me",{"label":27,"to":28},"On my desk","\u002Fdesk",{"label":30,"to":31},"About","\u002Fabout",{"label":33,"to":34},"Colophon","\u002Fcolophon",{"label":36,"to":37},"Contact","\u002Fcontact","Ante Borzić",[40,43],{"label":41,"to":42},"LinkedIn","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fante-borzic",{"label":44,"to":45},"GitHub","https:\u002F\u002Fgithub.com\u002Fb0rza","On a long-term contract.","site","Writing code is the easy part now. Musings and work from Ante Borzić, on the rest.","2Xoi9K5fmdspVs2Iabkuc-XV6mG1EfiZBm3RojZ-9K0",{"id":51,"title":52,"body":53,"date":424,"description":425,"draft":426,"extension":427,"headline":428,"meta":432,"minutes":221,"navigation":433,"path":434,"seo":435,"stem":436,"topic":437,"__hash__":438},"writing\u002Fwriting\u002Fan-install-policy-for-the-year-of-npm-worms.md","An install policy for the year of npm worms",{"type":54,"value":55,"toc":415},"minimark",[56,60,63,66,71,79,117,120,123,127,140,157,167,170,180,184,187,228,231,234,239,245,248,252,259,293,296,304,322,326,335,340,360,363,367,370,401,404,408,411],[57,58,59],"p",{},"By the end of this, a pnpm project has two properties. A package version published an hour ago can't get into an install, and nothing runs at install time that someone didn't approve by name. That takes five steps, none longer than a few lines of config.",[57,61,62],{},"The case for doing it now is this year's record. On 31 March the axios maintainer account was hijacked, and the bad release carried a remote access tool in an install script. In May, Mini Shai-Hulud spread through a compromised CI pipeline. In August, ChainDrop started at keyv and reached hundreds of packages by publishing through the projects' own trusted-publishing workflows. Its versions came with valid provenance, so \"we check provenance\" stopped being a policy.",[57,64,65],{},"The steps assume pnpm. Order matters: each one relies on the one before it.",[67,68,70],"h2",{"id":69},"pin-the-package-manager-and-pin-a-recent-one","Pin the package manager, and pin a recent one",[57,72,73,74,78],{},"Put the exact pnpm version in ",[75,76,77],"code",{},"package.json",":",[80,81,86],"pre",{"className":82,"code":83,"language":84,"meta":85,"style":85},"language-json shiki shiki-themes github-light github-dark","{\n  \"packageManager\": \"pnpm@12.14.0\"\n}\n","json","",[75,87,88,97,111],{"__ignoreMap":85},[89,90,93],"span",{"class":91,"line":92},"line",1,[89,94,96],{"class":95},"sVt8B","{\n",[89,98,100,104,107],{"class":91,"line":99},2,[89,101,103],{"class":102},"sj4cs","  \"packageManager\"",[89,105,106],{"class":95},": ",[89,108,110],{"class":109},"sZZnC","\"pnpm@12.14.0\"\n",[89,112,114],{"class":91,"line":113},3,[89,115,116],{"class":95},"}\n",[57,118,119],{},"Every machine and every deploy now runs the same pnpm, so the lockfile is read the same way everywhere.",[57,121,122],{},"The pin solves one problem and can create another. On a client site, I pinned pnpm to the 9.x version the deploy already used, so local installs matched production. It fixed the mismatch. It also kept the project on a release from before pnpm had a cooldown setting and before it blocked install scripts by default. A pin needs an owner who moves it forward, or the project quietly misses every protection that ships after it.",[67,124,126],{"id":125},"make-ci-and-docker-install-exactly-the-lockfile","Make CI and Docker install exactly the lockfile",[57,128,129,130,133,134,137,138,78],{},"Commit ",[75,131,132],{},"pnpm-lock.yaml"," and install with ",[75,135,136],{},"--frozen-lockfile",", which fails instead of updating the lockfile when it doesn't match ",[75,139,77],{},[80,141,145],{"className":142,"code":143,"language":144,"meta":85,"style":85},"language-dockerfile shiki shiki-themes github-light github-dark","COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .\u002F\nRUN pnpm install --frozen-lockfile\n","dockerfile",[75,146,147,152],{"__ignoreMap":85},[89,148,149],{"class":91,"line":92},[89,150,151],{},"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .\u002F\n",[89,153,154],{"class":91,"line":99},[89,155,156],{},"RUN pnpm install --frozen-lockfile\n",[158,159,160],"sidenote",{},[57,161,162,163,166],{},"The copy includes ",[75,164,165],{},"pnpm-workspace.yaml"," because the cooldown and the install script allowlist live there. Leave it out and the install runs without both.",[57,168,169],{},"pnpm turns this on by itself when it detects CI, but a Docker build usually isn't detected as CI, so pass the flag explicitly.",[57,171,172,173,175,176,179],{},"A version range in ",[75,174,77],{}," like ",[75,177,178],{},"^4.2.0"," can then never pull in a new release during a deploy. New versions only arrive through a lockfile change someone committed and reviewed.",[67,181,183],{"id":182},"wait-a-day-before-installing-anything-new","Wait a day before installing anything new",[57,185,186],{},"This is the cooldown. pnpm refuses to install a version until it has been public for a set number of minutes:",[80,188,192],{"className":189,"code":190,"language":191,"meta":85,"style":85},"language-yaml shiki shiki-themes github-light github-dark","# pnpm-workspace.yaml\nminimumReleaseAge: 1440\nminimumReleaseAgeExclude:\n  - some-package@4.2.1\n","yaml",[75,193,194,200,211,219],{"__ignoreMap":85},[89,195,196],{"class":91,"line":92},[89,197,199],{"class":198},"sJ8bj","# pnpm-workspace.yaml\n",[89,201,202,206,208],{"class":91,"line":99},[89,203,205],{"class":204},"s9eBZ","minimumReleaseAge",[89,207,106],{"class":95},[89,209,210],{"class":102},"1440\n",[89,212,213,216],{"class":91,"line":113},[89,214,215],{"class":204},"minimumReleaseAgeExclude",[89,217,218],{"class":95},":\n",[89,220,222,225],{"class":91,"line":221},4,[89,223,224],{"class":95},"  - ",[89,226,227],{"class":109},"some-package@4.2.1\n",[57,229,230],{},"Worm versions are usually spotted and pulled within hours, so a day keeps out most of them. All three worms above would have needed to stay undetected for a full day to get past it.",[57,232,233],{},"Since pnpm 11, 1440 minutes is the default. Write it down anyway. A reviewer can see it, and a pnpm downgrade can't remove it without anyone noticing.",[158,235,236],{},[57,237,238],{},"Exclude a version, not a package name. A bare name lets every future release skip the wait, including the bad one.",[57,240,241,242,244],{},"The cost is that an urgent security fix waits a day too. ",[75,243,215],{}," is the escape hatch: list the exact version you need now, and remove the entry once it's a day old.",[57,246,247],{},"Set the same delay on Renovate or Dependabot. Otherwise the bot opens a pull request for a version the cooldown then refuses, and someone wastes time on it.",[67,249,251],{"id":250},"only-let-approved-packages-run-install-scripts","Only let approved packages run install scripts",[57,253,254,255,258],{},"An install script runs with your user's permissions on every machine that installs the package. The axios release used one. ",[75,256,257],{},"allowBuilds"," lists the packages allowed to run them:",[80,260,262],{"className":189,"code":261,"language":191,"meta":85,"style":85},"# pnpm-workspace.yaml\nallowBuilds:\n  esbuild: true\n  unrs-resolver: true\n",[75,263,264,268,274,284],{"__ignoreMap":85},[89,265,266],{"class":91,"line":92},[89,267,199],{"class":198},[89,269,270,272],{"class":91,"line":99},[89,271,257],{"class":204},[89,273,218],{"class":95},[89,275,276,279,281],{"class":91,"line":113},[89,277,278],{"class":204},"  esbuild",[89,280,106],{"class":95},[89,282,283],{"class":102},"true\n",[89,285,286,289,291],{"class":91,"line":221},[89,287,288],{"class":204},"  unrs-resolver",[89,290,106],{"class":95},[89,292,283],{"class":102},[57,294,295],{},"Both of these need a build step because they install a native binary. Approve a package only when you can say why it needs one.",[158,297,298],{},[57,299,300,303],{},[75,301,302],{},"false"," is an answer too. A package whose install script only prints a message gets denied, and the decision is on record.",[57,305,306,307,310,311,314,315,317,318,321],{},"With ",[75,308,309],{},"strictDepBuilds"," on, which is the default since pnpm 11, an unapproved package with a build script stops the install with ",[75,312,313],{},"ERR_PNPM_IGNORED_BUILDS",". pnpm also adds it to ",[75,316,257],{}," with a placeholder value. You then decide on purpose, by editing the file or running ",[75,319,320],{},"pnpm approve-builds",", and the decision goes through review like any other change.",[67,323,325],{"id":324},"pin-exactly-the-packages-that-run-code-at-install","Pin exactly the packages that run code at install",[57,327,328,329,331,332,334],{},"For most dependencies, ranges in ",[75,330,77],{}," are fine, because the lockfile is the real pin. Packages on the ",[75,333,257],{}," list are different: a new version of one runs new code on install. Give those an exact version, so changing it is a deliberate commit.",[57,336,337,339],{},[75,338,257],{}," can also approve a version instead of a package, so a new release has to be approved again:",[80,341,343],{"className":189,"code":342,"language":191,"meta":85,"style":85},"allowBuilds:\n  esbuild@0.25.10: true\n",[75,344,345,351],{"__ignoreMap":85},[89,346,347,349],{"class":91,"line":92},[89,348,257],{"class":204},[89,350,218],{"class":95},[89,352,353,356,358],{"class":91,"line":99},[89,354,355],{"class":204},"  esbuild@0.25.10",[89,357,106],{"class":95},[89,359,283],{"class":102},[57,361,362],{},"The same goes for a package that's causing a problem right now. Pin it exactly until the problem is understood, then decide whether the pin stays.",[67,364,366],{"id":365},"check-that-each-setting-works","Check that each setting works",[57,368,369],{},"Each step has a check that takes a minute:",[371,372,373,380,386,392],"ul",{},[374,375,376,379],"li",{},[75,377,378],{},"pnpm --version"," in CI and in the Docker build prints the pinned version.",[374,381,382,383,385],{},"Changing a range in ",[75,384,77],{}," without updating the lockfile fails CI.",[374,387,388,391],{},[75,389,390],{},"pnpm add"," with a version published in the last hour is refused.",[374,393,394,395,397,398,400],{},"Removing an entry from ",[75,396,257],{}," and reinstalling stops with ",[75,399,313],{},".",[57,402,403],{},"The common place to get stuck is the Docker image. If it installs its own pnpm, the version that runs may not be the pinned one, so print it during the build.",[67,405,407],{"id":406},"where-this-policy-stops","Where this policy stops",[57,409,410],{},"These settings keep a fresh bad version out. They do nothing about one that's already in your lockfile, or one that stays undetected for longer than the cooldown. Finding those is a different job: auditing what you already have installed.",[412,413,414],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":85,"searchDepth":99,"depth":99,"links":416},[417,418,419,420,421,422,423],{"id":69,"depth":99,"text":70},{"id":125,"depth":99,"text":126},{"id":182,"depth":99,"text":183},{"id":250,"depth":99,"text":251},{"id":324,"depth":99,"text":325},{"id":365,"depth":99,"text":366},{"id":406,"depth":99,"text":407},"2026-10-16","Five steps for a pnpm project that keep a freshly published worm out of your installs, what each one stops, and what it costs you.",false,"md",[429,430,431],"An install policy","> for the year",">> of npm worms",{},true,"\u002Fwriting\u002Fan-install-policy-for-the-year-of-npm-worms",{"title":52,"description":425},"writing\u002Fan-install-policy-for-the-year-of-npm-worms","Security","2jyW3P3GzcqvQOvtogyKLTpgiKT82kDXKrT4TMan7Pk",[440,443,447,448],{"path":441,"title":442,"topic":437},"\u002Fwriting\u002Fhow-to-audit-a-system-with-an-ai-agent-that-never-gets-the-keys","How to audit a system with an AI agent that never gets the keys",{"path":444,"title":445,"topic":446},"\u002Fwriting\u002Fyour-token-cache-key-has-to-know-about-your-deployments","Your token cache key has to know about your deployments","Backend",{"path":434,"title":52,"topic":437},{"path":449,"title":450,"topic":451},"\u002Fwriting\u002Fwhy-our-purchase-event-waited-for-the-page-to-load","Why our purchase event waited for the page to load","Analytics",1793359541036]