Skip to content
Closed
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,24 @@ CapabilityEcho v0 detects:
- Pipe-to-shell install scripts in `package.json`.
- Network or publish commands in npm scripts.

## Detection limits

CapabilityEcho v0 inspects added diff lines, with a full-file pass for secret-variable
collection in changed JS and Python files. A few patterns are still structurally
bypassable today:

- **Same-line URL requirement.** Inline network detection gates on `https?://` (or a
variable substitution in workflow lines). A `fetch(` and its URL split across two
added lines may not flag inline. Source secret exfiltration still flags when a
secret variable defined elsewhere is referenced on the same line as the request.
- **No cross-file taint.** A new call site that references a URL or secret defined
in an existing (unchanged) file is not tainted today.
- **No Python dependency manifests yet.** `requirements.txt`, `pyproject.toml`, and
`Pipfile` are not scanned. `package.json` dependency capability is.

Bypass closures land regularly — see [`test/fixtures/bypasses/`](test/fixtures/bypasses)
for the corpus of patterns the detector has been hardened against.

## Complements ScopeTrail and PolicyMesh

Use the suite together:
Expand Down
2 changes: 1 addition & 1 deletion dist/action-bundle/index.js

Large diffs are not rendered by default.

30 changes: 26 additions & 4 deletions dist/detectors/js-capability.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,34 @@ 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);
if (!match) {
const directMatch = 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 (directMatch) {
addVariable(varsByFile, file, directMatch[1]);
return;
}
// Destructuring: const { API_TOKEN, GITHUB_SECRET: gh } = process.env
const destructureMatch = content.match(/\b(?:const|let|var)\s+\{([^}]+)\}\s*=\s*process\.env\b/i);
if (!destructureMatch) {
return;
}
for (const part of destructureMatch[1].split(',')) {
const renamed = part.match(/\s*([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)\s*/);
if (renamed && isSecretShapedName(renamed[1])) {
addVariable(varsByFile, file, renamed[2]);
continue;
}
const bare = part.match(/\s*([A-Za-z_$][\w$]*)\s*/);
if (bare && isSecretShapedName(bare[1])) {
addVariable(varsByFile, file, bare[1]);
}
}
}
function isSecretShapedName(name) {
return /^[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*$/i.test(name);
}
function addVariable(varsByFile, file, name) {
const vars = varsByFile.get(file) ?? new Set();
vars.add(match[1]);
vars.add(name);
varsByFile.set(file, vars);
}
function detectFetch(added, testFile) {
Expand Down Expand Up @@ -87,7 +109,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
43 changes: 37 additions & 6 deletions dist/detectors/py-capability.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,55 @@ export function detectPyCapability(lines, newFileContents = {}) {
}
function collectSecretVariables(lines, newFileContents) {
const varsByFile = new Map();
const aliasesByFile = new Map();
for (const [file, content] of Object.entries(newFileContents)) {
if (!isPyFile(file)) {
continue;
}
aliasesByFile.set(file, parseEnvImportAliases(content));
}
for (const added of lines) {
if (!isPyFile(added.file)) {
continue;
}
addSecretVariable(varsByFile, added.file, added.content);
addSecretVariable(varsByFile, added.file, added.content, aliasesByFile.get(added.file) ?? defaultAliases());
}
for (const [file, content] of Object.entries(newFileContents)) {
if (!isPyFile(file)) {
continue;
}
const aliases = aliasesByFile.get(file) ?? defaultAliases();
for (const line of content.split(/\r?\n/)) {
addSecretVariable(varsByFile, file, line);
addSecretVariable(varsByFile, file, line, aliases);
}
}
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);
function defaultAliases() {
return { getenv: new Set(['getenv']), environ: new Set(['environ']) };
}
function parseEnvImportAliases(content) {
const aliases = defaultAliases();
for (const line of content.split(/\r?\n/)) {
const match = line.match(/^\s*from\s+os\s+import\s+(.+?)(?:\s*#.*)?$/);
if (!match) {
continue;
}
for (const part of match[1].split(',')) {
const named = part.match(/\s*(getenv|environ)(?:\s+as\s+([A-Za-z_][\w]*))?\s*/);
if (named) {
aliases[named[1]].add(named[2] ?? named[1]);
}
}
}
return aliases;
}
function addSecretVariable(varsByFile, file, content, aliases) {
const getenvUnion = [...aliases.getenv].map(escapeRegExp).join('|');
const environUnion = [...aliases.environ].map(escapeRegExp).join('|');
const secretName = '[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*';
const pattern = new RegExp(String.raw `^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:(?:os\.)?(?:${environUnion})\s*(?:\[\s*['"]${secretName}['"]\s*\]|\.get\s*\(\s*['"]${secretName}['"])|(?:os\.)?(?:${getenvUnion})\s*\(\s*['"]${secretName}['"])`, 'i');
const match = content.match(pattern);
if (!match) {
return;
}
Expand Down Expand Up @@ -97,8 +128,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
17 changes: 11 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,13 @@ 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) {
if (/github\.event\.pull_request\.head\.(?:sha|ref|repo\.full_name|repo\.clone_url)/i.test(content)) {
return true;
}
// refs/pull/<n>/merge resolves to PR-authored code merged into base; running
// it under pull_request_target gives untrusted PR code elevated context.
return /\brefs\/pull\/.+?\/merge\b/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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"name": "capabilityecho",
"version": "0.1.0",
Expand All @@ -12,7 +12,7 @@
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"agent-gov-core": "^0.5.0"
"agent-gov-core": "^0.7.0"

Check warning on line 15 in package.json

View workflow job for this annotation

GitHub Actions / scope-review

TaskBound low scope creep

Changed dependency agent-gov-core from ^0.5.0 to ^0.7.0. Recommendation: Review whether the version change is in scope for the task.
},
"devDependencies": {
"@types/node": "^24.0.0",
Expand Down
36 changes: 31 additions & 5 deletions src/detectors/js-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,41 @@ 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
const directMatch = 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) {
if (directMatch) {
addVariable(varsByFile, file, directMatch[1]);
return;
}

// Destructuring: const { API_TOKEN, GITHUB_SECRET: gh } = process.env
const destructureMatch = content.match(/\b(?:const|let|var)\s+\{([^}]+)\}\s*=\s*process\.env\b/i);
if (!destructureMatch) {
return;
}

for (const part of destructureMatch[1].split(',')) {
const renamed = part.match(/\s*([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)\s*/);
if (renamed && isSecretShapedName(renamed[1])) {
addVariable(varsByFile, file, renamed[2]);
continue;
}

const bare = part.match(/\s*([A-Za-z_$][\w$]*)\s*/);
if (bare && isSecretShapedName(bare[1])) {
addVariable(varsByFile, file, bare[1]);
}
}
}

function isSecretShapedName(name: string): boolean {
return /^[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*$/i.test(name);
}

function addVariable(varsByFile: Map<string, Set<string>>, file: string, name: string): void {
const vars = varsByFile.get(file) ?? new Set<string>();
vars.add(match[1]);
vars.add(name);
varsByFile.set(file, vars);
}

Expand Down Expand Up @@ -113,7 +139,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
63 changes: 56 additions & 7 deletions src/detectors/py-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,82 @@ export function detectPyCapability(lines: AddedLine[], newFileContents: Record<s
return findings;
}

interface PyEnvAliases {
getenv: Set<string>;
environ: Set<string>;
}

function collectSecretVariables(lines: AddedLine[], newFileContents: Record<string, string>): Map<string, Set<string>> {
const varsByFile = new Map<string, Set<string>>();
const aliasesByFile = new Map<string, PyEnvAliases>();
for (const [file, content] of Object.entries(newFileContents)) {
if (!isPyFile(file)) {
continue;
}

aliasesByFile.set(file, parseEnvImportAliases(content));
}

for (const added of lines) {
if (!isPyFile(added.file)) {
continue;
}

addSecretVariable(varsByFile, added.file, added.content);
addSecretVariable(varsByFile, added.file, added.content, aliasesByFile.get(added.file) ?? defaultAliases());
}

for (const [file, content] of Object.entries(newFileContents)) {
if (!isPyFile(file)) {
continue;
}

const aliases = aliasesByFile.get(file) ?? defaultAliases();
for (const line of content.split(/\r?\n/)) {
addSecretVariable(varsByFile, file, line);
addSecretVariable(varsByFile, file, line, aliases);
}
}

return varsByFile;
}

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
function defaultAliases(): PyEnvAliases {
return { getenv: new Set(['getenv']), environ: new Set(['environ']) };
}

function parseEnvImportAliases(content: string): PyEnvAliases {
const aliases = defaultAliases();
for (const line of content.split(/\r?\n/)) {
const match = line.match(/^\s*from\s+os\s+import\s+(.+?)(?:\s*#.*)?$/);
if (!match) {
continue;
}

for (const part of match[1].split(',')) {
const named = part.match(/\s*(getenv|environ)(?:\s+as\s+([A-Za-z_][\w]*))?\s*/);
if (named) {
aliases[named[1] as 'getenv' | 'environ'].add(named[2] ?? named[1]);
}
}
}

return aliases;
}

function addSecretVariable(
varsByFile: Map<string, Set<string>>,
file: string,
content: string,
aliases: PyEnvAliases
): void {
const getenvUnion = [...aliases.getenv].map(escapeRegExp).join('|');
const environUnion = [...aliases.environ].map(escapeRegExp).join('|');
const secretName = '[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*';
const pattern = new RegExp(
String.raw`^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:(?:os\.)?(?:${environUnion})\s*(?:\[\s*['"]${secretName}['"]\s*\]|\.get\s*\(\s*['"]${secretName}['"])|(?:os\.)?(?:${getenvUnion})\s*\(\s*['"]${secretName}['"])`,
'i'
);

const match = content.match(pattern);
if (!match) {
return;
}
Expand Down Expand Up @@ -124,8 +173,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
Loading
Loading