diff --git a/CHANGELOG.md b/CHANGELOG.md index 1145020..7175dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Jest test suite now runs again. The `import.meta.url` usage in `src/sarif.ts` could not be compiled by ts-jest's CommonJS transform, which silently broke 3 of 4 test suites (only `scorer.test.ts` ran). Jest now runs in ES module mode (`ts-jest/presets/default-esm`), matching the package's actual ESM runtime, and CI is green again. +- Corrected a stale assertion in `sarif.test.ts` that hardcoded the SARIF tool version as `0.0.1`; it now validates against the real `package.json` version. + ### Added +- `--output=` is now accepted as an alias for `--format=` in the CLI, so the documented quick-start examples (e.g. `--output=sarif`) work as written. - Initial scaffold: project structure, TypeScript configuration, and package metadata. - Added real scanner implementation with 8 security rules, CLI, Jest test suite, ESLint config. - Auth rules: NO_AUTH, WEAK_API_KEY, MISSING_TLS diff --git a/jest.config.cjs b/jest.config.cjs index 3ebb6b5..6676502 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,13 +1,18 @@ // jest.config.cjs module.exports = { - preset: 'ts-jest', + preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], testMatch: ['**/__tests__/**/*.test.ts'], moduleNameMapper: { // Strip .js extensions so ts-jest can resolve .ts source files '^(\\.\\.?/.*)\\.js$': '$1', }, transform: { - '^.+\\.tsx?$': ['ts-jest', { tsconfig: { module: 'CommonJS' } }], + // Compile as ES modules so `import.meta` (used in src/sarif.ts) is supported. + '^.+\\.tsx?$': [ + 'ts-jest', + { useESM: true, tsconfig: { module: 'ESNext', moduleResolution: 'node' } }, + ], }, }; diff --git a/package.json b/package.json index 0418795..b70b1c1 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "build": "tsc", "typecheck": "tsc --noEmit", "lint": "eslint src --ext .ts --max-warnings 0", - "test": "jest --config jest.config.cjs", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.cjs", "prepublishOnly": "npm run build" }, "devDependencies": { diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts new file mode 100644 index 0000000..56a7d5b --- /dev/null +++ b/src/__tests__/cli.test.ts @@ -0,0 +1,81 @@ +import { parseArgs } from '../cli'; +import { RuleId, Severity } from '../types'; + +// ─── parseArgs() unit tests ─────────────────────────────────────────────────── + +describe('parseArgs()', () => { + it('defaults format to json', () => { + const result = parseArgs(['./config.json']); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.args.format).toBe('json'); + expect(result.args.target).toBe('./config.json'); + } + }); + + it('parses --format=sarif', () => { + const result = parseArgs(['--format=sarif', './config.json']); + expect(result.ok).toBe(true); + if (result.ok) expect(result.args.format).toBe('sarif'); + }); + + it('accepts --output= as an alias for --format= (documented quick-start)', () => { + const result = parseArgs(['--output=sarif', '--exit-code', './config.json']); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.args.format).toBe('sarif'); + expect(result.args.exitCode).toBe(true); + expect(result.args.target).toBe('./config.json'); + } + }); + + it('--output=table is accepted', () => { + const result = parseArgs(['--output=table', './config.json']); + expect(result.ok).toBe(true); + if (result.ok) expect(result.args.format).toBe('table'); + }); + + it('rejects an invalid --output value', () => { + const result = parseArgs(['--output=xml', './config.json']); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/Unknown format "xml"/); + }); + + it('rejects an invalid --format value', () => { + const result = parseArgs(['--format=xml', './config.json']); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/Unknown format "xml"/); + }); + + it('parses --fail-on= into a Severity', () => { + const result = parseArgs(['--fail-on=high', './config.json']); + expect(result.ok).toBe(true); + if (result.ok) expect(result.args.failOn).toBe(Severity.HIGH); + }); + + it('rejects an unknown --fail-on value', () => { + const result = parseArgs(['--fail-on=bogus', './config.json']); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/Unknown severity "bogus"/); + }); + + it('collects repeated --rule= flags', () => { + const result = parseArgs(['--rule=NO_AUTH', '--rule=MISSING_TLS', './config.json']); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.args.rules).toEqual([RuleId.NO_AUTH, RuleId.MISSING_TLS]); + } + }); + + it('rejects an unknown flag', () => { + const result = parseArgs(['--nope', './config.json']); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/Unknown flag "--nope"/); + }); + + it('leaves target undefined when no positional arg is given', () => { + const result = parseArgs(['--format=json']); + expect(result.ok).toBe(true); + if (result.ok) expect(result.args.target).toBeUndefined(); + }); +}); diff --git a/src/__tests__/sarif.test.ts b/src/__tests__/sarif.test.ts index d1d3d84..0c0d33c 100644 --- a/src/__tests__/sarif.test.ts +++ b/src/__tests__/sarif.test.ts @@ -1,6 +1,10 @@ +import { createRequire } from 'module'; import { toSarif } from '../sarif'; import { SecurityReport, Finding, RuleId, Severity } from '../types'; +const require = createRequire(import.meta.url); +const pkgVersion = (require('../../package.json') as { version: string }).version; + // ─── Helper to build a minimal SecurityReport ───────────────────────────────── function makeReport(overrides: Partial = {}): SecurityReport { @@ -52,9 +56,9 @@ describe('toSarif()', () => { expect(sarif.runs[0].tool.driver.name).toBe('@hailbytes/mcp-security-scanner'); }); - it('run.tool.driver.version is "0.0.1"', () => { + it('run.tool.driver.version matches the package.json version', () => { const sarif = toSarif(makeReport()); - expect(sarif.runs[0].tool.driver.version).toBe('0.0.1'); + expect(sarif.runs[0].tool.driver.version).toBe(pkgVersion); }); it('empty findings → results: []', () => { diff --git a/src/cli.ts b/src/cli.ts index 576118a..21fc158 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,12 +7,14 @@ * * Options: * --format=json|sarif|table Output format (default: json) + * --output=json|sarif|table Alias for --format * --exit-code Exit 1 on any finding * --fail-on=critical|high|medium|low Force fail when finding meets severity * --rule=RULE_ID Run only this rule (can be repeated) * --help, -h Show help */ +import { pathToFileURL } from 'url'; import { scan } from './scanner.js'; import { toSarif } from './sarif.js'; import { ScanConfig, Severity, RuleId, Finding } from './types.js'; @@ -30,6 +32,7 @@ function printHelp(): void { console.log(''); console.log('Options:'); console.log(' --format=json|sarif|table Output format (default: json)'); + console.log(' --output=json|sarif|table Alias for --format'); console.log(' --exit-code Exit 1 on any finding (regardless of score)'); console.log(' --fail-on=critical|high|medium|low'); console.log(' Force fail when any finding meets this severity'); @@ -92,15 +95,32 @@ function printTable(findings: Finding[], passed: boolean): void { console.log(passed ? 'PASSED' : 'FAILED'); } -async function main(): Promise { - const args = process.argv.slice(2); +/** + * Successfully parsed CLI arguments. + */ +export interface ParsedArgs { + format: 'json' | 'sarif' | 'table'; + exitCode: boolean; + failOn?: Severity; + rules: RuleId[]; + target?: string; +} - if (args.length === 0 || args.includes('--help') || args.includes('-h')) { - printHelp(); - if (args.length === 0) process.exit(2); - return; - } +/** + * Result of parsing CLI arguments: either the parsed args or an error message. + * Kept side-effect free (no process.exit / console) so it can be unit-tested. + */ +export type ParseArgsResult = + | { ok: true; args: ParsedArgs } + | { ok: false; error: string }; +/** + * Parse raw CLI arguments into a normalized {@link ParsedArgs} object. + * + * `--output=` is accepted as an alias for `--format=` so the documented + * quick-start examples (which use `--output=sarif`) work as written. + */ +export function parseArgs(args: string[]): ParseArgsResult { let format: 'json' | 'sarif' | 'table' = 'json'; let exitCode = false; let failOn: Severity | undefined; @@ -108,13 +128,13 @@ async function main(): Promise { let target: string | undefined; for (const arg of args) { - if (arg.startsWith('--format=')) { - const val = arg.slice('--format='.length); + if (arg.startsWith('--format=') || arg.startsWith('--output=')) { + const flag = arg.startsWith('--format=') ? '--format=' : '--output='; + const val = arg.slice(flag.length); if (val === 'json' || val === 'sarif' || val === 'table') { format = val; } else { - console.error(`Error: Unknown format "${val}". Expected json, sarif, or table.`); - process.exit(2); + return { ok: false, error: `Unknown format "${val}". Expected json, sarif, or table.` }; } } else if (arg === '--exit-code') { exitCode = true; @@ -130,8 +150,7 @@ async function main(): Promise { if (val in severityMap) { failOn = severityMap[val]; } else { - console.error(`Error: Unknown severity "${val}". Expected critical, high, medium, low, or info.`); - process.exit(2); + return { ok: false, error: `Unknown severity "${val}". Expected critical, high, medium, low, or info.` }; } } else if (arg.startsWith('--rule=')) { const ruleId = arg.slice('--rule='.length) as RuleId; @@ -139,16 +158,36 @@ async function main(): Promise { } else if (!arg.startsWith('--')) { target = arg; } else { - console.error(`Error: Unknown flag "${arg}". Use --help to see available options.`); - process.exit(2); + return { ok: false, error: `Unknown flag "${arg}". Use --help to see available options.` }; } } + return { ok: true, args: { format, exitCode, failOn, rules, target } }; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + + if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { + printHelp(); + if (argv.length === 0) process.exit(2); + return; + } + + const result = parseArgs(argv); + if (!result.ok) { + console.error(`Error: ${result.error}`); + process.exit(2); + } + + const { format, exitCode, failOn, rules, target } = result.args; + if (!target) { console.error('Error: No config path or URL provided.'); console.error(''); printHelp(); process.exit(2); + return; } const isUrl = target.startsWith('http://') || target.startsWith('https://') || @@ -187,4 +226,8 @@ async function main(): Promise { } } -main(); +// Only run when invoked directly as the CLI binary, not when imported (e.g. in tests). +const invokedPath = process.argv[1]; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + main(); +}