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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Live demo PR: [Demo: code-only capability drift](https://github.com/Conalh/Capab

That PR intentionally adds only application and workflow changes:

- A new `src/telemetry/client.ts` file with an external `fetch()` call.
- A new `src/api/sync.ts` file with an external `fetch()` call.
- A `postinstall` script that pipes a remote installer into `bash`.
- GitHub Actions `contents: write` permission and a `curl` bootstrap step.

Expand Down
2 changes: 1 addition & 1 deletion dist/action-bundle/index.js

Large diffs are not rendered by default.

9 changes: 1 addition & 8 deletions dist/action.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
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,
low: 1,
medium: 2,
high: 3,
critical: 4
};
import { renderReport, severityRank } from './report.js';
export async function mainAction(env = process.env) {
const repo = getInput(env, 'repo') || env.GITHUB_WORKSPACE || process.cwd();
const event = await readEvent(env);
Expand Down
18 changes: 18 additions & 0 deletions dist/detectors/workflow-permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ function detectExternalCurl(added) {
if (!/\b(curl|wget|Invoke-WebRequest|fetch\s*\()/i.test(added.content)) {
return [];
}
if (!referencesExternalUrlOrVariable(added.content)) {
return [];
}
return [
{
kind: 'capability_echo.workflow_external_curl',
Expand All @@ -200,6 +203,21 @@ function detectExternalCurl(added) {
}
];
}
function referencesExternalUrlOrVariable(content) {
const urls = content.match(/https?:\/\/[^\s'"`)]+/gi) ?? [];
for (const url of urls) {
if (!isLocalUrl(url)) {
return true;
}
}
if (urls.length === 0 && /\$\{?\w|\$\{\{/.test(content)) {
return true;
}
return false;
}
function isLocalUrl(url) {
return /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(url);
}
function detectSecretsInherit(added) {
if (!/^\s*secrets\s*:\s*inherit\s*(?:#.*)?$/i.test(added.content)) {
return [];
Expand Down
16 changes: 4 additions & 12 deletions dist/git-diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,18 +196,10 @@ 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\//, '');
return file
.replace(/\\/g, '/')
.replace(/^[a-z]:\//i, '')
.replace(/^b\//, '');
}
export async function readFileAtGitRef(repo, ref, relativePath) {
try {
Expand Down
26 changes: 21 additions & 5 deletions dist/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { fileURLToPath } from 'node:url';
import { runCapabilityDiff } from './diff.js';
import { renderReport } from './report.js';
import { renderReport, severityRank } 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`);
Expand All @@ -23,6 +23,10 @@ async function runDiffCommand(argv) {
? 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));
if (severityRank[parsed.failOn] > 0 && severityRank[report.rating] >= severityRank[parsed.failOn]) {
process.stderr.write(`CapabilityEcho capability drift rating ${report.rating} meets fail-on threshold ${parsed.failOn}.\n`);
return 1;
}
return 0;
}
function parseDiffArgs(argv) {
Expand All @@ -32,6 +36,7 @@ function parseDiffArgs(argv) {
let head;
let repo = process.cwd();
let format = 'text';
let failOn = 'none';
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = argv[index + 1];
Expand Down Expand Up @@ -62,6 +67,14 @@ function parseDiffArgs(argv) {
format = value;
index += 1;
}
else if (arg === '--fail-on') {
const normalized = (value ?? '').toLowerCase();
if (!isRating(normalized)) {
return { ok: false, error: `Invalid --fail-on value: ${value ?? ''}. Use none, low, medium, high, or critical.` };
}
failOn = normalized;
index += 1;
}
else {
return { ok: false, error: `Unknown argument: ${arg}` };
}
Expand All @@ -78,27 +91,30 @@ function parseDiffArgs(argv) {
if (!head) {
return { ok: false, error: 'Missing required --head <ref> argument.' };
}
return { ok: true, mode: 'git', repo, base, head, format };
return { ok: true, mode: 'git', repo, base, head, format, failOn };
}
if (!oldRoot) {
return { ok: false, error: 'Missing required --old <dir> argument or --base <ref> argument.' };
}
if (!newRoot) {
return { ok: false, error: 'Missing required --new <dir> argument.' };
}
return { ok: true, mode: 'directories', oldRoot, newRoot, format };
return { ok: true, mode: 'directories', oldRoot, newRoot, format, failOn };
}
function isReportFormat(value) {
return value === 'text' || value === 'markdown' || value === 'json' || value === 'github';
}
function isRating(value) {
return value === 'none' || value === 'low' || value === 'medium' || value === 'high' || value === 'critical';
}
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 <dir> --new <dir> [--format text|markdown|json|github]',
' capabilityecho diff --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github]'
' capabilityecho diff --old <dir> --new <dir> [--format text|markdown|json|github] [--fail-on none|low|medium|high|critical]',
' capabilityecho diff --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github] [--fail-on none|low|medium|high|critical]'
].join('\n');
}
2 changes: 1 addition & 1 deletion dist/report.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const SURFACE_LABELS = {
workflow: 'GitHub workflows',
container: 'container builds'
};
const severityRank = {
export const severityRank = {
none: 0,
low: 1,
medium: 2,
Expand Down
10 changes: 1 addition & 9 deletions src/action.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
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<EchoRating, number> = {
none: 0,
low: 1,
medium: 2,
high: 3,
critical: 4
};
import { renderReport, severityRank, type EchoRating } from './report.js';

export async function mainAction(env: NodeJS.ProcessEnv = process.env): Promise<number> {
const repo = getInput(env, 'repo') || env.GITHUB_WORKSPACE || process.cwd();
Expand Down
23 changes: 23 additions & 0 deletions src/detectors/workflow-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ function detectExternalCurl(added: AddedLine): Finding[] {
return [];
}

if (!referencesExternalUrlOrVariable(added.content)) {
return [];
}

return [
{
kind: 'capability_echo.workflow_external_curl',
Expand All @@ -241,6 +245,25 @@ function detectExternalCurl(added: AddedLine): Finding[] {
];
}

function referencesExternalUrlOrVariable(content: string): boolean {
const urls = content.match(/https?:\/\/[^\s'"`)]+/gi) ?? [];
for (const url of urls) {
if (!isLocalUrl(url)) {
return true;
}
}

if (urls.length === 0 && /\$\{?\w|\$\{\{/.test(content)) {
return true;
}

return false;
}

function isLocalUrl(url: string): boolean {
return /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(url);
}

function detectSecretsInherit(added: AddedLine): Finding[] {
if (!/^\s*secrets\s*:\s*inherit\s*(?:#.*)?$/i.test(added.content)) {
return [];
Expand Down
18 changes: 4 additions & 14 deletions src/git-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,20 +240,10 @@ function isExecError(error: unknown): error is Error & { code?: number | string;
}

function normalizeGitDiffPath(file: string): string {
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\//, '');
return file
.replace(/\\/g, '/')
.replace(/^[a-z]:\//i, '')
.replace(/^b\//, '');
}

export async function readFileAtGitRef(repo: string, ref: string, relativePath: string): Promise<string | null> {
Expand Down
34 changes: 27 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { fileURLToPath } from 'node:url';
import { runCapabilityDiff } from './diff.js';
import { renderReport, type ReportFormat } from './report.js';
import { renderReport, severityRank, type EchoRating, type ReportFormat } from './report.js';

export async function main(argv = process.argv.slice(2)): Promise<number> {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
Expand Down Expand Up @@ -31,12 +31,20 @@ async function runDiffCommand(argv: string[]): Promise<number> {
: await runCapabilityDiff({ mode: 'git', repo: parsed.repo, base: parsed.base, head: parsed.head });

process.stdout.write(renderReport(report, parsed.format));

if (severityRank[parsed.failOn] > 0 && severityRank[report.rating] >= severityRank[parsed.failOn]) {
process.stderr.write(
`CapabilityEcho capability drift rating ${report.rating} meets fail-on threshold ${parsed.failOn}.\n`
);
return 1;
}

return 0;
}

type ParsedDiffArgs =
| { ok: true; mode: 'directories'; oldRoot: string; newRoot: string; format: ReportFormat }
| { ok: true; mode: 'git'; repo: string; base: string; head: string; format: ReportFormat }
| { ok: true; mode: 'directories'; oldRoot: string; newRoot: string; format: ReportFormat; failOn: EchoRating }
| { ok: true; mode: 'git'; repo: string; base: string; head: string; format: ReportFormat; failOn: EchoRating }
| { ok: false; error: string };

function parseDiffArgs(argv: string[]): ParsedDiffArgs {
Expand All @@ -46,6 +54,7 @@ function parseDiffArgs(argv: string[]): ParsedDiffArgs {
let head: string | undefined;
let repo = process.cwd();
let format: ReportFormat = 'text';
let failOn: EchoRating = 'none';

for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
Expand All @@ -72,6 +81,13 @@ function parseDiffArgs(argv: string[]): ParsedDiffArgs {
}
format = value;
index += 1;
} else if (arg === '--fail-on') {
const normalized = (value ?? '').toLowerCase();
if (!isRating(normalized)) {
return { ok: false, error: `Invalid --fail-on value: ${value ?? ''}. Use none, low, medium, high, or critical.` };
}
failOn = normalized;
index += 1;
} else {
return { ok: false, error: `Unknown argument: ${arg}` };
}
Expand All @@ -93,7 +109,7 @@ function parseDiffArgs(argv: string[]): ParsedDiffArgs {
return { ok: false, error: 'Missing required --head <ref> argument.' };
}

return { ok: true, mode: 'git', repo, base, head, format };
return { ok: true, mode: 'git', repo, base, head, format, failOn };
}

if (!oldRoot) {
Expand All @@ -104,13 +120,17 @@ function parseDiffArgs(argv: string[]): ParsedDiffArgs {
return { ok: false, error: 'Missing required --new <dir> argument.' };
}

return { ok: true, mode: 'directories', oldRoot, newRoot, format };
return { ok: true, mode: 'directories', oldRoot, newRoot, format, failOn };
}

function isReportFormat(value: string | undefined): value is ReportFormat {
return value === 'text' || value === 'markdown' || value === 'json' || value === 'github';
}

function isRating(value: string): value is EchoRating {
return value === 'none' || value === 'low' || value === 'medium' || value === 'high' || value === 'critical';
}

const invokedPath = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false;

if (invokedPath) {
Expand All @@ -120,7 +140,7 @@ if (invokedPath) {
function usage(): string {
return [
'Usage:',
' capabilityecho diff --old <dir> --new <dir> [--format text|markdown|json|github]',
' capabilityecho diff --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github]'
' capabilityecho diff --old <dir> --new <dir> [--format text|markdown|json|github] [--fail-on none|low|medium|high|critical]',
' capabilityecho diff --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github] [--fail-on none|low|medium|high|critical]'
].join('\n');
}
2 changes: 1 addition & 1 deletion src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const SURFACE_LABELS: Record<FindingSurface, string> = {
container: 'container builds'
};

const severityRank: Record<EchoRating, number> = {
export const severityRank: Record<EchoRating, number> = {
none: 0,
low: 1,
medium: 2,
Expand Down
Loading