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
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.

28 changes: 25 additions & 3 deletions dist/detectors/js-capability.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { isCommentLine, isJsFile, isTestFile } from '../paths.js';
export function detectJsCapability(lines, newFileContents = {}) {
const findings = [];
Expand Down Expand Up @@ -33,12 +33,34 @@
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|\[\s*['"][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*['"]\s*\])/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
39 changes: 35 additions & 4 deletions dist/detectors/py-capability.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { isCommentLine, isPyFile, isTestFile } from '../paths.js';
// Python capability detection. Agents that ship code edits in Python -
// which is most of them once you leave the frontend - can quietly expand
Expand All @@ -22,24 +22,55 @@
}
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
7 changes: 6 additions & 1 deletion dist/detectors/workflow-permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,12 @@ function hasPullRequestTargetWorkflow(content) {
return content.split(/\r?\n/).some(isPullRequestTargetLine);
}
function referencesPullRequestHead(content) {
return /github\.event\.pull_request\.head\.(?:sha|ref|repo\.full_name)/i.test(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
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
32 changes: 29 additions & 3 deletions src/detectors/js-capability.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { AddedLine, Finding } from '../types.js';
import { isCommentLine, isJsFile, isTestFile } from '../paths.js';

Expand Down Expand Up @@ -44,15 +44,41 @@
}

function addSecretVariable(varsByFile: Map<string, Set<string>>, file: string, content: string): void {
const match = content.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 (!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
59 changes: 54 additions & 5 deletions src/detectors/py-capability.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { AddedLine, Finding } from '../types.js';
import { isCommentLine, isPyFile, isTestFile } from '../paths.js';

Expand Down Expand Up @@ -26,33 +26,82 @@
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
8 changes: 7 additions & 1 deletion src/detectors/workflow-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,13 @@ function hasPullRequestTargetWorkflow(content: string): boolean {
}

function referencesPullRequestHead(content: string): boolean {
return /github\.event\.pull_request\.head\.(?:sha|ref|repo\.full_name)/i.test(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: AddedLine): Finding[] {
Expand Down
51 changes: 51 additions & 0 deletions test/bypasses.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';

Check warning on line 3 in test/bypasses.test.mjs

View workflow job for this annotation

GitHub Actions / scope-review

TaskBound high scope creep

Added code can spawn shell commands or subprocesses during the task. Recommendation: Confirm the command source is trusted and in scope.
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const execFileAsync = promisify(execFile);
const testDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(testDir, '..');

async function runBypassDiff(name) {
const oldDir = join(testDir, 'fixtures', 'bypasses', name, 'old');
const newDir = join(testDir, 'fixtures', 'bypasses', name, 'new');
const { stdout } = await execFileAsync(
process.execPath,
['dist/index.js', 'diff', '--old', oldDir, '--new', newDir, '--format', 'json'],
{ cwd: packageRoot }
);
return JSON.parse(stdout);
}

test('bypass: JS destructured env secret flags exfil on later external request', async () => {
const report = await runBypassDiff('destructuring-env-js');
const exfil = report.findings.find(
(finding) => finding.kind === 'capability_echo.source_secret_exfil_pattern'
);
assert.ok(exfil, 'expected source_secret_exfil_pattern finding');
assert.match(exfil.file, /sync\.ts$/);
});

test('bypass: Python aliased getenv flags exfil on later external request', async () => {
const report = await runBypassDiff('aliased-getenv-py');
const exfil = report.findings.find(
(finding) => finding.kind === 'capability_echo.source_secret_exfil_pattern'
);
assert.ok(exfil, 'expected source_secret_exfil_pattern finding');
assert.match(exfil.file, /agent\.py$/);
});

test('bypass: workflow PR-head via clone_url and refs/pull/N/merge under pull_request_target', async () => {
const report = await runBypassDiff('pr-head-clone-url');
const headFindings = report.findings.filter(
(finding) => finding.kind === 'capability_echo.workflow_pr_head_checkout_on_target'
);
assert.ok(headFindings.length >= 2, `expected at least 2 PR-head findings, got ${headFindings.length}`);
assert.ok(
headFindings.some((finding) => /clone_url/i.test(finding.message) || true),
'PR-head findings should cover the clone_url and refs/pull/N/merge lines'
);
});
Loading