#!/usr/bin/env node /** * Static content scanner (router-gate v4 Stream C, spec §5.2). * * Pure multi-language suspicious-pattern scan for test/boot files. No I/O: * consumers (enforce-router-gate.mjs) read files via fs and call scanContent(). * Owns TEST_RUNNERS — shared with framework-boot-scanner.mjs (F7). */ // §5.2 test-runner detection. export const TEST_RUNNERS = [ /\bcomposer\s+(?:test|phpunit|pest)\b/, /\bphp\s+artisan\s+test\b/, /\bvendor\/bin\/(?:phpunit|pest)\b/, /\bbundle\s+exec\s+(?:rspec|rake|test)\b/, /\bgo\s+(?:test|run)\b/, /\bmvn\s+(?:test|package)\b/, /\bgradle\s+(?:test|build)\b/, /\bcargo\s+(?:test|run)\b/, /\bdotnet\s+(?:test|run)\b/, /\bnpx\s+vitest\b/, /\bnpm\s+(?:test|run\s+test)\b/, ]; export function isTestRunner(command) { if (typeof command !== 'string') return false; return TEST_RUNNERS.some((re) => re.test(command)); } const EXT_LANG = { '.php': 'php', '.rb': 'ruby', '.go': 'go', '.java': 'java', '.kt': 'java', // JVM treated as java patterns '.rs': 'rust', '.cs': 'dotnet', }; export function detectLanguage(filePath) { if (typeof filePath !== 'string') return null; const lower = filePath.toLowerCase(); const dot = lower.lastIndexOf('.'); if (dot < 0) return null; return EXT_LANG[lower.slice(dot)] || null; } // Always-suspicious: code execution + dynamic dispatch. // Each entry is { name, re } (single regex) or { name, all: [re,...] } (co-occurrence, // ALL must match somewhere in source — independent anchored tests, no proximity regex, // avoids catastrophic backtracking). const ALWAYS_PATTERNS = { php: [ { name: 'exec', re: /\bexec\s*\(/ }, { name: 'system', re: /\bsystem\s*\(/ }, { name: 'passthru', re: /\bpassthru\s*\(/ }, { name: 'shell_exec', re: /\bshell_exec\s*\(/ }, { name: 'popen', re: /\bpopen\s*\(/ }, { name: 'proc_open', re: /\bproc_open\s*\(/ }, { name: 'eval', re: /\beval\s*\(/ }, { name: 'assert', re: /\bassert\s*\(/ }, { name: 'pcntl_exec', re: /\bpcntl_exec\s*\(/ }, { name: 'pcntl_fork', re: /\bpcntl_fork\s*\(/ }, { name: 'backtick', re: /=\s*`[^`]*`/ }, ], ruby: [ { name: 'Kernel.eval', re: /\bKernel\.eval\b/ }, { name: 'eval', re: /\beval\s*\(/ }, { name: 'instance_eval', re: /\binstance_eval\b/ }, { name: 'class_eval', re: /\bclass_eval\b/ }, { name: 'system', re: /\bsystem\s*\(/ }, { name: 'exec', re: /\bexec\s*\(/ }, { name: 'IO.popen', re: /\bIO\.popen\b/ }, { name: 'Open3.popen', re: /\bOpen3\.popen/ }, { name: 'backtick', re: /=\s*`[^`]*`/ }, { name: 'send', re: /\b(?:public_)?send\s*\(/ }, ], go: [ { name: 'exec.Command', re: /\bexec\.Command(?:Context)?\b/ }, { name: 'syscall.Exec', re: /\bsyscall\.(?:Exec|ForkExec)\b/ }, { name: 'reflect.Call', all: [/\breflect\.ValueOf\b/, /\.Call\s*\(/] }, ], java: [ { name: 'Runtime.exec', re: /\bRuntime(?:\.getRuntime\(\))?\.exec\s*\(/ }, { name: 'ProcessBuilder', re: /\bProcessBuilder\b/ }, { name: 'Method.invoke', all: [/\bget(?:Declared)?Method\s*\(/, /\.invoke\s*\(/] }, ], rust: [ { name: 'process.Command', re: /\b(?:std::)?process::Command\b|\bCommand::new\b/ }, ], dotnet: [ { name: 'Process.Start', re: /\bProcess\.Start\b/ }, { name: 'ProcessStartInfo', re: /\bProcessStartInfo\b/ }, { name: 'Assembly.Load', all: [/\bAssembly\.Load\b/, /\.Invoke\s*\(/] }, ], }; // Protected-sensitive: file/dir deletion + write — only matter when the target is // a protected path (consumer decides). Reported with category 'protected_sensitive'. const PROTECTED_SENSITIVE_PATTERNS = { php: [ { name: 'file_put_contents', re: /\bfile_put_contents\s*\(/ }, { name: 'unlink', re: /\bunlink\s*\(/ }, { name: 'rmdir', re: /\brmdir\s*\(/ }, ], ruby: [ { name: 'File.delete', re: /\bFile\.delete\b/ }, { name: 'FileUtils.rm', re: /\bFileUtils\.rm\b/ }, { name: 'Dir.delete', re: /\bDir\.delete\b/ }, ], go: [ { name: 'os.Remove', re: /\bos\.Remove(?:All)?\s*\(/ }, ], java: [ { name: 'Files.delete', re: /\bFiles\.delete(?:IfExists)?\s*\(/ }, ], rust: [ { name: 'fs.remove', re: /\b(?:std::)?fs::remove_(?:file|dir_all)\b/ }, ], dotnet: [ { name: 'File.Delete', re: /\bFile\.Delete\b/ }, { name: 'Directory.Delete', re: /\bDirectory\.Delete\b/ }, ], }; function matchPattern(source, p) { if (p.all) return p.all.every((re) => re.test(source)); return p.re.test(source); } /** * Scan source text of a known language for suspicious patterns. * @param {string} source - file contents. * @param {string} lang - 'php'|'ruby'|'go'|'java'|'rust'|'dotnet'. * @param {{includeProtectedSensitive?: boolean}} [opts] * @returns {Array<{name: string, category: 'always'|'protected_sensitive', lang: string}>} */ export function scanContent(source, lang, opts = {}) { const { includeProtectedSensitive = true } = opts; const findings = []; if (typeof source !== 'string' || !lang) return findings; for (const p of ALWAYS_PATTERNS[lang] || []) { if (matchPattern(source, p)) findings.push({ name: p.name, category: 'always', lang }); } if (includeProtectedSensitive) { for (const p of PROTECTED_SENSITIVE_PATTERNS[lang] || []) { if (matchPattern(source, p)) findings.push({ name: p.name, category: 'protected_sensitive', lang }); } } return findings; } /** * Convenience: detect language from path, read via injected reader, scan. * @param {string} filePath * @param {(path: string) => string} readFile - injected (e.g. fs.readFileSync utf8). * @param {object} [opts] - forwarded to scanContent. */ export function scanFileWith(filePath, readFile, opts = {}) { const lang = detectLanguage(filePath); if (!lang) return []; let source; try { source = readFile(filePath); } catch { return []; } return scanContent(source, lang, opts); }