# 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.

- URL: https://firewallsync.com/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/
- Site: FirewallSync (https://firewallsync.com/)
- Topic: AppSec & APIs
- Tags: vulnerability management, cve, cve json 5, version matching, sbom, appsec
- Published: 2026-11-02
- Author: Craig Leslie

## Key takeaways

- In a CVE JSON 5 record, each product in containers.cna.affected has a versions list and an optional defaultStatus. Together they define a lookup: the first matching entry decides the status, and any version no entry matches gets defaultStatus.
- If defaultStatus is missing, the schema says it defaults to unknown. In our random sample of 1,000 CVE records from 2026, 54.8% of 2,061 product entries had no defaultStatus, so any version the CNA did not list is unknown, not safe.
- A range entry needs versionType, because "less than" depends on the numbering scheme. In the sample, 35.5% of 2,831 ranges used custom, which the schema says means the status algorithm cannot be executed.
- lessThan names the first fixed version (exclusive); lessThanOrEqual names the last affected one (inclusive). A * in an upper limit means "arbitrarily large", so 6.6.157 to 6.6.* covers the rest of the 6.6 branch.
- Code that matches scanner or SBOM versions against CVE records should return three answers plus "cannot decide", and send the last two to a human or to the vendor advisory.



**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).

```json
{
  "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:

```json
{"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.

```python
#!/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:

```text
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](/posts/cve-record-states-published-rejected-and-disputed-what-each-means-for-vulnerability-triage/).

## 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](/posts/vex-explained-what-a-not-affected-statement-means-and-how-to-read-one/)). An SBOM with accurate component versions is what makes this lookup possible at scale; our summary of the [2026 SBOM minimum elements](/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/) covers what to ask suppliers for. Once a finding is confirmed as affected, our [vulnerability prioritization workflow](/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/) 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)](https://github.com/CVEProject/cve-schema/blob/main/schema/CVE_Record_Format.json) | Verified |
| `version: 0` convention, Maven vs semver ordering example, `custom` means the algorithm cannot be executed | [CVE schema version-encoding guide (versions.md)](https://github.com/CVEProject/cve-schema/blob/main/schema/docs/versions.md) | Verified |
| Example records CVE-2026-100419, CVE-2026-0821, CVE-2026-100075, CVE-2026-103345, CVE-2026-23915 | [cvelistV5 repository](https://github.com/CVEProject/cvelistV5), 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.


Image credit: Photo by [Bozhin Karaivanov](https://unsplash.com/photos/a-close-up-of-a-measuring-tape-with-numbers-on-it-AgxK4Ohn1Cw) on Unsplash
