A CVE cross-reference is not an advisory entry
A vulnerability index can have the correct set of CVE identifiers and still assign them to the wrong advisories. A regex that searches every paragraph also captures narrative references such as “related to CVE-…”. Extract identifiers from declaration blocks, and verify the resulting relationships before using the index to make patch or exposure claims.
The trap is easy to miss during deduplication: the referenced identifier may already have a legitimate entry elsewhere. Removing its false assignment changes the relationships without changing the global distinct-ID count.
How the wrong relationship gets created #
Consider two synthetic advisories. Every identifier below is fictional:
- Advisory A, component Reader, declares
CVE-2099-10001. - Its impact paragraph mentions
CVE-2099-10002as a related issue. - Advisory B, component Writer, declares
CVE-2099-10002.
An extractor that carries the current advisory and component into every regex match produces three rows. The false row associates Advisory A and Reader with the second identifier. Both the broken and corrected indexes contain two distinct identifiers.
This error can contaminate a patch matrix: an analyst might attach the wrong affected component or release boundary to a real vulnerability. It requires no malicious input. A crafted narrative mention could produce the same misassociation in a pipeline that ingests untrusted documents, but that is a possible abuse of the parser, not evidence of an exploited advisory feed.
Filter by document role before matching identifiers #
For one fixed document layout, a practical repair is to collect complete paragraph text and process only blocks beginning with the declaration marker CVE-. Collecting the whole paragraph matters: identifiers can be split across inline elements, and indentation should not change classification.
Here is a runnable Python example using a deliberately small HTML schema. Save it as check_advisories.py and run python3 check_advisories.py:
import re
from html.parser import HTMLParser
ID = re.compile(r"\bCVE-\d{4}-\d{4,}\b")
FIXTURE = """
<article data-advisory="A" data-component="Reader">
<p> <strong>CVE-2099-10001</strong> </p>
<p>Related to <em>CVE-2099-10002</em>.</p>
</article>
<article data-advisory="B" data-component="Writer">
<p>\n CVE-2099-<span>10002</span> </p>
</article>
"""
class Index(HTMLParser):
def __init__(self, declarations_only):
super().__init__()
self.declarations_only = declarations_only
self.context = None
self.parts = None
self.rows = set()
def handle_starttag(self, tag, attrs):
if tag == "article":
attrs = dict(attrs)
self.context = (
attrs["data-advisory"], attrs["data-component"]
)
elif tag == "p":
self.parts = []
def handle_data(self, data):
if self.parts is not None:
self.parts.append(data)
def handle_endtag(self, tag):
if tag == "p" and self.parts is not None:
text = "".join(self.parts).strip()
if not self.declarations_only or text.startswith("CVE-"):
for identifier in ID.findall(text):
self.rows.add((*self.context, identifier))
self.parts = None
elif tag == "article":
self.context = None
def extract(declarations_only):
parser = Index(declarations_only)
parser.feed(FIXTURE)
parser.close()
return parser.rows
broken = extract(False)
fixed = extract(True)
assert fixed == {
("A", "Reader", "CVE-2099-10001"),
("B", "Writer", "CVE-2099-10002"),
}
assert broken - fixed == {("A", "Reader", "CVE-2099-10002")}
assert {row[2] for row in broken} == {row[2] for row in fixed}
print(f"relationships: {len(broken)} -> {len(fixed)}; distinct IDs unchanged")
Expected output:
relationships: 3 -> 2; distinct IDs unchanged
A prefix guard has a document contract #
This parser is a fixture, not a general advisory importer. A narrative paragraph could itself begin with a CVE identifier; another publisher might declare identifiers in a table or list. For production ingestion, select the publisher’s actual declaration element or structured field, record the source location, and reject or flag layouts the adapter does not recognize. The standard library’s HTMLParser documentation describes its event interface; it cannot infer a publisher’s semantic roles for you.
Keep regression fixtures for narrative references, whitespace, and inline markup. Review added and removed (advisory, component, CVE) tuples during a parser change, including declarations the new selector might accidentally discard.
Before trusting your next vulnerability-index refresh, check one known cross-reference against its owning advisory. A stable distinct-ID total cannot validate that assignment.