CVE cpeApplicability: CPE Match Nodes, Operators, Ranges

Since CVE Record Format 5.1.1, a CNA can put CPE Applicability Language statements in a cpeApplicability block. Here is how nodes, operator, negate, vulnerable and the four version bounds combine, what a 2,000-record sample of 2026 CVEs contains, and a tested Python evaluator that shows where the block and the affected block disagree.

Top-down view of a dense heap of loose jigsaw puzzle pieces, picture side and grey backs mixed together, in orange, yellow, green and red
Photo by Hans-Peter Gauster on Unsplash

Scope. This is a specification explainer with measurements from public data. We read the CVE Record Format schema on the CVEProject/cve-schema main branch (default dataVersion 5.2.0) and its advanced example record, sampled 2,000 random 2026 records from the cvelistV5 repository at commit f0b25a9 (November 4, 2026), and ran the Python script below with Python 3.13 against real records. We did not test commercial scanners or NVD’s own matching engine.

The short answer

cpeApplicability is where a CNA can say, in CPE terms, which configurations a CVE applies to. Each array element is one statement:

  1. A statement has nodes, and optionally an operator and negate for combining them.
  2. Each node has a required operator (AND or OR), an optional negate, and a cpeMatch list.
  3. Each cpeMatch has a CPE 2.3 criteria string, a required vulnerable boolean, and optional bounds: versionStartIncluding, versionStartExcluding, versionEndIncluding, versionEndExcluding.
  4. A host is in scope when the logic evaluates true against its CPE inventory and at least one matched entry has vulnerable: true.

If you already parse NVD configurations, the shape is familiar. The schema says so directly: the block is “mostly copied/forked from the NIST NVD CVE API v2.0 schema”.

Where the block lives

The schema defines cpeApplicability as an optional array on both the published CNA container and the ADP container. It was added in CVE Record Format 5.1.1 (the v5.1.1 tag in CVEProject/cve-schema dates from October 2024), which the release notes describe as adding “support for Common Platform Enumeration (CPE) Identifiers using the CPE Applicability Language”.

It is separate from the older cpes array inside each affected[] product. That array is a flat list of CPE names; its own schema description now ends: “Consider using the newer cpeApplicability block for defining CPE data using the CPE Applicability Language which includes more options for defining CPE Names.”

The schema adds one instruction that matters for consumers: “it is recommended that it align with (as much as possible) the product data provided within the affected block.” Recommended, not required. If you already read the affected block with the rules in our guide to lessThan, defaultStatus and versionType, treat cpeApplicability as a second encoding of the same claim, and expect the two to disagree sometimes.

The fields, one by one

LevelFieldRequiredMeaning
ElementnodesyesArray of nodes
ElementoperatornoAND or OR between nodes
ElementnegatenoInvert the element’s result
NodeoperatoryesAND or OR between the node’s cpeMatch entries
NodenegatenoInvert the node’s result
NodecpeMatchyesArray of match entries
cpeMatchcriteriayesCPE 2.3 formatted string, e.g. cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*
cpeMatchvulnerableyestrue for the vulnerable product, false for a platform or context CPE
cpeMatchversionStartIncluding / versionStartExcludingnoLower bound, inclusive or exclusive
cpeMatchversionEndIncluding / versionEndExcludingnoUpper bound, inclusive or exclusive
cpeMatchmatchCriteriaIdnoUUID, carried over from NVD’s schema

Two details trip people up.

Version bounds apply only when the CPE’s version field is a wildcard. A criteria string such as cpe:2.3:a:ibm:mq_operator:3.4.1:*:... names one exact version; a string with * in the version position plus bounds names a range. Our evaluator treats a specific version in the criteria as an exact match and ignores bounds in that case.

There is no versionType. The affected block tells you whether versions are semver, git commits, or a custom scheme; cpeMatch does not. Whatever compares 6.18.9 with 6.19 has to guess the ordering, and for strings like alpha_0.3 or 1.3-beta there may be no safe guess.

AND, OR and vulnerable: false

The schema’s advanced example record shows the pattern that makes this block more expressive than a flat list. Its single element has "operator": "AND" and two nodes:

  • Node 1 (OR): example_org:example_enterprise from 1.0.0 up to (excluding) 1.0.6, or from 2.1.6 up to 2.1.9, each with vulnerable: true.
  • Node 2 (OR): Windows, macOS or a hardware model xt-4500, each with vulnerable: false.

Read it as: the application is vulnerable in those ranges when running on one of those platforms. The platform CPEs are conditions, not affected products. A tool that flags every host whose inventory contains cpe:2.3:o:microsoft:windows:... has misread the record.

What a sample of 2026 records contains

We took a seeded random sample of 2,000 files from the 84,849 2026 records in cvelistV5 at commit f0b25a9; 1,969 were published. The counting script parsed each record’s CNA and ADP containers.

MeasureCountShare
Published records with a CNA cpeApplicability block538 of 1,96927.3%
…of which from Linux, VulnCheck, Microsoft or Oracle (186, 110, 97, 84)477 of 53888.7%
ADP containers carrying cpeApplicability00%
Records still using the flat affected[].cpes list195 of 1,9699.9%
Elements with no element-level operator489 of 58284.0%
Nodes, all with operator: OR598100%
cpeMatch entries2,667
…with vulnerable: false100.4%
…with a specific version in criteria rather than *1706.4%
…with versionStartIncluding / versionEndExcluding / versionEndIncluding2,397 / 2,333 / 11389.9% / 87.5% / 4.2%
…with matchCriteriaId00%

Three practical readings. First, most records have no CPE statement from the CNA at all, so a CPE-only matcher misses most of the corpus. Second, the real-world block is simpler than the spec allows: almost everything is one OR node of ranges, and AND with platform CPEs is rare. Third, coverage depends on a handful of CNAs, so your hit rate will swing with your vendor mix. An earlier 1,000-record sample we took for the affected-versions guide found 29.5%; treat the figure as a range, not a constant.

The absence in ADP containers fits CISA’s stated policy. The Vulnrichment README says: “as of December 10, 2024, CISA will no longer be adding CPE strings to the enriched dataset.” See our explainer on CVE ADP containers for what CISA-ADP does still add.

A tested evaluator

The script takes one CVE record and every CPE installed on one host, and answers True, False or unknown. It is illustrative: it compares only dotted numeric versions, handles the \:-style escaping in CPE 2.3 names only for splitting, and matches part, vendor and product but ignores the remaining CPE fields.

"""Evaluate a CVE record's cna.cpeApplicability against one host's CPE inventory.
Illustrative: numeric-dotted versions only; anything else is reported as 'unknown'."""
import json, re, sys

def split_cpe(cpe):
    # split on ':' not preceded by a backslash (CPE 2.3 formatted-string escaping)
    return re.split(r'(?<!\\):', cpe)

def vkey(v):
    if not re.fullmatch(r'\d+(\.\d+)*', v):
        raise ValueError(v)
    return tuple(int(x) for x in v.split('.'))

def cmp(a, b):
    a, b = vkey(a), vkey(b)
    n = max(len(a), len(b))
    a, b = a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
    return (a > b) - (a < b)

def match(m, installed_cpe):
    want, have = split_cpe(m['criteria']), split_cpe(installed_cpe)
    # compare part, vendor, product (fields 2-4); '*' is ANY
    for i in (2, 3, 4):
        if want[i] != '*' and want[i] != have[i]:
            return False
    ver = have[5]
    if want[5] not in ('*', '-'):            # exact version in the criteria
        return want[5] == ver
    checks = [('versionStartIncluding', lambda c: c >= 0), ('versionStartExcluding', lambda c: c > 0),
              ('versionEndIncluding', lambda c: c <= 0), ('versionEndExcluding', lambda c: c < 0)]
    return all(test(cmp(ver, m[k])) for k, test in checks if k in m)

def present(m, inventory):
    return any(match(m, c) for c in inventory)

def node(n, inv):
    hits = [present(m, inv) for m in n['cpeMatch']]
    r = all(hits) if n['operator'] == 'AND' else any(hits)
    return not r if n.get('negate') else r

def element(el, inv):
    res = [node(n, inv) for n in el['nodes']]
    r = all(res) if el.get('operator') == 'AND' else any(res)  # no operator: OR
    r = not r if el.get('negate') else r
    # the statement must also hit at least one entry flagged vulnerable
    vuln = any(m['vulnerable'] and present(m, inv)
               for n in el['nodes'] for m in n['cpeMatch'])
    return r and vuln

def evaluate(record, inventory):
    cna = record['containers']['cna']
    try:
        return any(element(el, inventory) for el in cna.get('cpeApplicability', []))
    except ValueError as e:
        return f'unknown (cannot order version {e})'

if __name__ == '__main__':
    rec = json.load(open(sys.argv[1]))
    inventory = sys.argv[2:]          # every CPE installed on one host
    print(' + '.join(inventory), '->', evaluate(rec, inventory))

Saved as cpe_eval.py, we ran it against three records from the sample and the schema’s example (output trimmed to the verdict):

$ python3 -I cpe_eval.py CVE-2026-74994.json 'cpe:2.3:a:erlang:erlang\/otp:28.5.0.5:*:*:*:*:*:*:*'
... -> True
$ python3 -I cpe_eval.py CVE-2026-74994.json 'cpe:2.3:a:erlang:erlang\/otp:28.5.0.6:*:*:*:*:*:*:*'
... -> False
$ python3 -I cpe_eval.py full-record-advanced-example.json cpe:2.3:a:example_org:example_enterprise:1.0.5:*:*:*:*:*:*:*
... -> False
$ python3 -I cpe_eval.py full-record-advanced-example.json cpe:2.3:a:example_org:example_enterprise:1.0.5:*:*:*:*:*:*:* cpe:2.3:o:microsoft:windows:11:*:*:*:*:*:*:*
... -> True
$ python3 -I cpe_eval.py CVE-2026-115975.json cpe:2.3:a:cervantessec:cervantes:v.1.4.0-beta:*:*:*:*:*:*:*
... -> unknown (cannot order version v.1.4.0-beta)

The Erlang/OTP record (CVE-2026-74994) is the common case: three ranges in one OR node, one per release line, with versionEndExcluding set to the fixed release. The example record shows the AND rule working: the application version alone is not enough; the platform must be present too. The Cervantes record (CVE-2026-115975) uses version strings like alpha_0.3 and 1.3-beta, and the honest answer is “cannot decide”.

Where cpeApplicability and affected disagree

CVE-2026-23122 is a Linux kernel record (published February 14, 2026). Its cpeApplicability block has one OR node with two ranges on cpe:2.3:o:linux:linux_kernel: 6.16 up to (excluding) 6.18.8, and 6.16 up to (excluding) 6.19. Its affected block for the semver product says 6.16 is affected, below 6.16 is unaffected, 6.18.8 through 6.18.* is unaffected, and 6.19 onwards is unaffected.

Our evaluator, run on kernel versions (output trimmed to the version and verdict):

6.15.4 -> False
6.18.7 -> True
6.18.9 -> True     # affected block: unaffected (6.18.8 to 6.18.*)
6.19   -> False

The second CPE range (6.16 to before 6.19) swallows the stable-branch fix, because a CPE range has no way to say “fixed in the 6.18 branch at 6.18.8 but not in mainline until 6.19”. The affected block can, through ordered entries and a 6.18.* upper limit. A scanner that trusts only CPE here raises a false positive on every patched 6.18 kernel.

That is the general lesson: CPE ranges are a lossy projection of branch-aware version data. Use them for discovery (which records mention a product I run?) and as a cross-check, then decide with the affected block.

Common mistakes

  • Treating every matched CPE as vulnerable. Entries with vulnerable: false are context. In our sample they were rare (10 of 2,667), which is exactly why code that ignores the flag passes tests and fails later.
  • Applying bounds to an exact-version criteria string. If the CPE’s version field is not *, compare it directly.
  • Assuming the element operator. 84% of sampled elements omit it. With a single node it does not matter; with several, decide and document what your tool does. Our script treats a missing element operator as OR.
  • Comparing versions as strings. "6.9" > "6.18" is true as text. Use a scheme-aware comparator and return “unknown” when the scheme is unknown.
  • Mixing sources silently. CNA cpeApplicability, the older affected[].cpes, and NVD’s configurations are three different statements. Store which one produced a match.

Where this fits in triage

CPE matching answers “does this CVE apply to an asset I run?”. It does not answer “how urgently?”. Once a record matches, the next questions are whether it is in CISA KEV, its EPSS probability and its SSVC decision values; our vulnerability prioritization workflow walks that path from record to ticket, and the KEV JSON feed guide covers the exploitation check.

Limits of this article

  • The counts come from one random sample of 2026 records at one commit; CNA behaviour changes, so rerun the count on your own snapshot before relying on a share.
  • The evaluator is illustrative. It does not implement the full CPE 2.3 name-matching specification (NISTIR 7696), wildcard characters inside fields, or version schemes beyond dotted integers.
  • We describe NVD configurations only by the schema’s own statement that this block was forked from the NVD CVE API v2.0 schema; we did not compare NVD’s published configurations for these CVEs.

Sources and verification

Important claimSourceStatus
Field names, required fields, enums, “mostly copied/forked from the NIST NVD CVE API v2.0 schema”, alignment recommendationCVE Record Format schema (cve-schema main)Verified
cpeApplicability allowed in CNA and ADP containers; cpes description recommends the newer blockCVE Record Format schemaVerified
Added in 5.1.1; tag dated October 2024cve-schema releases; v5.1.1 tag commit dateVerified (release-note text read via a mirror; qualified)
AND/OR example with vulnerable: false platformsfull-record-advanced-example.jsonVerified
CISA stopped adding CPE strings on December 10, 2024cisagov/vulnrichment READMEVerified
Sample counts and sharescvelistV5 at commit f0b25a9, our scriptVerified (computed)
CVE-2026-23122 cpeApplicability vs affected disagreementCVE-2026-23122 recordVerified (tested)

Frequently asked questions

What is cpeApplicability in a CVE record?

It is an optional array in a CVE JSON 5 record, added in CVE Record Format 5.1.1, where the CNA (or an ADP) describes affected products with the CPE Applicability Language: CPE 2.3 match strings grouped into nodes joined by AND or OR, each marked vulnerable or not, with optional version bounds.

What is the difference between affected.cpes and cpeApplicability?

affected[].cpes is a flat list of CPE names attached to one product entry. cpeApplicability is a separate block that can combine CPEs with AND/OR logic, mark platform CPEs as not vulnerable, negate nodes and express version ranges with versionStartIncluding, versionStartExcluding, versionEndIncluding and versionEndExcluding. The schema's cpes description itself recommends the newer block.

Is cpeApplicability the same as the NVD configuration?

No. It uses nearly the same structure, but it is written by the CNA inside the CVE record. NVD configurations are NVD's own analysis published through the NVD API. The two can differ, so record which source a match came from.

Should a scanner trust cpeApplicability over the affected versions list?

Not by default. The schema recommends that the block align with the affected block as much as possible, but CPE version ranges cannot say "fixed in this stable branch". Evaluate both and send disagreements to a human or the vendor advisory.

Vulnerability ManagementCveCve Json 5CpeCpeApplicabilityVersion MatchingAppSec