[OE-core][PATCH v7 04/12] spdx30: Include patch file information in VEX

Joshua Watt
2026-03-18T13:47:10Z

Modifies the SPDX VEX output to include the patches that fix a
particular vulnerability. This is done by adding a `patchedBy`
relationship from the `VexFixedVulnAssessmentRelationship` to the `File`
that provides the fix.

If the file can be located without fetching (e.g. is a file:// in
SRC_URI), the checksum will be included.

Signed-off-by: Joshua Watt <JPEWhacker@gmail.com>
---
meta/lib/oe/sbom30.py | 60 ++++++++++++++-------------
meta/lib/oe/spdx30_tasks.py | 81 ++++++++++++++++++++++++++++---------
2 files changed, 92 insertions(+), 49 deletions(-)

diff --git a/meta/lib/oe/sbom30.py b/meta/lib/oe/sbom30.py
index 50a72fce39..21f084dc16 100644
--- a/meta/lib/oe/sbom30.py
+++ b/meta/lib/oe/sbom30.py
@@ -620,37 +620,38 @@ class ObjectSet(oe.spdx30.SHACLObjectSet):
)
spdx_file.extension.append(OELicenseScannedExtension())

- def new_file(self, _id, name, path, *, purposes=[]):
- sha256_hash = bb.utils.sha256_file(path)
+ def new_file(self, _id, name, path, *, purposes=[], hashfile=True):
+ if hashfile:
+ sha256_hash = bb.utils.sha256_file(path)

- for f in self.by_sha256_hash.get(sha256_hash, []):
- if not isinstance(f, oe.spdx30.software_File):
- continue
+ for f in self.by_sha256_hash.get(sha256_hash, []):
+ if not isinstance(f, oe.spdx30.software_File):
+ continue

- if purposes:
- new_primary = purposes[0]
- new_additional = []
+ if purposes:
+ new_primary = purposes[0]
+ new_additional = []

- if f.software_primaryPurpose:
- new_additional.append(f.software_primaryPurpose)
- new_additional.extend(f.software_additionalPurpose)
+ if f.software_primaryPurpose:
+ new_additional.append(f.software_primaryPurpose)
+ new_additional.extend(f.software_additionalPurpose)

- new_additional = sorted(
- list(set(p for p in new_additional if p != new_primary))
- )
+ new_additional = sorted(
+ list(set(p for p in new_additional if p != new_primary))
+ )

- f.software_primaryPurpose = new_primary
- f.software_additionalPurpose = new_additional
+ f.software_primaryPurpose = new_primary
+ f.software_additionalPurpose = new_additional

- if f.name != name:
- for e in f.extension:
- if isinstance(e, OEFileNameAliasExtension):
- e.aliases.append(name)
- break
- else:
- f.extension.append(OEFileNameAliasExtension(aliases=[name]))
+ if f.name != name:
+ for e in f.extension:
+ if isinstance(e, OEFileNameAliasExtension):
+ e.aliases.append(name)
+ break
+ else:
+ f.extension.append(OEFileNameAliasExtension(aliases=[name]))

- return f
+ return f

spdx_file = oe.spdx30.software_File(
_id=_id,
@@ -661,12 +662,13 @@ class ObjectSet(oe.spdx30.SHACLObjectSet):
spdx_file.software_primaryPurpose = purposes[0]
spdx_file.software_additionalPurpose = purposes[1:]

- spdx_file.verifiedUsing.append(
- oe.spdx30.Hash(
- algorithm=oe.spdx30.HashAlgorithm.sha256,
- hashValue=sha256_hash,
+ if hashfile:
+ spdx_file.verifiedUsing.append(
+ oe.spdx30.Hash(
+ algorithm=oe.spdx30.HashAlgorithm.sha256,
+ hashValue=sha256_hash,
+ )
)
- )

return self.add(spdx_file)

diff --git a/meta/lib/oe/spdx30_tasks.py b/meta/lib/oe/spdx30_tasks.py
index a8fffbb085..aec47d4f81 100644
--- a/meta/lib/oe/spdx30_tasks.py
+++ b/meta/lib/oe/spdx30_tasks.py
@@ -568,44 +568,63 @@ def create_recipe_spdx(d):
if include_vex != "none":
patched_cves = oe.cve_check.get_patched_cves(d)
for cve, patched_cve in patched_cves.items():
- decoded_status = {
- "mapping": patched_cve["abbrev-status"],
- "detail": patched_cve["status"],
- "description": patched_cve.get("justification", None),
- }
+ mapping = patched_cve["abbrev-status"]
+ detail = patched_cve["status"]
+ description = patched_cve.get("justification", None)
+ resources = patched_cve.get("resource", [])

# If this CVE is fixed upstream, skip it unless all CVEs are
# specified.
- if (
- include_vex != "all"
- and "detail" in decoded_status
- and decoded_status["detail"]
- in (
- "fixed-version",
- "cpe-stable-backport",
- )
+ if include_vex != "all" and detail in (
+ "fixed-version",
+ "cpe-stable-backport",
):
bb.debug(1, "Skipping %s since it is already fixed upstream" % cve)
continue

spdx_cve = recipe_objset.new_cve_vuln(cve)

- cve_by_status.setdefault(decoded_status["mapping"], {})[cve] = (
+ cve_by_status.setdefault(mapping, {})[cve] = (
spdx_cve,
- decoded_status["detail"],
- decoded_status["description"],
+ detail,
+ description,
+ resources,
)

all_cves = set()
for status, cves in cve_by_status.items():
for cve, items in cves.items():
- spdx_cve, detail, description = items
+ spdx_cve, detail, description, resources = items
spdx_cve_id = oe.sbom30.get_element_link_id(spdx_cve)

all_cves.add(spdx_cve)

if status == "Patched":
- recipe_objset.new_vex_patched_relationship([spdx_cve_id], [recipe])
+ spdx_vex = recipe_objset.new_vex_patched_relationship(
+ [spdx_cve_id], [recipe]
+ )
+ patches = []
+ for idx, filepath in enumerate(resources):
+ patches.append(
+ recipe_objset.new_file(
+ recipe_objset.new_spdxid(
+ "patch", str(idx), os.path.basename(filepath)
+ ),
+ os.path.basename(filepath),
+ filepath,
+ purposes=[oe.spdx30.software_SoftwarePurpose.patch],
+ hashfile=os.path.isfile(filepath),
+ )
+ )
+
+ if patches:
+ recipe_objset.new_scoped_relationship(
+ spdx_vex,
+ oe.spdx30.RelationshipType.patchedBy,
+ oe.spdx30.LifecycleScopeType.build,
+ patches,
+ )
+
elif status == "Unpatched":
recipe_objset.new_vex_unpatched_relationship([spdx_cve_id], [recipe])
elif status == "Ignored":
@@ -751,12 +770,14 @@ def create_spdx(d):

# Collect all VEX statements from the recipe
vex_statements = {}
+ vex_patches = {}
for rel in recipe_objset.foreach_filter(
oe.spdx30.Relationship,
relationshipType=oe.spdx30.RelationshipType.hasAssociatedVulnerability,
):
for cve in rel.to:
vex_statements[cve] = []
+ vex_patches[cve] = []

for cve in vex_statements.keys():
for rel in recipe_objset.foreach_filter(
@@ -764,6 +785,13 @@ def create_spdx(d):
from_=cve,
):
vex_statements[cve].append(rel)
+ if rel.relationshipType == oe.spdx30.RelationshipType.fixedIn:
+ for patch_rel in recipe_objset.foreach_filter(
+ oe.spdx30.Relationship,
+ relationshipType=oe.spdx30.RelationshipType.patchedBy,
+ from_=rel,
+ ):
+ vex_patches[cve].extend(patch_rel.to)

# Write out the package SPDX data now. It is not complete as we cannot
# write the runtime data, so write it to a staging area and a later task
@@ -889,7 +917,9 @@ def create_spdx(d):

# Add concluded license relationship if manually set
# Only add when license analysis has been explicitly performed
- concluded_license_str = d.getVar("SPDX_CONCLUDED_LICENSE:%s" % package) or d.getVar("SPDX_CONCLUDED_LICENSE")
+ concluded_license_str = d.getVar(
+ "SPDX_CONCLUDED_LICENSE:%s" % package
+ ) or d.getVar("SPDX_CONCLUDED_LICENSE")
if concluded_license_str:
concluded_spdx_license = add_license_expression(
d, build_objset, concluded_license_str, license_data
@@ -915,9 +945,20 @@ def create_spdx(d):
for cve, vexes in vex_statements.items():
for vex in vexes:
if vex.relationshipType == oe.spdx30.RelationshipType.fixedIn:
- pkg_objset.new_vex_patched_relationship(
+ spdx_vex = pkg_objset.new_vex_patched_relationship(
[oe.sbom30.get_element_link_id(cve)], [spdx_package]
)
+ if vex_patches[cve]:
+ pkg_objset.new_scoped_relationship(
+ spdx_vex,
+ oe.spdx30.RelationshipType.patchedBy,
+ oe.spdx30.LifecycleScopeType.build,
+ [
+ oe.sbom30.get_element_link_id(p)
+ for p in vex_patches[cve]
+ ],
+ )
+
elif vex.relationshipType == oe.spdx30.RelationshipType.affects:
pkg_objset.new_vex_unpatched_relationship(
[oe.sbom30.get_element_link_id(cve)], [spdx_package]
--
2.53.0