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
2 changes: 1 addition & 1 deletion .github/workflows/capabilityecho.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ jobs:
with:
fetch-depth: 0

- uses: ./
- uses: Conalh/CapabilityEcho@main
with:
fail-on: none
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
node_modules/
dist/
*.tsbuildinfo
85 changes: 2 additions & 83 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
103 changes: 103 additions & 0 deletions dist/action.js
Original file line number Diff line number Diff line change
@@ -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();
}
68 changes: 68 additions & 0 deletions dist/detectors/js-capability.js
Original file line number Diff line number Diff line change
@@ -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)) {

Check warning on line 38 in dist/detectors/js-capability.js

View workflow job for this annotation

GitHub Actions / capability-drift

CapabilityEcho high capability drift

Added code can spawn shell commands or subprocesses. Recommendation: Confirm the command source is trusted and scoped to the task.
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.'
}
];
}
Loading