From f5efadfd07f1ea83cfa3bb65a405063a9007baad Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 8 Sep 2026 22:14:15 +0200 Subject: [PATCH] ci: pin every third-party action to a commit SHA, gated and Dependabot-maintained (D-226) A tag is a mutable pointer owned by the action's publisher. These jobs are not ordinary CI: they hold the code-signing Key Vault token (D-223), the federated storage credentials (D-222), and combine-rules.yml holds a contents:write token that pushes to main. Whoever could repoint v7 would run code with all of that. azure/login and Azure/functions-action were already pinned; the actions/* and github/codeql-action references were not, on the usual argument that GitHub owns them and shares the runner's trust boundary. That argument does not survive the fact that they run in the same jobs as the signing identity. All seven are now pinned by SHA with the version in a trailing comment -- the comment is what keeps the line reviewable, and Dependabot rewrites both together. action-pins.yml is the guard: pins nobody enforces rot, and no review reliably catches one @v4 added to a 200-line workflow. It lives in its own workflow because ci.yml deliberately ignores changes to the other workflow files, so a job placed there would stay silent for exactly the commits it must inspect. Like CI it reports rather than blocks -- main is unprotected; making it binding would mean adding it as a required status check, which is a branch-protection decision. Verified both directions: 49 references pass against the repo, and a fixture with @v7, @main and a bare SHA fails with three annotations while the local reusable workflow refs are skipped. Two floating tool versions closed while here. wix went from the range 5.* to 5.0.2 -- it writes the bytes of the MSI customers install, where AzureSignTool only signs them. ajv was the worse one: unpinned, and npm runs lifecycle scripts at install, so a hijacked release would have executed with the push token before a single rule was validated; now exact versions with --ignore-scripts. Line endings: sed -i rewrote the workflow files as LF. Git normalises on commit, so the content diff is unaffected and indexnow-notify.yml carries no change at all. Co-Authored-By: Claude Opus 5 (1M context) --- .github/dependabot.yml | 4 ++ .github/workflows/action-pins.yml | 70 ++++++++++++++++++++ .github/workflows/bootstrap-script-gates.yml | 2 +- .github/workflows/build-agent.yml | 10 +-- .github/workflows/build-msi.yml | 11 +-- .github/workflows/ci.yml | 24 ++++--- .github/workflows/codeql.yml | 8 +-- .github/workflows/combine-rules.yml | 9 ++- .github/workflows/deploy-backend.yml | 4 +- .github/workflows/deploy-mcp.yml | 4 +- .github/workflows/deploy-web.yml | 10 +-- .github/workflows/publish-scripts.yml | 4 +- internal | 2 +- 13 files changed, 122 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/action-pins.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4affd8cc..7c015190 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -55,6 +55,10 @@ updates: - dependency-name: '*' update-types: ['version-update:semver-major'] + # Every action is referenced by commit SHA with the version in a trailing comment; + # action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot + # is what keeps those pins current: it rewrites the SHA and the comment together, so the + # pins are reviewed in a PR instead of drifting silently or being bumped by hand. - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/action-pins.yml b/.github/workflows/action-pins.yml new file mode 100644 index 00000000..597778c5 --- /dev/null +++ b/.github/workflows/action-pins.yml @@ -0,0 +1,70 @@ +name: Action Pin Gate + +# Every third-party action must be referenced by a full commit SHA, never by a tag. +# Tags are mutable: whoever controls an action repository can repoint v4 at new code, +# and that code runs inside jobs that hold the code-signing identity, the storage +# credentials and a token that can push to main. A SHA is the only reference an +# upstream owner cannot change under us. +# +# Lives in its own workflow rather than in ci.yml: CI deliberately ignores changes to +# the other workflow files, so a guard placed there would stay silent for exactly the +# commits it needs to inspect. +# +# Like CI, this reports rather than blocks: main is deliberately unprotected, so a red +# run is a signal to act on, not a merge barrier. Making it binding means adding +# 'Verify action pins' as a required status check -- a branch-protection decision, not +# something this file can assert. +# +# The pins are not maintained by hand — .github/dependabot.yml opens a monthly grouped +# PR that rewrites both the SHA and the trailing version comment. The comment is +# therefore required too: it is what makes a pinned line reviewable. + +on: + push: + branches: [main] + paths: ['.github/workflows/**'] + pull_request: + branches: [main] + paths: ['.github/workflows/**'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify-pins: + name: Verify action pins + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Every third-party action is pinned to a SHA + run: | + set -uo pipefail + failed=0 + while IFS=: read -r file line ref; do + # Reusable workflows from this repository move with the commit that calls + # them, so there is nothing to pin. + case "$ref" in ./*) continue ;; esac + + if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*[^[:space:]] ]]; then + printf 'ok %s:%s %s\n' "$file" "$line" "$ref" + continue + fi + + if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]*$ ]]; then + echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@ # vN' so the line stays reviewable." + else + echo "::error file=$file,line=$line::Action '$ref' is not pinned to a commit SHA. Tags are mutable -- resolve it with: gh api repos///git/ref/tags/" + fi + failed=1 + # -H is load-bearing: with a single matching file grep omits the name, the + # fields shift by one and every diagnostic points at nothing. + done < <(grep -nHE '^[[:space:]]*(-[[:space:]]+)?uses:' .github/workflows/*.yml \ + | sed -E 's/^([^:]+):([0-9]+):[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*/\1:\2:/') + + if [ "$failed" -ne 0 ]; then + echo "::error::Unpinned action references found -- fix them before this reaches main." + exit 1 + fi + echo "All action references are pinned to commit SHAs." diff --git a/.github/workflows/bootstrap-script-gates.yml b/.github/workflows/bootstrap-script-gates.yml index 46cd1045..71484214 100644 --- a/.github/workflows/bootstrap-script-gates.yml +++ b/.github/workflows/bootstrap-script-gates.yml @@ -18,7 +18,7 @@ jobs: name: Bootstrap guard tests (PS 5.1) runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install Pester 5+ shell: powershell diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index 454fade1..ae60e556 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -72,7 +72,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Every released build is signed; a missing variable stops the run before a build # number is burnt. @@ -122,7 +122,7 @@ jobs: # SDK 10 = single build toolchain (parity with local build.ps1 / global.json). - name: Setup .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: '10.0.x' @@ -462,7 +462,7 @@ jobs: # Skipped on runs that publish nothing -- no log entry for bytes nobody receives. - name: Attest build provenance if: inputs.upload_blob || inputs.create_release - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: subject-path: | ${{ steps.artifact.outputs.versioned_zip_release }} @@ -470,13 +470,13 @@ jobs: staging/agent/AutopilotMonitor.SummaryDialog.exe - name: Upload build artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }} path: ${{ steps.artifact.outputs.versioned_zip_release }} - name: Upload version metadata - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: version-metadata path: ${{ env.MANIFEST_NAME }} diff --git a/.github/workflows/build-msi.yml b/.github/workflows/build-msi.yml index 9bdf87f9..7e196694 100644 --- a/.github/workflows/build-msi.yml +++ b/.github/workflows/build-msi.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # The loader executes as SYSTEM on every enrolling device — a parse error or # non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet. @@ -111,8 +111,11 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers + # install, so a compromised or simply broken point release must not flow into a build + # unnoticed. Bumping is a reviewed commit. - name: Install WiX toolset - run: dotnet tool install --global wix --version "5.*" + run: dotnet tool install --global wix --version 5.0.2 # Version pinned; roll-forward because the tool targets an older runtime than the SDK. - name: Install AzureSignTool @@ -235,12 +238,12 @@ jobs: # gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor - name: Attest build provenance (MSI) if: inputs.upload_blob - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: subject-path: ${{ env.STABLE_MSI }} - name: Upload build artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }} path: ${{ env.STABLE_MSI }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43d511e1..2e2df4ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ on: # Every other workflow: nothing here builds or tests anything CI covers. paths-ignore # cannot negate, so they are listed one by one; ci.yml itself stays out of the list # so a change to CI runs CI. A new workflow file is opt-in — add it here too. + - '.github/workflows/action-pins.yml' - '.github/workflows/bootstrap-script-gates.yml' - '.github/workflows/build-agent.yml' - '.github/workflows/build-msi.yml' @@ -67,6 +68,7 @@ on: # Every other workflow: nothing here builds or tests anything CI covers. paths-ignore # cannot negate, so they are listed one by one; ci.yml itself stays out of the list # so a change to CI runs CI. A new workflow file is opt-in — add it here too. + - '.github/workflows/action-pins.yml' - '.github/workflows/bootstrap-script-gates.yml' - '.github/workflows/build-agent.yml' - '.github/workflows/build-msi.yml' @@ -94,9 +96,9 @@ jobs: # windows-latest is required: the agent test projects target net48. runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: '10.0.x' @@ -125,9 +127,9 @@ jobs: run: working-directory: src/Web/autopilot-monitor-web steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '22' cache: npm @@ -159,9 +161,9 @@ jobs: run: working-directory: src/McpServer/autopilot-monitor-mcp steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '22' cache: npm @@ -179,7 +181,7 @@ jobs: # (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR # is honored by vector-search-provider.ts. - name: Cache embedding model - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ github.workspace }}/.hf-cache key: hf-model-Xenova-all-MiniLM-L6-v2 @@ -193,9 +195,9 @@ jobs: name: Generated guardrails match rules/guardrails.json runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '22' @@ -214,9 +216,9 @@ jobs: name: Generated wire types match shared-manifests.json runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '22' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0031dc44..cb871018 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -102,15 +102,15 @@ jobs: build-mode: none steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 if: matrix.language == 'csharp' with: dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.9 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -121,6 +121,6 @@ jobs: run: dotnet build AutopilotMonitor.sln --configuration Release - name: Analyze - uses: github/codeql-action/analyze@v4.37.9 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: /language:${{ matrix.language }} diff --git a/.github/workflows/combine-rules.yml b/.github/workflows/combine-rules.yml index 38dae3bc..ea12c623 100644 --- a/.github/workflows/combine-rules.yml +++ b/.github/workflows/combine-rules.yml @@ -24,14 +24,17 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '22' + # Exact versions and no lifecycle scripts: this job holds contents:write and pushes to + # main, so an install-time script from a hijacked package would run with a token that + # can rewrite the repository. ajv is pure JS and needs no install hooks. - name: Install ajv-cli - run: npm install -g ajv-cli ajv-formats + run: npm install -g --ignore-scripts ajv-cli@5.0.0 ajv-formats@3.0.1 - name: Validate Gather Rules run: | diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 0de02b84..99889e56 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -19,7 +19,7 @@ jobs: name: Build and Deploy Backend steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Login to Azure (OIDC) uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3 @@ -29,7 +29,7 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Setup .NET 10 - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/deploy-mcp.yml b/.github/workflows/deploy-mcp.yml index 7ca211cd..6d38787b 100644 --- a/.github/workflows/deploy-mcp.yml +++ b/.github/workflows/deploy-mcp.yml @@ -26,7 +26,7 @@ jobs: name: Build and Deploy MCP Server steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # The published customer documentation lives in its own PUBLIC repo and is # baked into the image as the search_docs corpus (see the Dockerfile). No @@ -36,7 +36,7 @@ jobs: # re-run actually rolls a new revision). /health reports the SHA captured # below so that staleness is checkable. - name: Check out documentation bundle - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: autopilotmonitor/autopilotmonitor-docs path: mcp-docs diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 386f45b8..7887e7bf 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest name: Build and Deploy Job steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: lfs: false fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates @@ -56,7 +56,7 @@ jobs: # workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json # records which docs commit is live. - name: Check out documentation bundle - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: autopilotmonitor/autopilotmonitor-docs path: whats-new-docs @@ -66,7 +66,7 @@ jobs: id: docs run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '22' cache: npm @@ -114,12 +114,12 @@ jobs: # commit, workflow file) in a public transparency log. # Verify: gh attestation verify portal-.tar.gz --repo okieselbach/AutopilotMonitor - name: Attest build provenance (portal export) - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: subject-path: ${{ steps.package.outputs.archive }} - name: Upload build artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: portal-${{ github.sha }} path: ${{ steps.package.outputs.archive }} diff --git a/.github/workflows/publish-scripts.yml b/.github/workflows/publish-scripts.yml index f964764e..95f44f82 100644 --- a/.github/workflows/publish-scripts.yml +++ b/.github/workflows/publish-scripts.yml @@ -54,7 +54,7 @@ jobs: needs: gates runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Check code-signing configuration shell: pwsh @@ -127,7 +127,7 @@ jobs: # when someone wants to look at the file. - name: Upload signed scripts if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: signed-scripts path: ${{ runner.temp }}/signed-scripts diff --git a/internal b/internal index 8204fc68..9a3a61f5 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 8204fc68af1bb10e17d50dcbc9999e99169df9fc +Subproject commit 9a3a61f56fc79a1cd076a852052f92d9589b10b2