#!/usr/bin/env node /** * Framework boot-path scanner (router-gate v4 Stream C, spec §5.2 F7 closure). * * Closes: Edit a framework boot file (e.g. app/Providers/AppServiceProvider.php) * with an RCE payload, then `composer test` -> payload executes during boot. * Detects test-runner Bash commands whose project has edited boot files, and * scans those files for suspicious patterns. Pure — fileExists / readFile injected. */ import { isTestRunner, scanContent, detectLanguage } from './static-content-scanner.mjs'; // project type -> predicate over an injected fileExists(relPath). // Detection rule per spec §5.2: each type needs its characteristic markers. const PROJECT_MARKERS = { laravel: (e) => e('composer.json') && e('app/Providers/AppServiceProvider.php'), rails: (e) => e('Gemfile') && e('config/application.rb'), express: (e) => e('package.json') && (e('app.js') || e('server.js')), django: (e) => e('manage.py'), spring: (e) => e('pom.xml') || e('build.gradle'), }; /** * @param {(relPath: string) => boolean} fileExists - injected (e.g. fs.existsSync). * @returns {string[]} detected project types (may be multiple). */ export function detectProjectType(fileExists) { if (typeof fileExists !== 'function') return []; const types = []; for (const [type, predicate] of Object.entries(PROJECT_MARKERS)) { try { if (predicate(fileExists)) types.push(type); } catch { /* ignore predicate errors */ } } return types; } // Boot-path glob sets per project type (spec §5.2 "Boot-path file set"). const BOOT_PATHS = { laravel: [ 'app/Providers/**/*.php', 'bootstrap/**/*.php', 'routes/*.php', 'app/Http/Kernel.php', 'app/Console/Kernel.php', 'app/Http/Middleware/**/*.php', 'app/Console/Commands/**/*.php', 'app/Models/*.php', // scan boot() method specifically (see decideBootScan) ], rails: [ 'config/initializers/**/*.rb', 'config/application.rb', 'config/routes.rb', ], express: [ 'app.js', 'server.js', 'index.js', 'routes/**/*.js', 'middleware/**/*.js', ], django: [ '*/apps.py', // scan ready() method '*/middleware.py', 'urls.py', ], spring: [], // Spring matched by annotation content scan, not a fixed path set }; /** * @param {string} projectType * @returns {string[]} glob patterns of boot-path files. */ export function bootPathSet(projectType) { return BOOT_PATHS[projectType] ? [...BOOT_PATHS[projectType]] : []; } /** * Glob match without external deps. Supports `**` (cross-dir) and `*` (single * segment). Single-pass tokenizer (no placeholder literals) so `**` is handled * before `*`. No nested quantifiers -> no catastrophic backtracking. */ export function matchesBootPattern(filePath, pattern) { if (typeof filePath !== 'string' || typeof pattern !== 'string') return false; const f = filePath.replace(/\\/g, '/'); const p = pattern.replace(/\\/g, '/'); let rx = ''; let i = 0; while (i < p.length) { if (p[i] === '*' && p[i + 1] === '*') { if (p[i + 2] === '/') { rx += '(?:.*/)?'; i += 3; } // **/ -> any dirs (incl none) else { rx += '.*'; i += 2; } // ** -> anything (cross-dir) } else if (p[i] === '*') { rx += '[^/]*'; i += 1; // * -> single path segment } else { const ch = p[i]; rx += /[.+^${}()|[\]\\]/.test(ch) ? '\\' + ch : ch; // escape one literal char i += 1; } } return new RegExp('^' + rx + '$').test(f); } /** * Edited files that match any boot-path pattern for the given project type. * @param {string} projectType * @param {string[]} editedFiles * @returns {string[]} */ export function intersectEditedBootFiles(projectType, editedFiles) { if (!Array.isArray(editedFiles) || editedFiles.length === 0) return []; const patterns = bootPathSet(projectType); if (patterns.length === 0) return []; return editedFiles.filter((file) => patterns.some((pat) => matchesBootPattern(file, pat))); } /** * Extract a PHP/JS-style method body by brace matching (char-scan, no regex on * the body, no backtracking). Returns the text between the method's opening { and * its matching }. Empty string if not found. * * NOTE (best-effort): the matcher is string/comment-naive — a `}` inside a string * literal or comment closes the body early, so this can under-report. It is only * used to scope the `findings` evidence for Model files; the security control is * the intersection-based hard-block in decideBootScan, which fires regardless of * findings. See the "hidden behind a } inside a string" test. */ export function extractPhpMethodBody(source, methodName) { if (typeof source !== 'string' || typeof methodName !== 'string') return ''; const sigRe = new RegExp('\\bfunction\\s+' + methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\('); const m = sigRe.exec(source); if (!m) return ''; let i = source.indexOf('{', m.index); // first '{' after the signature if (i < 0) return ''; let depth = 0; const start = i + 1; for (; i < source.length; i++) { const ch = source[i]; if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) return source.slice(start, i); } } return source.slice(start); // unbalanced — return rest defensively } // Patterns whose files should be scanned boot()-body-only (model files edited // legitimately for domain logic; only their boot() hook is RCE-relevant). const BOOT_BODY_ONLY = [/(?:^|\/)app\/Models\/[^/]+\.php$/i]; /** * F7 decision. Blocks a test-runner command when an edited file is a boot-path * file, and surfaces suspicious-pattern findings from those files. * * @param {{command: string, projectTypes: string[], editedFiles: string[], * readFile: (p: string) => string}} args * @returns {{block: boolean, reason?: string, intersection?: string[], findings?: object[]}} */ export function decideBootScan({ command, projectTypes, editedFiles, readFile }) { if (!isTestRunner(command)) return { block: false }; if (!Array.isArray(projectTypes) || projectTypes.length === 0) return { block: false }; const intersection = []; for (const type of projectTypes) { for (const f of intersectEditedBootFiles(type, editedFiles || [])) { if (!intersection.includes(f)) intersection.push(f); } } if (intersection.length === 0) return { block: false }; // Scan content of intersected boot files (extra evidence), max 50 files. const findings = []; for (const file of intersection.slice(0, 50)) { const lang = detectLanguage(file); if (!lang) continue; let source; try { source = readFile(file); } catch { continue; } const norm = file.replace(/\\/g, '/'); const bodyOnly = BOOT_BODY_ONLY.some((re) => re.test(norm)); const scanText = bodyOnly ? extractPhpMethodBody(source, 'boot') : source; for (const fnd of scanContent(scanText, lang)) { findings.push({ ...fnd, file }); } } const reason = [ `Test runner "${command}" while boot-path file(s) edited: ${intersection.join(', ')}.`, `Possible framework boot RCE (payload executes during boot). Request AskUser approval`, `with explicit test command + review of boot file content.`, ].join(' '); return { block: true, reason, intersection, findings }; }