diff --git a/.github/workflows/integrity-check.yml b/.github/workflows/integrity-check.yml index 96321310b..ec71fdd34 100644 --- a/.github/workflows/integrity-check.yml +++ b/.github/workflows/integrity-check.yml @@ -10,17 +10,19 @@ permissions: checks: write statuses: write -env: - CODECOV_TOKEN: '2f2eb890-30e2-4724-83eb-7633832cf0de' - concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + # Same-run artifact actions use the workflow artifact runtime; keep GITHUB_TOKEN scopes minimal. tests: name: Tests runs-on: ubuntu-latest + outputs: + coverage-artifact-id: ${{ steps.coverage-artifact.outputs.artifact-id }} + permissions: + contents: read steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Install package manager (from package.json) @@ -49,14 +51,74 @@ jobs: case "$(yarn --version)" in 1.*) echo 'expected up-to-date yarn version'; exit 1 ;; esac yarn install --immutable - run: yarn lint + - run: yarn test:workflow-policy - run: node scripts/verify-resource-cleanup-contract.mjs . - run: yarn test:ci --coverage - - run: bash <(curl -s https://codecov.io/bash) + - name: Preserve coverage for isolated upload + id: coverage-artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report + path: ./coverage/lcov.info + if-no-files-found: error + retention-days: 1 - name: Verify generated distribution run: | yarn build || { echo "build command should always succeed"; exit 61; } git diff --exit-code -- dist || { echo "generated dist is stale"; exit 62; } + upload-trusted-coverage: + name: Upload trusted coverage + if: >- + always() && needs.tests.outputs.coverage-artifact-id != '' && + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]')) + needs: tests + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Download coverage + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage-report + path: coverage + - name: Upload coverage to Codecov with OIDC + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + fail_ci_if_error: true + files: ./coverage/lcov.info + use_oidc: true + + upload-tokenless-pr-coverage: + name: Upload fork and Dependabot coverage without a token + if: >- + always() && needs.tests.outputs.coverage-artifact-id != '' && + github.event_name == 'pull_request' && + (github.event.pull_request.head.repo.full_name != github.repository || + github.event.pull_request.user.login == 'dependabot[bot]') + needs: tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Download coverage + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage-report + path: coverage + - name: Upload unprotected fork or Dependabot coverage without a token + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + fail_ci_if_error: true + files: ./coverage/lcov.info + override_branch: pr${{ github.event.pull_request.number }}:${{ github.event.pull_request.head.ref }} + use_oidc: false + orchestrator-integration: name: Orchestrator Integration if: >- diff --git a/package.json b/package.json index 29789ce3b..9cf835fb7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "node scripts/ensure-husky.mjs && vitest run", "test:watch": "vitest", "test:ci": "vitest run", + "test:workflow-policy": "node --test scripts/workflow-credential-policy.test.mjs", "coverage": "vitest run --coverage", "lint": "yarn oxlint --report-unused-disable-directives", "format": "oxfmt --write", diff --git a/scripts/verify-resource-cleanup-contract.mjs b/scripts/verify-resource-cleanup-contract.mjs index 22524af6a..d7ba28b15 100644 --- a/scripts/verify-resource-cleanup-contract.mjs +++ b/scripts/verify-resource-cleanup-contract.mjs @@ -1,6 +1,10 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { parse } from 'yaml'; +import { + findCredentialShapedEnvLiterals, + renderCredentialFinding, +} from './workflow-credential-policy.mjs'; const root = path.resolve(process.argv[2] || '.'); const failures = []; @@ -247,6 +251,119 @@ if (orchestratorWorkflow.includes('secrets.UNITY_')) if (existsSync(path.join(root, '.github/workflows/sync-secrets.yml'))) failures.push('RC016: cross-repository secret synchronization must remain removed'); +const integrityWorkflow = parse(read('.github/workflows/integrity-check.yml')); +const integrityTests = integrityWorkflow.jobs?.tests; +const integrityTestSteps = integrityTests?.steps || []; +const coverageGenerationStepIndex = integrityTestSteps.findIndex( + (step) => step.run === 'yarn test:ci --coverage', +); +const coverageArtifactStepIndex = integrityTestSteps.findIndex( + (step) => step.name === 'Preserve coverage for isolated upload', +); +const distVerificationStepIndex = integrityTestSteps.findIndex( + (step) => step.name === 'Verify generated distribution', +); +const coverageGenerationStep = integrityTestSteps[coverageGenerationStepIndex]; +const coverageArtifactStep = integrityTestSteps[coverageArtifactStepIndex]; +const hasExplicitConditionOrFailureOverride = (step) => + Object.hasOwn(step || {}, 'if') || Object.hasOwn(step || {}, 'continue-on-error'); +const trustedCoverageJob = integrityWorkflow.jobs?.['upload-trusted-coverage']; +const tokenlessPrCoverageJob = integrityWorkflow.jobs?.['upload-tokenless-pr-coverage']; +const trustedCoverageCondition = + "always() && needs.tests.outputs.coverage-artifact-id != '' && ((github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login != 'dependabot[bot]'))"; +const tokenlessPrCoverageCondition = + "always() && needs.tests.outputs.coverage-artifact-id != '' && github.event_name == 'pull_request' && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')"; +const expectedTrustedSteps = [ + { + name: 'Download coverage', + uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c', + with: { name: 'coverage-report', path: 'coverage' }, + }, + { + name: 'Upload coverage to Codecov with OIDC', + uses: 'codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f', + with: { + disable_search: true, + fail_ci_if_error: true, + files: './coverage/lcov.info', + use_oidc: true, + }, + }, +]; +const expectedPrSteps = [ + expectedTrustedSteps[0], + { + name: 'Upload unprotected fork or Dependabot coverage without a token', + uses: 'codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f', + with: { + disable_search: true, + fail_ci_if_error: true, + files: './coverage/lcov.info', + override_branch: + 'pr${{ github.event.pull_request.number }}:${{ github.event.pull_request.head.ref }}', + use_oidc: false, + }, + }, +]; +const uploadJobUsesCheckout = (job) => + (job?.steps || []).some((step) => String(step.uses || '').startsWith('actions/checkout@')); +if ( + integrityTests?.permissions?.contents !== 'read' || + Object.keys(integrityTests?.permissions || {}).length !== 1 || + Object.hasOwn(integrityTests?.permissions || {}, 'id-token') || + integrityTestSteps.some((step) => String(step.uses || '').startsWith('codecov/')) || + integrityTests?.outputs?.['coverage-artifact-id'] !== + '${{ steps.coverage-artifact.outputs.artifact-id }}' || + coverageGenerationStepIndex < 0 || + coverageArtifactStepIndex <= coverageGenerationStepIndex || + distVerificationStepIndex <= coverageArtifactStepIndex || + hasExplicitConditionOrFailureOverride(coverageGenerationStep) || + hasExplicitConditionOrFailureOverride(coverageArtifactStep) || + coverageArtifactStep?.id !== 'coverage-artifact' || + coverageArtifactStep?.uses !== + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || + coverageArtifactStep?.with?.name !== 'coverage-report' || + coverageArtifactStep?.with?.path !== './coverage/lcov.info' || + coverageArtifactStep?.with?.['if-no-files-found'] !== 'error' || + coverageArtifactStep?.with?.['retention-days'] !== 1 || + trustedCoverageJob?.if !== trustedCoverageCondition || + trustedCoverageJob?.needs !== 'tests' || + trustedCoverageJob?.permissions?.['id-token'] !== 'write' || + Object.keys(trustedCoverageJob?.permissions || {}).length !== 1 || + uploadJobUsesCheckout(trustedCoverageJob) || + JSON.stringify(trustedCoverageJob?.steps) !== JSON.stringify(expectedTrustedSteps) || + tokenlessPrCoverageJob?.if !== tokenlessPrCoverageCondition || + tokenlessPrCoverageJob?.needs !== 'tests' || + tokenlessPrCoverageJob?.permissions?.contents !== 'read' || + Object.keys(tokenlessPrCoverageJob?.permissions || {}).length !== 1 || + Object.hasOwn(tokenlessPrCoverageJob?.permissions || {}, 'id-token') || + uploadJobUsesCheckout(tokenlessPrCoverageJob) || + JSON.stringify(tokenlessPrCoverageJob?.steps) !== JSON.stringify(expectedPrSteps) || + JSON.stringify(integrityWorkflow).includes('secrets.CODECOV') +) + failures.push( + 'RC019: coverage must cross an artifact boundary into exact isolated pinned OIDC and fork/Dependabot tokenless upload jobs', + ); + +const pendingWorkflowDirectories = [path.join(root, '.github')]; +while (pendingWorkflowDirectories.length > 0) { + const directory = pendingWorkflowDirectories.pop(); + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + pendingWorkflowDirectories.push(absolute); + continue; + } + if (!entry.isFile() || (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml'))) + continue; + const fileName = path.relative(path.join(root, '.github'), absolute).replaceAll('\\', '/'); + const workflow = parse(read(path.relative(root, absolute))); + for (const finding of findCredentialShapedEnvLiterals(workflow, fileName)) { + failures.push(`RC018: ${renderCredentialFinding(finding)}`); + } + } +} + for (const fileName of readdirSync(path.join(root, '.github/workflows'))) { if (!fileName.endsWith('.yml') && !fileName.endsWith('.yaml')) continue; const workflow = parse(read(`.github/workflows/${fileName}`)); @@ -272,4 +389,4 @@ if (failures.length) { console.error(failures.join('\n')); process.exit(1); } -console.log('Resource cleanup contract verified (RC001-RC017).'); +console.log('Resource cleanup contract verified (RC001-RC019).'); diff --git a/scripts/workflow-credential-policy.mjs b/scripts/workflow-credential-policy.mjs new file mode 100644 index 000000000..53ff485b6 --- /dev/null +++ b/scripts/workflow-credential-policy.mjs @@ -0,0 +1,75 @@ +import { parse } from 'yaml'; + +const credentialNamePattern = + /(?:^|_)(?:API_KEY|ACCESS_KEY|CREDENTIAL|PASSWORD|PASSWD|PRIVATE_KEY|SECRET|TOKEN)(?:_|$)/i; +const credentialValuePatterns = [ + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + /^(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,})$/, + /^(?:AKIA|ASIA)[A-Z0-9]{16}$/, + /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + /^-----BEGIN [A-Z0-9 ]+ PRIVATE KEY-----/, + /^(?=.{32,}$)(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9+/=_-]+$/, +]; + +function isCredentialShapedLiteral(name, value) { + if (!credentialNamePattern.test(name) || typeof value !== 'string') return false; + + const scalar = value.trim(); + if (!scalar) return false; + if (/^\$\{\{\s*(?:secrets\.[A-Za-z_][A-Za-z0-9_]*|github\.token)\s*\}\}$/.test(scalar)) { + return false; + } + if (scalar.includes('${{')) return true; + + return credentialValuePatterns.some((pattern) => pattern.test(scalar)); +} + +function findCredentialShapedEnvLiterals(document, fileName = '') { + const findings = []; + + function visit(value, nodePath) { + if (Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, [...nodePath, String(index)])); + return; + } + if (!value || typeof value !== 'object') return; + + for (const [key, child] of Object.entries(value)) { + const childPath = [...nodePath, key]; + if (key === 'env' && child && typeof child === 'object' && !Array.isArray(child)) { + for (const [name, envValue] of Object.entries(child)) { + if (isCredentialShapedLiteral(name, envValue)) { + findings.push({ fileName, name, path: [...childPath, name].join('.') }); + } + } + } + visit(child, childPath); + } + } + + visit(document, []); + return findings; +} + +function renderDiagnosticComponent(value) { + return JSON.stringify(String(value)) + .replaceAll(':', '\\u003a') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029'); +} + +function renderCredentialFinding(finding) { + return `${renderDiagnosticComponent(finding.fileName)} has a credential-shaped literal at ${renderDiagnosticComponent(finding.path)}; use OIDC or a GitHub secret reference`; +} + +function auditWorkflowCredentialLiterals(source, fileName = '') { + return findCredentialShapedEnvLiterals(parse(source), fileName); +} + +export { + auditWorkflowCredentialLiterals, + findCredentialShapedEnvLiterals, + isCredentialShapedLiteral, + renderCredentialFinding, + renderDiagnosticComponent, +}; diff --git a/scripts/workflow-credential-policy.test.mjs b/scripts/workflow-credential-policy.test.mjs new file mode 100644 index 000000000..7b02b793e --- /dev/null +++ b/scripts/workflow-credential-policy.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + auditWorkflowCredentialLiterals, + isCredentialShapedLiteral, + renderCredentialFinding, + renderDiagnosticComponent, +} from './workflow-credential-policy.mjs'; + +test('credential-shaped env literal classification is data-driven', () => { + const cases = [ + ['CODECOV_TOKEN', '12345678-1234-4234-9234-123456789abc', true], + ['GH_TOKEN', `ghp_${'a'.repeat(36)}`, true], + ['SERVICE_API_KEY', 'AbCdEf0123456789AbCdEf0123456789', true], + ['DEPLOY_PRIVATE_KEY', '-----BEGIN OPENSSH PRIVATE KEY-----', true], + ['CODECOV_TOKEN', '${{ secrets.CODECOV_TOKEN }}', false], + ['GH_TOKEN', '${{ github.token }}', false], + ['GH_TOKEN', `prefix-\${{ github.token }}`, true], + ['GH_TOKEN', `\${{ github.token }}-${'a'.repeat(32)}`, true], + ['GH_TOKEN', '${{ steps.auth.outputs.token }}', true], + ['GH_TOKEN', '${{ github.token || secrets.FALLBACK_TOKEN }}', true], + ['UNITY_PASSWORD', 'integration-test-only', false], + ['AWS_SECRET_ACCESS_KEY', 'test', false], + ['RUN_ID', '12345678-1234-4234-9234-123456789abc', false], + ]; + + for (const [name, value, expected] of cases) { + assert.equal(isCredentialShapedLiteral(name, value), expected, `${name} classification`); + } +}); + +test('workflow audit finds top-level, job, and step env literals without returning values', () => { + const findings = auditWorkflowCredentialLiterals( + ` +env: + CODECOV_TOKEN: 12345678-1234-4234-9234-123456789abc +jobs: + test: + env: + GH_TOKEN: \${{ github.token }} + steps: + - env: + SERVICE_API_KEY: AbCdEf0123456789AbCdEf0123456789 + run: echo safe +`, + 'fixture.yml', + ); + + assert.deepEqual(findings, [ + { fileName: 'fixture.yml', name: 'CODECOV_TOKEN', path: 'env.CODECOV_TOKEN' }, + { + fileName: 'fixture.yml', + name: 'SERVICE_API_KEY', + path: 'jobs.test.steps.0.env.SERVICE_API_KEY', + }, + ]); + assert.equal(Object.hasOwn(findings[0], 'value'), false); +}); + +test('workflow audit accepts references and intentionally low-entropy synthetic fixtures', () => { + assert.deepEqual( + auditWorkflowCredentialLiterals( + ` +jobs: + test: + env: + UNITY_PASSWORD: integration-test-only + AWS_SECRET_ACCESS_KEY: test + GITHUB_TOKEN: \${{ secrets.GIT_PRIVATE_TOKEN }} +`, + 'safe.yml', + ), + [], + ); +}); + +test('workflow audit rejects expressions combined with credential material', () => { + const findings = auditWorkflowCredentialLiterals( + ` +env: + GH_TOKEN: prefix-\${{ github.token }} + SERVICE_API_KEY: "\${{ secrets.SERVICE_API_KEY }}-${'a'.repeat(32)}" +`, + 'expression-bypass.yml', + ); + + assert.deepEqual( + findings.map(({ name, path }) => ({ name, path })), + [ + { name: 'GH_TOKEN', path: 'env.GH_TOKEN' }, + { name: 'SERVICE_API_KEY', path: 'env.SERVICE_API_KEY' }, + ], + ); +}); + +test('RC018 diagnostic rendering safely quotes hostile file names and env paths', () => { + const credential = 'AbCdEf0123456789AbCdEf0123456789'; + const [finding] = auditWorkflowCredentialLiterals( + `env:\n "GH_TOKEN_::error file=target::forged\\n\\u2028": ${credential}\n`, + '.github/workflows/hostile\n::error file=target::forged\u2029.yml', + ); + const diagnostic = renderCredentialFinding(finding); + + assert.match(diagnostic, /^"\.github\/workflows\/hostile\\n/); + assert.match(diagnostic, / at "env\.GH_TOKEN_/); + assert.equal(diagnostic.includes(credential), false); + assert.equal(diagnostic.includes('::'), false); + for (const character of diagnostic) { + assert.equal( + character < ' ' || character === '\u007f' || character === '\u2028' || character === '\u2029', + false, + `diagnostic retained unsafe character U+${character.codePointAt(0).toString(16)}`, + ); + } +}); + +test('diagnostic component renderer escapes controls, separators, colons, and annotation markers', () => { + const rendered = renderDiagnosticComponent('quoted\r\x1b\u2028\u2029::error::'); + + assert.equal(rendered, '"quoted\\r\\u001b\\u2028\\u2029\\u003a\\u003aerror\\u003a\\u003a"'); +});