Enforce workflow data trust boundaries

Contract-Neutral: Moves workflow expressions into environment data flow without changing deployment interfaces or behavior.
This commit is contained in:
pulse-triage[bot]
2026-08-30 04:51:54 +01:00
parent a2bfadba7b
commit d8986c139a
12 changed files with 195 additions and 44 deletions
+5
View File
@@ -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**:
@@ -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}"
+29 -14
View File
@@ -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"
@@ -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
+13 -5
View File
@@ -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"
+4 -1
View File
@@ -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: |
@@ -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
@@ -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
+3 -1
View File
@@ -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:
+64
View File
@@ -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"(?<![\w.])(?:inputs|secrets)\.|(?<![\w.])github\.token\b"
)
CHECKOUT_PREFIX = "actions/checkout@"
WRITE_CREDENTIAL_RATIONALE = "# required: authenticated git writes"
@@ -48,10 +56,54 @@ def _checkout_block(lines: list[str], uses_index: int) -> 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
@@ -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)
+46 -6
View File
@@ -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], [])