diff --git a/docs/scanner-engines.md b/docs/scanner-engines.md new file mode 100644 index 0000000..4a7f196 --- /dev/null +++ b/docs/scanner-engines.md @@ -0,0 +1,52 @@ +# Scanner Engines + +CCO includes a built-in security scanner with 60+ detection rules. For deeper scanning, you can plug in any compatible external engine. CCO auto-detects installed engines and adds them to the scanner dropdown. + +## How it works + +1. Install any engine below +2. Open CCO → Security Scan panel +3. The engine appears in the dropdown automatically +4. Select it and click "Start Security Scan" +5. Results show in CCO with click-to-navigate — click any finding to jump to the MCP server entry + +## Compatible Engines + +| Engine | Rules | What it catches | Install | License | +|--------|------:|-----------------|---------|---------| +| **Built-in** | 60+ | Prompt injection, tool poisoning, credential exposure, data exfiltration, code execution | Included | MIT | +| **[cc-audit](https://github.com/ryo-ebata/cc-audit)** | 184 | Everything above + persistence (crontab, systemd, LaunchAgent), Docker security, supply chain, privilege escalation. False-positive handling. CWE IDs. Auto-fix. | `npm i -g @cc-audit/cc-audit` | MIT | +| **[AgentSeal](https://github.com/AgentSeal/agentseal)** | 400+ | Semantic ML detection (MiniLM), TR39 confusable characters, dataflow analysis, 225 adversarial probes, MCP registry trust scores (6,600+ servers) | `pip install agentseal` | FSL-1.1 | +| **[agent-audit](https://github.com/HeadyZhang/agent-audit)** | 53 | AST taint analysis, OWASP Agentic Top 10 (ASI-01 to ASI-10), "missing control" detection (no kill switch, no rate limit), memory poisoning | `pip install agent-audit` | MIT | +| **[mcp-audit](https://github.com/apisec-inc/mcp-audit)** | 60 | Secrets exposure, shadow API inventory, AI-BOM (CycloneDX), endpoint classification, OWASP LLM Top 10 | `pip install mcp-audit` | MIT | + +## Built-in vs external — they scan different things + +**Built-in scanner (recommended first):** Connects to each MCP server via JSON-RPC, retrieves actual tool definitions, and scans descriptions for prompt injection and hidden instructions. This is the primary attack surface — tool descriptions go straight into Claude's context as trusted text. + +**External scanners (complementary):** Scan your config files for supply chain risks, credential exposure, CVEs, and permission issues. They do NOT connect to MCP servers or read tool definitions — they check the config text itself. + +| What gets scanned | Built-in | External | +|-------------------|:---:|:---:| +| **Tool descriptions** (prompt injection, hidden instructions) | **Yes** | No | +| **Tool schemas** (suspicious parameter names) | **Yes** | No | +| Supply chain (unpinned packages, known malicious) | Basic | **Deep** | +| Credential exposure (API keys, secrets) | Basic | **Deep** | +| CVE checks (known vulnerabilities) | No | **Yes** | +| File permissions (world-readable configs) | No | **Yes** | +| Config hygiene (missing auth, insecure URLs) | No | **Yes** | + +**Best practice: run built-in first** (catches the dangerous stuff — hidden instructions in tool descriptions), then run an external engine for supply chain and config hygiene. + +## CCO's advantage + +Other scanners produce reports. CCO produces **navigation**. When any engine finds an issue, you click "Fix with Claude →" to copy a detailed prompt — including the engine name, rule ID, server path, and suggested fix — ready to paste into Claude Code. + +## Output format support + +CCO accepts two output formats from external scanners: + +- **SARIF** (Static Analysis Results Interchange Format) — industry standard, used by cc-audit and agent-audit +- **JSON** — generic findings array, used by AgentSeal and mcp-audit + +If you build your own scanner, output SARIF and CCO will pick it up automatically. diff --git a/src/security-scanner.mjs b/src/security-scanner.mjs index 674a1d0..5ce3b08 100644 --- a/src/security-scanner.mjs +++ b/src/security-scanner.mjs @@ -20,6 +20,326 @@ const HOME = homedir(); const BASELINE_DIR = join(HOME, ".claude", ".cco-security"); const BASELINE_PATH = join(BASELINE_DIR, "baselines.json"); +// ══════════════════════════════════════════════════════════════════════ +// EXTERNAL SCANNER ENGINE SUPPORT +// ══════════════════════════════════════════════════════════════════════ + +/** + * Registry of known external security scanner engines. + * CCO auto-detects installed ones and lets users switch between them. + * Each scanner's CLI is called with a path and returns SARIF or JSON findings. + */ +const EXTERNAL_SCANNERS = [ + { + id: "cc-audit", + name: "cc-audit", + description: "Claude Code security auditor — 184 rules, false-positive handling, CWE IDs", + url: "https://github.com/ryo-ebata/cc-audit", + detectCmd: ["cc-audit", ["--version"]], + scanCmd: (path) => ["cc-audit", ["scan", path, "--format", "sarif", "--quiet"]], + outputFormat: "sarif", + license: "MIT", + }, + { + id: "agentseal", + name: "AgentSeal", + description: "400+ rules — semantic ML detection, TR39 confusables, adversarial probes", + url: "https://github.com/AgentSeal/agentseal", + detectCmd: ["agentseal", ["--help"]], + scanCmd: (path) => ["agentseal", ["guard", path, "--output", "json", "--no-diff", "--no-registry"]], + outputFormat: "json-agentseal", + license: "FSL-1.1-Apache-2.0", + }, + { + id: "agent-audit", + name: "agent-audit", + description: "OWASP Agentic Top 10 — AST taint analysis, 53 rules", + url: "https://github.com/HeadyZhang/agent-audit", + detectCmd: ["agent-audit", ["--version"]], + scanCmd: (path) => ["agent-audit", ["scan", path, "--format", "sarif"]], + outputFormat: "sarif", + license: "MIT", + }, + { + id: "mcp-audit", + name: "mcp-audit", + description: "Secrets, shadow APIs, AI-BOM generation — 60 rules", + url: "https://github.com/apisec-inc/mcp-audit", + detectCmd: ["mcp-audit", ["--version"]], + scanCmd: (path) => ["mcp-audit", ["scan", path, "--format", "json"]], + outputFormat: "json-generic", + license: "MIT", + }, +]; + +/** + * Detect which external scanners are installed on the system. + * Returns array of scanner objects with `installed: true/false` and `version`. + */ +async function detectExternalScanners() { + const results = []; + + for (const scanner of EXTERNAL_SCANNERS) { + try { + const [cmd] = scanner.detectCmd; + // Use 'which' (Unix) or 'where' (Windows) to detect if binary exists + const whichCmd = process.platform === "win32" ? "where" : "which"; + const binPath = await new Promise((resolve, reject) => { + execFile(whichCmd, [cmd], { timeout: 3000 }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout.trim().split("\n")[0]); + }); + }); + + // Try to get version (best-effort, not required) + let version = "installed"; + try { + const [, args] = scanner.detectCmd; + const out = await new Promise((resolve, reject) => { + execFile(cmd, args, { timeout: 5000 }, (err, stdout) => { + if (err) resolve(""); // Some tools don't support --version + else resolve(stdout.trim().split("\n")[0]); + }); + }); + if (out) version = out; + } catch { /* version detection is optional */ } + + results.push({ ...scanner, installed: true, version, binPath }); + } catch { + results.push({ ...scanner, installed: false, version: null, binPath: null }); + } + } + + return results; +} + +/** + * Run an external scanner engine and parse its output into CCO finding format. + * @param {string} scannerId - ID from EXTERNAL_SCANNERS registry + * @param {string} scanPath - Path to scan (usually ~/.claude/) + * @returns {object} Scan results in CCO format + */ +async function runExternalScan(scannerId, scanPath) { + const scanner = EXTERNAL_SCANNERS.find(s => s.id === scannerId); + if (!scanner) throw new Error(`Unknown scanner: ${scannerId}`); + + const [cmd, args] = scanner.scanCmd(scanPath); + + const rawOutput = await new Promise((resolve, reject) => { + execFile(cmd, args, { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { + // Some scanners exit non-zero when findings exist — that's normal + if (err && !stdout) reject(new Error(`${scanner.name} failed: ${err.message}\n${stderr}`)); + else resolve(stdout); + }); + }); + + let parsed; + try { + parsed = JSON.parse(rawOutput); + } catch { + throw new Error(`${scanner.name} returned invalid JSON. First 200 chars: ${rawOutput.slice(0, 200)}`); + } + + // Convert to CCO finding format based on output type + if (scanner.outputFormat === "sarif") { + return parseSarifFindings(parsed, scanner); + } + // Generic JSON: expect { findings: [...] } or similar + return parseGenericFindings(parsed, scanner); +} + +/** + * Parse SARIF (Static Analysis Results Interchange Format) into CCO findings. + * SARIF spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/ + */ +function parseSarifFindings(sarif, scanner) { + const findings = []; + + for (const run of (sarif.runs || [])) { + const rules = {}; + for (const rule of (run.tool?.driver?.rules || [])) { + rules[rule.id] = rule; + } + + for (const result of (run.results || [])) { + const ruleId = result.ruleId || "UNKNOWN"; + const rule = rules[ruleId] || {}; + const severity = mapSarifSeverity(result.level); + const location = result.locations?.[0]?.physicalLocation; + const filePath = location?.artifactLocation?.uri || ""; + const region = location?.region; + + findings.push({ + id: ruleId, + category: rule.properties?.category || mapRuleIdToCategory(ruleId), + severity, + name: rule.shortDescription?.text || rule.name || ruleId, + description: rule.fullDescription?.text || result.message?.text || "", + sourceType: "external_scanner", + sourceName: `${scanner.name}: ${filePath}`, + matchedText: result.message?.text?.slice(0, 200) || "", + context: region ? `Line ${region.startLine}${region.startColumn ? `:${region.startColumn}` : ""}` : filePath, + externalScanner: scanner.id, + externalRuleUrl: rule.helpUri || null, + owasp: rule.properties?.owasp || null, + cwe: rule.properties?.cwe || extractCweFromTags(rule.properties?.tags), + }); + } + } + + return { + ok: true, + engine: scanner.id, + engineName: scanner.name, + engineVersion: scanner.version || "unknown", + findings, + severityCounts: countSeverities(findings), + }; +} + +/** + * Parse generic JSON findings (non-SARIF scanners). + * Handles multiple output shapes including AgentSeal's mcp_results/skill_results format. + */ +function parseGenericFindings(data, scanner) { + const findings = []; + + // AgentSeal format: { mcp_results: [...], skill_results: [...] } + if (data.mcp_results || data.skill_results) { + for (const result of (data.mcp_results || [])) { + for (const f of (result.findings || [])) { + findings.push({ + id: f.code || "EXT", + category: mapAgentSealCode(f.code), + severity: normalizeSeverity(f.severity), + name: f.title || f.code, + description: f.description || "", + sourceType: "external_scanner", + sourceName: `${scanner.name}: ${result.name}`, + serverName: result.name, // MCP server name — for click-to-navigate + matchedText: f.remediation || "", + context: result.source_file || result.command || "", + externalScanner: scanner.id, + externalRuleUrl: null, + owasp: null, + cwe: f.code?.startsWith("MCP-CVE") ? f.title : null, + }); + } + } + for (const result of (data.skill_results || [])) { + for (const f of (result.findings || [])) { + findings.push({ + id: f.code || "EXT", + category: mapAgentSealCode(f.code), + severity: normalizeSeverity(f.severity), + name: f.title || f.code, + description: f.description || "", + sourceType: "external_scanner", + sourceName: `${scanner.name}: ${result.name}`, + matchedText: f.remediation || "", + context: result.path || "", + externalScanner: scanner.id, + }); + } + } + return { + ok: true, + engine: scanner.id, + engineName: scanner.name, + engineVersion: scanner.version || "unknown", + scanDuration: data.duration_seconds ? `${data.duration_seconds.toFixed(1)}s` : null, + findings, + severityCounts: countSeverities(findings), + }; + } + + // Generic format: { findings: [...] } or { results: [...] } + const rawFindings = data.findings || data.results || data.vulnerabilities || []; + + for (const f of rawFindings) { + findings.push({ + id: f.id || f.rule_id || f.ruleId || "EXT", + category: f.category || f.type || "external", + severity: normalizeSeverity(f.severity || f.level || "medium"), + name: f.name || f.title || f.rule || f.id || "Finding", + description: f.description || f.message || f.detail || "", + sourceType: "external_scanner", + sourceName: `${scanner.name}: ${f.file || f.path || f.source || ""}`, + matchedText: f.matched_text || f.match || f.evidence || "", + context: f.context || f.location || "", + externalScanner: scanner.id, + externalRuleUrl: f.url || f.help_url || null, + owasp: f.owasp || null, + cwe: f.cwe || null, + }); + } + + return { + ok: true, + engine: scanner.id, + engineName: scanner.name, + engineVersion: scanner.version || "unknown", + findings, + severityCounts: countSeverities(findings), + }; +} + +/** Map AgentSeal finding codes to CCO categories. */ +function mapAgentSealCode(code) { + if (!code) return "external"; + if (code.startsWith("MCP-CVE")) return "supply_chain"; + if (code.startsWith("MCP-007")) return "supply_chain"; + if (code.startsWith("MCP-011")) return "sensitive_access"; + if (code.startsWith("MCP-")) return "mcp_config"; + if (code.startsWith("SKILL-")) return "prompt_injection"; + return "external"; +} + +/** Map SARIF severity levels to CCO severity. */ +function mapSarifSeverity(level) { + const map = { error: "high", warning: "medium", note: "low", none: "info" }; + return map[level] || "medium"; +} + +/** Normalize various severity strings to CCO's 5 levels. */ +function normalizeSeverity(sev) { + const s = String(sev).toLowerCase(); + if (s === "critical" || s === "crit") return "critical"; + if (s === "high" || s === "error" || s === "danger") return "high"; + if (s === "medium" || s === "warning" || s === "warn" || s === "moderate") return "medium"; + if (s === "low" || s === "note" || s === "minor") return "low"; + return "info"; +} + +/** Try to extract CWE from SARIF rule tags. */ +function extractCweFromTags(tags) { + if (!Array.isArray(tags)) return null; + const cweTag = tags.find(t => /^CWE-\d+$/i.test(t)); + return cweTag || null; +} + +/** Map rule ID prefixes to categories (fallback). */ +function mapRuleIdToCategory(ruleId) { + const prefix = ruleId.split("-")[0]?.toUpperCase(); + const map = { + PI: "prompt_injection", TP: "tool_poisoning", TS: "tool_shadowing", + SF: "sensitive_access", DE: "data_exfiltration", CH: "credential_harvest", + CE: "code_execution", CI: "command_injection", HK: "suspicious_hook", + SC: "supply_chain", PE: "persistence", XR: "cross_server_ref", + ASI: "owasp_agentic", EX: "exfiltration", + }; + return map[prefix] || "external"; +} + +/** Count severities from a findings array. */ +function countSeverities(findings) { + const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; + for (const f of findings) { + if (counts[f.severity] !== undefined) counts[f.severity]++; + } + return counts; +} + // ══════════════════════════════════════════════════════════════════════ // LAYER 1: DEOBFUSCATION (8 techniques from AgentSeal deobfuscate.py) // ══════════════════════════════════════════════════════════════════════ @@ -873,4 +1193,7 @@ export async function runSecurityScan(introspectionResults, scanData) { }; } -export { deobfuscate, scanText, PATTERNS, loadBaselines, compareBaselines, updateBaselines }; +export { + deobfuscate, scanText, PATTERNS, loadBaselines, compareBaselines, updateBaselines, + detectExternalScanners, runExternalScan, EXTERNAL_SCANNERS, +}; diff --git a/src/server.mjs b/src/server.mjs index 7e9a7dc..b5e6893 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -14,7 +14,7 @@ import { scan } from "./scanner.mjs"; import { moveItem, deleteItem, getValidDestinations } from "./mover.mjs"; import { countTokens, getMethod } from "./tokenizer.mjs"; import { introspectServers } from "./mcp-introspector.mjs"; -import { runSecurityScan, checkClaudeAvailable, llmJudge } from "./security-scanner.mjs"; +import { runSecurityScan, checkClaudeAvailable, llmJudge, detectExternalScanners, runExternalScan } from "./security-scanner.mjs"; // ── Update check ───────────────────────────────────────────────────── async function checkForUpdate() { @@ -758,9 +758,67 @@ async function handleRequest(req, res) { return json(res, { ok: true, ...status }); } + // GET /api/security-scanners — detect installed external scanner engines + if (path === "/api/security-scanners" && req.method === "GET") { + try { + const scanners = await detectExternalScanners(); + return json(res, { ok: true, scanners }); + } catch (err) { + return json(res, { ok: false, error: err.message }, 500); + } + } + // POST /api/security-scan — run full security scan (introspect + pattern + baseline) + // Accepts optional body: { engine: "cc-audit" | "agentseal" | "built-in" (default) } if (path === "/api/security-scan" && req.method === "POST") { try { + const body = await readBody(req).catch(() => ({})); + const engine = body?.engine || "built-in"; + + // External scanner engine + if (engine !== "built-in") { + const claudeDir = join(homedir(), ".claude"); + const externalResults = await runExternalScan(engine, claudeDir); + + // Merge with introspection data for click-to-navigate + if (!cachedData) await freshScan(); + const mcpItems = cachedData.items.filter(i => i.category === "mcp" && i.mcpConfig); + + // Map external findings to MCP server scope for click-to-navigate + for (const finding of externalResults.findings) { + // AgentSeal already sets serverName from mcp_results[].name + const name = finding.serverName; + const matchedServer = name + ? mcpItems.find(m => m.name === name) + : mcpItems.find(m => + finding.sourceName?.includes(m.name) || finding.context?.includes(m.name) + ); + if (matchedServer) { + finding.serverName = matchedServer.name; + finding.scopeId = matchedServer.scopeId; + } + } + + return json(res, { + ...externalResults, + timestamp: new Date().toISOString(), + totalServers: mcpItems.length, + serversConnected: mcpItems.length, + serversFailed: 0, + totalTools: 0, + servers: mcpItems.map(m => ({ + serverName: m.name, + scopeId: m.scopeId, + status: "external", + toolCount: 0, + tools: [], + findings: externalResults.findings.filter(f => f.serverName === m.name), + })), + baselines: [], + }); + } + + // Built-in scanner (original behavior) if (!cachedData) await freshScan(); // Get all MCP server items from scan data diff --git a/src/ui/app.js b/src/ui/app.js index eeaabde..6fe6680 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -2398,16 +2398,64 @@ let securityScanResults = null; let securityBadges = {}; /** Map of MCP server name → baseline status ("new" | "changed" | null). */ let securityBaselineStatus = {}; +/** Detected external scanner engines. */ +let detectedScanners = []; + +/** Detect installed external scanners and populate dropdown. */ +async function loadExternalScanners() { + const select = document.getElementById("securityEngineSelect"); + const info = document.getElementById("securityEngineInfo"); + if (!select) return; + + try { + const resp = await fetch("/api/security-scanners"); + const result = await resp.json(); + if (!result.ok) return; + + detectedScanners = result.scanners.filter(s => s.installed); + const allScanners = result.scanners || []; + + if (detectedScanners.length > 0) { + // Add installed scanners to dropdown + for (const scanner of detectedScanners) { + const opt = document.createElement("option"); + opt.value = scanner.id; + opt.textContent = `${scanner.name} (${scanner.description.split("—")[0].trim()})`; + select.appendChild(opt); + } + } + + // Always show "Add more engines" link at bottom + const notInstalled = allScanners.filter(s => !s.installed); + if (notInstalled.length > 0 || allScanners.length > 0) { + info.innerHTML = `+ Add scanner engines`; + } + + select.addEventListener("change", () => { + const selected = detectedScanners.find(s => s.id === select.value); + if (selected) { + info.innerHTML = `${selected.license} · + More`; + } else { + info.innerHTML = `+ Add scanner engines`; + } + }); + } catch { + // Even if detection fails, show the link + info.innerHTML = `+ Add scanner engines`; + } +} function setupSecurityScan() { const btn = document.getElementById("securityScanBtn"); const panel = document.getElementById("securityPanel"); const closeBtn = document.getElementById("securityClose"); const startBtn = document.getElementById("securityStartBtn"); - const rescanBtn = document.getElementById("securityRescanBtn"); if (!btn) return; + // Detect external scanners (non-blocking) + loadExternalScanners(); + // Cached results + new server check loaded in init() before renderAll btn.addEventListener("click", async () => { @@ -2419,7 +2467,14 @@ function setupSecurityScan() { await runSecurityScan(); } else if (securityScanResults) { renderSecurityResults(securityScanResults); + // Sync dropdown with cached results engine + const cachedEngine = securityScanResults.engine || "built-in"; + const select = document.getElementById("securityEngineSelect"); + if (select && [...select.options].some(o => o.value === cachedEngine)) { + select.value = cachedEngine; + } } + // Otherwise show intro with engine selector — user clicks "Start Security Scan" }); closeBtn?.addEventListener("click", () => { @@ -2430,10 +2485,6 @@ function setupSecurityScan() { await runSecurityScan(); }); - rescanBtn?.addEventListener("click", async () => { - await runSecurityScan(); - }); - // Delegate clicks on findings → navigate to item document.getElementById("securityResults")?.addEventListener("click", (e) => { const row = e.target.closest("[data-sec-server]"); @@ -2531,11 +2582,21 @@ async function runSecurityScan() { progressBar.classList.remove("security-bar-error"); progressText.textContent = "Connecting to MCP servers..."; + // Disable scan button during scan + const scanBtn = document.getElementById("securityStartBtn"); + if (scanBtn) { scanBtn.disabled = true; scanBtn.textContent = "Scanning..."; } + try { + const engineName = document.getElementById("securityEngineSelect")?.selectedOptions[0]?.textContent || "Built-in"; progressBar.style.width = "20%"; - progressText.textContent = "Fetching tool definitions from MCP servers..."; + progressText.textContent = `Scanning with ${engineName}...`; - const resp = await fetch("/api/security-scan", { method: "POST" }); + const selectedEngine = document.getElementById("securityEngineSelect")?.value || "built-in"; + const resp = await fetch("/api/security-scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ engine: selectedEngine }), + }); const scanData = await resp.json(); progressBar.style.width = "90%"; @@ -2570,6 +2631,9 @@ async function runSecurityScan() { else if (b.hasChanges) securityBaselineStatus[b.serverName] = "changed"; } + // Re-enable scan button + if (scanBtn) { scanBtn.disabled = false; scanBtn.textContent = "▶ Scan"; } + renderSecurityResults(scanData); // Re-render main list — badges only on servers with findings, clean servers get cleared renderAll(); @@ -2578,30 +2642,31 @@ async function runSecurityScan() { // Clear NEW flags (baselines updated), but check for CHANGED servers securityBaselineStatus = {}; const changedCount = (scanData.baselines || []).filter(b => b.hasChanges && !b.isFirstScan).length; - const scanBtn = document.getElementById("securityScanBtn"); + const sidebarBtn = document.getElementById("securityScanBtn"); - if (changedCount > 0 && scanBtn) { + if (changedCount > 0 && sidebarBtn) { // Servers changed since last scan — re-shimmer + CHANGED badges for (const b of (scanData.baselines || [])) { if (b.hasChanges && !b.isFirstScan) securityBaselineStatus[b.serverName] = "changed"; } - scanBtn.classList.add("sec-btn-alert"); - scanBtn.querySelector(".sec-btn-tooltip")?.remove(); + sidebarBtn.classList.add("sec-btn-alert"); + sidebarBtn.querySelector(".sec-btn-tooltip")?.remove(); const tip = document.createElement("span"); tip.className = "sec-btn-tooltip"; tip.textContent = `${changedCount} MCP server${changedCount > 1 ? "s" : ""} changed — click to rescan`; - scanBtn.appendChild(tip); + sidebarBtn.appendChild(tip); renderAll(); // re-render to show CHANGED badges - } else if (scanBtn) { + } else if (sidebarBtn) { // All clear — remove shimmer + tooltip + re-render to clear badges - scanBtn.classList.remove("sec-btn-alert"); - scanBtn.querySelector(".sec-btn-tooltip")?.remove(); + sidebarBtn.classList.remove("sec-btn-alert"); + sidebarBtn.querySelector(".sec-btn-tooltip")?.remove(); renderAll(); } } catch (err) { progressText.textContent = `Error: ${err.message}`; progressBar.classList.add("security-bar-error"); + if (scanBtn) { scanBtn.disabled = false; scanBtn.textContent = "▶ Scan"; } } } @@ -2613,7 +2678,7 @@ function renderSecurityResults(scanData) { progress.classList.add("hidden"); results.classList.remove("hidden"); - document.getElementById("securityRescanBtn")?.classList.remove("hidden"); + // Rescan handled by main security button shimmer alert — no separate rescan button needed footer.classList.remove("hidden"); const { severityCounts, totalTools, totalServers, serversConnected, baselines, findings } = scanData; @@ -2662,8 +2727,8 @@ function renderSecurityResults(scanData) { // Group findings by server name const byServer = {}; for (const f of findings) { - const parts = (f.sourceName || "").split("/"); - const server = parts[0] || "unknown"; + // External scanners set serverName directly; built-in uses sourceName "server/tool" format + const server = f.serverName || (f.sourceName || "").split("/")[0] || "unknown"; if (!byServer[server]) byServer[server] = []; byServer[server].push(f); } @@ -2707,9 +2772,71 @@ function renderSecurityResults(scanData) { for (const f of deduped) { const bc = f.severity === "critical" ? "sec-critical" : f.severity === "high" ? "sec-high" : f.severity === "medium" ? "sec-medium" : "sec-low"; const countLabel = f.count > 1 ? ` ×${f.count}` : ""; - html += `