Back to glossary

Semantic Versioning (SemVer)

Semantic versioning (SemVer) numbers releases MAJOR.MINOR.PATCH, so each bump signals a breaking change, a new feature, or a fix that ranges auto-install.

Also known as SemVer 2.0.0, MAJOR.MINOR.PATCH

Semantic versioning (SemVer) is a convention for numbering software releases as MAJOR.MINOR.PATCH, where each increment declares a kind of change: breaking, backward-compatible feature, or backward-compatible fix. Package managers turn that declaration into ranges like ^1.14.0 that install new releases automatically, which makes SemVer both a compatibility contract and a delivery channel for whatever gets published next.

The version number is a claim the publisher makes, and nothing checks it. The npm registry verifies that 1.14.1 is well formed and has never been used before. It does not verify that 1.14.1 is a bug fix. On March 31, 2026, an attacker published axios@1.14.1, which added one new dependency carrying a remote access trojan. Any project declaring ^1.14.0 resolved to it on the next fresh install. We pulled registry timestamps for the axios compromise and the Qix maintainer compromise: all 20 malicious versions pushed to hijacked packages were patch bumps.

Our position: treat a version number as metadata to verify, not a promise to trust. A patch release that adds an install script or a network call is a major change for security purposes, whatever its number says.

What is semantic versioning?

#

Semantic versioning is a written specification, currently SemVer 2.0.0, originally authored by Tom Preston-Werner. It starts from one requirement: a package must declare a public API. Version numbers then describe how each release changes that API.

  • MAJOR increments for backward-incompatible API changes. MINOR and PATCH reset to 0.
  • MINOR increments for backward-compatible new functionality or deprecations. PATCH resets to 0.
  • PATCH increments for backward-compatible bug fixes.

Two more rules matter for security. Released versions are immutable: the spec says the contents of a released version "MUST NOT be modified." npm enforces this at the registry. Its unpublish policy states that once package@version has been used, "you can never use it again," even after an unpublish. And major version zero is a free-for-all: under SemVer, 0.y.z is initial development where anything may change at any time.

The spec also defines two optional suffixes. A pre-release (1.0.0-rc.1) sorts below its release. Build metadata (1.0.0+20260331) is ignored for ordering. Pre-release identifiers compare field by field, numerically when numeric, so beta.11 sorts after beta.2:

JavaScript
// npm i semver@7
const semver = require('semver')
console.log(semver.sort([
  '1.0.0', '1.0.0-rc.1', '1.0.0-alpha', '1.0.0-alpha.1',
  '1.0.0-alpha.beta', '1.0.0-beta', '1.0.0-beta.2', '1.0.0-beta.11'
]))
// [ '1.0.0-alpha', '1.0.0-alpha.1', '1.0.0-alpha.beta', '1.0.0-beta',
//   '1.0.0-beta.2', '1.0.0-beta.11', '1.0.0-rc.1', '1.0.0' ]

SemVer's own introduction names the failure mode this page is about. The spec frames the goal as escaping dependency hell, caught between two errors. Pin too tightly and you get version lock. Specify too loosely and, in the spec's words, "you will inevitably be bitten by version promiscuity." In 2026 the bite is not only a broken build. It is code from whoever controls the publish token.

Not every ecosystem that looks like SemVer is SemVer. Python uses PEP 440, which normalizes 1.0.0-alpha to 1.0.0a0 and allows forms like 1.0.0.post1 that SemVer rejects. A v prefix is also not part of a semantic version, although node-semver strips it and accepts v1.2.3 as 1.2.3.

A note on semantic versioning in the npm ecosystem

#

While the official specification states that every change in a 0.x version is breaking, npm based their implementation on an earlier version of the spec. In the npm ecosystem, versions are either v0.0.X, v0.X.Y, or vX.Y.Z - and in any of these groups, X is major, Y is minor, and Z is patch. You can see this in action on npm's semver playground with a package such as object-keys and a version range of ^0.1.0.

Non-npm ecosystems that implement semver may or may not follow npm's v0 semantics. There is an open pull request to the specification to bring the spec in line with npm's semantics.

How does semantic versioning work in package managers?

#

Semantic versioning works in package managers through range operators that translate a declared version into a set of acceptable future versions. The resolver then installs the highest version in that set that exists on the registry at that moment. npm writes a caret range by default: its config docs list save-prefix as ^ as of npm 12.2.1.

node-semver, the parser npm uses, shows exactly what each operator means:

JavaScript
const { Range } = require('semver')
for (const r of ['^1.14.0', '~1.14.0', '^0.30.0', '^0.0.3', '1.x'])
  console.log(r.padEnd(8), '=>', new Range(r).range)
// ^1.14.0  => >=1.14.0 <2.0.0-0
// ~1.14.0  => >=1.14.0 <1.15.0-0
// ^0.30.0  => >=0.30.0 <0.31.0-0
// ^0.0.3   => >=0.0.3 <0.0.4-0
// 1.x      => >=1.0.0 <2.0.0-0

Three details in that output matter. A caret range fixes the leftmost nonzero component, so ^0.30.0 behaves like a tilde range and ^0.0.3 allows exactly one version. The -0 upper bound excludes pre-releases of the next major. And pre-releases are excluded entirely unless the range itself names a pre-release on the same MAJOR.MINOR.PATCH tuple, so ^1.14.0 never installs 1.15.0-beta.1.

The figure tests four ranges against an illustrative version list. The exact pin 1.14.0 accepts only 1.14.0. The tilde range ~1.14.0 accepts 1.14.0 and 1.14.1, and a fresh install picks 1.14.1. The caret range ^1.14.0 accepts 1.14.0, 1.14.1, 1.15.0, and 1.99.0, and picks 1.99.0. The open range >=1.14.0 also accepts 2.0.0 and picks it. No range accepts 2.0.0-rc.1, because node-semver excludes pre-releases from ranges that do not name one. Every range except the exact pin accepts 1.14.1, the version the axios attacker published.

How do caret ranges install compromised versions?

#

Caret ranges install compromised versions because resolution happens at install time, against whatever the registry holds at that second. A malicious release that falls inside a declared range becomes the default answer for every fresh resolution until it is removed. Nothing in the depending project changes.

The registry's own metadata shows the axios window. npm keeps the time entry for a version even after the version itself is removed, which makes the packument a forensic record:

Bash
curl -s https://registry.npmjs.org/axios \
  | jq '{t140: .time["1.14.0"], t141: .time["1.14.1"],
         t0303: .time["0.30.3"], t0304: .time["0.30.4"],
         live141: (.versions | has("1.14.1"))}'
# {
#   "t140": "2026-03-27T19:01:40.915Z",
#   "t141": "2026-03-31T00:21:58.168Z",
#   "t0303": "2026-02-18T17:19:20.081Z",
#   "t0304": "2026-03-31T01:00:57.285Z",
#   "live141": false
# }

The attacker published two versions 39 minutes apart: 1.14.1 for the 1.x line and 0.30.4 for the 0.x line. That choice was deliberate. Because ^0.30.3 stops below 0.31.0, a 1.x release could never reach projects still on axios 0.30. One patch per supported line covered caret and tilde ranges on both lines. Axios had about 100 million weekly downloads at the time, according to Socket's report on the attack.

Exposure did not require listing axios at all. Our blast radius analysis found ranges like ^1.13.5 in continuous integration (CI) tools, SDKs, and Model Context Protocol (MCP) servers, so a transitive dependency range pulled 1.14.1 into builds that never named axios. As that post puts it, "the risk was introduced entirely at resolution time."

Attackers choose the patch bump

#

The patch bump is the attacker's best option because it is the smallest increment, and the smallest increment satisfies the most ranges. A patch release lands inside caret, tilde, x, and open-ended ranges alike. Only an exact pin or a lockfile keeps it out.

We checked every malicious version from two campaigns against the registry's time field, and compared it with the previous release on the same line:

Package@versionPrevious releasePublished (UTC)Days since previousBump
axios@1.14.11.14.02026-03-31 00:213patch
axios@0.30.40.30.32026-03-31 01:0040patch
ansi-styles@6.2.26.2.12025-09-08 13:121,062patch
debug@4.4.24.4.12025-09-08 13:12118patch
chalk@5.6.15.6.02025-09-08 13:1322patch
supports-color@10.2.110.2.02025-09-08 13:1322patch
strip-ansi@7.1.17.1.02025-09-08 13:13834patch
ansi-regex@6.2.16.2.02025-09-08 13:1422patch
wrap-ansi@9.0.19.0.02025-09-08 13:14681patch
color-convert@3.1.13.1.02025-09-08 13:14118patch
color-name@2.0.12.0.02025-09-08 13:151,035patch
is-arrayish@0.3.30.3.22025-09-08 13:152,638patch
slice-ansi@7.1.17.1.02025-09-08 13:15680patch
error-ex@1.3.31.3.22025-09-08 13:162,638patch
color-string@2.1.12.1.02025-09-08 13:1810patch
simple-swizzle@0.2.30.2.22025-09-08 13:193,218patch
supports-hyperlinks@4.1.14.1.02025-09-08 13:19121patch
has-ansi@6.0.16.0.02025-09-08 13:19425patch
chalk-template@1.1.11.1.02025-09-08 13:20839patch
backslash@0.2.10.2.02025-09-08 13:203,441patch

All 20 are patch increments. Every one satisfies both ^ and ~ of its predecessor, which we confirmed with semver.satisfies(). None of these versions can still be installed. The Qix packages together received 2 to 3 billion weekly downloads, according to Socket's report. The list excludes plain-crypto-js, the attacker's own package, and proto-tinker-wc, whose reported version number we could not reconcile.

The chart sorts the same 20 versions by how long their line had been quiet. backslash@0.2.1 arrived 3,441 days, about 9.4 years, after 0.2.0. simple-swizzle@0.2.3 followed 8.8 years of silence, and is-arrayish@0.3.3 and error-ex@1.3.3 followed 7.2 years each. Eleven of the 20 came more than a year after the previous release. At the other end, axios@1.14.1 shipped 3 days after 1.14.0, and four more versions came within 22 days of their predecessors.

Worms automate the same choice. Socket's analysis of Shai-Hulud v2 found its propagation routine "increments the patch version (x.y.z → x.y.z+1)" before republishing each stolen package. That works because SemVer gives a maintainer account takeover the same authority as the maintainer: a patch number is one more thing a stolen token can sign.

The lesson for detection: a decade-dormant package that suddenly ships a patch is an anomaly in its own right. The version number told consumers "safe to auto-update." The timing, the new dependency, and the new code told a different story, and those are the signals worth reading.

Lockfiles freeze ranges for the installs that read them

#

A lockfile records the exact version each range resolved to, so a frozen install (npm ci, pnpm install --frozen-lockfile) reproduces yesterday's tree instead of today's registry. During the axios window, a project with a committed lockfile that installed with npm ci did not pick up 1.14.1.

Lockfiles stop at three boundaries. First, adding or updating any dependency recomputes part of the graph against the live registry. Second, published packages do not carry lockfiles to their consumers, so every library range is resolved fresh in each consumer's tree. Third, ad hoc execution skips the project lockfile entirely. npx some-cli resolves the CLI's dependencies at run time. Rust has the same gap: the Cargo book says cargo install ignores the packaged Cargo.lock by default and recomputes versions, "possibly using newer versions that have been released since the package was published," unless you pass --locked.

Ranges in published libraries are not negligence. Our blast radius analysis calls them a deliberate trade-off: exact pins in libraries would force duplicate copies and version conflicts across the tree, and would block consumers from receiving fixes. The control point therefore belongs with whoever installs. Applications should commit a lockfile, install with frozen lockfiles in CI, and pin the tools they run with npx inside a project rather than resolving them live.

A dependency cooldown narrows the window from the other side. The pnpm 11 release set minimumReleaseAge to 1,440 minutes by default, so a caret range resolves to the newest version that is at least a day old. npm offers min-release-age, which defaults to null as of npm 12.2.1. The axios payload shows why a day is enough for many attacks. npm replaced plain-crypto-js@4.2.1 with a security placeholder 3 hours and 26 minutes after it was published, according to registry timestamps, so a 24-hour cooldown would never have resolved it. A cooldown does nothing against a malicious release that stays undetected longer than the wait, and it delays urgent security fixes by the same amount.

How ecosystems turn SemVer into installs

#

Each package manager makes a different default bet on how much of the future to accept. We ran each tool's add command in a clean project in October 2026 and recorded what it wrote:

Tool (version tested)CommandWritten to manifestFresh resolution picks
npm 10.9.9npm install lodash"lodash": "^4.18.1"Highest 4.x on the registry
pnpm 10.28.0pnpm add lodash"lodash": "^4.18.1"Highest 4.x; pnpm 11 excludes versions under 24 hours old
Cargo 1.97.0cargo add serdeserde = "1.0.229", which Cargo reads as ^1.0.229Highest 1.x
Poetry 2.4.3poetry add requestsrequests (>=2.34.2,<3.0.0)Highest 2.x
uv 0.11.32uv add requestsrequests>=2.34.2Highest version of any major
Go modulesgo getA minimum version in go.modThe highest minimum any module requires, never a newer release

Go is the outlier. Its module reference describes minimal version selection: the build list is "the minimum versions that satisfy all requirements," and higher available versions are not selected "since nothing requires them." A malicious v1.4.3 published tonight does not enter a Go build until someone edits a requirement or runs go get -u. Go also encodes the major version in the import path from v2 on, so example.com/mod/v2 is a different module from example.com/mod. Our Go modules entry covers the mechanics. Go has one SemVer leak of its own: for v2+ repositories without a go.mod, the reference says the go command may automatically upgrade to higher +incompatible versions "even though it may break the build."

The verdict: Go's default is the only one in this table where a newly published version cannot reach your build without a human action. npm, pnpm, and Cargo trade that for automatic patch delivery inside a major line. That trade is reasonable only when it is paired with a lockfile, frozen installs in CI, and a cooldown. uv writes only a lower bound, so a new major is eligible too, and the lockfile does all of the work. Use ranges in libraries, pin in applications, and never let an install that skips the lockfile run with credentials in reach.

Version numbers as an attack surface

#

Version numbers are inputs to resolver logic, and attackers craft them like any other input. Dependency confusion depends on version precedence. Alex Birsan's original research showed that when pip sees a package on both a private and a public index, "it defaults to installing from the source with the higher version number." A public upload at 9000.0.0 wins.

Release order is another signal. Socket raises a Chronological version anomaly alert when semantic versions are published out of chronological order. Backports to old lines are legitimate, and axios still publishes 0.x releases, so this alert is low severity by design. It is context for the reviewer, not a verdict.

Malformed specifiers are a third case. npm accepts dependency specifiers that are not SemVer ranges at all, such as dist-tags and URLs. Socket's Bad dependency semver alert flags npm packages whose dependency versions are invalid according to node-semver. A separate Wildcard dependency alert flags *, empty strings, and unbounded > ranges. Neither alert fires on ordinary caret or tilde ranges, because those are the ecosystem norm.

Install-time execution is what turned the axios patch into a compromise in seconds. plain-crypto-js@4.2.1 ran its dropper from a postinstall hook. npm v12 made dependency install scripts opt-in in July 2026, following pnpm 10, which closes that path for packages you have not approved. It does not close the Qix path: that payload hooked browser APIs in applications that bundled the patched chalk or debug, and targeted their end users.

What SemVer cannot express

#

SemVer encodes API compatibility. It has no field for who published the release, how it was built, or what the code does at install and run time. The axios@1.14.1 diff changed no public API. It added a dependency. By the letter of the spec, that is a valid patch release.

That gap is why we analyze each version's contents rather than its number. Socket's scanner flagged plain-crypto-js@4.2.1 six minutes after it was published, before the first poisoned axios release went out. Nothing in its version string gave it away.

The open problem is that compatibility and trust share one number. A release that keeps every function signature but adds a network call, an install hook, or a new maintainer should not ride a caret range into production. Until package managers treat those changes as breaking, the range in your manifest is a standing order to install whatever the next publisher ships.