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:
- Walk the
versionslist in order. - An entry with only
versionandstatusmatches that exact version. - An entry with
versionTypepluslessThanorlessThanOrEqualmatches a range, and an optionalchangeslist can flip the status partway through it. - The first matching entry wins. If nothing matches, the answer is
defaultStatus, and if that is missing, the answer isunknown.
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
| Field | What the schema says | What it means in practice |
|---|---|---|
version | The single version, or the start of a range. “By convention, typically 0 denotes the earliest possible version.” | "version": "0" means “no lower bound” |
status | One of affected, unaffected, unknown | unknown 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, custom | Picks 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 |
changes | Status 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.
| Measure | Count | Share |
|---|---|---|
Product entries with no defaultStatus (so unknown applies) | 1,130 of 2,061 | 54.8% |
defaultStatus: unaffected | 585 | 28.4% |
defaultStatus: affected | 311 | 15.1% |
Product entries with only single versions and no defaultStatus | 315 | 15.3% |
Version entries that are single versions / lessThan ranges / lessThanOrEqual ranges | 1,724 / 1,929 / 902 of 4,555 | 37.8% / 42.3% / 19.8% |
Ranges with versionType: custom | 1,006 of 2,831 | 35.5% |
Ranges with semver / git / rpm | 971 / 560 / 171 | 34.3% / 19.8% / 6.0% |
Ranges with a * in the upper limit | 717 | 25.3% |
Version entries using changes | 48 | 1.1% |
Published records with a cpeApplicability block | 290 of 984 | 29.5% |
Published records with a packageURL on any product | 61 | 6.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 areunknown. - Off-by-one on the upper bound.
lessThan: 0.23.0excludes 0.23.0;lessThanOrEqual: 3.8.4.13includes 3.8.4.13. - Comparing strings. As text, “0.10” sorts before “0.9”. Parse versions with the ecosystem’s rules.
- Assuming
changesis 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
REJECTEDrecord has noaffectedblock 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 claim | Source | Status |
|---|---|---|
Field definitions, the status algorithm, defaultStatus defaults to unknown, * wildcard, changes must be re-sorted, packageURL must not include a version | CVE JSON schema, CVE_Record_Format.json (v5.2.0) | Verified |
version: 0 convention, Maven vs semver ordering example, custom means the algorithm cannot be executed | CVE schema version-encoding guide (versions.md) | Verified |
| Example records CVE-2026-100419, CVE-2026-0821, CVE-2026-100075, CVE-2026-103345, CVE-2026-23915 | cvelistV5 repository, commit 451e97a | Verified (read November 2, 2026) |
| Sample counts and percentages | Our analysis of 1,000 randomly sampled 2026 records from cvelistV5 at commit 451e97a | Verified (computed with code); a sample, not a census |
| Evaluator output | Script above, run with Python 3.13 | Verified |
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
