diff --git a/plugins/llm-security/scanners/ai-bom-generator.mjs b/plugins/llm-security/scanners/ai-bom-generator.mjs new file mode 100644 index 0000000..132c2a5 --- /dev/null +++ b/plugins/llm-security/scanners/ai-bom-generator.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// ai-bom-generator.mjs — AI Bill of Materials generator +// Discovers AI components (models, MCP servers, plugins, knowledge, hooks) +// and outputs a CycloneDX 1.6-compatible JSON BOM. +// +// CLI: node scanners/ai-bom-generator.mjs [--output-file ] +// Scanner prefix: BOM. Always exits 0 (informational). +// Zero external dependencies. + +import { resolve } from 'node:path'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { discoverComponents, buildAIBOM } from './lib/bom-builder.mjs'; + +function parseArgs(argv) { + const args = { target: null, outputFile: null }; + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--output-file' && argv[i + 1]) { + args.outputFile = argv[++i]; + } else if (!args.target) { + args.target = argv[i]; + } + } + return args; +} + +async function main() { + const args = parseArgs(process.argv); + if (!args.target) { + process.stderr.write('Usage: node ai-bom-generator.mjs [--output-file ]\n'); + process.exit(1); + } + + const targetPath = resolve(args.target); + if (!existsSync(targetPath)) { + process.stderr.write(`Target path does not exist: ${targetPath}\n`); + process.exit(1); + } + + // Read project metadata from package.json if available + let projectMeta = {}; + try { + const pkg = JSON.parse(readFileSync(resolve(targetPath, 'package.json'), 'utf-8')); + projectMeta = { name: pkg.name, version: pkg.version }; + } catch { /* no package.json */ } + + const components = await discoverComponents(targetPath); + const bom = buildAIBOM(components, projectMeta); + + const jsonStr = JSON.stringify(bom, null, 2) + '\n'; + if (args.outputFile) { + writeFileSync(args.outputFile, jsonStr); + process.stderr.write(`[ai-bom] BOM written to ${args.outputFile} (${components.length} components)\n`); + } else { + process.stdout.write(jsonStr); + } + + process.stderr.write(`[ai-bom] Discovered ${components.length} AI components\n`); + process.exit(0); +} + +main().catch(err => { + process.stderr.write(`Fatal error: ${err.message}\n`); + process.exit(1); +}); diff --git a/plugins/llm-security/scanners/lib/bom-builder.mjs b/plugins/llm-security/scanners/lib/bom-builder.mjs new file mode 100644 index 0000000..88d2a29 --- /dev/null +++ b/plugins/llm-security/scanners/lib/bom-builder.mjs @@ -0,0 +1,195 @@ +// bom-builder.mjs — CycloneDX 1.6 AI-BOM builder +// Discovers AI components in a Claude Code project and builds a +// CycloneDX-compatible BOM. Zero external dependencies. + +import { readFile, readdir, access, stat } from 'node:fs/promises'; +import { join, basename, extname } from 'node:path'; +import { parseFrontmatter } from './yaml-frontmatter.mjs'; + +// --------------------------------------------------------------------------- +// File helpers +// --------------------------------------------------------------------------- + +async function readJson(filePath) { + try { return JSON.parse(await readFile(filePath, 'utf-8')); } + catch { return null; } +} + +async function readText(filePath) { + try { return await readFile(filePath, 'utf-8'); } + catch { return null; } +} + +async function fileExists(filePath) { + try { await access(filePath); return true; } + catch { return false; } +} + +async function listDir(dirPath) { + try { return await readdir(dirPath); } + catch { return []; } +} + +// --------------------------------------------------------------------------- +// Component discovery +// --------------------------------------------------------------------------- + +/** + * Extract model references from text (CLAUDE.md, agent frontmatter, settings). + * @param {string} text + * @returns {string[]} unique model names + */ +function extractModels(text) { + if (!text) return []; + const modelPatterns = [ + /\b(claude-opus-4-6|claude-sonnet-4-6|claude-haiku-4-5[-\w]*)\b/gi, + /\b(opus|sonnet|haiku)\b/gi, + /\b(gpt-4o?[-\w]*|gpt-3\.5[-\w]*)\b/gi, + /\b(gemini[-\w]*)\b/gi, + ]; + const found = new Set(); + for (const pat of modelPatterns) { + for (const match of text.matchAll(pat)) { + found.add(match[1].toLowerCase()); + } + } + return [...found]; +} + +/** + * Discover all AI components in a project. + * @param {string} projectRoot + * @returns {Promise} Array of CycloneDX component objects + */ +export async function discoverComponents(projectRoot) { + const components = []; + const dependencies = []; + + // 1. ML Models — from CLAUDE.md, agent frontmatter, settings + const modelSources = new Set(); + const claudeMd = await readText(join(projectRoot, 'CLAUDE.md')); + for (const m of extractModels(claudeMd)) modelSources.add(m); + + // Agent files + const agentDir = join(projectRoot, 'agents'); + const agentFiles = (await listDir(agentDir)).filter(f => f.endsWith('.md')); + for (const file of agentFiles) { + const content = await readText(join(agentDir, file)); + if (!content) continue; + const fm = parseFrontmatter(content); + if (fm?.model) modelSources.add(fm.model.toLowerCase()); + for (const m of extractModels(content)) modelSources.add(m); + } + + // Settings files + for (const settingsPath of [ + join(projectRoot, '.claude', 'settings.json'), + join(projectRoot, 'settings.json'), + ]) { + const settings = await readJson(settingsPath); + if (settings) { + for (const m of extractModels(JSON.stringify(settings))) modelSources.add(m); + } + } + + for (const model of modelSources) { + components.push({ + type: 'machine-learning-model', + name: model, + 'bom-ref': `model-${model}`, + }); + } + + // 2. MCP Servers — from .mcp.json, .claude/settings.json + const mcpSources = [ + join(projectRoot, '.mcp.json'), + join(projectRoot, '.claude', '.mcp.json'), + ]; + for (const mcpPath of mcpSources) { + const mcpConfig = await readJson(mcpPath); + if (!mcpConfig?.mcpServers) continue; + for (const [name, config] of Object.entries(mcpConfig.mcpServers)) { + components.push({ + type: 'framework', + name: `mcp-server:${name}`, + 'bom-ref': `mcp-${name}`, + description: config.command ? `${config.command} ${(config.args || []).join(' ')}`.trim() : undefined, + }); + } + } + + // 3. Plugins — from global settings enabledPlugins + const globalSettings = await readJson(join(projectRoot, '.claude', 'settings.json')); + if (globalSettings?.enabledPlugins) { + for (const plugin of globalSettings.enabledPlugins) { + components.push({ + type: 'library', + name: `plugin:${basename(plugin)}`, + 'bom-ref': `plugin-${basename(plugin)}`, + }); + } + } + + // 4. Knowledge bases — knowledge/*.md files + const knowledgeDir = join(projectRoot, 'knowledge'); + const knowledgeFiles = (await listDir(knowledgeDir)).filter(f => f.endsWith('.md') || f.endsWith('.json')); + for (const file of knowledgeFiles) { + components.push({ + type: 'data', + name: `knowledge:${file}`, + 'bom-ref': `knowledge-${file}`, + }); + } + + // 5. Hooks — from hooks/hooks.json + const hooksJson = await readJson(join(projectRoot, 'hooks', 'hooks.json')); + if (hooksJson?.hooks) { + for (const [event, entries] of Object.entries(hooksJson.hooks)) { + for (const entry of (Array.isArray(entries) ? entries : [entries])) { + const hooks = entry.hooks || []; + for (const hook of hooks) { + if (hook.command) { + const hookName = basename(hook.command.split('/').pop().replace(/\$\{.*?\}/g, '')); + components.push({ + type: 'framework', + name: `hook:${hookName}`, + 'bom-ref': `hook-${hookName}-${event}`, + description: `${event} hook${entry.matcher ? ` (matcher: ${entry.matcher})` : ''}`, + }); + } + } + } + } + } + + return components; +} + +/** + * Build a CycloneDX 1.6 AI-BOM from discovered components. + * @param {object[]} components - From discoverComponents() + * @param {object} [projectMeta] - { name, version } + * @returns {object} CycloneDX 1.6 JSON + */ +export function buildAIBOM(components, projectMeta = {}) { + return { + bomFormat: 'CycloneDX', + specVersion: '1.6', + version: 1, + metadata: { + timestamp: new Date().toISOString(), + tools: [{ + vendor: 'llm-security', + name: 'ai-bom-generator', + version: '6.0.0', + }], + component: { + type: 'application', + name: projectMeta.name || 'unknown', + version: projectMeta.version || '0.0.0', + }, + }, + components, + dependencies: [], + }; +} diff --git a/plugins/llm-security/tests/scanners/ai-bom.test.mjs b/plugins/llm-security/tests/scanners/ai-bom.test.mjs new file mode 100644 index 0000000..56edab1 --- /dev/null +++ b/plugins/llm-security/tests/scanners/ai-bom.test.mjs @@ -0,0 +1,123 @@ +// ai-bom.test.mjs — Tests for AI-BOM generator (CycloneDX 1.6) + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { discoverComponents, buildAIBOM } from '../../scanners/lib/bom-builder.mjs'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const GRADE_A_FIXTURE = resolve(__dirname, '../fixtures/posture-scan/grade-a-project'); +const GRADE_F_FIXTURE = resolve(__dirname, '../fixtures/posture-scan/grade-f-project'); +const PLUGIN_ROOT = resolve(__dirname, '../..'); + +// --------------------------------------------------------------------------- +// CycloneDX structure tests +// --------------------------------------------------------------------------- + +describe('ai-bom: buildAIBOM structure', () => { + it('produces valid CycloneDX 1.6 envelope', () => { + const bom = buildAIBOM([]); + assert.equal(bom.bomFormat, 'CycloneDX'); + assert.equal(bom.specVersion, '1.6'); + assert.equal(bom.version, 1); + }); + + it('has metadata with timestamp and tools', () => { + const bom = buildAIBOM([]); + assert.ok(bom.metadata.timestamp.match(/^\d{4}-\d{2}-\d{2}T/)); + assert.equal(bom.metadata.tools[0].name, 'ai-bom-generator'); + assert.equal(bom.metadata.tools[0].vendor, 'llm-security'); + }); + + it('includes project metadata from arguments', () => { + const bom = buildAIBOM([], { name: 'test-project', version: '1.2.3' }); + assert.equal(bom.metadata.component.name, 'test-project'); + assert.equal(bom.metadata.component.version, '1.2.3'); + }); + + it('defaults project name to unknown', () => { + const bom = buildAIBOM([]); + assert.equal(bom.metadata.component.name, 'unknown'); + assert.equal(bom.metadata.component.version, '0.0.0'); + }); + + it('has components and dependencies arrays', () => { + const bom = buildAIBOM([]); + assert.ok(Array.isArray(bom.components)); + assert.ok(Array.isArray(bom.dependencies)); + }); + + it('passes through components array', () => { + const components = [ + { type: 'machine-learning-model', name: 'opus', 'bom-ref': 'model-opus' }, + ]; + const bom = buildAIBOM(components); + assert.equal(bom.components.length, 1); + assert.equal(bom.components[0].name, 'opus'); + }); +}); + +// --------------------------------------------------------------------------- +// Component discovery tests +// --------------------------------------------------------------------------- + +describe('ai-bom: discoverComponents', () => { + it('discovers hooks from grade-a fixture', async () => { + const components = await discoverComponents(GRADE_A_FIXTURE); + const hooks = components.filter(c => c.name.startsWith('hook:')); + assert.ok(hooks.length >= 4, `Expected >= 4 hooks, got ${hooks.length}`); + }); + + it('discovers knowledge files from plugin root', async () => { + const components = await discoverComponents(PLUGIN_ROOT); + const knowledge = components.filter(c => c.name.startsWith('knowledge:')); + assert.ok(knowledge.length >= 5, `Expected >= 5 knowledge files, got ${knowledge.length}`); + }); + + it('returns empty array for minimal project', async () => { + const components = await discoverComponents(GRADE_F_FIXTURE); + // Grade-f has no hooks, no MCP, no knowledge + assert.ok(Array.isArray(components)); + }); + + it('all components have type and name', async () => { + const components = await discoverComponents(PLUGIN_ROOT); + for (const c of components) { + assert.ok(c.type, `Component missing type: ${JSON.stringify(c)}`); + assert.ok(c.name, `Component missing name: ${JSON.stringify(c)}`); + assert.ok(c['bom-ref'], `Component missing bom-ref: ${JSON.stringify(c)}`); + } + }); + + it('component types are valid CycloneDX types', async () => { + const validTypes = new Set(['machine-learning-model', 'framework', 'library', 'data', 'application']); + const components = await discoverComponents(PLUGIN_ROOT); + for (const c of components) { + assert.ok(validTypes.has(c.type), `Invalid component type: ${c.type} for ${c.name}`); + } + }); +}); + +// --------------------------------------------------------------------------- +// Integration: discover + build +// --------------------------------------------------------------------------- + +describe('ai-bom: end-to-end', () => { + it('produces valid CycloneDX BOM from real project', async () => { + const components = await discoverComponents(PLUGIN_ROOT); + const bom = buildAIBOM(components, { name: 'llm-security', version: '6.0.0' }); + + assert.equal(bom.bomFormat, 'CycloneDX'); + assert.equal(bom.specVersion, '1.6'); + assert.ok(bom.components.length > 0, 'Expected at least one component'); + assert.equal(bom.metadata.component.name, 'llm-security'); + }); + + it('empty project produces valid BOM with zero components', async () => { + const components = await discoverComponents(GRADE_F_FIXTURE); + const bom = buildAIBOM(components); + assert.equal(bom.bomFormat, 'CycloneDX'); + assert.ok(Array.isArray(bom.components)); + }); +});