This article is co-authored with a generative AI. Facts have been cross-checked against official documentation where possible, but errors may remain. Please verify primary sources before making important decisions.
The repository that hosts this blog (Next.js + Cloudflare Workers) had two Dependabot alerts pending. Both were moderate severity and both came in as transitive dependencies:
postcss < 8.5.10 — XSS via Unescaped </style> (GHSA-qx2v-qp2m-jg93 / CVE-2026-41305)
esbuild <= 0.24.2 — dev server CORS leak (GHSA-67mh-4wv8-2f99)
The original motivation was simply "make it auto-fix this," but as I worked through the setup, it became clear that a Dependabot-and-npm audit setup, which is built around the known-CVE model, is not a great defense against supply chain attacks of the maintainer-takeover style. This post tries to combine both: automated handling of known CVEs, and a partial line of defense against supply chain attacks.
Separating the threats first
It helped to lay out the threat model before adding mechanisms.
| Type | Example | How it's detected |
|---|---|---|
| Known vulnerabilities | postcss XSS, Next.js SSRF | Registered in NVD / GHSA; visible to npm audit and Dependabot |
| Maintainer takeover → malicious new release | chalk/debug npm account takeover (2025-09, qix account phishing), eslint-config-prettier (2025-07), ua-parser-js (2021) | Usually caught within hours to a few days; the attack window is the period until the package is yanked |
| Action tampering | tj-actions/changed-files (2025-03), which leaked secrets from repositories around the world | Several mutable tags were force-pushed to point at a single malicious commit |
| Long-dormant backdoor | xz-utils (disclosed 2024-03, more than two years of trust building) | Standard audits do not catch this |
Install-time RCE via postinstall hooks | Arbitrary code runs the moment npm install is invoked | Hard to scan for; the practical mitigation is to disable script execution |
npm audit and Dependabot security updates are strong against the first row, but the next four rows need different layers. The configuration in this post covers rows 2, 3, and 5 to a reasonable degree. It does not address row 4 (xz-utils-style attacks).
What I set up
Everything below is built on top of features that GitHub or npm officially provide. There are no custom tricks.
1. Pin transitive vulnerabilities with npm overrides
Neither postcss nor esbuild were direct dependencies; both came in as grandchild dependencies that next and its toolchain were pulling. Dependabot can only propose new versions for direct dependencies, so I pinned them through overrides in package.json.
{
"overrides": {
"postcss": ">=8.5.10",
"esbuild": ">=0.25.0"
}
}
npm install --package-lock-only --ignore-scripts
npm audit # found 0 vulnerabilities
After this, the actual node_modules/postcss was replaced with 8.5.14 and both alerts cleared.
2. Add a cooldown to Dependabot
Maintainer-takeover supply chain attacks of the recent shape tend to be detected and yanked within hours to a few days of publication. So I introduced a "don't pull versions that are too fresh" policy.
For this purpose, Renovate has had minimumReleaseAge for a while, and "if you want supply chain protection, switch to Renovate" was a common piece of advice. Dependabot caught up: cooldown reached general availability in 2025-07, so you can now build this layer with Dependabot alone.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/web"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
minor-and-patch:
update-types: ["minor", "patch"]
cooldown:
default-days: 5
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
According to the official docs, cooldown does not apply to security updates. So CVEs still come through immediately, while everything else waits a few days.
3. Pin GitHub Actions to a commit SHA
Tags like actions/checkout@v4 are mutable. A maintainer can move them to a different commit at any time. The tj-actions/changed-files incident (2025-03, CVE-2025-30066) is a real-world example: an attacker force-pushed multiple existing tags (from v1 up to around v45) to point at a single malicious commit. That commit was crafted to scrape secrets out of the CI runner's memory and write them, base64-encoded, into the workflow log. The exfiltration channel was not a remote server, but the public workflow log itself.
The defense is what the GitHub Security hardening for GitHub Actions guide recommends: pin to an immutable commit SHA.
# Avoid: mutable tag
- uses: actions/checkout@v4
# Use: immutable SHA + human-readable comment
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
git ls-remote is a reliable way to look up the SHA behind a tag:
git ls-remote --tags https://github.com/actions/checkout.git refs/tags/v4
# 34e114876b0b11c390a56381ad16ebd13914f8d5 refs/tags/v4
For the actual rewrite I used sed:
cd .github/workflows && for f in *.yml; do
sed -i.bak \
-e 's|uses: actions/checkout@v4|uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4|g' \
-e 's|uses: actions/setup-node@v4|uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4|g' \
-e 's|uses: actions/setup-python@v5|uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5|g' \
"$f"
rm "$f.bak"
done
The # v4 comment is for humans, but Dependabot also reads it; when it updates the SHA, it updates the comment alongside. So SHA pinning does not lock you out of upgrades. I pinned actions/* org actions too. Even GitHub's own org is not entirely immune from compromise.
4. Auto-merge security updates only
With cooldown in place, I wanted to auto-merge only PRs that carry a security advisory and leave regular minor / patch updates for manual review.
My first plan was to use the dependabot/fetch-metadata action's ghsa-id output for the condition. But after digging into the action, I realized that the ghsa-id output only gets populated if you pass alert-lookup: true and supply a PAT (Personal Access Token) as github-token. With the default GITHUB_TOKEN it always returns an empty string, which silently makes the condition false and skips every PR.
A simpler route is to check for the security label that Dependabot automatically attaches to every security advisory PR. Label-based gating needs no extra token.
# .github/workflows/dependabot-auto-merge.yml
name: Dependabot auto-merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
if: >-
github.actor == 'dependabot[bot]' &&
contains(github.event.pull_request.labels.*.name, 'security')
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
gh pr review --approve "$PR_URL" -b "Auto-approving Dependabot security update."
gh pr merge --auto --squash "$PR_URL"
You also need two repository-level flags. The second call hits the Enable Dependabot security updates endpoint.
gh api -X PATCH repos/:owner/:repo \
-f allow_auto_merge=true \
-f delete_branch_on_merge=true
gh api -X PUT repos/:owner/:repo/automated-security-fixes
Since auto-merge waits for CI to pass, I added a minimal PR-gate CI workflow at the same time: just npm ci + npm audit --audit-level=high + npm run build. One caveat: how safe auto-merge is depends on what your CI actually checks. For a static blog where "the build passes" is a reasonable proxy for "everything still works," the minimal pipeline is fine. For an application of meaningful complexity, auto-merge without E2E or integration tests raises the risk of "vulnerability fixed, feature broken."
5. Disable postinstall with ignore-scripts=true
The class of supply chain attack with the largest blast radius is arbitrary code execution via install-time hooks like postinstall. So I added one line to web/.npmrc:
ignore-scripts=true
I had a vague impression that this would break packages with native binaries, so I tested it in a separate directory by running npm ci --ignore-scripts:
esbuild— has apostinstallhook of its own, but the actual binary ships through 26 platform-specific entries inoptionalDependencies(@esbuild/darwin-arm64and friends). It works fine.sharp— distributes 14 platform-specific entries plus@img/sharp-libvips-*asoptionalDependencies. It works fine.workerd(Cloudflare Workers runtime) — also has apostinstallhook, but the binary comes in via@cloudflare/workerd-darwin-arm64and similaroptionalDependencies. It works fine.
The postinstall hooks are still there in the package metadata, but the actual distribution path has moved to optionalDependencies, so ignore-scripts does not break the build in practice.
That said, this is only because my stack happens to be on the side of the migration that is already complete. Depending on the stack, this setting will break the build. For example, projects using prisma rely on postinstall to run prisma generate and produce typed clients; native extensions that build locally need their hook to fire too. In those cases, ignore-scripts=true alone will leave you without those generated types or binaries. You'll need to either invoke npm rebuild <pkg> or call npx prisma generate explicitly from your build script, opting in to just the hooks you need. Before enabling this, I'd recommend trying npm ci --ignore-scripts && npm run build once in the target repository to see what actually breaks.
What this protects against, and what it doesn't
Being honest about it:
| Scenario | Protected by this setup? |
|---|---|
| Known CVEs (like the postcss/esbuild cases above) | Yes (npm audit + auto-merge) |
| Account-takeover packages yanked within hours to days | Often (cooldown 5 days) |
tj-actions/changed-files style (mutable tag rewrite) | Yes (SHA pin) |
postinstall RCE | Yes (ignore-scripts) |
| Long-dormant backdoor (xz-utils style) | No; even cooldown 30 days lets it through |
| Zero-day vulnerabilities | No; nobody knows about them yet |
Local npm install on a developer machine | Partially (the local .npmrc does apply) |
Typosquatting (reactdom instead of react-dom) | No; relies on human review |
Secret leakage from missing permissions: minimization in workflows | Not addressed this round |
The xz-utils style (a malicious maintainer who builds trust over more than two years before slipping in a backdoor) is out of reach for this configuration. Defending against that requires another layer: tools like socket.dev for proactive scanning, or OpenSSF Scorecard-style trust evaluation of upstream projects.
What's left for later
If I keep going, the next items I'd pick up are:
- Minimize
permissions:per job in each workflow (currently at the default). - Try the
socket.devGitHub App to surface package risk scores on PRs. - Add a
gitleakspre-commit hook for accidental secret commits. - Consider adding
ignore-scripts=trueto a global.npmrcon developer machines as well.
What I put in is meant to fit "the kind of additional cost a single-person repository can absorb without effort." It is not a full defense. But the attack surface is meaningfully narrower than running Dependabot alone.
Further reading (Japanese)
The individual topics covered here have been treated in more depth by existing Japanese-language posts. Cross-reading them is a good way to verify what you find here.
- Dentsu Soken Tech Blog "Trying GitHub Dependabot's cooldown feature" — A behavioral study of
cooldown. - ncdc "Pinning third-party Actions by SHA: actually no, that alone isn't enough" — A deeper discussion of where SHA pinning still falls short.
- junko_ai "The axios attack could have been blocked with two lines: notes on
.npmrcandignore-scripts" — Real-impact scenarios and experiments aroundignore-scripts. - zephel01 "Supply chain attacks in 2026: practical defenses for npm / Python / Go / Rust" — A cross-language summary of mitigations.
Primary sources
- Security hardening for GitHub Actions (GitHub Docs) — Basis for SHA pinning.
- Dependabot options reference (GitHub Docs) —
cooldownsyntax and its non-application to security updates. - Dependabot supports configuration of a minimum package age (GitHub Changelog, 2025-07-01) —
cooldownGA announcement. overrides(npm Docs) —npm overridesspec.dependabot/fetch-metadata(GitHub) — The action I initially tried (ghsa-idrequiresalert-lookup: trueplus a PAT).- tj-actions/changed-files incident summary (StepSecurity) — Real example of a mutable tag attack.
- CVE-2025-30066 (NVD) — CVE record for tj-actions/changed-files.
- CVE-2024-3094 (NVD) — xz-utils backdoor.
- OpenSSF Scorecard checks —
Pinned-Dependenciescheck.


