From e56e327b440fd492ac197b70aaf891ba352af1ff Mon Sep 17 00:00:00 2001 From: Chase Date: Fri, 30 Oct 2026 13:16:05 +0100 Subject: [PATCH] Merge pull request #6 from Blockstream/security-hardening publish.yml: security fixes for explicit tags, artifact hand-off and OCI checks --- .github/dependabot.yml | 3 + .github/workflows/lint.yml | 4 +- .github/workflows/publish.yml | 170 +++++++++++++++++++++++++++------- README.md | 9 +- 4 files changed, 146 insertions(+), 40 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fecd44d..ecfd169 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,9 @@ updates: directory: / schedule: interval: weekly + # Let a new action release age before it reaches the workflow that signs images. + cooldown: + default-days: 7 groups: actions: patterns: ["*"] diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0746e9b..8261bbb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,9 +19,9 @@ jobs: persist-credentials: false - name: actionlint - run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -color + run: docker run --rm --network none -v "$PWD:/repo:ro" -w /repo rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 -color - name: zizmor env: GH_TOKEN: ${{ github.token }} - run: pipx run zizmor==1.5.2 --min-severity medium .github/workflows + run: pipx run zizmor==1.31.1 --min-severity medium .github diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8aab6e1..b462be2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -120,7 +120,6 @@ jobs: outputs: matrix: ${{ steps.plan.outputs.matrix }} platforms: ${{ steps.plan.outputs.platforms }} - artifact-prefix: ${{ steps.plan.outputs.artifact-prefix }} tags: ${{ steps.plan.outputs.tags }} moving-tags: ${{ steps.plan.outputs.moving-tags }} steps: @@ -128,6 +127,7 @@ jobs: id: plan env: DEPLOY_ENVIRONMENT: ${{ inputs.environment }} + BUILD_ENVIRONMENT: ${{ inputs.build-environment }} CALLER_SHA: ${{ github.sha }} IMAGE_SUFFIX: ${{ inputs.image-name }} TAGS: ${{ inputs.tags }} @@ -148,6 +148,11 @@ jobs: environment = os.environ["DEPLOY_ENVIRONMENT"] if not re.fullmatch(r"[a-z][a-z0-9-]{0,62}", environment): raise SystemExit("environment must be a lowercase environment name") + # The build runs caller code; keep the publish environment's secrets out of it. + build_environment = os.environ["BUILD_ENVIRONMENT"] + if build_environment and (build_environment.lower() == environment + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,254}", build_environment)): + raise SystemExit("build-environment must be a valid name other than environment") if not re.fullmatch(r"[0-9a-f]{40}", os.environ["CALLER_SHA"]): raise SystemExit("Caller SHA must be a full commit SHA") suffix = os.environ["IMAGE_SUFFIX"] @@ -161,6 +166,10 @@ jobs: raise SystemExit("Invalid explicit tag") if os.environ["TAGS"] and not tags: raise SystemExit("tags must contain at least one tag when non-empty") + # Derived tags carry the environment (prod- only from production). Any + # environment may pass explicit tags, so they must not look derived. + if any(re.fullmatch(r"(?i)prod-.*|[0-9a-f]+-[0-9]+", tag) for tag in tags): + raise SystemExit("Explicit tags must not use the prod- prefix or the derived - form") if not set(moving).issubset(tags): raise SystemExit("moving-tags must be a subset of tags") platforms = lines("PLATFORMS") @@ -185,12 +194,11 @@ jobs: for key, value in {"matrix": matrix, "platforms": platforms, "tags": tags, "moving-tags": moving}.items(): print(f"{key}={json.dumps(value, separators=(',', ':'))}", file=output) - print(f"artifact-prefix={prefix}", file=output) PY build: needs: prepare - # Forks skip instead of failing at authentication. + # Forked repositories skip instead of failing at authentication. if: contains(fromJSON('["Blockstream", "BlockstreamResearch"]'), github.repository_owner) strategy: matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} @@ -200,8 +208,12 @@ jobs: environment: ${{ inputs.build-environment || format('{0}-build', inputs.environment) }} permissions: contents: read + # One output per matrix leg; a leg leaves the other outputs empty. publish + # downloads by ID, because any job in the run can replace a named artifact. outputs: - artifact-id: ${{ steps.upload.outputs.artifact-id }} + artifact-id: ${{ steps.artifact.outputs.default }} + artifact-id-amd64: ${{ steps.artifact.outputs.linux-amd64 }} + artifact-id-arm64: ${{ steps.artifact.outputs.linux-arm64 }} steps: - name: Check out caller at the triggering commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -211,6 +223,9 @@ jobs: - name: Set up Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + with: + # BuildKit produces the image that publish signs; do not follow a moving tag. + driver-opts: image=moby/buildkit:v0.34.1@sha256:99368f3e46177c6405b9740e4461d91cee004128daeecca78d4ceac50ec1da5c - name: Build OCI archive env: @@ -321,6 +336,18 @@ jobs: retention-days: 1 compression-level: 0 + - name: Record the artifact ID for this platform + id: artifact + env: + ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} + BUILD_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + [[ "$ARTIFACT_ID" =~ ^[0-9]+$ ]] || { echo 'Invalid artifact ID' >&2; exit 1; } + key=default + [[ -z "$BUILD_PLATFORM" ]] || key="${BUILD_PLATFORM//\//-}" + echo "$key=$ARTIFACT_ID" >> "$GITHUB_OUTPUT" + publish: needs: [prepare, build] runs-on: ubuntu-24.04 @@ -339,6 +366,8 @@ jobs: id: destination env: BUILD_ARTIFACT_ID: ${{ needs.build.outputs.artifact-id }} + AMD64_ARTIFACT_ID: ${{ needs.build.outputs.artifact-id-amd64 }} + ARM64_ARTIFACT_ID: ${{ needs.build.outputs.artifact-id-arm64 }} PLATFORMS_JSON: ${{ needs.prepare.outputs.platforms }} CALLER_SHA: ${{ github.sha }} DEPLOY_ENVIRONMENT: ${{ inputs.environment }} @@ -358,8 +387,11 @@ jobs: from pathlib import Path import re - if not json.loads(os.environ["PLATFORMS_JSON"]) and not re.fullmatch(r"[0-9]+", os.environ["BUILD_ARTIFACT_ID"]): - raise SystemExit("Missing or invalid build artifact ID") + platforms = json.loads(os.environ["PLATFORMS_JSON"]) + artifact_ids = {"linux/amd64": "AMD64_ARTIFACT_ID", "linux/arm64": "ARM64_ARTIFACT_ID"} + for key in [artifact_ids[platform] for platform in platforms] or ["BUILD_ARTIFACT_ID"]: + if not re.fullmatch(r"[0-9]+", os.environ[key]): + raise SystemExit("Missing or invalid build artifact ID") if not re.fullmatch(r"[0-9a-f]{40}", os.environ["CALLER_SHA"]): raise SystemExit("Caller SHA must be a full commit SHA") for key, label in [("PROVIDER", "workload-identity-provider"), @@ -415,15 +447,17 @@ jobs: if: contains(fromJSON(needs.prepare.outputs.platforms), 'linux/amd64') uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: ${{ needs.prepare.outputs.artifact-prefix }}-linux-amd64 + artifact-ids: ${{ needs.build.outputs.artifact-id-amd64 }} path: ${{ runner.temp }}/incoming-image/linux-amd64 + merge-multiple: true - name: Download the arm64 artifact if: contains(fromJSON(needs.prepare.outputs.platforms), 'linux/arm64') uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: ${{ needs.prepare.outputs.artifact-prefix }}-linux-arm64 + artifact-ids: ${{ needs.build.outputs.artifact-id-arm64 }} path: ${{ runner.temp }}/incoming-image/linux-arm64 + merge-multiple: true - name: Unpack and merge OCI data without executing caller content env: @@ -458,6 +492,8 @@ jobs: total += member.size if member.size < 0 or total > 10 * 1024**3: raise SystemExit("OCI archive exceeds 10 GiB limit") + if not is_blob and member.size > 1024**2: + raise SystemExit("OCI layout metadata is too large") target = root / name target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) digest = hashlib.sha256() @@ -473,21 +509,87 @@ jobs: if not (root / "index.json").is_file() or not (root / "oci-layout").is_file(): raise SystemExit("Missing OCI layout metadata") - def read_descriptor(root, descriptor): + MANIFEST_TYPES = ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") + DESCRIPTOR = {"mediaType", "digest", "size", "urls", "annotations", "platform", "artifactType", "data"} + FIELDS = { + "index": {"schemaVersion", "mediaType", "manifests", "annotations", "artifactType", "subject"}, + "manifest": {"schemaVersion", "mediaType", "config", "layers", "annotations", "artifactType", "subject"}, + "config": {"architecture", "os", "os.version", "os.features", "variant", "config", "rootfs", + "history", "created", "author"}, + } + + def strict_json(data): + def pairs(items): + keys = [key for key, _ in items] + if len(keys) != len(set(keys)): + raise SystemExit("Duplicate key in OCI JSON") + return dict(items) + return json.loads(data, object_pairs_hook=pairs) + + def canonical(value, fields): + # crane decodes with Go, which matches keys case-insensitively (with + # Unicode folding). Reject keys that Go could read as another field. + if not isinstance(value, dict): + raise SystemExit("Invalid OCI JSON object") + folded = [key.casefold() for key in value] + known = {field.casefold(): field for field in fields} + if len(folded) != len(set(folded)) or any( + fold in known and key != known[fold] for key, fold in zip(value, folded)): + raise SystemExit("Non-canonical OCI JSON key") + return value + + def local_blob(root, descriptor): + # Blob names were checked against their content during unpack. + canonical(descriptor, DESCRIPTOR) digest = descriptor.get("digest", "") - if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest) or descriptor.get("urls"): + size = descriptor.get("size") + if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest) \ + or "urls" in descriptor or "data" in descriptor: raise SystemExit("Invalid OCI descriptor") blob = root / "blobs/sha256" / digest.split(":")[1] - data = blob.read_bytes() - if len(data) != descriptor.get("size"): - raise SystemExit("OCI descriptor size mismatch") - return json.loads(data) + if type(size) is not int or not blob.is_file() or blob.stat().st_size != size: + raise SystemExit("OCI blob is missing or has the wrong size") + return blob + + def read_descriptor(root, descriptor, kind): + # Manifests and configs are small JSON; never read a layer into memory. + blob = local_blob(root, descriptor) + if descriptor["size"] > 4 * 1024**2: + raise SystemExit("OCI manifest or config is too large") + return canonical(strict_json(blob.read_bytes()), FIELDS[kind]) + + def single_image(root): + # Every blob the image references must be in this artifact. Otherwise + # publish could sign a digest that only exists in the registry. + if strict_json((root / "oci-layout").read_bytes()) != {"imageLayoutVersion": "1.0.0"}: + raise SystemExit("Unsupported OCI layout version") + index = canonical(strict_json((root / "index.json").read_bytes()), FIELDS["index"]) + if index.get("schemaVersion") != 2 or not isinstance(index.get("manifests"), list) \ + or len(index["manifests"]) != 1: + raise SystemExit("Each OCI archive must contain exactly one image manifest") + descriptor = index["manifests"][0] + if canonical(descriptor, DESCRIPTOR).get("mediaType") not in MANIFEST_TYPES: + raise SystemExit("Expected an image manifest") + manifest = read_descriptor(root, descriptor, "manifest") + # The blob itself must be the image manifest its descriptor claims, not an index. + if manifest.get("schemaVersion") != 2 or manifest.get("mediaType") != descriptor["mediaType"] \ + or any(key.casefold() == "manifests" for key in manifest): + raise SystemExit("OCI manifest does not match its descriptor") + config = read_descriptor(root, manifest.get("config"), "config") + layers = manifest.get("layers") + if not isinstance(layers, list): + raise SystemExit("Invalid image manifest") + for layer in layers: + local_blob(root, layer) + return descriptor, config temp = Path(os.environ["RUNNER_TEMP"]) root = temp / "publish-oci" platforms = json.loads(os.environ["PLATFORMS_JSON"]) if not platforms: unpack(temp / "incoming-image/build-image.oci.tar", root) + single_image(root) else: root.mkdir(mode=0o700) blobs = root / "blobs/sha256" @@ -499,17 +601,7 @@ jobs: name = platform.replace("/", "-") source = temp / ("unpacked-" + name) unpack(temp / "incoming-image" / name / "build-image.oci.tar", source) - if json.loads((source / "oci-layout").read_bytes()) != {"imageLayoutVersion": "1.0.0"}: - raise SystemExit("Unsupported OCI layout version") - index = json.loads((source / "index.json").read_bytes()) - if index.get("schemaVersion") != 2 or len(index.get("manifests", [])) != 1: - raise SystemExit("Each platform archive must contain exactly one image manifest") - descriptor = index["manifests"][0] - if descriptor.get("mediaType") not in ("application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.v2+json"): - raise SystemExit("Expected a platform image manifest") - manifest = read_descriptor(source, descriptor) - config = read_descriptor(source, manifest["config"]) + descriptor, config = single_image(source) operating_system, architecture = platform.split("/") if config.get("os") != operating_system or config.get("architecture") != architecture: raise SystemExit("OCI image does not match the requested platform") @@ -529,16 +621,21 @@ jobs: PY - name: Install crane with a pinned archive checksum - # This maintained release installer verifies the digest, and does not - # perform the unrelated GHCR login in imjasonh/setup-crane. - uses: jaxxstorm/action-install-gh-release@25e24d2d23ae098373794ef1d6faecb48ee52da8 # v3.0.0 - with: - repo: google/go-containerregistry - tag: v0.22.1 - platform: linux - arch: x86_64 - digest: 0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0 - cache: disable + # A plain download keeps third-party action code out of the job that + # holds the OIDC token. The checksum pins the release archive. + env: + CRANE_VERSION: v0.22.1 + CRANE_SHA256: 0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0 + run: | + set -euo pipefail + archive="$RUNNER_TEMP/crane.tar.gz" + curl --proto '=https' --tlsv1.2 -fsSL -o "$archive" \ + "https://github.com/google/go-containerregistry/releases/download/$CRANE_VERSION/go-containerregistry_Linux_x86_64.tar.gz" + echo "$CRANE_SHA256 $archive" | sha256sum --check --strict - + mkdir -m 700 "$RUNNER_TEMP/crane-bin" + tar -xzf "$archive" -C "$RUNNER_TEMP/crane-bin" --no-same-owner --no-same-permissions crane + chmod 0755 "$RUNNER_TEMP/crane-bin/crane" + echo "$RUNNER_TEMP/crane-bin" >> "$GITHUB_PATH" - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -610,7 +707,8 @@ jobs: if result.returncode == 0: raise SystemExit("Registry digest lookup failed; retry before publishing") error = result.stderr.strip().splitlines()[-1:] or [""] - error = error[0] + # Drop the reference, request URL and quoted text: all can contain the tag. + error = re.sub(r'https?://\S*|"[^"]*"', "", error[0].replace(f"{image}:{tag}", "")) codes = re.findall(r"\b([A-Z][A-Z_]+):", error) absent = bool(codes) and set(codes) <= {"MANIFEST_UNKNOWN", "NAME_UNKNOWN"} # Accept an explicit HTTP 404, never a generic transport or auth error. diff --git a/README.md b/README.md index 09cfe16..c2505e5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ Three stages: The tag is `-`, prefixed `prod-` when `environment` is `production`. Non-empty `tags` replaces that derived tag -entirely, including the production prefix. +entirely, including the production prefix. Explicit tags must not start with +`prod-` (in any case) or have the derived `-` form: every +environment can pass `tags`, so only derived tags may look like derived tags. ### Caller @@ -84,7 +86,7 @@ jobs: | `platforms` | empty | Newline-separated `linux/amd64` and/or `linux/arm64`; empty preserves the original single build | | `build-args` | empty | Newline-separated literal `KEY=VALUE` pairs; public values only | | `environment` | (required) | Environment of the publish job (deployment branch policy) | -| `build-environment` | `-build` | Environment of the build job; holds build variables and secrets | +| `build-environment` | `-build` | Environment of the build job; holds build variables and secrets. Must differ from `environment` | | `context` | `.` | Build context within the checkout | | `dockerfile` | `Dockerfile` | Dockerfile within the checkout | | `target` | | Dockerfile target | @@ -241,3 +243,6 @@ cosign verify \ Always check `--certificate-github-workflow-repository`: this workflow is public, so the identity alone does not say which repository built the image. +A tag does not prove which branch built an image. To accept only images built +from one ref, also check it, for example +`--certificate-github-workflow-ref refs/heads/main`.