Browse Source

ci(security): least privilege permissions + pin actions to SHAs (Task 05)

Harden all 9 GitHub Actions workflows:

Least privilege:
- Add explicit top-level `permissions: contents: read` to installer-checks,
  validate-test-suites, and opencode (opencode's job keeps the writes it
  needs; the top level now defaults to read).
- Every workflow now declares an explicit top-level permissions block.
- Write scopes retained only where justified (releases, doc/registry
  commits, follow-up PRs, and sync-docs' issues.create).

Immutable action pinning:
- Pin every `uses:` to a full 40-char commit SHA with a version comment,
  including first-party actions/* (checkout, github-script, setup-node,
  upload-artifact) and third-party (oven-sh/setup-bun).
- Replace the two mutable refs that could change under us at any push:
  ludeeus/action-shellcheck@master and sst/opencode/github@latest.

Record all SHAs, versions, and permission justifications in
docs/maintenance/repository-recovery/action-pin-inventory.md.

Verified: all 9 workflows parse as valid YAML; no floating tag/branch refs
remain; every workflow has a top-level permissions block.
darrenhinde 3 weeks ago
parent
commit
c407e59f12

+ 3 - 3
.github/workflows/create-release.yml

@@ -27,13 +27,13 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 2
       
       - name: Check if this was a version bump PR merge
         id: check
-        uses: actions/github-script@v7
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         with:
           script: |
             const fs = require('fs');
@@ -83,7 +83,7 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
       

+ 10 - 7
.github/workflows/installer-checks.yml

@@ -15,6 +15,9 @@ on:
       - 'update.sh'
   workflow_dispatch:
 
+permissions:
+  contents: read
+
 jobs:
   shellcheck:
     name: ShellCheck Analysis
@@ -22,10 +25,10 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
       
       - name: Run ShellCheck on install.sh
-        uses: ludeeus/action-shellcheck@master
+        uses: ludeeus/action-shellcheck@00b27aa7cb85167568cb48a3838b75f4265f2bca # master (resolved 2026-07-15)
         with:
           scandir: '.'
           additional_files: 'install.sh update.sh'
@@ -43,7 +46,7 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
       
       - name: Check install.sh syntax
         run: bash -n install.sh
@@ -73,7 +76,7 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
       
       - name: Install jq (Ubuntu)
         if: matrix.os == 'ubuntu-latest'
@@ -102,7 +105,7 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
       
       - name: Install jq (Ubuntu)
         if: matrix.os == 'ubuntu-latest'
@@ -133,7 +136,7 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
       
       - name: Install jq (Ubuntu)
         if: matrix.os == 'ubuntu-latest'
@@ -162,7 +165,7 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
       
       - name: Install dependencies
         run: sudo apt-get install -y jq curl

+ 5 - 2
.github/workflows/opencode.yml

@@ -4,6 +4,9 @@ on:
   issue_comment:
     types: [created]
 
+permissions:
+  contents: read
+
 jobs:
   opencode:
     if: |
@@ -19,12 +22,12 @@ jobs:
       issues: write
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 1
 
       - name: Run opencode
-        uses: sst/opencode/github@latest
+        uses: sst/opencode/github@77fc88c8ade8e5a620ebbe1197f3a572d29ae91a # latest (resolved 2026-07-15)
         env:
           ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
         with:

+ 4 - 4
.github/workflows/post-merge-pr.yml

@@ -25,13 +25,13 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 2
       
       - name: Check if this was a version bump PR
         id: check_pr_labels
-        uses: actions/github-script@v7
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         with:
           script: |
             // Get the commit that triggered this workflow
@@ -106,12 +106,12 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
       
       - name: Setup Node.js
-        uses: actions/setup-node@v4
+        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
         with:
           node-version: '20'
       

+ 4 - 4
.github/workflows/pr-checks.yml

@@ -18,7 +18,7 @@ jobs:
     steps:
       - name: Check PR title format
         id: validate
-        uses: actions/github-script@v7
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         with:
           script: |
             const prTitle = context.payload.pull_request.title;
@@ -163,7 +163,7 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
           persist-credentials: false
@@ -192,12 +192,12 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           persist-credentials: false
       
       - name: Setup Node.js
-        uses: actions/setup-node@v4
+        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
         with:
           node-version: '20'
           cache: 'npm'

+ 5 - 5
.github/workflows/sync-docs.yml

@@ -32,7 +32,7 @@ jobs:
     
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 2
       
@@ -87,7 +87,7 @@ jobs:
     
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
       
@@ -105,7 +105,7 @@ jobs:
       
       - name: Create sync issue for OpenCode
         id: create_issue
-        uses: actions/github-script@v7
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         with:
           script: |
             const issue = await github.rest.issues.create({
@@ -195,7 +195,7 @@ jobs:
     
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
       
       - name: Delete branch if created
         run: |
@@ -207,7 +207,7 @@ jobs:
       
       - name: Comment on issue
         if: needs.sync-documentation.outputs.issue_number
-        uses: actions/github-script@v7
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         with:
           script: |
             await github.rest.issues.createComment({

+ 1 - 1
.github/workflows/update-registry.yml

@@ -18,7 +18,7 @@ jobs:
     
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
       

+ 2 - 2
.github/workflows/validate-registry.yml

@@ -21,7 +21,7 @@ jobs:
 
     steps:
       - name: Checkout code without persisted credentials
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
         with:
           fetch-depth: 0
           persist-credentials: false
@@ -32,7 +32,7 @@ jobs:
           sudo apt-get install -y jq
 
       - name: Install Bun
-        uses: oven-sh/setup-bun@v2
+        uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
         with:
           bun-version: latest
 

+ 7 - 4
.github/workflows/validate-test-suites.yml

@@ -15,6 +15,9 @@ on:
       - '.github/workflows/validate-test-suites.yml'
   workflow_dispatch:
 
+permissions:
+  contents: read
+
 jobs:
   validate:
     name: Validate Test Suite Definitions
@@ -22,10 +25,10 @@ jobs:
     
     steps:
       - name: Checkout code
-        uses: actions/checkout@v4
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
       
       - name: Setup Node.js
-        uses: actions/setup-node@v4
+        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
         with:
           node-version: '20'
           cache: 'npm'
@@ -42,7 +45,7 @@ jobs:
       
       - name: Comment on PR (if validation failed)
         if: failure() && github.event_name == 'pull_request'
-        uses: actions/github-script@v7
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         with:
           script: |
             github.rest.issues.createComment({
@@ -54,7 +57,7 @@ jobs:
       
       - name: Upload validation report
         if: always()
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         with:
           name: suite-validation-report
           path: |

+ 53 - 0
docs/maintenance/repository-recovery/action-pin-inventory.md

@@ -0,0 +1,53 @@
+# GitHub Actions Pin & Permissions Inventory
+
+_Repository-recovery Task 05 — least privilege + immutable action pinning._
+_Resolved: 2026-07-15._
+
+## Third-party / external action pins
+
+Every `uses:` reference is pinned to a full 40-character commit SHA, with the
+human-readable version retained as a trailing comment. SHAs were resolved via
+`gh api repos/<owner>/<repo>/commits/<ref>` on the date above.
+
+| Action | Previous ref | Pinned SHA | Note |
+|--------|--------------|------------|------|
+| `actions/checkout` | `@v4` (19 uses) | `34e114876b0b11c390a56381ad16ebd13914f8d5` | GitHub-owned |
+| `actions/github-script` | `@v7` (6 uses) | `f28e40c7f34bde8b3046d885e986cb6290c5673b` | GitHub-owned |
+| `actions/setup-node` | `@v4` (3 uses) | `49933ea5288caeca8642d1e84afbd3f7d6820020` | GitHub-owned |
+| `actions/upload-artifact` | `@v4` (1 use) | `ea165f8d65b6e75b540449e92b4886f43607fa02` | GitHub-owned |
+| `oven-sh/setup-bun` | `@v2` (1 use) | `0c5077e51419868618aeaa5fe8019c62421857d6` | Matches the SHA already pinned by the other usage |
+| `ludeeus/action-shellcheck` | `@master` (1 use) | `00b27aa7cb85167568cb48a3838b75f4265f2bca` | **Was a mutable branch ref** — highest risk before pinning |
+| `sst/opencode/github` | `@latest` (1 use) | `77fc88c8ade8e5a620ebbe1197f3a572d29ae91a` | **Was a mutable ref** — freezes the OpenCode agent version; update deliberately |
+
+### Update guidance
+
+To bump a pinned action later: re-resolve the desired tag to its SHA
+(`gh api repos/<owner>/<repo>/commits/<tag> --jq .sha`), replace the SHA, and
+update the trailing version comment in the same edit. Never revert a pin back to
+a floating tag or branch.
+
+## Workflow permissions (least privilege)
+
+Every workflow now declares an explicit top-level `permissions:` block. Default
+is `contents: read`; write scopes remain only where a job demonstrably needs them.
+
+| Workflow | Top-level | Job-level writes | Justification |
+|----------|-----------|------------------|---------------|
+| `pr-checks.yml` | `contents: read` | — | read-only validation |
+| `validate-registry.yml` | `contents: read` | — | read-only validation |
+| `installer-checks.yml` | `contents: read` **(added)** | — | shellcheck / installer tests, read-only |
+| `validate-test-suites.yml` | `contents: read` **(added)** | — | suite validation, read-only |
+| `opencode.yml` | `contents: read` **(added)** | `id-token`, `contents`, `pull-requests`, `issues: write` (job) | `/oc` agent, gated to OWNER/MEMBER; job needs write to act on PRs/issues |
+| `create-release.yml` | `contents: write` | — | creates tags/releases |
+| `post-merge-pr.yml` | `contents: write`, `pull-requests: write` | — | opens follow-up PRs |
+| `sync-docs.yml` | `contents: write`, `pull-requests: write`, `issues: write` | — | commits doc branch, opens PR, creates a sync issue (`issues.create`) |
+| `update-registry.yml` | `contents: write` | — | commits registry updates |
+
+`issues: write` in `sync-docs.yml` is retained because the workflow calls
+`github.rest.issues.create` to open a sync-tracking issue.
+
+## Verification
+
+- All 9 workflow files parse as valid YAML.
+- No `uses:` reference remains on a floating tag or branch — all are 40-char SHAs.
+- Every workflow has an explicit top-level `permissions:` block.