Safeguard
Vulnerability Management•

How to Read a CVE Record in Code: CVE JSON 5, Affected Versions and CISA Vulnrichment

A CVE record in the JSON 5 format can say precisely which versions are affected, but it can also say it in free text, say nothing, or contradict itself. How to read one correctly, with five real records from this week and data from 1,197 new records.

Safeguard Security Team
Security Research
11 min read

Every vulnerability tool eventually reads CVE records, and most read them less carefully than the format deserves. A CVE record in the current JSON 5 format can say precisely which versions are affected, but it can also say it in free text, say nothing at all, or contradict itself. This guide shows how to read one correctly, using five real records published this week, and what one day of new records looks like when you check them all.

It's for engineers who pull CVE data into scanners, dashboards, SBOM tools or triage scripts. By the end you'll know where to get records, which fields matter, the official algorithm for deciding whether a version is affected, the mistakes that show up in real records, and a short script that reads a record the way a tool should.

The short answer

Fetch records from the CVE Services API (https://cveawg.mitre.org/api/cve/<CVE-ID>) or the bulk cvelistV5 repository. Check cveMetadata.state first, then read containers.cna.affected using the schema's own algorithm: a version not matched by any entry takes the product's defaultStatus, and a missing defaultStatus means "unknown". Then read the adp containers, where CISA's Vulnrichment program adds SSVC decision points and KEV status. Don't assume the CNA's data is consistent. In five records published this week we found a free-text range that matches nothing, a record that marks every version unaffected, and one that marks the fixed version affected.

Where records come from

There are three places to get them:

  • One record: GET https://cveawg.mitre.org/api/cve/CVE-2026-49950 returns the full record as JSON, with no authentication for reads.
  • All records: the CVEProject/cvelistV5 repository holds every record as a JSON file, updated continuously, which suits bulk loads and diffs.
  • CISA's enrichment on its own: the cisagov/vulnrichment repository mirrors the records CISA has enriched. The same data appears inside each record as an adp container named CISA-ADP.

The records we fetched this week report "dataType": "CVE_RECORD" and "dataVersion": "5.2".

The fields that matter

FieldWhat it tells youWatch for
cveMetadata.statePUBLISHED or REJECTEDRejected records have no affected data. Drop them, don't crash
cveMetadata.assignerShortNameThe CNA that wrote the recordQuality varies by CNA, so it's worth tracking
cveMetadata.datePublished, dateUpdatedWhen it went public and last changedRecords change. Re-read on dateUpdated
containers.cna.affected[]Products and versionsThe heart of the record, and the least consistent part
containers.cna.metricsCVSS (any version) and other scoresCan be absent. CISA-ADP sometimes adds CVSS
containers.adp[]Data added by Authorized Data PublishersCISA-ADP carries SSVC and KEV

Each affected entry identifies a product in one of several ways. The best for package scanners is collectionURL plus packageName (for example https://pypi.org and a project name). After that come repo, a source repository URL, then cpes, and finally a plain vendor and product name.

How to decide whether a version is affected

The schema includes the algorithm. Paraphrased:

  1. Go through the product's versions entries in order.
  2. An entry with no lessThan or lessThanOrEqual matches only that exact version.
  3. An entry with lessThan matches version <= v < lessThan. One with lessThanOrEqual matches version <= v <= lessThanOrEqual. Comparison follows the entry's versionType, and an optional changes list can switch the status partway through a range.
  4. The first matching entry's status wins.
  5. If nothing matches, the answer is the product's defaultStatus. If that's missing, it is unknown.

Rules 2 and 5 cause most of the surprises below. A version string such as "< 3.14.0" isn't a range under rule 2. It's an exact version that no real release will ever equal.

Five real records, five lessons

This script reads records the way the algorithm intends. It shows which identifier the CNA gave, turns each versions entry into a readable statement, flags free text, spells out what defaultStatus implies, and pulls CISA's SSVC and KEV data from the CISA-ADP container.

#!/usr/bin/env python3
"""Read a CVE JSON 5 record the way a tool should: what is affected, by which
identifier, and what CISA's Vulnrichment adds. Usage: python3 cve_read.py CVE-ID [...]"""
import json, sys, urllib.request

def fetch(cve):
    url = f"https://cveawg.mitre.org/api/cve/{cve}"
    with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "cve-read"})) as r:
        return json.load(r)

def identifier(a):
    """Best machine identifier the CNA gave, from most to least useful to a package scanner."""
    if a.get("packageName") and a.get("collectionURL"):
        return f"package {a['collectionURL']} :: {a['packageName']}"
    if a.get("repo"):
        return f"repo {a['repo']}"
    if a.get("cpes"):
        return f"cpe {a['cpes'][0]}"
    return f"name only: {a.get('vendor', '?')} / {a.get('product', '?')}"

def ranges(a):
    """Turn one 'affected' entry into readable range statements, flagging what a tool can't parse."""
    out, default = [], a.get("defaultStatus", "unknown")
    for v in a.get("versions") or []:
        ver, st, typ = str(v.get("version", "")), v.get("status"), v.get("versionType", "none")
        if v.get("lessThan"):
            out.append(f"{st}: {ver} <= v < {v['lessThan']} ({typ})")
        elif v.get("lessThanOrEqual"):
            out.append(f"{st}: {ver} <= v <= {v['lessThanOrEqual']} ({typ})")
        elif any(c in ver for c in "<>* ") or not ver[:1].isdigit():
            out.append(f"{st}: {ver!r}  <- free text, not machine-comparable")
        else:
            out.append(f"{st}: exactly {ver} ({typ})")
    if not out:
        out.append("no versions given")
    out.append(f"defaultStatus: {default}" + ("  <- every version not listed is affected" if default == "affected" else ""))
    return out

def vulnrichment(rec):
    for adp in rec["containers"].get("adp", []):
        if adp.get("providerMetadata", {}).get("shortName") != "CISA-ADP":
            continue
        ssvc, kev = {}, None
        for m in adp.get("metrics", []):
            o = m.get("other", {})
            if o.get("type") == "ssvc":
                ssvc = {k: v for opt in o["content"]["options"] for k, v in opt.items()}
            elif o.get("type") == "kev":
                kev = o["content"].get("dateAdded")
        return ssvc, kev
    return None, None

for cve in sys.argv[1:]:
    rec = fetch(cve)
    meta = rec["cveMetadata"]
    print(f"\n{cve}  state={meta['state']}  CNA={meta.get('assignerShortName')}  published={meta.get('datePublished', '')[:10]}")
    if meta["state"] != "PUBLISHED":
        continue
    for a in rec["containers"]["cna"].get("affected", []):
        print(f"  {identifier(a)}")
        for line in ranges(a):
            print(f"    {line}")
    ssvc, kev = vulnrichment(rec)
    print(f"  CISA Vulnrichment: {ssvc or 'none yet'}" + (f"; in KEV since {kev}" if kev else ""))

Here is what it prints for five records, fetched on 14 October 2026:

$ python3 cve_read.py CVE-2026-104935 CVE-2026-49950 CVE-2026-108261 CVE-2026-110392 CVE-2026-110447
CVE-2026-104935  state=PUBLISHED  CNA=ivanti  published=2026-10-13
  name only: Ivanti / Connect Secure
    unaffected: exactly 25.1.3.0 (custom)
    unaffected: exactly 25.1.3.1 (custom)
    unaffected: exactly 22.8R2.5 (custom)
    unaffected: exactly 22.7R2.14 (custom)
    defaultStatus: affected  <- every version not listed is affected
  CISA Vulnrichment: {'Exploitation': 'active', 'Automatable': 'yes', 'Technical Impact': 'total'}; in KEV since 2026-10-13

CVE-2026-49950  state=PUBLISHED  CNA=VulnCheck  published=2026-10-13
  repo https://github.com/xorbitsai/xoscar
    affected: 0.1.0 <= v <= 0.11.2 (semver)
    defaultStatus: unaffected
  CISA Vulnrichment: none yet

CVE-2026-108261  state=PUBLISHED  CNA=GitHub_M  published=2026-10-09
  name only: tinacms / tinacms
    affected: '< 3.14.0'  <- free text, not machine-comparable
    defaultStatus: unknown
  name only: @tinacms / app
    affected: '< 2.5.14'  <- free text, not machine-comparable
    defaultStatus: unknown
  CISA Vulnrichment: {'Exploitation': 'poc', 'Automatable': 'no', 'Technical Impact': 'total'}

CVE-2026-110392  state=PUBLISHED  CNA=samsung.tv_appliance  published=2026-10-14
  name only: Samsung Open Source / QA-Board
    unaffected: '0.8.x (first public release) through 1.0.1'  <- free text, not machine-comparable
    defaultStatus: unaffected
  CISA Vulnrichment: none yet

CVE-2026-110447  state=PUBLISHED  CNA=mitre  published=2026-10-14
  package https://apps.nextcloud.com :: richdocumentscode
    affected: 0 <= v < 26.4.303 (semver)
    defaultStatus: affected  <- every version not listed is affected
  CISA Vulnrichment: none yet

Ivanti (CVE-2026-104935): fixed versions plus a default. The record lists only the patched releases as unaffected and sets defaultStatus: affected. That's a correct and common pattern for vendors with non-semantic version strings: anything that isn't one of the fixed builds is affected. A tool that only looks for affected entries would find none and miss the actively exploited bug that CISA added to the KEV catalog the same day.

Xoscar (CVE-2026-49950): a clean range, identified only by repository. 0.1.0 <= v <= 0.11.2, semver, default unaffected. It's well formed, but the product is identified by a GitHub URL, not as a PyPI package, so nothing that matches on package name can use it directly.

TinaCMS (CVE-2026-108261): a range written as text. The record from GitHub's CNA puts "< 3.14.0" in the version field with no lessThan and no defaultStatus. Under the schema's algorithm that matches no version, and everything else falls back to unknown. Read literally, the record says nothing is known to be affected. The GitHub advisory for the same CVE does carry structured npm ranges, so a tool that reads only the CVE record gets less than one that also reads the advisory.

Samsung QA-Board (CVE-2026-110392): everything unaffected. The description is an OS command injection, but the only versions entry has status: unaffected with free text ("0.8.x (first public release) through 1.0.1"), and defaultStatus is also unaffected. Any tool that trusts the structure concludes that no version is vulnerable. Whatever the CNA intended, a tool can't safely guess.

Collabora CODE (CVE-2026-110447): the range contradicts the default. This is the only one of the five that gives full package coordinates, the Nextcloud app store plus richdocumentscode. Its range is right, affected below 26.4.303. But defaultStatus is affected, and under rule 5 that makes every version outside the range affected too, including the fixed 26.4.303. A strict reader would flag the patched release.

What a day of new records looks like

To see how common these problems are, we pulled 1,200 CVE IDs from GitHub advisories created on 13 and 14 October, fetched each record from the CVE API, and classified how each one encoded its affected versions. 1,197 were published. Because 13 October was Patch Tuesday, Microsoft wrote 872 of them and Adobe 165, so we report the other 160 separately:

All 1,197Excluding Microsoft and Adobe (160)
Every affected entry contains a structured range97.9%95.6%
At least one entry is free text, exact versions only, or empty2.1%4.4%
Gives package coordinates (collectionURL + packageName)0.5%3.8%
Gives a source repo URL2.4%18.1%
Gives CPEs1.2%8.8%
CISA Vulnrichment SSVC present about a day later77.5%1.9%

Two findings stand out. First, version ranges are mostly well structured. The problems above are real, but they are the minority. Second, almost no records say which package is affected. Only six of 1,197 gave package coordinates, which is why advisory databases such as GitHub's and OSV have to map CVEs to packages by hand, and why that mapping can lag. Separately, CISA had enriched 925 of the 1,037 Microsoft and Adobe records within about a day, but only 3 of the other 160. If your prioritisation depends on SSVC values, as CISA's own patch deadlines for federal agencies now do, expect them to arrive later for open-source and smaller vendors.

Limits: this is one Patch Tuesday snapshot, taken on the morning of 14 October (UTC). The sample is CVE IDs that reached GitHub's advisory database, capped at 12 API pages. The "structured" category means the encoding is machine-readable, not that the versions are correct, and the Collabora record above passes that test while still contradicting itself.

Rules for code that reads CVE records

  1. Implement the schema's algorithm exactly, including the exact-match rule and the defaultStatus fallback. Don't treat missing data as "not affected".
  2. Treat free text in version as unknown, and flag it. Don't parse "< 3.14.0" by guesswork in production, or if you do, label the result as inferred.
  3. Sanity-check records against themselves. If the first version above an affected range is also covered by defaultStatus: affected, or every entry says unaffected while the description describes a vulnerability, route the record to a person.
  4. Rank identifiers. Package coordinates beat a repo URL, which beats a CPE, which beats a name. When only a name is given, join with an advisory database instead of string-matching product names.
  5. Read the ADP containers, and expect them to be late. No SSVC data means not assessed yet, not low risk. CISA's own guidance treats a missing assessment as not automatable with total impact.
  6. Re-read on dateUpdated. Ranges get corrected, and rejected records disappear. A one-time import goes stale.

The fair view

None of this is a case against the CVE format. JSON 5 can express everything a tool needs: exact ranges, version schemes, package coordinates and per-range status changes. Most records published this week used the range fields correctly. The gaps come from the many CNAs writing records under time pressure, often for products that have no package coordinates to give. Tooling has to meet the data where it is, which means reading the whole record, believing the structure only when it's consistent, and treating everything else as a question. For how the scores in these records differ, see our comparison of CVSS, EPSS and SSVC.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.