From 2f959e6afba61222511dfb1962e2af4eb31de63f Mon Sep 17 00:00:00 2001 From: "SREDevOps.org Agent" Date: Fri, 30 Oct 2026 15:16:51 -0300 Subject: [PATCH] fix(ci): harden workflow token scope and download integrity (#39) Security audit of .github/workflows found a script-injection vector in the release workflow, over-broad token grants on non-publishing jobs, and downloaded tooling/manifests executed without integrity checks. - release: pass the tag/dispatch input through env instead of interpolating it into the validation script; default workflow permissions to contents:read - build-main/build-gateway: grant write scopes only to the build job - _build-image/codeql: stop persisting checkout credentials - ci/release: verify actionlint, hadolint and git-cliff against pinned SHA-256s - ci-manifests/e2e: verify upstream CRD manifests against versions.env pins - e2e: redact token material before uploading cluster diagnostics - docs: add workflow-security.md with controls, audit record and residual repository-setting recommendations Co-authored-by: SREDevOpsAgents --- .github/workflows/_build-image.yaml | 2 + .github/workflows/build-gateway.yaml | 4 +- .github/workflows/build-main.yaml | 4 +- .github/workflows/ci-manifests.yaml | 11 ++++- .github/workflows/ci.yaml | 16 +++++-- .github/workflows/codeql.yml | 2 + .github/workflows/e2e-kind.yaml | 9 +++- .github/workflows/release.yaml | 23 +++++++--- docs/README.md | 1 + docs/workflow-security.md | 69 ++++++++++++++++++++++++++++ versions.env | 4 ++ 11 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 docs/workflow-security.md diff --git a/.github/workflows/_build-image.yaml b/.github/workflows/_build-image.yaml index 08c300b..0774bb3 100644 --- a/.github/workflows/_build-image.yaml +++ b/.github/workflows/_build-image.yaml @@ -53,6 +53,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Prepare environment env: diff --git a/.github/workflows/build-gateway.yaml b/.github/workflows/build-gateway.yaml index d3203ce..26ab1b9 100644 --- a/.github/workflows/build-gateway.yaml +++ b/.github/workflows/build-gateway.yaml @@ -12,11 +12,9 @@ on: - .github/workflows/build-gateway.yaml - .github/workflows/_build-image.yaml +# Write scopes are granted to the build job only; every other job stays read-only. permissions: contents: read - packages: write - id-token: write - attestations: write concurrency: group: build-gateway-${{ github.ref }} diff --git a/.github/workflows/build-main.yaml b/.github/workflows/build-main.yaml index 433d330..b70fedb 100644 --- a/.github/workflows/build-main.yaml +++ b/.github/workflows/build-main.yaml @@ -13,11 +13,9 @@ on: - .github/workflows/build-main.yaml - .github/workflows/_build-image.yaml +# Write scopes are granted to the build job only; every other job stays read-only. permissions: contents: read - packages: write - id-token: write - attestations: write concurrency: group: build-main-${{ github.ref }} diff --git a/.github/workflows/ci-manifests.yaml b/.github/workflows/ci-manifests.yaml index dfb7253..6f925ee 100644 --- a/.github/workflows/ci-manifests.yaml +++ b/.github/workflows/ci-manifests.yaml @@ -46,12 +46,19 @@ jobs: - name: Install agent-sandbox and Gateway API CRDs run: | set -euo pipefail - kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml" + # Download, verify the pinned digest from versions.env, then apply. + curl -fsSL --proto '=https' -o /tmp/agent-sandbox.yaml \ + "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml" + printf '%s %s\n' "$AGENT_SANDBOX_MANIFEST_SHA256" /tmp/agent-sandbox.yaml | sha256sum -c - + kubectl apply -f /tmp/agent-sandbox.yaml kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s kubectl wait --for=condition=Established crd/sandboxclaims.extensions.agents.x-k8s.io --timeout=180s kubectl wait --for=condition=Established crd/sandboxes.agents.x-k8s.io --timeout=180s - kubectl apply -f "https://github.com/kubernetes-sigs/gateway-api/releases/download/$GATEWAY_API_VERSION/standard-install.yaml" + curl -fsSL --proto '=https' -o /tmp/gateway-api.yaml \ + "https://github.com/kubernetes-sigs/gateway-api/releases/download/$GATEWAY_API_VERSION/standard-install.yaml" + printf '%s %s\n' "$GATEWAY_API_MANIFEST_SHA256" /tmp/gateway-api.yaml | sha256sum -c - + kubectl apply -f /tmp/gateway-api.yaml kubectl wait --for=condition=Established crd/gateways.gateway.networking.k8s.io --timeout=180s kubectl wait --for=condition=Established crd/httproutes.gateway.networking.k8s.io --timeout=180s - name: Set up Python diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5840941..5c3a0c4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,19 +37,29 @@ jobs: - name: Install actionlint env: ACTIONLINT_VERSION: v1.7.12 + # SHA-256 of actionlint_1.7.12_linux_amd64.tar.gz, published in the + # release's actionlint_1.7.12_checksums.txt. Update it with the version. + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 run: | set -euo pipefail version="${ACTIONLINT_VERSION#v}" - curl -fsSL -o /tmp/actionlint.tar.gz "https://github.com/rhysd/actionlint/releases/download/$ACTIONLINT_VERSION/actionlint_${version}_linux_amd64.tar.gz" - tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint + asset="actionlint_${version}_linux_amd64.tar.gz" + curl -fsSL --proto '=https' -o "/tmp/$asset" \ + "https://github.com/rhysd/actionlint/releases/download/$ACTIONLINT_VERSION/$asset" + printf '%s %s\n' "$ACTIONLINT_SHA256" "/tmp/$asset" | sha256sum -c - + tar -xzf "/tmp/$asset" -C /tmp actionlint install -m 0755 /tmp/actionlint /usr/local/bin/actionlint actionlint -version - name: Install hadolint env: HADOLINT_VERSION: v2.12.0 + # SHA-256 of hadolint-Linux-x86_64, published next to the download. + HADOLINT_SHA256: 56de6d5e5ec427e17b74fa48d51271c7fc0d61244bf5c90e828aab8362d55010 run: | set -euo pipefail - curl -fsSL -o /tmp/hadolint "https://github.com/hadolint/hadolint/releases/download/$HADOLINT_VERSION/hadolint-Linux-x86_64" + curl -fsSL --proto '=https' -o /tmp/hadolint \ + "https://github.com/hadolint/hadolint/releases/download/$HADOLINT_VERSION/hadolint-Linux-x86_64" + printf '%s %s\n' "$HADOLINT_SHA256" /tmp/hadolint | sha256sum -c - install -m 0755 /tmp/hadolint /usr/local/bin/hadolint hadolint --version - name: Repository checks diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ba03c11..e698b9d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,6 +58,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/e2e-kind.yaml b/.github/workflows/e2e-kind.yaml index 6d77953..9c83bdf 100644 --- a/.github/workflows/e2e-kind.yaml +++ b/.github/workflows/e2e-kind.yaml @@ -40,7 +40,10 @@ jobs: - name: Install agent-sandbox run: | set -euo pipefail - kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml" + curl -fsSL --proto '=https' -o /tmp/agent-sandbox.yaml \ + "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml" + printf '%s %s\n' "$AGENT_SANDBOX_MANIFEST_SHA256" /tmp/agent-sandbox.yaml | sha256sum -c - + kubectl apply -f /tmp/agent-sandbox.yaml kubectl -n agent-sandbox-system rollout status deployment --timeout=180s kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s @@ -66,7 +69,9 @@ jobs: echo "== events ==" kubectl get events -n dsh-demo --sort-by=.lastTimestamp 2>&1 || true echo "== gateway logs ==" - kubectl -n dsh-demo logs deploy/dsh-gateway --tail=200 2>&1 || true + # Redact any token material before this file becomes an artifact. + kubectl -n dsh-demo logs deploy/dsh-gateway --tail=200 2>&1 \ + | sed -E 's/(token=)[^&"[:space:]]+/\1[REDACTED]/g' || true } > /tmp/e2e-cluster.txt - name: Upload cluster state if: always() diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e3515dc..13fd068 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,11 +17,10 @@ on: required: true type: string +# Read-only by default; the build and publish jobs add the write scopes they +# need as job-level grants. permissions: - contents: write - packages: write - id-token: write - attestations: write + contents: read concurrency: group: release-${{ github.ref }} @@ -51,13 +50,17 @@ jobs: - name: Validate tag against versions.env id: meta shell: bash + env: + # Passed through the environment, never interpolated into the script: + # a tag name or dispatch input can contain shell metacharacters. + REQUESTED_TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail set -a # shellcheck disable=SC1091 source versions.env set +a - tag="${{ inputs.tag || github.ref_name }}" + tag="$REQUESTED_TAG" identity="$DSH_VERSION" [ -z "$RELEASE_REVISION" ] || identity="$DSH_VERSION-$RELEASE_REVISION" if [ "$tag" != "$identity" ]; then @@ -205,11 +208,17 @@ jobs: - name: Install git-cliff env: GIT_CLIFF_VERSION: v2.14.2 + # SHA-256 of git-cliff-2.14.2-x86_64-unknown-linux-gnu.tar.gz; upstream + # publishes the matching SHA-512 next to the asset. Update with the version. + GIT_CLIFF_SHA256: 24f397c733add5390fdceee3a2088588ab0d5f944ce00d34cb7029b888cf2db4 run: | set -euo pipefail version="${GIT_CLIFF_VERSION#v}" - curl -fsSL -o /tmp/git-cliff.tar.gz "https://github.com/orhun/git-cliff/releases/download/$GIT_CLIFF_VERSION/git-cliff-$version-x86_64-unknown-linux-gnu.tar.gz" - tar -xzf /tmp/git-cliff.tar.gz -C /tmp + asset="git-cliff-${version}-x86_64-unknown-linux-gnu.tar.gz" + curl -fsSL --proto '=https' -o "/tmp/$asset" \ + "https://github.com/orhun/git-cliff/releases/download/$GIT_CLIFF_VERSION/$asset" + printf '%s %s\n' "$GIT_CLIFF_SHA256" "/tmp/$asset" | sha256sum -c - + tar -xzf "/tmp/$asset" -C /tmp install -m 0755 "/tmp/git-cliff-$version/git-cliff" /usr/local/bin/git-cliff git-cliff --version diff --git a/docs/README.md b/docs/README.md index d9a618f..192ec1c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ gets a DSH Web container running before anything else. | [examples.md](examples.md) | The example contract: required layout, manifest rules, exposure variants, components and image naming, scripts, docs, and how to add a new example. | | [gitflow.md](gitflow.md) | Branches, protected `main`, squash merges, Conventional PR titles, required CI checks, release branches, hotfixes and the one-time GitHub settings checklist. | | [versioning.md](versioning.md) | Release identity (`DSH ref` + optional `-rN`), the current identity `dsh-v0.2.1-rc.5`, why identities are immutable, channels, compatibility matrix, bumping pins, the release checklist, rollback, git-cliff and digest pinning. | +| [workflow-security.md](workflow-security.md) | GitHub Actions security posture: least-privilege tokens, pinned actions, untrusted-input handling, download integrity checks, artifact hygiene, the latest audit record and residual repository-setting recommendations. | ## Project skills diff --git a/docs/workflow-security.md b/docs/workflow-security.md new file mode 100644 index 0000000..3ae7e55 --- /dev/null +++ b/docs/workflow-security.md @@ -0,0 +1,69 @@ +# Workflow security (GitHub Actions) + +The CI/CD surface lives entirely in [`.github/workflows/`](../.github/workflows/) +and runs on GitHub-hosted runners. This page describes the controls those +workflows enforce and records the most recent audit. They complement the threat +notes in [SECURITY.md](../SECURITY.md) and the release procedure in +[versioning.md](versioning.md). + +## Controls + +| Control | Where | What it does | +| --- | --- | --- | +| Least-privilege tokens | Every workflow | A top-level `permissions:` block defaults to read-only; `packages: write`, `contents: write`, `id-token: write` and `attestations: write` are granted only to the jobs that publish images, attestations or releases. | +| Pinned actions | Every `uses:` | Third-party actions and reusable workflows are pinned to a full 40-character commit SHA (the version is kept in the trailing comment). Dependabot updates the pins weekly. The only unpinned reference is the in-repository `./.github/workflows/_build-image.yaml`. | +| No privileged untrusted triggers | Trigger blocks | No workflow uses `pull_request_target` or `workflow_run`. PR-triggered workflows run untrusted code with a read-only token and no secrets. | +| Untrusted input is data | `release.yaml` | The release tag (from a tag push or `workflow_dispatch` input) is passed through `env:` and read as a shell variable; it is never interpolated into a `run:` script, so metacharacters in a tag name cannot execute. | +| Checkout credential hygiene | Checkout steps | `persist-credentials: false` is set wherever the job does not need to push. The changelog job keeps credentials because it pushes a branch and opens a PR with `GITHUB_TOKEN`. | +| Download integrity | `ci.yaml`, `release.yaml` | actionlint, hadolint and git-cliff are downloaded with `curl --proto '=https'` and verified against a pinned SHA-256 with `sha256sum -c` before execution. | +| Cluster manifest integrity | `ci-manifests.yaml`, `e2e-kind.yaml` | The agent-sandbox and Gateway API release manifests are downloaded and checked against the `*_MANIFEST_SHA256` pins in [versions.env](../versions.env) before `kubectl apply`. | +| Log and artifact hygiene | `ci-image.yaml`, `e2e-kind.yaml` | Pod logs are truncated and token material is redacted before logs are printed or diagnostic files are uploaded as artifacts. | +| Supply-chain monitoring | Repository settings | Dependabot (GitHub Actions, pip, Docker), CodeQL advanced setup and the dependency-review action run on every applicable change. CODEOWNERS covers the whole repository, including `.github/workflows/`. | + +## Maintaining the pins + +- The tool versions and their SHA-256 digests live together in the install step + (`ACTIONLINT_SHA256`, `HADOLINT_SHA256`, `GIT_CLIFF_SHA256`); update both + values when bumping a version. +- Control-plane versions are in `versions.env`; update + `AGENT_SANDBOX_MANIFEST_SHA256` or `GATEWAY_API_MANIFEST_SHA256` together with + the corresponding version, otherwise CI fails the digest check by design. +- GitHub Actions pins are updated by Dependabot; never replace a SHA with a tag. + +## Audit record (2026-10-30) + +Scope: all 11 workflows under `.github/workflows/`, plus `.github/CODEOWNERS` +and `.github/dependabot.yml`. Findings are grouped by the categories in +GitHub's [Secure use reference](https://docs.github.com/en/actions/reference/security/secure-use). + +| # | Category | File | Severity | Finding | Status | +| --- | --- | --- | --- | --- | --- | +| 1 | Script injection | `release.yaml` | High | Tag name / dispatch input interpolated into a privileged `run:` block | Fixed: passed through `env` | +| 2 | Token permissions | `release.yaml` | Medium | `meta` and `tests` inherited workflow-level write scopes | Fixed: workflow default is `contents: read` | +| 3 | Token permissions | `build-main.yaml`, `build-gateway.yaml` | Low | `meta` inherited `packages`/`id-token`/`attestations` write | Fixed: workflow default is `contents: read` | +| 4 | Checkout | `_build-image.yaml` | Low | Checkout persisted the token in `.git/config` | Fixed: `persist-credentials: false` | +| 5 | Checkout | `codeql.yml` | Low | Checkout persisted the token in `.git/config` | Fixed: `persist-credentials: false` | +| 6 | Supply chain | `ci.yaml` | Medium | actionlint and hadolint were executed without a checksum | Fixed: pinned SHA-256 verified before install | +| 7 | Supply chain | `release.yaml` | Medium | git-cliff was executed without a checksum in the release job | Fixed: pinned SHA-256 verified before install | +| 8 | Supply chain | `ci-manifests.yaml`, `e2e-kind.yaml` | Medium | Upstream CRD/controller manifests applied without integrity checks | Fixed: pinned SHA-256 in `versions.env` | +| 9 | Secret hygiene | `e2e-kind.yaml` | Low | Raw gateway logs uploaded in a 7-day artifact | Fixed: `token=` values redacted | +| 10 | Repository controls | Repository settings | Low | No environment approval gate on publishing; single CODEOWNER | Recommendation: see below | + +Checked and clean: no `pull_request_target`/`workflow_run`, no self-hosted +runners, all third-party actions pinned, PR workflows read-only, no secrets in +workflow files, no secret interpolation into scripts. + +## Residual recommendations (repository settings) + +These cannot be enforced from workflow files alone: + +- Add a protected `release` environment with required reviewers and attach it to + the `build-main`, `build-gateway` and `publish-release` jobs. This requires the + environment to exist in repository settings first, so it is intentionally not + wired into the workflow yet. +- Keep "Allow GitHub Actions to create and approve pull requests" limited to + creating PRs (the changelog job needs create, not approve). +- Require review from Code Owners for `.github/workflows/` and add a second + owner so workflow changes are never single-person. +- Optionally run OpenSSF Scorecard on a schedule for continuous checks on these + controls. diff --git a/versions.env b/versions.env index 9746453..68f1004 100644 --- a/versions.env +++ b/versions.env @@ -16,8 +16,12 @@ PNPM_VERSION=11.28.5 RELEASE_REVISION= # Control-plane versions used by the examples and their manifests. +# The matching SHA-256s pin the release manifests that CI downloads and +# applies; update both the version and the digest when bumping either pin. AGENT_SANDBOX_VERSION=v1.0.9 +AGENT_SANDBOX_MANIFEST_SHA256=4bf9ee5558e9a663f91cfa5963e96f2591bfaa1dcf79f03bfab65bc5d65cc699 GATEWAY_API_VERSION=v1.2.1 +GATEWAY_API_MANIFEST_SHA256=97598bf6ab3b33b9b5c5432bdd24de091e4e9c3aa0575ebb0710a2a19cd64d64 OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy:v7.15.5 # Registry layout: the main image is the repository itself, and every example