diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e28dc00..6d4114b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,20 +1,89 @@ -name: Docker Build and Push +name: Verified Container Release +# Only the signed source-release workflow calls this publisher. on: - push: - tags: - - 'v*' - workflow_dispatch: + workflow_call: + inputs: + release_tag: + required: true + type: string + release_commit: + required: true + type: string + tag_object: + required: true + type: string permissions: contents: read packages: write + id-token: write + attestations: write jobs: docker: + # Owner opt-in after confirming OWASP package rights and the first-release plan. + if: github.repository == 'OWASP/openshield' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.CONTAINER_RELEASE_ENABLED == 'true' runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_COMMIT: ${{ inputs.release_commit }} + EXPECTED_TAG_OBJECT: ${{ inputs.tag_object }} + IMAGE_NAME: ghcr.io/owasp/openshield + CANDIDATE: ghcr.io/owasp/openshield:candidate-${{ github.run_id }}-${{ github.run_attempt }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Checkout immutable release commit + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ inputs.release_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Reverify signed source before build + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release_integrity.py verify > "$RUNNER_TEMP/release-source.json" + + - name: Build once and save the exact image + run: | + set -euo pipefail + docker build --tag "$CANDIDATE" \ + --label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${RELEASE_COMMIT}" \ + --label "org.opencontainers.image.version=${RELEASE_TAG}" . + docker image inspect "$CANDIDATE" --format '{{.Id}}' > "$RUNNER_TEMP/image-id" + docker save --output "$RUNNER_TEMP/image.tar" "$CANDIDATE" + + - name: Scan saved image before registry publication + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + input: ${{ runner.temp }}/image.tar + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + format: json + output: ${{ runner.temp }}/image-trivy.json + + - name: Install checksum-verified Syft + env: + SYFT_VERSION: "1.46.0" + SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca + run: | + set -euo pipefail + archive="$RUNNER_TEMP/syft_${SYFT_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location --output "$archive" \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" + echo "${SYFT_SHA256} ${archive}" | sha256sum --check --strict + sudo tar --extract --gzip --file "$archive" --directory /usr/local/bin syft + + - name: Generate image SBOM from scanned archive + run: syft "docker-archive:$RUNNER_TEMP/image.tar" -o "cyclonedx-json=$RUNNER_TEMP/image.cdx.json" + + - name: Reverify tag before any registry write + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release_integrity.py verify - name: Log in to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 @@ -23,20 +92,71 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + - name: Push scanned candidate and capture registry digest + id: push + run: | + set -euo pipefail + test "$(docker image inspect "$CANDIDATE" --format '{{.Id}}')" = "$(< "$RUNNER_TEMP/image-id")" + docker push "$CANDIDATE" + docker image inspect "$CANDIDATE" --format '{{json .RepoDigests}}' | \ + python3 scripts/release_integrity.py digest --image "$IMAGE_NAME" > "$RUNNER_TEMP/image-digest.json" + + - name: Attest image provenance by digest + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Attest image SBOM by the same digest + uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3 with: - images: ghcr.io/openshield-org/openshield - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + sbom-path: ${{ runner.temp }}/image.cdx.json + push-to-registry: true + + - name: Verify both attestations before version promotion + env: + GH_TOKEN: ${{ github.token }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -euo pipefail + gh attestation verify "oci://${IMAGE_NAME}@${DIGEST}" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/docker.yml" \ + --source-digest "$RELEASE_COMMIT" --source-ref "refs/tags/$RELEASE_TAG" + gh attestation verify "oci://${IMAGE_NAME}@${DIGEST}" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/docker.yml" \ + --source-digest "$RELEASE_COMMIT" --source-ref "refs/tags/$RELEASE_TAG" \ + --predicate-type https://cyclonedx.org/bom + + - name: Promote verified image without rebuilding + env: + GH_TOKEN: ${{ github.token }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -euo pipefail + python3 scripts/release_integrity.py verify + test "$(docker image inspect "$CANDIDATE" --format '{{.Id}}')" = "$(< "$RUNNER_TEMP/image-id")" + VERSION_REF="${IMAGE_NAME}:${RELEASE_TAG#v}" + docker tag "$CANDIDATE" "$VERSION_REF" + docker push "$VERSION_REF" + docker image inspect "$VERSION_REF" --format '{{json .RepoDigests}}' | \ + python3 scripts/release_integrity.py digest --image "$IMAGE_NAME" --expect "$DIGEST" + printf 'Verified image: `%s@%s`\nSource: `%s`\nVersion: `%s`\n' \ + "$IMAGE_NAME" "$DIGEST" "$RELEASE_COMMIT" "$VERSION_REF" >> "$GITHUB_STEP_SUMMARY" + + - name: Retain container release evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + name: container-release-evidence-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/image-trivy.json + ${{ runner.temp }}/image.cdx.json + ${{ runner.temp }}/image-digest.json + ${{ runner.temp }}/release-source.json + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8661e9b..8d92f2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,10 @@ on: tags: - "v*" +concurrency: + group: signed-release-${{ github.ref }} + cancel-in-progress: false + permissions: contents: write id-token: write @@ -13,30 +17,25 @@ permissions: jobs: release: runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.verify.outputs.release_tag }} + release_commit: ${{ steps.verify.outputs.release_commit }} + tag_object: ${{ steps.verify.outputs.tag_object }} env: TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ github.ref_name }} steps: - name: Checkout signed tag uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 + persist-credentials: false - - name: Verify annotated tag signature + - name: Verify signed tag and main ancestry + id: verify env: GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag_object=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq '.object.sha') - object_type=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq '.object.type') - if [ "$object_type" != "tag" ]; then - echo "Release tags must be signed annotated tags; ${TAG} is ${object_type}." - exit 1 - fi - verified=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.verified') - if [ "$verified" != "true" ]; then - echo "GitHub could not verify the signature on ${TAG}." - exit 1 - fi + run: python3 scripts/release_integrity.py verify - name: Install Syft env: @@ -52,12 +51,14 @@ jobs: sudo tar --extract --gzip --file "$archive" --directory /usr/local/bin syft - name: Build deterministic release artifacts + env: + RELEASE_COMMIT: ${{ steps.verify.outputs.release_commit }} run: | set -euo pipefail mkdir -p dist syft dir:. --source-name openshield --source-version "$TAG" \ -o "cyclonedx-json=dist/openshield-${TAG}-sbom.cyclonedx.json" - git archive --format=tar --prefix="openshield-${TAG}/" "$TAG" | \ + git archive --format=tar --prefix="openshield-${TAG}/" "$RELEASE_COMMIT" | \ gzip --no-name > "dist/openshield-${TAG}.tar.gz" cd dist sha256sum "openshield-${TAG}.tar.gz" "openshield-${TAG}-sbom.cyclonedx.json" > SHA256SUMS @@ -77,9 +78,29 @@ jobs: with: subject-path: dist/SHA256SUMS + - name: Reverify source before release publication + env: + GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ steps.verify.outputs.release_commit }} + EXPECTED_TAG_OBJECT: ${{ steps.verify.outputs.tag_object }} + run: python3 scripts/release_integrity.py verify + - name: Publish release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: generate_release_notes: true make_latest: true files: dist/* + + container: + needs: release + permissions: + contents: read + packages: write + id-token: write + attestations: write + uses: ./.github/workflows/docker.yml + with: + release_tag: ${{ needs.release.outputs.release_tag }} + release_commit: ${{ needs.release.outputs.release_commit }} + tag_object: ${{ needs.release.outputs.tag_object }} diff --git a/docs/container-release-integrity.md b/docs/container-release-integrity.md new file mode 100644 index 0000000..f7f1837 --- /dev/null +++ b/docs/container-release-integrity.md @@ -0,0 +1,109 @@ +# Container release integrity + +This is the container-publication slice of #304. It does not claim that the +deployment topology, restore drills or complete enterprise release criteria are +finished. #336 separately addresses Python dependency locking. + +## Release path + +1. A stable `vMAJOR.MINOR.PATCH` tag push starts `release.yml`. Branch pushes, + manual dispatch, lightweight tags, unverified signatures and nested tags do + not qualify. The tag must directly identify the event/checkout commit, and + that commit must be on the fetched `main` history. +2. Source artifacts are built from that immutable commit, attested and published + through the signed-release job. The tag object is checked again before + publishing source assets. +3. Only after the source job succeeds can its dependent reusable `docker.yml` + job run. It requires owner opt-in via the repository variable + `CONTAINER_RELEASE_ENABLED=true`. +4. The container gate rechecks repository, event, annotated-tag identity, + signature, commit and `main` ancestry. It builds one Linux runner-native image + and exports a Docker archive. Trivy scans that archive; Syft generates the + image CycloneDX SBOM from the same archive, not from the source directory. +5. A passing scan and another tag check permit pushing a unique + `candidate-RUN_ID-RUN_ATTEMPT` tag. The image ID must still match the built + image. Both provenance and SBOM attestations bind to the exact registry + manifest digest, using GitHub's keyless Sigstore-backed attestation actions. +6. Both attestations must verify against the repository, reusable signer + workflow, source commit and tag ref. Only then is the existing local image + tagged as `MAJOR.MINOR.PATCH` and pushed, without rebuilding. The promoted + digest is checked against the attested digest. No moving `latest` or + `MAJOR.MINOR` aliases are updated by this workflow. + +All publishing steps use normal success dependencies. Scan errors, unavailable +verification services, ambiguous digests and failed attestations stop version +promotion. The last evidence-upload step may run on failure but cannot publish +an image. Trivy retains the current CI policy: fail on HIGH/CRITICAL findings +with available fixes (`ignore-unfixed: true`). This is not a claim that the +image has no vulnerabilities; no new ignore list is introduced. + +## OWASP transfer and first-release approval + +GitHub now identifies this repository as `OWASP/openshield`. New image releases +target **`ghcr.io/owasp/openshield`**, not the historical organization namespace. +Nothing in this change migrates, deletes or overwrites historical packages. + +Before enabling the container job, an OWASP repository/package administrator must: + +- Confirm that the repository's `GITHUB_TOKEN` may create/write this package + and that its intended visibility and repository association are correct. +- Confirm the tag-creation/signing authority, `main` protections and review + process. A GitHub-verified signature plus ancestry is not an independent + authorization check on the signer; trusted tag writers and protected workflow + files remain essential. Effective protection enforcement is tracked in #298. +- Review the workflow and approve a first-release verification plan, then set + `CONTAINER_RELEASE_ENABLED=true`. Leaving it unset disables container + publication; it does not disable source releases. +- Use a new signed stable release tag only after this workflow is promoted to + `main` through the normal process. Old tags retain their old workflow code; + this change is not a retroactive gate for historical workflows. +- Verify the published image and evidence below before announcing availability. + +No administrator settings, tags, registry writes, release dispatches or deployments +are required to review this PR. Local unit tests use fake GitHub responses and +temporary local Git histories; they do not prove live OIDC/registry integration. +The first owner-authorized release must supply that operating evidence. + +## Verify an image + +Use the digest recorded in the successful workflow summary and +`container-release-evidence-RUN_ID-RUN_ATTEMPT` artifact. That artifact retains +the Trivy report, image SBOM, source identity and registry digest for 90 days. +Image attestations are also pushed to the registry and recorded by GitHub. + +```bash +# Substitute the actual digest and source commit recorded by the release. +IMAGE=ghcr.io/owasp/openshield@sha256:ACTUAL_DIGEST +COMMIT=ACTUAL_SOURCE_COMMIT +TAG=vX.Y.Z + +gh attestation verify "oci://$IMAGE" --repo OWASP/openshield \ + --signer-workflow OWASP/openshield/.github/workflows/docker.yml \ + --source-digest "$COMMIT" --source-ref "refs/tags/$TAG" + +gh attestation verify "oci://$IMAGE" --repo OWASP/openshield \ + --signer-workflow OWASP/openshield/.github/workflows/docker.yml \ + --source-digest "$COMMIT" --source-ref "refs/tags/$TAG" \ + --predicate-type https://cyclonedx.org/bom +``` + +Authenticate to GHCR if required for package access. Use a current GitHub CLI +supporting these attestation flags. For reusable workflows, the reusable workflow +is the signer identity, not the caller. See the +[GitHub CLI verification reference](https://cli.github.com/manual/gh_attestation_verify). + +## Failures and reruns + +A failed run can leave an unpromoted candidate in GHCR: registry publication and +signing are not one atomic transaction. Never deploy a candidate or infer trust +from its tag. Verify the image by digest with both predicates. A source release +may already exist when the dependent container job fails; do not announce the +container until its job and verification finish. + +Tag moves are rechecked before writes but are not locked atomically across GitHub +and GHCR. An administrator must restrict moving/deleting release tags. Reruns +can rebuild different bytes from mutable base/OS dependencies and can replace +the version tag; use a new release version for changed images and deploy pinned +digests. Digest pinning, not tag spelling, gives immutable consumption. Candidate +cleanup, immutable package-tag enforcement, multi-architecture publishing and +base-image reproducibility remain follow-up work. diff --git a/docs/release-security.md b/docs/release-security.md index 505674a..3b9c56d 100644 --- a/docs/release-security.md +++ b/docs/release-security.md @@ -2,10 +2,14 @@ ## Current process -Version tags trigger `.github/workflows/release.yml`, which creates GitHub -release notes. Published releases trigger `.github/workflows/sbom-release.yml`, -which generates and uploads a CycloneDX SBOM. Release actions are pinned to -specific commits. +Stable version tags trigger `.github/workflows/release.yml`, which verifies a +signed annotated tag and its commit's `main` ancestry, then creates and attests +the source archive, CycloneDX SBOM and checksum manifest before publication. +The dependent container workflow is owner-opt-in and scans the built image +before publishing a candidate, binds provenance/SBOM to its digest, and verifies +both before version promotion. Release actions are pinned to specific commits. +See [container release integrity](container-release-integrity.md) for the OWASP +registry transition, trust boundaries and first-release validation requirements. ## Required signing process diff --git a/docs/release-verification.md b/docs/release-verification.md index d1244df..7bc805e 100644 --- a/docs/release-verification.md +++ b/docs/release-verification.md @@ -1,5 +1,12 @@ # Verifying OpenShield Releases +For the current OWASP-hosted container workflow, see +[container release integrity](container-release-integrity.md), including owner +opt-in, the new GHCR namespace and digest verification. The commands below retain +the historical source-artifact identity for releases from `openshield-org`; +for new OWASP-hosted source releases substitute `OWASP/openshield` in both +`--repo` and `--signer-workflow`. + OpenShield release artifacts are produced only from a GitHub-verified signed annotated tag. GitHub Actions generates a deterministic source archive, a CycloneDX SBOM and SHA-256 checksums, then creates identity-bound Sigstore diff --git a/scripts/release_integrity.py b/scripts/release_integrity.py new file mode 100644 index 0000000..8137ef5 --- /dev/null +++ b/scripts/release_integrity.py @@ -0,0 +1,100 @@ +"""Read-only release preflight and exact registry-digest validation.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +REPOSITORY = "OWASP/openshield" +SHA = re.compile(r"[0-9a-f]{40}") +TAG = re.compile(r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") + + +def command(args: list[str]) -> str: + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=60).stdout.strip() + + +def verify(environment: dict[str, str], run=command) -> dict[str, str]: + tag = environment.get("RELEASE_TAG", "") + commit = environment.get("GITHUB_SHA", "") + expected_tag = environment.get("EXPECTED_TAG_OBJECT", "") + if environment.get("GITHUB_REPOSITORY") != REPOSITORY: + raise ValueError("Publication is restricted to the OpenShield repository") + if not TAG.fullmatch(tag): + raise ValueError("Use a stable vMAJOR.MINOR.PATCH release tag") + if environment.get("GITHUB_EVENT_NAME") != "push" or environment.get("GITHUB_REF") != f"refs/tags/{tag}": + raise ValueError("Publication requires the matching tag-push event") + if not SHA.fullmatch(commit) or environment.get("RELEASE_COMMIT", commit) != commit: + raise ValueError("The requested commit must match the tag event") + if (expected_tag or "RELEASE_COMMIT" in environment) and not SHA.fullmatch(expected_tag): + raise ValueError("Invalid expected annotated-tag object") + + ref = json.loads(run(["gh", "api", f"repos/{REPOSITORY}/git/ref/tags/{tag}"])) + obj = ref.get("object", {}) + tag_object = obj.get("sha", "") + if obj.get("type") != "tag" or not SHA.fullmatch(tag_object): + raise ValueError("A signed annotated tag is required, not a lightweight tag") + if expected_tag and tag_object != expected_tag: + raise ValueError("The tag moved after the source-release gate") + signed = json.loads(run(["gh", "api", f"repos/{REPOSITORY}/git/tags/{tag_object}"])) + verification = signed.get("verification", {}) + if verification.get("verified") is not True or verification.get("reason") != "valid": + raise ValueError("GitHub did not verify the tag signature as valid") + target = signed.get("object", {}) + if signed.get("tag") != tag or target.get("type") != "commit" or target.get("sha") != commit: + raise ValueError("The signed tag must directly identify the event commit") + if run(["git", "rev-parse", "HEAD"]) != commit: + raise ValueError("Checkout differs from the verified commit") + run(["git", "fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]) + # Nonzero exit, including transport/repository errors, prevents publication. + run(["git", "merge-base", "--is-ancestor", commit, "refs/remotes/origin/main"]) + return {"release_tag": tag, "release_commit": commit, "tag_object": tag_object} + + +def registry_digest(values, image: str) -> str: + if not isinstance(values, list) or not all(isinstance(value, str) for value in values): + raise ValueError("RepoDigests must be a list of strings") + prefix = f"{image}@" + matches = {value[len(prefix) :] for value in values if value.startswith(prefix)} + if len(matches) != 1: + raise ValueError("Expected exactly one digest for the published repository") + digest = matches.pop() + if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise ValueError("Invalid registry manifest digest") + return digest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="operation", required=True) + sub.add_parser("verify") + digest_parser = sub.add_parser("digest") + digest_parser.add_argument("--image", required=True) + digest_parser.add_argument("--expect") + args = parser.parse_args() + try: + if args.operation == "verify": + result = verify(dict(os.environ)) + else: + digest = registry_digest(json.load(sys.stdin), args.image) + if args.expect and digest != args.expect: + raise ValueError("Promotion changed the verified digest") + result = {"digest": digest} + if output := os.environ.get("GITHUB_OUTPUT"): + with Path(output).open("a", encoding="utf-8") as handle: + for key, value in result.items(): + handle.write(f"{key}={value}\n") + print(json.dumps(result)) + return 0 + except (ValueError, TypeError, AttributeError, OSError, subprocess.SubprocessError) as exc: + print(f"Release rejected: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_integrity.py b/tests/test_release_integrity.py new file mode 100644 index 0000000..7d51f54 --- /dev/null +++ b/tests/test_release_integrity.py @@ -0,0 +1,334 @@ +"""Offline release tests. No live registry, GitHub mutation or signing keys.""" + +import io +import json +from pathlib import Path +import subprocess + +import pytest +import yaml + +from scripts import release_integrity as gate + +ROOT = Path(__file__).resolve().parents[1] +COMMIT = "a" * 40 +TAG_OBJECT = "b" * 40 +IMAGE = "ghcr.io/owasp/openshield" +DIGEST = "sha256:" + "c" * 64 + + +@pytest.fixture +def environment(): + return { + "GITHUB_REPOSITORY": gate.REPOSITORY, + "GITHUB_EVENT_NAME": "push", + "GITHUB_REF": "refs/tags/v1.2.3", + "GITHUB_SHA": COMMIT, + "RELEASE_TAG": "v1.2.3", + "RELEASE_COMMIT": COMMIT, + "EXPECTED_TAG_OBJECT": TAG_OBJECT, + } + + +class Commands: + def __init__(self): + self.calls = [] + self.ref = {"object": {"type": "tag", "sha": TAG_OBJECT}} + self.signed = { + "tag": "v1.2.3", + "verification": {"verified": True, "reason": "valid"}, + "object": {"type": "commit", "sha": COMMIT}, + } + self.head = COMMIT + self.not_on_main = False + + def __call__(self, args): + self.calls.append(args) + if args[:2] == ["gh", "api"]: + assert len(args) == 3 # Read-only GET, never a publication operation. + return json.dumps(self.ref if "/git/ref/" in args[2] else self.signed) + if args[:2] == ["git", "rev-parse"]: + return self.head + if args[:2] == ["git", "merge-base"] and self.not_on_main: + raise subprocess.CalledProcessError(1, args) + assert args[:2] in (["git", "fetch"], ["git", "merge-base"]) + return "" + + +@pytest.mark.parametrize("container", [False, True]) +def test_verified_source_and_container_gates(environment, container): + if not container: + environment.pop("RELEASE_COMMIT") + environment.pop("EXPECTED_TAG_OBJECT") + run = Commands() + assert gate.verify(environment, run) == { + "release_tag": "v1.2.3", + "release_commit": COMMIT, + "tag_object": TAG_OBJECT, + } + assert run.calls[-1] == ["git", "merge-base", "--is-ancestor", COMMIT, "refs/remotes/origin/main"] + + +@pytest.mark.parametrize( + "key,value", + [ + ("GITHUB_REPOSITORY", "someone/fork"), + ("GITHUB_REPOSITORY", "openshield-org/openshield"), + ("GITHUB_EVENT_NAME", "workflow_dispatch"), + ("GITHUB_EVENT_NAME", "pull_request"), + ("GITHUB_REF", "refs/heads/main"), + ("GITHUB_REF", "refs/tags/v1.2.4"), + ("RELEASE_TAG", "v1.2.3; echo injected"), + ("RELEASE_TAG", "v1.2.3\nextra=value"), + ("RELEASE_TAG", "v01.2.3"), + ("RELEASE_TAG", "v1.2.3-rc.1"), + ("RELEASE_TAG", "v1"), + ("GITHUB_SHA", "not-a-sha"), + ("RELEASE_COMMIT", "d" * 40), + ("EXPECTED_TAG_OBJECT", "bad"), + ("EXPECTED_TAG_OBJECT", ""), + ], +) +def test_invalid_context_rejected_before_commands(environment, key, value): + environment[key] = value + run = Commands() + with pytest.raises(ValueError): + gate.verify(environment, run) + assert run.calls == [] + + +@pytest.mark.parametrize( + "case", + [ + "lightweight", + "moved", + "unsigned", + "invalid_reason", + "truthy_string", + "wrong_commit", + "nested_tag", + "wrong_tag", + "wrong_checkout", + ], +) +def test_invalid_evidence_rejected(environment, case): + run = Commands() + if case == "lightweight": + run.ref["object"]["type"] = "commit" + elif case == "moved": + run.ref["object"]["sha"] = "d" * 40 + elif case == "unsigned": + run.signed["verification"]["verified"] = False + elif case == "invalid_reason": + run.signed["verification"]["reason"] = "expired_key" + elif case == "truthy_string": + run.signed["verification"]["verified"] = "true" + elif case == "wrong_commit": + run.signed["object"]["sha"] = "e" * 40 + elif case == "nested_tag": + run.signed["object"]["type"] = "tag" + elif case == "wrong_tag": + run.signed["tag"] = "v1.2.4" + else: + run.head = "f" * 40 + with pytest.raises(ValueError): + gate.verify(environment, run) + assert all(args[:2] != ["git", "merge-base"] for args in run.calls) + + +def test_signed_but_unmerged_commit_rejected(environment): + run = Commands() + run.not_on_main = True + with pytest.raises(subprocess.CalledProcessError): + gate.verify(environment, run) + + +@pytest.mark.parametrize("payload", ["not json", "null", "[]", "{}"]) +def test_malformed_api_response_writes_no_outputs(environment, monkeypatch, tmp_path, payload): + for key, value in environment.items(): + monkeypatch.setenv(key, value) + output = tmp_path / "outputs" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setattr("sys.argv", ["release_integrity.py", "verify"]) + real_verify = gate.verify + monkeypatch.setattr(gate, "verify", lambda env: real_verify(env, lambda _: payload)) + assert gate.main() == 1 + assert not output.exists() + + +def test_transport_failure_writes_no_outputs(monkeypatch, tmp_path): + output = tmp_path / "outputs" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setattr("sys.argv", ["release_integrity.py", "verify"]) + + def unavailable(_): + raise subprocess.TimeoutExpired("gh api", 60) + + monkeypatch.setattr(gate, "verify", unavailable) + assert gate.main() == 1 + assert not output.exists() + + +@pytest.mark.parametrize("on_main", [True, False]) +def test_ancestry_against_real_local_git_history(tmp_path, environment, on_main): + repo = tmp_path / "repo" + remote = tmp_path / "remote.git" + + def git(*args): + result = subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True) + return result.stdout.strip() + + subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True) + subprocess.run(["git", "init", "-b", "main", str(repo)], check=True, capture_output=True) + git("config", "user.name", "Offline test") + git("config", "user.email", "test@example.invalid") + git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "base fixture") + base = git("rev-parse", "HEAD") + git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "advance main fixture") + git("remote", "add", "origin", str(remote)) + git("push", "origin", "main") # local filesystem remote, never GitHub + git("checkout", "--detach", base) + if not on_main: + git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "unmerged fixture") + commit = git("rev-parse", "HEAD") + environment.update(GITHUB_SHA=commit, RELEASE_COMMIT=commit) + metadata = Commands() + metadata.signed["object"]["sha"] = commit + + def run(args): + return metadata(args) if args[0] == "gh" else git(*args[1:]) + + if on_main: + assert gate.verify(environment, run)["release_commit"] == base + else: + with pytest.raises(subprocess.CalledProcessError): + gate.verify(environment, run) + + +@pytest.mark.parametrize( + "values", + [ + None, + {}, + [], + [123], + [f"other/image@{DIGEST}"], + [f"{IMAGE}@sha256:bad"], + [f"{IMAGE}@{DIGEST}", f"{IMAGE}@sha256:{'d' * 64}"], + [f"{IMAGE}.attacker.invalid@{DIGEST}"], + ], +) +def test_missing_ambiguous_or_foreign_digest_rejected(values): + with pytest.raises(ValueError): + gate.registry_digest(values, IMAGE) + + +def test_exact_repository_digest_selected(): + assert gate.registry_digest([f"other/image@{DIGEST}", f"{IMAGE}@{DIGEST}"], IMAGE) == DIGEST + + +@pytest.mark.parametrize("expected,success", [(DIGEST, True), ("sha256:" + "e" * 64, False)]) +def test_promotion_digest_checked_before_outputs(monkeypatch, tmp_path, expected, success): + output = tmp_path / "outputs" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setattr("sys.argv", ["release_integrity.py", "digest", "--image", IMAGE, "--expect", expected]) + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps([f"{IMAGE}@{DIGEST}"]))) + assert gate.main() == (0 if success else 1) + assert output.exists() == success + if success: + assert output.read_text() == f"digest={DIGEST}\n" + + +def workflows(): + return tuple( + yaml.safe_load((ROOT / ".github/workflows" / name).read_text()) for name in ("release.yml", "docker.yml") + ) + + +def test_no_independent_container_publication_trigger(): + source, container = workflows() + assert set(container.get("on", container.get(True))) == {"workflow_call"} + call = source["jobs"]["container"] + assert call["needs"] == "release" + assert call["uses"] == "./.github/workflows/docker.yml" + assert "if" not in call # default success(); never always() after failed source release + assert call["with"]["release_commit"] == "${{ needs.release.outputs.release_commit }}" + assert call["with"]["tag_object"] == "${{ needs.release.outputs.tag_object }}" + assert "vars.CONTAINER_RELEASE_ENABLED == 'true'" in container["jobs"]["docker"]["if"] + + +def test_scan_and_attestations_gate_version_publication(): + _, workflow = workflows() + steps = workflow["jobs"]["docker"]["steps"] + names = [step["name"] for step in steps] + order = [ + "Reverify signed source before build", + "Build once and save the exact image", + "Scan saved image before registry publication", + "Generate image SBOM from scanned archive", + "Reverify tag before any registry write", + "Log in to GitHub Container Registry", + "Push scanned candidate and capture registry digest", + "Attest image provenance by digest", + "Attest image SBOM by the same digest", + "Verify both attestations before version promotion", + "Promote verified image without rebuilding", + ] + assert [names.index(name) for name in order] == sorted(names.index(name) for name in order) + for step in steps[:-1]: + assert not step.get("continue-on-error", False) + assert "if" not in step # every publication step fails closed + runs = "\n".join(step.get("run", "") for step in steps) + assert runs.count("docker build ") == 1 + assert 'syft "docker-archive:$RUNNER_TEMP/image.tar"' in runs + assert '--image "$IMAGE_NAME" --expect "$DIGEST"' in runs + scan = next(step for step in steps if step["name"] == order[2]) + assert scan["with"]["input"] == "${{ runner.temp }}/image.tar" + assert scan["with"]["exit-code"] == "1" + assert scan["with"]["severity"] == "CRITICAL,HIGH" + + +def test_attestations_bind_digest_workflow_and_source(): + _, workflow = workflows() + steps = workflow["jobs"]["docker"]["steps"] + attest = [step for step in steps if step.get("uses", "").startswith("actions/attest")] + assert len(attest) == 2 + for step in attest: + assert step["with"]["subject-digest"] == "${{ steps.push.outputs.digest }}" + assert step["with"]["subject-name"] == "${{ env.IMAGE_NAME }}" + assert step["with"]["push-to-registry"] is True + verify = next(step["run"] for step in steps if step["name"] == "Verify both attestations before version promotion") + assert verify.count('gh attestation verify "oci://${IMAGE_NAME}@${DIGEST}"') == 2 + assert verify.count('--source-digest "$RELEASE_COMMIT" --source-ref "refs/tags/$RELEASE_TAG"') == 2 + assert verify.count('--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/docker.yml"') == 2 + assert "--predicate-type https://cyclonedx.org/bom" in verify + + +def test_actions_pinned_and_checkout_token_not_persisted(): + _, workflow = workflows() + steps = workflow["jobs"]["docker"]["steps"] + for step in steps: + if "uses" in step: + assert gate.SHA.fullmatch(step["uses"].split("@", 1)[1]) + assert steps[0]["with"]["ref"] == "${{ inputs.release_commit }}" + assert steps[0]["with"]["persist-credentials"] is False + + +def test_workflow_shell_blocks_parse_without_execution(): + source, container = workflows() + for workflow in (source, container): + for job in workflow["jobs"].values(): + for step in job.get("steps", []): + if "run" in step: + result = subprocess.run(["bash", "-n"], input=step["run"], text=True, capture_output=True) + assert result.returncode == 0, f"{step['name']}: {result.stderr}" + + +def test_source_archive_uses_verified_commit_and_rechecks_before_publish(): + source, _ = workflows() + steps = source["jobs"]["release"]["steps"] + build = next(step for step in steps if step["name"] == "Build deterministic release artifacts") + assert build["env"]["RELEASE_COMMIT"] == "${{ steps.verify.outputs.release_commit }}" + assert '"$RELEASE_COMMIT"' in build["run"] + names = [step["name"] for step in steps] + assert names.index("Reverify source before release publication") < names.index("Publish release") diff --git a/tests/test_signed_release_workflow.py b/tests/test_signed_release_workflow.py index f296042..4af5b1e 100644 --- a/tests/test_signed_release_workflow.py +++ b/tests/test_signed_release_workflow.py @@ -21,10 +21,10 @@ def test_release_workflow_has_keyless_attestation_permissions(): def test_release_requires_verified_annotated_tag(): - source = WORKFLOW.read_text(encoding="utf-8") - assert 'object_type" != "tag"' in source - assert ".verification.verified" in source - assert 'verified" != "true"' in source + steps = _workflow()["jobs"]["release"]["steps"] + verification = next(step for step in steps if step.get("id") == "verify") + assert verification["run"] == "python3 scripts/release_integrity.py verify" + assert steps.index(verification) < next(i for i, step in enumerate(steps) if step["name"] == "Install Syft") def test_release_attests_every_distributed_manifest():