Skip to content
Draft
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
92 changes: 92 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,54 @@ jobs:
echo "Test failed: Command succeeded when it should have failed."
exit 1

- name: Run Setup Cyclonedx Via Local Setup Cyclonedx Action
id: setup_cyclonedx
uses: ./setup-cyclonedx

- name: Verify Cyclonedx Is On PATH With The Default Spec Version
env:
CYCLONEDX_PATH: ${{ steps.setup_cyclonedx.outputs.path }}
run: |
test "$(command -v cyclonedx)" = "$CYCLONEDX_PATH"
test "$CYCLONEDX_SPEC_VERSION" = v1_7

- name: Merge Image SBOMs Via Local Attest Chart Action
id: attest_chart
uses: ./attest-chart
with:
chart-path: testdata/attest-chart
chart-name: smoke
chart-version: 1.2.3
attest: "false"

- name: Verify Merged Chart SBOM
env:
SBOM_PATH: ${{ steps.attest_chart.outputs.sbom-path }}
# sboms/ also holds a busybox-c SBOM that bom.json does not reference,
# so the nested names prove a stale SBOM is left out.
run: |
test "$SBOM_PATH" = sbom-out/smoke-1.2.3.cdx.json
cyclonedx validate --input-file "$SBOM_PATH" --input-version v1_7 --fail-on-errors
jq -e '.specVersion == "1.7"
and .metadata.component.group == "codesphere"
and .metadata.component.name == "smoke"
and .metadata.component.version == "1.2.3"
and ([.components[].components[].name] | sort) == ["busybox-a", "busybox-b"]' "$SBOM_PATH"

- name: Run Attest Chart On A Chart Without Image SBOMs
id: attest_chart_empty
uses: ./attest-chart
with:
chart-path: testdata/charts/e2e
chart-name: e2e
chart-version: 0.0.0
attest: "false"

- name: Verify No Chart SBOM Was Written
env:
SBOM_PATH: ${{ steps.attest_chart_empty.outputs.sbom-path }}
run: test -z "$SBOM_PATH"

attestation-smoke:
if: github.event_name == 'push'
runs-on: ubuntu-latest
Expand Down Expand Up @@ -168,3 +216,47 @@ jobs:
--certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/ci.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$attestation_ref"

- id: chart
name: Package and push smoke chart
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
repository="$(echo "oci://ghcr.io/${GITHUB_REPOSITORY}/attestation-smoke-charts" | tr '[:upper:]' '[:lower:]')"
version="0.0.0-${GITHUB_SHA::12}"
helm package testdata/attestation/chart --version "$version" -d "$RUNNER_TEMP/chart"
echo "$GH_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
push_log="$(helm push "$RUNNER_TEMP"/chart/*.tgz "$repository" 2>&1 | tee /dev/stderr)"
digest="$(printf '%s\n' "$push_log" | awk '/^Digest: /{print $2}' | head -n1)"
name="$(yq '.name' testdata/attestation/chart/Chart.yaml)"
test -n "$digest"
{
echo "name=$name"
echo "version=$version"
echo "ref=${repository#oci://}/${name}@${digest}"
} >> "$GITHUB_OUTPUT"

- id: chart_sbom
name: Merge and attest chart SBOM
uses: ./attest-chart
with:
chart-path: testdata/attestation/chart
chart-name: ${{ steps.chart.outputs.name }}
chart-version: ${{ steps.chart.outputs.version }}
chart-ref: ${{ steps.chart.outputs.ref }}

- name: Verify chart SBOM attestation
shell: bash
env:
CHART_REF: ${{ steps.chart.outputs.ref }}
CHART_NAME: ${{ steps.chart.outputs.name }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cosign verify-attestation --k8s-keychain \
--type cyclonedx \
--certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/ci.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$CHART_REF" |
jq -se --arg name "$CHART_NAME" \
'.[0].payload | @base64d | fromjson | .predicate.specVersion == "1.7" and .predicate.metadata.component.name == $name'
13 changes: 13 additions & 0 deletions .yamllint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Permissive on layout, strict on syntax: GitHub workflow and action files
# use long run: lines, `on:` keys and SHA-pin comments the defaults reject.
extends: default

rules:
document-start: disable
truthy:
check-keys: false
line-length:
max: 200
comments:
min-spaces-from-content: 1
comments-indentation: disable
59 changes: 57 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ Choose the section that matches how you use this repository:

## Users Of The GitHub Action

This repository ships three composite Actions:
This repository ships five composite Actions:

- [generate/action.yml](/Users/schrodit/dev/cs/bom/generate/action.yml) generates BOM files for Helm charts
- [registry-login/action.yml](/Users/schrodit/dev/cs/bom/registry-login/action.yml) authenticates registry access for subsequent checks
- [check/action.yml](/Users/schrodit/dev/cs/bom/check/action.yml) validates BOM files against upstream registries
- [attest-chart/action.yml](attest-chart/action.yml) merges a chart's image SBOMs into one chart SBOM and attests it on the pushed chart
- [setup-cyclonedx/action.yml](setup-cyclonedx/action.yml) installs the pinned `cyclonedx-cli` for callers that merge SBOMs themselves

All Actions:

- run a prebuilt binary from `dist/linux-*` directly on the runner
- run a prebuilt binary from `dist/linux-*`, or a pinned release binary, directly on the runner
- support Linux runners only

### Generate BOMs In CI
Expand Down Expand Up @@ -300,6 +302,58 @@ Check outputs:

All selected BOMs are checked even when some fail. The Action exits unsuccessfully after processing every BOM and writes a step-summary table containing each BOM and its validation status or error. Its metrics include the total number of image references outside the allowed registries and the total number that could not be found upstream.

### Attest Chart SBOMs In CI

`generate` with `sbom: true` writes one SBOM per image. `attest-chart` runs after the chart is pushed: it merges the CycloneDX SBOMs the chart's `bom.json` references hierarchically into one CycloneDX document whose subject is the chart, then attests it on the chart's GHCR digest with keyless Cosign and reads the attestation back. SBOMs left in `sboms/` for digests the chart no longer uses are not merged.

```yaml
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v6

- uses: codesphere-cloud/bom/generate@main
with:
include-paths: chart
sbom: true

- id: push
uses: codesphere-cloud/helm-action/push@main
with:
chart: chart
repository: oci://ghcr.io/codesphere-cloud/charts

- uses: codesphere-cloud/bom/attest-chart@main
with:
chart-path: chart
chart-name: my-chart
chart-version: 1.2.3
chart-ref: ghcr.io/codesphere-cloud/charts/my-chart@${{ steps.push.outputs.digest }}
```

Attest-chart inputs:

- `chart-path`, `chart-name`, `chart-version`: the chart whose `bom.json` SBOMs are merged, and the subject the merged SBOM describes. Required unless `sbom-path` is set
- `chart-group`: group of the subject. Default: `codesphere`
- `chart-ref`: the pushed chart as `ghcr.io/<repository>@sha256:<digest>`. Required when `attest` is `true`. Only GHCR is supported: Cosign gets the registry credentials through its GitHub keychain
- `sbom-path`: attest an already merged SBOM instead of merging, for jobs that merge and attest separately
- `output-dir`: where the merged SBOM is written, as `<chart-name>-<chart-version>.cdx.json`. Default: `sbom-out`
- `spec-version`: CycloneDX version of the merged SBOM, as `cyclonedx-cli --output-version` takes it. Default: `v1_7`
- `attest`: attest and verify. Requires `id-token: write` and registry write access. Default: `true`
- `certificate-identity-regexp`: identity the attestation is verified against. Default: any workflow of the calling repository, matched literally
- `registry-token`: GHCR token Cosign uses for the registry. Default: `github.token`

A chart without image SBOMs gets no chart SBOM: the `sbom-path` output is empty and nothing is attested.

Callers that merge SBOMs in their own scripts use `setup-cyclonedx` instead. It puts `cyclonedx` on `PATH` and exports `CYCLONEDX_SPEC_VERSION` (input `spec-version`, default `v1_7`), so the version and the spec default stay defined in this repository.

[setup-cyclonedx/install.sh](setup-cyclonedx/install.sh) is the only place the `cyclonedx-cli` version and checksums are pinned. Renovate bumps the version, and [scripts/pin-cyclonedx.sh](scripts/pin-cyclonedx.sh) rewrites the checksums in the same commit.

## Users Of The CLI

Use the CLI for local debugging, reproducing GitHub Action behavior, or developing chart/BOM logic outside CI.
Expand Down Expand Up @@ -435,6 +489,7 @@ make dist
- [internal/sbom](/Users/schrodit/dev/cs/bom/internal/sbom): SPDX and `csbom` / `csbom-v2` formatting and parsing
- [internal/ghaction](/Users/schrodit/dev/cs/bom/internal/ghaction): shared action filtering, path resolution, and output handling
- [generate/action.yml](/Users/schrodit/dev/cs/bom/generate/action.yml) and [check/action.yml](/Users/schrodit/dev/cs/bom/check/action.yml): composite Action entrypoints
- [attest-chart/action.yml](attest-chart/action.yml) and [setup-cyclonedx/action.yml](setup-cyclonedx/action.yml): composite Actions around the pinned `cyclonedx-cli`, which [setup-cyclonedx/install.sh](setup-cyclonedx/install.sh) installs
- [scripts/build-dist.sh](/Users/schrodit/dev/cs/bom/scripts/build-dist.sh): dist artifact builder

### Action Development
Expand Down
184 changes: 184 additions & 0 deletions attest-chart/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
name: bom attest chart
description: >
Merge the per-image CycloneDX SBOMs that a chart's bom.json references, written by generate
with sbom: true, into one chart-level SBOM and attest it on the pushed chart's GHCR digest
with keyless Cosign.
inputs:
chart-path:
description: Chart directory whose bom.json lists the per-image SBOMs to merge. Not used when sbom-path is set.
required: false
default: ""
chart-name:
description: Name of the software the merged SBOM describes, usually the chart name.
required: false
default: ""
chart-version:
description: Version of the software the merged SBOM describes, usually the chart version.
required: false
default: ""
chart-group:
description: Group of the software the merged SBOM describes.
required: false
default: codesphere
chart-ref:
description: Pushed chart to attest, as ghcr.io/<repository>@sha256:<digest>. Required when attest is true.
required: false
default: ""
sbom-path:
description: An already merged chart SBOM to attest. Skips the merge.
required: false
default: ""
output-dir:
description: Directory the merged SBOM is written to, as <chart-name>-<chart-version>.cdx.json.
required: false
default: sbom-out
spec-version:
description: CycloneDX specification version of the merged SBOM, as cyclonedx-cli's --output-version takes it.
required: false
default: v1_7
attest:
description: Attest the SBOM on chart-ref and verify the attestation. Requires id-token write permission.
required: false
default: "true"
certificate-identity-regexp:
description: Signing identity the attestation is verified against. Defaults to any workflow of the calling repository.
required: false
default: ""
registry-token:
description: GHCR token cosign uses for the chart registry; the default needs the workflow permission packages write.
required: false
default: ${{ github.token }}
outputs:
sbom-path:
description: The merged or given chart SBOM. Empty when the chart has no image SBOMs.
value: ${{ steps.merge.outputs.sbom-path }}
runs:
using: composite
steps:
# GitHub only warns on a missing required input of a composite action, so
# these checks are the real enforcement, and they run before anything is
# downloaded or signed.
- name: Validate inputs
shell: bash
env:
CHART_PATH: ${{ inputs.chart-path }}
CHART_NAME: ${{ inputs.chart-name }}
CHART_VERSION: ${{ inputs.chart-version }}
CHART_REF: ${{ inputs.chart-ref }}
SBOM_PATH: ${{ inputs.sbom-path }}
SPEC_VERSION: ${{ inputs.spec-version }}
ATTEST: ${{ inputs.attest }}
run: |
if [ -n "$SBOM_PATH" ]; then
if [ ! -f "$SBOM_PATH" ]; then
echo "::error title=No SBOM::sbom-path '$SBOM_PATH' does not exist"
exit 1
fi
elif [ -z "$CHART_PATH" ] || [ -z "$CHART_NAME" ] || [ -z "$CHART_VERSION" ]; then
echo "::error title=Missing inputs::chart-path, chart-name and chart-version are required unless sbom-path is set"
exit 1
fi
if ! [[ "$SPEC_VERSION" =~ ^v1_[0-9]+$ ]]; then
echo "::error title=Invalid spec-version::expected v1_<minor>, got '$SPEC_VERSION'"
exit 1
fi
# Registry credentials reach cosign only through the GHCR keychain (see
# the attest step), so another registry would fail to authorize.
if [ "$ATTEST" = "true" ] && ! [[ "$CHART_REF" =~ ^ghcr\.io/[^@[:space:]]+@sha256:[0-9a-f]{64}$ ]]; then
echo "::error title=Invalid chart-ref::attesting needs ghcr.io/<repository>@sha256:<digest>, got '$CHART_REF'"
exit 1
fi

- id: merge
name: Merge image SBOMs into the chart SBOM
shell: bash
env:
CHART_PATH: ${{ inputs.chart-path }}
CHART_NAME: ${{ inputs.chart-name }}
CHART_VERSION: ${{ inputs.chart-version }}
CHART_GROUP: ${{ inputs.chart-group }}
SBOM_PATH: ${{ inputs.sbom-path }}
OUTPUT_DIR: ${{ inputs.output-dir }}
SPEC_VERSION: ${{ inputs.spec-version }}
INSTALL_DIR: ${{ runner.temp }}/cyclonedx-cli
run: |
if [ -n "$SBOM_PATH" ]; then
echo "sbom-path=$SBOM_PATH" >> "$GITHUB_OUTPUT"
exit 0
fi

# generate keeps SBOMs of digests the chart no longer uses, so only
# the ones the current bom.json references describe the chart.
bom_json="$CHART_PATH/bom.json"
image_sboms=()
if [ -f "$bom_json" ]; then
# Outside the loop, so a bom.json jq cannot read fails the step.
sbom_paths="$(jq -r '[.containerImages // {} | .[] | .sboms.cyclonedx.path // empty] | unique | .[]' "$bom_json")"
while IFS= read -r path; do
[ -n "$path" ] || continue
if ! [[ "$path" =~ ^sboms/sha256-[0-9a-f]{64}\.cdx\.json$ ]]; then
echo "::error title=Invalid SBOM path::$bom_json references '$path', expected sboms/sha256-<digest>.cdx.json"
exit 1
fi
if [ ! -f "$CHART_PATH/$path" ]; then
echo "::error title=Missing SBOM::$bom_json references $path, which does not exist"
exit 1
fi
image_sboms+=("$CHART_PATH/$path")
done <<< "$sbom_paths"
fi
if [ "${#image_sboms[@]}" -eq 0 ]; then
echo "::notice::$CHART_PATH has no image SBOMs in bom.json, so it gets no chart SBOM."
echo "sbom-path=" >> "$GITHUB_OUTPUT"
exit 0
fi

cyclonedx="$("${{ github.action_path }}/../setup-cyclonedx/install.sh" "$INSTALL_DIR")"
out="$OUTPUT_DIR/${CHART_NAME}-${CHART_VERSION}.cdx.json"
mkdir -p "$OUTPUT_DIR"
"$cyclonedx" merge --hierarchical --output-version "$SPEC_VERSION" \
--group "$CHART_GROUP" --name "$CHART_NAME" --version "$CHART_VERSION" \
--input-files "${image_sboms[@]}" \
--output-file "$out"
echo "sbom-path=$out" >> "$GITHUB_OUTPUT"

- name: Set up Cosign
if: ${{ inputs.attest == 'true' && steps.merge.outputs.sbom-path != '' }}
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# The default is a shared path under $HOME, which concurrent jobs on a
# persistent runner would download the same binary into at once.
install-dir: ${{ runner.temp }}/cosign

- name: Attest and verify the chart SBOM
if: ${{ inputs.attest == 'true' && steps.merge.outputs.sbom-path != '' }}
shell: bash
env:
CHART_REF: ${{ inputs.chart-ref }}
PREDICATE: ${{ steps.merge.outputs.sbom-path }}
IDENTITY_REGEXP: ${{ inputs.certificate-identity-regexp }}
# cosign has no env var for registry credentials. --k8s-keychain adds
# go-containerregistry's GitHub keychain, which reads GITHUB_TOKEN (and
# GITHUB_ACTOR) for ghcr.io, so the token is in neither argv nor a file.
GITHUB_TOKEN: ${{ inputs.registry-token }}
Comment thread
tuxerrante marked this conversation as resolved.
Resolved
run: |
# An empty docker config of this step's own, so cosign cannot pick up
# a credential another step or job left in $HOME. Without any
# config.json, cosign's keychain falls back to $REGISTRY_AUTH_FILE and
# $XDG_RUNTIME_DIR/containers/auth.json, which may be shared.
DOCKER_CONFIG="$(mktemp -d "$RUNNER_TEMP/attest-chart-auth.XXXXXX")"
export DOCKER_CONFIG
echo '{}' > "$DOCKER_CONFIG/config.json"

# A repository name may contain '.', which must match only itself.
repository_pattern="${GITHUB_REPOSITORY//./\\.}"
identity="${IDENTITY_REGEXP:-^https://github\\.com/${repository_pattern}/}"

cosign attest --yes --k8s-keychain --type cyclonedx \
--predicate "$PREDICATE" "$CHART_REF"
# Read it back, so an attestation that never landed fails the run
# instead of shipping a chart we only believe is attested.
cosign verify-attestation --k8s-keychain --type cyclonedx \
--certificate-identity-regexp "$identity" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$CHART_REF" > /dev/null
Loading
Loading