#!/usr/bin/env node // auto-cleaner.mjs — Deterministic remediation engine for security findings // Zero external dependencies. Reuses scanners/lib/ shared library. // // CLI: node auto-cleaner.mjs --findings [--dry-run] // // Fix operations are pure functions (content in → content out). // Atomic writes: write to .clean-tmp, validate, rename over original. // Content-based matching (not line-number based) for robustness. import { readFile, writeFile, rename, unlink, stat } from 'node:fs/promises'; import { writeFileSync, unlinkSync } from 'node:fs'; import { resolve, extname, join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; import { fixResult, cleanEnvelope } from './lib/output.mjs'; // --------------------------------------------------------------------------- // Classification: finding → tier // --------------------------------------------------------------------------- /** * Classify a finding into a remediation tier. * @param {object} finding - Scanner finding object * @returns {'auto'|'semi_auto'|'manual'|'skip'} */ function classifyFinding(f) { const s = f.scanner || ''; const title = (f.title || '').toLowerCase(); const desc = (f.description || '').toLowerCase(); const file = (f.file || '').toLowerCase(); const combined = `${title} ${desc}`; // --- UNI findings --- if (s === 'UNI') { if (title.includes('zero-width')) return 'auto'; if (title.includes('unicode tag') || title.includes('steganograph')) return 'auto'; if (title.includes('bidi')) return 'auto'; if (title.includes('homoglyph')) { // Code files → auto, markdown → semi_auto const codeExts = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.py', '.jsx', '.tsx']; return codeExts.some(ext => file.endsWith(ext)) ? 'auto' : 'semi_auto'; } return 'semi_auto'; } // --- ENT findings --- if (s === 'ENT') return 'semi_auto'; // --- PRM findings --- if (s === 'PRM') { if (title.includes('haiku') && combined.includes('sensitive')) return 'auto'; if (title.includes('ghost hook') || combined.includes('script not found')) return 'semi_auto'; if (combined.includes('read-only') && combined.includes('write')) return 'semi_auto'; if (combined.includes('dangerous') && combined.includes('triple')) return 'semi_auto'; return 'manual'; } // --- DEP findings --- if (s === 'DEP') { if (combined.includes('cve') && !combined.includes('fix available')) return 'manual'; return 'semi_auto'; } // --- TNT findings --- if (s === 'TNT') return 'manual'; // --- GIT findings --- if (s === 'GIT') { if (combined.includes('suspicious domain') && combined.includes('post-commit')) return 'auto'; if (combined.includes('hook') && combined.includes('network')) return 'semi_auto'; return 'skip'; } // --- NET findings --- if (s === 'NET') { if (f.severity === 'high' && combined.includes('suspicious')) return 'auto'; if (combined.includes('loopback') || combined.includes('127.0.0.1')) return 'auto'; if (combined.includes('ip-based url') && f.severity !== 'info') return 'semi_auto'; if (f.severity === 'info') return 'manual'; return 'semi_auto'; } // --- LLM-detected findings (from skill-scanner-agent) --- if (s === 'SKL' || s === 'MCP') { if (combined.includes('html comment injection') || combined.includes('/g; const result = content.replace(pattern, ''); return result !== content ? result : null; } /** * Strip spoofed "# SYSTEM:" headers (not inside code fences). */ function stripSystemHeaders(content) { const lines = content.split('\n'); const result = []; let inCodeFence = false; let changed = false; for (const line of lines) { if (line.trimStart().startsWith('```')) { inCodeFence = !inCodeFence; } if (!inCodeFence && /^#\s*SYSTEM\s*:/i.test(line)) { changed = true; continue; // Remove this line } result.push(line); } return changed ? result.join('\n') : null; } /** * Strip persistence mechanism code blocks (crontab, LaunchAgent, systemctl, zshrc writes). */ function stripPersistence(content) { const lines = content.split('\n'); const result = []; let inMaliciousBlock = false; let inCodeFence = false; let changed = false; const PERSISTENCE_PATTERNS = [ /crontab\s+-/, /LaunchAgent/i, /systemctl\s+(enable|start|restart)/, />>?\s*~\/\.(?:zshrc|bashrc|profile|bash_profile)/, /Library\/LaunchAgents/, ]; for (const line of lines) { const trimmed = line.trimStart(); if (trimmed.startsWith('```')) { if (!inCodeFence) { inCodeFence = true; // Check if next lines contain persistence patterns result.push(line); continue; } else { inCodeFence = false; if (inMaliciousBlock) { inMaliciousBlock = false; changed = true; continue; // Skip the closing ``` } result.push(line); continue; } } if (inCodeFence && !inMaliciousBlock) { if (PERSISTENCE_PATTERNS.some(p => p.test(line))) { inMaliciousBlock = true; changed = true; // Remove the opening ``` we already pushed result.pop(); continue; } } if (inMaliciousBlock) { continue; // Skip lines inside malicious code block } // Also catch inline persistence commands outside code fences if (!inCodeFence && PERSISTENCE_PATTERNS.some(p => p.test(line))) { changed = true; continue; } result.push(line); } return changed ? result.join('\n') : null; } /** * Strip privilege escalation writes (to hooks.json, settings.json, CLAUDE.md). */ function stripEscalation(content) { const ESCALATION_TARGETS = [ /hooks\/hooks\.json/, /~\/\.claude\/settings\.json/, /\.claude\/settings\.json/, /CLAUDE\.md/i, ]; const lines = content.split('\n'); const result = []; let changed = false; for (const line of lines) { if (ESCALATION_TARGETS.some(p => p.test(line)) && (/modif|write|update|overwrite|create|set|add|push|insert|append|config/i.test(line))) { changed = true; continue; } result.push(line); } return changed ? result.join('\n') : null; } /** * Strip non-standard registry redirections (npm config set registry, --index-url). */ function stripRegistryRedirect(content) { const patterns = [ /npm\s+config\s+set\s+registry\s+(?!https:\/\/registry\.npmjs\.org)/, /--index-url\s+(?!https:\/\/pypi\.org)/, /--extra-index-url\s+https?:\/\/(?!pypi\.org)/, ]; const lines = content.split('\n'); const result = []; let changed = false; for (const line of lines) { if (patterns.some(p => p.test(line))) { changed = true; continue; } result.push(line); } return changed ? result.join('\n') : null; } /** * Strip lines containing suspicious exfiltration domain URLs. */ function stripSuspiciousUrls(content) { const lines = content.split('\n'); const result = []; let changed = false; for (const line of lines) { const lower = line.toLowerCase(); if (EXFIL_DOMAINS.some(d => lower.includes(d)) && /https?:\/\//.test(line)) { changed = true; continue; } result.push(line); } return changed ? result.join('\n') : null; } /** * Normalize loopback IPs to localhost. */ function normalizeLoopback(content) { const pattern = /http:\/\/127\.0\.0\.1/g; const result = content.replace(pattern, 'http://localhost'); return result !== content ? result : null; } /** * Upgrade haiku model to sonnet in YAML frontmatter. */ function upgradeHaikuModel(content) { const fmMatch = content.match(/^(---\r?\n[\s\S]*?\r?\n---)/); if (!fmMatch) return null; const fm = fmMatch[1]; const upgraded = fm.replace(/model:\s*haiku/i, 'model: sonnet'); if (upgraded === fm) return null; return content.replace(fm, upgraded); } /** * Strip injection phrases from frontmatter name/description fields. */ function stripInjectionFrontmatter(content) { const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/); if (!fmMatch) return null; const INJECTION_PHRASES = [ //g, /ignore\s+(?:previous|above|all)\s+instructions/gi, /you\s+are\s+now\s+(?:a|an)\s+/gi, /override\s+safety\s+constraints/gi, /unrestricted\s+(?:diagnostic\s+)?mode/gi, /pre-authorized/gi, /elevated\s+permissions/gi, ]; let fm = fmMatch[2]; let changed = false; for (const pattern of INJECTION_PHRASES) { const cleaned = fm.replace(pattern, ''); if (cleaned !== fm) { fm = cleaned; changed = true; } } return changed ? `${fmMatch[1]}${fm}${fmMatch[3]}${content.slice(fmMatch[0].length)}` : null; } /** * Move MCP credential values from args to env in JSON config. */ function moveMcpCredsToEnv(content) { let parsed; try { parsed = JSON.parse(content); } catch { return null; } // Look for mcpServers pattern const servers = parsed.mcpServers || parsed.mcp_servers; if (!servers || typeof servers !== 'object') return null; let changed = false; const CRED_PATTERNS = [ /api[_-]?key/i, /secret/i, /token/i, /password/i, /credential/i, /auth/i, /bearer/i, ]; for (const [, config] of Object.entries(servers)) { const args = config.args; if (!Array.isArray(args)) continue; if (!config.env) config.env = {}; for (let i = args.length - 1; i >= 0; i--) { const arg = String(args[i]); if (CRED_PATTERNS.some(p => p.test(arg))) { // If the arg looks like a key=value pair or the next arg is the value const envKey = arg.replace(/[^A-Z0-9_]/gi, '_').toUpperCase(); if (i + 1 < args.length) { config.env[envKey] = String(args[i + 1]); args.splice(i, 2); } else { config.env[envKey] = arg; args.splice(i, 1); } changed = true; } } } return changed ? JSON.stringify(parsed, null, 2) : null; } /** * Strip writeFile calls targeting MCP/Claude config paths. */ function stripSelfModification(content) { const lines = content.split('\n'); const result = []; let changed = false; const SELF_MOD_PATTERNS = [ /writeFile.*\.claude/i, /writeFile.*hooks\.json/i, /writeFile.*settings\.json/i, /writeFile.*\.mcp\.json/i, /writeFile.*plugin\.json/i, /fs\.write.*\.claude/i, /fs\.write.*hooks\.json/i, ]; for (const line of lines) { if (SELF_MOD_PATTERNS.some(p => p.test(line))) { changed = true; continue; } result.push(line); } return changed ? result.join('\n') : null; } /** * Strip npm/pip/git self-update code blocks. */ function stripSelfUpdate(content) { const lines = content.split('\n'); const result = []; let inSelfUpdate = false; let changed = false; const SELF_UPDATE = [ /npm\s+(install|update)\s+(-g\s+)?.*self/i, /pip\s+install\s+--upgrade\s+.*self/i, /git\s+pull\s+.*origin/i, /curl.*\|\s*(sh|bash)/, /wget.*\|\s*(sh|bash)/, ]; for (const line of lines) { const trimmed = line.trimStart(); if (trimmed.startsWith('```') && inSelfUpdate) { inSelfUpdate = false; changed = true; continue; } if (inSelfUpdate) continue; if (SELF_UPDATE.some(p => p.test(line))) { // If inside a code fence, mark block for removal const lastLine = result[result.length - 1] || ''; if (lastLine.trimStart().startsWith('```')) { result.pop(); // Remove the opening ``` inSelfUpdate = true; } changed = true; continue; } result.push(line); } return changed ? result.join('\n') : null; } // --------------------------------------------------------------------------- // Fix operation registry // --------------------------------------------------------------------------- /** Map of operation names → fix functions + metadata */ const FIX_OPS = { strip_zero_width: { fn: stripZeroWidth, desc: 'Remove zero-width invisible characters', }, strip_unicode_tags: { fn: stripUnicodeTags, desc: 'Remove Unicode Tag steganography codepoints', }, strip_bidi: { fn: stripBidi, desc: 'Remove BIDI override characters', }, normalize_homoglyphs: { fn: normalizeHomoglyphs, desc: 'Normalize Cyrillic confusables to Latin equivalents', codeOnly: true, }, strip_html_comment_injections: { fn: stripHtmlCommentInjections, desc: 'Remove comment injections', }, strip_system_headers: { fn: stripSystemHeaders, desc: 'Remove spoofed # SYSTEM: headers', }, strip_persistence: { fn: stripPersistence, desc: 'Remove persistence mechanisms (crontab, LaunchAgent, zshrc)', }, strip_escalation: { fn: stripEscalation, desc: 'Remove privilege escalation writes to hooks/settings', }, strip_registry_redirect: { fn: stripRegistryRedirect, desc: 'Remove non-standard package registry redirections', }, strip_suspicious_urls: { fn: stripSuspiciousUrls, desc: 'Remove lines with suspicious exfiltration domain URLs', }, normalize_loopback: { fn: normalizeLoopback, desc: 'Replace 127.0.0.1 with localhost', }, upgrade_haiku_model: { fn: upgradeHaikuModel, desc: 'Upgrade model: haiku to model: sonnet in frontmatter', }, strip_injection_frontmatter: { fn: stripInjectionFrontmatter, desc: 'Remove injection phrases from frontmatter fields', }, move_mcp_creds_to_env: { fn: moveMcpCredsToEnv, desc: 'Move credentials from MCP args to env block', }, strip_self_modification: { fn: stripSelfModification, desc: 'Remove writeFile calls targeting config paths', }, strip_self_update: { fn: stripSelfUpdate, desc: 'Remove self-update mechanisms (pipe-to-shell, etc.)', }, }; // --------------------------------------------------------------------------- // Finding → fix operation mapping // --------------------------------------------------------------------------- /** * Determine which fix operations to apply for a given finding. * @param {object} f - Finding object * @returns {string[]} - Array of operation names from FIX_OPS */ function opsForFinding(f) { const s = f.scanner || ''; const title = (f.title || '').toLowerCase(); const desc = (f.description || '').toLowerCase(); const combined = `${title} ${desc}`; if (s === 'UNI') { if (title.includes('zero-width')) return ['strip_zero_width']; if (title.includes('unicode tag') || title.includes('steganograph')) return ['strip_unicode_tags']; if (title.includes('bidi')) return ['strip_bidi']; if (title.includes('homoglyph')) return ['normalize_homoglyphs']; } if (s === 'PRM') { if (title.includes('haiku')) return ['upgrade_haiku_model']; } if (s === 'NET' || s === 'GIT') { if (combined.includes('suspicious') && combined.includes('domain')) return ['strip_suspicious_urls']; if (combined.includes('loopback') || combined.includes('127.0.0.1')) return ['normalize_loopback']; } // LLM-detected findings if (s === 'SKL' || s === 'MCP' || s === '') { const ops = []; if (combined.includes('html comment injection') || combined.includes('