From a6284824c861ddeb959ceef6758983a68d542604 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 05:09:26 +0000 Subject: [PATCH] feat(cli): add --fail-on flag to configure the CI gate threshold The exit-code gate was hardcoded to D/F. Teams with stricter policies had no way to fail on C or B without piping through jq or grep. --fail-on exits 1 when the result is at or below the given grade (default: D, preserving current behaviour). https://claude.ai/code/session_01PT1hTSyhxp3S3pjWUmxwvA --- README.md | 5 ++++- src/cli.ts | 39 +++++++++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d85cb02..ec3190b 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,11 @@ npx @hailbytes/security-headers https://example.com # Output raw JSON npx @hailbytes/security-headers https://example.com --json -# Use as a CI gate (exits 1 on grade D or F) +# Use as a CI gate (exits 1 on grade D or F — default threshold) npx @hailbytes/security-headers https://staging.example.com || echo "Security headers gate failed" + +# Stricter CI gate: fail on C or below +npx @hailbytes/security-headers https://staging.example.com --fail-on C ``` ### Library — analyze a URL diff --git a/src/cli.ts b/src/cli.ts index a6d2e8b..ec42f33 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,14 @@ const RED = '\x1b[31m'; const GRN = '\x1b[32m'; const YLW = '\x1b[33m'; +const GRADES = ['A+', 'A', 'B', 'C', 'D', 'F'] as const; +type Grade = typeof GRADES[number]; + +// Returns true when reportGrade is at or worse than threshold (higher index = worse). +function gradeAtOrBelow(reportGrade: string, threshold: Grade): boolean { + return GRADES.indexOf(reportGrade as Grade) >= GRADES.indexOf(threshold); +} + const GRADE_COLOR: Record = { 'A+': '\x1b[92m', A: GRN, B: YLW, C: YLW, D: RED, F: '\x1b[91m', }; @@ -37,16 +45,19 @@ function printHelp() { console.log(' npx @hailbytes/security-headers [options]'); console.log(''); console.log(`${B}Options:${R}`); - console.log(' --json Output report as JSON'); - console.log(' --timeout ms Fetch timeout in milliseconds (default: 10000)'); - console.log(' --version Print version and exit'); - console.log(' --help Print this help and exit'); + console.log(' --json Output report as JSON'); + console.log(' --timeout ms Fetch timeout in milliseconds (default: 10000)'); + console.log(' --fail-on grade Exit 1 when grade is at or below this threshold'); + console.log(' (default: D — exits 1 on D or F)'); + console.log(' Valid grades: A+, A, B, C, D, F'); + console.log(' --version Print version and exit'); + console.log(' --help Print this help and exit'); console.log(''); console.log(`${B}Examples:${R}`); console.log(' security-headers https://example.com'); console.log(' security-headers https://example.com --json'); console.log(' security-headers https://example.com --timeout 5000'); - console.log(' security-headers https://staging.example.com || echo "Gate failed"'); + console.log(' security-headers https://staging.example.com --fail-on C || echo "Gate failed"'); } function printReport(r: SecurityHeaderReport) { @@ -82,9 +93,21 @@ async function main() { const jsonMode = args.includes('--json'); const timeoutArg = args.find((a, i) => a === '--timeout' && args[i + 1]); const timeoutMs = timeoutArg ? parseInt(args[args.indexOf('--timeout') + 1], 10) : undefined; - const url = args.find(a => !a.startsWith('--') && a !== String(timeoutMs)); + + const failOnIdx = args.indexOf('--fail-on'); + const failOnRaw = failOnIdx !== -1 ? args[failOnIdx + 1] : undefined; + let failOn: Grade = 'D'; + if (failOnRaw !== undefined) { + if (!(GRADES as readonly string[]).includes(failOnRaw)) { + console.error(`Invalid --fail-on value: "${failOnRaw}". Valid grades: ${GRADES.join(', ')}`); + process.exit(1); + } + failOn = failOnRaw as Grade; + } + + const url = args.find(a => !a.startsWith('--') && a !== String(timeoutMs) && a !== failOnRaw); if (!url) { - console.error('Usage: security-headers [--json] [--timeout ms] [--help] [--version]'); + console.error('Usage: security-headers [--json] [--timeout ms] [--fail-on grade] [--help] [--version]'); console.error('Run with --help for full usage information.'); process.exit(1); } @@ -92,7 +115,7 @@ async function main() { const report = await analyze(url, timeoutMs !== undefined ? { timeoutMs } : undefined); if (jsonMode) { console.log(JSON.stringify(report, null, 2)); } else { printReport(report); } - if (report.grade === 'D' || report.grade === 'F') process.exit(1); + if (gradeAtOrBelow(report.grade, failOn)) process.exit(1); } catch (err) { console.error(`Error: ${(err as Error).message}`); process.exit(1);