# 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

Source: https://socket.dev/glossary/semantic-versioning-semver

Last updated: 2026-10-30

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](https://socket.dev/blog/axios-npm-package-compromised) and the [Qix maintainer compromise](https://socket.dev/blog/npm-author-qix-compromised-in-major-supply-chain-attack): 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](https://semver.org/) says the contents of a released version "MUST NOT be modified." npm enforces this at the registry. Its [unpublish policy](https://docs.npmjs.com/policies/unpublish) 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](https://socket.dev/glossary/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](https://peps.python.org/pep-0440/), 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](https://www.npmjs.com/package/semver) 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](https://semver.npmjs.com/) 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](https://github.com/semver/semver/pull/923) 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](https://docs.npmjs.com/cli/v12/using-npm/config) 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`.

> Matrix showing which of seven versions each npm range accepts. The exact pin 1.14.0 accepts only 1.14.0. ~1.14.0 accepts 1.14.0 and 1.14.1. ^1.14.0 accepts 1.14.0 through 1.99.0. \>=1.14.0 also accepts 2.0.0. No range accepts the prerelease 2.0.0-rc.1. Every range except the exact pin accepts 1.14.1, the version an attacker published.

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](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise) found ranges like `^1.13.5` in continuous integration (CI) tools, SDKs, and Model Context Protocol (MCP) servers, so a [transitive dependency](https://socket.dev/glossary/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@version | Previous release | Published (UTC) | Days since previous | Bump |
| --- | --- | --- | --- | --- |
| axios@1.14.1 | 1.14.0 | 2026-03-31 00:21 | 3 | patch |
| axios@0.30.4 | 0.30.3 | 2026-03-31 01:00 | 40 | patch |
| ansi-styles@6.2.2 | 6.2.1 | 2025-09-08 13:12 | 1,062 | patch |
| debug@4.4.2 | 4.4.1 | 2025-09-08 13:12 | 118 | patch |
| chalk@5.6.1 | 5.6.0 | 2025-09-08 13:13 | 22 | patch |
| supports-color@10.2.1 | 10.2.0 | 2025-09-08 13:13 | 22 | patch |
| strip-ansi@7.1.1 | 7.1.0 | 2025-09-08 13:13 | 834 | patch |
| ansi-regex@6.2.1 | 6.2.0 | 2025-09-08 13:14 | 22 | patch |
| wrap-ansi@9.0.1 | 9.0.0 | 2025-09-08 13:14 | 681 | patch |
| color-convert@3.1.1 | 3.1.0 | 2025-09-08 13:14 | 118 | patch |
| color-name@2.0.1 | 2.0.0 | 2025-09-08 13:15 | 1,035 | patch |
| is-arrayish@0.3.3 | 0.3.2 | 2025-09-08 13:15 | 2,638 | patch |
| slice-ansi@7.1.1 | 7.1.0 | 2025-09-08 13:15 | 680 | patch |
| error-ex@1.3.3 | 1.3.2 | 2025-09-08 13:16 | 2,638 | patch |
| color-string@2.1.1 | 2.1.0 | 2025-09-08 13:18 | 10 | patch |
| simple-swizzle@0.2.3 | 0.2.2 | 2025-09-08 13:19 | 3,218 | patch |
| supports-hyperlinks@4.1.1 | 4.1.0 | 2025-09-08 13:19 | 121 | patch |
| has-ansi@6.0.1 | 6.0.0 | 2025-09-08 13:19 | 425 | patch |
| chalk-template@1.1.1 | 1.1.0 | 2025-09-08 13:20 | 839 | patch |
| backslash@0.2.1 | 0.2.0 | 2025-09-08 13:20 | 3,441 | patch |

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.

> Bar chart of how many days each of 20 hijacked npm packages had gone without a release on that line before the attacker published a malicious patch version, from 3 days for axios 1.14.1 to 3,441 days for backslash 0.2.1

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](https://socket.dev/blog/shai-hulud-strikes-again-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](https://socket.dev/glossary/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](https://socket.dev/glossary/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](https://doc.rust-lang.org/cargo/commands/cargo-install.html) 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](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise) 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](https://socket.dev/glossary/dependency-cooldown) narrows the window from the other side. The [pnpm 11 release](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults) 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) | Command | Written to manifest | Fresh resolution picks |
| --- | --- | --- | --- |
| npm 10.9.9 | npm install lodash | "lodash": "^4.18.1" | Highest 4.x on the registry |
| pnpm 10.28.0 | pnpm add lodash | "lodash": "^4.18.1" | Highest 4.x; pnpm 11 excludes versions under 24 hours old |
| Cargo 1.97.0 | cargo add serde | serde = "1.0.229", which Cargo reads as ^1.0.229 | Highest 1.x |
| Poetry 2.4.3 | poetry add requests | requests (\>=2.34.2,\<3.0.0) | Highest 2.x |
| uv 0.11.32 | uv add requests | requests\>=2.34.2 | Highest version of any major |
| Go modules | go get | A minimum version in go.mod | The highest minimum any module requires, never a newer release |

Go is the outlier. Its [module reference](https://go.dev/ref/mod) 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](https://socket.dev/glossary/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](https://socket.dev/glossary/dependency-confusion) depends on version precedence. Alex Birsan's [original research](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610) 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](https://docs.socket.dev/docs/supply-chain-risk) 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](https://socket.dev/alerts/badSemverDependency) alert flags npm packages whose dependency versions are invalid according to node-semver. A separate [Wildcard dependency](https://socket.dev/alerts/floatingDependency) 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](https://socket.dev/glossary/postinstall-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.
