diff --git a/CHANGELOG.md b/CHANGELOG.md index 86bdd2b1..295aa0f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [BE] ๐Ÿงพ **Lockfile ๊ธฐ๋ฐ˜ CycloneDX SBOM ์ƒ์„ฑ๊ธฐ (#953 2์ฐจ ์ฆ๋ถ„)**: `app/release/sbom.py`๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ €์žฅ์†Œ๊ฐ€ ์ด๋ฏธ ์ปค๋ฐ‹ํ•˜๋Š” lockfile์„ ์ˆœ์ˆ˜ ํ…์ŠคํŠธ/JSON ํŒŒ์‹ฑ๋งŒ์œผ๋กœ CycloneDX 1.6 `bom`์œผ๋กœ ๋งŒ๋“ญ๋‹ˆ๋‹ค โ€” `pip`/`npm` ์‹คํ–‰ยท์˜์กด์„ฑ ํ•ด์„ยท๋„คํŠธ์›Œํฌ ์—†์Œ. `parse_pip_lock(text)`์€ `name==version` ์š”๊ตฌ์‚ฌํ•ญ๊ณผ ๋’ค๋”ฐ๋ฅด๋Š” `--hash=sha256:` ๊ฐ’์„ ์ˆ˜์ง‘(์ฃผ์„ยท์˜ต์…˜ ์ค„ skip), `parse_npm_lock(obj)`์€ `package-lock.json` v2/v3์˜ `packages` ๋งต์„ ์ˆœํšŒ(root `""`ยท๋ฒ„์ „ ์—†๋Š” workspace link skip, `@scope/` ์œ ์ง€, `integrity` โ†’ hash), `build_sbom(*, pip_lock, npm_lock, component_name, component_version, generated_at)`์€ ๋‘˜์„ ๋ณ‘ํ•ฉยท`purl` ์ค‘๋ณต ์ œ๊ฑฐยท`(type, name, version)` ์ •๋ ฌํ•ด CycloneDX ๋ด‰ํˆฌ๋กœ ๊ฐ์Œ‰๋‹ˆ๋‹ค. ๋นˆ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ/๋น„-dict npm_lock์€ ์ฒซ ๋ฌธ์ œ ํ•„๋“œ๋ช…์„ ๋‹ด์€ `ValueError`. ํ…Œ์ŠคํŠธ 16์ข…(์ž‘์€ ๋ฆฌํ„ฐ๋Ÿด ํ”ฝ์Šค์ฒ˜, ์‹ค์ œ lockfile ๋ฏธ์‚ฌ์šฉ). `docs/doctoring/release-manifest.md`์— ๊ณ„์•ฝ ๊ธฐ๋ก, OWASP CycloneDX 1.6ยทNTIA(2021) SBOM ์ตœ์†Œ ์š”์†Œ ์ธ์šฉ. - [BE] ๐Ÿ“ฆ **๋ฆด๋ฆฌ์Šค ์ฆ๊ฑฐ ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์กฐ๋ฆฝ๊ธฐ (#953 1์ฐจ ์ฆ๋ถ„)**: `app/release/manifest.py`์˜ `build_release_manifest(*, source_commit, backend_version, frontend_version, migration_revisions, dependency_lock_digests, included_prs, known_limitations, generated_at) -> dict`๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. ํ˜ธ์ถœ์ž๊ฐ€ ์ด๋ฏธ ์ˆ˜์ง‘ํ•œ ๋ฆด๋ฆฌ์Šค ์‚ฌ์‹ค(commitยท๋ฒ„์ „ยทAlembic revisionยทlock ํŒŒ์ผ sha256 ๋‹ค์ด์ œ์ŠคํŠธยทํฌํ•จ PRยท์•Œ๋ ค์ง„ ํ•œ๊ณ„ยท์ƒ์„ฑ ์‹œ๊ฐ)์„ ๊ฒ€์ฆยท์ •๊ทœํ™”ํ•ด ๋ถˆ๋ณ€ยทJSON ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•œ ๋‹จ์ผ ๋งค๋‹ˆํŽ˜์ŠคํŠธ๋กœ ๋งŒ๋“ญ๋‹ˆ๋‹ค. gitยท๋„คํŠธ์›ŒํฌยทํŒŒ์ผ์‹œ์Šคํ…œ ์ ‘๊ทผ ์—†์Œ. ๊ฒ€์ฆ ์‹คํŒจ ์‹œ ์ฒซ ๋ฌธ์ œ ํ•„๋“œ๋ช…์„ ๋‹ด์€ `ValueError`. `migration_revisions`/`included_prs`๋Š” ์ •๋ ฌยท์ค‘๋ณต ์ œ๊ฑฐ, digest๋Š” `^sha256:[0-9a-f]{64}$` ๊ฐ•์ œ. `is_ga_candidate = len(known_limitations) == 0`(ํ•œ๊ณ„๊ฐ€ ํ•˜๋‚˜๋ผ๋„ ์žˆ์œผ๋ฉด GA ํ›„๋ณด ์•„๋‹˜ โ€” ์ •์ง์„ฑ ๊ทœ์น™). ํ…Œ์ŠคํŠธ 12์ข…. `docs/doctoring/release-manifest.md`์— ํ•„๋“œ ๊ณ„์•ฝยทํ›„์† ์ฆ๋ถ„(SBOMยท์„œ๋ช… provenanceยทoperability baselineยท๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ฆฌํ—ˆ์„ค) ๊ธฐ๋ก, NIST SP 800-218ยทSLSA v1.2 ์ธ์šฉ. - [BE] ๐Ÿ”’ **Cryptography 50+ ๋ณด์•ˆ ๊ฒฝ๊ณ„ ๊ฐฑ์‹ **: `pyproject.toml`๊ณผ ๋‘ hash-locked ์š”๊ตฌ์‚ฌํ•ญ ํŒŒ์ผ์„ ๋™์ผํ•œ Cryptography 50+ ํ•ด์„์œผ๋กœ ์ •ํ•ฉํ™”ํ•˜์—ฌ PKCS#7 ์˜ค๋ฅ˜ยทํƒ€์ด๋ฐ ๊ตฌ๋ถ„์œผ๋กœ ์ธํ•œ CVE-2026-69247 ์™„ํ™”๋ฅผ ์‹ค์ œ ์„ค์น˜ยท๊ฒ€์ฆ ๊ฒฝ๋กœ์— ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. - [FE] โšก **๊ฒ€์ƒ‰ ๋…ธ๋“œ ์ฐธ์กฐ ์•ˆ์ •ํ™” ๋ฐ ์ˆœ์ฐจ ์Šค๋ƒ…์ƒท ํด๋ง**: ๊ฐ™์€ ์ •๊ทœํ™” ๊ฒ€์ƒ‰์–ด์™€ ์›๋ณธ ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ์—๋Š” ์žฅ์‹๋œ `node.data` ์ฐธ์กฐ๋ฅผ ์žฌ์‚ฌ์šฉํ•˜์—ฌ ๋“œ๋ž˜๊ทธ ์ค‘ ๋ถˆํ•„์š”ํ•œ ํ•˜์œ„ ๋ Œ๋”๋ง๊ณผ ํ• ๋‹น์„ ์ค„์ž…๋‹ˆ๋‹ค. ์Šค๋ƒ…์ƒท ํด๋ง์€ ์ด์ „ ์š”์ฒญ์ด ๋๋‚œ ๋’ค์—๋งŒ ๋‹ค์Œ ์š”์ฒญ์„ ์˜ˆ์•ฝํ•˜๋ฉฐ, ์„ ํƒ ๋ณ€๊ฒฝยท์–ธ๋งˆ์šดํŠธ ํ›„ ๋„์ฐฉํ•œ ์˜ค๋ž˜๋œ ์„ฑ๊ณต ๋˜๋Š” ์‹คํŒจ ์‘๋‹ต์„ ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค. diff --git a/backend/app/release/sbom.py b/backend/app/release/sbom.py new file mode 100644 index 00000000..38ec9c98 --- /dev/null +++ b/backend/app/release/sbom.py @@ -0,0 +1,230 @@ +"""Build a CycloneDX 1.6 SBOM from the lockfiles already in the repo. + +A software bill of materials (SBOM) lists every third-party component that +ships inside a release, so a buyer's security team can match it against +vulnerability feeds and license policy. This module produces one **from the +lockfiles the repo already commits** -- the hash-locked pip/uv requirements +lock and the npm ``package-lock.json`` -- by pure text/JSON parsing. It never +runs ``pip``/``npm``, never resolves a dependency graph, and never touches +the network: whatever the lockfile pins is exactly what the SBOM reports. + +Public functions: + +* :func:`parse_pip_lock` -- turn a requirements lock's text into component + dicts (name, version, ``pkg:pypi`` purl, SHA-256 hashes). +* :func:`parse_npm_lock` -- turn a parsed ``package-lock.json`` (v2/v3, which + carries a ``packages`` map) into component dicts (name, version, + ``pkg:npm`` purl, integrity hash). +* :func:`build_sbom` -- merge both into one CycloneDX 1.6 ``bom`` document + with the components de-duplicated by purl and stably sorted. + +Deferred (tracked on issue #953): signing the SBOM, attaching it to the +release manifest built by :mod:`app.release.manifest`, and emitting VEX +(exploitability) statements. + +References (APA 7th): + +* OWASP Foundation. (2024). *CycloneDX specification 1.6*. + https://cyclonedx.org/docs/1.6/ +* National Telecommunications and Information Administration. (2021). *The + minimum elements for a software bill of materials (SBOM)*. U.S. Department + of Commerce. + https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom +""" + +from __future__ import annotations + +import re +from typing import Any + +SBOM_SPEC_VERSION = "1.6" +"""The CycloneDX schema version emitted by :func:`build_sbom`.""" + +_PIP_HASH_RE = re.compile(r"--hash=sha256:([0-9a-fA-F]{64})") +_PIP_NAME_VERSION_RE = re.compile(r"^([A-Za-z0-9._-]+)\s*==\s*([^\s;]+)") + + +def _require_non_empty_str(value: object, field: str) -> str: + """Return ``value`` unchanged, or raise :class:`ValueError` naming ``field``.""" + + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field} must be a non-empty string") + return value + + +def _logical_lines(text: str) -> list[str]: + """Join ``\\``-continued lines so one requirement is one string.""" + + joined = text.replace("\\\n", " ").replace("\\\r\n", " ") + return joined.splitlines() + + +def parse_pip_lock(text: str) -> list[dict[str, Any]]: + """Parse a pip / uv requirements lock into CycloneDX component dicts. + + Recognises ``name==version`` requirements (the form every hash-locked + lockfile uses) and collects the ``--hash=sha256:`` values that + follow, whether on the same line or on ``\\``-continued lines. Blank + lines, ``#`` comments, and option lines (anything starting with ``-``) + are skipped. + + Args: + text: The full lockfile text. + + Returns: + One dict per requirement: ``{"type": "library", "name", "version", + "purl": "pkg:pypi/@", "hashes": [{"alg": "SHA-256", + "content": }, ...]}``. Order follows the file. + """ + + components: list[dict[str, Any]] = [] + for raw in _logical_lines(text): + line = raw.strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + match = _PIP_NAME_VERSION_RE.match(line) + if match is None: + continue + name, version = match.group(1), match.group(2) + hashes = [ + {"alg": "SHA-256", "content": h.lower()} + for h in _PIP_HASH_RE.findall(line) + ] + components.append( + { + "type": "library", + "name": name, + "version": version, + "purl": f"pkg:pypi/{name}@{version}", + "hashes": hashes, + } + ) + return components + + +def _npm_name_from_key(key: str) -> str | None: + """Return the package name for a ``package-lock.json`` ``packages`` key. + + ``"node_modules/foo"`` -> ``"foo"``; ``"node_modules/@scope/bar"`` -> + ``"@scope/bar"``; nested ``".../node_modules/baz"`` -> ``"baz"``. Keys + without a ``node_modules/`` segment (the root ``""`` and workspace + entries) return ``None`` so the caller skips them. + """ + + marker = "node_modules/" + if marker not in key: + return None + return key.rsplit(marker, 1)[1] + + +def _npm_hashes(integrity: object) -> list[dict[str, str]]: + """Turn an npm ``integrity`` string (``sha512-``) into hash dicts.""" + + if not isinstance(integrity, str) or "-" not in integrity: + return [] + algo, _, content = integrity.partition("-") + alg_map = {"sha512": "SHA-512", "sha384": "SHA-384", "sha256": "SHA-256"} + if algo not in alg_map or not content: + return [] + return [{"alg": alg_map[algo], "content": content}] + + +def parse_npm_lock(obj: dict[str, Any]) -> list[dict[str, Any]]: + """Parse a parsed ``package-lock.json`` (v2/v3) into component dicts. + + Walks ``obj["packages"]``, skips the root key ``""`` and any entry with + no ``version`` (workspace links, bundled placeholders), and emits one + component per installed ``node_modules`` package. + + Args: + obj: The already-``json.load``ed lockfile. Must be a dict; a + ``packages`` key is expected (an absent one yields ``[]``). + + Returns: + One dict per package: ``{"type": "library", "name", "version", + "purl": "pkg:npm/@", "hashes": [...]}``. + + Raises: + ValueError: If ``obj`` is not a dict. + """ + + if not isinstance(obj, dict): + raise ValueError("npm_lock must be a parsed JSON object (dict)") + packages = obj.get("packages") + if not isinstance(packages, dict): + return [] + + components: list[dict[str, Any]] = [] + for key, entry in packages.items(): + if key == "" or not isinstance(entry, dict): + continue + name = _npm_name_from_key(key) + version = entry.get("version") + if name is None or not isinstance(version, str) or not version: + continue + components.append( + { + "type": "library", + "name": name, + "version": version, + "purl": f"pkg:npm/{name}@{version}", + "hashes": _npm_hashes(entry.get("integrity")), + } + ) + return components + + +def build_sbom( + *, + pip_lock: str, + npm_lock: dict[str, Any], + component_name: str, + component_version: str, + generated_at: str, +) -> dict[str, Any]: + """Merge the pip and npm components into one CycloneDX 1.6 ``bom``. + + Args: + pip_lock: Requirements-lock text (see :func:`parse_pip_lock`). + npm_lock: Parsed ``package-lock.json`` (see :func:`parse_npm_lock`). + component_name: Name of the application this SBOM describes. + component_version: Its version string. + generated_at: SBOM timestamp, recorded verbatim (an ISO-8601 string + is expected; this function does not parse it). + + Returns: + ``{"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, + "metadata": {"timestamp", "component": {...}}, "components": [...]}`` + with components de-duplicated by ``purl`` and sorted by + ``(type, name, version)``. + + Raises: + ValueError: If ``component_name``, ``component_version``, or + ``generated_at`` is blank, or if ``npm_lock`` is not a dict. + """ + + name = _require_non_empty_str(component_name, "component_name") + version = _require_non_empty_str(component_version, "component_version") + timestamp = _require_non_empty_str(generated_at, "generated_at") + + merged: dict[str, dict[str, Any]] = {} + for component in [*parse_pip_lock(pip_lock), *parse_npm_lock(npm_lock)]: + merged.setdefault(component["purl"], component) + + components = sorted( + merged.values(), key=lambda c: (c["type"], c["name"], c["version"]) + ) + return { + "bomFormat": "CycloneDX", + "specVersion": SBOM_SPEC_VERSION, + "version": 1, + "metadata": { + "timestamp": timestamp, + "component": { + "type": "application", + "name": name, + "version": version, + }, + }, + "components": components, + } diff --git a/backend/tests/test_sbom.py b/backend/tests/test_sbom.py new file mode 100644 index 00000000..db833ade --- /dev/null +++ b/backend/tests/test_sbom.py @@ -0,0 +1,197 @@ +"""Tests for :mod:`app.release.sbom` โ€” CycloneDX SBOM from lockfiles.""" + +from __future__ import annotations + +import json + +import pytest + +from app.release.sbom import ( + SBOM_SPEC_VERSION, + build_sbom, + parse_npm_lock, + parse_pip_lock, +) + +_PIP_LOCK = """\ +# generated by a tool +--index-url https://pypi.org/simple + +anyio==4.4.0 \\ + --hash=sha256:1111111111111111111111111111111111111111111111111111111111111111 \\ + --hash=sha256:2222222222222222222222222222222222222222222222222222222222222222 +click==8.1.7 +-e file:///local/thing +""" + +_NPM_LOCK = { + "name": "frontend", + "lockfileVersion": 3, + "packages": { + "": {"name": "frontend", "version": "0.1.0"}, + "node_modules/nanoid": { + "version": "5.0.7", + "integrity": "sha512-oooooooooooooooooooooo", + }, + "node_modules/@xyflow/react": { + "version": "12.3.0", + "integrity": "sha512-rrrrrrrrrrrrrrrrrrrrrr", + }, + "node_modules/leftpad": {"link": True}, + "frontend/node_modules/dedupe-me": { + "version": "1.0.0", + "integrity": "sha512-dddddddddddddddddddddd", + }, + }, +} + + +def test_parse_pip_lock_happy_path_and_hashes() -> None: + """A name==version line yields one component with its collected hashes.""" + + components = parse_pip_lock(_PIP_LOCK) + by_name = {c["name"]: c for c in components} + assert set(by_name) == {"anyio", "click"} + assert by_name["anyio"]["version"] == "4.4.0" + assert by_name["anyio"]["purl"] == "pkg:pypi/anyio@4.4.0" + assert by_name["anyio"]["hashes"] == [ + {"alg": "SHA-256", "content": "1" * 64}, + {"alg": "SHA-256", "content": "2" * 64}, + ] + + +def test_parse_pip_lock_skips_comments_blanks_and_options() -> None: + """Comment, blank, -e and --index-url lines produce no components.""" + + components = parse_pip_lock(_PIP_LOCK) + assert all(c["name"] not in {"", "-e", "--index-url"} for c in components) + assert len(components) == 2 + + +def test_parse_pip_lock_component_without_hashes() -> None: + """A bare pin still parses, with an empty hash list.""" + + [component] = parse_pip_lock("click==8.1.7\n") + assert component["hashes"] == [] + + +def test_parse_npm_lock_happy_path() -> None: + """Each node_modules entry with a version becomes a component.""" + + components = parse_npm_lock(_NPM_LOCK) + by_name = {c["name"]: c for c in components} + assert by_name["nanoid"]["version"] == "5.0.7" + assert by_name["nanoid"]["purl"] == "pkg:npm/nanoid@5.0.7" + assert by_name["nanoid"]["hashes"] == [ + {"alg": "SHA-512", "content": "oooooooooooooooooooooo"} + ] + + +def test_parse_npm_lock_skips_root_and_linkless_versionless() -> None: + """The root key and a link-only (no version) entry are skipped.""" + + names = {c["name"] for c in parse_npm_lock(_NPM_LOCK)} + assert "frontend" not in names # the "" root entry + assert "leftpad" not in names # link: true, no version + + +def test_parse_npm_lock_scoped_and_nested_names() -> None: + """Scoped packages keep the @scope/ prefix; nested paths take the last segment.""" + + names = {c["name"] for c in parse_npm_lock(_NPM_LOCK)} + assert "@xyflow/react" in names + assert "dedupe-me" in names # from frontend/node_modules/dedupe-me + + +def test_parse_npm_lock_rejects_non_dict() -> None: + """A non-dict lock is a programming error.""" + + with pytest.raises(ValueError, match="npm_lock"): + parse_npm_lock(["not", "a", "dict"]) # type: ignore[arg-type] + + +def test_parse_npm_lock_missing_packages_key() -> None: + """A lock without a packages map yields no components (no crash).""" + + assert parse_npm_lock({"name": "x", "lockfileVersion": 3}) == [] + + +def test_build_sbom_shape_and_spec_version() -> None: + """The bom has the CycloneDX envelope and the declared spec version.""" + + bom = build_sbom( + pip_lock=_PIP_LOCK, + npm_lock=_NPM_LOCK, + component_name="pg-erd-cloud", + component_version="0.1.0", + generated_at="2026-09-03T00:00:00Z", + ) + assert bom["bomFormat"] == "CycloneDX" + assert bom["specVersion"] == SBOM_SPEC_VERSION == "1.6" + assert bom["version"] == 1 + assert bom["metadata"]["timestamp"] == "2026-09-03T00:00:00Z" + assert bom["metadata"]["component"] == { + "type": "application", + "name": "pg-erd-cloud", + "version": "0.1.0", + } + + +def test_build_sbom_sorted_and_deduped() -> None: + """Components are sorted by (type, name, version) and unique per purl.""" + + bom = build_sbom( + pip_lock="click==8.1.7\nclick==8.1.7\n", + npm_lock={"packages": {"node_modules/nanoid": {"version": "5.0.7"}}}, + component_name="app", + component_version="1.0.0", + generated_at="t", + ) + keys = [(c["type"], c["name"], c["version"]) for c in bom["components"]] + assert keys == sorted(keys) + purls = [c["purl"] for c in bom["components"]] + assert len(purls) == len(set(purls)) + # sorted by name: "click" before "nanoid" + assert purls == ["pkg:pypi/click@8.1.7", "pkg:npm/nanoid@5.0.7"] + + +def test_build_sbom_round_trips_through_json() -> None: + """The whole document is JSON-serializable.""" + + bom = build_sbom( + pip_lock=_PIP_LOCK, + npm_lock=_NPM_LOCK, + component_name="app", + component_version="1.0.0", + generated_at="t", + ) + assert json.loads(json.dumps(bom)) == bom + + +@pytest.mark.parametrize("field", ["component_name", "component_version", "generated_at"]) +def test_build_sbom_blank_metadata_rejected(field: str) -> None: + """Blank envelope metadata raises ValueError naming the field.""" + + kwargs = { + "pip_lock": "", + "npm_lock": {}, + "component_name": "app", + "component_version": "1.0.0", + "generated_at": "t", + } + kwargs[field] = " " + with pytest.raises(ValueError, match=field): + build_sbom(**kwargs) # type: ignore[arg-type] + + +def test_build_sbom_non_dict_npm_lock_rejected() -> None: + """build_sbom surfaces the npm_lock type error from parse_npm_lock.""" + + with pytest.raises(ValueError, match="npm_lock"): + build_sbom( + pip_lock="", + npm_lock="nope", # type: ignore[arg-type] + component_name="app", + component_version="1.0.0", + generated_at="t", + ) diff --git a/docs/doctoring/release-manifest.md b/docs/doctoring/release-manifest.md index 83b7fa17..370675c0 100644 --- a/docs/doctoring/release-manifest.md +++ b/docs/doctoring/release-manifest.md @@ -45,10 +45,38 @@ the supported way to ship a beta / non-GA artifact without the manifest claiming GA. A release workflow that wants a GA claim must first drive `known_limitations` to empty. +## SBOM generation โ€” landed + +`app.release.sbom` builds a **CycloneDX 1.6** `bom` from the lockfiles the +repo already commits, by pure text/JSON parsing โ€” no `pip`/`npm` run, no +dependency resolution, no network. Whatever a lockfile pins is exactly what +the SBOM reports. + +- `parse_pip_lock(text)` โ€” reads `name==version` requirements and collects + the trailing `--hash=sha256:` values (same line or `\`-continued); + skips blank / `#` / option (`-โ€ฆ`) lines. Component: + `{"type": "library", "name", "version", "purl": "pkg:pypi/@", + "hashes": [{"alg": "SHA-256", "content": }, โ€ฆ]}`. +- `parse_npm_lock(obj)` โ€” walks a parsed `package-lock.json` v2/v3 + `packages` map; skips the root `""` key and any entry with no `version` + (workspace links); derives the name from the key (last `node_modules/` + segment, `@scope/` kept); maps `integrity` (`sha512-`) to a hash + entry. Non-dict input raises `ValueError`. +- `build_sbom(*, pip_lock, npm_lock, component_name, component_version, + generated_at)` โ€” merges both, de-duplicates by `purl`, sorts components + by `(type, name, version)`, and wraps them in the CycloneDX envelope + with an `application` metadata component. Blank envelope metadata raises + `ValueError` naming the first bad field. + +Still deferred: signing the SBOM, referencing it from the release manifest +by digest, and emitting VEX (exploitability) statements. + ## Deferred (later increments on #953) -- **SBOM generation** โ€” an SPDX or CycloneDX document per shipped artifact, - referenced from the manifest by digest. +- **SBOM signing + manifest linkage** โ€” the CycloneDX document now + generates (`app.release.sbom`, above); signing it and referencing it + from the manifest by digest, plus an SPDX rendering and VEX statements, + remain. - **Signed build provenance / attestation** โ€” SLSA v1.2-compatible, tying the manifest to the build that produced it. - **The operability baseline** โ€” SLI/SLO, dashboards, alerts, runbooks @@ -76,3 +104,8 @@ specification, version 3.0*. https://spdx.dev/specifications/ OWASP Foundation. (2024). *CycloneDX specification, version 1.6*. https://cyclonedx.org/specification/overview/ + +National Telecommunications and Information Administration. (2021). *The +minimum elements for a software bill of materials (SBOM)*. U.S. Department +of Commerce. +https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom