Dependency Pinning
A dependency safety scan — not a CVE scan. It checks two things across every
ecosystem in scope:
- Pinning — dependencies pinned to an immutable content SHA / digest, not a
movable tag. Tags can be silently re-pointed to altered or malicious artifacts
(the tj-actions class of supply-chain attacks). A digest can't.
- Cooldown — a project never adopts an artifact newer than N days (default
7). A fresh release is where a compromise lands first; the cooldown buys time
for it to be caught before it reaches the build.
Report first. Mutate only on explicit approval. Bumping a pin is itself risky, so
every proposed change names the new SHA and flags it for a supply-chain check.
Scope
Ask what to scan if unclear: a single project, a list of repos, or "everything under
~/dev/work". Then discover dependency manifests:
ROOT="${1:-.}"
/usr/bin/find "$ROOT" -type d \( -name node_modules -o -name .git -o -name .terraform \
-o -name vendor -o -name packages -o -name bin -o -name obj -o -path '*/assets/libs' \) -prune -o \
-type f \( \
-iname 'Dockerfile*' -o -name 'docker-compose*.y*ml' -o -name 'compose*.y*ml' \
-o -path '*/.github/workflows/*.y*ml' \
-o -name 'package.json' -o -name 'deno.json*' \
-o -name 'pyproject.toml' -o -name 'requirements*.txt' \
-o -name '*.csproj' -o -name 'packages.config' -o -name 'Directory.Packages.props' \
-o -name 'Cargo.toml' -o -name 'go.mod' \
-o -name 'pom.xml' -o -name 'build.gradle' -o -name 'build.gradle.kts' \
-o -name 'Gemfile' -o -name '*.gemspec' \
-o -name '*.tf' \
\) -print 2>/dev/null
# Also catch images pulled from scripts/CI, not just Dockerfiles/compose:
grep -rEn 'docker (run|pull|build)[^|]*[a-z0-9./-]+:[a-z0-9._-]+' "$ROOT" \
--include='*.sh' --include='*.y*ml' --include='Makefile' 2>/dev/null
Ignore vendored/minified asset trees (*/assets/**, *.min.*) — those package.json
files are bundled libraries, not your declared dependencies.
What "good" looks like, how to detect violations, how to fix
Docker / Compose (pinning: required · cooldown: manual)
- Good:
FROM repo/img@sha256:<digest> · image: repo/img@sha256:<digest>
- Bad:
:latest, floating tags, no digest.
- Detect:
grep -rEn '^\s*FROM |image:\s' <files> → flag any ref without @sha256:.
Also scan scripts/CI/Makefiles for docker run|pull|build ... img:tag — images
pulled outside Dockerfiles/compose are easy to miss and just as swappable.
- Fix: resolve the digest of a tag that is ≥ N days old:
docker buildx imagetools inspect repo/img:<ver> --format '{{.Manifest.Digest}}'
then write repo/img@sha256:<digest> # <ver>.
- Cooldown: no native support — enforce by choosing a digest for an old-enough tag.
GitHub Actions (pinning: required · cooldown: via tooling)
- Good:
uses: owner/repo@<40-hex-sha> # vX.Y.Z
- Bad:
@v4, @main, @<branch>.
- Detect:
grep -rEn 'uses:\s' .github/workflows → flag any ref not matching a 40-char hex SHA.
- Fix:
git ls-remote https://github.com/<owner>/<repo> refs/tags/<ver> → SHA; pin with the version in a trailing comment.
- Cooldown / automation:
ratchet (sethvargo/ratchet) or pinact pin+update actions by SHA;
Renovate/Dependabot minimumReleaseAge / cooldown gates the version that gets pinned.
JavaScript — npm / yarn / pnpm / bun / deno (pinning: lockfile · cooldown: tooling)
- Good: lockfile committed (
package-lock.json / yarn.lock / pnpm-lock.yaml /
bun.lock / deno.lock) with integrity hashes, and CI installs frozen
(npm ci, pnpm i --frozen-lockfile, yarn --immutable, deno install --frozen).
- Bad: no lockfile committed; relying on
^/~/latest without a lock; npm install in CI.
- Detect: manifest present but lockfile missing/gitignored; CI using non-frozen installs.
- Fix: generate + commit the lockfile; switch CI to the frozen install command.
- Cooldown: pnpm has native
minimumReleaseAge (config/.npmrc). For npm/yarn/bun,
add Renovate minimumReleaseAge: "7 days" (or Dependabot cooldown). Deno: pin exact
versions in import map / deno.json; deno.lock carries integrity.
Python — uv / pdm / poetry / pip (pinning: lockfile+hashes · cooldown: uv native / tooling)
- Good: lockfile committed with hashes (
uv.lock, pdm.lock, poetry.lock) and
installed locked (uv sync --locked, pdm sync, poetry install); for bare pip,
hashed pins via pip-compile --generate-hashes + pip install --require-hashes.
- Bad: unpinned/
>=/* requirements, no lockfile, no hashes.
- Detect:
pyproject.toml/requirements.txt present but no lockfile/hashes; ranges without a lock.
- Fix: adopt a lock workflow or generate hashed requirements; commit the lock.
- Cooldown: uv has native
exclude-newer / UV_EXCLUDE_NEWER (resolve as of a date —
a true cooldown). Otherwise Renovate minimumReleaseAge.
.NET — NuGet (pinning: exact versions + lockfile · cooldown: tooling)
- Good: exact versions (
packages.config version="x.y.z", or <PackageReference Version="x.y.z"/> with no range/*) plus a committed packages.lock.json
(enable <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>, restore
with dotnet restore --locked-mode). The lockfile carries contentHash per package.
- Bad: floating versions (
Version="*", Version="[1.0,)"), no lockfile, packages
restored from a non-pinned feed.
- Detect:
grep -rEn 'Version="\*"|Version="\[' **/*.csproj; .csproj/packages.config
present but no packages.lock.json beside it; check Directory.Packages.props for
central versions if used.
- Fix: pin exact versions; enable + commit
packages.lock.json; pin the feed in
nuget.config.
- Cooldown: no native NuGet cooldown — use Renovate
minimumReleaseAge (NuGet supported).
Rust — Cargo · Go · Java (Maven/Gradle) · Ruby (Bundler)
Terser, but the same two questions apply (immutable pin? cooldown?):
- Rust/Cargo: commit
Cargo.lock (carries crates.io sha256 checksums); cargo --locked.
No native cooldown → Renovate minimumReleaseAge (cargo datasource). cargo audit/cargo vet adjacent.
- Go modules:
go.mod + go.sum (cryptographic hashes, verified vs the checksum DB);
go mod verify, build with -mod=readonly. No native cooldown → Renovate (gomod).
- Java/Maven: pin exact versions in
pom.xml — ban LATEST/RELEASE/version ranges
(enforce with maven-enforcer requireReleaseDeps / ban-dynamic-versions). No lockfile natively.
- Java/Gradle: enable dependency locking (
gradle.lockfile) + verification metadata
(gradle --write-verification-metadata sha256 → verification-metadata.xml with checksums/signatures).
- Ruby/Bundler: commit
Gemfile.lock; bundle config set frozen true; add checksums with
bundle lock --add-checksums (Bundler 2.5+). No native cooldown → Renovate (bundler).
- Cooldown across all of the above is best delivered by Renovate
minimumReleaseAge /
Dependabot cooldown, which support these datasources.
Any other ecosystem you encounter (general rule)
Don't stop at the languages listed here. For any package manager in the target:
- Identify the manifest + its lockfile; confirm the lockfile carries integrity
hashes and is committed, and that CI installs frozen/locked.
- Flag floating/range/
latest version specifiers in the manifest.
- Research whether that manager supports a cooldown — native settings first
(names vary:
minimumReleaseAge, exclude-newer, --as-of, min-age, quarantine),
then a meta-tool (Renovate minimumReleaseAge / Dependabot cooldown) for that
datasource. If unsure, check the tool's current docs rather than guessing.
- Report findings the same way and propose the concrete setting to add.
Terraform (pinning: lock + version constraints)
- Good:
.terraform.lock.hcl committed with multi-platform hashes
(terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 ...);
providers pinned in required_providers; modules pinned by version or git commit SHA;
AMIs/data sources pinned by ID (no most_recent = true).
- Bad: lock gitignored, missing platforms,
most_recent = true, modules on main.
- Detect: lock in
.gitignore; grep -rn 'most_recent\s*=\s*true' *.tf; module sources on a branch.
- Fix: un-ignore + commit the lock (multi-platform); pin module/AMI refs.
Adding cooldowns where supported (offer these)
- Renovate (cross-ecosystem):
"minimumReleaseAge": "7 days" in renovate.json — the
single best lever; gates npm/pip/docker/actions/etc. behind the cooldown.
- Dependabot:
cooldown: block in .github/dependabot.yml.
- pnpm:
minimumReleaseAge · uv: exclude-newer · GH Actions: ratchet/pinact.
Report format
DEPENDENCY SAFETY REPORT (cooldown target: 7 days)
===================================================
<repo/path>
Docker : 2 images unpinned (FROM node:20, postgres:16) — PIN MISSING
GH Actions : 3 actions on tags (@v4) — PIN MISSING
JS (pnpm) : lockfile committed ✓ · no cooldown — COOLDOWN MISSING
Python (uv) : uv.lock ✓ · exclude-newer not set — COOLDOWN MISSING
Terraform : .terraform.lock.hcl gitignored — LOCK NOT COMMITTED
...
SUMMARY: 4 pin gaps, 3 cooldown gaps, 1 uncommitted lock across N repos.
Classify each as: OK · PIN MISSING · COOLDOWN MISSING · LOCK NOT COMMITTED.
Then list exact fixes (the resolved SHA/digest, the config snippet) per item.
Action rules
- Default to read-only. Do not edit files, run installers, or commit until the user approves.
- When approved, change one ecosystem at a time; show the diff.
- Every pin bump names the new SHA/digest and reminds the user to do a supply-chain
check and respect the cooldown (no artifact newer than N days).
- Never weaken an existing pin. Never add a
:latest/floating ref as a "fix".
- Keep the human-readable version in a trailing comment beside every SHA pin.
- Commit lockfiles — never gitignore them.
1---2name: dependency-pinning3description: Audit Docker, CI, and language dependencies for SHA/digest pinning and cooldowns.4---56# Dependency Pinning78A dependency **safety** scan — not a CVE scan. It checks two things across every9ecosystem in scope:10111. **Pinning** — dependencies pinned to an immutable **content SHA / digest**, not a12 movable tag. Tags can be silently re-pointed to altered or malicious artifacts13 (the tj-actions class of supply-chain attacks). A digest can't.142. **Cooldown** — a project never adopts an artifact newer than **N days** (default15 **7**). A fresh release is where a compromise lands first; the cooldown buys time16 for it to be caught before it reaches the build.1718**Report first. Mutate only on explicit approval.** Bumping a pin is itself risky, so19every proposed change names the new SHA and flags it for a supply-chain check.2021## Scope2223Ask what to scan if unclear: a single project, a list of repos, or "everything under24`~/dev/work`". Then discover dependency manifests:2526```bash27ROOT="${1:-.}"28/usr/bin/find "$ROOT" -type d \( -name node_modules -o -name .git -o -name .terraform \29 -o -name vendor -o -name packages -o -name bin -o -name obj -o -path '*/assets/libs' \) -prune -o \30 -type f \( \31 -iname 'Dockerfile*' -o -name 'docker-compose*.y*ml' -o -name 'compose*.y*ml' \32 -o -path '*/.github/workflows/*.y*ml' \33 -o -name 'package.json' -o -name 'deno.json*' \34 -o -name 'pyproject.toml' -o -name 'requirements*.txt' \35 -o -name '*.csproj' -o -name 'packages.config' -o -name 'Directory.Packages.props' \36 -o -name 'Cargo.toml' -o -name 'go.mod' \37 -o -name 'pom.xml' -o -name 'build.gradle' -o -name 'build.gradle.kts' \38 -o -name 'Gemfile' -o -name '*.gemspec' \39 -o -name '*.tf' \40 \) -print 2>/dev/null41# Also catch images pulled from scripts/CI, not just Dockerfiles/compose:42grep -rEn 'docker (run|pull|build)[^|]*[a-z0-9./-]+:[a-z0-9._-]+' "$ROOT" \43 --include='*.sh' --include='*.y*ml' --include='Makefile' 2>/dev/null44```45Ignore vendored/minified asset trees (`*/assets/**`, `*.min.*`) — those `package.json`46files are bundled libraries, not your declared dependencies.4748## What "good" looks like, how to detect violations, how to fix4950### Docker / Compose (pinning: required · cooldown: manual)51- **Good:** `FROM repo/img@sha256:<digest>` · `image: repo/img@sha256:<digest>`52- **Bad:** `:latest`, floating tags, no digest.53- **Detect:** `grep -rEn '^\s*FROM |image:\s' <files>` → flag any ref without `@sha256:`.54 Also scan scripts/CI/Makefiles for `docker run|pull|build ... img:tag` — images55 pulled outside Dockerfiles/compose are easy to miss and just as swappable.56- **Fix:** resolve the digest of a tag that is **≥ N days old**:57 `docker buildx imagetools inspect repo/img:<ver> --format '{{.Manifest.Digest}}'`58 then write `repo/img@sha256:<digest> # <ver>`.59- **Cooldown:** no native support — enforce by choosing a digest for an old-enough tag.6061### GitHub Actions (pinning: required · cooldown: via tooling)62- **Good:** `uses: owner/repo@<40-hex-sha> # vX.Y.Z`63- **Bad:** `@v4`, `@main`, `@<branch>`.64- **Detect:** `grep -rEn 'uses:\s' .github/workflows` → flag any ref not matching a 40-char hex SHA.65- **Fix:** `git ls-remote https://github.com/<owner>/<repo> refs/tags/<ver>` → SHA; pin with the version in a trailing comment.66- **Cooldown / automation:** `ratchet` (sethvargo/ratchet) or `pinact` pin+update actions by SHA;67 Renovate/Dependabot `minimumReleaseAge` / cooldown gates the version that gets pinned.6869### JavaScript — npm / yarn / pnpm / bun / deno (pinning: lockfile · cooldown: tooling)70- **Good:** lockfile **committed** (`package-lock.json` / `yarn.lock` / `pnpm-lock.yaml` /71 `bun.lock` / `deno.lock`) with integrity hashes, and CI installs frozen72 (`npm ci`, `pnpm i --frozen-lockfile`, `yarn --immutable`, `deno install --frozen`).73- **Bad:** no lockfile committed; relying on `^`/`~`/`latest` without a lock; `npm install` in CI.74- **Detect:** manifest present but lockfile missing/gitignored; CI using non-frozen installs.75- **Fix:** generate + commit the lockfile; switch CI to the frozen install command.76- **Cooldown:** **pnpm** has native `minimumReleaseAge` (config/`.npmrc`). For npm/yarn/bun,77 add **Renovate `minimumReleaseAge: "7 days"`** (or Dependabot cooldown). Deno: pin exact78 versions in import map / `deno.json`; `deno.lock` carries integrity.7980### Python — uv / pdm / poetry / pip (pinning: lockfile+hashes · cooldown: uv native / tooling)81- **Good:** lockfile committed with hashes (`uv.lock`, `pdm.lock`, `poetry.lock`) and82 installed locked (`uv sync --locked`, `pdm sync`, `poetry install`); for bare pip,83 hashed pins via `pip-compile --generate-hashes` + `pip install --require-hashes`.84- **Bad:** unpinned/`>=`/`*` requirements, no lockfile, no hashes.85- **Detect:** `pyproject.toml`/`requirements.txt` present but no lockfile/hashes; ranges without a lock.86- **Fix:** adopt a lock workflow or generate hashed requirements; commit the lock.87- **Cooldown:** **uv** has native `exclude-newer` / `UV_EXCLUDE_NEWER` (resolve as of a date —88 a true cooldown). Otherwise Renovate `minimumReleaseAge`.8990### .NET — NuGet (pinning: exact versions + lockfile · cooldown: tooling)91- **Good:** exact versions (`packages.config` `version="x.y.z"`, or `<PackageReference92 Version="x.y.z"/>` with no range/`*`) **plus a committed `packages.lock.json`**93 (enable `<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>`, restore94 with `dotnet restore --locked-mode`). The lockfile carries `contentHash` per package.95- **Bad:** floating versions (`Version="*"`, `Version="[1.0,)"`), no lockfile, packages96 restored from a non-pinned feed.97- **Detect:** `grep -rEn 'Version="\*"|Version="\[' **/*.csproj`; `.csproj`/`packages.config`98 present but no `packages.lock.json` beside it; check `Directory.Packages.props` for99 central versions if used.100- **Fix:** pin exact versions; enable + commit `packages.lock.json`; pin the feed in101 `nuget.config`.102- **Cooldown:** no native NuGet cooldown — use **Renovate `minimumReleaseAge`** (NuGet supported).103104### Rust — Cargo · Go · Java (Maven/Gradle) · Ruby (Bundler)105Terser, but the same two questions apply (immutable pin? cooldown?):106- **Rust/Cargo:** commit `Cargo.lock` (carries crates.io sha256 checksums); `cargo --locked`.107 No native cooldown → Renovate `minimumReleaseAge` (cargo datasource). `cargo audit`/`cargo vet` adjacent.108- **Go modules:** `go.mod` + `go.sum` (cryptographic hashes, verified vs the checksum DB);109 `go mod verify`, build with `-mod=readonly`. No native cooldown → Renovate (gomod).110- **Java/Maven:** pin exact versions in `pom.xml` — **ban `LATEST`/`RELEASE`/version ranges**111 (enforce with `maven-enforcer` requireReleaseDeps / ban-dynamic-versions). No lockfile natively.112- **Java/Gradle:** enable **dependency locking** (`gradle.lockfile`) + **verification metadata**113 (`gradle --write-verification-metadata sha256` → `verification-metadata.xml` with checksums/signatures).114- **Ruby/Bundler:** commit `Gemfile.lock`; `bundle config set frozen true`; add checksums with115 `bundle lock --add-checksums` (Bundler 2.5+). No native cooldown → Renovate (bundler).116- Cooldown across all of the above is best delivered by **Renovate `minimumReleaseAge`** /117 Dependabot cooldown, which support these datasources.118119### Any other ecosystem you encounter (general rule)120Don't stop at the languages listed here. For **any** package manager in the target:1211. Identify the **manifest** + its **lockfile**; confirm the lockfile carries **integrity122 hashes** and is **committed**, and that CI installs **frozen/locked**.1232. Flag floating/range/`latest` version specifiers in the manifest.1243. **Research whether that manager supports a cooldown** — native settings first125 (names vary: `minimumReleaseAge`, `exclude-newer`, `--as-of`, min-age, quarantine),126 then a meta-tool (**Renovate `minimumReleaseAge`** / Dependabot cooldown) for that127 datasource. If unsure, check the tool's current docs rather than guessing.1284. Report findings the same way and propose the concrete setting to add.129130### Terraform (pinning: lock + version constraints)131- **Good:** `.terraform.lock.hcl` **committed** with **multi-platform** hashes132 (`terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 ...`);133 providers pinned in `required_providers`; modules pinned by version or git commit SHA;134 AMIs/data sources pinned by ID (no `most_recent = true`).135- **Bad:** lock gitignored, missing platforms, `most_recent = true`, modules on `main`.136- **Detect:** lock in `.gitignore`; `grep -rn 'most_recent\s*=\s*true' *.tf`; module sources on a branch.137- **Fix:** un-ignore + commit the lock (multi-platform); pin module/AMI refs.138139## Adding cooldowns where supported (offer these)140- **Renovate** (cross-ecosystem): `"minimumReleaseAge": "7 days"` in `renovate.json` — the141 single best lever; gates npm/pip/docker/actions/etc. behind the cooldown.142- **Dependabot**: `cooldown:` block in `.github/dependabot.yml`.143- **pnpm**: `minimumReleaseAge` · **uv**: `exclude-newer` · **GH Actions**: ratchet/pinact.144145## Report format146147```text148DEPENDENCY SAFETY REPORT (cooldown target: 7 days)149===================================================150<repo/path>151 Docker : 2 images unpinned (FROM node:20, postgres:16) — PIN MISSING152 GH Actions : 3 actions on tags (@v4) — PIN MISSING153 JS (pnpm) : lockfile committed ✓ · no cooldown — COOLDOWN MISSING154 Python (uv) : uv.lock ✓ · exclude-newer not set — COOLDOWN MISSING155 Terraform : .terraform.lock.hcl gitignored — LOCK NOT COMMITTED156 ...157SUMMARY: 4 pin gaps, 3 cooldown gaps, 1 uncommitted lock across N repos.158```159160Classify each as: `OK` · `PIN MISSING` · `COOLDOWN MISSING` · `LOCK NOT COMMITTED`.161Then list **exact fixes** (the resolved SHA/digest, the config snippet) per item.162163## Action rules164- Default to **read-only**. Do not edit files, run installers, or commit until the user approves.165- When approved, change **one ecosystem at a time**; show the diff.166- Every pin bump names the new SHA/digest and reminds the user to do a supply-chain167 check and respect the cooldown (no artifact newer than N days).168- Never weaken an existing pin. Never add a `:latest`/floating ref as a "fix".169- Keep the human-readable version in a trailing comment beside every SHA pin.170- Commit lockfiles — never gitignore them.