CVE Affected Versions: lessThan, defaultStatus, versionType

The affected block in a CVE record is a small algorithm, not a list. Here is how version, lessThan, lessThanOrEqual, changes, versionType and defaultStatus combine, what a sample of 1,000 2026 CVE records actually contains, and a tested Python evaluator that refuses version types it cannot order.

Macro close-up of the steel scales of a vernier caliper, with inch and centimetre graduations and a sliding vernier scale
Photo by Bozhin Karaivanov on Unsplash

Scope. This is a specification explainer with measurements from public data. We read the CVE JSON schema (version 5.2.0, CVEProject/cve-schema main branch) and its version-encoding guide, sampled 1,000 random 2026 records from the cvelistV5 repository at commit 451e97a (November 2, 2026), and ran the Python script below against real records. We did not test commercial scanners.

The short answer

A CVE record’s affected block does not hand you a list of bad versions. For each product it gives a small decision procedure:

  1. Walk the versions list in order.
  2. An entry with only version and status matches that exact version.
  3. An entry with versionType plus lessThan or lessThanOrEqual matches a range, and an optional changes list can flip the status partway through it.
  4. The first matching entry wins. If nothing matches, the answer is defaultStatus, and if that is missing, the answer is unknown.

The possible answers are affected, unaffected and unknown. Your tooling needs a fourth, “I cannot order these versions”, because the record does not always give you enough to decide.

Where the fields live

The CNA (CVE Numbering Authority) writes containers.cna.affected, an array of product objects. The schema requires each product to be identified by either vendor and product or collectionURL and packageName, and to carry versions, defaultStatus or both. A 5.2 record can also carry a packageURL (a purl, which the schema says must not include a version).

{
  "vendor": "GitoxideLabs",
  "product": "gitoxide",
  "defaultStatus": "unaffected",
  "packageURL": "pkg:cargo/gix-fs",
  "versions": [
    {"version": "0", "status": "affected", "versionType": "semver", "lessThan": "0.23.0"},
    {"version": "0.23.0", "status": "unaffected", "versionType": "semver"}
  ]
}

That is the product entry from CVE-2026-100419 (a gix-fs symlink worktree escape), shown in full. Read it as: semantic versions from 0 up to but not including 0.23.0 are affected, 0.23.0 is unaffected, and anything else is unaffected by default.

The fields, one by one

FieldWhat the schema saysWhat it means in practice
versionThe single version, or the start of a range. “By convention, typically 0 denotes the earliest possible version.”"version": "0" means “no lower bound”
statusOne of affected, unaffected, unknownunknown can mean nobody investigated or the vendor did not disclose
versionType“The version numbering system used for specifying the range”; examples include semver, maven, python, rpm, git, customPicks the comparison rules. Required for a range
lessThan“The non-inclusive upper limit of the range. This is the least version NOT in the range.”Usually the first fixed release
lessThanOrEqual“The inclusive upper limit of the range”Usually the last vulnerable release
changesStatus changes inside a range, each with at and status; “clients must re-sort the list themselves rather than assume it is sorted”Lets one entry say “affected, then fixed from X”
defaultStatus“The default status for versions that are not otherwise listed… If not specified, defaultStatus defaults to ‘unknown’.”The answer for every version no entry matched

The upper limit can end in *, which the schema defines as “an arbitrarily large number in the version ordering”. {version: 1.0, lessThan: 1.*} describes the whole 1.x branch, and lessThan: * means “and everything after”.

Why versionType is not optional

The schema guide explains that “there is no single definition of ’less than’ for versions” and gives the example that in semantic versioning 1.0.0-cr1 sorts before 1.0.0-m1, while in Maven the opposite is true. If your code compares every version string with one generic routine, it will be wrong for some ecosystems.

The guide is blunt about custom: it “means that the version status algorithm cannot be executed, so its use is discouraged”. It exists to convert older data.

Three records, three encodings

Allow-list style (gix-fs, CVE-2026-100419). The range is affected and defaultStatus is unaffected. Version 0.22.1 is affected, 0.23.0 is unaffected by exact match, and 0.25.4 is unaffected by default.

List without a default (quickjs, CVE-2026-0821). The CNA lists eleven single versions from 0.1 to 0.11.0 as affected and sets no defaultStatus. Version 0.10 is affected. Version 0.12.0 is unknown: the record makes no claim about it. A naive matcher that treats “not listed” as “not affected” would clear it.

Deny-list style (Linux, CVE-2026-100075). The second product entry sets "defaultStatus": "affected" and lists unaffected ranges per stable branch:

{"version": "4.7", "status": "affected"},
{"version": "0", "lessThan": "4.7", "status": "unaffected", "versionType": "semver"},
{"version": "6.6.157", "lessThanOrEqual": "6.6.*", "status": "unaffected", "versionType": "semver"},
{"version": "7.3", "lessThanOrEqual": "*", "status": "unaffected", "versionType": "original_commit_for_fix"}

(Excerpt; the record also lists 5.10, 5.15, 6.1, 6.12, 6.18 and 7.2 branches.) Kernels before 4.7 are unaffected, 6.6.157 and later 6.6 releases are unaffected, and anything not covered, such as 6.6.150 or a 5.4 kernel, falls through to affected. The first product entry in the same record describes the same flaw with versionType: git commit ranges, which you cannot evaluate without the kernel’s commit history.

What a sample of 2026 records contains

We drew 1,000 CVE IDs at random (seeded) from the 83,665 record files under cves/2026 at cvelistV5 commit 451e97a. 16 were REJECTED; the 984 published records held 2,061 product entries, 4,555 version entries and 2,831 ranges. Counts were computed with a Python script over the JSON.

MeasureCountShare
Product entries with no defaultStatus (so unknown applies)1,130 of 2,06154.8%
defaultStatus: unaffected58528.4%
defaultStatus: affected31115.1%
Product entries with only single versions and no defaultStatus31515.3%
Version entries that are single versions / lessThan ranges / lessThanOrEqual ranges1,724 / 1,929 / 902 of 4,55537.8% / 42.3% / 19.8%
Ranges with versionType: custom1,006 of 2,83135.5%
Ranges with semver / git / rpm971 / 560 / 17134.3% / 19.8% / 6.0%
Ranges with a * in the upper limit71725.3%
Version entries using changes481.1%
Published records with a cpeApplicability block290 of 98429.5%
Published records with a packageURL on any product616.2%

Two numbers matter most for automation. More than half of product entries leave unlisted versions as unknown, and about a third of ranges use custom, so a generic comparator cannot decide them. This is one sample at one point in time; the mix varies by CNA, and a different year or sample would give different shares.

A tested evaluator

The script implements the schema’s algorithm. It handles dotted numeric versions (so 1.2.10 sorts after 1.2.9) and *, and it refuses any range whose versionType it has not been told how to order. It does not implement semver pre-release ordering, RPM epochs or Maven qualifiers; treat it as a reference, not a drop-in matcher. Python 3.10 or later, standard library only.

#!/usr/bin/env python3
"""Decide affected / unaffected / unknown for one product version from a CVE record,
following the algorithm in the CVE JSON 5 schema (product.versions + defaultStatus).
Usage: affected_status.py RECORD.json VERSION [vendor/product filter] [--numeric TYPE,...]
"""
import json, sys

SUPPORTED = {"semver"}  # extend with real comparators (rpm, maven, python...) before use

def key(v):
    parts = []
    for p in v.split("."):
        if p == "*":
            parts.append(float("inf"))
        elif p.isdigit():
            parts.append(int(p))
        else:
            raise ValueError(f"non-numeric version part {p!r} in {v!r}")
    return parts

def cmp(a, b):
    ka, kb = key(a), key(b)
    n = max(len(ka), len(kb))
    # pad: a missing part is 0, except after a wildcard, which stays infinite
    ka += [float("inf") if ka and ka[-1] == float("inf") else 0] * (n - len(ka))
    kb += [float("inf") if kb and kb[-1] == float("inf") else 0] * (n - len(kb))
    return (ka > kb) - (ka < kb)

def status(product, v):
    for e in product.get("versions", []):
        is_range = "lessThan" in e or "lessThanOrEqual" in e
        if not is_range:
            if v == e["version"]:
                return e["status"], "exact match"
            continue
        vt = e.get("versionType")
        if vt not in SUPPORTED:
            return "undetermined", f"range uses versionType {vt!r}; needs that ordering"
        lo_ok = cmp(e["version"], v) <= 0
        hi_ok = (cmp(v, e["lessThan"]) < 0) if "lessThan" in e else (cmp(v, e["lessThanOrEqual"]) <= 0)
        if lo_ok and hi_ok:
            s = e["status"]
            for c in sorted(e.get("changes", []), key=lambda c: key(c["at"])):  # schema: re-sort yourself
                if cmp(c["at"], v) <= 0:
                    s = c["status"]
            return s, "range match"
    return product.get("defaultStatus", "unknown"), "defaultStatus" + ("" if "defaultStatus" in product else " (absent, so unknown)")

if __name__ == "__main__":
    args = sys.argv[1:]
    if "--numeric" in args:  # opt-in: treat these extra versionTypes as dotted numeric
        i = args.index("--numeric")
        SUPPORTED.update(args[i + 1].split(","))
        del args[i:i + 2]
    sys.argv[1:] = args
    rec = json.load(open(sys.argv[1]))
    version = sys.argv[2]
    want = sys.argv[3].lower() if len(sys.argv) > 3 else ""
    if rec["cveMetadata"]["state"] != "PUBLISHED":
        sys.exit(f"{rec['cveMetadata']['cveId']} is {rec['cveMetadata']['state']}")
    for i, p in enumerate(rec["containers"]["cna"].get("affected", [])):
        name = f"{p.get('vendor', '')}/{p.get('product', p.get('packageName', ''))}"
        if want and want not in name.lower():
            continue
        print(f"{rec['cveMetadata']['cveId']} [{i}] {name} {version}: %s (%s)" % status(p, version))

We ran it with Python 3.13 on November 2, 2026 against records downloaded from cvelistV5 (raw.githubusercontent.com/CVEProject/cvelistV5/<commit>/cves/2026/<block>/<CVE>.json). Selected output:

CVE-2026-100419 [0] GitoxideLabs/gitoxide 0.22.1: affected (range match)
CVE-2026-100419 [0] GitoxideLabs/gitoxide 0.23.0: unaffected (exact match)
CVE-2026-100419 [0] GitoxideLabs/gitoxide 0.25.4: unaffected (defaultStatus)
CVE-2026-0821 [0] quickjs-ng/quickjs 0.12.0: unknown (defaultStatus (absent, so unknown))
CVE-2026-100075 [0] Linux/Linux 6.6.150: undetermined (range uses versionType 'git'; needs that ordering)
CVE-2026-100075 [1] Linux/Linux 6.6.150: undetermined (range uses versionType 'original_commit_for_fix'; needs that ordering)
CVE-2026-103345 [0] Shamim Rajani/Pie Register 3.8.4.13: undetermined (range uses versionType 'custom'; needs that ordering)
CVE-2026-23915 is REJECTED

The Linux result is the instructive one. For 6.6.150 the evaluator reaches the 7.3 ... * entry, whose versionType is original_commit_for_fix, a value that is not among the schema’s examples. A strict implementation stops there. If you decide, for this CNA’s data, that those values are ordinary kernel version numbers, you can opt in with --numeric original_commit_for_fix, and the second product entry then gives 4.4.302 unaffected, 4.7 affected, 6.6.150 affected (by defaultStatus), 6.6.157 unaffected and 7.3 unaffected. That opt-in is our judgment about one CNA’s encoding, not something the schema states.

Common mistakes

  • Treating “not listed” as “not affected”. Without defaultStatus: unaffected, unlisted versions are unknown.
  • Off-by-one on the upper bound. lessThan: 0.23.0 excludes 0.23.0; lessThanOrEqual: 3.8.4.13 includes 3.8.4.13.
  • Comparing strings. As text, “0.10” sorts before “0.9”. Parse versions with the ecosystem’s rules.
  • Assuming changes is sorted. The schema tells clients to re-sort.
  • Reading only the first product entry. The same record can describe a product twice, as the Linux record does with git ranges and version ranges.
  • Ignoring the record state. A REJECTED record has no affected block to evaluate; see CVE record states.

Where this fits in triage

Version matching answers “do we run an affected version?”, which comes before any priority signal. If the answer is unknown or undetermined, check the vendor advisory, or a supplier VEX statement that says whether the product is affected (see how to read a VEX not-affected statement). An SBOM with accurate component versions is what makes this lookup possible at scale; our summary of the 2026 SBOM minimum elements covers what to ask suppliers for. Once a finding is confirmed as affected, our vulnerability prioritization workflow takes it from record to ticket using KEV, EPSS and SSVC.

Limits of this article

The sample covers 2026 records only and reflects the repository on November 2, 2026; CNAs update records, so shares will drift. Our evaluator is a reference implementation of the schema’s algorithm for numeric versions, not a full comparator for semver pre-releases, RPM, Maven, Python or git ranges. We did not examine ADP containers, which can carry their own affected data (15 of the 984 sampled records had one), or NVD’s separate CPE configurations.

Sources and verification

Important claimSourceStatus
Field definitions, the status algorithm, defaultStatus defaults to unknown, * wildcard, changes must be re-sorted, packageURL must not include a versionCVE JSON schema, CVE_Record_Format.json (v5.2.0)Verified
version: 0 convention, Maven vs semver ordering example, custom means the algorithm cannot be executedCVE schema version-encoding guide (versions.md)Verified
Example records CVE-2026-100419, CVE-2026-0821, CVE-2026-100075, CVE-2026-103345, CVE-2026-23915cvelistV5 repository, commit 451e97aVerified (read November 2, 2026)
Sample counts and percentagesOur analysis of 1,000 randomly sampled 2026 records from cvelistV5 at commit 451e97aVerified (computed with code); a sample, not a census
Evaluator outputScript above, run with Python 3.13Verified

Frequently asked questions

What does defaultStatus mean in a CVE record?

It is the status for every version of that product that no entry in the versions list matches. The CVE JSON schema says that if defaultStatus is not specified, it defaults to unknown. A record that lists only the affected versions and omits defaultStatus therefore says nothing about other versions.

What is the difference between lessThan and lessThanOrEqual?

lessThan is the non-inclusive upper limit, which the schema describes as the least version not in the range; it is usually the fixed version. lessThanOrEqual is the inclusive upper limit, the greatest version in the range. Only one of the two should be used on an entry.

Can I compare versions with versionType custom automatically?

Not safely. The CVE schema documentation says custom means an otherwise unspecified ordering and that using it means the version status algorithm cannot be executed. Treat a custom range as needing the vendor's own advisory or a vendor-specific comparator.

Is this the same as the CPE configuration on NVD?

No. The versions and defaultStatus fields sit in the CNA's affected product entries. CPE data can appear in a product's cpes array or in a separate cpeApplicability block, and NVD adds its own configurations. In our sample, 29.5% of published records carried a cpeApplicability block.

Vulnerability ManagementCveCve Json 5Version MatchingSbomAppSec