mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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], [])
|
||||
|
||||
Reference in New Issue
Block a user