Software Engineering · Dependencies

Install Scripts: The Code That Runs Before the Package Does

What package managers execute at install time, why lifecycle hooks exist, and how their privilege, ordering, and failure semantics make them a separate trust boundary from the code being installed.

Downloading a package and installing it are not the same act. Downloading copies bytes into a cache. Installing may execute code from those bytes, in the installing user’s context, with that user’s environment, network access, and filesystem permissions. The gap between the two is the lifecycle hook, and it is the reason a dependency graph is not just a set of libraries.

Lifecycle hooks exist because many packages are not distributable as plain source. A native module has to be compiled against the local platform’s headers and ABI. A package with generated parsers or protocol buffers has to produce them. A package whose code depends on the presence of a system library has to detect it. Doing this work at install time, on the target machine, is the only way to handle the combinatorics of platform, architecture, libc, and compiler version without publishing a binary for every combination.

The cost of that flexibility is that installation becomes arbitrary code execution, and the trust decision shifts from “do I want this library’s behaviour in my program” to “do I trust this publisher to run code on my machine”.

Where the hooks sit in the sequence

Package managers define a small vocabulary of events, and the exact names differ by ecosystem while the structure is stable. The stages, in order:

A pre-install hook runs before the package’s files are placed. It is the least commonly used stage and the most dangerous, since it executes before the package’s own content is present and must therefore carry its own logic.

A install or post-install hook runs after files are in place. This is where native compilation, binary download, and code generation normally happen, because the package’s source and build configuration are now available at their final paths.

A pre-publish or prepack hook runs when the publisher builds the artifact. It is not part of installation at all, but it matters for supply risk: whatever it does at publish time is baked into the artifact that consumers later download, so a compromised publishing environment can inject into the artifact without any consumer ever running a script.

Dependency-level hooks — the scripts of every package in the graph, not just the root — are the ones that matter for supply risk. A root project’s own scripts are code the project’s maintainers wrote and reviewed. A transitive package’s script is code from a publisher the project’s maintainers have never examined.

{
  "scripts": {
    "preinstall": "node ./check-platform.js",
    "install": "node-gyp rebuild",
    "postinstall": "node ./fetch-native-binary.js"
  }
}

Each of those three lines is a shell command executed with the installer’s privileges. The install line invokes a real tool that compiles against the local toolchain; the postinstall line downloads a binary, which is a network request whose target and content are determined by the package’s own code at run time.

Privilege and environment are inherited, not granted

An install script does not run in a sandbox by default. It runs as the invoking user, with that user’s home directory, SSH keys, cloud credentials, shell configuration, and network reachability. On a developer laptop that is one set of exposures; in CI it is a service account’s token, often with write access to a registry or a deployment target.

The environment is also inherited. Install scripts commonly read environment variables to decide what to build — a target architecture, a path to a system library, a flag to skip optional components. Anything present in the environment is therefore readable by every script in the graph, which means a CI job that exports a registry token before running an install has handed that token to every transitive dependency’s install script.

The mitigation that costs nothing is ordering: perform installs in a step that has no credentials, then inject credentials for the steps that need them. This does not stop a malicious script from doing damage within the sandbox’s reach, but it removes the most valuable target. Build systems that separate a dependency-fetch stage from a publish stage get this property structurally.

Ordering and determinism

Install scripts run in dependency order, leaves first, because a package’s install may need its own dependencies present. Cycles in the graph, which resolution permits in some ecosystems, force the installer to break the ordering arbitrarily; the result is that a script may run before a package it depends on has been installed.

Concurrency is a second source of nondeterminism. Installers that run scripts in parallel across independent subtrees gain wall-clock time, but scripts that write to shared locations — a global cache, a shared build directory, a system path — can interleave. A script that is correct when run alone can fail when run beside another instance of itself.

Failure handling varies more than it should. Some installers treat a failing script as fatal for the whole installation; others treat scripts of optional dependencies as best-effort and continue. The second behaviour means that an installation can report success while a native module silently failed to build, and the failure surfaces later as a load-time error in the application. Reading the installer’s output rather than its exit code is sometimes necessary, which is a sign that the semantics are underspecified.

Idempotence is a requirement in practice that no registry enforces. A script that appends to a file, creates a directory without checking, or downloads a binary into a path that already holds a stale one will misbehave on a second run. Since installs are frequently repeated — a cache clear, a reinstall after a lockfile change, a CI run on a warm workspace — non-idempotent scripts produce environment-dependent behaviour that is hard to reproduce.

Why the sandbox is hard to add after the fact

Retrofitting isolation onto install scripts is difficult for a structural reason: the scripts need to do exactly the things a sandbox forbids. Compiling native code requires executing the compiler, reading system headers, and writing to the package’s directory. Downloading a prebuilt binary requires network access. Detecting a system library requires reading outside the project. Any sandbox permissive enough to allow the legitimate uses is permissive enough to allow a great deal of illegitimate ones.

The approaches that have been adopted each pick a different point to cut:

  • Disabling scripts entirely, then explicitly allowing a named list of packages to run theirs. This inverts the default from allow to deny, which is the correct direction for a trust boundary. The cost is maintenance: the allowlist must be updated as dependencies change, and a package that quietly begins needing a build step breaks the install until it is added.
  • Running scripts only for packages that declare they need them, and skipping the rest. This narrows the surface without requiring a list, at the cost of trusting the declaration.
  • Deferring all script execution to a container or a separate build stage with restricted credentials and no access to the host filesystem. This is the strongest option and the one that requires the most infrastructure.
  • Verifying the artifact’s provenance before installation, so that the bytes are known to have been produced by a specific build process from a specific source revision. Provenance does not prevent a malicious script, but it makes the script attributable, which changes the economics of publishing one.

The default-deny position requires no judgment per package, which is why it is the direction several ecosystems have taken: scripts are off unless the project opts in, and the opt-in is a reviewable list in the repository.

The build-versus-fetch decision

A package that needs platform-specific code has three distribution strategies, and the choice determines how much install-time code runs.

  • Compile on the consumer’s machine. The package ships source and a build definition; the install script invokes the compiler. Advantages: works on any platform with a toolchain, no per-platform publishing, no binary size in the registry. Disadvantages: every consumer needs a compiler and system headers, install time includes a compile, and the resulting binary depends on the consumer’s toolchain version in ways that can produce different behaviour from a tested build.
  • Download a prebuilt binary at install time. The package ships a small script that determines the platform and fetches the matching artifact from a release server. Advantages: fast install, no toolchain requirement. Disadvantages: the install requires network access beyond the registry, the download target is a second trust domain, and the platform-detection logic is itself code that runs on the consumer’s machine.
  • Ship the binaries inside the package. All platform artifacts in one tarball, selected at load time. Advantages: no install-time network access, no compiler. Disadvantages: artifact size multiplied by the number of platforms, and the registry must accept large packages.

The second strategy is the one with the widest exposure. It means a package’s install script can contact a host that is not the registry, and the integrity digest in the lockfile covers the script, not what the script downloads. The downloaded bytes have whatever verification the script itself implements, which is usually a hash stored in the script or in a file the script reads.

Auditing what a graph will execute

The practical question is not whether install scripts are risky but which scripts a given graph will run. That is answerable statically: every package’s manifest declares its scripts, and the set of packages in the graph is known from the lockfile. Joining the two produces the list of commands that installation will execute.

Two properties of that list are worth reporting. Its size — a large application may execute scripts from dozens of packages, and the count grows with the graph. And its stability — a lockfile refresh can add a package that carries a script where the previous version did not, so a diff of the script-bearing package set is a useful review artifact alongside the version diff.

The scripts themselves are harder to review than their count, because they are shell one-liners invoking arbitrary programs. A script that reads node ./build.js delegates all its behaviour to a file inside the package, which is exactly the file the reviewer would need to read. This is why the declaration-based allowlist is more tractable than content review: it does not ask the reviewer to understand every script, only to decide which publishers’ scripts to permit.

The residual risk after all mitigations is that a permitted package’s script changes behaviour in a patch release, since a range admits it automatically. Pinning the package exactly and reviewing the pin is the only way to make that change visible, which connects the script trust boundary to the general question of how tightly constraints should be written. The narrower the range, the fewer script changes arrive unannounced.

References

  1. npm scripts documentation — npm documentation
  2. The Twelve-Factor App: Build, release, run — 12factor.net
  3. SLSA specification — Open Source Security Foundation