diff --git a/plugins/llm-security/bin/llm-security.mjs b/plugins/llm-security/bin/llm-security.mjs new file mode 100755 index 0000000..600c613 --- /dev/null +++ b/plugins/llm-security/bin/llm-security.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// llm-security CLI — standalone entry point for llm-security scanners. +// Usage: llm-security [args] +// Works without Claude Code. Zero dependencies. + +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const PKG = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')); + +const USAGE = `llm-security v${PKG.version} — AI security scanning for Claude Code projects + +Usage: llm-security [options] + +Commands: + scan [--format sarif] [--baseline] [--save-baseline] + Run deterministic deep-scan (10 scanners) + deep-scan [--format sarif] [--baseline] [--save-baseline] + Alias for scan + posture + Quick security posture assessment (16 categories) + audit-bom [--output-file ] + Generate AI Bill of Materials (CycloneDX 1.6) + benchmark [--adaptive] [--category ] + Run attack simulation benchmark + +Options: + --help Show this help + --version Show version +`; + +const [subcommand, ...rest] = process.argv.slice(2); + +if (!subcommand || subcommand === '--help' || subcommand === '-h') { + process.stdout.write(USAGE); + process.exit(0); +} + +if (subcommand === '--version' || subcommand === '-v') { + process.stdout.write(`${PKG.version}\n`); + process.exit(0); +} + +// Map subcommands to scanner scripts and their arguments +const COMMANDS = { + scan: { script: 'scanners/scan-orchestrator.mjs' }, + 'deep-scan': { script: 'scanners/scan-orchestrator.mjs' }, + posture: { script: 'scanners/posture-scanner.mjs' }, + 'audit-bom': { script: 'scanners/ai-bom-generator.mjs' }, + benchmark: { script: 'scanners/attack-simulator.mjs', prependArgs: ['--benchmark', '--json'] }, +}; + +const cmd = COMMANDS[subcommand]; +if (!cmd) { + process.stderr.write(`Unknown command: ${subcommand}\n\n`); + process.stderr.write(USAGE); + process.exit(1); +} + +const scriptPath = resolve(ROOT, cmd.script); +const args = [...(cmd.prependArgs || []), ...rest]; + +const child = spawn('node', [scriptPath, ...args], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], +}); + +child.stdout.pipe(process.stdout); +child.stderr.pipe(process.stderr); + +child.on('close', (code) => { + process.exitCode = code ?? 1; +}); diff --git a/plugins/llm-security/package.json b/plugins/llm-security/package.json index a2b2fe8..080363e 100644 --- a/plugins/llm-security/package.json +++ b/plugins/llm-security/package.json @@ -1,8 +1,11 @@ { "name": "llm-security", - "version": "5.1.0", + "version": "6.0.0", "description": "Security scanning, auditing, and threat modeling for Claude Code projects", "type": "module", + "bin": { + "llm-security": "./bin/llm-security.mjs" + }, "engines": { "node": ">=18" }, diff --git a/plugins/llm-security/tests/scanners/cli-wrapper.test.mjs b/plugins/llm-security/tests/scanners/cli-wrapper.test.mjs new file mode 100644 index 0000000..0660dfe --- /dev/null +++ b/plugins/llm-security/tests/scanners/cli-wrapper.test.mjs @@ -0,0 +1,108 @@ +// cli-wrapper.test.mjs — Tests for bin/llm-security.mjs CLI dispatcher +import { describe, it } from 'node:test'; +import { execFile, spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { strict as assert } from 'node:assert'; +import { readFileSync, unlinkSync, existsSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI = resolve(__dirname, '../../bin/llm-security.mjs'); +const PKG = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')); + +// For --help/--version/unknown: CLI writes directly to its own stdout +function run(args, opts = {}) { + return new Promise((resolve) => { + execFile('node', [CLI, ...args], { timeout: 30000, ...opts }, (err, stdout, stderr) => { + resolve({ + code: err ? err.code ?? 1 : 0, + stdout: stdout || '', + stderr: stderr || '', + }); + }); + }); +} + +// For scanner subcommands: capture output via spawn with piped stdio +function runCapture(args) { + return new Promise((resolve) => { + const chunks = []; + const errChunks = []; + const child = spawn('node', [CLI, ...args], { + timeout: 60000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', (c) => chunks.push(c)); + child.stderr.on('data', (c) => errChunks.push(c)); + child.on('close', (code) => { + resolve({ + code: code ?? 1, + stdout: Buffer.concat(chunks).toString(), + stderr: Buffer.concat(errChunks).toString(), + }); + }); + }); +} + +describe('CLI wrapper: bin/llm-security.mjs', () => { + it('--help prints usage with subcommands', async () => { + const { code, stdout } = await run(['--help']); + assert.equal(code, 0, 'exit code should be 0'); + assert.ok(stdout.includes('scan'), 'should list scan subcommand'); + assert.ok(stdout.includes('posture'), 'should list posture subcommand'); + assert.ok(stdout.includes('audit-bom'), 'should list audit-bom subcommand'); + assert.ok(stdout.includes('benchmark'), 'should list benchmark subcommand'); + assert.ok(stdout.includes('deep-scan'), 'should list deep-scan subcommand'); + }); + + it('--version prints version from package.json', async () => { + const { code, stdout } = await run(['--version']); + assert.equal(code, 0, 'exit code should be 0'); + assert.ok(stdout.trim().includes(PKG.version), `should print ${PKG.version}`); + }); + + it('no arguments prints help and exits 0', async () => { + const { code, stdout } = await run([]); + assert.equal(code, 0, 'exit code should be 0'); + assert.ok(stdout.includes('Usage'), 'should print usage'); + }); + + it('unknown subcommand prints help and exits 1', async () => { + const { code, stderr } = await run(['nonexistent']); + assert.equal(code, 1, 'exit code should be 1'); + assert.ok(stderr.includes('Unknown'), 'should mention unknown command'); + }); + + it('scan dispatches to scan-orchestrator and produces JSON', async () => { + const cwd = resolve(__dirname, '../..'); + const { code, stdout } = await runCapture(['scan', cwd]); + // scan-orchestrator produces JSON; non-zero exit (findings exist) is expected + const parsed = JSON.parse(stdout); + assert.ok(parsed.scanners || parsed.aggregate, 'should produce scanner result JSON'); + }); + + it('posture dispatches to posture-scanner and produces JSON', async () => { + const cwd = resolve(__dirname, '../..'); + const { code, stdout } = await runCapture(['posture', cwd]); + const parsed = JSON.parse(stdout); + assert.ok(parsed.grade || parsed.categories, 'should produce posture JSON'); + }); + + it('scan --format sarif via --output-file produces complete SARIF', async () => { + // Note: scan-orchestrator calls process.exit() immediately after + // process.stdout.write(), which can truncate piped output >64KB on macOS. + // Using --output-file bypasses this by writing to file instead. + const cwd = resolve(__dirname, '../..'); + const tmpFile = resolve(__dirname, '../../.sarif-test-output.json'); + try { + await runCapture(['scan', cwd, '--format', 'sarif', '--output-file', tmpFile]); + assert.ok(existsSync(tmpFile), 'output file should exist'); + const content = readFileSync(tmpFile, 'utf8'); + const parsed = JSON.parse(content); + assert.equal(parsed.version, '2.1.0', 'should produce SARIF 2.1.0'); + assert.ok(parsed.$schema, 'should have $schema field'); + } finally { + if (existsSync(tmpFile)) unlinkSync(tmpFile); + } + }); +});