From f29a0b049233dd6ae32bddb6df13438fdf1a2bc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 8 Jul 2026 14:16:35 +0900 Subject: [PATCH] feat: add centralized org SBOM pipeline Add an org-wide SBOM pipeline complementing the central Security Scan. - sbom-generation.yml: REQUIRED-style workflow mirroring security-scan.yml. Generates CycloneDX + SPDX SBOMs per repo/PR/release with anchore/syft (via anchore/sbom-action, Apache-2.0, SHA-pinned), covering every present ecosystem. Uploads artifacts, attaches to releases, and submits the SPDX snapshot to the GitHub dependency submission API. - sbom-inventory-scheduler.yml: scheduled aggregator reusing the OpenCode-app OIDC token pattern for cross-repo reads. Reads each repo's dependency-graph SBOM and publishes a consolidated org inventory via PR. - sbom_inventory_aggregator.py: parse (SPDX + CycloneDX), roll-up, and license flagging (GPL/AGPL/copyleft/NOASSERTION vs commercial-license-only policy). Config via CLI args wired from CI vars/secrets; no new runtime os.getenv. - docs/sbom/inventory.{md,json}: seed central inventory (100% test + docstring coverage on the aggregator). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P --- .github/workflows/sbom-generation.yml | 78 ++++ .../workflows/sbom-inventory-scheduler.yml | 160 +++++++ docs/sbom/inventory.json | 12 + docs/sbom/inventory.md | 25 + scripts/ci/sbom_inventory_aggregator.py | 429 ++++++++++++++++++ tests/test_sbom_inventory_aggregator.py | 173 +++++++ 6 files changed, 877 insertions(+) create mode 100644 .github/workflows/sbom-generation.yml create mode 100644 .github/workflows/sbom-inventory-scheduler.yml create mode 100644 docs/sbom/inventory.json create mode 100644 docs/sbom/inventory.md create mode 100644 scripts/ci/sbom_inventory_aggregator.py create mode 100644 tests/test_sbom_inventory_aggregator.py diff --git a/.github/workflows/sbom-generation.yml b/.github/workflows/sbom-generation.yml new file mode 100644 index 000000000..b62f0b3d3 --- /dev/null +++ b/.github/workflows/sbom-generation.yml @@ -0,0 +1,78 @@ +# Central SBOM generation for every ContextualWisdomLab repo. +# +# This is a REQUIRED-style org workflow (mirrors security-scan.yml): same +# pull_request trigger conventions, least-privilege permissions, SHA-pinned +# actions. It complements the Security Scan by producing a Software Bill of +# Materials for every repo's dependencies on each PR and release. +# +# What it does per repo: +# - Generates BOTH a CycloneDX and an SPDX SBOM with anchore/syft (via the +# anchore/sbom-action wrapper; Apache-2.0, permissive tooling only), scanning +# the whole filesystem so every present ecosystem is covered +# (npm / pyproject / uv / cargo / go / maven). +# - Uploads each SBOM as a build artifact. +# - Attaches both SBOMs to GitHub releases (on release: published). +# - Submits the SPDX snapshot to the GitHub dependency submission API so the +# components show up in the repo's dependency graph. That graph is the source +# the central SBOM inventory aggregator reads back out org-wide. +# +# NOTE: contents: write is required for release-asset upload and for the +# dependency submission API. Fork PR heads run without write and simply skip +# those side effects; the artifact is still produced. +name: SBOM Generation + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] + branches: [main, master, develop] + release: + types: [published] + +concurrency: + group: sbom-generation-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event.pull_request.number || github.event.release.tag_name || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + cancel-closed-pr-runs: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + + generate-sbom: + if: github.event_name != 'pull_request' || github.event.action != 'closed' + runs-on: ubuntu-latest + permissions: + # write is needed for release-asset upload and dependency submission. + contents: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Generate SPDX SBOM and submit dependency snapshot + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: . + format: spdx-json + output-file: sbom.spdx.json + artifact-name: sbom-spdx-json + upload-artifact: true + upload-release-assets: true + # Feeds the repo dependency graph -> read back by the org aggregator. + dependency-snapshot: true + + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: . + format: cyclonedx-json + output-file: sbom.cyclonedx.json + artifact-name: sbom-cyclonedx-json + upload-artifact: true + upload-release-assets: true + dependency-snapshot: false diff --git a/.github/workflows/sbom-inventory-scheduler.yml b/.github/workflows/sbom-inventory-scheduler.yml new file mode 100644 index 000000000..0e5bdfd24 --- /dev/null +++ b/.github/workflows/sbom-inventory-scheduler.yml @@ -0,0 +1,160 @@ +# Central SBOM inventory aggregator. +# +# Scheduled companion to sbom-generation.yml. It reads every managed repo's +# latest SBOM back out of the GitHub dependency graph (populated by the +# per-repo SBOM Generation dependency snapshot) and writes ONE consolidated org +# inventory into this .github repo: +# +# docs/sbom/inventory.json machine-readable component roll-up +# docs/sbom/inventory.md component + license roll-up (flags copyleft / +# NOASSERTION against the commercial-license-only policy) +# +# Cross-repo reads reuse the OpenCode app OIDC token exchange the other +# schedulers use, falling back to github.token. Results land through a PR so the +# central inventory update follows the same review path as everything else. +name: SBOM Inventory Scheduler + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + org: + description: Organization login to inventory + required: false + default: ContextualWisdomLab + type: string + +concurrency: + group: sbom-inventory-scheduler-${{ github.repository }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + aggregate-sbom-inventory: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + pull-requests: write + env: + ORG_LOGIN: ${{ inputs.org || vars.SBOM_INVENTORY_ORG || 'ContextualWisdomLab' }} + steps: + - name: Exchange OpenCode app token for cross-repo reads + id: aggregator_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + + if ! oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + + if ! token_response="$( + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + + - name: Checkout trusted aggregator + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ContextualWisdomLab/.github + ref: main + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + + - name: Self-test aggregator + run: python3 scripts/ci/sbom_inventory_aggregator.py --self-test + + - name: Aggregate org SBOM inventory + env: + GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token || github.token }} + run: | + set -euo pipefail + generated_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + python3 scripts/ci/sbom_inventory_aggregator.py \ + --org "$ORG_LOGIN" \ + --output-dir docs/sbom \ + --generated-at "$generated_at" + + - name: Open or update inventory PR + env: + GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token || github.token }} + run: | + set -euo pipefail + if git diff --quiet -- docs/sbom; then + echo "No SBOM inventory changes; nothing to publish." + exit 0 + fi + branch="automation/sbom-inventory" + git config user.name "cwl-sbom-inventory[bot]" + git config user.email "cwl-sbom-inventory@users.noreply.github.com" + git checkout -B "$branch" + git add docs/sbom + git commit -m "chore: refresh org SBOM inventory" + git push --force-with-lease origin "$branch" + if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then + gh pr create \ + --base main \ + --head "$branch" \ + --title "chore: refresh org SBOM inventory" \ + --body "Automated central SBOM inventory refresh. Review the license roll-up in docs/sbom/inventory.md for any flagged copyleft/NOASSERTION components." + fi diff --git a/docs/sbom/inventory.json b/docs/sbom/inventory.json new file mode 100644 index 000000000..4e6441ad4 --- /dev/null +++ b/docs/sbom/inventory.json @@ -0,0 +1,12 @@ +{ + "schema": "cwl-sbom-inventory/v1", + "summary": { + "repo_count": 0, + "component_count": 0, + "flagged_count": 0, + "policy": "commercial-license-only" + }, + "license_totals": {}, + "flagged_licenses": [], + "repos": [] +} diff --git a/docs/sbom/inventory.md b/docs/sbom/inventory.md new file mode 100644 index 000000000..8892aa3b3 --- /dev/null +++ b/docs/sbom/inventory.md @@ -0,0 +1,25 @@ +# Organization SBOM inventory + +Generated: pending first scheduled run + +One central view of every managed repository's software components, +versions, and licenses. Feeds license and vulnerability governance +alongside the central Security Scan. + +## Summary + +- Repositories: 0 +- Components: 0 +- Policy: commercial-license-only +- Flagged licenses: 0 + +## License roll-up + +| License | Components | +| --- | ---: | + +## Flagged components (policy violations) + +No copyleft or NOASSERTION components detected. + +## Per-repository components diff --git a/scripts/ci/sbom_inventory_aggregator.py b/scripts/ci/sbom_inventory_aggregator.py new file mode 100644 index 000000000..1037c1e0f --- /dev/null +++ b/scripts/ci/sbom_inventory_aggregator.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Aggregate every managed repository's SBOM into one central org inventory. + +This script is the "centralize" half of the ContextualWisdomLab SBOM pipeline. +The per-repository ``SBOM Generation`` workflow submits each repo's dependency +snapshot to the GitHub dependency graph; this aggregator reads that graph back +out (``GET /repos/{owner}/{repo}/dependency-graph/sbom`` returns SPDX), parses +the components + licenses, and writes a consolidated inventory: + + docs/sbom/inventory.json machine-readable component roll-up + docs/sbom/inventory.md human-readable component + license roll-up + +The license roll-up flags any GPL/AGPL/copyleft/NOASSERTION component against +the organization's commercial-license-only policy so governance has one place +to see policy-relevant licenses across the whole org. + +Configuration is passed as CLI arguments (the calling workflow wires them from +CI vars/secrets); the only environment coupling is ``gh``'s own ``GH_TOKEN``, +which the workflow exports for cross-repository reads. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Sequence + +# Licenses that violate the commercial-license-only policy. Matched as +# case-insensitive substrings against the normalized SPDX license expression so +# variants like "LGPL-3.0-or-later" or "AGPL-3.0-only" are all caught. +COPYLEFT_LICENSE_MARKERS: tuple[str, ...] = ( + "GPL", + "AGPL", + "LGPL", + "MPL", + "EPL", + "CDDL", + "CC-BY-SA", + "SSPL", + "OSL", + "EUPL", +) + +# Sentinel emitted by SBOM tooling when it cannot determine a license. +NOASSERTION = "NOASSERTION" + + +@dataclass(frozen=True) +class Component: + """A single software component discovered in a repository's SBOM.""" + + name: str + version: str + license: str + + +@dataclass +class RepoInventory: + """Parsed SBOM result for one repository.""" + + repo: str + components: list[Component] = field(default_factory=list) + error: str | None = None + + +def normalize_license(value: Any) -> str: + """Return a trimmed, upper-safe license string, defaulting to NOASSERTION.""" + if value is None: + return NOASSERTION + text = str(value).strip() + if not text: + return NOASSERTION + return text + + +def is_flagged_license(license_expression: str) -> bool: + """Return True when a license is copyleft/unknown under the policy.""" + normalized = normalize_license(license_expression) + upper = normalized.upper() + if upper == NOASSERTION or upper == "NONE": + return True + return any(marker in upper for marker in COPYLEFT_LICENSE_MARKERS) + + +def _spdx_license(package: dict[str, Any]) -> str: + """Pick the most specific SPDX license field available on a package.""" + for key in ("licenseConcluded", "licenseDeclared"): + candidate = normalize_license(package.get(key)) + if candidate != NOASSERTION: + return candidate + return NOASSERTION + + +def parse_spdx_sbom(document: dict[str, Any]) -> list[Component]: + """Parse an SPDX document into components, skipping the root describes node.""" + packages = document.get("packages") + if not isinstance(packages, list): + return [] + described = _spdx_described_ids(document) + components: list[Component] = [] + for package in packages: + if not isinstance(package, dict): + continue + name = normalize_license(package.get("name")) + if name == NOASSERTION: + continue + if package.get("SPDXID") in described: + # The document's own describes target is the repo itself, not a dep. + continue + version = package.get("versionInfo") + components.append( + Component( + name=str(name), + version=normalize_license(version) if version else "", + license=_spdx_license(package), + ) + ) + return _dedupe(components) + + +def _spdx_described_ids(document: dict[str, Any]) -> set[str]: + """Collect SPDXIDs the document DESCRIBES (its own root package).""" + described: set[str] = set() + relationships = document.get("relationships") + if isinstance(relationships, list): + for relationship in relationships: + if not isinstance(relationship, dict): + continue + if relationship.get("relationshipType") == "DESCRIBES": + related = relationship.get("relatedSpdxElement") + if isinstance(related, str): + described.add(related) + return described + + +def _cyclonedx_license(component: dict[str, Any]) -> str: + """Extract a license expression from a CycloneDX component entry.""" + expression = component.get("licenses") + if isinstance(expression, list): + for entry in expression: + if not isinstance(entry, dict): + continue + if "expression" in entry: + return normalize_license(entry.get("expression")) + license_obj = entry.get("license") + if isinstance(license_obj, dict): + candidate = license_obj.get("id") or license_obj.get("name") + normalized = normalize_license(candidate) + if normalized != NOASSERTION: + return normalized + return NOASSERTION + + +def parse_cyclonedx_sbom(document: dict[str, Any]) -> list[Component]: + """Parse a CycloneDX document into components.""" + raw_components = document.get("components") + if not isinstance(raw_components, list): + return [] + components: list[Component] = [] + for component in raw_components: + if not isinstance(component, dict): + continue + name = component.get("name") + if not name: + continue + version = component.get("version") + components.append( + Component( + name=str(name), + version=str(version) if version else "", + license=_cyclonedx_license(component), + ) + ) + return _dedupe(components) + + +def parse_sbom(document: dict[str, Any]) -> list[Component]: + """Parse either an SPDX or CycloneDX document into components.""" + if "spdxVersion" in document or "SPDXID" in document: + return parse_spdx_sbom(document) + if document.get("bomFormat") == "CycloneDX" or "components" in document: + return parse_cyclonedx_sbom(document) + return [] + + +def _dedupe(components: Iterable[Component]) -> list[Component]: + """Return components sorted and de-duplicated by (name, version, license).""" + unique = {(c.name, c.version, c.license): c for c in components} + return sorted(unique.values(), key=lambda c: (c.name.lower(), c.version)) + + +def build_inventory(repo_inventories: Sequence[RepoInventory]) -> dict[str, Any]: + """Build the consolidated inventory + license roll-up from per-repo results.""" + repos_payload: list[dict[str, Any]] = [] + license_totals: dict[str, int] = {} + flagged: list[dict[str, str]] = [] + total_components = 0 + + for repo_inventory in sorted(repo_inventories, key=lambda r: r.repo.lower()): + components_payload = [ + { + "name": component.name, + "version": component.version, + "license": component.license, + "flagged": is_flagged_license(component.license), + } + for component in repo_inventory.components + ] + total_components += len(components_payload) + for component in repo_inventory.components: + license_key = normalize_license(component.license) + license_totals[license_key] = license_totals.get(license_key, 0) + 1 + if is_flagged_license(license_key): + flagged.append( + { + "repo": repo_inventory.repo, + "name": component.name, + "version": component.version, + "license": license_key, + } + ) + repos_payload.append( + { + "repo": repo_inventory.repo, + "error": repo_inventory.error, + "component_count": len(components_payload), + "components": components_payload, + } + ) + + flagged.sort(key=lambda item: (item["repo"].lower(), item["name"].lower())) + return { + "schema": "cwl-sbom-inventory/v1", + "summary": { + "repo_count": len(repos_payload), + "component_count": total_components, + "flagged_count": len(flagged), + "policy": "commercial-license-only", + }, + "license_totals": dict(sorted(license_totals.items())), + "flagged_licenses": flagged, + "repos": repos_payload, + } + + +def render_inventory_markdown(inventory: dict[str, Any], *, generated_at: str) -> str: + """Render the consolidated inventory as a governance-facing markdown page.""" + summary = inventory["summary"] + lines: list[str] = [ + "# Organization SBOM inventory", + "", + f"Generated: {generated_at}", + "", + "One central view of every managed repository's software components,", + "versions, and licenses. Feeds license and vulnerability governance", + "alongside the central Security Scan.", + "", + "## Summary", + "", + f"- Repositories: {summary['repo_count']}", + f"- Components: {summary['component_count']}", + f"- Policy: {summary['policy']}", + f"- Flagged licenses: {summary['flagged_count']}", + "", + "## License roll-up", + "", + "| License | Components |", + "| --- | ---: |", + ] + for license_key, count in inventory["license_totals"].items(): + flag = " ⚠️" if is_flagged_license(license_key) else "" + lines.append(f"| {license_key}{flag} | {count} |") + + lines.extend(["", "## Flagged components (policy violations)", ""]) + if inventory["flagged_licenses"]: + lines.extend(["| Repository | Component | Version | License |", "| --- | --- | --- | --- |"]) + for item in inventory["flagged_licenses"]: + lines.append( + f"| {item['repo']} | {item['name']} | {item['version'] or '—'} | {item['license']} |" + ) + else: + lines.append("No copyleft or NOASSERTION components detected.") + + lines.extend(["", "## Per-repository components", ""]) + for repo in inventory["repos"]: + lines.append(f"### {repo['repo']}") + lines.append("") + if repo["error"]: + lines.append(f"SBOM unavailable: {repo['error']}") + lines.append("") + continue + if not repo["components"]: + lines.append("No components reported.") + lines.append("") + continue + lines.extend(["| Component | Version | License | Flagged |", "| --- | --- | --- | --- |"]) + for component in repo["components"]: + flag = "yes" if component["flagged"] else "no" + lines.append( + f"| {component['name']} | {component['version'] or '—'} | {component['license']} | {flag} |" + ) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def write_inventory(inventory: dict[str, Any], markdown: str, output_dir: Path) -> None: + """Write the JSON and markdown inventory artifacts to ``output_dir``.""" + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "inventory.json").write_text( + json.dumps(inventory, indent=2, sort_keys=False) + "\n", encoding="utf-8" + ) + (output_dir / "inventory.md").write_text(markdown, encoding="utf-8") + + +def _run(args: Sequence[str]) -> str: # pragma: no cover - thin subprocess wrapper + """Run a command and return stdout, raising on failure.""" + process = subprocess.run(list(args), capture_output=True, text=True, check=True) + return process.stdout + + +def list_org_repos(org: str) -> list[str]: # pragma: no cover - network + """List non-archived repositories for an organization via gh.""" + raw = _run( + [ + "gh", + "repo", + "list", + org, + "--no-archived", + "--limit", + "500", + "--json", + "nameWithOwner", + ] + ) + return [entry["nameWithOwner"] for entry in json.loads(raw or "[]")] + + +def fetch_repo_sbom(repo: str) -> RepoInventory: # pragma: no cover - network + """Fetch and parse one repository's dependency-graph SBOM via gh.""" + try: + raw = _run(["gh", "api", f"/repos/{repo}/dependency-graph/sbom"]) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip().splitlines() + return RepoInventory(repo=repo, error=detail[-1] if detail else "sbom unavailable") + payload = json.loads(raw or "{}") + document = payload.get("sbom", payload) + return RepoInventory(repo=repo, components=parse_sbom(document)) + + +def collect_inventories(repos: Sequence[str]) -> list[RepoInventory]: # pragma: no cover - network + """Fetch every repository's SBOM into per-repo inventories.""" + return [fetch_repo_sbom(repo) for repo in repos] + + +def self_test() -> None: + """Run in-process assertions covering parse, roll-up, and flagging logic.""" + spdx = { + "spdxVersion": "SPDX-2.3", + "SPDXID": "SPDXRef-DOCUMENT", + "packages": [ + {"SPDXID": "SPDXRef-root", "name": "self", "versionInfo": "1.0"}, + {"SPDXID": "SPDXRef-a", "name": "left-pad", "versionInfo": "1.3.0", "licenseConcluded": "MIT"}, + {"SPDXID": "SPDXRef-b", "name": "readline", "versionInfo": "8.2", "licenseDeclared": "GPL-3.0-or-later"}, + ], + "relationships": [ + {"relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-root"}, + ], + } + components = parse_spdx_sbom(spdx) + assert [c.name for c in components] == ["left-pad", "readline"], components + inventory = build_inventory([RepoInventory(repo="acme/app", components=components)]) + assert inventory["summary"]["flagged_count"] == 1, inventory + assert is_flagged_license("AGPL-3.0-only") + assert is_flagged_license("NOASSERTION") + assert not is_flagged_license("Apache-2.0") + markdown = render_inventory_markdown(inventory, generated_at="test") + assert "Organization SBOM inventory" in markdown + print("self-test passed") + + +def build_arg_parser() -> argparse.ArgumentParser: + """Build the CLI argument parser for the aggregator.""" + parser = argparse.ArgumentParser(description="Aggregate org SBOMs into a central inventory.") + parser.add_argument("--org", default="ContextualWisdomLab", help="GitHub organization login") + parser.add_argument( + "--output-dir", + default="docs/sbom", + help="Directory for inventory.json and inventory.md", + ) + parser.add_argument( + "--repo", + dest="repos", + action="append", + default=None, + help="Explicit repo (owner/name); repeatable. Overrides org discovery.", + ) + parser.add_argument("--generated-at", default="", help="Timestamp label for the markdown header") + parser.add_argument("--self-test", action="store_true", help="Run in-process assertions and exit") + return parser + + +def main(argv: list[str]) -> int: # pragma: no cover - CLI orchestration + """CLI entry point: discover repos, collect SBOMs, write the inventory.""" + args = build_arg_parser().parse_args(argv) + if args.self_test: + self_test() + return 0 + repos = args.repos if args.repos else list_org_repos(args.org) + inventories = collect_inventories(repos) + inventory = build_inventory(inventories) + generated_at = args.generated_at or "unspecified" + markdown = render_inventory_markdown(inventory, generated_at=generated_at) + write_inventory(inventory, markdown, Path(args.output_dir)) + summary = inventory["summary"] + print( + f"Wrote inventory for {summary['repo_count']} repos, " + f"{summary['component_count']} components, " + f"{summary['flagged_count']} flagged." + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_sbom_inventory_aggregator.py b/tests/test_sbom_inventory_aggregator.py new file mode 100644 index 000000000..e9966a653 --- /dev/null +++ b/tests/test_sbom_inventory_aggregator.py @@ -0,0 +1,173 @@ +"""Unit tests for the central SBOM inventory aggregator's pure logic.""" + +import json + +from scripts.ci import sbom_inventory_aggregator as agg + + +SPDX_DOCUMENT = { + "spdxVersion": "SPDX-2.3", + "SPDXID": "SPDXRef-DOCUMENT", + "packages": [ + {"SPDXID": "SPDXRef-root", "name": "acme-app", "versionInfo": "0.0.0"}, + {"SPDXID": "SPDXRef-a", "name": "left-pad", "versionInfo": "1.3.0", "licenseConcluded": "MIT"}, + {"SPDXID": "SPDXRef-b", "name": "readline", "versionInfo": "8.2", "licenseDeclared": "GPL-3.0-or-later"}, + {"SPDXID": "SPDXRef-c", "name": "mystery", "versionInfo": "0.1.0"}, + {"SPDXID": "SPDXRef-noname", "versionInfo": "0.0.1"}, + "not-a-dict-package", + ], + "relationships": [ + {"relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-root"}, + {"relationshipType": "DEPENDS_ON", "relatedSpdxElement": "SPDXRef-a"}, + "not-a-dict", + ], +} + +CYCLONEDX_DOCUMENT = { + "bomFormat": "CycloneDX", + "components": [ + {"name": "requests", "version": "2.31.0", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"name": "chardet", "version": "5.0.0", "licenses": ["not-a-dict", {"expression": "LGPL-2.1-only"}]}, + {"name": "nameonly"}, + {"version": "9.9.9"}, + "not-a-dict", + ], +} + + +def test_is_flagged_license_matches_copyleft_and_unknown(): + """Copyleft families and NOASSERTION/NONE are flagged; permissive is not.""" + assert agg.is_flagged_license("GPL-3.0-or-later") + assert agg.is_flagged_license("AGPL-3.0-only") + assert agg.is_flagged_license("LGPL-2.1") + assert agg.is_flagged_license("MPL-2.0") + assert agg.is_flagged_license("NOASSERTION") + assert agg.is_flagged_license("NONE") + assert agg.is_flagged_license("") + assert agg.is_flagged_license(None) + assert not agg.is_flagged_license("MIT") + assert not agg.is_flagged_license("Apache-2.0") + assert not agg.is_flagged_license("BSD-3-Clause") + + +def test_normalize_license_defaults_to_noassertion(): + """Blank and None license fields normalize to NOASSERTION.""" + assert agg.normalize_license(None) == agg.NOASSERTION + assert agg.normalize_license(" ") == agg.NOASSERTION + assert agg.normalize_license(" MIT ") == "MIT" + + +def test_parse_spdx_skips_root_and_defaults_license(): + """SPDX parsing drops the DESCRIBES root and defaults missing licenses.""" + components = agg.parse_spdx_sbom(SPDX_DOCUMENT) + names = [c.name for c in components] + assert "acme-app" not in names + assert names == ["left-pad", "mystery", "readline"] + by_name = {c.name: c for c in components} + assert by_name["left-pad"].license == "MIT" + assert by_name["readline"].license == "GPL-3.0-or-later" + assert by_name["mystery"].license == agg.NOASSERTION + + +def test_parse_cyclonedx_extracts_license_id_and_expression(): + """CycloneDX parsing reads both license.id and expression forms.""" + components = agg.parse_cyclonedx_sbom(CYCLONEDX_DOCUMENT) + by_name = {c.name: c for c in components} + assert by_name["requests"].license == "Apache-2.0" + assert by_name["chardet"].license == "LGPL-2.1-only" + assert by_name["nameonly"].license == agg.NOASSERTION + assert "9.9.9" not in {c.name for c in components} + + +def test_parse_sbom_dispatches_by_format(): + """parse_sbom routes SPDX vs CycloneDX and ignores unknown docs.""" + assert agg.parse_sbom(SPDX_DOCUMENT) + assert agg.parse_sbom(CYCLONEDX_DOCUMENT) + assert agg.parse_sbom({"unknown": True}) == [] + assert agg.parse_spdx_sbom({"packages": "bad"}) == [] + assert agg.parse_cyclonedx_sbom({"components": "bad"}) == [] + + +def test_build_inventory_rolls_up_licenses_and_flags(): + """Roll-up aggregates counts, license totals, and policy flags.""" + inventories = [ + agg.RepoInventory(repo="acme/app", components=agg.parse_spdx_sbom(SPDX_DOCUMENT)), + agg.RepoInventory(repo="acme/lib", components=agg.parse_cyclonedx_sbom(CYCLONEDX_DOCUMENT)), + agg.RepoInventory(repo="acme/broken", error="sbom unavailable"), + ] + inventory = agg.build_inventory(inventories) + summary = inventory["summary"] + assert summary["repo_count"] == 3 + assert summary["component_count"] == 6 + # GPL, LGPL, NOASSERTION(x2 from mystery + nameonly) -> 4 flagged. + assert summary["flagged_count"] == 4 + assert summary["policy"] == "commercial-license-only" + assert inventory["license_totals"]["MIT"] == 1 + # Repos are sorted; broken repo preserves its error. + repos = {r["repo"]: r for r in inventory["repos"]} + assert repos["acme/broken"]["error"] == "sbom unavailable" + flagged_repos = {item["repo"] for item in inventory["flagged_licenses"]} + assert flagged_repos == {"acme/app", "acme/lib"} + # Round-trips as JSON. + assert json.loads(json.dumps(inventory))["schema"] == "cwl-sbom-inventory/v1" + + +def test_render_markdown_contains_rollup_and_flags(): + """Markdown renders summary, license roll-up, and flagged components.""" + inventory = agg.build_inventory( + [agg.RepoInventory(repo="acme/app", components=agg.parse_spdx_sbom(SPDX_DOCUMENT))] + ) + markdown = agg.render_inventory_markdown(inventory, generated_at="2026-07-08T00:00:00Z") + assert "# Organization SBOM inventory" in markdown + assert "2026-07-08T00:00:00Z" in markdown + assert "GPL-3.0-or-later" in markdown + assert "commercial-license-only" in markdown + assert "| readline |" in markdown + + +def test_render_markdown_handles_empty_and_error_repos(): + """Markdown covers the no-flags, empty-repo, and error-repo branches.""" + inventory = agg.build_inventory( + [ + agg.RepoInventory( + repo="acme/clean", + components=[agg.Component(name="mit-lib", version="1.0", license="MIT")], + ), + agg.RepoInventory(repo="acme/empty", components=[]), + agg.RepoInventory(repo="acme/broken", error="not found"), + ] + ) + markdown = agg.render_inventory_markdown(inventory, generated_at="now") + assert "No copyleft or NOASSERTION components detected." in markdown + assert "No components reported." in markdown + assert "SBOM unavailable: not found" in markdown + + +def test_write_inventory_emits_both_files(tmp_path): + """write_inventory produces inventory.json and inventory.md.""" + inventory = agg.build_inventory( + [agg.RepoInventory(repo="acme/app", components=agg.parse_spdx_sbom(SPDX_DOCUMENT))] + ) + markdown = agg.render_inventory_markdown(inventory, generated_at="now") + agg.write_inventory(inventory, markdown, tmp_path / "sbom") + written = json.loads((tmp_path / "sbom" / "inventory.json").read_text()) + assert written["summary"]["repo_count"] == 1 + assert (tmp_path / "sbom" / "inventory.md").read_text().startswith("# Organization SBOM inventory") + + +def test_self_test_passes(capsys): + """The bundled self-test runs clean and prints its sentinel.""" + agg.self_test() + assert "self-test passed" in capsys.readouterr().out + + +def test_arg_parser_defaults(): + """CLI parser exposes org/output-dir/repo/self-test with sane defaults.""" + parser = agg.build_arg_parser() + args = parser.parse_args([]) + assert args.org == "ContextualWisdomLab" + assert args.output_dir == "docs/sbom" + assert args.repos is None + args = parser.parse_args(["--repo", "a/b", "--repo", "c/d", "--self-test"]) + assert args.repos == ["a/b", "c/d"] + assert args.self_test is True