From 8c57ec774d30bf448b08cd28908e7aff2e885be9 Mon Sep 17 00:00:00 2001 From: ithiria894 Date: Sat, 28 Mar 2026 15:43:19 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20external=20scanner=20engine=20slots?= =?UTF-8?q?=20=E2=80=94=20plug=20in=20cc-audit,=20AgentSeal,=20or=20any=20?= =?UTF-8?q?SARIF/JSON=20scanner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security panel now supports external scanner engines alongside the built-in scanner: - Auto-detects installed scanners (cc-audit, AgentSeal, agent-audit, mcp-audit) - Engine selector dropdown always visible in security panel action bar - External scanner CLI output (SARIF or JSON) parsed into CCO finding format - Findings mapped to MCP server scope for click-to-navigate - AgentSeal-specific parser handles mcp_results/skill_results format - Human-readable category labels (Supply Chain, Sensitive Access, etc.) - Description + remediation rendered per finding - "▶ Scan" button with disabled state during scan - docs/scanner-engines.md explains compatible engines + install commands - "+ Add scanner engines" link for discovery Tested E2E: AgentSeal guard → 26 findings (6 high, 20 medium) → click-to-navigate → zero JS errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/scanner-engines.md | 45 ++++++ src/security-scanner.mjs | 325 ++++++++++++++++++++++++++++++++++++++- src/server.mjs | 60 +++++++- src/ui/app.js | 138 ++++++++++++++--- src/ui/index.html | 16 +- src/ui/style.css | 62 +++++++- 6 files changed, 619 insertions(+), 27 deletions(-) create mode 100644 docs/scanner-engines.md diff --git a/docs/scanner-engines.md b/docs/scanner-engines.md new file mode 100644 index 0000000..4c93246 --- /dev/null +++ b/docs/scanner-engines.md @@ -0,0 +1,45 @@ +# 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 | + +## Why external engines? + +CCO's built-in scanner is fast and catches the most common threats. But specialized engines go deeper: + +- **cc-audit** has false-positive exclusions that reduce noise (14+ exclusion patterns per rule vs zero in built-in) +- **AgentSeal** uses machine learning to catch rephrased attacks that bypass regex +- **agent-audit** does AST-level taint analysis — tracking data from tool inputs to dangerous sinks +- **mcp-audit** generates AI-BOMs (software bill of materials) that enterprise security teams require + +You don't have to choose one. Install multiple and switch between them in the dropdown to get different perspectives on the same configs. + +## CCO's advantage + +Other scanners produce reports. CCO produces **navigation**. When any engine finds an issue, you click the finding and land directly on the MCP server entry in the scope tree. Delete it, move it, or inspect its config — without leaving CCO. + +## 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..37ac804 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,41 @@ 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 += `
`; + html += `
`; + html += `
`; html += `${esc(f.severity.charAt(0).toUpperCase())}`; html += `${esc(f.name)}${countLabel}`; + // Category label (human-readable, not cryptic rule codes) + const categoryLabel = { + supply_chain: "Supply Chain", prompt_injection: "Prompt Injection", + tool_poisoning: "Tool Poisoning", tool_shadowing: "Tool Shadowing", + sensitive_access: "Sensitive Access", data_exfiltration: "Data Exfiltration", + credential_harvest: "Credentials", code_execution: "Code Execution", + command_injection: "Command Injection", suspicious_hook: "Suspicious Hook", + persistence: "Persistence", cross_server_ref: "Cross-Server", + mcp_config: "MCP Config", external: "Security", + }[f.category] || f.category; + html += `${esc(categoryLabel)}`; + html += `
`; + // Description + if (f.description) { + html += `
${esc(f.description)}
`; + } + // Remediation (from external scanners) + if (f.matchedText && f.externalScanner) { + html += `
💡 ${esc(f.matchedText)}
`; + } + // Source context (matched text for built-in, file path for external) + if (f.context && !f.externalScanner) { + html += `
${esc(f.context)}
`; + } else if (f.matchedText && !f.externalScanner) { + const truncMatch = f.matchedText.length > 120 ? f.matchedText.slice(0, 120) + "…" : f.matchedText; + html += `
${esc(truncMatch)}
`; + } + // External rule link + if (f.externalRuleUrl) { + html += ``; + } html += `
`; } html += `
`; @@ -2771,7 +2868,8 @@ function renderSecurityResults(scanData) { }); const scanTime = new Date(scanData.timestamp).toLocaleString(); - footerNote.textContent = `${scanTime}`; + const engineLabel = scanData.engineName ? `${scanData.engineName} · ` : ""; + footerNote.textContent = `${engineLabel}${scanTime}`; } /** Save security scan results to server for persistence across sessions. */ diff --git a/src/ui/index.html b/src/ui/index.html index 3e0be44..ff0c5c2 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -150,15 +150,23 @@

Context Budget

diff --git a/src/ui/style.css b/src/ui/style.css index 8eba5ca..0685b28 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -1213,6 +1213,25 @@ body { .security-note { font-size: 11px; color: var(--fg3); } .security-start-btn { margin-top: 8px; font-size: 13px; padding: 7px 18px; } +/* ── Security action bar (engine selector + scan button, always visible) ── */ +.security-action-bar { + display: flex; align-items: flex-start; gap: 8px; + padding: 8px 14px; border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} +.security-engine-col { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } +.security-engine-select { + font-size: 0.8rem; padding: 4px 8px; border-radius: 5px; + background: var(--bg-secondary, var(--bg2)); color: var(--text-primary, var(--fg1)); + border: 1px solid var(--border-light, var(--border)); + cursor: pointer; min-width: 0; flex: 1; +} +.security-engine-select:focus { outline: 1px solid var(--accent); border-color: var(--accent); } +.security-engine-info { font-size: 0.68rem; color: var(--text-muted, var(--fg3)); white-space: nowrap; } +.security-engine-info a { color: var(--accent); text-decoration: none; } +.security-engine-info a:hover { text-decoration: underline; } +.security-scan-btn { font-size: 0.8rem; padding: 6px 18px; white-space: nowrap; flex-shrink: 0; font-weight: 600; letter-spacing: 0.3px; } + /* ── Progress bar ── */ .security-progress-text { font-size: 12px; color: var(--fg2); margin-bottom: 8px; } .security-progress-bar-wrap { height: 4px; background: var(--bg2); border-radius: 2px; overflow: hidden; } @@ -1292,6 +1311,48 @@ body { .sec-row-name { flex: 1; font-weight: 600; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sec-findings-list { padding-left: 20px; } + +/* Finding item — expandable card with details */ +.sec-finding-item { + padding: 6px 10px; + border-radius: var(--radius); + cursor: pointer; + transition: background 100ms; + font-size: 11px; + border-left: 2px solid transparent; + margin-bottom: 2px; +} +.sec-finding-item:hover { background: var(--bg-secondary, var(--bg2)); border-left-color: var(--accent); } +.sec-finding-header { display: flex; align-items: center; gap: 6px; } +.sec-finding-label { color: var(--text-primary, var(--fg1)); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.sec-finding-tags { display: flex; gap: 3px; margin-left: auto; flex-shrink: 0; } +.sec-tag { + font-size: 9px; padding: 1px 5px; border-radius: 3px; + background: var(--bg-tertiary, var(--bg3, #2a2a3a)); color: var(--text-primary, var(--fg1)); + font-family: monospace; white-space: nowrap; margin-left: auto; flex-shrink: 0; +} +.sec-category-label { + font-size: 9px; padding: 1px 6px; border-radius: 3px; + background: var(--accent-light, rgba(99, 179, 237, 0.15)); color: var(--accent); + white-space: nowrap; margin-left: auto; flex-shrink: 0; font-weight: 600; +} +.sec-finding-desc { + font-size: 11px; color: var(--text-primary, var(--fg1)); line-height: 1.4; + margin: 3px 0 0 22px; opacity: 0.9; +} +.sec-finding-fix { + font-size: 11px; color: var(--accent); line-height: 1.4; + margin: 3px 0 0 22px; +} +.sec-finding-context { + font-size: 10px; color: var(--text-muted, var(--fg3)); font-family: monospace; + margin: 2px 0 0 22px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sec-finding-link { font-size: 10px; margin: 2px 0 0 22px; } +.sec-finding-link a { color: var(--accent); text-decoration: none; } +.sec-finding-link a:hover { text-decoration: underline; } + +/* Keep old class for backward compat */ .sec-finding-row { display: flex; align-items: center; @@ -1303,7 +1364,6 @@ body { font-size: 11px; } .sec-finding-row:hover { background: var(--bg2); } -.sec-finding-label { color: var(--fg2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* ── Footer ── */ .security-footer { From d8fae3dbe59e206330883953fcb6176ed88e1029 Mon Sep 17 00:00:00 2001 From: ithiria894 Date: Sat, 28 Mar 2026 15:46:07 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20clickable=20"Fix=20with=20Claude"?= =?UTF-8?q?=20=E2=80=94=20copy=20actionable=20prompt=20from=20any=20securi?= =?UTF-8?q?ty=20finding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click any 💡 remediation line to copy a detailed prompt to clipboard: - MCP server name + config file path - Issue name, severity, category - Scanner engine name + rule ID (for credibility) - Full description + suggested fix - Request to evaluate root cause and guide through fix Hover reveals "Fix with Claude →" action label. Click copies prompt + shows toast. Works for both external scanner findings and built-in scanner findings. Also: replaced cryptic rule ID tags (MCP-007) with human-readable category labels (Supply Chain, Sensitive Access, etc.) and improved text contrast for dark theme readability. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/app.js | 64 +++++++++++++++++++++++++++++++++++++++++++----- src/ui/style.css | 10 ++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/ui/app.js b/src/ui/app.js index 37ac804..5937f3c 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -2792,16 +2792,45 @@ function renderSecurityResults(scanData) { if (f.description) { html += `
${esc(f.description)}
`; } - // Remediation (from external scanners) + // Remediation — clickable, copies prompt for Claude Code if (f.matchedText && f.externalScanner) { - html += `
💡 ${esc(f.matchedText)}
`; + const engineName = scanData.engineName || f.externalScanner; + const serverName = f.serverName || server; + const configPath = f.context || "~/.claude/.mcp.json"; + const prompt = [ + `I found a security issue in my MCP server "${serverName}" at ${configPath}:`, + ``, + `Issue: ${f.name} (${categoryLabel})`, + `Severity: ${f.severity}`, + `${f.description}`, + ``, + `Detected by: ${engineName} (rule ${f.id})`, + `Suggested fix: ${f.matchedText}`, + ``, + `Please evaluate the root cause of this issue, confirm whether this fix is appropriate for my setup, and guide me through applying it if needed.`, + ].join("\n"); + const promptAttr = esc(prompt).replace(/"/g, """); + html += `
💡 ${esc(f.matchedText)} Fix with Claude →
`; + } else if (f.matchedText && !f.externalScanner) { + // Built-in scanner — also make clickable + const prompt = [ + `I found a security issue in my MCP server "${server}":`, + ``, + `Issue: ${f.name}`, + `Severity: ${f.severity}`, + `Matched: ${f.matchedText}`, + `Context: ${f.context || ""}`, + ``, + `Detected by: CCO built-in scanner (rule ${f.id})`, + ``, + `Please evaluate this finding, explain the risk, and help me fix it if needed.`, + ].join("\n"); + const promptAttr = esc(prompt).replace(/"/g, """); + html += `
💡 ${esc(f.matchedText.length > 120 ? f.matchedText.slice(0, 120) + "…" : f.matchedText)} Fix with Claude →
`; } - // Source context (matched text for built-in, file path for external) + // Source context (file path for external) if (f.context && !f.externalScanner) { html += `
${esc(f.context)}
`; - } else if (f.matchedText && !f.externalScanner) { - const truncMatch = f.matchedText.length > 120 ? f.matchedText.slice(0, 120) + "…" : f.matchedText; - html += `
${esc(truncMatch)}
`; } // External rule link if (f.externalRuleUrl) { @@ -2859,6 +2888,29 @@ function renderSecurityResults(scanData) { }); }); + // "Fix with Claude →" click → copy prompt to clipboard + results.querySelectorAll(".sec-fix-clickable").forEach(el => { + el.addEventListener("click", async (e) => { + e.stopPropagation(); + const prompt = el.dataset.fixPrompt; + if (!prompt) return; + try { + await navigator.clipboard.writeText(prompt); + toast("Prompt copied — paste in Claude Code"); + } catch { + // Fallback for non-HTTPS + const ta = document.createElement("textarea"); + ta.value = prompt; + ta.style.cssText = "position:fixed;opacity:0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + toast("Prompt copied — paste in Claude Code"); + } + }); + }); + // Server row click → navigate to MCP item results.querySelectorAll(".sec-server-row[data-sec-server]").forEach(row => { row.addEventListener("click", (e) => { diff --git a/src/ui/style.css b/src/ui/style.css index 0685b28..06d9704 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -1344,6 +1344,16 @@ body { font-size: 11px; color: var(--accent); line-height: 1.4; margin: 3px 0 0 22px; } +.sec-fix-clickable { + cursor: pointer; border-radius: 4px; padding: 2px 6px; margin-left: 18px; + transition: background 150ms; +} +.sec-fix-clickable:hover { background: var(--accent-light, rgba(99, 179, 237, 0.1)); } +.sec-fix-action { + font-size: 10px; font-weight: 600; opacity: 0; transition: opacity 150ms; + margin-left: 6px; +} +.sec-fix-clickable:hover .sec-fix-action { opacity: 1; } .sec-finding-context { font-size: 10px; color: var(--text-muted, var(--fg3)); font-family: monospace; margin: 2px 0 0 22px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; From 958c54e77601564dcfa4edd2910bcce946a5c2ed Mon Sep 17 00:00:00 2001 From: ithiria894 Date: Sat, 28 Mar 2026 15:47:27 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20show=20rule=20ID=20alongside=20categ?= =?UTF-8?q?ory=20label=20(MCP-007=20=C2=B7=20Supply=20Chain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/app.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/app.js b/src/ui/app.js index 5937f3c..6fe6680 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -2786,7 +2786,8 @@ function renderSecurityResults(scanData) { persistence: "Persistence", cross_server_ref: "Cross-Server", mcp_config: "MCP Config", external: "Security", }[f.category] || f.category; - html += `${esc(categoryLabel)}`; + const ruleId = (f.id && f.id !== "EXT") ? `${f.id} · ` : ""; + html += `${esc(ruleId + categoryLabel)}`; html += ``; // Description if (f.description) { From 4fa59382961e6ff01c8be0b5dd7092d66ce0eda1 Mon Sep 17 00:00:00 2001 From: ithiria894 Date: Sat, 28 Mar 2026 15:52:07 -0700 Subject: [PATCH 4/4] docs: clarify built-in vs external scanner capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built-in scanner is the primary defense — it actually connects to MCP servers and reads tool definitions (the real attack surface for prompt injection). External scanners complement by checking config hygiene, supply chain, CVEs. Added comparison table to scanner-engines.md showing what each scans. Reverted --connect flag on AgentSeal (doesn't add tool introspection). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/scanner-engines.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/scanner-engines.md b/docs/scanner-engines.md index 4c93246..4a7f196 100644 --- a/docs/scanner-engines.md +++ b/docs/scanner-engines.md @@ -20,20 +20,27 @@ CCO includes a built-in security scanner with 60+ detection rules. For deeper sc | **[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 | -## Why external engines? +## Built-in vs external — they scan different things -CCO's built-in scanner is fast and catches the most common threats. But specialized engines go deeper: +**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. -- **cc-audit** has false-positive exclusions that reduce noise (14+ exclusion patterns per rule vs zero in built-in) -- **AgentSeal** uses machine learning to catch rephrased attacks that bypass regex -- **agent-audit** does AST-level taint analysis — tracking data from tool inputs to dangerous sinks -- **mcp-audit** generates AI-BOMs (software bill of materials) that enterprise security teams require +**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. -You don't have to choose one. Install multiple and switch between them in the dropdown to get different perspectives on the same configs. +| 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 the finding and land directly on the MCP server entry in the scope tree. Delete it, move it, or inspect its config — without leaving CCO. +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