Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/cli/src/commands/build-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
47 changes: 47 additions & 0 deletions packages/cli/src/commands/build-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <command>` or `run: <command>`
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<string>();
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;
Expand Down
Loading