Ansible Galaxy · MIT

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.

$ ansible-galaxy role install echennells.supply_chain_hardening
Why this exists

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.

What it does

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
How it works

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.

/etc/npmrc  ·  ~/.npmrc Universal
Config files are the load-bearing defense. Every package manager reads its config unconditionally when invoked — regardless of process tree, PAM state, or shell context. That makes this the layer that covers direct-exec callers: Docker 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
Deployed per-user and system-wide: ~/.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.
Pre-flight protects existing /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.
Coverage

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
One protection has no backstop

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.

Quick start

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 from Ansible Galaxy Recommended
# Install the role
ansible-galaxy role install \
  echennells.supply_chain_hardening

# Reference it in your playbook
- hosts: all
  roles:
    - echennells.supply_chain_hardening
Clone and run directly Source
# 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 — run specific ecosystems
--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
Configuration
Every age gate derives from a single variable in 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
Don't run it with global --become

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.

Limitations

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.