diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 43ca2780e..e319f2d19 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -12,6 +12,11 @@ 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. +Each workflow declares its default `GITHUB_TOKEN` permissions explicitly and +enumerates scopes instead of using `read-all` or `write-all`. Workflow inputs, +secrets, and `github.token` are passed to `run` steps through `env`; they are +data and must never be interpolated into the generated shell program. + ## Issue Triage Automation **Files**: diff --git a/.github/workflows/backfill-release-assets.yml b/.github/workflows/backfill-release-assets.yml index 462ed85d0..03e366f99 100644 --- a/.github/workflows/backfill-release-assets.yml +++ b/.github/workflows/backfill-release-assets.yml @@ -55,9 +55,14 @@ jobs: GH_TOKEN: ${{ github.token }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ inputs.tag }} run: | - ./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}" + ./scripts/backfill-release-assets.sh --tag "${TAG}" --repo "${REPOSITORY}" - name: Validate published release packet + env: + REPOSITORY: ${{ github.repository }} + TAG: ${{ inputs.tag }} run: | - ./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}" + ./scripts/validate-published-release.sh "${TAG}" "${REPOSITORY}" diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 0cdf123ef..b97aac0f8 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -274,6 +274,7 @@ jobs: APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + VERSION: ${{ inputs.version }} run: | set -euo pipefail for name in \ @@ -305,7 +306,7 @@ jobs: security list-keychains -d user -s "$keychain" ldflags="$(./scripts/release_ldflags.sh agent \ - --version "v${{ inputs.version }}" \ + --version "v${VERSION}" \ --update-public-keys "$PULSE_UPDATE_SIGNING_PUBLIC_KEY")" for arch in amd64 arm64; do output="native-agent-binaries/pulse-agent-darwin-${arch}" @@ -371,6 +372,7 @@ jobs: shell: pwsh env: PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + VERSION: ${{ inputs.version }} run: | $ErrorActionPreference = 'Stop' foreach ($name in @('PULSE_UPDATE_SIGNING_PUBLIC_KEY')) { @@ -380,7 +382,7 @@ jobs: } New-Item -ItemType Directory -Path unsigned-native-agent-binaries -Force | Out-Null - $ldflags = & bash ./scripts/release_ldflags.sh agent --version "v${{ inputs.version }}" --update-public-keys $env:PULSE_UPDATE_SIGNING_PUBLIC_KEY + $ldflags = & bash ./scripts/release_ldflags.sh agent --version "v$env:VERSION" --update-public-keys $env:PULSE_UPDATE_SIGNING_PUBLIC_KEY foreach ($arch in @('amd64', 'arm64', '386')) { $env:GOOS = 'windows' $env:GOARCH = $arch @@ -569,6 +571,7 @@ jobs: SIGNPATH_SIGNING_REQUEST_URL: ${{ steps.signpath.outputs.signing_request_url }} SIGNPATH_INPUT_ARTIFACT_ID: ${{ steps.signpath.outputs.github_input_artifact_id }} SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT: ${{ vars.SIGNPATH_EXPECTED_CERTIFICATE_SUBJECT }} + VERSION: ${{ inputs.version }} run: | $ErrorActionPreference = 'Stop' $sourceDir = if ($env:WINDOWS_SIGNING_BACKEND -eq 'signpath') { 'signed-native-agent-binaries' } else { 'unsigned-native-agent-binaries' } @@ -595,7 +598,7 @@ jobs: $evidence = [ordered]@{ schemaVersion = 1 backend = $env:WINDOWS_SIGNING_BACKEND - version = '${{ inputs.version }}' + version = $env:VERSION sourceSha = $env:GITHUB_SHA workflowRunUrl = "https://github.com/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID" signPathSigningRequestId = $env:SIGNPATH_SIGNING_REQUEST_ID @@ -639,11 +642,13 @@ jobs: steps: - name: Resolve candidate identity id: identity + env: + VERSION: ${{ inputs.version }} run: | set -euo pipefail - echo "artifact_name=release-candidate-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" - echo "manifest_artifact_name=release-candidate-manifest-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" - echo "container_artifact_name=release-container-payload-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "artifact_name=release-candidate-${GITHUB_SHA}-${VERSION}" >> "$GITHUB_OUTPUT" + echo "manifest_artifact_name=release-candidate-manifest-${GITHUB_SHA}-${VERSION}" >> "$GITHUB_OUTPUT" + echo "container_artifact_name=release-container-payload-${GITHUB_SHA}-${VERSION}" >> "$GITHUB_OUTPUT" - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -652,9 +657,11 @@ jobs: fetch-depth: 0 - name: Validate candidate identity + env: + VERSION: ${{ inputs.version }} run: | set -euo pipefail - test "$(tr -d '\n' < VERSION)" = "${{ inputs.version }}" + test "$(tr -d '\n' < VERSION)" = "${VERSION}" test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - name: Set up Go @@ -701,6 +708,7 @@ jobs: EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.artifact_digest }} EXPECTED_ARTIFACT_NAME: ${{ needs.obtain-release-payload.outputs.artifact_name }} EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }} + VERSION: ${{ inputs.version }} run: | set -euo pipefail [[ "${EXPECTED_ARTIFACT_ID}" =~ ^[0-9]+$ ]] @@ -732,7 +740,7 @@ jobs: python3 scripts/release_candidate_manifest.py verify-local \ --release-dir release-compiled/payload \ --manifest release-compiled/manifest/release-compiled.json \ - --version "${{ inputs.version }}" \ + --version "${VERSION}" \ --source-sha "${GITHUB_SHA}" mkdir -p release-candidate-manifest payload_manifest_sha256="$(sha256sum release-compiled/manifest/release-compiled.json | awk '{print $1}')" @@ -743,7 +751,7 @@ jobs: --arg payload_manifest_sha256 "${payload_manifest_sha256}" \ --argjson compiler_workflow_run_id "${EXPECTED_COMPILER_RUN_ID}" \ --argjson release_workflow_run_id "${GITHUB_RUN_ID}" \ - --arg version "${{ inputs.version }}" \ + --arg version "${VERSION}" \ --arg source_sha "${GITHUB_SHA}" \ '{schema_version: 2, trust_boundary: "separate-trusted-self-hosted-compiler-workflow", verified_on: "github-hosted", artifact_id: $artifact_id, artifact_name: $artifact_name, artifact_sha256: $artifact_sha256, payload_manifest_sha256: $payload_manifest_sha256, compiler_workflow_run_id: $compiler_workflow_run_id, release_workflow_run_id: $release_workflow_run_id, version: $version, source_sha: $source_sha}' \ > release-candidate-manifest/compiled-payload-verification.json @@ -770,7 +778,7 @@ jobs: path: release-candidate-manifest - name: Build release candidate - run: ./scripts/build-release.sh "${{ inputs.version }}" + run: ./scripts/build-release.sh "${VERSION}" env: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} @@ -779,6 +787,7 @@ jobs: PULSE_REQUIRE_WINDOWS_SIGNING: ${{ inputs.require_windows_signing }} PULSE_AGENT_NATIVE_BINARIES_DIR: ${{ (inputs.require_macos_signing || inputs.require_windows_signing) && format('{0}/native-agent-binaries', github.workspace) || '' }} PULSE_RELEASE_COMPILED_PAYLOAD_DIR: ${{ github.workspace }}/release-compiled/payload + VERSION: ${{ inputs.version }} - name: Validate installer signing key pins env: @@ -798,23 +807,29 @@ jobs: done - name: Validate complete candidate locally - run: ./scripts/validate-release.sh "${{ inputs.version }}" --skip-docker + env: + VERSION: ${{ inputs.version }} + run: ./scripts/validate-release.sh "${VERSION}" --skip-docker - name: Create immutable candidate manifest + env: + VERSION: ${{ inputs.version }} run: | python3 scripts/release_candidate_manifest.py create \ --release-dir release \ - --version "${{ inputs.version }}" \ + --version "${VERSION}" \ --source-sha "${GITHUB_SHA}" \ --output release-candidate-manifest/release-candidate.json - name: Create exact-candidate container payload + env: + VERSION: ${{ inputs.version }} run: | set -euo pipefail payload_root="$RUNNER_TEMP/release-container-payload" ./scripts/prepare-release-container-context.sh \ release \ - "${{ inputs.version }}" \ + "${VERSION}" \ "${payload_root}/payload/release" mkdir -p "${payload_root}/payload/compiled/binaries" for arch in amd64 arm64; do @@ -824,7 +839,7 @@ jobs: done python3 scripts/release_candidate_manifest.py create \ --release-dir "${payload_root}/payload" \ - --version "${{ inputs.version }}" \ + --version "${VERSION}" \ --source-sha "${GITHUB_SHA}" \ --output "${payload_root}/release-container-payload.json" diff --git a/.github/workflows/compile-release-payload.yml b/.github/workflows/compile-release-payload.yml index 16c9f3e24..25a5c38e5 100644 --- a/.github/workflows/compile-release-payload.yml +++ b/.github/workflows/compile-release-payload.yml @@ -45,10 +45,13 @@ jobs: ref: ${{ inputs.source_sha }} - name: Validate candidate identity + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + VERSION: ${{ inputs.version }} run: | set -euo pipefail - test "$(tr -d '\n' < VERSION)" = "${{ inputs.version }}" - test "$(git rev-parse HEAD)" = "${{ inputs.source_sha }}" + test "$(tr -d '\n' < VERSION)" = "${VERSION}" + test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}" - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -84,7 +87,8 @@ jobs: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} PULSE_RELEASE_BUILD_JOBS: "2" - run: ./scripts/build-release-binaries.sh "${{ inputs.version }}" "$RUNNER_TEMP/release-compiled" + VERSION: ${{ inputs.version }} + run: ./scripts/build-release-binaries.sh "${VERSION}" "$RUNNER_TEMP/release-compiled" - name: Upload exact-SHA compiled payload uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index af398db14..95b95daf2 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -62,8 +62,10 @@ jobs: - name: Extract version from release tag id: version + env: + INPUT_TAG: ${{ inputs.tag }} run: | - TAG="${{ inputs.tag }}" + TAG="${INPUT_TAG}" VERSION="${TAG#v}" # Detect if this is a prerelease (RC, alpha, beta) @@ -105,12 +107,15 @@ jobs: path: ${{ runner.temp }}/release-container-payload - name: Verify exact-candidate container payload + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + VERSION: ${{ steps.version.outputs.version }} run: | python3 scripts/release_candidate_manifest.py verify-local \ --release-dir "$RUNNER_TEMP/release-container-payload/payload" \ --manifest "$RUNNER_TEMP/release-container-payload/release-container-payload.json" \ - --version "${{ steps.version.outputs.version }}" \ - --source-sha "${{ inputs.source_sha }}" + --version "${VERSION}" \ + --source-sha "${EXPECTED_SOURCE_SHA}" - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 @@ -263,8 +268,11 @@ jobs: id: proof env: GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + TAG: ${{ inputs.tag }} run: | set -euo pipefail ./scripts/verify-release-container-images.sh \ - "${{ inputs.tag }}" "${{ inputs.source_sha }}" \ - "${{ github.repository }}" >> "$GITHUB_OUTPUT" + "${TAG}" "${SOURCE_SHA}" \ + "${REPOSITORY}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 79d485e59..9cb8f762d 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -139,8 +139,11 @@ jobs: path: dist/pulse-${{ steps.versions.outputs.chart_version }}.tgz - name: Authenticate with GHCR + env: + GHCR_TOKEN: ${{ github.token }} + GHCR_USERNAME: ${{ github.actor }} run: | - echo "${{ github.token }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin + echo "${GHCR_TOKEN}" | helm registry login ghcr.io --username "${GHCR_USERNAME}" --password-stdin - name: Push chart to GHCR run: | diff --git a/.github/workflows/qualify-release-containers.yml b/.github/workflows/qualify-release-containers.yml index 7a5c706ae..c06dab17d 100644 --- a/.github/workflows/qualify-release-containers.yml +++ b/.github/workflows/qualify-release-containers.yml @@ -23,6 +23,8 @@ jobs: # expose and repair runner capacity without blocking a stable patch. runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-build"]') }} timeout-minutes: 15 + env: + VERSION: ${{ inputs.version }} steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -49,7 +51,7 @@ jobs: python3 scripts/release_candidate_manifest.py verify-local \ --release-dir "$RUNNER_TEMP/release-container-payload/payload" \ --manifest "$RUNNER_TEMP/release-container-payload/release-container-payload.json" \ - --version "${{ inputs.version }}" \ + --version "${VERSION}" \ --source-sha "${{ github.sha }}" - name: Assemble exact-candidate runtime and agent images @@ -60,14 +62,14 @@ jobs: --platform linux/amd64 \ --build-context "release_payload=${RUNNER_TEMP}/release-container-payload/payload/release" \ --load \ - --tag "pulse-helm-smoke:${{ inputs.version }}" \ + --tag "pulse-helm-smoke:${VERSION}" \ . docker buildx build \ --target agent_runtime_prebuilt \ --platform linux/amd64 \ --build-context "release_payload=${RUNNER_TEMP}/release-container-payload/payload/release" \ --load \ - --tag "pulse-agent-candidate:${{ inputs.version }}" \ + --tag "pulse-agent-candidate:${VERSION}" \ . docker buildx build \ --file deploy/provider-msp/Dockerfile.control-plane \ @@ -75,24 +77,24 @@ jobs: --platform linux/amd64 \ --build-context "compiled_payload=${RUNNER_TEMP}/release-container-payload/payload/compiled" \ --load \ - --tag "pulse-control-plane-candidate:${{ inputs.version }}" \ + --tag "pulse-control-plane-candidate:${VERSION}" \ . - name: Verify container binaries match immutable candidate run: | set -euo pipefail expected_server="$(sha256sum "$RUNNER_TEMP/release-container-payload/payload/release/amd64/bin/pulse" | awk '{print $1}')" - actual_server="$(docker run --rm --entrypoint /bin/sh "pulse-helm-smoke:${{ inputs.version }}" -c 'sha256sum /app/pulse' | awk '{print $1}')" + actual_server="$(docker run --rm --entrypoint /bin/sh "pulse-helm-smoke:${VERSION}" -c 'sha256sum /app/pulse' | awk '{print $1}')" expected_agent="$(sha256sum "$RUNNER_TEMP/release-container-payload/payload/release/amd64/bin/pulse-agent-linux-amd64" | awk '{print $1}')" - actual_embedded_agent="$(docker run --rm --entrypoint /bin/sh "pulse-helm-smoke:${{ inputs.version }}" -c 'sha256sum /usr/local/bin/pulse-agent' | awk '{print $1}')" - actual_agent="$(docker run --rm --entrypoint /bin/sh "pulse-agent-candidate:${{ inputs.version }}" -c 'sha256sum /usr/local/bin/pulse-agent' | awk '{print $1}')" + actual_embedded_agent="$(docker run --rm --entrypoint /bin/sh "pulse-helm-smoke:${VERSION}" -c 'sha256sum /usr/local/bin/pulse-agent' | awk '{print $1}')" + actual_agent="$(docker run --rm --entrypoint /bin/sh "pulse-agent-candidate:${VERSION}" -c 'sha256sum /usr/local/bin/pulse-agent' | awk '{print $1}')" expected_control_plane="$(sha256sum "$RUNNER_TEMP/release-container-payload/payload/compiled/binaries/pulse-control-plane-linux-amd64" | awk '{print $1}')" - actual_control_plane="$(docker run --rm --entrypoint /bin/sh "pulse-control-plane-candidate:${{ inputs.version }}" -c 'sha256sum /usr/local/bin/pulse-control-plane' | awk '{print $1}')" + actual_control_plane="$(docker run --rm --entrypoint /bin/sh "pulse-control-plane-candidate:${VERSION}" -c 'sha256sum /usr/local/bin/pulse-control-plane' | awk '{print $1}')" test "${actual_server}" = "${expected_server}" test "${actual_embedded_agent}" = "${expected_agent}" test "${actual_agent}" = "${expected_agent}" test "${actual_control_plane}" = "${expected_control_plane}" - docker run --rm --entrypoint /bin/sh "pulse-helm-smoke:${{ inputs.version }}" -c ' + docker run --rm --entrypoint /bin/sh "pulse-helm-smoke:${VERSION}" -c ' set -euo pipefail test "$(readlink /usr/local/bin/pulse-agent)" = "/opt/pulse/bin/pulse-agent-linux-amd64" test -x /usr/local/bin/pulse-agent diff --git a/.github/workflows/recover-release-activation.yml b/.github/workflows/recover-release-activation.yml index d9460b953..17aa9f2b2 100644 --- a/.github/workflows/recover-release-activation.yml +++ b/.github/workflows/recover-release-activation.yml @@ -198,10 +198,11 @@ jobs: id: container_proof env: GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} run: | set -euo pipefail ./scripts/verify-release-container-images.sh \ - "${{ inputs.tag }}" "${{ steps.qualify.outputs.source_sha }}" \ + "${TAG}" "${{ steps.qualify.outputs.source_sha }}" \ "${{ github.repository }}" >> "$GITHUB_OUTPUT" - name: Dispatch a fresh durable convergence owner diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 0427fcfd2..74118cd95 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -184,11 +184,13 @@ jobs: steps: - name: Check release type + env: + VERIFY_ONLY: ${{ inputs.verify_only }} run: | echo "Tag: ${{ needs.resolve.outputs.tag }}" echo "Target: ${{ needs.resolve.outputs.target }}" echo "Environment: ${{ needs.resolve.outputs.environment_name }}" - echo "Verification only: ${{ inputs.verify_only }}" + echo "Verification only: ${VERIFY_ONLY}" - name: Validate demo environment configuration env: diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index c94a0733a..574f08a45 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -14,6 +14,14 @@ 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#]+)") +RUN_RE = re.compile(r"^(\s*)(?:-\s*)?run:\s*(.*)$") +EXPRESSION_RE = re.compile(r"\$\{\{(.*?)\}\}") +# Workflow-call and dispatch inputs are data, not shell source. Secrets include +# github.token because Actions makes that credential available independently of +# an explicit secrets.GITHUB_TOKEN reference. +SHELL_DATA_CONTEXT_RE = re.compile( + r"(? list[tuple[int, str]]: return block +def _run_script_lines(lines: list[str], run_index: int) -> list[tuple[int, str]]: + """Return the source lines GitHub will materialize as a run script.""" + match = RUN_RE.match(lines[run_index]) + if not match: + return [] + run_indent = len(match.group(1)) + value = match.group(2).strip() + if value not in {"|", "|-", "|+", ">", ">-", ">+"}: + return [(run_index, match.group(2))] + + script: list[tuple[int, str]] = [] + for index in range(run_index + 1, len(lines)): + line = lines[index] + if line.strip() and _indent(line) <= run_indent: + break + script.append((index, line)) + return script + + def audit_workflow(path: Path) -> list[Finding]: lines = path.read_text(encoding="utf-8").splitlines() findings: list[Finding] = [] + permission_declarations = [ + index + for index, line in enumerate(lines) + if re.match(r"^permissions\s*:", line) + ] + if len(permission_declarations) != 1: + findings.append( + Finding( + path, + 1, + "workflow must declare top-level permissions explicitly exactly once", + ) + ) + elif lines[permission_declarations[0]].split("#", 1)[0].strip() not in { + "permissions:", + "permissions: {}", + }: + findings.append( + Finding( + path, + permission_declarations[0] + 1, + "workflow permissions must use a scope mapping or explicit empty mapping", + ) + ) + for index, line in enumerate(lines): line_number = index + 1 code = line.split("#", 1)[0] @@ -64,6 +116,18 @@ def audit_workflow(path: Path) -> list[Finding]: ) ) + if RUN_RE.match(code): + for script_index, script_line in _run_script_lines(lines, index): + for expression in EXPRESSION_RE.findall(script_line): + if SHELL_DATA_CONTEXT_RE.search(expression): + findings.append( + Finding( + path, + script_index + 1, + "workflow inputs and secrets must enter run scripts through env", + ) + ) + match = USES_RE.search(code) if not match: continue diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 79c5543af..8746a2cd1 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1721,8 +1721,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("name: Backfill Release Assets", backfill_workflow) self.assertIn("workflow_dispatch:", backfill_workflow) self.assertIn('SYFT_VERSION="1.42.4"', backfill_workflow) - self.assertIn('./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}"', backfill_workflow) - self.assertIn('./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}"', backfill_workflow) + self.assertIn("TAG: ${{ inputs.tag }}", backfill_workflow) + self.assertIn("REPOSITORY: ${{ github.repository }}", backfill_workflow) + self.assertIn('./scripts/backfill-release-assets.sh --tag "${TAG}" --repo "${REPOSITORY}"', backfill_workflow) + self.assertIn('./scripts/validate-published-release.sh "${TAG}" "${REPOSITORY}"', backfill_workflow) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", backfill_workflow) self.assertIn("./scripts/prepare-release-container-context.sh", candidate_workflow) self.assertIn('test "${actual_server}" = "${expected_server}"', docker_build) diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index 7878a64bf..c83037580 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -31,7 +31,9 @@ class WorkflowTrustTest(unittest.TestCase): def test_accepts_immutable_dependencies_and_explicit_checkout_credentials(self) -> None: findings = self.audit( - f"""jobs: + f"""permissions: + contents: read +jobs: test: runs-on: ubuntu-24.04 steps: @@ -47,7 +49,9 @@ class WorkflowTrustTest(unittest.TestCase): def test_rejects_mutable_action_runner_and_container_references(self) -> None: findings = self.audit( - """jobs: + """permissions: + contents: read +jobs: test: runs-on: ubuntu-latest steps: @@ -61,14 +65,16 @@ class WorkflowTrustTest(unittest.TestCase): def test_rejects_implicit_or_unjustified_checkout_credentials(self) -> None: omitted = self.audit( - f"""steps: + f"""permissions: {{}} +steps: - uses: actions/checkout@{PIN} """ ) self.assertTrue(any("must set persist-credentials" in finding for finding in omitted)) unjustified = self.audit( - f"""steps: + f"""permissions: {{}} +steps: - uses: actions/checkout@{PIN} with: persist-credentials: true @@ -77,7 +83,8 @@ class WorkflowTrustTest(unittest.TestCase): self.assertTrue(any("require # required" in finding for finding in unjustified)) mixed_case = self.audit( - f"""steps: + f"""permissions: {{}} +steps: - uses: Actions/Checkout@{PIN} """ ) @@ -87,7 +94,8 @@ class WorkflowTrustTest(unittest.TestCase): def test_accepts_documented_authenticated_git_write(self) -> None: findings = self.audit( - f"""steps: + f"""permissions: {{}} +steps: - uses: actions/checkout@{PIN} with: persist-credentials: true # required: authenticated git writes @@ -95,6 +103,38 @@ class WorkflowTrustTest(unittest.TestCase): ) self.assertEqual(findings, []) + def test_requires_explicit_least_privilege_permissions(self) -> None: + missing = self.audit("jobs: {}\n") + self.assertTrue(any("top-level permissions" in finding for finding in missing)) + + broad = self.audit("permissions: write-all\njobs: {}\n") + self.assertTrue(any("scope mapping" in finding for finding in broad)) + + dynamic = self.audit("permissions: ${{ inputs.permissions }}\njobs: {}\n") + self.assertTrue(any("scope mapping" in finding for finding in dynamic)) + + def test_rejects_shell_template_data_but_accepts_env_data(self) -> None: + findings = self.audit( + """permissions: {} +jobs: + unsafe: + runs-on: ubuntu-24.04 + steps: + - run: echo "${{ inputs.name }}" + - run: | + echo "${{ secrets.ACCESS_TOKEN }}" + echo "${{ github.token }}" + - env: + NAME: ${{ inputs.name }} + TOKEN: ${{ github.token }} + run: printf '%s\\n' "$NAME" >/dev/null +""" + ) + self.assertEqual( + sum("must enter run scripts through env" in finding for finding in findings), + 3, + ) + 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], [])