diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c1974f1c0..43ca2780e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,5 +1,17 @@ # GitHub Actions Workflows +## Trust contract + +`scripts/check_workflow_trust.py` validates every workflow during the script +smoke suite. Remote actions and reusable workflows must use full commit SHAs, +container actions must use SHA-256 digests, and GitHub-hosted runners must use +dated image labels rather than moving `-latest` aliases. + +Every `actions/checkout` step must also set `persist-credentials` explicitly. +Use `false` unless a later command in the same job performs an authenticated +Git write. The small number of write-path exceptions use `true` with the +machine-checked `# required: authenticated git writes` rationale. + ## Issue Triage Automation **Files**: diff --git a/.github/workflows/backfill-release-assets.yml b/.github/workflows/backfill-release-assets.yml index 243fd2727..462ed85d0 100644 --- a/.github/workflows/backfill-release-assets.yml +++ b/.github/workflows/backfill-release-assets.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 810029e32..c92845d90 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -34,6 +34,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 - name: Install gitleaks @@ -72,6 +73,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 - name: Classify changed files @@ -110,6 +112,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -179,6 +183,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -228,6 +234,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false # test_gitleaks_ignore.py resolves historical fingerprints against # branch- and tag-reachable commits, so it needs full history. fetch-depth: 0 @@ -258,6 +265,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 59334cc2d..0cdf123ef 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -255,6 +255,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -356,6 +358,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -644,6 +648,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 - name: Validate candidate identity diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index de7d5bd77..1f18a4b31 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -26,6 +26,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 path: repos/pulse diff --git a/.github/workflows/close-needs-retest-timeout.yml b/.github/workflows/close-needs-retest-timeout.yml index 0efcf6b0e..c6071c99e 100644 --- a/.github/workflows/close-needs-retest-timeout.yml +++ b/.github/workflows/close-needs-retest-timeout.yml @@ -31,6 +31,7 @@ jobs: - name: Check out triage helper uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false sparse-checkout: | .github/scripts/issue-version-triage.cjs sparse-checkout-cone-mode: false diff --git a/.github/workflows/compile-release-payload.yml b/.github/workflows/compile-release-payload.yml index 6ece992ca..16c9f3e24 100644 --- a/.github/workflows/compile-release-payload.yml +++ b/.github/workflows/compile-release-payload.yml @@ -40,6 +40,7 @@ jobs: - name: Checkout exact release source uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 ref: ${{ inputs.source_sha }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 06edacd56..d9f254075 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -139,6 +139,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 - name: Resolve required release branch @@ -303,6 +304,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -334,6 +337,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -369,6 +374,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -404,6 +411,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Download verified frontend bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -440,6 +449,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -603,6 +614,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -700,6 +713,7 @@ jobs: if: ${{ needs.prepare.outputs.visual_capture_count != '0' }} uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 - name: Install browser capture runtime @@ -751,6 +765,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: true # required: authenticated git writes fetch-depth: 0 - name: Download immutable release candidate @@ -1182,6 +1197,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -1565,6 +1582,8 @@ jobs: steps: - name: Checkout release integrity control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Publish the fully staged release env: @@ -1889,6 +1908,8 @@ jobs: steps: - name: Checkout release integrity control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Enforce irreversible release commit outcome env: diff --git a/.github/workflows/eval-model-matrix.yml b/.github/workflows/eval-model-matrix.yml index 2c560510e..793ad5748 100644 --- a/.github/workflows/eval-model-matrix.yml +++ b/.github/workflows/eval-model-matrix.yml @@ -36,6 +36,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml index aa3e1dece..89fbc4151 100644 --- a/.github/workflows/helm-ci.yml +++ b/.github/workflows/helm-ci.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 diff --git a/.github/workflows/helm-pages.yml b/.github/workflows/helm-pages.yml index 4a572ae47..c09040275 100644 --- a/.github/workflows/helm-pages.yml +++ b/.github/workflows/helm-pages.yml @@ -151,7 +151,7 @@ jobs: ref: gh-pages path: gh-pages fetch-depth: 1 - persist-credentials: true + persist-credentials: true # required: authenticated git writes - name: Publish chart release and merge Pages index env: diff --git a/.github/workflows/install-sh-smoke.yml b/.github/workflows/install-sh-smoke.yml index f52a7bf85..57abc1867 100644 --- a/.github/workflows/install-sh-smoke.yml +++ b/.github/workflows/install-sh-smoke.yml @@ -101,6 +101,8 @@ jobs: steps: - name: Checkout repository (for README key extraction) uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Resolve smoke inputs id: inputs diff --git a/.github/workflows/issue-version-label-sync.yml b/.github/workflows/issue-version-label-sync.yml index 889a057db..b08d8498f 100644 --- a/.github/workflows/issue-version-label-sync.yml +++ b/.github/workflows/issue-version-label-sync.yml @@ -25,6 +25,7 @@ jobs: - name: Check out triage helper uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false sparse-checkout: | .github/scripts/issue-version-triage.cjs sparse-checkout-cone-mode: false diff --git a/.github/workflows/issue-version-retest-comment.yml b/.github/workflows/issue-version-retest-comment.yml index 54048907d..5ebedb364 100644 --- a/.github/workflows/issue-version-retest-comment.yml +++ b/.github/workflows/issue-version-retest-comment.yml @@ -26,6 +26,7 @@ jobs: - name: Check out triage helper uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false sparse-checkout: | .github/scripts/issue-version-triage.cjs sparse-checkout-cone-mode: false diff --git a/.github/workflows/patrol-qualification-live.yml b/.github/workflows/patrol-qualification-live.yml index 5f84ac8c9..5b9b81ab6 100644 --- a/.github/workflows/patrol-qualification-live.yml +++ b/.github/workflows/patrol-qualification-live.yml @@ -52,6 +52,8 @@ jobs: steps: - name: Checkout pinned source uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/patrol-qualification-regression.yml b/.github/workflows/patrol-qualification-regression.yml index ab250b5d2..9bff2e0ec 100644 --- a/.github/workflows/patrol-qualification-regression.yml +++ b/.github/workflows/patrol-qualification-regression.yml @@ -43,6 +43,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/promote-floating-tags.yml b/.github/workflows/promote-floating-tags.yml index a2183bfeb..7fc6c8551 100644 --- a/.github/workflows/promote-floating-tags.yml +++ b/.github/workflows/promote-floating-tags.yml @@ -65,6 +65,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/public-docs.yml b/.github/workflows/public-docs.yml index fb729fc23..7696b197b 100644 --- a/.github/workflows/public-docs.yml +++ b/.github/workflows/public-docs.yml @@ -27,10 +27,12 @@ concurrency: jobs: check: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Check out repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Validate public documentation run: python3 scripts/check_public_docs.py diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index fa4cdf86a..af398db14 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -56,6 +56,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 fetch-tags: true @@ -239,6 +240,8 @@ jobs: steps: - name: Checkout release verification control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 6a5adca37..79d485e59 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -87,6 +87,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/recover-demo-server.yml b/.github/workflows/recover-demo-server.yml index 3087e5d9d..19a2c02d6 100644 --- a/.github/workflows/recover-demo-server.yml +++ b/.github/workflows/recover-demo-server.yml @@ -25,6 +25,7 @@ jobs: - name: Checkout exact recovery implementation uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/recover-release-activation.yml b/.github/workflows/recover-release-activation.yml index 24b20dc8f..d9460b953 100644 --- a/.github/workflows/recover-release-activation.yml +++ b/.github/workflows/recover-release-activation.yml @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout release control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Qualify the quarantined source run id: qualify diff --git a/.github/workflows/release-convergence.yml b/.github/workflows/release-convergence.yml index 701226c61..b7b55f963 100644 --- a/.github/workflows/release-convergence.yml +++ b/.github/workflows/release-convergence.yml @@ -58,6 +58,8 @@ jobs: steps: - name: Checkout release integrity control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Wait for verified public activation marker id: marker @@ -211,7 +213,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 1 - persist-credentials: true + persist-credentials: true # required: authenticated git writes - name: Acquire repository-ref lease id: acquire @@ -465,7 +467,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 1 - persist-credentials: true + persist-credentials: true # required: authenticated git writes - name: Release owned repository-ref lease env: diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index c124fd4ec..92049867e 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -121,6 +121,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 - name: Resolve required release branch diff --git a/.github/workflows/repo-boundary-audit.yml b/.github/workflows/repo-boundary-audit.yml index 93878668f..3166a87c9 100644 --- a/.github/workflows/repo-boundary-audit.yml +++ b/.github/workflows/repo-boundary-audit.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Install boundary audit dependencies run: | diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index bed76583d..f78819fa0 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -57,6 +59,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/signpath-test-signing.yml b/.github/workflows/signpath-test-signing.yml index 41752420c..9df56439a 100644 --- a/.github/workflows/signpath-test-signing.yml +++ b/.github/workflows/signpath-test-signing.yml @@ -27,6 +27,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 542e893ee..28cfecffc 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -42,6 +42,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -81,6 +83,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -232,6 +236,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/unified-agent-native.yml b/.github/workflows/unified-agent-native.yml index 99ce478a5..568af13bb 100644 --- a/.github/workflows/unified-agent-native.yml +++ b/.github/workflows/unified-agent-native.yml @@ -74,6 +74,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -178,6 +180,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 17ec2fa07..0427fcfd2 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -130,6 +130,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 fetch-tags: true @@ -272,6 +273,7 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + persist-credentials: false fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/validate-release-assets.yml b/.github/workflows/validate-release-assets.yml index 8da8032a2..c3bf72a87 100644 --- a/.github/workflows/validate-release-assets.yml +++ b/.github/workflows/validate-release-assets.yml @@ -72,6 +72,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Determine release context id: context diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py new file mode 100644 index 000000000..c94a0733a --- /dev/null +++ b/scripts/check_workflow_trust.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Fail closed when GitHub Actions trust inputs become mutable or implicit.""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +ACTION_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +CONTAINER_DIGEST_RE = re.compile(r"^docker://.+@sha256:[0-9a-f]{64}$") +HOSTED_LATEST_RE = re.compile(r"\b(?:ubuntu|windows|macos)-latest\b") +USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)") +CHECKOUT_PREFIX = "actions/checkout@" +WRITE_CREDENTIAL_RATIONALE = "# required: authenticated git writes" + + +@dataclass(frozen=True) +class Finding: + path: Path + line: int + message: str + + def render(self) -> str: + return f"{self.path}:{self.line}: {self.message}" + + +def _indent(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def _checkout_block(lines: list[str], uses_index: int) -> list[tuple[int, str]]: + """Return lines belonging to the checkout step after its uses declaration.""" + uses_indent = _indent(lines[uses_index]) + block: list[tuple[int, str]] = [] + for index in range(uses_index + 1, len(lines)): + line = lines[index] + stripped = line.strip() + if stripped and ( + _indent(line) < uses_indent + or (_indent(line) == uses_indent and stripped.startswith("- ")) + ): + break + block.append((index, line)) + return block + + +def audit_workflow(path: Path) -> list[Finding]: + lines = path.read_text(encoding="utf-8").splitlines() + findings: list[Finding] = [] + + for index, line in enumerate(lines): + line_number = index + 1 + code = line.split("#", 1)[0] + if HOSTED_LATEST_RE.search(code): + findings.append( + Finding( + path, + line_number, + "mutable hosted runner label; use an explicit dated image", + ) + ) + + match = USES_RE.search(code) + if not match: + continue + dependency = match.group(1).strip("'\"") + if dependency.startswith("./"): + continue + if dependency.startswith("docker://"): + if not CONTAINER_DIGEST_RE.fullmatch(dependency): + findings.append( + Finding( + path, + line_number, + "container action is not pinned to a sha256 digest", + ) + ) + continue + + owner_and_action, separator, ref = dependency.rpartition("@") + if not separator or "/" not in owner_and_action or not ACTION_SHA_RE.fullmatch(ref): + findings.append( + Finding( + path, + line_number, + "remote action or reusable workflow is not pinned to a full commit SHA", + ) + ) + continue + + # GitHub repository names are case-insensitive, so normalize before + # applying checkout-specific credential controls. + if not dependency.lower().startswith(CHECKOUT_PREFIX): + continue + credential_settings = [ + (block_index, block_line) + for block_index, block_line in _checkout_block(lines, index) + if re.match(r"^\s*persist-credentials\s*:", block_line) + ] + if len(credential_settings) != 1: + findings.append( + Finding( + path, + line_number, + "checkout must set persist-credentials explicitly exactly once", + ) + ) + continue + + setting_index, setting = credential_settings[0] + value_match = re.match( + r"^\s*persist-credentials\s*:\s*(true|false)\b", setting + ) + if not value_match: + findings.append( + Finding( + path, + setting_index + 1, + "persist-credentials must be the literal true or false", + ) + ) + elif value_match.group(1) == "true" and WRITE_CREDENTIAL_RATIONALE not in setting: + findings.append( + Finding( + path, + setting_index + 1, + f"persisted checkout credentials require {WRITE_CREDENTIAL_RATIONALE}", + ) + ) + + return findings + + +def audit_directory(workflow_directory: Path) -> list[Finding]: + findings: list[Finding] = [] + paths = sorted(workflow_directory.glob("*.yml")) + sorted( + workflow_directory.glob("*.yaml") + ) + for path in paths: + findings.extend(audit_workflow(path)) + return findings + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "workflow_directory", + nargs="?", + type=Path, + default=Path(__file__).resolve().parents[1] / ".github" / "workflows", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not args.workflow_directory.is_dir(): + print(f"workflow directory not found: {args.workflow_directory}", file=sys.stderr) + return 2 + findings = audit_directory(args.workflow_directory) + if findings: + for finding in findings: + print(finding.render(), file=sys.stderr) + print(f"GitHub Actions trust validation failed ({len(findings)} finding(s)).", file=sys.stderr) + return 1 + print("GitHub Actions trust validation passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py new file mode 100644 index 000000000..7878a64bf --- /dev/null +++ b/scripts/tests/test_workflow_trust.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Tests for the GitHub Actions trust contract.""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = REPO_ROOT / "scripts" / "check_workflow_trust.py" +SPEC = importlib.util.spec_from_file_location("check_workflow_trust", MODULE_PATH) +assert SPEC and SPEC.loader +workflow_trust = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = workflow_trust +SPEC.loader.exec_module(workflow_trust) + +PIN = "a" * 40 +DIGEST = "b" * 64 + + +class WorkflowTrustTest(unittest.TestCase): + def audit(self, content: str) -> list[str]: + with tempfile.TemporaryDirectory() as temporary_directory: + path = Path(temporary_directory) / "test.yml" + path.write_text(content, encoding="utf-8") + return [finding.message for finding in workflow_trust.audit_workflow(path)] + + def test_accepts_immutable_dependencies_and_explicit_checkout_credentials(self) -> None: + findings = self.audit( + f"""jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@{PIN} + with: + persist-credentials: false + - uses: owner/action/path@{PIN} # v1 + - uses: docker://example/image@sha256:{DIGEST} + - uses: ./.github/workflows/local.yml +""" + ) + self.assertEqual(findings, []) + + def test_rejects_mutable_action_runner_and_container_references(self) -> None: + findings = self.audit( + """jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: owner/action@v2 + - uses: docker://alpine:3.22 +""" + ) + self.assertTrue(any("mutable hosted runner" in finding for finding in findings)) + self.assertTrue(any("full commit SHA" in finding for finding in findings)) + self.assertTrue(any("sha256 digest" in finding for finding in findings)) + + def test_rejects_implicit_or_unjustified_checkout_credentials(self) -> None: + omitted = self.audit( + f"""steps: + - uses: actions/checkout@{PIN} +""" + ) + self.assertTrue(any("must set persist-credentials" in finding for finding in omitted)) + + unjustified = self.audit( + f"""steps: + - uses: actions/checkout@{PIN} + with: + persist-credentials: true +""" + ) + self.assertTrue(any("require # required" in finding for finding in unjustified)) + + mixed_case = self.audit( + f"""steps: + - uses: Actions/Checkout@{PIN} +""" + ) + self.assertTrue( + any("must set persist-credentials" in finding for finding in mixed_case) + ) + + def test_accepts_documented_authenticated_git_write(self) -> None: + findings = self.audit( + f"""steps: + - uses: actions/checkout@{PIN} + with: + persist-credentials: true # required: authenticated git writes +""" + ) + self.assertEqual(findings, []) + + def test_repository_workflows_satisfy_contract(self) -> None: + findings = workflow_trust.audit_directory(REPO_ROOT / ".github" / "workflows") + self.assertEqual([finding.render() for finding in findings], []) + + +if __name__ == "__main__": + unittest.main()