feat(scanner): add AI-BOM generator — CycloneDX 1.6 format for AI supply chain transparency
New bom-builder.mjs discovers AI components (models, MCP servers, plugins, knowledge files, hooks) and builds CycloneDX 1.6 JSON BOMs. CLI entry point: node scanners/ai-bom-generator.mjs <target>. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
269c14445c
commit
0439e0f650
3 changed files with 383 additions and 0 deletions
65
plugins/llm-security/scanners/ai-bom-generator.mjs
Normal file
65
plugins/llm-security/scanners/ai-bom-generator.mjs
Normal file
|
|
@ -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 <target> [--output-file <path>]
|
||||
// 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 <target-path> [--output-file <path>]\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);
|
||||
});
|
||||
195
plugins/llm-security/scanners/lib/bom-builder.mjs
Normal file
195
plugins/llm-security/scanners/lib/bom-builder.mjs
Normal file
|
|
@ -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<object[]>} 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: [],
|
||||
};
|
||||
}
|
||||
123
plugins/llm-security/tests/scanners/ai-bom.test.mjs
Normal file
123
plugins/llm-security/tests/scanners/ai-bom.test.mjs
Normal file
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue