#!/usr/bin/env node // dashboard-aggregator.mjs — Cross-project security dashboard // Discovers Claude Code projects, runs posture-scanner on each, aggregates results. // Machine grade = weakest link (lowest grade across all projects). // // Standalone CLI: node scanners/dashboard-aggregator.mjs [--no-cache] [--max-depth N] // Library: import { aggregate, discoverProjects } from './dashboard-aggregator.mjs' // // Cache: ~/.cache/llm-security/dashboard-latest.json (24h staleness by default) // Zero external dependencies — Node.js builtins only. import { readFile, writeFile, readdir, stat, mkdir, access } from 'node:fs/promises'; import { join, resolve, basename, relative } from 'node:path'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { scan } from './posture-scanner.mjs'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const VERSION = '7.3.1'; /** Cache location */ const CACHE_DIR = join(homedir(), '.cache', 'llm-security'); const CACHE_FILE = join(CACHE_DIR, 'dashboard-latest.json'); /** Default staleness threshold (24 hours in ms) */ const STALENESS_MS = 24 * 60 * 60 * 1000; /** Default max directory traversal depth from home */ const DEFAULT_MAX_DEPTH = 3; /** Directories to skip during discovery */ const SKIP_DIRS = new Set([ 'node_modules', '.git', '.hg', '.svn', '__pycache__', '.pytest_cache', '.mypy_cache', 'dist', 'build', '.next', '.nuxt', '.venv', 'venv', 'env', 'coverage', '.nyc_output', '.angular', '.cache', '.Trash', 'Library', 'Applications', 'Pictures', 'Music', 'Movies', 'Downloads', 'Documents', 'Desktop', 'Public', ]); /** Markers that indicate a Claude Code project */ const PROJECT_MARKERS = ['.claude', 'CLAUDE.md', '.claude-plugin']; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function fileExists(filePath) { try { await access(filePath); return true; } catch { return false; } } async function readJson(filePath) { try { const raw = await readFile(filePath, 'utf-8'); return JSON.parse(raw); } catch { return null; } } async function writeJson(filePath, data) { await mkdir(join(filePath, '..'), { recursive: true }); await writeFile(filePath, JSON.stringify(data, null, 2) + '\n'); } async function isDirectory(dirPath) { try { const s = await stat(dirPath); return s.isDirectory(); } catch { return false; } } /** * Derive a short display name for a project path. * @param {string} absPath * @returns {string} */ function projectDisplayName(absPath) { const home = homedir(); if (absPath.startsWith(home)) { return '~/' + relative(home, absPath); } return absPath; } // --------------------------------------------------------------------------- // Project Discovery // --------------------------------------------------------------------------- /** * Check if a directory is a Claude Code project (has any marker). * @param {string} dirPath - Absolute path * @returns {Promise} */ async function isClaudeProject(dirPath) { for (const marker of PROJECT_MARKERS) { if (await fileExists(join(dirPath, marker))) return true; } return false; } /** * Recursively discover Claude Code projects under a root directory. * @param {string} root - Absolute path to start searching * @param {number} maxDepth - Max directory depth to traverse * @param {number} [currentDepth=0] * @returns {Promise} - Array of absolute paths to project roots */ async function walkForProjects(root, maxDepth, currentDepth = 0) { if (currentDepth > maxDepth) return []; const projects = []; // Check if this directory itself is a project if (await isClaudeProject(root)) { projects.push(root); // Don't recurse into sub-dirs of a found project (avoid duplicates) return projects; } // Recurse into children let entries; try { entries = await readdir(root, { withFileTypes: true }); } catch { return projects; } for (const entry of entries) { if (!entry.isDirectory()) continue; if (SKIP_DIRS.has(entry.name)) continue; if (entry.name.startsWith('.') && entry.name !== '.claude') continue; const childPath = join(root, entry.name); const childProjects = await walkForProjects(childPath, maxDepth, currentDepth + 1); projects.push(...childProjects); } return projects; } /** * Discover plugins installed via ~/.claude/plugins/. * Each marketplace/plugin-name/ directory is a potential project root, * but also check individual plugins/ sub-dirs within marketplaces. * @returns {Promise} */ async function discoverPlugins() { const pluginsRoot = join(homedir(), '.claude', 'plugins'); const projects = []; if (!await isDirectory(pluginsRoot)) return projects; // Check marketplaces const marketplaces = await readdir(pluginsRoot, { withFileTypes: true }).catch(() => []); for (const mp of marketplaces) { if (!mp.isDirectory()) continue; const mpPath = join(pluginsRoot, mp.name); // Check if marketplace itself is a project if (await isClaudeProject(mpPath)) { projects.push(mpPath); } // Check plugins within marketplace (e.g., plugins/llm-security/) const pluginsDirPath = join(mpPath, 'plugins'); if (await isDirectory(pluginsDirPath)) { const plugins = await readdir(pluginsDirPath, { withFileTypes: true }).catch(() => []); for (const plugin of plugins) { if (!plugin.isDirectory()) continue; const pluginPath = join(pluginsDirPath, plugin.name); if (await isClaudeProject(pluginPath)) { projects.push(pluginPath); } } } // Check direct plugin dirs (non-marketplace structure) const directPlugins = await readdir(mpPath, { withFileTypes: true }).catch(() => []); for (const dp of directPlugins) { if (!dp.isDirectory() || dp.name === 'plugins') continue; const dpPath = join(mpPath, dp.name); if (await isClaudeProject(dpPath) && !projects.includes(dpPath)) { projects.push(dpPath); } } } return projects; } /** * Discover all Claude Code projects. * Searches ~/ (depth-limited) and ~/.claude/plugins/. * @param {object} [opts] * @param {number} [opts.maxDepth=3] - Max depth for home directory traversal * @param {string[]} [opts.extraPaths] - Additional paths to check * @returns {Promise} - Deduplicated array of absolute project paths */ export async function discoverProjects(opts = {}) { const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH; const extraPaths = opts.extraPaths || []; const [homeProjects, pluginProjects] = await Promise.all([ walkForProjects(homedir(), maxDepth), discoverPlugins(), ]); // Check extra paths const extraProjects = []; for (const p of extraPaths) { const abs = resolve(p); if (await isClaudeProject(abs)) { extraProjects.push(abs); } } // Deduplicate by absolute path const seen = new Set(); const all = [...homeProjects, ...pluginProjects, ...extraProjects]; const unique = []; for (const p of all) { const resolved = resolve(p); if (!seen.has(resolved)) { seen.add(resolved); unique.push(resolved); } } return unique.sort(); } // --------------------------------------------------------------------------- // Aggregation // --------------------------------------------------------------------------- /** Grade ordering for comparison (lower index = better) */ const GRADE_ORDER = ['A', 'B', 'C', 'D', 'F']; /** * Get the worse of two grades. * @param {string} a * @param {string} b * @returns {string} */ function worseGrade(a, b) { const ia = GRADE_ORDER.indexOf(a); const ib = GRADE_ORDER.indexOf(b); return ia >= ib ? a : b; } /** * Find the worst category (lowest status) in a posture result. * @param {object} postureResult - Result from posture-scanner scan() * @returns {{ name: string, status: string } | null} */ function worstCategory(postureResult) { const statusOrder = ['FAIL', 'PARTIAL', 'N_A', 'PASS']; let worst = null; let worstIdx = statusOrder.length; for (const cat of postureResult.categories || []) { const idx = statusOrder.indexOf(cat.status); if (idx < worstIdx) { worstIdx = idx; worst = { name: cat.name, status: cat.status }; } } return worst; } /** * Run posture-scanner on all discovered projects and aggregate results. * @param {object} [opts] * @param {number} [opts.maxDepth=3] - Max depth for home directory traversal * @param {string[]} [opts.extraPaths] - Additional paths to check * @param {boolean} [opts.useCache=true] - Use cached results if fresh * @param {number} [opts.stalenessMs=86400000] - Cache staleness threshold * @returns {Promise} - Aggregated dashboard result */ export async function aggregate(opts = {}) { const useCache = opts.useCache !== false; const stalenessMs = opts.stalenessMs ?? STALENESS_MS; // Check cache first if (useCache) { const cached = await readJson(CACHE_FILE); if (cached && cached.meta?.timestamp) { const age = Date.now() - new Date(cached.meta.timestamp).getTime(); if (age < stalenessMs) { return { ...cached, meta: { ...cached.meta, from_cache: true } }; } } } const startMs = Date.now(); // Discover projects const projectPaths = await discoverProjects({ maxDepth: opts.maxDepth, extraPaths: opts.extraPaths, }); // Scan each project const projectResults = []; let machineGrade = 'A'; const errors = []; for (const projectPath of projectPaths) { try { const result = await scan(projectPath); const worst = worstCategory(result); const entry = { path: projectPath, display_name: projectDisplayName(projectPath), grade: result.scoring.grade, pass_rate: result.scoring.pass_rate, risk_score: result.risk.score, risk_band: result.risk.band, verdict: result.risk.verdict, worst_category: worst ? worst.name : null, worst_status: worst ? worst.status : null, findings_count: result.findings.length, counts: result.counts, duration_ms: result.duration_ms, }; projectResults.push(entry); machineGrade = worseGrade(machineGrade, result.scoring.grade); } catch (err) { errors.push({ path: projectPath, display_name: projectDisplayName(projectPath), error: err.message, }); } } // Aggregate counts const aggCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; for (const p of projectResults) { for (const sev of Object.keys(aggCounts)) { aggCounts[sev] += p.counts[sev] || 0; } } const totalFindings = projectResults.reduce((sum, p) => sum + p.findings_count, 0); const durationMs = Date.now() - startMs; const result = { meta: { scanner: 'dashboard-aggregator', version: VERSION, timestamp: new Date().toISOString(), duration_ms: durationMs, from_cache: false, }, machine: { grade: machineGrade, projects_scanned: projectResults.length, projects_errored: errors.length, total_findings: totalFindings, counts: aggCounts, }, projects: projectResults, errors, }; // Write cache try { await writeJson(CACHE_FILE, result); } catch { // Cache write failure is non-fatal } return result; } // --------------------------------------------------------------------------- // CLI entry point // --------------------------------------------------------------------------- const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); if (isMain) { const args = process.argv.slice(2); const noCache = args.includes('--no-cache'); const maxDepthIdx = args.indexOf('--max-depth'); const maxDepth = maxDepthIdx >= 0 ? parseInt(args[maxDepthIdx + 1], 10) : DEFAULT_MAX_DEPTH; try { const result = await aggregate({ useCache: !noCache, maxDepth, }); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); process.exit(result.machine.grade === 'F' ? 1 : 0); } catch (err) { process.stderr.write(`Error: ${err.message}\n`); process.exit(2); } }