Files
brain/tools/framework-boot-scanner.mjs

201 lines
7.2 KiB
JavaScript

#!/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 };
}