Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 177 additions & 1 deletion build-and-sign-image/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ 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'
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
Expand Down Expand Up @@ -186,6 +196,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
Expand All @@ -201,5 +227,155 @@ 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@36a5fde73e0fcb1d1e70be9ad66b4724e783bda7
id: syft

# 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
run: |
set -euo pipefail
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}"

# 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}" \
--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"

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' && steps.sbom.outputs.has_sbom == 'true' }}
shell: bash
run: |
set -euo pipefail
# One attestation per repository@digest
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.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
with:
name: sbom-protect-${{ inputs.component }}
path: protect-${{ inputs.component }}.cdx.json
if-no-files-found: error
147 changes: 147 additions & 0 deletions build-and-sign-image/sbom/list_bases.py
Original file line number Diff line number Diff line change
@@ -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 <dockerfile> [<target-stage>]
"""
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 <dockerfile> [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()
Loading