Safe package-manager defaults, applied at the host.
An Ansible role that hardens 14 package managers at once. Age gates, install-script
blocking, and integrity checks land as config files and system-wide environment variables — so a naive
npm install or pip install is hardened without the caller knowing about it.
AI agents install packages unpredictably.
You can't control which package manager an agent reaches for, what shell it runs under, or when it
decides to install something. Rather than police every call site, this role moves the safe default
down to the host: age gates and script blocking are already in place before the agent ever types
install.
48-hour release age gate
Freshly published versions are the ones that get pulled after a compromise. A default cooldown means your host isn't the first to install them.
Install scripts blocked
Lifecycle hooks are the classic execution path for malicious packages. They're off by default across npm, pnpm, Yarn, Bun, Deno, pip/uv, Composer, and NuGet.
Works on tools you haven't installed
Config files are written to their expected paths whether or not the package manager exists yet. Install pnpm next month and the hardened config is already waiting.
Eight protections across fourteen package managers.
Coverage isn't uniform — each ecosystem exposes a different set of knobs, and the role applies the strictest one available rather than pretending the gaps aren't there.
| Protection | npm | pnpm | Yarn | Bun | Deno | pip/uv | Cargo | Go | Composer | Bundler | Maven | Gradle | NuGet |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 48h release age gate | * | — | — | — | — | — | — | ||||||
| Install script blocking | — | — | — | — | — | ||||||||
| Pre-install reputation (npq) | — | — | — | — | — | — | — | — | — | — | |||
| Socket Firewall | — | — | — | — | — | — | — | — | — | — | |||
| Exact version pinning | — | — | — | — | — | — | — | — | — | — | |||
| Hash / integrity verification | — | — | — | — | — | — | — | — | |||||
| HTTPS-only / source pinning | — | — | — | — | — | — | — | ||||||
| Lockfile enforcement | — | — | — | — | — | — | — | — | — | — | — |
* via the third-party cargo-cooldown crate
Age gates all derive from one variable: release_age_hours: 48
Four layers, because no single one covers every caller.
A shell alias never fires for a systemd service. An environment variable never reaches a Docker
CMD. The role stacks mechanisms so that whichever way a package manager gets invoked,
something is still in front of it.
CMD, systemd services, and long-lived agent processes. Files are written
before the tools are even installed.
# Managed by ansible-supply-chain-security ignore-scripts=true min-release-age=2 # npm counts in DAYS, integer only save-exact=true audit=true fund=false package-lock=true
~/.npmrc, /etc/npmrc,
~/.config/pnpm/rc, ~/.config/pnpm/config.yaml,
~/.yarnrc.yml, /etc/yarnrc.yml, ~/.bunfig.toml,
~/.config/uv/uv.toml, /etc/uv/uv.toml,
~/.config/pip/pip.conf, /etc/pip.conf,
~/.cargo/config.toml, ~/.config/composer/config.json,
~/.bundle/config.
/etc files. If
/etc/npmrc, /etc/yarnrc.yml, /etc/pip.conf, or
/etc/uv/uv.toml exists without the role's marker comment — meaning a
sysadmin or corporate config management put it there — the playbook fails loudly rather
than clobbering a private registry setting and creating a dependency-confusion exposure.
/etc/environment so PAM loads it
for ssh, sudo -i, cron, and anything forked from them. This is the
redundancy layer — it catches human workflows and inherited processes, and outranks a
user's own ~/.npmrc where it applies.
# npm export NPM_CONFIG_IGNORE_SCRIPTS=true export NPM_CONFIG_MIN_RELEASE_AGE=2 export NPM_CONFIG_SAVE_EXACT=true # Go — GOTOOLCHAIN has no config-file equivalent export GOSUMDB=sum.golang.org export GOFLAGS=-mod=readonly export GOTOOLCHAIN=local # PHP (Composer 2.9+) export COMPOSER_SKIP_SCRIPTS="…full event enumeration…"
min-release-age is in days; pnpm's
minimumReleaseAge is in minutes; bun's is seconds; Yarn's
npmMinimalAgeGate is integer minutes. The role derives all four from one
variable, so 48 hours becomes npm 2, pnpm 2880,
bun 172800, yarn 2880.
*-real, and read-only subcommands pass
through untouched so their output isn't corrupted.
# pip → uv, so uv's age gate and wheels-only rules apply /usr/local/bin/pip → uv pip … # npm → registry-touching subcommands via Socket Firewall /usr/local/bin/npm → sfw npm install|ci|update|audit # bun runtime silently auto-installs missing imports /usr/local/bin/bun → bun-real --no-install # deno has no global config; wrapped in place at ~/.deno/bin ~/.deno/bin/deno → deno-real --minimum-dependency-age # composer has no host-wide script switch /usr/local/bin/composer → composer-real --no-scripts --no-plugins
/usr/local/bin. Deno's
installer prepends ~/.deno/bin to PATH, so a wrapper further
down the path would be silently bypassed. Installing at the discovered location defeats
PATH ordering by sitting upstream of it. Each wrapper is individually disablable —
npm_path_wrapper, bun_path_wrapper,
deno_path_wrapper, composer_path_wrapper.
npm, yarn, and pnpm through
npq, which
runs 14 pre-install checks: typosquatting detection, provenance regression, dormant
maintainer flagging, install-script warnings. Auto-continue is off — warnings must be
acknowledged.
alias npm="npq-hero npm" alias yarn="npq-hero yarn" alias pnpm="npq-hero pnpm"
sh -c, sudo,
lifecycle hooks, or an AI agent shelling out via subprocess — which is most automated
traffic. npq is a complement for humans typing at a prompt, not the primary defense.
DOCKER_CONTENT_TRUST environment variable, podman's
policy.json is enforced by the runtime — it can't be bypassed by unsetting a
variable or passing a CLI flag. Default policy rejects all registries and allowlists
docker.io, ghcr.io, quay.io, mcr.microsoft.com, and gcr.io.
# Two independent gates, both false by default. # Enabling the first does NOT touch Docker. podman_enabled: false # install podman + policy.json podman_disable_docker: false # stop + disable the Docker daemon podman_docker_compat: false # symlink docker.sock → podman
podman_allowed_registries to change the allowlist.
Which callers see the environment layer.
Environment variables propagate through PAM and process inheritance — but not into every context. For the rows below that miss out, the config-file layer is what actually does the work.
| Caller | Sees the env vars? |
|---|---|
Login shell — ssh, sudo -i, su -, getty |
Yes — PAM loads /etc/environment |
| Cron job, ssh session, any child of a PAM-launched parent | Yes — inherited across fork/exec |
bash -c "…" from inside a PAM-launched shell |
Yes — inherited |
Container CMD ["python", "app.py"] started by Docker |
No — no PAM, no shell sourcing |
systemd service without Environment= directives |
No |
env -i bash -c "…" — deliberately clean environment |
No |
GOTOOLCHAIN=local is env-var only. It stops go install
from auto-fetching a newer toolchain than the host has — a path an attacker could use to ship
malicious build constraints. Go has no config-file equivalent, so this one vanishes for systemd and
Docker CMD callers. If you run Go-touching agents under systemd, add
Environment=GOTOOLCHAIN=local to the unit file. Every other env-var protection has a
config-file backstop.
Apply it to a host, a sandbox, or a container image.
The role configures the package managers you already have — it doesn't install them (podman is the opt-in exception). Run it anywhere a package manager runs.
# Install the role ansible-galaxy role install \ echennells.supply_chain_hardening # Reference it in your playbook - hosts: all roles: - echennells.supply_chain_hardening
# Clone git clone git@github.com:echennells/\ supply-chain-hardening.git cd supply-chain-hardening # Localhost, a remote host, or a subset ansible-playbook site.yml --limit localhost ansible-playbook site.yml --limit servers ansible-playbook site.yml --tags npm,pip,uv
--tags npm # npm only --tags pip,uv # Python only --tags cargo # Rust only --tags go # Go only --tags java # Maven + Gradle --tags github # zizmor + pinact --tags shell # env vars only
defaults/main.yml. Change it once
and all package managers follow.
release_age_hours: 48 # Force-refresh the auditing tools after a # Go/Rust toolchain upgrade ansible-playbook site.yml -e refresh_tools=true # Dry run — detection probes really execute ansible-playbook site.yml --check
Facts are gathered under the play's become settings, so escalating from a non-root account sets
ansible_env.HOME=/root and every per-user config lands in root's home instead of the
intended user's. The role escalates per task where root is required, so plain
ansible-playbook site.yml still applies all system-wide hardening. Pre-flight refuses
the escalated invocation outright.
This raises the default posture. It is not a sandbox.
Process isolation is a separate, complementary concern: a sandbox controls what can run — this controls how package managers behave when they do. The gaps below are documented rather than papered over.
Env vars and config files can be overridden by any process running as the same user.
This protects against naive installs, not determined bypass.
CLI flags outrank config in both pip and npm.
python3 -m pip install --no-binary :all: … goes around both the wrapper and /etc/pip.conf; npm install --ignore-scripts=false re-enables lifecycle scripts regardless of config or env. Don't expose hosts to untrusted callers passing arbitrary flags and expect these layers to save you.
Yarn 1.x (classic) receives no yarn hardening at all.
Every yarn setting deployed lives in the Yarn 2+ ~/.yarnrc.yml format, which classic doesn't read — and it fails silently. This bites by default on distros shipping yarn 1.22 from apt. Activate a berry version or treat yarn as unprotected there.
pnpm 11 has no system-wide config path.
It reads only ~/.config/pnpm/config.yaml per-user, so sudo pnpm install runs against root's empty config. The onlyBuiltDependencies allowlist and blockExoticSubdeps are also version-tiered between pnpm 10 and 11.
Ruby and Cargo have no install-script blocking.
extconf.rb and build.rs execute unconditionally. No config can prevent this — it's an ecosystem-level gap.
Hardening the host doesn't harden containers running on it.
Containers carry their own environment and filesystem. Apply the role inside them separately.
GitHub Actions hardening is detection-only.
The github tag installs zizmor and pinact but doesn't run them, scan workflows, or pin anything. You invoke them yourself.
The full limitations list — including Composer's version-tiered audit blocking, Bun's runtime auto-install gap, and the cross-distro test matrix's coverage caveats — is maintained in the repository README.
Harden the host once.
MIT licensed. Tested against Ubuntu 22.04, Ubuntu 24.04, and Debian 12.