
18 npm packages, including chalk@5.6.1, landed on the registry on 8 September 2025. I opened chalk issue 656, created at 13:55 UTC that day. sindresorhus wrote the bad lines “were available for approximately 2 hours.” A caret in package.json plus npm install on a build agent is how that number enters a lockfile without a person reading a changelog.
The miss is treating every bump as the same ticket. A public advisory is a version you already chose, now known bad. A hijacked maintainer is a tarball nobody intended you to run. Keep the secure coding checklist next to the bot config. Read injection when the code you already trust starts concatenating input.
A CVE bump is homework. A new tarball is not
Two clocks sit on the same pull request title.
| Signal | What you do |
|---|---|
| Known CVE | Advisory names a fix version. Merge the pin. Ship. |
| Version bot, no advisory | Diff the lockfile. Check the publisher. Wait out the cooldown. |
| Unexpected publish | Do not automerge. Restore the last committed tree. See the sibling page. |
Software composition analysis matches a coordinate to an advisory. No advisory, no alert. That is correct for lodash@4.17.20 with a public CVE. It is silent for a two-hour chalk tarball. Mixing those jobs is how a team spends Friday on a CVSS ticket while a floating range walks into CI.
PIN package-lock.json names chalk@5.6.0
npm ci installs that exact tarball
Dependabot later opens 5.6.2 after the advisory
FLOAT package.json says "chalk": "^5.6.0"
npm install on 8 Sep 2025 writes 5.6.1
audit has nothing to print yet
CONTROL save-exact, committed lockfile, npm ci
cooldown or minimumReleaseAge of 3 days
no automerge on an unknown publisher
What 8 September 2025 actually changed
I am dating the window from the first-party issue, not from secondary blogs. chalk#656 opened 8 September 2025 at 13:55 UTC with the title “Version 5.6.1 published to npm is compromised (RESOLVED).” The clean republish list on that thread includes chalk@5.6.2, strip-ansi@7.1.2, ansi-styles@6.2.3, and the rest of that family. A later note on the same comment, “08 Sep 2025 21:59 CEST”, says npm took the infected versions down.
The HN comment this page quotes names 8 and 9 September 2025. The issue and the takedown sit on the 8th. The first-party issue and BleepingComputer put the publish and the takedown on 8 September.
This page will not reconstruct the payload. The defensive fact is enough: a package you already trusted grew new code at publish time. Clients that installed during those two hours received it. The sibling guide is the runbook after a bad number is already in a tree. This page is how the bot is supposed to behave so that number never becomes “the next hygiene PR.”
Pin the lockfile, then install with ci
Commit package-lock.json. Build with npm ci. npm ci refuses to proceed when the lockfile and package.json disagree. That is the feature. npm install will resolve, rewrite, and keep going.
# .npmrc in the app repo
save-exact=true
# CI: install exactly what main already agreed
npm ci --ignore-scripts
save-exact writes chalk@5.6.2, not ^5.6.2. Ranges in package.json are a product choice. The lockfile is the security choice. Never regenerate the lockfile on the release agent. A laptop that ran npm update on a Monday morning is an install-time event. Main does not protect that laptop.
failIfNoLockfile is the named fallback when a language has no ci verb. Fail the job. Do not invent a resolve step.
# scripts/fail-if-no-lockfile.sh
if [ ! -f package-lock.json ]; then
echo "no lockfile; refusing to resolve" >&2
exit 1
fi
npm ci --ignore-scripts
Integrity hashes in a lockfile v2 or v3 file decide whether bits changed after you accepted a tarball. They do not decide whether the accepted tarball was honest. Pinning plus ci is the cheap half. The bot config is the other half.
Dependabot: group the noise, keep the CVE lane
I opened the GitHub Blog post “Tame Dependabot” dated 29 July 2026. Microsoft’s GCToolkit, as of that month, had 92 of 578 commits as Dependabot version bumps. The fix in that post is three lines of policy: group routine bumps, slow the cadence, leave the security lane alone.
The same post dates a February 2026 change: Dependabot can group one library across many directories with group-by: dependency-name. It also states a new default cooldown. Dependabot now waits until a release has been on its registry for at least three days before opening a version-update pull request. Security updates still open as soon as a fix is disclosed. That split is the whole point of this page.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
groups:
weekly-batch:
patterns: ["*"]
update-types: ["minor", "patch"]
open-pull-requests-limit: 5
Leave majors out of the group so a human sees the breaking change. Do not set applies-to: security-updates on that weekly batch. A disclosed CVE should not wait for Monday. Confirm Dependabot alerts and security updates are on before you trust a slower version cadence. The 29 July 2026 post is explicit: the schedule shapes version updates, not the CVE lane.
Automerge is the footgun. A clean audit score on a three-day-old minor is still not a verdict on a surprise publisher. If you automerge patches, restrict it to packages you already ship and whose publisher has not changed. A new name in the lockfile is a review, not a merge.
Renovate: pin ranges and wait three days
I opened Renovate’s configuration-options page. rangeStrategy default is auto. For npm, auto keeps a wide range and updates the lockfile when the new number still satisfies the caret. That is how a float keeps winning. Set rangeStrategy to pin so ^5.6.0 becomes 5.6.2.
The same page documents minimumReleaseAge. As of Renovate 42.19.5, a value of 0 days is treated as unset. The docs call out npm’s 72-hour unpublish window and recommend minimumReleaseAge: "3 days" for npm so you do not pin a version that can still vanish, or worse, a version that has not had three days of eyes on it.
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"rangeStrategy": "pin",
"packageRules": [
{
"matchDatasources": ["npm"],
"minimumReleaseAge": "3 days"
},
{
"matchUpdateTypes": ["patch"],
"matchPackageNames": ["chalk", "debug"],
"automerge": true
}
]
}
automerge on that second rule is the named fallback for packages you already decided you trust, after the age check. It is not a global true. A major, a new package name, or a publisher you have not seen stays on a human PR. Pair this with dependencyDashboard so a pending age check is visible instead of looking like Renovate stalled.
Review a bot PR as if a stranger published it
The title “Bump chalk from 5.6.0 to 5.6.2” is not enough. Open the lockfile diff. You are looking for three things.
- The number you expected. A CVE ticket names the fix version. A version bot should move one coordinate, or one group you configured. A lockfile that grew a second package you did not ask for is the review.
- The publisher. npm’s
_npmUseron the registry page, or thepackage-lock.jsonresolvedhost. A new scope or a new user on a package you have shipped for years is a stop. - Lifecycle scripts. A new
preinstall,install, orpostinstallin a dependency you already had is a stop until that package is on the allowlist you use for native compiles.
# in the bot PR checkout
git diff origin/main -- package-lock.json | rg -n "postinstall|preinstall|resolved"
# names that appeared in the 8 Sep 2025 family, so you can grep a stale tree
rg -n "chalk-5\\.6\\.1|debug-4\\.4\\.2|ansi-styles-6\\.2\\.2" package-lock.json
If any of those 8 September coordinates still appear in 2026, that tree was written during the window or copied from one that was. Replace each pin with the clean line the maintainer published the same day. Then rebuild with npm ci, not npm install.
Transitive installs are why the lockfile review matters more than the package.json line. You may never have typed chalk. The lockfile still records it. A floating range one level up can pull the new number without a human seeing the name in the PR title.
Prove the agent cannot float
You are not proving a package is kind. You are proving the build cannot silently move, and that a missing lockfile cannot resolve.
# 1. ci must be clean against the committed lockfile
# scripts already off via ignore-scripts in .npmrc
npm ci
# 2. a second job with the lockfile removed must fail
# (the fail-if-no-lockfile.sh block above)
# 3. Dependabot security updates stay enabled even if
# version updates are weekly. Check the repo setting,
# not the yml schedule.
A CI cache that keys on package.json but not the lockfile will fetch whatever the range allows today. Key the cache on the lockfile digest. A developer laptop is still an install-time event. Put save-exact=true in the repo .npmrc so a local npm install lodash does not write a caret for the next person.
I could not confirm a first-party GitHub number, as of August 2026, for how many repos have the three-day cooldown on by default beyond the 29 July 2026 blog statement that it is the default. Treat that post as the citation. If a repo still opens a version PR the same hour a number hits npm, the cooldown is off. Turn it on, or set minimumReleaseAge in Renovate.
Questions we keep getting
Does Dependabot catch a hijacked release?
Not in the first hour. Security updates need an advisory. Version updates, with the 2026 cooldown, wait three days, which is longer than the two-hour chalk window. The lockfile pin is what stops a same-day npm install. Audit is the CVE lane.
Should we automerge patches?
Only for packages you already ship, after minimumReleaseAge or cooldown, and only when the publisher did not change. A new name, a new postinstall, or a major stays on a human. A global automerge is how a surprise tarball becomes main.
Is pinning enough on its own?
Pinning stops a floating range from moving on the next npm install. It does not stop a human or a bot from rewriting the lockfile onto a bad number. Pair the pin with npm ci, a three-day wait on routine bumps, and a review of unexpected names. The sibling page is the runbook when the bad number is already there.



