397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
201 lines
7.2 KiB
JavaScript
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 };
|
|
}
|