Keeping dependencies secure: semver, automation and their limits
Updated 13 min read
Most of an application's code comes from dependencies. How semver and automated updates keep it secure, and what to do when an update can't.

On 7 March 2017, the Apache Software Foundation published a fix for a critical vulnerability in Struts, a Java web framework used by thousands of companies. Equifax ran Struts in a system that handled consumer disputes. Its own policy reportedly required critical patches within 48 hours. The fix still wasn't applied, and from mid-May attackers used the vulnerability to get in. Over the following weeks they took the personal data of about 147 million people, and the settlement with the FTC, the CFPB and 50 US states and territories came to as much as $700 million.
The vulnerability wasn't new and the fix wasn't secret. What failed was the routine of taking updates. That routine is rarely discussed outside engineering teams, yet it decides how much of a company's software is quietly out of date, how expensive the next upgrade will be, and how fast a published fix can reach production. This article is about that routine: what version numbers promise, how automation turns updates from projects into maintenance, and what to do when an update can't deliver the fix you need.
Most of your code isn't yours
A typical web application declares a few dozen dependencies: libraries and frameworks it builds on instead of writing them itself. Those bring their own dependencies, called transitive dependencies, and the full tree is usually hundreds of packages. Even a small website can install about 800. Almost nobody chose them directly, and nobody reads their changelogs.
Together they form the application's software supply chain. Like any supply chain, it needs a record of what's in it and a way to replace a part that turns out to be faulty. The record is the lock file (and, in more regulated settings, a software bill of materials, or SBOM). The replacement process is dependency management.
What a version number promises
Most ecosystems use semantic versioning, or semver. A version such as 7.29.1 has three parts, and each one is a promise from the library's authors:
- Patch (
7.29.0→7.29.1): bug fixes and security fixes, nothing else changes. Safe to take routinely, after the tests pass and the release has been public for a few days (more on that below). - Minor (
7.29→7.30): new features, but nothing that worked before should break. Safe with a passing test suite. - Major (
7→8): breaking changes. Code that used the library may need to change. This is a small project, not an update.
A project states which versions of each dependency it accepts with a version constraint:
^7.29.1accepts any 7.x from 7.29.1 up: patch and minor updates, never a new major. Below 1.0 the caret narrows:^0.4.2accepts only 0.4.x, because in 0.x versions the minor number is the one that signals breaking changes.~7.29.1accepts only patch updates within 7.29.7.29.1accepts exactly one version and nothing else.
The lock file (package-lock.json, composer.lock, poetry.lock and so on) records the exact versions that were actually installed, so every developer, every CI run and every server gets the same tree. The constraint is the policy, and the lock file is the current state. Updating means moving the state forward within the policy.
Semver is a promise, not a guarantee. Versions below 1.0 make no compatibility promise at all, breaking changes sometimes slip into minor releases, and some ecosystems use their own variations. That's why updates go through tests rather than straight to production. But the promise holds often enough to build a process on.
Automation: small updates, continuously
There are two ways to keep dependencies current. One is to ignore them until something forces an upgrade, then spend days or weeks catching up across several major versions at once. The other is to take small updates all the time, so that each one is trivial.
The second way only works if it's automated. Tools such as GitHub's Dependabot and Renovate watch the dependency tree, open pull requests for available updates, and let the test suite decide whether they're safe. A sensible configuration follows semver directly:
- Patch and minor updates are grouped into one pull request a week, and only include releases that have been public for a few days. When the tests and the build pass, they can be merged with a click, or automatically.
- Major updates are proposed separately, or kept out of the automation entirely, and planned as work, because they need someone to read the migration guide.
- Security updates arrive as soon as an advisory is published, outside the weekly schedule.
The benefits are practical, and they show up on the business side as well:
- Lower cost over time. Many small updates cost far less than one big upgrade, because each one changes little and breaks rarely. When it does break, the cause is obvious.
- Faster security fixes. When an advisory is published, a project that is up to date is usually one patch release away from the fix. A project that is three major versions behind has to do the upgrade first, under pressure.
- Predictable work. Updates become a few minutes of review each week instead of an unplanned project that competes with features.
- A record. Every update is a reviewed, tested change with a history, which is what auditors and customers increasingly ask for.
Automation can also fail quietly. By default, Dependabot stops opening update pull requests once five are open. In one project I maintain, six forgotten pull requests from two years earlier had blocked every update since, and nobody noticed, because a tool that does nothing raises no alarm. Grouping updates into one weekly pull request fixed it, but only after major versions were kept out: a grouped pull request that contained three majors failed the build every week and held back the safe updates with it. The same discipline that makes deployments safe, small steps and the risky part kept separate, applies here.
When the update itself is the attack
Everything above assumes that a new release is what its authors meant to publish. Sometimes it isn't. In September 2025, an attacker phished the npm account of the maintainer of chalk, debug and more than a dozen other packages that together are downloaded billions of times a week, and published versions containing code that stole cryptocurrency. About a week later a self-replicating worm, named Shai-Hulud, spread through hundreds of npm packages by stealing publishing tokens from the machines and CI pipelines that installed it, and publishing infected versions of whatever those tokens could reach.
These are supply-chain attacks: the vulnerability isn't a bug in a legitimate release, the release itself is malicious. Automation makes them more dangerous, because a pipeline that installs every new patch within hours installs the malicious one within hours too. Malicious releases tend to be spotted and pulled quickly (the chalk and debug versions were gone within hours), so the standard defence is a minimum release age, also called a cooldown: don't install a version until it has been public for a few days.
- Dependabot has a
cooldownsetting, and since July 2026 waits three days by default before proposing a version update. - Renovate has
minimumReleaseAge. - pnpm has
minimumReleaseAgesince version 10.16, and npm and Yarn have added similar settings.
Security updates are the exception. Dependabot doesn't delay them, and they shouldn't wait: a published fix for a known vulnerability is worth taking at once. A few days' delay on routine updates costs almost nothing, and it covers the window in which malicious releases tend to be caught.
From advisory to fix
Security fixes travel through the same pipeline. When a vulnerability is found in a library, it's published as an advisory, usually with a CVE identifier and a CVSS severity score from 0 to 10. Scanners such as Dependabot alerts, npm audit, composer audit, pip-audit and OSV-Scanner compare the project's dependency tree against those advisories and report every match.
Each alert names the package, the vulnerable versions and the first fixed version. In the common case, the fix is a patch release that the project's constraints already allow, and the weekly update, or a dedicated security update, installs it. That's the case automation was built for, and it handles it well.
What an alert doesn't tell you is whether the fixed version can actually be installed. That depends on the packages in between.
When an update can't deliver the fix
Libraries declare constraints on their own dependencies too, and three situations block the fix:
- An exact pin. A library you depend on has decided that it works with exactly one version of something, and that version is the vulnerable one. Updating your own dependencies changes nothing, and the library's next release may pin the same version again.
- A pin you can't see. The pin sits two or three levels down, in a library you've never heard of.
- An abandoned path. The vulnerable package comes through a dependency that is no longer maintained and will never be updated.
I ran into all three in a single afternoon. The CMS pinned its HTTP client to an exact version with ten advisories, two of them rated high, and its latest release still pinned the same version. A code editor bundled in the CMS admin pinned a sanitising library to an exact version, and a new advisory for that version was published while I was fixing the first problem. And a database tool pulled in an old version of a bundler through a helper package that its authors had deprecated. In each case the fix was a patch release away, and no update command could install it.
The answer is an override: the root project overrules the version that a library asks for. Most package managers support it:
- npm has
overrides, which can be scoped to one parent package. Yarn hasresolutions, and pnpm has its ownoverrides:
"overrides": {
"payload": {
"undici": "^7.29.1"
}
}- Maven overrides transitive versions through
dependencyManagement. Gradle uses dependency constraints, orresolutionStrategy.forcewhen a library declares a strict version that constraints can't overrule. - Go resolves every module to the highest version anything asks for, so requiring the fixed version in your own
go.modis enough. - pip can raise the floor with a constraints file, but it can't overrule a library's exact pin. uv can, with
override-dependencies. - Composer has no general override. If the library's constraint allows the fixed version, requiring it directly works. If it pins an exact version, the options are an inline alias, a fork, or waiting for a release. Since Composer 2.9, updates refuse to resolve to versions with known advisories by default, so an exact pin on a vulnerable version stops
composer updateuntil it's fixed or the advisory is explicitly ignored.
Three rules keep overrides from becoming the next problem:
- Use a range, not an exact version.
^7.29.1keeps receiving patch releases through the normal automation. An exact override freezes the dependency again. - Scope it as narrowly as the tool allows, to the one parent that needs it.
- Review it whenever the parent changes. A range has a flip side: if the parent library later moves to the next major version of the dependency, an override such as
^7.29.1quietly forces the old major back, and something breaks. Overrides go out of date, and every major update of the parent is the moment to check whether they're still needed. - Write down why it exists. An override is a temporary disagreement with a library's authors, and someone has to know when it can be removed.
package.jsondoesn't allow comments, so the reason belongs in the commit message, a short decision record, or a note next to the manifest.
The traps in between
Overrides are simple to write. The trouble is in how lock files react to them:
- A lock file can ignore a new override. npm treated my project as up to date after I added one, because the lock file already recorded the old version. The override only took effect after those stale entries were removed and the tree was resolved again.
- An update can produce a lock file that a clean install rejects. A routine update rearranged the tree so that one package received a version of a shared dependency it didn't accept. Everything still built locally, because the installed files happened to work.
npm ci, which installs strictly from the lock file, refused it, which is why CI should install that way: it proves the lock file is valid before anything reaches production. Not every tool is that strict.composer installonly warns when the lock file is out of date withcomposer.jsonand installs anyway, so PHP pipelines need an explicit check such ascomposer validate --strict.
Decide, don't just react
Not every alert is a real risk, and teams that treat every alert as an emergency soon learn to ignore them all. The professional approach is a triage for each one:
- Severity says how bad the vulnerability is in general.
- Likelihood of exploitation says whether anyone is actually using it. The EPSS score estimates the probability of exploitation in the next 30 days, and the CISA Known Exploited Vulnerabilities catalogue lists the ones already used in real attacks.
- Reachability says whether it matters here: is the package loaded at runtime, is the vulnerable function used, and can untrusted input reach it? Development dependencies aren't automatically safe: they run in CI, where deployment keys and other secrets live.
- Remediation time is the target for fixing it, set by severity: days for critical issues, weeks for minor ones.
The outcome is always a decision: updated, overridden, or dismissed with a written reason. In the case of the deprecated bundler above, searching the installed code showed that nothing ever loaded it, so the alert could have been dismissed. A scoped override cost one line and brought the alert count to zero, which keeps the next real alert visible. Both are legitimate. Leaving the alert open for months isn't.
What a mature setup looks like
- A version policy: patch and minor updates weekly and automated, majors planned as work.
- Automated pull requests, grouped and tested, with nothing blocking them, and a waiting period of a few days for new releases.
- Strict installs and lock file checks in CI, so that an invalid dependency tree fails the build instead of reaching production.
- Scanning on every change, and a triage decision for every alert within a set time.
- Scoped overrides with ranges for fixes that updates can't deliver, each with a recorded reason.
Updates are maintenance, not projects
Equifax had a policy and a patch, and neither helped, because applying the patch was still a manual, one-off task that could slip. The opposite is a routine in which updates happen every week whether anyone remembers them or not, version numbers tell the automation what is safe, and the rare fix that an update can't deliver is handled with an override and a decision. That routine costs a few minutes a week. Its absence can cost far more.
Sources: Equifax data breach, CSO Online; Equifax data breach, EPIC; Semantic Versioning 2.0.0; Widespread supply chain compromise impacting npm ecosystem, CISA; Dependabot options reference, GitHub.


