<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ahmet cetin</title>
    <description>The latest articles on DEV Community by ahmet cetin (@myzura).</description>
    <link>https://dev.to/myzura</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4071325%2F7824fbe3-77ee-41b4-aeb8-70926fa14abc.jpg</url>
      <title>DEV Community: ahmet cetin</title>
      <link>https://dev.to/myzura</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/myzura"/>
    <language>en</language>
    <item>
      <title>How many npm packages actually run code when you `npm install`? I measured a sample.</title>
      <dc:creator>ahmet cetin</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:49:37 +0000</pubDate>
      <link>https://dev.to/myzura/how-many-npm-packages-actually-run-code-when-you-npm-install-i-measured-a-sample-45e4</link>
      <guid>https://dev.to/myzura/how-many-npm-packages-actually-run-code-when-you-npm-install-i-measured-a-sample-45e4</guid>
      <description>&lt;p&gt;The npm supply-chain conversation tends to happen without a denominator. "Install scripts are dangerous" — fine, but how many packages even have one? I kept not finding the number, so I went and measured it.&lt;/p&gt;

&lt;p&gt;The number&lt;br&gt;
I took 658 packages from one corner of npm (the MCP-server ecosystem — that's what I happened to be auditing) and counted how many declare an install-lifecycle script: the preinstall / install / postinstall hooks that run code the moment you npm install.&lt;/p&gt;

&lt;p&gt;Twenty do. That's 3.0% of the sample.&lt;/p&gt;

&lt;p&gt;Two honest caveats before anyone runs with that figure:&lt;/p&gt;

&lt;p&gt;It's 3% of this sample, not "3% of npm." 658 packages is the union of a handful of registry search queries — a search result, not a census frame. Run it on a different slice and you'll get a different number.&lt;br&gt;
It counts the top-level package's own declared hooks. npm runs install scripts for every package in the resolved dependency tree, so a package with a clean manifest can still execute code at install through a dependency three levels down. This measures the surface a package is responsible for itself.&lt;br&gt;
With those stated: the number is calmer than the panic implies, and it costs nothing to reproduce for any package:&lt;/p&gt;

&lt;p&gt;curl -s &lt;a href="https://registry.npmjs.org/" rel="noopener noreferrer"&gt;https://registry.npmjs.org/&lt;/a&gt;/latest | jq '.scripts'&lt;br&gt;
The part where the manifest lied to me&lt;br&gt;
Reading the manifest tells you a script exists. It does not tell you what the script does — and on four of the twenty, that gap bit me.&lt;/p&gt;

&lt;p&gt;Four declared preinstall: npx only-allow pnpm. I filed those as a benign package-manager guard: only-allow just prints an error if you used the wrong package manager. In intent, that's exactly what it is.&lt;/p&gt;

&lt;p&gt;But npx fetches only-allow from the registry if it isn't already cached. So a "guard" whose whole job is to print a message makes a network call on your machine at install time. Nothing sinister — it's a completely standard idiom — but it is not what "benign guard" implied to me, and I could not have learned it by reading the text.&lt;/p&gt;

&lt;p&gt;That's the general lesson, and the only reason this is worth writing up:&lt;/p&gt;

&lt;p&gt;A manifest tells you what a script is. Only running it tells you what it needs.&lt;/p&gt;

&lt;p&gt;So I made the "running it" part reusable&lt;br&gt;
Reading manifests is cheap and misleading; running the scripts is where the truth is. So I wrapped that in something you can point at any package.&lt;/p&gt;

&lt;p&gt;It fetches the exact tarball npm install would, extracts it inside a container with no network, all Linux capabilities dropped, and a read-only filesystem, runs the declared install scripts, and returns a verdict — plus the sha256 of the exact bytes it read, the exit code, and the sandbox's own output.&lt;/p&gt;

&lt;p&gt;npx -y @kenwea/mcp check debug&lt;br&gt;
Resolved debug → &lt;a href="mailto:debug@4.4.3"&gt;debug@4.4.3&lt;/a&gt;&lt;br&gt;
  &lt;a href="https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" rel="noopener noreferrer"&gt;https://registry.npmjs.org/debug/-/debug-4.4.3.tgz&lt;/a&gt;&lt;br&gt;
  verdict     MANUAL_REVIEW&lt;br&gt;
  why         this package declares no preinstall, install or postinstall script, so&lt;br&gt;
              nothing of its own runs when it is installed. That is worth knowing and&lt;br&gt;
              it is not a pass: we executed nothing, and an unrun artifact is not a&lt;br&gt;
              passing one&lt;br&gt;
  sha256      89c1ac9c946ee8905a875837114528e97eeae35e03be3190584b2216af43e4a7&lt;br&gt;
  size        13449 bytes&lt;br&gt;
  executed    no&lt;br&gt;
  signed      ed25519, key 00f55dd04da212b3&lt;br&gt;
Note the verdict logic, because it's the part I care most about getting right: "nothing ran" is information, not a pass. A package with no install script isn't "safe," it just moved the interesting behavior to import-time. Reporting that as approved would be the convenient lie.&lt;/p&gt;

&lt;p&gt;Why it's signed&lt;br&gt;
The output is signed under a published Ed25519 key and bound to the sha256 of the bytes. That matters for one reason: "I ran it and it's fine" from the party that wrote the code is circular — you cannot vouch for your own artifact, and every reviewer knows it. A signature from something that isn't the author is a third-party record you can forward, and one that outlives the version: when a registry pulls a compromised release (as npm did with the recent chalk / debug tombstones), the bytes are gone and the incident becomes unauditable. A signature minted while the version was live is the thing that survives.&lt;/p&gt;

&lt;p&gt;You can verify any signature yourself — the payload names the artifact, the hash, the verdict, and the constraints it ran under. (Verification is client-side on purpose: a page where we confirmed our own signature would prove nothing.)&lt;/p&gt;

&lt;p&gt;In CI&lt;br&gt;
Default is report-only — a verdict is a successful result even when it's rejected. To make a pipeline block, ask for it:&lt;/p&gt;

&lt;p&gt;npx -y @kenwea/mcp check &lt;a href="mailto:express@4.18.2"&gt;express@4.18.2&lt;/a&gt; --fail-on rejected&lt;br&gt;
Or as a GitHub Action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;uses: kenwea-protocol/kenwea-notary-action@v1
with:
package: ${{ matrix.package }}
fail-on: rejected
--fail-on rejected blocks only on rejected; --fail-on manual_review also blocks on manual_review. An honest non-answer — a package it couldn't fetch — never fails the gate. Turning "we couldn't read it" into a red build is the one thing this refuses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honest limits&lt;br&gt;
So you find out here rather than by being surprised:&lt;/p&gt;

&lt;p&gt;Dependencies are not installed. This measures a package's own declared install surface, not its full transitive closure. That's a deliberate scope, not an oversight — but it's a real limit.&lt;br&gt;
Node and Python only, and 20 checks/hour on the free anonymous key (minted on first use to rate-limit — no signup, no email).&lt;br&gt;
A clean install script says nothing about import-time. Which is the honest edge of the whole thing…&lt;br&gt;
The question I can't answer&lt;br&gt;
Install-time is the surface everyone worries about, and it turns out to be nearly empty — 3% in my sample, and half of those are package-manager guards. Meanwhile the surface that's actually large — what a module does the first time you require() it — I can't find anyone measuring, and I don't have a design for it either.&lt;/p&gt;

&lt;p&gt;For a module whose whole job is to sit there until a function call arrives, what would "run it and see" even mean? Import it and watch — for how long, against what expectation of normal? That's the part I'd genuinely take feedback on.&lt;/p&gt;

&lt;p&gt;Disclosure: I built this. The check (@kenwea/mcp) is one piece of kenwea.com, which is early and quiet — the check runs standalone with no account, which is why I'm writing about it rather than the site.&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>devops</category>
      <category>npm</category>
    </item>
  </channel>
</rss>
