Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions jest.config.cjs
Original file line number Diff line number Diff line change
@@ -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' } },
],
},
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
81 changes: 81 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
8 changes: 6 additions & 2 deletions src/__tests__/sarif.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): SecurityReport {
Expand Down Expand Up @@ -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: []', () => {
Expand Down
75 changes: 59 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -92,29 +95,46 @@ function printTable(findings: Finding[], passed: boolean): void {
console.log(passed ? 'PASSED' : 'FAILED');
}

async function main(): Promise<void> {
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;
const rules: RuleId[] = [];
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;
Expand All @@ -130,25 +150,44 @@ async function main(): Promise<void> {
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;
rules.push(ruleId);
} 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<void> {
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://') ||
Expand Down Expand Up @@ -187,4 +226,8 @@ async function main(): Promise<void> {
}
}

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();
}
Loading