feat(cli): add standalone CLI wrapper — npx llm-security scan without Claude Code
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ec320f40c
commit
52d26ddb0b
3 changed files with 189 additions and 1 deletions
77
plugins/llm-security/bin/llm-security.mjs
Executable file
77
plugins/llm-security/bin/llm-security.mjs
Executable file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// llm-security CLI — standalone entry point for llm-security scanners.
|
||||||
|
// Usage: llm-security <subcommand> [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 <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
scan <target> [--format sarif] [--baseline] [--save-baseline]
|
||||||
|
Run deterministic deep-scan (10 scanners)
|
||||||
|
deep-scan <target> [--format sarif] [--baseline] [--save-baseline]
|
||||||
|
Alias for scan
|
||||||
|
posture <target>
|
||||||
|
Quick security posture assessment (16 categories)
|
||||||
|
audit-bom <target> [--output-file <path>]
|
||||||
|
Generate AI Bill of Materials (CycloneDX 1.6)
|
||||||
|
benchmark [--adaptive] [--category <name>]
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "llm-security",
|
"name": "llm-security",
|
||||||
"version": "5.1.0",
|
"version": "6.0.0",
|
||||||
"description": "Security scanning, auditing, and threat modeling for Claude Code projects",
|
"description": "Security scanning, auditing, and threat modeling for Claude Code projects",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"llm-security": "./bin/llm-security.mjs"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
108
plugins/llm-security/tests/scanners/cli-wrapper.test.mjs
Normal file
108
plugins/llm-security/tests/scanners/cli-wrapper.test.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue