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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 50 additions & 3 deletions dist/action.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions dist/detectors/dockerfile-capability.js
Original file line number Diff line number Diff line change
@@ -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.'
}
];
}
65 changes: 64 additions & 1 deletion dist/detectors/js-capability.js
Original file line number Diff line number Diff line change
@@ -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 [];
Expand All @@ -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,
Expand All @@ -34,13 +64,45 @@ 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 [];
}
return [
{
kind: 'subprocess_spawn_added',
surface: 'source',
severity: testFile ? 'low' : 'high',
file: added.file,
line: added.line,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions dist/detectors/package-deps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand Down
17 changes: 6 additions & 11 deletions dist/detectors/package-scripts.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading