diff --git a/packages/cli/src/commands/build-actions.test.ts b/packages/cli/src/commands/build-actions.test.ts index f07df8e7..f969b380 100644 --- a/packages/cli/src/commands/build-actions.test.ts +++ b/packages/cli/src/commands/build-actions.test.ts @@ -61,4 +61,78 @@ jobs: expect(findings).toHaveLength(0); }); + + it('detects secrets interpolated in run steps', () => { + const findings = auditWorkflowContent('workflow.yml', ` +name: Secrets Test +on: push +permissions: + contents: read +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: curl -H "Authorization: \${{ secrets.API_TOKEN }}" https://example.com +`); + + expect(findings.map((f) => f.rule)).toContain('secrets-in-run'); + const finding = findings.find((f) => f.rule === 'secrets-in-run'); + expect(finding?.severity).toBe('high'); + }); + + it('does not flag secrets used in env: blocks', () => { + const findings = auditWorkflowContent('workflow.yml', ` +name: Safe Secrets +on: push +permissions: + contents: read +jobs: + deploy: + runs-on: ubuntu-latest + env: + TOKEN: \${{ secrets.API_TOKEN }} + steps: + - run: curl -H "Authorization: $TOKEN" https://example.com +`); + + expect(findings.map((f) => f.rule)).not.toContain('secrets-in-run'); + }); + + it('flags third-party actions not pinned to a SHA', () => { + const findings = auditWorkflowContent('workflow.yml', ` +name: Third Party Test +on: push +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: some-org/cool-action@v2.1.0 +`); + + expect(findings.map((f) => f.rule)).toContain('third-party-action'); + const finding = findings.find((f) => f.rule === 'third-party-action'); + expect(finding?.severity).toBe('low'); + expect(finding?.message).toContain('some-org/cool-action'); + }); + + it('does not flag trusted-org actions or SHA-pinned actions', () => { + const findings = auditWorkflowContent('workflow.yml', ` +name: Pinned Test +on: push +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: aws-actions/configure-aws-credentials@v4 + - uses: some-org/cool-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 +`); + + expect(findings.map((f) => f.rule)).not.toContain('third-party-action'); + }); }); diff --git a/packages/cli/src/commands/build-actions.ts b/packages/cli/src/commands/build-actions.ts index df9af6c9..715e71d8 100644 --- a/packages/cli/src/commands/build-actions.ts +++ b/packages/cli/src/commands/build-actions.ts @@ -133,6 +133,53 @@ export function auditWorkflowContent(file: string, content: string): WorkflowAud add('wget-pipe-bash', 'high', 'workflow contains a `wget ... | bash|sh` pattern'); } + // Secrets exposure: detect ${{ secrets.* }} interpolated in run: steps where + // the value might be echoed or passed as a shell argument and leak into logs. + // Handles both inline run steps (`- run: cmd ${{ secrets.X }}`) and block + // multi-line run steps (`- run: |\n cmd ${{ secrets.X }}`). + const secretsInRunRe = /\$\{\{\s*secrets\.\w+\s*\}\}/; + // Collect all run-step bodies (inline and block styles). + const runBodies: string[] = []; + // Inline: `- run: ` or `run: ` + for (const m of content.matchAll(/^\s*-?\s*run:\s+(.+)$/gm)) { + runBodies.push(m[1] ?? ''); + } + // Block: `- run: |` or `- run: >` followed by indented lines + for (const m of content.matchAll(/^\s*-?\s*run:\s*[|>][-+]?\d*[^\n]*\n((?:[ \t]+[^\n]*\n?)*)/gm)) { + runBodies.push(m[1] ?? ''); + } + if (runBodies.some((body) => secretsInRunRe.test(body))) { + add('secrets-in-run', 'high', 'secret interpolated directly in a `run:` step — value may appear in workflow logs'); + } + + // Third-party actions: flag uses: lines whose owner is not in the well-known + // trusted set AND that are not pinned to an immutable SHA digest. + const trustedOrgs = new Set([ + 'actions', 'github', 'docker', 'hashicorp', 'azure', + 'google-github-actions', 'aws-actions', 'slsa-framework', + ]); + const reportedThirdParty = new Set(); + for (const match of content.matchAll(/uses:\s*([A-Za-z0-9_.-]+)\/([^\s@]+)@([^\s]+)/g)) { + const owner = match[1] ?? ''; + const actionPath = match[2] ?? ''; + const ref = match[3] ?? ''; + if (trustedOrgs.has(owner.toLowerCase())) continue; + // Skip if already reported as unpinned-action-branch (main/master) + if (/^(main|master)$/.test(ref)) continue; + // Flag if not pinned to a full SHA-256 (40-char hex) + if (!/^[0-9a-f]{40}$/i.test(ref)) { + const key = `${owner}/${actionPath}`; + if (!reportedThirdParty.has(key)) { + reportedThirdParty.add(key); + add( + 'third-party-action', + 'low', + `action ${owner}/${actionPath} is from a third-party publisher — consider pinning to a SHA digest`, + ); + } + } + } + for (const match of content.matchAll(/^\s*image:\s*([^\s#]+)\s*(?:#.*)?$/gm)) { const image = match[1]; if (!image) continue;