From f7035b5644ffc857879b71c39353b2141f17c3f0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 15 May 2026 17:09:19 -0400 Subject: [PATCH] Harden CI installs against supply-chain lifecycle hooks --- .github/workflows/ci.yml | 77 ++----------------- .github/workflows/reusable-test.yml | 76 ++---------------- ...operator-readiness-dashboard-2026-05-15.md | 6 +- .../supply-chain-incident-response.md | 10 ++- scripts/ci/validate-workflow-security.js | 38 ++++++++- tests/ci/validate-workflow-security.test.js | 30 ++++++-- 6 files changed, 80 insertions(+), 157 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af2bf7f5..9ee3d080 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,73 +68,6 @@ jobs: if: matrix.pm == 'bun' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - # Cache configuration - - name: Get npm cache directory - if: matrix.pm == 'npm' - id: npm-cache-dir - shell: bash - run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT - - - name: Restore npm cache - if: matrix.pm == 'npm' - continue-on-error: true - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ${{ steps.npm-cache-dir.outputs.dir }} - key: ${{ runner.os }}-node-${{ matrix.node }}-npm-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node }}-npm- - - - name: Get pnpm store directory - if: matrix.pm == 'pnpm' - id: pnpm-cache-dir - shell: bash - env: - COREPACK_ENABLE_STRICT: '0' - run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Restore pnpm cache - if: matrix.pm == 'pnpm' - continue-on-error: true - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ${{ steps.pnpm-cache-dir.outputs.dir }} - key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node }}-pnpm- - - - name: Get yarn cache directory - if: matrix.pm == 'yarn' - id: yarn-cache-dir - shell: bash - run: | - # Try Yarn Berry first, fall back to Yarn v1 - if yarn config get cacheFolder >/dev/null 2>&1; then - echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - else - echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - fi - - - name: Restore yarn cache - if: matrix.pm == 'yarn' - continue-on-error: true - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ${{ steps.yarn-cache-dir.outputs.dir }} - key: ${{ runner.os }}-node-${{ matrix.node }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node }}-yarn- - - - name: Restore bun cache - if: matrix.pm == 'bun' - continue-on-error: true - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} - restore-keys: | - ${{ runner.os }}-bun- - # Install dependencies # COREPACK_ENABLE_STRICT=0 allows pnpm to install even though # package.json declares "packageManager": "yarn@..." @@ -142,16 +75,18 @@ jobs: shell: bash env: COREPACK_ENABLE_STRICT: '0' + npm_config_ignore_scripts: 'true' + YARN_ENABLE_SCRIPTS: 'false' run: | case "${{ matrix.pm }}" in - npm) npm ci ;; + npm) npm ci --ignore-scripts ;; # pnpm v10 can fail CI on ignored native build scripts # (for example msgpackr-extract) even though this repo is Yarn-native # and pnpm is only exercised here as a compatibility lane. - pnpm) pnpm install --config.strict-dep-builds=false --no-frozen-lockfile ;; + pnpm) pnpm install --ignore-scripts --config.strict-dep-builds=false --no-frozen-lockfile ;; # Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature - yarn) yarn install ;; - bun) bun install ;; + yarn) yarn install --mode=skip-build ;; + bun) bun install --ignore-scripts ;; *) echo "Unsupported package manager: ${{ matrix.pm }}" && exit 1 ;; esac diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml index 6f98cbeb..56f22f05 100644 --- a/.github/workflows/reusable-test.yml +++ b/.github/workflows/reusable-test.yml @@ -59,88 +59,24 @@ jobs: if: inputs.package-manager == 'bun' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - - name: Get npm cache directory - if: inputs.package-manager == 'npm' - id: npm-cache-dir - shell: bash - run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT - - - name: Restore npm cache - if: inputs.package-manager == 'npm' - continue-on-error: true - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ${{ steps.npm-cache-dir.outputs.dir }} - key: ${{ runner.os }}-node-${{ inputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ inputs.node-version }}-npm- - - - name: Get pnpm store directory - if: inputs.package-manager == 'pnpm' - id: pnpm-cache-dir - shell: bash - env: - COREPACK_ENABLE_STRICT: '0' - run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Restore pnpm cache - if: inputs.package-manager == 'pnpm' - continue-on-error: true - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ${{ steps.pnpm-cache-dir.outputs.dir }} - key: ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm- - - - name: Get yarn cache directory - if: inputs.package-manager == 'yarn' - id: yarn-cache-dir - shell: bash - run: | - # Try Yarn Berry first, fall back to Yarn v1 - if yarn config get cacheFolder >/dev/null 2>&1; then - echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - else - echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - fi - - - name: Restore yarn cache - if: inputs.package-manager == 'yarn' - continue-on-error: true - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ${{ steps.yarn-cache-dir.outputs.dir }} - key: ${{ runner.os }}-node-${{ inputs.node-version }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-node-${{ inputs.node-version }}-yarn- - - - name: Restore bun cache - if: inputs.package-manager == 'bun' - continue-on-error: true - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} - restore-keys: | - ${{ runner.os }}-bun- - # COREPACK_ENABLE_STRICT=0 allows pnpm to install even though # package.json declares "packageManager": "yarn@..." - name: Install dependencies shell: bash env: COREPACK_ENABLE_STRICT: '0' + npm_config_ignore_scripts: 'true' + YARN_ENABLE_SCRIPTS: 'false' run: | case "${{ inputs.package-manager }}" in - npm) npm ci ;; + npm) npm ci --ignore-scripts ;; # pnpm v10 can fail CI on ignored native build scripts # (for example msgpackr-extract) even though this repo is Yarn-native # and pnpm is only exercised here as a compatibility lane. - pnpm) pnpm install --config.strict-dep-builds=false --no-frozen-lockfile ;; + pnpm) pnpm install --ignore-scripts --config.strict-dep-builds=false --no-frozen-lockfile ;; # Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature - yarn) yarn install ;; - bun) bun install ;; + yarn) yarn install --mode=skip-build ;; + bun) bun install --ignore-scripts ;; *) echo "Unsupported package manager: ${{ inputs.package-manager }}" && exit 1 ;; esac diff --git a/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md b/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md index c1485589..e258e152 100644 --- a/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md +++ b/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md @@ -34,12 +34,12 @@ Run these from `everything-claude-code` unless a row says otherwise. | AgentShield PRs/issues | GitHub connector and `gh` readback | 0 open PRs; 0 open issues | | ECC Tools PRs/issues | Local `gh pr list` and `gh issue list` | 0 open PRs; 0 open issues | | Discussion baseline | GraphQL discussion sweep | Main repo #1923 marked answered; no answerable Q&A missing an answer | -| Supply-chain IOC scan | `node scripts/ci/scan-supply-chain-iocs.js --root --home` | Passed; 1241 files inspected | +| Supply-chain IOC scan | `node scripts/ci/scan-supply-chain-iocs.js --root --home` | Passed; repo/home targeted scan inspected 200 files after clean no-script reinstall | | IOC unit tests | `node tests/ci/scan-supply-chain-iocs.test.js` | 15/15 passed | | Dead-man switch persistence sweep | Process, LaunchAgent, and known payload filename sweep for Mini Shai-Hulud markers | No matches | -| Workflow security gate | `node scripts/ci/validate-workflow-security.js` | Passed; 8 workflow files inspected | +| Workflow security gate | `node scripts/ci/validate-workflow-security.js` | Passed; 8 workflow files inspected; package-manager test installs disable lifecycle scripts and no Actions cache use remains | | Supply-chain watch workflow | `.github/workflows/supply-chain-watch.yml` | Scheduled every 6 hours; emits `supply-chain-ioc-report.json` | -| npm signatures and audit | `npm audit signatures && npm audit --audit-level=moderate` in main, AgentShield, ECC Tools | 0 vulnerabilities in each checked package | +| npm signatures and audit | `npm audit signatures && npm audit --audit-level=high` in main | 213 verified signatures, 17 verified attestations, 0 high vulnerabilities | ## Prompt-To-Artifact Checklist diff --git a/docs/security/supply-chain-incident-response.md b/docs/security/supply-chain-incident-response.md index 3d2aad6f..903bed07 100644 --- a/docs/security/supply-chain-incident-response.md +++ b/docs/security/supply-chain-incident-response.md @@ -126,8 +126,10 @@ If ECC or a maintainer machine installed a known-bad package version: keys, and local `.npmrc` tokens; - any MCP, plugin, or harness credentials available in environment variables or user-scope config. -6. Purge GitHub Actions caches for affected repositories. -7. Reinstall from a clean environment with `npm ci --ignore-scripts` first. +6. Purge GitHub Actions dependency caches for affected repositories. +7. Reinstall from a clean environment with lifecycle scripts disabled first: + `npm ci --ignore-scripts`, `pnpm install --ignore-scripts`, + `yarn install --mode=skip-build`, or `bun install --ignore-scripts`. 8. Re-enable lifecycle scripts only after the dependency tree and package versions are pinned to known-clean releases. @@ -136,7 +138,9 @@ If ECC or a maintainer machine installed a known-bad package version: ECC enforces these rules through `scripts/ci/validate-workflow-security.js`: - privileged workflows must not checkout untrusted PR refs; -- workflows with write permissions must use `npm ci --ignore-scripts`; +- all workflow dependency installs must disable lifecycle scripts; +- workflows must not restore or save shared GitHub Actions dependency caches + during active supply-chain hardening; - workflows with `id-token: write` must not restore or save shared dependency caches; - workflows that run `npm audit` must also run `npm audit signatures`; diff --git a/scripts/ci/validate-workflow-security.js b/scripts/ci/validate-workflow-security.js index 5f88cce4..22018b3b 100644 --- a/scripts/ci/validate-workflow-security.js +++ b/scripts/ci/validate-workflow-security.js @@ -25,11 +25,28 @@ const RULES = [ ]; const WRITE_PERMISSION_PATTERN = /^\s*(?:contents|issues|pull-requests|actions|checks|deployments|discussions|id-token|packages|pages|repository-projects|security-events|statuses):\s*write\b/m; -const NPM_CI_PATTERN = /\bnpm\s+ci\b(?![^\n]*--ignore-scripts)/g; const NPM_AUDIT_PATTERN = /\bnpm\s+audit\b(?!\s+signatures\b)/; const NPM_AUDIT_SIGNATURES_PATTERN = /\bnpm\s+audit\s+signatures\b/; const ACTIONS_CACHE_PATTERN = /uses:\s*['"]?actions\/cache@/m; const ID_TOKEN_WRITE_PATTERN = /^\s*id-token:\s*write\b/m; +const UNSAFE_INSTALL_PATTERNS = [ + { + pattern: /\bnpm\s+ci\b(?![^\n]*--ignore-scripts)/g, + description: 'npm ci must include --ignore-scripts', + }, + { + pattern: /\bpnpm\s+install\b(?![^\n]*--ignore-scripts)/g, + description: 'pnpm install must include --ignore-scripts', + }, + { + pattern: /\byarn\s+install\b(?![^\n]*--mode=skip-build)/g, + description: 'yarn install must use --mode=skip-build', + }, + { + pattern: /\bbun\s+install\b(?![^\n]*--ignore-scripts)/g, + description: 'bun install must include --ignore-scripts', + }, +]; function getWorkflowFiles(workflowsDir) { if (!fs.existsSync(workflowsDir)) { @@ -120,11 +137,14 @@ function findViolations(filePath, source) { } } - for (const match of source.matchAll(NPM_CI_PATTERN)) { + } + + for (const installRule of UNSAFE_INSTALL_PATTERNS) { + for (const match of source.matchAll(installRule.pattern)) { violations.push({ filePath, - event: 'write-permission install', - description: 'workflows with write permissions must install npm dependencies with --ignore-scripts', + event: 'dependency install scripts', + description: `workflow dependency installs must not run lifecycle scripts: ${installRule.description}`, expression: match[0], line: getLineNumber(source, match.index), }); @@ -141,6 +161,16 @@ function findViolations(filePath, source) { }); } + if (ACTIONS_CACHE_PATTERN.test(source)) { + violations.push({ + filePath, + event: 'dependency cache', + description: 'GitHub Actions dependency caches are disabled during active supply-chain hardening', + expression: 'actions/cache', + line: getLineNumber(source, source.search(ACTIONS_CACHE_PATTERN)), + }); + } + if (/\bpull_request_target\s*:/m.test(source) && ACTIONS_CACHE_PATTERN.test(source)) { violations.push({ filePath, diff --git a/tests/ci/validate-workflow-security.test.js b/tests/ci/validate-workflow-security.test.js index dc0e0577..b32f1176 100644 --- a/tests/ci/validate-workflow-security.test.js +++ b/tests/ci/validate-workflow-security.test.js @@ -107,21 +107,39 @@ function run() { assert.match(result.stderr, /pull_request_target workflows must not restore or save shared dependency caches/); })) passed++; else failed++; - if (test('rejects npm ci without ignore-scripts in workflows with write permissions', () => { + if (test('rejects dependency cache use in ordinary workflows', () => { const result = runValidator({ - 'unsafe-write-install.yml': `name: Unsafe\non:\n workflow_dispatch:\npermissions:\n contents: read\n issues: write\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: npm ci\n`, + 'unsafe-cache.yml': `name: Unsafe\non:\n pull_request:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/cache@v5\n with:\n path: ~/.npm\n key: cache\n`, }); - assert.notStrictEqual(result.status, 0, 'Expected validator to fail on npm ci without --ignore-scripts'); - assert.match(result.stderr, /write permissions must install npm dependencies with --ignore-scripts/); + assert.notStrictEqual(result.status, 0, 'Expected validator to fail on actions/cache use'); + assert.match(result.stderr, /dependency caches are disabled during active supply-chain hardening/); })) passed++; else failed++; - if (test('allows npm ci with ignore-scripts in workflows with write permissions', () => { + if (test('rejects npm ci without ignore-scripts in any workflow', () => { const result = runValidator({ - 'safe-write-install.yml': `name: Safe\non:\n workflow_dispatch:\npermissions:\n contents: read\n issues: write\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: npm ci --ignore-scripts\n`, + 'unsafe-install.yml': `name: Unsafe\non:\n pull_request:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: npm ci\n`, + }); + assert.notStrictEqual(result.status, 0, 'Expected validator to fail on npm ci without --ignore-scripts'); + assert.match(result.stderr, /npm ci must include --ignore-scripts/); + })) passed++; else failed++; + + if (test('allows package-manager installs with lifecycle scripts disabled', () => { + const result = runValidator({ + 'safe-install.yml': `name: Safe\non:\n pull_request:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: |\n npm ci --ignore-scripts\n pnpm install --ignore-scripts --no-frozen-lockfile\n yarn install --mode=skip-build\n bun install --ignore-scripts\n`, }); assert.strictEqual(result.status, 0, result.stderr || result.stdout); })) passed++; else failed++; + if (test('rejects pnpm, yarn, and bun installs that run lifecycle scripts', () => { + const result = runValidator({ + 'unsafe-matrix-install.yml': `name: Unsafe\non:\n pull_request:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: |\n pnpm install --no-frozen-lockfile\n yarn install\n bun install\n`, + }); + assert.notStrictEqual(result.status, 0, 'Expected validator to fail on script-running installs'); + assert.match(result.stderr, /pnpm install must include --ignore-scripts/); + assert.match(result.stderr, /yarn install must use --mode=skip-build/); + assert.match(result.stderr, /bun install must include --ignore-scripts/); + })) passed++; else failed++; + if (test('rejects checkout credential persistence in workflows with write permissions', () => { const result = runValidator({ 'unsafe-write-checkout.yml': `name: Unsafe\non:\n workflow_dispatch:\npermissions:\n contents: write\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: npm ci --ignore-scripts\n`,