ktg-plugin-marketplace/plugins/llm-security/scanners/dashboard-aggregator.mjs
Kjell Tore Guttormsen ce3891bdd0 feat(llm-security): playground Fase 3 — v7.5.0 med 18 parsere/renderere
Single-file SPA playground har nå parser + renderer for alle 18
produces_report=true-kommandoer (Fase 2: 10 høy-prio + Fase 3: 8
gjenstående: mcp-inspect, supply-check, pre-deploy, diff, watch,
registry, clean, threat-model). 18 markdown test-fixtures fungerer
som kontrakt-anker for parser-utvikling.

Komplett demo-prosjekt `dft-komplett-demo` har alle 18 rapporter
ferdig parsed inline — klikk-gjennom uten "parser ikke implementert"-
paneler. 2 nye archetypes i KEY_STATS_CONFIG: kanban-buckets (clean)
og matrix-risk (threat-model).

Bug-fix: normalizeVerdictText sjekker nå GO-WITH-CONDITIONS /
CONDITIONAL / BETINGET FØR plain GO så betinget verdict (pre-deploy
med åpne vilkår) ikke kollapser til ALLOW.

Eksponert 11 window-globaler for testing/automasjon (__store,
__navigate, __loadDemoState, __PARSERS, __RENDERERS, __CATALOG,
__inferVerdict, __inferKeyStats, __renderPageShell,
__handlePasteImport, __scheduleRender). 12 Playwright-genererte
screenshots i playground/screenshots/v7.5.0/.

A11Y-rapport (WCAG 2.1 AA): 0 blokkerende, 3 mindre forbedringer
flagget for v7.5.x patch (skip-link, heading-hierarki på project,
aria-live toast).

Versjonsbump 7.4.0 -> 7.5.0 i 10 filer (package.json, plugin.json,
CLAUDE.md header, README badge, CHANGELOG-entry, 3 scanner VERSION-
konstanter, ROADMAP, marketplace-rot README).

Ingen scanner- eller hook-behavior-changes — purely additive surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 22:15:47 +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 = '7.5.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);
}
}