diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c689593e..f73d63c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,7 +261,7 @@ jobs: node-version: '20.x' - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run ESLint run: npx eslint scripts/**/*.js tests/**/*.js diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index 7908cc11..1a4eb8bd 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -33,7 +33,7 @@ jobs: - name: Run security audit run: | if [ -f package-lock.json ]; then - npm ci + npm ci --ignore-scripts npm audit --audit-level=high else echo "No package-lock.json found; skipping npm audit" diff --git a/scripts/ci/validate-workflow-security.js b/scripts/ci/validate-workflow-security.js index 03936136..45238d97 100644 --- a/scripts/ci/validate-workflow-security.js +++ b/scripts/ci/validate-workflow-security.js @@ -24,6 +24,11 @@ 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 ACTIONS_CACHE_PATTERN = /uses:\s*['"]?actions\/cache@/m; +const ID_TOKEN_WRITE_PATTERN = /^\s*id-token:\s*write\b/m; + function getWorkflowFiles(workflowsDir) { if (!fs.existsSync(workflowsDir)) { return []; @@ -100,6 +105,28 @@ function findViolations(filePath, source) { } } + if (WRITE_PERMISSION_PATTERN.test(source)) { + for (const match of source.matchAll(NPM_CI_PATTERN)) { + violations.push({ + filePath, + event: 'write-permission install', + description: 'workflows with write permissions must install npm dependencies with --ignore-scripts', + expression: match[0], + line: getLineNumber(source, match.index), + }); + } + } + + if (ID_TOKEN_WRITE_PATTERN.test(source) && ACTIONS_CACHE_PATTERN.test(source)) { + violations.push({ + filePath, + event: 'id-token cache', + description: 'workflows with id-token: write must not restore or save shared dependency caches', + expression: 'id-token: write + actions/cache', + line: getLineNumber(source, source.search(ID_TOKEN_WRITE_PATTERN)), + }); + } + return violations; } diff --git a/tests/ci/validate-workflow-security.test.js b/tests/ci/validate-workflow-security.test.js index 9d58c6f6..e9a3d567 100644 --- a/tests/ci/validate-workflow-security.test.js +++ b/tests/ci/validate-workflow-security.test.js @@ -99,6 +99,29 @@ function run() { assert.match(result.stderr, /pull_request\.head\.sha/); })) passed++; else failed++; + if (test('rejects npm ci without ignore-scripts in workflows with write permissions', () => { + 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`, + }); + 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/); + })) passed++; else failed++; + + if (test('allows npm ci with ignore-scripts in workflows with write permissions', () => { + 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`, + }); + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + })) passed++; else failed++; + + if (test('rejects actions/cache in workflows with id-token write', () => { + const result = runValidator({ + 'unsafe-oidc-cache.yml': `name: Unsafe\non:\n push:\npermissions:\n contents: read\n id-token: write\njobs:\n release:\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 id-token workflow cache use'); + assert.match(result.stderr, /id-token: write must not restore or save shared dependency caches/); + })) passed++; else failed++; + console.log(`\nPassed: ${passed}`); console.log(`Failed: ${failed}`);