diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index f2818c0b..8d267237 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -16,6 +16,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: '20.x' @@ -27,6 +29,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: '20.x' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32122c13..50b0cc02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 4368a0c1..901ac526 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -42,6 +42,7 @@ jobs: with: fetch-depth: 0 ref: ${{ inputs.tag }} + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 diff --git a/scripts/ci/validate-workflow-security.js b/scripts/ci/validate-workflow-security.js index eae0c226..5f88cce4 100644 --- a/scripts/ci/validate-workflow-security.js +++ b/scripts/ci/validate-workflow-security.js @@ -108,6 +108,18 @@ function findViolations(filePath, source) { } if (WRITE_PERMISSION_PATTERN.test(source)) { + for (const step of checkoutSteps) { + if (!/persist-credentials:\s*['"]?false['"]?\b/m.test(step.text)) { + violations.push({ + filePath, + event: 'write-permission checkout', + description: 'workflows with write permissions must disable checkout credential persistence', + expression: 'actions/checkout without persist-credentials: false', + line: step.startLine, + }); + } + } + for (const match of source.matchAll(NPM_CI_PATTERN)) { violations.push({ filePath, diff --git a/tests/ci/validate-workflow-security.test.js b/tests/ci/validate-workflow-security.test.js index a21d253e..dc0e0577 100644 --- a/tests/ci/validate-workflow-security.test.js +++ b/tests/ci/validate-workflow-security.test.js @@ -122,6 +122,21 @@ function run() { assert.strictEqual(result.status, 0, result.stderr || result.stdout); })) 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`, + }); + assert.notStrictEqual(result.status, 0, 'Expected validator to fail on credential-persisting checkout'); + assert.match(result.stderr, /write permissions must disable checkout credential persistence/); + })) passed++; else failed++; + + if (test('allows checkout with disabled credential persistence in workflows with write permissions', () => { + const result = runValidator({ + 'safe-write-checkout.yml': `name: Safe\non:\n workflow_dispatch:\npermissions:\n contents: write\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n persist-credentials: false\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`,