Skip to content
Open
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
56 changes: 42 additions & 14 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { createRequire } from 'node:module';
import { analyze } from './index.js';
import type { SecurityHeaderReport } from './types.js';
import type { SecurityHeaderReport, Grade } from './types.js';

const require = createRequire(import.meta.url);

Expand All @@ -19,6 +19,9 @@ const STATUS_ICON: Record<string, string> = {
good: `${GRN}✓${R}`, warning: `${YLW}⚠${R}`, missing: `${RED}✗${R}`, error: `${RED}✗${R}`,
};

// Ordered from best to worst so index comparisons work for threshold logic.
const GRADE_ORDER: readonly Grade[] = ['A+', 'A', 'B', 'C', 'D', 'F'];

function getVersion(): string {
try {
const pkg = require('../package.json') as { version?: string };
Expand All @@ -37,15 +40,18 @@ function printHelp() {
console.log(' npx @hailbytes/security-headers <url> [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 level (default: D)');
console.log(` Valid grades (best→worst): ${GRADE_ORDER.join(', ')}`);
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 --fail-on C');
console.log(' security-headers https://staging.example.com || echo "Gate failed"');
}

Expand All @@ -55,15 +61,15 @@ function printReport(r: SecurityHeaderReport) {
if (r.url) console.log(`${D}${r.url}${R}`);
console.log(`${D}Analyzed: ${r.analyzedAt}${R}\n`);
console.log(`Grade: ${B}${gc}${r.grade}${R} Score: ${r.score}/${r.maxScore} (${r.percentage}%)\n`);
console.log('\u2500'.repeat(60));
console.log(''.repeat(60));
for (const h of r.headers) {
const icon = STATUS_ICON[h.status] ?? '?';
console.log(`${icon} ${B}${h.header}${R} ${D}(${h.score}/${h.maxScore})${R}`);
if (h.raw) console.log(` ${D}${h.raw.slice(0, 80)}${h.raw.length > 80 ? '\u2026' : ''}${R}`);
for (const f of h.findings) console.log(` ${YLW}\u2192${R} ${f}`);
if (h.raw) console.log(` ${D}${h.raw.slice(0, 80)}${h.raw.length > 80 ? '' : ''}${R}`);
for (const f of h.findings) console.log(` ${YLW}${R} ${f}`);
for (const rec of h.recommendations) console.log(` ${D} Fix: ${rec}${R}`);
}
console.log('\u2500'.repeat(60) + '\n');
console.log(''.repeat(60) + '\n');
}

async function main() {
Expand All @@ -80,19 +86,41 @@ 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));

// Collect values that follow named flags so they are not mistaken for the
// URL positional argument.
const flagValues = new Set<string>();
for (let i = 0; i < args.length; i++) {
if ((args[i] === '--timeout' || args[i] === '--fail-on') && i + 1 < args.length) {
flagValues.add(args[i + 1]);
}
}

const timeoutIdx = args.indexOf('--timeout');
const timeoutMs = timeoutIdx !== -1 ? parseInt(args[timeoutIdx + 1], 10) : undefined;

const failOnIdx = args.indexOf('--fail-on');
const failOnGrade = failOnIdx !== -1 ? (args[failOnIdx + 1]?.toUpperCase() as Grade) : undefined;

if (failOnGrade !== undefined && !GRADE_ORDER.includes(failOnGrade)) {
console.error(`Error: --fail-on must be one of: ${GRADE_ORDER.join(', ')}`);
process.exit(1);
}

const url = args.find(a => !a.startsWith('--') && !flagValues.has(a));
if (!url) {
console.error('Usage: security-headers <url> [--json] [--timeout ms] [--help] [--version]');
console.error('Usage: security-headers <url> [--json] [--timeout ms] [--fail-on grade] [--help] [--version]');
console.error('Run with --help for full usage information.');
process.exit(1);
}

try {
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);

const threshold = failOnGrade ?? 'D';
if (GRADE_ORDER.indexOf(report.grade) >= GRADE_ORDER.indexOf(threshold)) process.exit(1);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
Expand Down
Loading