ktg-plugin-marketplace/plugins/llm-security/scanners/dashboard-aggregator.mjs
Kjell Tore Guttormsen dea17a1c11 chore(release): bump to v6.0.0 — CAISS-readiness release with compliance, governance, CLI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 14:03:10 +02:00

406 lines
12 KiB
JavaScript

#!/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 = '6.0.0';
/** 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<boolean>}
*/
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<string[]>} - 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<string[]>}
*/
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<string[]>} - 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<object>} - 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);
}
}