From 36e4cea693f69fe9b08ec93cbd360fd3eb1f2aee Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 6 Sep 2026 18:04:55 -0700 Subject: [PATCH] Pin workflow actions and document live-edit script trust (#755) Pin existing action versions to verified commit SHAs, restrict CI to a read-only repository token, and document the optional live-edit validation script. Preserve required sync/sheriff permissions and existing runtime behavior. Related to #480; extension permission assessment remains separate. AI assistance: Codex, under maintainer direction. --- .github/workflows/ci.yml | 107 ++++++++++---------- .github/workflows/sheriff.yml | 4 +- .github/workflows/sync-generated-output.yml | 6 +- README.md | 2 + scripts/test-suites.mjs | 1 + tests/workflow-security.test.js | 35 +++++++ 6 files changed, 98 insertions(+), 57 deletions(-) create mode 100644 tests/workflow-security.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ad52830a..e84c428a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ on: schedule: - cron: '0 7 * * *' +permissions: + contents: read + concurrency: # Scheduled runs get their own group: the 07:00 UTC nightly and a push to # main share github.ref, and cancel-in-progress would let them kill each @@ -34,12 +37,12 @@ jobs: live_svelte_adapter_deepseek: ${{ steps.plan.outputs.live_svelte_adapter_deepseek }} steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 @@ -59,15 +62,15 @@ jobs: node-version: [22.18.0, 24] steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -102,7 +105,7 @@ jobs: if: needs.changes.outputs.detector == 'true' run: rustup show && rustup target add wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 if: needs.changes.outputs.detector == 'true' - name: Install wasm-pack @@ -140,7 +143,7 @@ jobs: run: git diff --exit-code -- .agents .claude .cursor .dsh .gemini .github/skills plugin extension/detector - name: Upload build artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: impeccable-build-node-${{ matrix.node-version }} # Ship the packaged zips, not the unpacked Firefox staging tree. @@ -157,14 +160,14 @@ jobs: if: needs.changes.outputs.rust == 'true' steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # rust-toolchain.toml names the channel; `rustup show` installs it. # Never override the toolchain here. - name: Install the pinned toolchain run: rustup show - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Build run: cargo build --workspace --all-targets @@ -181,10 +184,10 @@ jobs: if: needs.changes.outputs.rust == 'true' steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install the pinned toolchain run: rustup show - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: cargo build --workspace --all-targets - run: cargo test --workspace --no-fail-fast @@ -194,9 +197,9 @@ jobs: if: needs.changes.outputs.core == 'true' steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Exercise Windows launcher downloads and verification @@ -213,15 +216,15 @@ jobs: if: needs.changes.outputs.oracle == 'true' || needs.changes.outputs.rust == 'true' steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -231,7 +234,7 @@ jobs: - name: Install the pinned toolchain run: rustup show - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Build the engine from source run: cargo build --release -p impeccable @@ -255,10 +258,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 @@ -291,15 +294,15 @@ jobs: if: needs.changes.outputs.cli_remote_e2e == 'true' steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -311,7 +314,7 @@ jobs: - name: Install the pinned toolchain run: rustup show - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Build the engine run: cargo build --release -p impeccable @@ -337,20 +340,20 @@ jobs: fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: Cache fixture npm downloads - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.npm key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} @@ -358,7 +361,7 @@ jobs: ${{ runner.os }}-fixture-npm- - name: Cache Playwright Chromium - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} @@ -376,7 +379,7 @@ jobs: - name: Install the pinned toolchain run: rustup show - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Build the engine run: cargo build --release -p impeccable @@ -393,7 +396,7 @@ jobs: - name: Upload live E2E failure artifacts if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: live-e2e-smoke-${{ matrix.group }}-artifacts path: test-results/live-e2e @@ -423,20 +426,20 @@ jobs: fixtures: vite8-react-tailwindv3,vite8-react-tailwindv4,vite8-react-ts,vite8-react-tsx-repeated-aside,vite8-react-unocss,vite8-react-vanilla-extract steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: Cache fixture npm downloads - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.npm key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} @@ -444,7 +447,7 @@ jobs: ${{ runner.os }}-fixture-npm- - name: Cache Playwright Chromium - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} @@ -462,7 +465,7 @@ jobs: - name: Install the pinned toolchain run: rustup show - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Build the engine run: cargo build --release -p impeccable @@ -478,7 +481,7 @@ jobs: - name: Upload live E2E failure artifacts if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: live-e2e-full-${{ matrix.group }}-artifacts path: test-results/live-e2e @@ -499,23 +502,23 @@ jobs: - name: Checkout repository if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Setup Bun if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: Cache fixture npm downloads if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.npm key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} @@ -524,7 +527,7 @@ jobs: - name: Cache Playwright Chromium if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} @@ -545,7 +548,7 @@ jobs: if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} run: rustup show - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} - name: Build the engine @@ -576,23 +579,23 @@ jobs: - name: Checkout repository if: ${{ env.DEEPSEEK_API_KEY != '' }} - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node if: ${{ env.DEEPSEEK_API_KEY != '' }} - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Setup Bun if: ${{ env.DEEPSEEK_API_KEY != '' }} - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest - name: Cache fixture npm downloads if: ${{ env.DEEPSEEK_API_KEY != '' }} - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.npm key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }} @@ -601,7 +604,7 @@ jobs: - name: Cache Playwright Chromium if: ${{ env.DEEPSEEK_API_KEY != '' }} - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }} @@ -622,7 +625,7 @@ jobs: if: ${{ env.DEEPSEEK_API_KEY != '' }} run: rustup show - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 if: ${{ env.DEEPSEEK_API_KEY != '' }} - name: Build the engine @@ -643,15 +646,15 @@ jobs: GOOGLE_CLOUD_API_KEY: ${{ secrets.GOOGLE_CLOUD_API_KEY }} steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/.github/workflows/sheriff.yml b/.github/workflows/sheriff.yml index f9a19884c..8abeea653 100644 --- a/.github/workflows/sheriff.yml +++ b/.github/workflows/sheriff.yml @@ -48,10 +48,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 diff --git a/.github/workflows/sync-generated-output.yml b/.github/workflows/sync-generated-output.yml index dff62f9cc..466be1546 100644 --- a/.github/workflows/sync-generated-output.yml +++ b/.github/workflows/sync-generated-output.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Optional PAT or GitHub App token. With the default GITHUB_TOKEN, @@ -57,12 +57,12 @@ jobs: token: ${{ secrets.SYNC_GENERATED_OUTPUT_TOKEN || github.token }} - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/README.md b/README.md index 822129d12..5458f181e 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,8 @@ npx impeccable update Live mode edits a local checkout through a development server or local static HTML. Injecting its localhost HTTP helper into a deployed production site, including an HTTPS site, is not supported. Do not disable browser security or weaken production CSP to make it work. +Use live mode only in projects you trust to run locally. Applying copy edits automatically runs `package.json`'s optional `scripts["impeccable:manual-edit-validate"]` command in a shell, with your user permissions; review that script before using live mode in an unfamiliar checkout. + For production inspection, use `npx impeccable detect https://example.com` or the browser extension. These inspect the rendered page; they do not provide live variant editing or write changes back to your source. ## CLI diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 3401c063f..618a88d18 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -52,6 +52,7 @@ export const SUITES = { 'tests/validate-plugin-manifest.test.js', 'tests/plugin-paths.test.js', 'tests/release-engine-workflow.test.js', + 'tests/workflow-security.test.js', ], }, { diff --git a/tests/workflow-security.test.js b/tests/workflow-security.test.js new file mode 100644 index 000000000..14e95f695 --- /dev/null +++ b/tests/workflow-security.test.js @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync, readdirSync } from 'node:fs'; + +const directory = new URL('../.github/workflows/', import.meta.url); +const workflows = Object.fromEntries(readdirSync(directory) + .filter(name => /\.ya?ml$/.test(name)) + .map(name => [name, Bun.YAML.parse(readFileSync(new URL(name, directory), 'utf8'))])); + +describe('workflow execution boundaries', () => { + test('repository actions are pinned to full commit SHAs', () => { + for (const [name, workflow] of Object.entries(workflows)) { + for (const [jobName, job] of Object.entries(workflow.jobs)) { + for (const step of job.steps || []) { + if (!step.uses || step.uses.startsWith('./')) continue; + expect(step.uses, `${name}: ${jobName}`).toMatch(/@[a-f0-9]{40}$/); + } + } + } + }); + + test('CI uses a read-only repository token without job-level escalation', () => { + const ci = workflows['ci.yml']; + expect(ci.permissions).toEqual({ contents: 'read' }); + for (const job of Object.values(ci.jobs)) { + expect(job.permissions).toBeUndefined(); + } + }); + + test('generated-output sync and sheriff retain their required write access', () => { + expect(workflows['sync-generated-output.yml'].permissions).toEqual({ contents: 'write' }); + expect(workflows['sheriff.yml'].permissions).toEqual({ + actions: 'read', checks: 'read', contents: 'read', issues: 'write', 'pull-requests': 'write', + }); + }); +});