From e20da1ce6e994d6837777a78b519bff2480fcb4b Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 13:01:29 +0000 Subject: [PATCH 01/12] Enforce pinned action policy in privileged release jobs Co-authored-by: c1-squire-dev[bot] --- .github/workflows/release.yaml | 88 +++++++++++++++------------- Makefile | 1 + docs/release-workflow.md | 59 +++++++++++++++++++ scripts/check-release-actions.py | 88 ++++++++++++++++++++++++++++ scripts/test-release-actions.py | 99 ++++++++++++++++++++++++++++++++ tools/privileged-actions.txt | 8 +++ 6 files changed, 303 insertions(+), 40 deletions(-) create mode 100755 scripts/check-release-actions.py create mode 100755 scripts/test-release-actions.py create mode 100644 tools/privileged-actions.txt diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 729dd7f..53d593b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -422,7 +422,7 @@ jobs: tools: go,gon,syft,goreleaser - name: Checkout caller repo - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: path: _caller repository: ${{ github.event.repository.full_name }} @@ -496,13 +496,13 @@ jobs: envsubst < templates/.slsa-provenance-predicate-template.json.tmpl | tee "${GENERATED_DIR}/predicate.json" - name: Import Keychain Certs - uses: apple-actions/import-codesign-certs@v1 + uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1 with: p12-file-base64: ${{ secrets.APPLE_SIGNING_KEY_P12 }} p12-password: ${{ secrets.APPLE_SIGNING_KEY_P12_PASSWORD }} - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5 with: role-to-assume: arn:aws:iam::025044153841:role/${{ steps.role-names.outputs.gha_artifacts_role_name }} aws-region: us-west-2 @@ -745,7 +745,7 @@ jobs: run: git config --system core.longpaths true - name: Checkout caller repo - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: path: _caller repository: ${{ github.event.repository.full_name }} @@ -942,7 +942,7 @@ jobs: ls "${CALLER_DIST}"/*.sbom.sigstore.json - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5 with: role-to-assume: arn:aws:iam::025044153841:role/${{ steps.role-names.outputs.gha_artifacts_role_name }} aws-region: us-west-2 @@ -1024,7 +1024,7 @@ jobs: tools: go,goreleaser - name: Checkout caller repo - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: path: _caller repository: ${{ github.event.repository.full_name }} @@ -1146,7 +1146,7 @@ jobs: - name: Set up QEMU if: inputs.docker == true || inputs.lambda == true - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 with: platforms: linux/arm64 image: tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 @@ -1157,14 +1157,14 @@ jobs: - name: Configure Public ECR AWS credentials via OIDC if: inputs.docker == true - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5 with: role-to-assume: arn:aws:iam::025044153841:role/${{ steps.role-names.outputs.gha_artifacts_role_name }} aws-region: us-east-1 - name: Login to Public ECR if: inputs.docker == true - uses: aws-actions/amazon-ecr-login@v2 + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2 with: registry-type: public @@ -1196,14 +1196,14 @@ jobs: - name: Configure Lambda ECR AWS credentials via OIDC if: inputs.lambda == true - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5 with: role-to-assume: "arn:aws:iam::168442440833:role/${{ steps.role-names.outputs.ecr_push_role_name }}" aws-region: us-west-2 - name: Login to Lambda ECR if: inputs.lambda == true - uses: aws-actions/amazon-ecr-login@v2 + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2 - name: Run GoReleaser for Lambda if: inputs.lambda == true @@ -1361,7 +1361,7 @@ jobs: cosign-release: v2.5.2 - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5 with: role-to-assume: arn:aws:iam::025044153841:role/${{ steps.role-names.outputs.gha_artifacts_role_name }} aws-region: us-west-2 @@ -1582,7 +1582,7 @@ jobs: tools: go - name: Checkout connector repo - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: path: _connector repository: ${{ github.event.repository.full_name }} @@ -1618,7 +1618,7 @@ jobs: - name: Get GitHub OIDC token for registry API id: registry-oidc - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const token = await core.getIDToken('connector-registry') @@ -1752,14 +1752,14 @@ jobs: # anonymous rate limit, which shared runner addresses exhaust. - name: Configure Public ECR AWS credentials via OIDC if: inputs.docker == true - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5 with: role-to-assume: arn:aws:iam::025044153841:role/GHA-PublicECRPull aws-region: us-east-1 - name: Login to Public ECR if: inputs.docker == true - uses: aws-actions/amazon-ecr-login@v2 + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2 with: registry-type: public @@ -1849,27 +1849,35 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Send Datadog event on failure - uses: masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185 # v1 - with: - api-key: ${{ secrets.DATADOG_API_KEY }} - api-url: https://us3.datadoghq.com/ - events: | - - title: "Baton Connector Release Failed" - text: | - %%% - # `${{ github.event.repository.name }}`:`${{ inputs.tag }}` Release Failed - ## Error - [View Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - ```${{ steps.collect-ann.outputs.annotations }}``` - ## Details - * **Repository:** `${{ github.event.repository.full_name }}` - * **Release Tag:** `${{ inputs.tag }}` - * **Workflow:** `baton-connector-release` - * **Action Run ID:** `${{ github.run_id }}` - %%% - alert_type: "error" - host: ${{ github.repository_owner }} - tags: - - "github_repository:${{ github.event.repository.full_name }}" - - "github_release_tag:${{ inputs.tag }}" - - "github_workflow:baton-connector-release" + continue-on-error: true # reporting must not obscure the release failure + env: + DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }} + REPOSITORY: ${{ github.repository }} + REPOSITORY_NAME: ${{ github.event.repository.name }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + RUN_ID: ${{ github.run_id }} + RELEASE_TAG: ${{ inputs.tag }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ANNOTATIONS: ${{ steps.collect-ann.outputs.annotations }} + run: | + set -euo pipefail + jq -n --arg repo "$REPOSITORY" --arg tag "$RELEASE_TAG" \ + --arg url "$RUN_URL" --arg annotations "$ANNOTATIONS" \ + --arg name "$REPOSITORY_NAME" --arg owner "$REPOSITORY_OWNER" --arg run "$RUN_ID" \ + '{title: "Baton Connector Release Failed", + text: ("%%%\n# `" + $name + "`:`" + $tag + "` Release Failed\n## Error\n[View Workflow Run](" + $url + ")\n```" + $annotations + "```\n## Details\n* **Repository:** `" + $repo + "`\n* **Release Tag:** `" + $tag + "`\n* **Workflow:** `baton-connector-release`\n* **Action Run ID:** `" + $run + "`\n%%%"), + alert_type: "error", host: $owner, + tags: [("github_repository:" + $repo), ("github_release_tag:" + $tag), + "github_workflow:baton-connector-release"]}' > "$RUNNER_TEMP/release-failure-event.json" + curl --fail-with-body --silent --show-error \ + -H "DD-API-KEY: $DATADOG_API_KEY" -H "Content-Type: application/json" \ + --data-binary "@$RUNNER_TEMP/release-failure-event.json" \ + https://api.us3.datadoghq.com/api/v1/events || true + jq -n --arg repo "$REPOSITORY" --argjson now "$(date +%s)" \ + '{series: [{metric: "baton.connector.release.failure", type: "count", + points: [[$now, 1]], tags: [("github_repository:" + $repo)]}]}' \ + > "$RUNNER_TEMP/release-failure-metric.json" + curl --fail-with-body --silent --show-error \ + -H "DD-API-KEY: $DATADOG_API_KEY" -H "Content-Type: application/json" \ + --data-binary "@$RUNNER_TEMP/release-failure-metric.json" \ + https://api.us3.datadoghq.com/api/v1/series || true diff --git a/Makefile b/Makefile index 39dbefd..56d46e7 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ test-scripts: bash scripts/test-derive-iam-role-name.sh bash scripts/test-normalize-release-options.sh python3 scripts/test-release-config-templates.py + python3 scripts/test-release-actions.py python3 scripts/test-check-image-locks.py python3 scripts/test-windows-pre-build-script.py python3 scripts/test-safe-relative-path.py diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 2d60455..cb63206 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -276,6 +276,65 @@ Tool-using jobs set `GOTOOLCHAIN=local` at job scope. The binaries job also sets `GOSUMDB=sum.golang.org` at job scope. Cosign and bundled-buildx/QEMU setup run after trust/tool bootstrap and before their signing or AWS credentials. +### Privileged Action Allowlist + +`tools/privileged-actions.txt` is the reviewed third-party action repository +allowlist. First-party `actions/*` actions are allowed separately, including +the App-token mint action; every remote action must use a full 40-character +commit SHA. A version comment is informational, not the execution reference. +`scripts/check-release-actions.py`, exercised by `make test-scripts` in repository +CI, checks every job with `id-token: write` or a secret reference, plus the +explicit credential-bearing job inventory. It also checks the implementations +of the two permitted local composite actions so they cannot hide a new remote +action. Unknown local paths and container actions fail closed. + +The local guard and locked-tools actions come from the immutable `_workflows` +checkout at `job.workflow_sha`; they are not third-party downloads. The remaining +action inventory is below (counts include repeated conditional steps): + +| Job | Action | Class | Count | Why it remains | +| --- | --- | --- | --- | --- | +| validate-inputs | actions/checkout | First-party | 1 | Fetch the pinned workflow implementation. | +| validate-inputs | ca-trust-guard (local) | Local | 1 | Enforce runner trust before validation. | +| goreleaser-binaries | actions/checkout | First-party | 2 | Fetch pinned workflow and exact caller source. | +| goreleaser-binaries | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | +| goreleaser-binaries | sigstore/cosign-installer | Third-party | 1 | Bootstrap the pinned signing tool. | +| goreleaser-binaries | apple-actions/import-codesign-certs | Third-party | 1 | Import the Apple signing identity into the temporary keychain. | +| goreleaser-binaries | aws-actions/configure-aws-credentials | Third-party | 1 | Exchange job OIDC for the artifact publishing role. | +| goreleaser-windows | actions/checkout | First-party | 2 | Fetch pinned workflow and exact caller source. | +| goreleaser-windows | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | +| goreleaser-windows | sigstore/cosign-installer | Third-party | 1 | Bootstrap the pinned signing tool. | +| goreleaser-windows | aws-actions/configure-aws-credentials | Third-party | 1 | Exchange job OIDC for the artifact publishing role. | +| goreleaser-docker | actions/checkout | First-party | 2 | Fetch pinned workflow and exact caller source. | +| goreleaser-docker | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | +| goreleaser-docker | sigstore/cosign-installer | Third-party | 1 | Bootstrap the pinned signing tool. | +| goreleaser-docker | docker/setup-qemu-action | Third-party | 1 | Enable arm64 builds with a digest-pinned binfmt image. | +| goreleaser-docker | aws-actions/configure-aws-credentials | Third-party | 2 | Assume the separate public and Lambda publishing roles. | +| goreleaser-docker | aws-actions/amazon-ecr-login | Third-party | 2 | Authenticate to public and private ECR. | +| publish-release-manifest | actions/checkout | First-party | 1 | Fetch the pinned workflow implementation. | +| publish-release-manifest | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | +| publish-release-manifest | sigstore/cosign-installer | Third-party | 1 | Bootstrap manifest signing. | +| publish-release-manifest | aws-actions/configure-aws-credentials | Third-party | 1 | Exchange job OIDC for manifest publication. | +| record-registry-api | actions/checkout | First-party | 2 | Fetch pinned workflow and exact caller metadata. | +| record-registry-api | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | +| record-registry-api | actions/github-script | First-party | 1 | Request the registry-audience OIDC token and mask it. | +| verify-release | actions/checkout | First-party | 1 | Fetch the pinned verification implementation. | +| verify-release | ca-trust-guard (local) | Local | 1 | Enforce runner trust before verification. | +| verify-release | sigstore/cosign-installer | Third-party | 1 | Bootstrap signature verification. | +| verify-release | aws-actions/configure-aws-credentials | Third-party | 1 | Assume the pull-only Public ECR role. | +| verify-release | aws-actions/amazon-ecr-login | Third-party | 1 | Avoid anonymous pull throttling during verification. | +| notify-release-failure | None | — | 0 | Post the existing Datadog event and a failure-count metric via curl. | + +The locked-tools composite additionally uses one SHA-pinned first-party +`actions/checkout` to fetch the locked gon source. `docker/setup-buildx-action` +is allowed but not currently used: Buildx comes from locked-tools. The mint +action is allowed but this workflow does not currently need an App private key. +The allowlist reduces download exposure; it does not eliminate the runner's +pre-step action downloads. Datadog reporting preserves the existing event title, +host, tags and details; API errors are best-effort and cannot fail the reporting +job. Offline fixtures exercise both successful and rejected API requests. +Live release and forced-failure rehearsals remain separate rollout checks. + ### Locked Release Tools `.github/actions/locked-tools/tool-locks.txt` identifies tool versions, artifact diff --git a/scripts/check-release-actions.py b/scripts/check-release-actions.py new file mode 100755 index 0000000..3e0db7f --- /dev/null +++ b/scripts/check-release-actions.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Reject unreviewed action downloads in credential-bearing release jobs.""" +import json +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parent.parent +ALLOWED_ACTIONS = { + line.strip() for line in (ROOT / "tools/privileged-actions.txt").read_text().splitlines() + if line.strip() and not line.startswith("#") +} +LOCAL_ACTIONS = { + "./_workflows/.github/actions/ca-trust-guard", + "./_workflows/.github/actions/locked-tools", +} +PRIVILEGED_JOBS = { + "validate-inputs", "goreleaser-binaries", "goreleaser-windows", + "goreleaser-docker", "publish-release-manifest", "record-registry-api", + "verify-release", "notify-release-failure", +} + + +def load_yaml(path): + return json.loads(subprocess.check_output(["yq", ".", str(path)], text=True)) + + +def check_steps(steps, location): + errors = [] + for step in steps: + action = step.get("uses") + if action is None: + continue + if action in LOCAL_ACTIONS: + continue + name, separator, ref = action.partition("@") + first_party = re.fullmatch(r"actions/[a-zA-Z0-9_-]+", name) is not None + if (not first_party and name not in ALLOWED_ACTIONS) or not separator: + errors.append(f"{location}: action not allowlisted: {action}") + elif not re.fullmatch(r"[0-9a-f]{40}", ref): + errors.append(f"{location}: action must use a full commit SHA: {action}") + return errors + + +def check_local_bootstrap(steps, location): + errors = [] + ready = False + for step in steps: + action = step.get("uses", "") + settings = step.get("with", {}) + if (re.fullmatch(r"actions/checkout@[0-9a-f]{40}", action) + and settings.get("repository") == "ConductorOne/github-workflows" + and settings.get("path") == "_workflows" + and settings.get("ref") == "${{ job.workflow_sha }}" + and settings.get("persist-credentials") is False + and "token" not in settings and "if" not in step): + ready = True + if action in LOCAL_ACTIONS and not ready: + errors.append(f"{location}: local action requires the preceding pinned workflow checkout") + return errors + + +def check_workflow(workflow): + errors = [] + for name, job in workflow["jobs"].items(): + permissions = job.get("permissions", workflow.get("permissions", {})) + oidc = permissions == "write-all" or ( + isinstance(permissions, dict) and permissions.get("id-token") == "write") + if name in PRIVILEGED_JOBS or oidc or "secrets." in json.dumps(job): + errors.extend(check_steps(job.get("steps", []), name)) + errors.extend(check_local_bootstrap(job.get("steps", []), name)) + return errors + + +def check_repository(root): + errors = check_workflow(load_yaml(root / ".github/workflows/release.yaml")) + for action in sorted(LOCAL_ACTIONS): + path = root / action.removeprefix("./_workflows/") / "action.yml" + errors.extend(check_steps(load_yaml(path)["runs"]["steps"], action)) + return errors + + +if __name__ == "__main__": + failures = check_repository(ROOT) + for failure in failures: + print(failure, file=sys.stderr) + sys.exit(bool(failures)) diff --git a/scripts/test-release-actions.py b/scripts/test-release-actions.py new file mode 100755 index 0000000..b8ea5d8 --- /dev/null +++ b/scripts/test-release-actions.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Offline positive and adverse controls for privileged action policy.""" +import copy +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent +sys.dont_write_bytecode = True + + +def load_module(name): + spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +policy = load_module("check-release-actions") + + +class ReleaseActionsTest(unittest.TestCase): + def test_repository_policy(self): + self.assertEqual(policy.check_repository(ROOT), []) + + def test_each_guarded_job_rejects_unreviewed_action_and_mutable_pin(self): + workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") + for job in policy.PRIVILEGED_JOBS: + for action in ("unreviewed/action@" + "a" * 40, "actions/checkout@v5", + "./_workflows/.github/actions/unreviewed", "docker://unreviewed:latest"): + with self.subTest(job=job, action=action): + changed = copy.deepcopy(workflow) + changed["jobs"][job]["steps"].append({"uses": action}) + self.assertTrue(policy.check_workflow(changed)) + + def test_new_credential_jobs_cannot_escape_inventory(self): + for job in ({"permissions": {"id-token": "write"}}, + {"env": {"TOKEN": "${{ secrets.NEW_TOKEN }}"}}, + {"permissions": "write-all"}): + job["steps"] = [{"uses": "unreviewed/action@" + "a" * 40}] + self.assertTrue(policy.check_workflow({"jobs": {"new-job": job}})) + self.assertTrue(policy.check_workflow({"permissions": {"id-token": "write"}, + "jobs": {"new-job": {"steps": job["steps"]}}})) + + def test_local_actions_require_immutable_checkout(self): + steps = policy.load_yaml(ROOT / ".github/workflows/release.yaml")["jobs"]["validate-inputs"]["steps"][:2] + self.assertEqual(policy.check_local_bootstrap(steps, "fixture"), []) + for field, value in (("ref", "main"), ("persist-credentials", True), ("token", "secret")): + changed = copy.deepcopy(steps) + changed[0]["with"][field] = value + self.assertTrue(policy.check_local_bootstrap(changed, "fixture")) + self.assertTrue(policy.check_local_bootstrap(list(reversed(steps)), "fixture")) + + def test_first_party_actions_require_sha(self): + for name in ("checkout", "github-script", "upload-artifact", "download-artifact", + "setup-go", "cache", "create-github-app-token"): + self.assertEqual(policy.check_steps([{"uses": f"actions/{name}@" + "a" * 40}], "first-party"), []) + self.assertTrue(policy.check_steps([{"uses": f"actions/{name}@v2"}], "first-party")) + self.assertTrue(policy.check_steps([{"uses": "actions-impostor/checkout@" + "a" * 40}], "third-party")) + + + def test_failure_reporting_preserves_fields_and_tolerates_api_failure(self): + workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") + step = workflow["jobs"]["notify-release-failure"]["steps"][-1] + self.assertTrue(step["continue-on-error"]) + for status in (0, 22): + with self.subTest(status=status), tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + fake = directory / "curl" + fake.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$RUNNER_TEMP/calls"\nexit ' + str(status) + '\n') + fake.chmod(0o755) + environment = dict(os.environ, PATH=f"{directory}:{os.environ['PATH']}", + RUNNER_TEMP=str(directory), DATADOG_API_KEY="fixture-key", + REPOSITORY="ConductorOne/example", REPOSITORY_NAME="example", + REPOSITORY_OWNER="ConductorOne", RUN_ID="42", RELEASE_TAG="v1.2.3", + RUN_URL="https://github.com/ConductorOne/example/actions/runs/42", + ANNOTATIONS='failure with "quotes"\nand newline') + subprocess.run(["bash", "-c", step["run"]], env=environment, check=True, + capture_output=True, text=True) + event = json.loads((directory / "release-failure-event.json").read_text()) + self.assertEqual(event["title"], "Baton Connector Release Failed") + self.assertEqual(event["alert_type"], "error") + self.assertEqual(event["host"], "ConductorOne") + self.assertEqual(event["tags"], ["github_repository:ConductorOne/example", + "github_release_tag:v1.2.3", "github_workflow:baton-connector-release"]) + self.assertIn(environment["ANNOTATIONS"], event["text"]) + self.assertEqual(len((directory / "calls").read_text().splitlines()), 2) + metric = json.loads((directory / "release-failure-metric.json").read_text())["series"][0] + self.assertEqual(metric["metric"], "baton.connector.release.failure") + self.assertEqual(metric["points"][0][1], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/privileged-actions.txt b/tools/privileged-actions.txt new file mode 100644 index 0000000..b0c280a --- /dev/null +++ b/tools/privileged-actions.txt @@ -0,0 +1,8 @@ +# Third-party action repositories allowed in credential-bearing release jobs. +# First-party actions/* are allowed separately; all remote actions need full SHAs. +sigstore/cosign-installer +aws-actions/configure-aws-credentials +aws-actions/amazon-ecr-login +apple-actions/import-codesign-certs +docker/setup-qemu-action +docker/setup-buildx-action From 8f7776895d6f212f92ba07cb0040e296d98eae3d Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 13:27:16 +0000 Subject: [PATCH 02/12] Cover first-party subactions and inherited release secrets Co-authored-by: c1-squire-dev[bot] --- scripts/check-release-actions.py | 10 +++++++--- scripts/test-release-actions.py | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/check-release-actions.py b/scripts/check-release-actions.py index 3e0db7f..ac6402c 100755 --- a/scripts/check-release-actions.py +++ b/scripts/check-release-actions.py @@ -35,7 +35,10 @@ def check_steps(steps, location): if action in LOCAL_ACTIONS: continue name, separator, ref = action.partition("@") - first_party = re.fullmatch(r"actions/[a-zA-Z0-9_-]+", name) is not None + components = name.split("/") + first_party = (len(components) >= 2 and components[0] == "actions" + and all(re.fullmatch(r"[A-Za-z0-9_.-]+", part) and part not in {".", ".."} + for part in components[1:])) if (not first_party and name not in ALLOWED_ACTIONS) or not separator: errors.append(f"{location}: action not allowlisted: {action}") elif not re.fullmatch(r"[0-9a-f]{40}", ref): @@ -67,8 +70,9 @@ def check_workflow(workflow): permissions = job.get("permissions", workflow.get("permissions", {})) oidc = permissions == "write-all" or ( isinstance(permissions, dict) and permissions.get("id-token") == "write") - if name in PRIVILEGED_JOBS or oidc or "secrets." in json.dumps(job): - errors.extend(check_steps(job.get("steps", []), name)) + inherited_secret = "secrets." in json.dumps(workflow.get("env", {})) + if name in PRIVILEGED_JOBS or oidc or job.get("secrets") or inherited_secret or "secrets." in json.dumps(job): + errors.extend(check_steps([job] if "uses" in job else job.get("steps", []), name)) errors.extend(check_local_bootstrap(job.get("steps", []), name)) return errors diff --git a/scripts/test-release-actions.py b/scripts/test-release-actions.py index b8ea5d8..72eeba4 100755 --- a/scripts/test-release-actions.py +++ b/scripts/test-release-actions.py @@ -46,6 +46,10 @@ def test_new_credential_jobs_cannot_escape_inventory(self): self.assertTrue(policy.check_workflow({"jobs": {"new-job": job}})) self.assertTrue(policy.check_workflow({"permissions": {"id-token": "write"}, "jobs": {"new-job": {"steps": job["steps"]}}})) + self.assertTrue(policy.check_workflow({"env": {"TOKEN": "${{ secrets.RELEASE_TOKEN }}"}, + "jobs": {"new-job": {"steps": job["steps"]}}})) + self.assertTrue(policy.check_workflow({"jobs": {"new-job": {"secrets": "inherit", + "uses": "unreviewed/workflow@v1"}}})) def test_local_actions_require_immutable_checkout(self): steps = policy.load_yaml(ROOT / ".github/workflows/release.yaml")["jobs"]["validate-inputs"]["steps"][:2] @@ -58,7 +62,7 @@ def test_local_actions_require_immutable_checkout(self): def test_first_party_actions_require_sha(self): for name in ("checkout", "github-script", "upload-artifact", "download-artifact", - "setup-go", "cache", "create-github-app-token"): + "setup-go", "cache", "cache/restore", "cache/save", "create-github-app-token"): self.assertEqual(policy.check_steps([{"uses": f"actions/{name}@" + "a" * 40}], "first-party"), []) self.assertTrue(policy.check_steps([{"uses": f"actions/{name}@v2"}], "first-party")) self.assertTrue(policy.check_steps([{"uses": "actions-impostor/checkout@" + "a" * 40}], "third-party")) From afabc5806792cac1d7ccf130e6beaeb3764c6caf Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 13:35:50 +0000 Subject: [PATCH 03/12] Recognize bracket and whole-context secret references Co-authored-by: c1-squire-dev[bot] --- scripts/check-release-actions.py | 15 +++++++++++++-- scripts/test-release-actions.py | 3 +++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/check-release-actions.py b/scripts/check-release-actions.py index ac6402c..842658f 100755 --- a/scripts/check-release-actions.py +++ b/scripts/check-release-actions.py @@ -64,14 +64,25 @@ def check_local_bootstrap(steps, location): return errors +def references_secrets(value): + if isinstance(value, str): + # Match the context itself too (for example, toJSON(secrets)). + return re.search(r"\bsecrets\b", value) is not None + if isinstance(value, dict): + return any(references_secrets(item) for item in value.values()) + if isinstance(value, list): + return any(references_secrets(item) for item in value) + return False + + def check_workflow(workflow): errors = [] for name, job in workflow["jobs"].items(): permissions = job.get("permissions", workflow.get("permissions", {})) oidc = permissions == "write-all" or ( isinstance(permissions, dict) and permissions.get("id-token") == "write") - inherited_secret = "secrets." in json.dumps(workflow.get("env", {})) - if name in PRIVILEGED_JOBS or oidc or job.get("secrets") or inherited_secret or "secrets." in json.dumps(job): + inherited_secret = references_secrets(workflow.get("env", {})) + if name in PRIVILEGED_JOBS or oidc or job.get("secrets") or inherited_secret or references_secrets(job): errors.extend(check_steps([job] if "uses" in job else job.get("steps", []), name)) errors.extend(check_local_bootstrap(job.get("steps", []), name)) return errors diff --git a/scripts/test-release-actions.py b/scripts/test-release-actions.py index 72eeba4..affb221 100755 --- a/scripts/test-release-actions.py +++ b/scripts/test-release-actions.py @@ -41,6 +41,9 @@ def test_each_guarded_job_rejects_unreviewed_action_and_mutable_pin(self): def test_new_credential_jobs_cannot_escape_inventory(self): for job in ({"permissions": {"id-token": "write"}}, {"env": {"TOKEN": "${{ secrets.NEW_TOKEN }}"}}, + {"env": {"TOKEN": "${{ secrets['NEW_TOKEN'] }}"}}, + {"env": {"TOKEN": "${{ secrets\n ['NEW_TOKEN'] }}"}}, + {"env": {"TOKEN": "${{ toJSON(secrets) }}"}}, {"permissions": "write-all"}): job["steps"] = [{"uses": "unreviewed/action@" + "a" * 40}] self.assertTrue(policy.check_workflow({"jobs": {"new-job": job}})) From 56442bdf6a8d47fbdb01bf77a08c73c8055f0940 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 13:48:48 +0000 Subject: [PATCH 04/12] Clarify failure notification event and metric behavior Co-authored-by: c1-squire-dev[bot] --- docs/release-workflow.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/release-workflow.md b/docs/release-workflow.md index cb63206..022b18f 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -144,6 +144,14 @@ Post-release validation (non-blocking): subject to the anonymous rate limit - Triggers Datadog notification on failure +### notify-release-failure + +Posts the existing Datadog failure event and adds a failure-count metric via +best-effort curl requests. The metric is an addition to the previous event-only +notification, intended to support future staleness monitoring. The axiomatic +workflow remains event-only; aligning its notification behavior is a low-priority +follow-up. Notification API failures cannot fail the reporting job. + ## Security Properties ### Pinned Container Images @@ -426,8 +434,7 @@ requires `GOFLAGS=-mod=readonly`, `GOPROXY=https://proxy.golang.org` and Cosign v2.5.2 is installed by `sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac`. That action verifies its bootstrap binary against hashes embedded in the -pinned action and verifies other requested versions by signature. The -failure-only Datadog action is also commit-pinned. +pinned action and verifies other requested versions by signature. Buildx is never downloaded: `scripts/setup-release-buildx.sh` prints the runner-bundled plugin's version, fails if it is absent, and creates a builder From 32533b43dbdf3ac6c61b0f8ad2590eaf2f4a0524 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 15:32:16 +0000 Subject: [PATCH 05/12] Bound failure reporting and reject overwritten workflow bootstraps Co-authored-by: c1-squire-dev[bot] --- .github/workflows/release.yaml | 7 ++++--- docs/release-workflow.md | 4 +++- scripts/check-release-actions.py | 29 +++++++++++++++++++++-------- scripts/test-release-actions.py | 28 ++++++++++++++++++++++++++-- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 53d593b..01c44a8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1789,6 +1789,7 @@ jobs: actions: read contents: read runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Collect failure annotations id: collect-ann @@ -1839,7 +1840,7 @@ jobs: # Keep Datadog payload compact (~4k limit on text) out="$(printf "%s" "$out" | tail -c 3500)" - # Single line: multi-line output breaks the events YAML block scalar. + # Single line: keeps the annotation block compact for the jq-built event text. out="$(printf "%s" "$out" | tr '\n' ' ' | sed 's/ \+/ /g')" { @@ -1869,7 +1870,7 @@ jobs: alert_type: "error", host: $owner, tags: [("github_repository:" + $repo), ("github_release_tag:" + $tag), "github_workflow:baton-connector-release"]}' > "$RUNNER_TEMP/release-failure-event.json" - curl --fail-with-body --silent --show-error \ + curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \ -H "DD-API-KEY: $DATADOG_API_KEY" -H "Content-Type: application/json" \ --data-binary "@$RUNNER_TEMP/release-failure-event.json" \ https://api.us3.datadoghq.com/api/v1/events || true @@ -1877,7 +1878,7 @@ jobs: '{series: [{metric: "baton.connector.release.failure", type: "count", points: [[$now, 1]], tags: [("github_repository:" + $repo)]}]}' \ > "$RUNNER_TEMP/release-failure-metric.json" - curl --fail-with-body --silent --show-error \ + curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \ -H "DD-API-KEY: $DATADOG_API_KEY" -H "Content-Type: application/json" \ --data-binary "@$RUNNER_TEMP/release-failure-metric.json" \ https://api.us3.datadoghq.com/api/v1/series || true diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 022b18f..8fb9ac3 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -294,7 +294,9 @@ commit SHA. A version comment is informational, not the execution reference. CI, checks every job with `id-token: write` or a secret reference, plus the explicit credential-bearing job inventory. It also checks the implementations of the two permitted local composite actions so they cannot hide a new remote -action. Unknown local paths and container actions fail closed. +action. Unknown local paths and step-level `docker://` references fail closed. +Job-level `container:` and `services:` are not covered by this checker; +extending coverage is a low-priority follow-up. The local guard and locked-tools actions come from the immutable `_workflows` checkout at `job.workflow_sha`; they are not third-party downloads. The remaining diff --git a/scripts/check-release-actions.py b/scripts/check-release-actions.py index 842658f..933d2d6 100755 --- a/scripts/check-release-actions.py +++ b/scripts/check-release-actions.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Reject unreviewed action downloads in credential-bearing release jobs.""" import json +import os from pathlib import Path import re import subprocess @@ -51,14 +52,26 @@ def check_local_bootstrap(steps, location): ready = False for step in steps: action = step.get("uses", "") - settings = step.get("with", {}) - if (re.fullmatch(r"actions/checkout@[0-9a-f]{40}", action) - and settings.get("repository") == "ConductorOne/github-workflows" - and settings.get("path") == "_workflows" - and settings.get("ref") == "${{ job.workflow_sha }}" - and settings.get("persist-credentials") is False - and "token" not in settings and "if" not in step): - ready = True + settings = step.get("with") or {} + checkout = action.startswith("actions/checkout@") + if "with" in step and not isinstance(step["with"], dict): + errors.append(f"{location}: action inputs must be a mapping") + if checkout: + ready = False + continue + if checkout: + path = settings.get("path", "") + if not isinstance(path, str): + errors.append(f"{location}: checkout path must be a string") + ready = False + continue + if os.path.normpath(path) == "_workflows": + ready = (re.fullmatch(r"actions/checkout@[0-9a-f]{40}", action) is not None + and settings.get("repository") == "ConductorOne/github-workflows" + and settings.get("path") == "_workflows" + and settings.get("ref") == "${{ job.workflow_sha }}" + and settings.get("persist-credentials") is False + and "token" not in settings and "if" not in step) if action in LOCAL_ACTIONS and not ready: errors.append(f"{location}: local action requires the preceding pinned workflow checkout") return errors diff --git a/scripts/test-release-actions.py b/scripts/test-release-actions.py index affb221..13516e4 100755 --- a/scripts/test-release-actions.py +++ b/scripts/test-release-actions.py @@ -63,6 +63,26 @@ def test_local_actions_require_immutable_checkout(self): self.assertTrue(policy.check_local_bootstrap(changed, "fixture")) self.assertTrue(policy.check_local_bootstrap(list(reversed(steps)), "fixture")) + def test_empty_or_non_mapping_inputs_fail_cleanly(self): + steps = policy.load_yaml(ROOT / ".github/workflows/release.yaml")["jobs"]["validate-inputs"]["steps"][:2] + for inputs in (None, [], ["path", "_workflows"], "", "not-a-mapping", False, 0): + with self.subTest(inputs=inputs): + changed = copy.deepcopy(steps) + changed[0]["with"] = inputs + self.assertTrue(policy.check_local_bootstrap(changed, "fixture")) + + def test_later_checkout_cannot_overwrite_verified_workflows(self): + steps = policy.load_yaml(ROOT / ".github/workflows/release.yaml")["jobs"]["validate-inputs"]["steps"][:2] + for path in ("_workflows", "./_workflows", "_workflows/", "_workflows/."): + with self.subTest(path=path): + overwrite = copy.deepcopy(steps[0]) + overwrite["with"].update(path=path, repository="unreviewed/workflows", ref="main") + self.assertTrue(policy.check_local_bootstrap([steps[0], overwrite, steps[1]], "fixture")) + other_checkout = copy.deepcopy(overwrite) + other_checkout["with"]["path"] = "_caller" + self.assertEqual(policy.check_local_bootstrap([steps[0], other_checkout, steps[1]], "fixture"), []) + self.assertEqual(policy.check_local_bootstrap([steps[0], steps[0], steps[1]], "fixture"), []) + def test_first_party_actions_require_sha(self): for name in ("checkout", "github-script", "upload-artifact", "download-artifact", "setup-go", "cache", "cache/restore", "cache/save", "create-github-app-token"): @@ -74,8 +94,9 @@ def test_first_party_actions_require_sha(self): def test_failure_reporting_preserves_fields_and_tolerates_api_failure(self): workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") step = workflow["jobs"]["notify-release-failure"]["steps"][-1] + self.assertEqual(workflow["jobs"]["notify-release-failure"]["timeout-minutes"], 5) self.assertTrue(step["continue-on-error"]) - for status in (0, 22): + for status in (0, 22, 28): with self.subTest(status=status), tempfile.TemporaryDirectory() as directory: directory = Path(directory) fake = directory / "curl" @@ -96,7 +117,10 @@ def test_failure_reporting_preserves_fields_and_tolerates_api_failure(self): self.assertEqual(event["tags"], ["github_repository:ConductorOne/example", "github_release_tag:v1.2.3", "github_workflow:baton-connector-release"]) self.assertIn(environment["ANNOTATIONS"], event["text"]) - self.assertEqual(len((directory / "calls").read_text().splitlines()), 2) + calls = (directory / "calls").read_text().splitlines() + self.assertEqual(len(calls), 2) + for call in calls: + self.assertIn("--connect-timeout 10 --max-time 30", call) metric = json.loads((directory / "release-failure-metric.json").read_text())["series"][0] self.assertEqual(metric["metric"], "baton.connector.release.failure") self.assertEqual(metric["points"][0][1], 1) From ae409b76973a25f491dca4ac9a2e306dc466e48b Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 16:33:14 +0000 Subject: [PATCH 06/12] Invalidate bootstrap readiness for root and unresolvable checkouts Co-authored-by: c1-squire-dev[bot] --- scripts/check-release-actions.py | 4 +++- scripts/test-release-actions.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/check-release-actions.py b/scripts/check-release-actions.py index 933d2d6..5612e8f 100755 --- a/scripts/check-release-actions.py +++ b/scripts/check-release-actions.py @@ -65,7 +65,9 @@ def check_local_bootstrap(steps, location): errors.append(f"{location}: checkout path must be a string") ready = False continue - if os.path.normpath(path) == "_workflows": + rel = os.path.normpath(str(path or ".")) + if (rel in (".", "_workflows") or rel.startswith("_workflows/") + or rel.startswith("..") or os.path.isabs(rel) or "${{" in str(path)): ready = (re.fullmatch(r"actions/checkout@[0-9a-f]{40}", action) is not None and settings.get("repository") == "ConductorOne/github-workflows" and settings.get("path") == "_workflows" diff --git a/scripts/test-release-actions.py b/scripts/test-release-actions.py index 13516e4..bfc1344 100755 --- a/scripts/test-release-actions.py +++ b/scripts/test-release-actions.py @@ -73,10 +73,14 @@ def test_empty_or_non_mapping_inputs_fail_cleanly(self): def test_later_checkout_cannot_overwrite_verified_workflows(self): steps = policy.load_yaml(ROOT / ".github/workflows/release.yaml")["jobs"]["validate-inputs"]["steps"][:2] - for path in ("_workflows", "./_workflows", "_workflows/", "_workflows/."): + for path in (None, ".", "", "_workflows", "./_workflows", "_workflows/", "_workflows/.", + "_workflows/.github", "${{ github.workspace }}/_workflows", "..", "../caller", + "/tmp/workflows"): with self.subTest(path=path): overwrite = copy.deepcopy(steps[0]) overwrite["with"].update(path=path, repository="unreviewed/workflows", ref="main") + if path is None: + overwrite["with"].pop("path") self.assertTrue(policy.check_local_bootstrap([steps[0], overwrite, steps[1]], "fixture")) other_checkout = copy.deepcopy(overwrite) other_checkout["with"]["path"] = "_caller" From a72f2907254d65aba16c6c95f68eef579c713a2d Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 19:08:47 +0000 Subject: [PATCH 07/12] Clarify best-effort notification failure rationale Co-authored-by: c1-squire-dev[bot] --- .github/workflows/release.yaml | 2 +- docs/release-workflow.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 01c44a8..3cc9bc6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1850,7 +1850,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Send Datadog event on failure - continue-on-error: true # reporting must not obscure the release failure + continue-on-error: true # best-effort reporting: a Datadog failure does not fail this job (the workflow is already failed) env: DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }} REPOSITORY: ${{ github.repository }} diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 8fb9ac3..4839929 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -150,7 +150,8 @@ Posts the existing Datadog failure event and adds a failure-count metric via best-effort curl requests. The metric is an addition to the previous event-only notification, intended to support future staleness monitoring. The axiomatic workflow remains event-only; aligning its notification behavior is a low-priority -follow-up. Notification API failures cannot fail the reporting job. +follow-up. Reporting is best-effort: a Datadog failure does not fail this job; +the workflow is already failed. ## Security Properties From 9742d3dd7052ff695a877f05f4627a14dcd0753b Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 20:41:31 +0000 Subject: [PATCH 08/12] Retain the pinned Datadog notification survivor Co-authored-by: c1-squire-dev[bot] --- .github/workflows/release.yaml | 57 +++++++--------- docs/release-workflow.md | 30 +++++---- scripts/check-release-actions.py | 2 +- scripts/test-release-actions.py | 110 +++++++++++++++++++++---------- tools/privileged-actions.txt | 5 +- 5 files changed, 125 insertions(+), 79 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3cc9bc6..4049fdd 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1840,7 +1840,7 @@ jobs: # Keep Datadog payload compact (~4k limit on text) out="$(printf "%s" "$out" | tail -c 3500)" - # Single line: keeps the annotation block compact for the jq-built event text. + # Single line: multi-line output breaks the events YAML block scalar. out="$(printf "%s" "$out" | tr '\n' ' ' | sed 's/ \+/ /g')" { @@ -1851,34 +1851,27 @@ jobs: - name: Send Datadog event on failure continue-on-error: true # best-effort reporting: a Datadog failure does not fail this job (the workflow is already failed) - env: - DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }} - REPOSITORY: ${{ github.repository }} - REPOSITORY_NAME: ${{ github.event.repository.name }} - REPOSITORY_OWNER: ${{ github.repository_owner }} - RUN_ID: ${{ github.run_id }} - RELEASE_TAG: ${{ inputs.tag }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - ANNOTATIONS: ${{ steps.collect-ann.outputs.annotations }} - run: | - set -euo pipefail - jq -n --arg repo "$REPOSITORY" --arg tag "$RELEASE_TAG" \ - --arg url "$RUN_URL" --arg annotations "$ANNOTATIONS" \ - --arg name "$REPOSITORY_NAME" --arg owner "$REPOSITORY_OWNER" --arg run "$RUN_ID" \ - '{title: "Baton Connector Release Failed", - text: ("%%%\n# `" + $name + "`:`" + $tag + "` Release Failed\n## Error\n[View Workflow Run](" + $url + ")\n```" + $annotations + "```\n## Details\n* **Repository:** `" + $repo + "`\n* **Release Tag:** `" + $tag + "`\n* **Workflow:** `baton-connector-release`\n* **Action Run ID:** `" + $run + "`\n%%%"), - alert_type: "error", host: $owner, - tags: [("github_repository:" + $repo), ("github_release_tag:" + $tag), - "github_workflow:baton-connector-release"]}' > "$RUNNER_TEMP/release-failure-event.json" - curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \ - -H "DD-API-KEY: $DATADOG_API_KEY" -H "Content-Type: application/json" \ - --data-binary "@$RUNNER_TEMP/release-failure-event.json" \ - https://api.us3.datadoghq.com/api/v1/events || true - jq -n --arg repo "$REPOSITORY" --argjson now "$(date +%s)" \ - '{series: [{metric: "baton.connector.release.failure", type: "count", - points: [[$now, 1]], tags: [("github_repository:" + $repo)]}]}' \ - > "$RUNNER_TEMP/release-failure-metric.json" - curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \ - -H "DD-API-KEY: $DATADOG_API_KEY" -H "Content-Type: application/json" \ - --data-binary "@$RUNNER_TEMP/release-failure-metric.json" \ - https://api.us3.datadoghq.com/api/v1/series || true + uses: masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185 # v1 + with: + api-key: ${{ secrets.DATADOG_API_KEY }} + api-url: https://us3.datadoghq.com/ + events: | + - title: "Baton Connector Release Failed" + text: | + %%% + # `${{ github.event.repository.name }}`:`${{ inputs.tag }}` Release Failed + ## Error + [View Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + ```${{ steps.collect-ann.outputs.annotations }}``` + ## Details + * **Repository:** `${{ github.event.repository.full_name }}` + * **Release Tag:** `${{ inputs.tag }}` + * **Workflow:** `baton-connector-release` + * **Action Run ID:** `${{ github.run_id }}` + %%% + alert_type: "error" + host: ${{ github.repository_owner }} + tags: + - "github_repository:${{ github.event.repository.full_name }}" + - "github_release_tag:${{ inputs.tag }}" + - "github_workflow:baton-connector-release" diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 4839929..a0f5b52 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -146,12 +146,17 @@ Post-release validation (non-blocking): ### notify-release-failure -Posts the existing Datadog failure event and adds a failure-count metric via -best-effort curl requests. The metric is an addition to the previous event-only -notification, intended to support future staleness monitoring. The axiomatic -workflow remains event-only; aligning its notification behavior is a low-priority -follow-up. Reporting is best-effort: a Datadog failure does not fail this job; -the workflow is already failed. +Posts the existing Datadog failure event through the named, SHA-pinned +`masci/datadog` notification-only survivor. The latest-attempt annotation +collector feeds `steps.collect-ann.outputs.annotations` into the action's +`events` input, unchanged from main. Notification is event-only: the added +failure-count metric has been removed. Reporting is best-effort: a Datadog +failure does not fail this job; the workflow is already failed. +The job timeout remains five minutes. + +| Action / immutable ref | Credential custody | Re-pin review | +| --- | --- | --- | +| `masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185` | `DATADOG_API_KEY`, a Datadog org write key; only this step's `with: api-key` receives it. Non-signing, notification only; never job-level environment. | Reviewer diffs the action's tag-to-SHA source. Renovate PRs are not auto-merged (`renovate.json` keeps `automerge: false`). | ## Security Properties @@ -288,7 +293,8 @@ after trust/tool bootstrap and before their signing or AWS credentials. ### Privileged Action Allowlist `tools/privileged-actions.txt` is the reviewed third-party action repository -allowlist. First-party `actions/*` actions are allowed separately, including +allowlist; exact-reference rows bind named exceptions to one reviewed commit. +First-party `actions/*` actions are allowed separately, including the App-token mint action; every remote action must use a full 40-character commit SHA. A version comment is informational, not the execution reference. `scripts/check-release-actions.py`, exercised by `make test-scripts` in repository @@ -334,17 +340,17 @@ action inventory is below (counts include repeated conditional steps): | verify-release | sigstore/cosign-installer | Third-party | 1 | Bootstrap signature verification. | | verify-release | aws-actions/configure-aws-credentials | Third-party | 1 | Assume the pull-only Public ECR role. | | verify-release | aws-actions/amazon-ecr-login | Third-party | 1 | Avoid anonymous pull throttling during verification. | -| notify-release-failure | None | — | 0 | Post the existing Datadog event and a failure-count metric via curl. | +| notify-release-failure | masci/datadog | Third-party | 1 | Named, pinned notification-only survivor; the immutable ref, key custody and re-pin review are recorded above. | The locked-tools composite additionally uses one SHA-pinned first-party `actions/checkout` to fetch the locked gon source. `docker/setup-buildx-action` is allowed but not currently used: Buildx comes from locked-tools. The mint action is allowed but this workflow does not currently need an App private key. The allowlist reduces download exposure; it does not eliminate the runner's -pre-step action downloads. Datadog reporting preserves the existing event title, -host, tags and details; API errors are best-effort and cannot fail the reporting -job. Offline fixtures exercise both successful and rejected API requests. -Live release and forced-failure rehearsals remain separate rollout checks. +pre-step action downloads. Datadog reporting preserves main's action pin, +event configuration and annotation plumbing. Offline fixtures check the exact +allowlist row, step-only key custody and best-effort configuration, including +negative controls. Live failure delivery remains a separate rollout check. ### Locked Release Tools diff --git a/scripts/check-release-actions.py b/scripts/check-release-actions.py index 5612e8f..519a101 100755 --- a/scripts/check-release-actions.py +++ b/scripts/check-release-actions.py @@ -40,7 +40,7 @@ def check_steps(steps, location): first_party = (len(components) >= 2 and components[0] == "actions" and all(re.fullmatch(r"[A-Za-z0-9_.-]+", part) and part not in {".", ".."} for part in components[1:])) - if (not first_party and name not in ALLOWED_ACTIONS) or not separator: + if (not first_party and name not in ALLOWED_ACTIONS and action not in ALLOWED_ACTIONS) or not separator: errors.append(f"{location}: action not allowlisted: {action}") elif not re.fullmatch(r"[0-9a-f]{40}", ref): errors.append(f"{location}: action must use a full commit SHA: {action}") diff --git a/scripts/test-release-actions.py b/scripts/test-release-actions.py index bfc1344..285b9c0 100755 --- a/scripts/test-release-actions.py +++ b/scripts/test-release-actions.py @@ -3,11 +3,9 @@ import copy import importlib.util import json -import os from pathlib import Path import subprocess import sys -import tempfile import unittest ROOT = Path(__file__).resolve().parent.parent @@ -22,6 +20,36 @@ def load_module(name): policy = load_module("check-release-actions") +DATADOG_ACTION = "masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185" +DATADOG_KEY = "${{ secrets.DATADOG_API_KEY }}" + + +def notification_errors(workflow): + errors = [] + job = workflow["jobs"]["notify-release-failure"] + notification = job["steps"][-1] + if job.get("if") != "failure()" or job.get("timeout-minutes") != 5: + errors.append("failure notification must remain conditional and bounded") + if job.get("permissions") != {"checks": "read", "actions": "read", "contents": "read"}: + errors.append("notification job must remain non-signing and read-only") + if notification.get("uses") != DATADOG_ACTION or notification.get("continue-on-error") is not True: + errors.append("notification action must remain pinned and best-effort") + if notification.get("with", {}).get("api-key") != DATADOG_KEY: + errors.append("the notification step must receive the key directly") + if "DATADOG_API_KEY" in json.dumps(workflow.get("env", {})).upper(): + errors.append("the notification key must not be workflow-scoped") + for name, candidate_job in workflow["jobs"].items(): + if "DATADOG_API_KEY" in json.dumps({key: value for key, value in candidate_job.items() if key != "steps"}).upper(): + errors.append("the notification key must not be job-scoped") + for step in candidate_job.get("steps", []): + remainder = copy.deepcopy(step) + if name == "notify-release-failure" and step is notification: + remainder.get("with", {}).pop("api-key", None) + elif step.get("uses", "").startswith("masci/datadog@"): + errors.append("the Datadog action is notification-only") + if "DATADOG_API_KEY" in json.dumps(remainder).upper(): + errors.append("the notification key escaped its single action input") + return errors class ReleaseActionsTest(unittest.TestCase): @@ -95,39 +123,55 @@ def test_first_party_actions_require_sha(self): self.assertTrue(policy.check_steps([{"uses": "actions-impostor/checkout@" + "a" * 40}], "third-party")) - def test_failure_reporting_preserves_fields_and_tolerates_api_failure(self): + def test_failure_reporting_restores_the_pinned_action_and_event(self): workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") + self.assertEqual(notification_errors(workflow), []) step = workflow["jobs"]["notify-release-failure"]["steps"][-1] - self.assertEqual(workflow["jobs"]["notify-release-failure"]["timeout-minutes"], 5) - self.assertTrue(step["continue-on-error"]) - for status in (0, 22, 28): - with self.subTest(status=status), tempfile.TemporaryDirectory() as directory: - directory = Path(directory) - fake = directory / "curl" - fake.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$RUNNER_TEMP/calls"\nexit ' + str(status) + '\n') - fake.chmod(0o755) - environment = dict(os.environ, PATH=f"{directory}:{os.environ['PATH']}", - RUNNER_TEMP=str(directory), DATADOG_API_KEY="fixture-key", - REPOSITORY="ConductorOne/example", REPOSITORY_NAME="example", - REPOSITORY_OWNER="ConductorOne", RUN_ID="42", RELEASE_TAG="v1.2.3", - RUN_URL="https://github.com/ConductorOne/example/actions/runs/42", - ANNOTATIONS='failure with "quotes"\nand newline') - subprocess.run(["bash", "-c", step["run"]], env=environment, check=True, - capture_output=True, text=True) - event = json.loads((directory / "release-failure-event.json").read_text()) - self.assertEqual(event["title"], "Baton Connector Release Failed") - self.assertEqual(event["alert_type"], "error") - self.assertEqual(event["host"], "ConductorOne") - self.assertEqual(event["tags"], ["github_repository:ConductorOne/example", - "github_release_tag:v1.2.3", "github_workflow:baton-connector-release"]) - self.assertIn(environment["ANNOTATIONS"], event["text"]) - calls = (directory / "calls").read_text().splitlines() - self.assertEqual(len(calls), 2) - for call in calls: - self.assertIn("--connect-timeout 10 --max-time 30", call) - metric = json.loads((directory / "release-failure-metric.json").read_text())["series"][0] - self.assertEqual(metric["metric"], "baton.connector.release.failure") - self.assertEqual(metric["points"][0][1], 1) + self.assertEqual(set(step["with"]), {"api-key", "api-url", "events"}) + self.assertEqual(step["with"]["api-url"], "https://us3.datadoghq.com/") + self.assertNotIn("run", step) + self.assertNotIn("env", step) + events = json.loads(subprocess.check_output(["yq", "."], input=step["with"]["events"], text=True)) + self.assertEqual(len(events), 1) + event = events[0] + self.assertEqual(set(event), {"title", "text", "alert_type", "host", "tags"}) + self.assertEqual(event["title"], "Baton Connector Release Failed") + self.assertEqual(event["alert_type"], "error") + self.assertEqual(event["host"], "${{ github.repository_owner }}") + self.assertEqual(event["tags"], [ + "github_repository:${{ github.event.repository.full_name }}", + "github_release_tag:${{ inputs.tag }}", "github_workflow:baton-connector-release"]) + self.assertIn("${{ steps.collect-ann.outputs.annotations }}", event["text"]) + self.assertIn(DATADOG_ACTION, policy.ALLOWED_ACTIONS) + self.assertNotIn("masci/datadog", policy.ALLOWED_ACTIONS) + self.assertEqual(policy.check_steps([step], "notify-release-failure"), []) + for ref in ("v1", "a" * 40): + self.assertTrue(policy.check_steps([{"uses": f"masci/datadog@{ref}"}], "notify-release-failure")) + self.assertFalse(json.loads((ROOT / "renovate.json").read_text())["automerge"]) + + def test_notification_custody_and_best_effort_negative_controls(self): + workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") + changed = copy.deepcopy(workflow) + changed["env"]["DD_KEY"] = DATADOG_KEY + self.assertTrue(notification_errors(changed)) + changed = copy.deepcopy(workflow) + changed["jobs"]["notify-release-failure"]["env"] = {"DD_KEY": DATADOG_KEY} + self.assertTrue(notification_errors(changed)) + changed = copy.deepcopy(workflow) + changed["jobs"]["goreleaser-binaries"]["steps"].append({"env": {"DD_KEY": DATADOG_KEY}, "run": "true"}) + self.assertTrue(notification_errors(changed)) + changed = copy.deepcopy(workflow) + changed["jobs"]["notify-release-failure"]["steps"][-1]["env"] = {"DD_KEY": DATADOG_KEY} + self.assertTrue(notification_errors(changed)) + changed = copy.deepcopy(workflow) + changed["jobs"]["notify-release-failure"]["steps"][-1]["continue-on-error"] = False + self.assertTrue(notification_errors(changed)) + changed = copy.deepcopy(workflow) + changed["jobs"]["notify-release-failure"]["timeout-minutes"] = 10 + self.assertTrue(notification_errors(changed)) + changed = copy.deepcopy(workflow) + changed["jobs"]["goreleaser-binaries"]["steps"].append({"uses": DATADOG_ACTION}) + self.assertTrue(notification_errors(changed)) if __name__ == "__main__": diff --git a/tools/privileged-actions.txt b/tools/privileged-actions.txt index b0c280a..9942d78 100644 --- a/tools/privileged-actions.txt +++ b/tools/privileged-actions.txt @@ -1,4 +1,4 @@ -# Third-party action repositories allowed in credential-bearing release jobs. +# Third-party repositories or exact action references allowed in credential-bearing release jobs. # First-party actions/* are allowed separately; all remote actions need full SHAs. sigstore/cosign-installer aws-actions/configure-aws-credentials @@ -6,3 +6,6 @@ aws-actions/amazon-ecr-login apple-actions/import-codesign-certs docker/setup-qemu-action docker/setup-buildx-action +# Notification only; custody: DATADOG_API_KEY (Datadog org write key, non-signing). +# Re-pin review: diff the action's tag-to-SHA source; Renovate PRs are not auto-merged. +masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185 From d20de4fb801b99f582a42c41479f2c970815fe67 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 20:51:52 +0000 Subject: [PATCH 09/12] Document manual workflow action re-pinning Co-authored-by: c1-squire-dev[bot] --- docs/release-workflow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-workflow.md b/docs/release-workflow.md index a0f5b52..fcff081 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -156,7 +156,7 @@ The job timeout remains five minutes. | Action / immutable ref | Credential custody | Re-pin review | | --- | --- | --- | -| `masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185` | `DATADOG_API_KEY`, a Datadog org write key; only this step's `with: api-key` receives it. Non-signing, notification only; never job-level environment. | Reviewer diffs the action's tag-to-SHA source. Renovate PRs are not auto-merged (`renovate.json` keeps `automerge: false`). | +| `masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185` | `DATADOG_API_KEY`, a Datadog org write key; only this step's `with: api-key` receives it. Non-signing, notification only; never job-level environment. | Diff the action's tag-to-SHA source; re-pinning is manual (no Renovate tracking exists for workflow SHAs). | ## Security Properties From 253c7f4718dfc6e7d5396124d026a0424049da58 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 20:59:05 +0000 Subject: [PATCH 10/12] Clarify manual action re-pinning and remove unrelated assertion Co-authored-by: c1-squire-dev[bot] --- scripts/test-release-actions.py | 1 - tools/privileged-actions.txt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/test-release-actions.py b/scripts/test-release-actions.py index 285b9c0..d21006f 100755 --- a/scripts/test-release-actions.py +++ b/scripts/test-release-actions.py @@ -147,7 +147,6 @@ def test_failure_reporting_restores_the_pinned_action_and_event(self): self.assertEqual(policy.check_steps([step], "notify-release-failure"), []) for ref in ("v1", "a" * 40): self.assertTrue(policy.check_steps([{"uses": f"masci/datadog@{ref}"}], "notify-release-failure")) - self.assertFalse(json.loads((ROOT / "renovate.json").read_text())["automerge"]) def test_notification_custody_and_best_effort_negative_controls(self): workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") diff --git a/tools/privileged-actions.txt b/tools/privileged-actions.txt index 9942d78..f192e1d 100644 --- a/tools/privileged-actions.txt +++ b/tools/privileged-actions.txt @@ -7,5 +7,5 @@ apple-actions/import-codesign-certs docker/setup-qemu-action docker/setup-buildx-action # Notification only; custody: DATADOG_API_KEY (Datadog org write key, non-signing). -# Re-pin review: diff the action's tag-to-SHA source; Renovate PRs are not auto-merged. +# Re-pin review: diff the action's tag-to-SHA source; re-pinning is manual (no Renovate tracking exists for workflow SHAs). masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185 From cb85b31c557b823674607d17a27773446aee6883 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 21:17:30 +0000 Subject: [PATCH 11/12] Keep release action pins and concise custody documentation Co-authored-by: c1-squire-dev[bot] --- Makefile | 1 - docs/release-workflow.md | 93 +++------------- scripts/check-release-actions.py | 118 --------------------- scripts/test-release-actions.py | 177 ------------------------------- tools/privileged-actions.txt | 11 -- 5 files changed, 17 insertions(+), 383 deletions(-) delete mode 100755 scripts/check-release-actions.py delete mode 100755 scripts/test-release-actions.py delete mode 100644 tools/privileged-actions.txt diff --git a/Makefile b/Makefile index 56d46e7..39dbefd 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,6 @@ test-scripts: bash scripts/test-derive-iam-role-name.sh bash scripts/test-normalize-release-options.sh python3 scripts/test-release-config-templates.py - python3 scripts/test-release-actions.py python3 scripts/test-check-image-locks.py python3 scripts/test-windows-pre-build-script.py python3 scripts/test-safe-relative-path.py diff --git a/docs/release-workflow.md b/docs/release-workflow.md index fcff081..dc1eb11 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -144,20 +144,6 @@ Post-release validation (non-blocking): subject to the anonymous rate limit - Triggers Datadog notification on failure -### notify-release-failure - -Posts the existing Datadog failure event through the named, SHA-pinned -`masci/datadog` notification-only survivor. The latest-attempt annotation -collector feeds `steps.collect-ann.outputs.annotations` into the action's -`events` input, unchanged from main. Notification is event-only: the added -failure-count metric has been removed. Reporting is best-effort: a Datadog -failure does not fail this job; the workflow is already failed. -The job timeout remains five minutes. - -| Action / immutable ref | Credential custody | Re-pin review | -| --- | --- | --- | -| `masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185` | `DATADOG_API_KEY`, a Datadog org write key; only this step's `with: api-key` receives it. Non-signing, notification only; never job-level environment. | Diff the action's tag-to-SHA source; re-pinning is manual (no Renovate tracking exists for workflow SHAs). | - ## Security Properties ### Pinned Container Images @@ -290,67 +276,21 @@ Tool-using jobs set `GOTOOLCHAIN=local` at job scope. The binaries job also sets `GOSUMDB=sum.golang.org` at job scope. Cosign and bundled-buildx/QEMU setup run after trust/tool bootstrap and before their signing or AWS credentials. -### Privileged Action Allowlist - -`tools/privileged-actions.txt` is the reviewed third-party action repository -allowlist; exact-reference rows bind named exceptions to one reviewed commit. -First-party `actions/*` actions are allowed separately, including -the App-token mint action; every remote action must use a full 40-character -commit SHA. A version comment is informational, not the execution reference. -`scripts/check-release-actions.py`, exercised by `make test-scripts` in repository -CI, checks every job with `id-token: write` or a secret reference, plus the -explicit credential-bearing job inventory. It also checks the implementations -of the two permitted local composite actions so they cannot hide a new remote -action. Unknown local paths and step-level `docker://` references fail closed. -Job-level `container:` and `services:` are not covered by this checker; -extending coverage is a low-priority follow-up. - -The local guard and locked-tools actions come from the immutable `_workflows` -checkout at `job.workflow_sha`; they are not third-party downloads. The remaining -action inventory is below (counts include repeated conditional steps): - -| Job | Action | Class | Count | Why it remains | -| --- | --- | --- | --- | --- | -| validate-inputs | actions/checkout | First-party | 1 | Fetch the pinned workflow implementation. | -| validate-inputs | ca-trust-guard (local) | Local | 1 | Enforce runner trust before validation. | -| goreleaser-binaries | actions/checkout | First-party | 2 | Fetch pinned workflow and exact caller source. | -| goreleaser-binaries | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | -| goreleaser-binaries | sigstore/cosign-installer | Third-party | 1 | Bootstrap the pinned signing tool. | -| goreleaser-binaries | apple-actions/import-codesign-certs | Third-party | 1 | Import the Apple signing identity into the temporary keychain. | -| goreleaser-binaries | aws-actions/configure-aws-credentials | Third-party | 1 | Exchange job OIDC for the artifact publishing role. | -| goreleaser-windows | actions/checkout | First-party | 2 | Fetch pinned workflow and exact caller source. | -| goreleaser-windows | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | -| goreleaser-windows | sigstore/cosign-installer | Third-party | 1 | Bootstrap the pinned signing tool. | -| goreleaser-windows | aws-actions/configure-aws-credentials | Third-party | 1 | Exchange job OIDC for the artifact publishing role. | -| goreleaser-docker | actions/checkout | First-party | 2 | Fetch pinned workflow and exact caller source. | -| goreleaser-docker | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | -| goreleaser-docker | sigstore/cosign-installer | Third-party | 1 | Bootstrap the pinned signing tool. | -| goreleaser-docker | docker/setup-qemu-action | Third-party | 1 | Enable arm64 builds with a digest-pinned binfmt image. | -| goreleaser-docker | aws-actions/configure-aws-credentials | Third-party | 2 | Assume the separate public and Lambda publishing roles. | -| goreleaser-docker | aws-actions/amazon-ecr-login | Third-party | 2 | Authenticate to public and private ECR. | -| publish-release-manifest | actions/checkout | First-party | 1 | Fetch the pinned workflow implementation. | -| publish-release-manifest | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | -| publish-release-manifest | sigstore/cosign-installer | Third-party | 1 | Bootstrap manifest signing. | -| publish-release-manifest | aws-actions/configure-aws-credentials | Third-party | 1 | Exchange job OIDC for manifest publication. | -| record-registry-api | actions/checkout | First-party | 2 | Fetch pinned workflow and exact caller metadata. | -| record-registry-api | ca-trust-guard / locked-tools (local) | Local | 1 each | Enforce trust and install checksum-verified tools. | -| record-registry-api | actions/github-script | First-party | 1 | Request the registry-audience OIDC token and mask it. | -| verify-release | actions/checkout | First-party | 1 | Fetch the pinned verification implementation. | -| verify-release | ca-trust-guard (local) | Local | 1 | Enforce runner trust before verification. | -| verify-release | sigstore/cosign-installer | Third-party | 1 | Bootstrap signature verification. | -| verify-release | aws-actions/configure-aws-credentials | Third-party | 1 | Assume the pull-only Public ECR role. | -| verify-release | aws-actions/amazon-ecr-login | Third-party | 1 | Avoid anonymous pull throttling during verification. | -| notify-release-failure | masci/datadog | Third-party | 1 | Named, pinned notification-only survivor; the immutable ref, key custody and re-pin review are recorded above. | - -The locked-tools composite additionally uses one SHA-pinned first-party -`actions/checkout` to fetch the locked gon source. `docker/setup-buildx-action` -is allowed but not currently used: Buildx comes from locked-tools. The mint -action is allowed but this workflow does not currently need an App private key. -The allowlist reduces download exposure; it does not eliminate the runner's -pre-step action downloads. Datadog reporting preserves main's action pin, -event configuration and annotation plumbing. Offline fixtures check the exact -allowlist row, step-only key custody and best-effort configuration, including -negative controls. Live failure delivery remains a separate rollout check. +### Pinned Release Action Custody + +Review and organization Actions policy own action drift. Custody below describes +explicit inputs; job-scoped credentials remain available according to permissions. + +| Action | SHA | Which job | Custody | +| --- | --- | --- | --- | +| actions/checkout | `fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09` | All jobs except notify-release-failure; locked-tools gon checkout | Job-scoped `GITHUB_TOKEN`; `persist-credentials: false`. | +| actions/github-script | `f28e40c7f34bde8b3046d885e986cb6290c5673b` | record-registry-api | Job OIDC credentials; masked registry-audience token output. | +| sigstore/cosign-installer | `398d4b0eeef1380460a10c8013a76f728fb906ac` | goreleaser-binaries, goreleaser-windows, goreleaser-docker, publish-release-manifest, verify-release | Tool bootstrap in OIDC-enabled jobs; no explicit secret input. | +| apple-actions/import-codesign-certs | `253ddeeac23f2bdad1646faac5c8c2832e800071` | goreleaser-binaries | Step inputs `APPLE_SIGNING_KEY_P12` and `APPLE_SIGNING_KEY_P12_PASSWORD`. | +| aws-actions/configure-aws-credentials | `61815dcd50bd041e203e49132bacad1fd04d2708` | goreleaser-binaries, goreleaser-windows, goreleaser-docker, publish-release-manifest, verify-release | Exchanges job OIDC for scoped AWS role credentials. | +| aws-actions/amazon-ecr-login | `03f1aad4c6c7ffd436567f42f9384779290529bd` | goreleaser-docker, verify-release | Job AWS credentials; registry authentication for publishing or pulling. | +| docker/setup-qemu-action | `c7c53464625b32c7a7e944ae62b3e17d2b600130` | goreleaser-docker | Digest-pinned binfmt setup in an OIDC-enabled job; no explicit secret input. | +| masci/datadog | `93a16378298f4e1980f13f6b412c0b8e66b96185` | notify-release-failure | `DATADOG_API_KEY` (Datadog org write key), only step `with: api-key`; non-signing, notification only. Best-effort (`continue-on-error: true`), five-minute job; event-only (added metric removed). Re-pin review: diff the action's tag-to-SHA source; re-pinning is manual (no Renovate tracking exists for workflow SHAs). | ### Locked Release Tools @@ -443,7 +383,8 @@ requires `GOFLAGS=-mod=readonly`, `GOPROXY=https://proxy.golang.org` and Cosign v2.5.2 is installed by `sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac`. That action verifies its bootstrap binary against hashes embedded in the -pinned action and verifies other requested versions by signature. +pinned action and verifies other requested versions by signature. The +failure-only Datadog action is also commit-pinned. Buildx is never downloaded: `scripts/setup-release-buildx.sh` prints the runner-bundled plugin's version, fails if it is absent, and creates a builder diff --git a/scripts/check-release-actions.py b/scripts/check-release-actions.py deleted file mode 100755 index 519a101..0000000 --- a/scripts/check-release-actions.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -"""Reject unreviewed action downloads in credential-bearing release jobs.""" -import json -import os -from pathlib import Path -import re -import subprocess -import sys - -ROOT = Path(__file__).resolve().parent.parent -ALLOWED_ACTIONS = { - line.strip() for line in (ROOT / "tools/privileged-actions.txt").read_text().splitlines() - if line.strip() and not line.startswith("#") -} -LOCAL_ACTIONS = { - "./_workflows/.github/actions/ca-trust-guard", - "./_workflows/.github/actions/locked-tools", -} -PRIVILEGED_JOBS = { - "validate-inputs", "goreleaser-binaries", "goreleaser-windows", - "goreleaser-docker", "publish-release-manifest", "record-registry-api", - "verify-release", "notify-release-failure", -} - - -def load_yaml(path): - return json.loads(subprocess.check_output(["yq", ".", str(path)], text=True)) - - -def check_steps(steps, location): - errors = [] - for step in steps: - action = step.get("uses") - if action is None: - continue - if action in LOCAL_ACTIONS: - continue - name, separator, ref = action.partition("@") - components = name.split("/") - first_party = (len(components) >= 2 and components[0] == "actions" - and all(re.fullmatch(r"[A-Za-z0-9_.-]+", part) and part not in {".", ".."} - for part in components[1:])) - if (not first_party and name not in ALLOWED_ACTIONS and action not in ALLOWED_ACTIONS) or not separator: - errors.append(f"{location}: action not allowlisted: {action}") - elif not re.fullmatch(r"[0-9a-f]{40}", ref): - errors.append(f"{location}: action must use a full commit SHA: {action}") - return errors - - -def check_local_bootstrap(steps, location): - errors = [] - ready = False - for step in steps: - action = step.get("uses", "") - settings = step.get("with") or {} - checkout = action.startswith("actions/checkout@") - if "with" in step and not isinstance(step["with"], dict): - errors.append(f"{location}: action inputs must be a mapping") - if checkout: - ready = False - continue - if checkout: - path = settings.get("path", "") - if not isinstance(path, str): - errors.append(f"{location}: checkout path must be a string") - ready = False - continue - rel = os.path.normpath(str(path or ".")) - if (rel in (".", "_workflows") or rel.startswith("_workflows/") - or rel.startswith("..") or os.path.isabs(rel) or "${{" in str(path)): - ready = (re.fullmatch(r"actions/checkout@[0-9a-f]{40}", action) is not None - and settings.get("repository") == "ConductorOne/github-workflows" - and settings.get("path") == "_workflows" - and settings.get("ref") == "${{ job.workflow_sha }}" - and settings.get("persist-credentials") is False - and "token" not in settings and "if" not in step) - if action in LOCAL_ACTIONS and not ready: - errors.append(f"{location}: local action requires the preceding pinned workflow checkout") - return errors - - -def references_secrets(value): - if isinstance(value, str): - # Match the context itself too (for example, toJSON(secrets)). - return re.search(r"\bsecrets\b", value) is not None - if isinstance(value, dict): - return any(references_secrets(item) for item in value.values()) - if isinstance(value, list): - return any(references_secrets(item) for item in value) - return False - - -def check_workflow(workflow): - errors = [] - for name, job in workflow["jobs"].items(): - permissions = job.get("permissions", workflow.get("permissions", {})) - oidc = permissions == "write-all" or ( - isinstance(permissions, dict) and permissions.get("id-token") == "write") - inherited_secret = references_secrets(workflow.get("env", {})) - if name in PRIVILEGED_JOBS or oidc or job.get("secrets") or inherited_secret or references_secrets(job): - errors.extend(check_steps([job] if "uses" in job else job.get("steps", []), name)) - errors.extend(check_local_bootstrap(job.get("steps", []), name)) - return errors - - -def check_repository(root): - errors = check_workflow(load_yaml(root / ".github/workflows/release.yaml")) - for action in sorted(LOCAL_ACTIONS): - path = root / action.removeprefix("./_workflows/") / "action.yml" - errors.extend(check_steps(load_yaml(path)["runs"]["steps"], action)) - return errors - - -if __name__ == "__main__": - failures = check_repository(ROOT) - for failure in failures: - print(failure, file=sys.stderr) - sys.exit(bool(failures)) diff --git a/scripts/test-release-actions.py b/scripts/test-release-actions.py deleted file mode 100755 index d21006f..0000000 --- a/scripts/test-release-actions.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env python3 -"""Offline positive and adverse controls for privileged action policy.""" -import copy -import importlib.util -import json -from pathlib import Path -import subprocess -import sys -import unittest - -ROOT = Path(__file__).resolve().parent.parent -sys.dont_write_bytecode = True - - -def load_module(name): - spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / f"{name}.py") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -policy = load_module("check-release-actions") -DATADOG_ACTION = "masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185" -DATADOG_KEY = "${{ secrets.DATADOG_API_KEY }}" - - -def notification_errors(workflow): - errors = [] - job = workflow["jobs"]["notify-release-failure"] - notification = job["steps"][-1] - if job.get("if") != "failure()" or job.get("timeout-minutes") != 5: - errors.append("failure notification must remain conditional and bounded") - if job.get("permissions") != {"checks": "read", "actions": "read", "contents": "read"}: - errors.append("notification job must remain non-signing and read-only") - if notification.get("uses") != DATADOG_ACTION or notification.get("continue-on-error") is not True: - errors.append("notification action must remain pinned and best-effort") - if notification.get("with", {}).get("api-key") != DATADOG_KEY: - errors.append("the notification step must receive the key directly") - if "DATADOG_API_KEY" in json.dumps(workflow.get("env", {})).upper(): - errors.append("the notification key must not be workflow-scoped") - for name, candidate_job in workflow["jobs"].items(): - if "DATADOG_API_KEY" in json.dumps({key: value for key, value in candidate_job.items() if key != "steps"}).upper(): - errors.append("the notification key must not be job-scoped") - for step in candidate_job.get("steps", []): - remainder = copy.deepcopy(step) - if name == "notify-release-failure" and step is notification: - remainder.get("with", {}).pop("api-key", None) - elif step.get("uses", "").startswith("masci/datadog@"): - errors.append("the Datadog action is notification-only") - if "DATADOG_API_KEY" in json.dumps(remainder).upper(): - errors.append("the notification key escaped its single action input") - return errors - - -class ReleaseActionsTest(unittest.TestCase): - def test_repository_policy(self): - self.assertEqual(policy.check_repository(ROOT), []) - - def test_each_guarded_job_rejects_unreviewed_action_and_mutable_pin(self): - workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") - for job in policy.PRIVILEGED_JOBS: - for action in ("unreviewed/action@" + "a" * 40, "actions/checkout@v5", - "./_workflows/.github/actions/unreviewed", "docker://unreviewed:latest"): - with self.subTest(job=job, action=action): - changed = copy.deepcopy(workflow) - changed["jobs"][job]["steps"].append({"uses": action}) - self.assertTrue(policy.check_workflow(changed)) - - def test_new_credential_jobs_cannot_escape_inventory(self): - for job in ({"permissions": {"id-token": "write"}}, - {"env": {"TOKEN": "${{ secrets.NEW_TOKEN }}"}}, - {"env": {"TOKEN": "${{ secrets['NEW_TOKEN'] }}"}}, - {"env": {"TOKEN": "${{ secrets\n ['NEW_TOKEN'] }}"}}, - {"env": {"TOKEN": "${{ toJSON(secrets) }}"}}, - {"permissions": "write-all"}): - job["steps"] = [{"uses": "unreviewed/action@" + "a" * 40}] - self.assertTrue(policy.check_workflow({"jobs": {"new-job": job}})) - self.assertTrue(policy.check_workflow({"permissions": {"id-token": "write"}, - "jobs": {"new-job": {"steps": job["steps"]}}})) - self.assertTrue(policy.check_workflow({"env": {"TOKEN": "${{ secrets.RELEASE_TOKEN }}"}, - "jobs": {"new-job": {"steps": job["steps"]}}})) - self.assertTrue(policy.check_workflow({"jobs": {"new-job": {"secrets": "inherit", - "uses": "unreviewed/workflow@v1"}}})) - - def test_local_actions_require_immutable_checkout(self): - steps = policy.load_yaml(ROOT / ".github/workflows/release.yaml")["jobs"]["validate-inputs"]["steps"][:2] - self.assertEqual(policy.check_local_bootstrap(steps, "fixture"), []) - for field, value in (("ref", "main"), ("persist-credentials", True), ("token", "secret")): - changed = copy.deepcopy(steps) - changed[0]["with"][field] = value - self.assertTrue(policy.check_local_bootstrap(changed, "fixture")) - self.assertTrue(policy.check_local_bootstrap(list(reversed(steps)), "fixture")) - - def test_empty_or_non_mapping_inputs_fail_cleanly(self): - steps = policy.load_yaml(ROOT / ".github/workflows/release.yaml")["jobs"]["validate-inputs"]["steps"][:2] - for inputs in (None, [], ["path", "_workflows"], "", "not-a-mapping", False, 0): - with self.subTest(inputs=inputs): - changed = copy.deepcopy(steps) - changed[0]["with"] = inputs - self.assertTrue(policy.check_local_bootstrap(changed, "fixture")) - - def test_later_checkout_cannot_overwrite_verified_workflows(self): - steps = policy.load_yaml(ROOT / ".github/workflows/release.yaml")["jobs"]["validate-inputs"]["steps"][:2] - for path in (None, ".", "", "_workflows", "./_workflows", "_workflows/", "_workflows/.", - "_workflows/.github", "${{ github.workspace }}/_workflows", "..", "../caller", - "/tmp/workflows"): - with self.subTest(path=path): - overwrite = copy.deepcopy(steps[0]) - overwrite["with"].update(path=path, repository="unreviewed/workflows", ref="main") - if path is None: - overwrite["with"].pop("path") - self.assertTrue(policy.check_local_bootstrap([steps[0], overwrite, steps[1]], "fixture")) - other_checkout = copy.deepcopy(overwrite) - other_checkout["with"]["path"] = "_caller" - self.assertEqual(policy.check_local_bootstrap([steps[0], other_checkout, steps[1]], "fixture"), []) - self.assertEqual(policy.check_local_bootstrap([steps[0], steps[0], steps[1]], "fixture"), []) - - def test_first_party_actions_require_sha(self): - for name in ("checkout", "github-script", "upload-artifact", "download-artifact", - "setup-go", "cache", "cache/restore", "cache/save", "create-github-app-token"): - self.assertEqual(policy.check_steps([{"uses": f"actions/{name}@" + "a" * 40}], "first-party"), []) - self.assertTrue(policy.check_steps([{"uses": f"actions/{name}@v2"}], "first-party")) - self.assertTrue(policy.check_steps([{"uses": "actions-impostor/checkout@" + "a" * 40}], "third-party")) - - - def test_failure_reporting_restores_the_pinned_action_and_event(self): - workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") - self.assertEqual(notification_errors(workflow), []) - step = workflow["jobs"]["notify-release-failure"]["steps"][-1] - self.assertEqual(set(step["with"]), {"api-key", "api-url", "events"}) - self.assertEqual(step["with"]["api-url"], "https://us3.datadoghq.com/") - self.assertNotIn("run", step) - self.assertNotIn("env", step) - events = json.loads(subprocess.check_output(["yq", "."], input=step["with"]["events"], text=True)) - self.assertEqual(len(events), 1) - event = events[0] - self.assertEqual(set(event), {"title", "text", "alert_type", "host", "tags"}) - self.assertEqual(event["title"], "Baton Connector Release Failed") - self.assertEqual(event["alert_type"], "error") - self.assertEqual(event["host"], "${{ github.repository_owner }}") - self.assertEqual(event["tags"], [ - "github_repository:${{ github.event.repository.full_name }}", - "github_release_tag:${{ inputs.tag }}", "github_workflow:baton-connector-release"]) - self.assertIn("${{ steps.collect-ann.outputs.annotations }}", event["text"]) - self.assertIn(DATADOG_ACTION, policy.ALLOWED_ACTIONS) - self.assertNotIn("masci/datadog", policy.ALLOWED_ACTIONS) - self.assertEqual(policy.check_steps([step], "notify-release-failure"), []) - for ref in ("v1", "a" * 40): - self.assertTrue(policy.check_steps([{"uses": f"masci/datadog@{ref}"}], "notify-release-failure")) - - def test_notification_custody_and_best_effort_negative_controls(self): - workflow = policy.load_yaml(ROOT / ".github/workflows/release.yaml") - changed = copy.deepcopy(workflow) - changed["env"]["DD_KEY"] = DATADOG_KEY - self.assertTrue(notification_errors(changed)) - changed = copy.deepcopy(workflow) - changed["jobs"]["notify-release-failure"]["env"] = {"DD_KEY": DATADOG_KEY} - self.assertTrue(notification_errors(changed)) - changed = copy.deepcopy(workflow) - changed["jobs"]["goreleaser-binaries"]["steps"].append({"env": {"DD_KEY": DATADOG_KEY}, "run": "true"}) - self.assertTrue(notification_errors(changed)) - changed = copy.deepcopy(workflow) - changed["jobs"]["notify-release-failure"]["steps"][-1]["env"] = {"DD_KEY": DATADOG_KEY} - self.assertTrue(notification_errors(changed)) - changed = copy.deepcopy(workflow) - changed["jobs"]["notify-release-failure"]["steps"][-1]["continue-on-error"] = False - self.assertTrue(notification_errors(changed)) - changed = copy.deepcopy(workflow) - changed["jobs"]["notify-release-failure"]["timeout-minutes"] = 10 - self.assertTrue(notification_errors(changed)) - changed = copy.deepcopy(workflow) - changed["jobs"]["goreleaser-binaries"]["steps"].append({"uses": DATADOG_ACTION}) - self.assertTrue(notification_errors(changed)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/privileged-actions.txt b/tools/privileged-actions.txt deleted file mode 100644 index f192e1d..0000000 --- a/tools/privileged-actions.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Third-party repositories or exact action references allowed in credential-bearing release jobs. -# First-party actions/* are allowed separately; all remote actions need full SHAs. -sigstore/cosign-installer -aws-actions/configure-aws-credentials -aws-actions/amazon-ecr-login -apple-actions/import-codesign-certs -docker/setup-qemu-action -docker/setup-buildx-action -# Notification only; custody: DATADOG_API_KEY (Datadog org write key, non-signing). -# Re-pin review: diff the action's tag-to-SHA source; re-pinning is manual (no Renovate tracking exists for workflow SHAs). -masci/datadog@93a16378298f4e1980f13f6b412c0b8e66b96185 From 6583b65111315dc8337e478b3ceebcd56b2283ce Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 21:24:56 +0000 Subject: [PATCH 12/12] Describe Datadog notification as event-only Co-authored-by: c1-squire-dev[bot] --- docs/release-workflow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-workflow.md b/docs/release-workflow.md index dc1eb11..61d5188 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -290,7 +290,7 @@ explicit inputs; job-scoped credentials remain available according to permission | aws-actions/configure-aws-credentials | `61815dcd50bd041e203e49132bacad1fd04d2708` | goreleaser-binaries, goreleaser-windows, goreleaser-docker, publish-release-manifest, verify-release | Exchanges job OIDC for scoped AWS role credentials. | | aws-actions/amazon-ecr-login | `03f1aad4c6c7ffd436567f42f9384779290529bd` | goreleaser-docker, verify-release | Job AWS credentials; registry authentication for publishing or pulling. | | docker/setup-qemu-action | `c7c53464625b32c7a7e944ae62b3e17d2b600130` | goreleaser-docker | Digest-pinned binfmt setup in an OIDC-enabled job; no explicit secret input. | -| masci/datadog | `93a16378298f4e1980f13f6b412c0b8e66b96185` | notify-release-failure | `DATADOG_API_KEY` (Datadog org write key), only step `with: api-key`; non-signing, notification only. Best-effort (`continue-on-error: true`), five-minute job; event-only (added metric removed). Re-pin review: diff the action's tag-to-SHA source; re-pinning is manual (no Renovate tracking exists for workflow SHAs). | +| masci/datadog | `93a16378298f4e1980f13f6b412c0b8e66b96185` | notify-release-failure | `DATADOG_API_KEY` (Datadog org write key), only step `with: api-key`; non-signing, notification only. Best-effort (`continue-on-error: true`), five-minute job; event-only. Re-pin review: diff the action's tag-to-SHA source; re-pinning is manual (no Renovate tracking exists for workflow SHAs). | ### Locked Release Tools