From 76acfd2e1fd01eedf05b4a656da00754f147dd4c Mon Sep 17 00:00:00 2001 From: Asher Feldman Date: Fri, 12 Jun 2026 10:43:15 -0700 Subject: [PATCH 1/3] feat(build-and-sign-image): syft base OCI SBOM generation --- build-and-sign-image/action.yml | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/build-and-sign-image/action.yml b/build-and-sign-image/action.yml index 0c2f208..9bdba3b 100644 --- a/build-and-sign-image/action.yml +++ b/build-and-sign-image/action.yml @@ -33,6 +33,9 @@ inputs: push: description: 'To push or not' default: 'true' + sbom: + description: 'Generate an SBOM for the pushed image, attest, and upload as a workflow artifact' + default: 'false' runs: using: composite @@ -203,3 +206,67 @@ runs: env: DIGEST: '${{ steps.push.outputs.digest }}' COSIGN_EXPERIMENTAL: 'true' + + - name: 'Install syft' + if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} + uses: anchore/sbom-action/download-syft@e11c554f704a0b820cbf8c51673f6945e0731532 # v0.20.0 — verify/pin latest + id: syft + + - name: 'Generate SBOM for pushed image' + if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} + shell: bash + run: | + set -euo pipefail + # First repository line is the canonical scan source + primary_repo="$(echo "${REPOSITORIES}" | head -n1 | xargs)" + echo "Scanning ${primary_repo}@${DIGEST}" + # syft reuses the docker credentials established by the login action above + "${SYFT_CMD}" "registry:${primary_repo}@${DIGEST}" \ + -o "cyclonedx-json=protect-${COMPONENT}.cdx.json" + + jq -e '.bomFormat == "CycloneDX" and ((.components // []) | length > 0)' \ + "protect-${COMPONENT}.cdx.json" > /dev/null \ + || { echo "::error::SBOM for ${COMPONENT} is empty or invalid"; exit 1; } + jq '{component: env.COMPONENT, components: (.components|length)}' \ + "protect-${COMPONENT}.cdx.json" + env: + SYFT_CMD: ${{ steps.syft.outputs.cmd }} + REPOSITORIES: ${{ inputs.repositories }} + DIGEST: ${{ steps.push.outputs.digest }} + COMPONENT: ${{ inputs.component }} + + - name: 'Cosign attest SBOM to all registries' + if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} + shell: bash + run: | + set -euo pipefail + # One attestation per repository@digest (digest-addressed, so per-tag + # iteration is unnecessary — unlike the signing loop above) + echo "${REPOSITORIES}" | while read -r repo; do + repo="$(echo "${repo}" | xargs)" + [ -z "${repo}" ] && continue + pullstring="${repo}@${DIGEST}" + echo "Attesting SBOM to ${pullstring}" + for i in $(seq 1 5); do + cosign attest --yes \ + --type cyclonedx \ + --predicate "protect-${COMPONENT}.cdx.json" \ + "${pullstring}" && break + echo "Attempt $i failed for ${pullstring}, retrying in 15s..." + sleep 15 + [ $i -eq 5 ] && exit 1 + done + done + env: + REPOSITORIES: ${{ inputs.repositories }} + DIGEST: ${{ steps.push.outputs.digest }} + COMPONENT: ${{ inputs.component }} + COSIGN_EXPERIMENTAL: 'true' + + - name: 'Upload SBOM as workflow artifact' + if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 (matching your workflow's pin) + with: + name: sbom-protect-${{ inputs.component }} + path: protect-${{ inputs.component }}.cdx.json + if-no-files-found: error From cff83bfd2c9e4c9ad1abcc3d8915a1f62daa7b8d Mon Sep 17 00:00:00 2001 From: Asher Feldman Date: Fri, 12 Jun 2026 12:03:28 -0700 Subject: [PATCH 2/3] chore(build-and-sign-image): fix OCI signing --- build-and-sign-image/action.yml | 42 ++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/build-and-sign-image/action.yml b/build-and-sign-image/action.yml index 9bdba3b..d30c4e4 100644 --- a/build-and-sign-image/action.yml +++ b/build-and-sign-image/action.yml @@ -189,6 +189,22 @@ runs: attempt_limit: 3 attempt_delay: 30000 + - name: 'Resolve pushed image digest' + if: ${{ inputs.push == 'true' }} + id: digest + shell: bash + run: | + set -euo pipefail + primary_repo="$(echo "${REPOSITORIES}" | head -n1 | xargs)" + DIGEST="$(docker buildx imagetools inspect \ + "${primary_repo}:${PROTECT_VERSION}" \ + --format '{{json .Manifest}}' | jq -r '.digest')" + : "${DIGEST:?could not resolve image digest}" + echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}" + env: + REPOSITORIES: ${{ inputs.repositories }} + PROTECT_VERSION: ${{ steps.version.outputs.protect_version }} + - name: 'Cosign sign all images' if: '${{ inputs.push == true }}' shell: bash @@ -204,23 +220,33 @@ runs: done done env: - DIGEST: '${{ steps.push.outputs.digest }}' + DIGEST: '${{ steps.digest.outputs.digest }}' COSIGN_EXPERIMENTAL: 'true' - name: 'Install syft' if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} - uses: anchore/sbom-action/download-syft@e11c554f704a0b820cbf8c51673f6945e0731532 # v0.20.0 — verify/pin latest + uses: anchore/sbom-action/download-syft@36a5fde73e0fcb1d1e70be9ad66b4724e783bda7 id: syft - name: 'Generate SBOM for pushed image' if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} + id: sbom shell: bash run: | set -euo pipefail - # First repository line is the canonical scan source primary_repo="$(echo "${REPOSITORIES}" | head -n1 | xargs)" + + # wretry.action does not pass through inner action outputs, so + # resolve the digest from the registry via the short-sha tag + if [ -z "${DIGEST}" ]; then + DIGEST="$(docker buildx imagetools inspect \ + "${primary_repo}:${PROTECT_VERSION}" \ + --format '{{json .Manifest}}' | jq -r '.digest')" + fi + : "${DIGEST:?could not resolve image digest}" + echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}" + echo "Scanning ${primary_repo}@${DIGEST}" - # syft reuses the docker credentials established by the login action above "${SYFT_CMD}" "registry:${primary_repo}@${DIGEST}" \ -o "cyclonedx-json=protect-${COMPONENT}.cdx.json" @@ -233,6 +259,7 @@ runs: SYFT_CMD: ${{ steps.syft.outputs.cmd }} REPOSITORIES: ${{ inputs.repositories }} DIGEST: ${{ steps.push.outputs.digest }} + PROTECT_VERSION: ${{ steps.version.outputs.protect_version }} COMPONENT: ${{ inputs.component }} - name: 'Cosign attest SBOM to all registries' @@ -240,8 +267,7 @@ runs: shell: bash run: | set -euo pipefail - # One attestation per repository@digest (digest-addressed, so per-tag - # iteration is unnecessary — unlike the signing loop above) + # One attestation per repository@digest echo "${REPOSITORIES}" | while read -r repo; do repo="$(echo "${repo}" | xargs)" [ -z "${repo}" ] && continue @@ -259,13 +285,13 @@ runs: done env: REPOSITORIES: ${{ inputs.repositories }} - DIGEST: ${{ steps.push.outputs.digest }} + DIGEST: ${{ steps.sbom.outputs.digest }} COMPONENT: ${{ inputs.component }} COSIGN_EXPERIMENTAL: 'true' - name: 'Upload SBOM as workflow artifact' if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 (matching your workflow's pin) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sbom-protect-${{ inputs.component }} path: protect-${{ inputs.component }}.cdx.json From 9d8f7e6cb7488db65154fe5b0053db1460f4786b Mon Sep 17 00:00:00 2001 From: Asher Feldman Date: Thu, 18 Jun 2026 15:03:24 -0700 Subject: [PATCH 3/3] feat(build-and-sign-image): merge base-image SBOMs for copy-only composites Co-Authored-By: Claude Opus 4.8 --- build-and-sign-image/action.yml | 99 ++++++++++++++-- build-and-sign-image/sbom/list_bases.py | 147 +++++++++++++++++++++++ build-and-sign-image/sbom/merge.py | 150 ++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 8 deletions(-) create mode 100644 build-and-sign-image/sbom/list_bases.py create mode 100644 build-and-sign-image/sbom/merge.py diff --git a/build-and-sign-image/action.yml b/build-and-sign-image/action.yml index d30c4e4..d11071d 100644 --- a/build-and-sign-image/action.yml +++ b/build-and-sign-image/action.yml @@ -36,6 +36,13 @@ inputs: sbom: description: 'Generate an SBOM for the pushed image, attest, and upload as a workflow artifact' default: 'false' + sbom_strict: + description: | + Fail the build when the merged SBOM has zero components (no coverage). + Defaults to false during rollout so copy-only composites whose bases do + not yet carry SBOM attestations warn-and-skip instead of breaking the + release. Flip to true once every base image is publishing SBOMs. + default: 'false' runs: using: composite @@ -228,7 +235,17 @@ runs: uses: anchore/sbom-action/download-syft@36a5fde73e0fcb1d1e70be9ad66b4724e783bda7 id: syft - - name: 'Generate SBOM for pushed image' + # Build the composite's SBOM in three parts and merge them: + # 1. scan the pushed image itself (cargo-auditable dep trees, shipped apk + # rootfs, etc. -- empty for pure FROM-scratch copy-only composites); + # 2. enumerate the bases the target stage COPYs shipped artifacts from and + # download each base's own CycloneDX attestation (by digest, no verify); + # 3. merge the local scan + base SBOMs into one document. + # Bases without an attestation (toolchain builders, or pins predating the + # SBOM rollout) are skipped with a warning. By default an empty result warns + # and skips attestation rather than failing the release; set sbom_strict to + # flip empty -> hard failure once every base is publishing SBOMs. + - name: 'Generate and merge SBOM for pushed image' if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} id: sbom shell: bash @@ -246,24 +263,90 @@ runs: : "${DIGEST:?could not resolve image digest}" echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}" + # 1) Scan the pushed composite image itself. Drop per-file noise; keep + # package + binary (cargo-auditable) catalogers. Zero components is + # expected for pure copy-only composites and is not an error here. echo "Scanning ${primary_repo}@${DIGEST}" "${SYFT_CMD}" "registry:${primary_repo}@${DIGEST}" \ - -o "cyclonedx-json=protect-${COMPONENT}.cdx.json" + --select-catalogers "-file" \ + -o "cyclonedx-json=local.cdx.json" + + # 2) Enumerate the bases this composite copies from and pull each base's + # CycloneDX attestation, keyed by an immutable digest. + dockerfile="./images/Dockerfile.${DOCKERFILE_NAME}" + [ -f "${dockerfile}" ] || dockerfile="./images/Containerfile.${DOCKERFILE_NAME}" + mkdir -p bases + python3 "${GITHUB_ACTION_PATH}/sbom/list_bases.py" "${dockerfile}" "${TARGET}" \ + > bases.txt \ + || { echo "::warning::could not enumerate bases from ${dockerfile}"; : > bases.txt; } + + bases_found=0 + bases_with_sbom=0 + while IFS= read -r base; do + [ -n "${base}" ] || continue + bases_found=$((bases_found + 1)) + case "${base}" in + *@sha256:*) pull="${base}" ;; + *) + d="$(docker buildx imagetools inspect "${base}" \ + --format '{{json .Manifest}}' 2>/dev/null \ + | jq -r '.digest // empty')" + if [ -z "${d}" ]; then + echo "::warning::could not resolve ${base} to a digest; skipping" + continue + fi + pull="${base%:*}@${d}" + ;; + esac + safe="$(printf '%s' "${base}" | tr -c 'A-Za-z0-9._-' '_')" + # Download attestations and extract the first CycloneDX predicate. + # Handles both the bundle (.dsseEnvelope.payload) and bare DSSE shapes. + if cosign download attestation "${pull}" 2>/dev/null \ + | jq -s '[.[] | (.dsseEnvelope.payload // .payload) | @base64d + | fromjson + | select(.predicateType == "https://cyclonedx.org/bom") + | .predicate] | .[0] // empty' \ + > "bases/${safe}.cdx.json" 2>/dev/null \ + && [ -s "bases/${safe}.cdx.json" ]; then + bases_with_sbom=$((bases_with_sbom + 1)) + echo " + ${base}: CycloneDX attestation merged" + else + rm -f "bases/${safe}.cdx.json" + echo "::warning::no CycloneDX attestation for base ${base} (toolchain builder, or pinned digest predates SBOM rollout -- bump the FROM pin to an SBOM-bearing digest)" + fi + done < bases.txt + + # 3) Merge local scan + base SBOMs into protect-${COMPONENT}.cdx.json. + LOCAL_SBOM=local.cdx.json BASES_DIR=bases \ + python3 "${GITHUB_ACTION_PATH}/sbom/merge.py" - jq -e '.bomFormat == "CycloneDX" and ((.components // []) | length > 0)' \ - "protect-${COMPONENT}.cdx.json" > /dev/null \ - || { echo "::error::SBOM for ${COMPONENT} is empty or invalid"; exit 1; } - jq '{component: env.COMPONENT, components: (.components|length)}' \ - "protect-${COMPONENT}.cdx.json" + count="$(jq '(.components // []) | length' "protect-${COMPONENT}.cdx.json")" + echo "SBOM for ${COMPONENT}: ${count} components (${bases_with_sbom}/${bases_found} bases + local scan)" + + if [ "${count}" -eq 0 ]; then + if [ "${SBOM_STRICT}" = "true" ]; then + echo "::error::SBOM for ${COMPONENT} has no components and sbom_strict=true" + exit 1 + fi + echo "::warning::SBOM for ${COMPONENT} has no components; its bases carry no SBOM yet. Skipping attestation. Set sbom_strict=true to fail instead once the SBOM rollout is complete." + echo "has_sbom=false" >> "${GITHUB_OUTPUT}" + else + echo "has_sbom=true" >> "${GITHUB_OUTPUT}" + fi env: SYFT_CMD: ${{ steps.syft.outputs.cmd }} REPOSITORIES: ${{ inputs.repositories }} DIGEST: ${{ steps.push.outputs.digest }} PROTECT_VERSION: ${{ steps.version.outputs.protect_version }} COMPONENT: ${{ inputs.component }} + DOCKERFILE_NAME: ${{ inputs.dockerfile || inputs.component }} + TARGET: ${{ inputs.target }} + SBOM_STRICT: ${{ inputs.sbom_strict }} + GITHUB_ACTION_PATH: ${{ github.action_path }} + COSIGN_EXPERIMENTAL: 'true' - name: 'Cosign attest SBOM to all registries' - if: ${{ inputs.sbom == 'true' && inputs.push == 'true' }} + if: ${{ inputs.sbom == 'true' && inputs.push == 'true' && steps.sbom.outputs.has_sbom == 'true' }} shell: bash run: | set -euo pipefail diff --git a/build-and-sign-image/sbom/list_bases.py b/build-and-sign-image/sbom/list_bases.py new file mode 100644 index 0000000..8ba0423 --- /dev/null +++ b/build-and-sign-image/sbom/list_bases.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""List the external base images a composite's target stage copies artifacts from. + +Many protect images are copy-only composites (`FROM scratch` + `COPY --from=...`) +that re-ship artifacts built in sibling repos. Scanning such an image directly +finds nothing, so instead we merge in the CycloneDX attestations the base images +already carry. This script discovers which bases to pull. + +It parses a Dockerfile, finds the target build stage (or the last stage when no +target is given), and prints -- one registry ref per line -- the images that the +target stage pulls shipped artifacts from via `COPY --from=`. Stage aliases / +indexes are resolved transitively back to their `FROM` image; `scratch` is +dropped. Build args (e.g. ${PROTECT_VERSION}) are expanded from the environment, +falling back to Dockerfile `ARG` defaults; a ref left with an unresolved `$VAR` +is dropped with a warning (it can't be pulled). + +Toolchain/builder bases need no special-casing here: they are emitted like any +other base, and simply have no CycloneDX attestation to download later. + +Usage: list_bases.py [] +""" +import os +import re +import sys + +_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)") + + +def expand(value, variables): + """Expand ${VAR} / $VAR from `variables`; leave unknown refs untouched.""" + def repl(m): + name = m.group(1) or m.group(2) + return variables.get(name, m.group(0)) + return _VAR.sub(repl, value) + + +def parse(dockerfile): + """Return (global ARG defaults, [stage,...]) where a stage is a dict with + keys: alias (or None), ref (the FROM image/alias), copies (list of + --from values).""" + with open(dockerfile) as fh: + raw = fh.read() + # Collapse line continuations so each instruction is one logical line. + raw = re.sub(r"\\\r?\n", " ", raw) + + global_args = {} + stages = [] + seen_from = False + cur = None + for line in raw.splitlines(): + s = line.strip() + if not s or s.startswith("#"): + continue + kw = s.split(None, 1)[0].upper() + + if kw == "ARG" and not seen_from: + body = s.split(None, 1)[1] if len(s.split(None, 1)) > 1 else "" + if "=" in body: + k, v = body.split("=", 1) + global_args[k.strip()] = v.strip().strip('"\'') + elif body.strip(): + global_args.setdefault(body.strip(), "") + continue + + if kw == "FROM": + seen_from = True + parts = s.split() + idx = 1 + while idx < len(parts) and parts[idx].startswith("--"): + idx += 1 # skip flags such as --platform=$BUILDPLATFORM + ref = parts[idx] if idx < len(parts) else "" + alias = None + if idx + 2 < len(parts) and parts[idx + 1].upper() == "AS": + alias = parts[idx + 2] + cur = {"alias": alias, "ref": ref, "copies": []} + stages.append(cur) + continue + + if kw == "COPY" and cur is not None: + m = re.search(r"--from=(\S+)", s) + if m: + cur["copies"].append(m.group(1).strip('"\'')) + continue + + return global_args, stages + + +def main(): + if len(sys.argv) < 2: + print("usage: list_bases.py [target]", file=sys.stderr) + sys.exit(2) + dockerfile = sys.argv[1] + target = sys.argv[2].strip() if len(sys.argv) > 2 and sys.argv[2].strip() else None + + global_args, stages = parse(dockerfile) + if not stages: + return + + variables = dict(global_args) + variables.update(os.environ) # build-args from the env win over ARG defaults + + by_alias = {} + for i, st in enumerate(stages): + by_alias[str(i)] = st # numeric stage index, e.g. COPY --from=0 + if st["alias"]: + by_alias[st["alias"].lower()] = st + + tstage = None + if target: + tstage = by_alias.get(target.lower()) + if tstage is None: + print("::warning::target stage %r not found in %s; using last stage" + % (target, dockerfile), file=sys.stderr) + if tstage is None: + tstage = stages[-1] + + def resolve(value, depth=0): + """Resolve a --from value to a concrete image ref, following stage + aliases/indexes that themselves `FROM` another stage.""" + if depth > 16: + return None + st = by_alias.get(value.lower()) + if st is not None: + return resolve(st["ref"], depth + 1) + return value # not a stage -> an image ref (or 'scratch') + + out = [] + for raw_from in tstage["copies"]: + ref = resolve(raw_from) + if not ref: + continue + ref = expand(ref, variables) + if ref.lower() == "scratch": + continue + if "$" in ref: + print("::warning::skipping base with unresolved build-arg: %s" % ref, + file=sys.stderr) + continue + if ref not in out: + out.append(ref) + + for ref in out: + print(ref) + + +if __name__ == "__main__": + main() diff --git a/build-and-sign-image/sbom/merge.py b/build-and-sign-image/sbom/merge.py new file mode 100644 index 0000000..7925814 --- /dev/null +++ b/build-and-sign-image/sbom/merge.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Merge a composite image's local syft scan with the CycloneDX SBOMs pulled +from its base images into one CycloneDX 1.6 document. + +A copy-only composite re-ships artifacts whose real provenance lives in the base +images it copies from. The action scans the pushed composite directly (catching +e.g. cargo-auditable dep trees or a shipped apk rootfs) and downloads each base's +CycloneDX attestation; this script stitches them into a single SBOM describing +what the composite actually ships. + +Reads from the environment: + COMPONENT composite component name (-> metadata.component + output file) + PROTECT_VERSION composite version (the short-sha tag) + LOCAL_SBOM path to the syft scan of the pushed composite (may be absent + or contain zero components for FROM-scratch composites) + BASES_DIR directory of .cdx.json predicates already extracted from + base attestations (may be empty/missing) + +Always writes protect-.cdx.json (even with zero components, so the +caller can decide warn-vs-fail) and prints a JSON summary to stdout. +""" +import json +import os +import sys + + +def load(path): + try: + with open(path) as fh: + return json.load(fh) + except (OSError, ValueError): + return None + + +def dedup_key(comp): + return (comp.get("purl") + or comp.get("bom-ref") + or "%s@%s" % (comp.get("name", "?"), comp.get("version", "?"))) + + +def main(): + component = os.environ["COMPONENT"] + version = os.environ.get("PROTECT_VERSION", "") + local_path = os.environ.get("LOCAL_SBOM", "") + bases_dir = os.environ.get("BASES_DIR", "") + + components = [] + seen = set() + raw_deps = [] + contributed = [] + + def add(comp): + if not isinstance(comp, dict): + return + key = dedup_key(comp) + if key in seen: + return + seen.add(key) + components.append(comp) + + # Local scan of the pushed composite first. + local = load(local_path) if local_path else None + if local: + for c in local.get("components") or []: + add(c) + raw_deps.extend(local.get("dependencies") or []) + + # Then each base image's downloaded CycloneDX predicate. + base_files = [] + if bases_dir and os.path.isdir(bases_dir): + base_files = sorted( + os.path.join(bases_dir, f) + for f in os.listdir(bases_dir) if f.endswith(".cdx.json")) + for bf in base_files: + doc = load(bf) + if not doc: + continue + primary = (doc.get("metadata") or {}).get("component") + label = os.path.basename(bf)[:-len(".cdx.json")] + if isinstance(primary, dict): + label = (primary.get("purl") + or "%s@%s" % (primary.get("name", "?"), + primary.get("version", "?"))) + add(primary) # the base image itself is a shipped component + for c in doc.get("components") or []: + add(c) + raw_deps.extend(doc.get("dependencies") or []) + contributed.append(label) + + image_ref = ("protect-%s@%s" % (component, version) if version + else "protect-%s" % component) + + # Carry over and dedup the inner dependency graphs, then make the composite + # depend on every top-level component we merged. + dep_by_ref = {} + order = [] + for d in raw_deps: + ref = d.get("ref") + if not ref or ref == image_ref: + continue + if ref not in dep_by_ref: + dep_by_ref[ref] = set() + order.append(ref) + for dd in d.get("dependsOn") or []: + dep_by_ref[ref].add(dd) + + top_refs = [c["bom-ref"] for c in components if c.get("bom-ref")] + dependencies = [{"ref": image_ref, "dependsOn": top_refs}] + for ref in order: + dependencies.append({"ref": ref, "dependsOn": sorted(dep_by_ref[ref])}) + + properties = [{"name": "dev.edera.sbom.bases", "value": str(len(contributed))}] + for b in contributed: + properties.append({"name": "dev.edera.sbom.base", "value": b}) + + metadata_component = { + "bom-ref": image_ref, + "type": "container", + "name": "protect-%s" % component, + } + if version: + metadata_component["version"] = version + + document = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": metadata_component, + "properties": properties, + }, + "components": components, + "dependencies": dependencies, + } + + out = "protect-%s.cdx.json" % component + with open(out, "w") as fh: + json.dump(document, fh, indent=2) + fh.write("\n") + + print(json.dumps({ + "component": component, + "components": len(components), + "bases_merged": len(contributed), + "bases": contributed, + }, indent=2)) + + +if __name__ == "__main__": + main()