wolfBoot/tools/sbom/validate_sbom.py

149 lines
6.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Structural validator for wolfGlass SBOM output.
This is the product-neutral form of wolfBoot's validate_sbom.py. It asserts the
essentials that every SBOM route must satisfy, so CI (and humans) can fail fast
on a broken generator. It is not a full schema validator.
CycloneDX (*.cdx.json):
* bomFormat == "CycloneDX"
* specVersion == "1.6"
* metadata.component.name starts with --name-prefix (if given)
* metadata.component has a non-empty version
* at least one component or component property recorded
* optional --min-properties N on metadata.component.properties
* optional --require-dep-version NAME: a components[] entry with that
name must exist and carry a non-empty version and CPE. Nested
sub-components count, since wolfcrypt ships inside wolfssl.
SPDX (*.spdx.json):
* spdxVersion starts with "SPDX-2"
* has a name and at least one package
* optional --require-dep-version NAME: a packages[] entry whose name
contains NAME must carry a non-empty versionInfo
The file kind is detected by content, so argument order does not matter.
Usage:
validate_sbom.py [--name-prefix PREFIX] [--min-properties N]
[--require-dep-version NAME] FILE [FILE ...]
"""
import argparse
import json
import sys
def fail(path, msg):
print(f"FAIL [{path}]: {msg}", file=sys.stderr)
sys.exit(1)
def flatten_components(components):
"""Yield every component, including sub-components.
CycloneDX nests a component that ships inside another one (wolfcrypt
inside the wolfssl release) under `components[]`. A check that reads only
the top level would report such a component as missing."""
for comp in components or []:
yield comp
yield from flatten_components(comp.get("components"))
def validate_cyclonedx(path, d, name_prefix, min_properties, require_deps):
if d.get("bomFormat") != "CycloneDX":
fail(path, f"bomFormat != CycloneDX (got {d.get('bomFormat')!r})")
if d.get("specVersion") != "1.6":
fail(path, f"specVersion != 1.6 (got {d.get('specVersion')!r})")
comp = d.get("metadata", {}).get("component", {})
name = comp.get("name", "")
if name_prefix and not name.startswith(name_prefix):
fail(path, f"metadata.component.name does not start with "
f"{name_prefix!r} (got {name!r})")
if not comp.get("version"):
fail(path, "metadata.component.version is empty")
props = comp.get("properties") or []
if not d.get("components") and not props:
fail(path, "no components or component properties recorded")
if min_properties is not None and len(props) < min_properties:
fail(path, f"metadata.component.properties has {len(props)} entries, "
f"need at least {min_properties} (config capture likely "
f"empty — check --options-h vs --cflags)")
all_comps = list(flatten_components(d.get("components")))
for dep_name in require_deps:
if not all_comps:
fail(path, "components[] is empty but --require-dep-version "
f"{dep_name!r} was requested (dependency graph "
"missing — check DEP_WOLFSSL / DEP_WOLFCRYPT wiring)")
matches = [c for c in all_comps if c.get("name") == dep_name]
if not matches:
fail(path, f"required dependency component {dep_name!r} missing")
if not matches[0].get("version"):
fail(path, f"dependency component {dep_name!r} has no version "
f"(pass --dep-version or set WOLFSSL_DIR)")
if not matches[0].get("cpe"):
fail(path, f"dependency component {dep_name!r} has no cpe "
f"(CPE-driven scanners cannot match it)")
print(f"OK [{path}]: CycloneDX 1.6, component "
f"{comp.get('name')} {comp.get('version')}, "
f"{len(props)} properties")
def validate_spdx(path, d, require_deps):
ver = d.get("spdxVersion", "")
if not ver.startswith("SPDX-2"):
fail(path, f"spdxVersion not SPDX-2.x (got {ver!r})")
if not d.get("name"):
fail(path, "document name is empty")
pkgs = d.get("packages") or []
if not pkgs:
fail(path, "no packages recorded")
for dep_name in require_deps:
matches = [p for p in pkgs
if dep_name.lower() in (p.get("name") or "").lower()]
if not matches:
fail(path, f"required dependency package matching {dep_name!r} "
f"missing")
if not matches[0].get("versionInfo"):
fail(path, f"dependency package {matches[0].get('name')!r} has "
f"no versionInfo")
print(f"OK [{path}]: {ver}, {len(pkgs)} package(s)")
def main(argv):
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--name-prefix", default="",
help="Require metadata.component.name to start with this.")
ap.add_argument("--min-properties", type=int, default=None,
help="Require at least N CycloneDX component properties "
"(guards empty --cflags captures).")
ap.add_argument("--require-dep-version", action="append", default=[],
metavar="NAME",
help="Require a dependency component/package NAME with "
"a non-empty version (repeatable).")
ap.add_argument("files", nargs="+")
args = ap.parse_args(argv[1:])
for path in args.files:
try:
with open(path) as f:
d = json.load(f)
except FileNotFoundError:
fail(path, "file not found")
except json.JSONDecodeError as e:
fail(path, f"invalid JSON: {e}")
if "bomFormat" in d or path.endswith(".cdx.json"):
validate_cyclonedx(path, d, args.name_prefix,
args.min_properties, args.require_dep_version)
elif "spdxVersion" in d or path.endswith(".spdx.json"):
validate_spdx(path, d, args.require_dep_version)
else:
fail(path, "unrecognized SBOM format (neither CycloneDX nor SPDX)")
print("All SBOMs valid.")
if __name__ == "__main__":
main(sys.argv)