diff --git a/.github/workflows/release-image.yaml b/.github/workflows/release-image.yaml index d0532bd..22d2a88 100644 --- a/.github/workflows/release-image.yaml +++ b/.github/workflows/release-image.yaml @@ -3,6 +3,9 @@ name: Release falco_plugin (OCI) on: release: types: [published] + workflow_dispatch: + push: + branches: ["asher/sbom"] # remove after testing permissions: contents: read # Default token to read @@ -88,3 +91,47 @@ jobs: env: DIGEST: '${{ steps.push.outputs.digest }}' COSIGN_EXPERIMENTAL: 'true' + + - name: install syft + if: github.event_name != 'pull_request' + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 + id: syft + + - name: generate and attest CycloneDX SBOM + if: github.event_name != 'pull_request' + shell: bash + env: + DIGEST: '${{ steps.push.outputs.digest }}' + IMAGE: '${{ steps.meta.outputs.full-image-name }}' + SYFT_CMD: '${{ steps.syft.outputs.cmd }}' + COMPONENT: edera_falco_plugin + run: | + set -euo pipefail + ref="${IMAGE}@${DIGEST}" + version="${GITHUB_SHA::7}" + + pairs=() + for plat in linux/amd64 linux/arm64; do + out="${COMPONENT}.${plat##*/}.cdx.json" + echo "scanning ${plat}" + "${SYFT_CMD}" "registry:${ref}" \ + --platform "${plat}" \ + --select-catalogers "-file" \ + -o "cyclonedx-json=${out}" + n="$(jq '(.components // []) | length' "${out}")" + echo " ${plat}: ${n} components" + [ "${n}" -gt 0 ] || { echo "::error::no SBOM components for ${plat}"; exit 1; } + pairs+=("${plat}=${out}") + done + + python3 merge-cdx.py "${COMPONENT}.cdx.json" "${COMPONENT}" "${version}" "${pairs[@]}" + + jq -e '.bomFormat == "CycloneDX" and ((.components // []) | length > 0)' \ + "${COMPONENT}.cdx.json" >/dev/null \ + || { echo "::error::SBOM for ${COMPONENT} is empty or invalid"; exit 1; } + jq '{component: env.COMPONENT, components: (.components | length)}' \ + "${COMPONENT}.cdx.json" + + cosign attest --yes --type cyclonedx \ + --predicate "${COMPONENT}.cdx.json" \ + "${ref}" diff --git a/Containerfile b/Containerfile index 674a106..fb83d5b 100644 --- a/Containerfile +++ b/Containerfile @@ -5,7 +5,9 @@ RUN apt-get update && apt-get --assume-yes install protobuf-compiler git clang c WORKDIR /usr/src/app COPY . . -RUN cargo build --release +# cargo-auditable embeds the resolved dependency tree into the .so (readable by syft) +RUN cargo install cargo-auditable --version 0.6.7 --locked +RUN cargo auditable build --release RUN mkdir -p /var/lib/edera/protect/falco RUN mv ./target/release/libedera_falco_plugin.so /var/lib/edera/protect/falco/ diff --git a/merge-cdx.py b/merge-cdx.py new file mode 100644 index 0000000..6331568 --- /dev/null +++ b/merge-cdx.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Merge per-architecture syft CycloneDX scans into one CycloneDX 1.6 SBOM. + +syft emits only a single platform per scan, so each architecture of a multi-arch +image is scanned separately. This unions the per-arch component sets into one +SBOM so EVERY architecture's shipped dependencies are covered, then that single +SBOM is attested to the image index digest. (protect's build-and-sign-image +merge reads exactly one CycloneDX predicate from the index digest, so separate +per-arch attestations would not all be seen -- a single unioned SBOM is required.) + +Usage: + merge-cdx.py OUT NAME VERSION PLATFORM=FILE [PLATFORM=FILE ...] + +Components are deduped across arches by purl (falling back to bom-ref, then +name@version). Each component records the platform(s) it was found in via a +`dev.edera.platform` property. metadata.component is set to NAME@VERSION so the +downstream merge labels this base cleanly (never name@?). +""" +import copy +import json +import sys + + +def comp_key(c): + return ( + c.get("purl") + or c.get("bom-ref") + or "%s@%s" % (c.get("name", "?"), c.get("version", "")) + ) + + +def add_platform_prop(component, platform): + props = component.setdefault("properties", []) + for p in props: + if p.get("name") == "dev.edera.platform": + p["value"] = ",".join(sorted(set(p["value"].split(",")) | {platform})) + return + props.append({"name": "dev.edera.platform", "value": platform}) + + +def main(): + if len(sys.argv) < 5: + print("usage: merge-cdx.py OUT NAME VERSION PLATFORM=FILE ...", file=sys.stderr) + sys.exit(2) + out_path, name, version = sys.argv[1], sys.argv[2], sys.argv[3] + pairs = sys.argv[4:] + + merged = {} # comp_key -> kept component dict + order = [] # first-seen order of comp_keys + dep_edges = {} # canonical ref -> set(canonical dependsOn refs) + roots = set() # per-arch image root bom-refs (dropped from output graph) + + for pair in pairs: + platform, _, path = pair.partition("=") + if not path: + print("bad PLATFORM=FILE arg: %r" % pair, file=sys.stderr) + sys.exit(2) + with open(path) as f: + doc = json.load(f) + + root = (doc.get("metadata") or {}).get("component", {}).get("bom-ref") + if root: + roots.add(root) + + # Map this arch's local bom-refs to the canonical (first-seen) bom-ref, + # so dependency edges stay valid after dedup. + local_map = {} + for c in doc.get("components") or []: + key = comp_key(c) + if key not in merged: + merged[key] = copy.deepcopy(c) + order.append(key) + canonical = merged[key].get("bom-ref") + if c.get("bom-ref") and canonical: + local_map[c["bom-ref"]] = canonical + add_platform_prop(merged[key], platform) + + for d in doc.get("dependencies") or []: + ref = local_map.get(d.get("ref"), d.get("ref")) + if not ref: + continue + deps = {local_map.get(x, x) for x in (d.get("dependsOn") or [])} + dep_edges.setdefault(ref, set()).update(deps) + + components = [merged[k] for k in order] + image_ref = "%s@%s" % (name, version) if version else name + + dependencies = [ + { + "ref": image_ref, + "dependsOn": [c["bom-ref"] for c in components if c.get("bom-ref")], + } + ] + for ref in sorted(dep_edges): + if ref in roots or ref == image_ref: + continue + deps = sorted(dep_edges[ref]) + if deps: + dependencies.append({"ref": ref, "dependsOn": deps}) + + metadata_component = {"bom-ref": image_ref, "type": "container", "name": name} + if version: + metadata_component["version"] = version + + document = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": {"component": metadata_component}, + "components": components, + "dependencies": dependencies, + } + with open(out_path, "w") as out: + json.dump(document, out, indent=2) + out.write("\n") + + print( + json.dumps( + { + "component": name, + "version": version, + "platforms": [p.split("=", 1)[0] for p in pairs], + "components": len(components), + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main()