Files
brain/tools/static-content-scanner.test.mjs
T

153 lines
6.7 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import {
TEST_RUNNERS,
isTestRunner,
detectLanguage,
scanContent,
scanFileWith,
} from './static-content-scanner.mjs';
describe('isTestRunner', () => {
it('matches PHP/Laravel test runners', () => {
expect(isTestRunner('composer test')).toBe(true);
expect(isTestRunner('php artisan test --parallel')).toBe(true);
expect(isTestRunner('vendor/bin/pest')).toBe(true);
expect(isTestRunner('vendor/bin/phpunit tests/Unit')).toBe(true);
});
it('matches Ruby / Go / Java / Rust / .NET / JS runners', () => {
expect(isTestRunner('bundle exec rspec')).toBe(true);
expect(isTestRunner('go test ./...')).toBe(true);
expect(isTestRunner('mvn test')).toBe(true);
expect(isTestRunner('gradle build')).toBe(true);
expect(isTestRunner('cargo test')).toBe(true);
expect(isTestRunner('dotnet test')).toBe(true);
expect(isTestRunner('npx vitest run')).toBe(true);
expect(isTestRunner('npm run test')).toBe(true);
});
it('does not match unrelated commands', () => {
expect(isTestRunner('git status')).toBe(false);
expect(isTestRunner('ls -la')).toBe(false);
expect(isTestRunner('composer install')).toBe(false);
expect(isTestRunner('')).toBe(false);
expect(isTestRunner(null)).toBe(false);
});
it('exposes TEST_RUNNERS as a non-empty array', () => {
expect(Array.isArray(TEST_RUNNERS)).toBe(true);
expect(TEST_RUNNERS.length).toBeGreaterThanOrEqual(11);
});
});
describe('detectLanguage', () => {
it('maps extensions to language keys', () => {
expect(detectLanguage('app/Providers/AppServiceProvider.php')).toBe('php');
expect(detectLanguage('config/routes.rb')).toBe('ruby');
expect(detectLanguage('main.go')).toBe('go');
expect(detectLanguage('src/Main.java')).toBe('java');
expect(detectLanguage('src/Config.kt')).toBe('java'); // JVM
expect(detectLanguage('src/lib.rs')).toBe('rust');
expect(detectLanguage('Program.cs')).toBe('dotnet');
});
it('is case-insensitive on extension', () => {
expect(detectLanguage('Foo.PHP')).toBe('php');
});
it('returns null for unknown / extensionless / non-string', () => {
expect(detectLanguage('notes.txt')).toBeNull();
expect(detectLanguage('Makefile')).toBeNull();
expect(detectLanguage(42)).toBeNull();
});
});
describe('scanContent — always-suspicious patterns', () => {
it('flags PHP code-execution sinks', () => {
const names = scanContent('<?php exec($cmd); eval($x);', 'php').map((f) => f.name);
expect(names).toContain('exec');
expect(names).toContain('eval');
});
it('flags PHP shell_exec / proc_open / pcntl / backticks', () => {
const names = scanContent('<?php $o = shell_exec($c); proc_open($c); $r = `whoami`;', 'php').map((f) => f.name);
expect(names).toContain('shell_exec');
expect(names).toContain('proc_open');
expect(names).toContain('backtick');
});
it('flags Ruby eval/system/popen/backticks', () => {
const names = scanContent('Kernel.eval(x); system("rm"); IO.popen(c); y = `ls`', 'ruby').map((f) => f.name);
expect(names).toContain('Kernel.eval');
expect(names).toContain('system');
expect(names).toContain('IO.popen');
expect(names).toContain('backtick');
});
it('flags Go exec + reflect co-occurrence', () => {
const src = 'import "os/exec"\nexec.Command("sh")\nv := reflect.ValueOf(f); v.Call(args)';
const names = scanContent(src, 'go').map((f) => f.name);
expect(names).toContain('exec.Command');
expect(names).toContain('reflect.Call');
});
it('does NOT flag Go reflect.Call when only ValueOf present (co-occurrence requires both)', () => {
const names = scanContent('v := reflect.ValueOf(f)', 'go').map((f) => f.name);
expect(names).not.toContain('reflect.Call');
});
it('flags Java Runtime.exec / ProcessBuilder / reflective invoke', () => {
const src = 'Runtime.getRuntime().exec(c); new ProcessBuilder(c); m = cls.getMethod("x"); m.invoke(o);';
const names = scanContent(src, 'java').map((f) => f.name);
expect(names).toContain('Runtime.exec');
expect(names).toContain('ProcessBuilder');
expect(names).toContain('Method.invoke');
});
it('flags Rust process::Command', () => {
const names = scanContent('let o = std::process::Command::new("sh");', 'rust').map((f) => f.name);
expect(names).toContain('process.Command');
});
it('flags .NET Process.Start + Assembly.Load reflective invoke', () => {
const src = 'Process.Start(p); var a = Assembly.Load(b); mi.Invoke(o, null);';
const names = scanContent(src, 'dotnet').map((f) => f.name);
expect(names).toContain('Process.Start');
expect(names).toContain('Assembly.Load');
});
});
describe('scanContent — protected-sensitive (file deletion / write)', () => {
it('includes protected-sensitive findings by default', () => {
const findings = scanContent('<?php unlink($f); file_put_contents($p, $d);', 'php');
const prot = findings.filter((f) => f.category === 'protected_sensitive').map((f) => f.name);
expect(prot).toContain('unlink');
expect(prot).toContain('file_put_contents');
});
it('omits protected-sensitive findings when includeProtectedSensitive: false', () => {
const findings = scanContent('<?php unlink($f);', 'php', { includeProtectedSensitive: false });
expect(findings.find((f) => f.category === 'protected_sensitive')).toBeUndefined();
});
it('still flags always-suspicious even with includeProtectedSensitive: false', () => {
const findings = scanContent('<?php exec($c); unlink($f);', 'php', { includeProtectedSensitive: false });
expect(findings.map((f) => f.name)).toContain('exec');
});
});
describe('scanContent — guards', () => {
it('returns [] for non-string source or unknown lang', () => {
expect(scanContent(null, 'php')).toEqual([]);
expect(scanContent('exec(', 'cobol')).toEqual([]);
expect(scanContent('exec(', null)).toEqual([]);
});
it('returns [] for clean source', () => {
expect(scanContent('<?php class Foo { public function bar() { return 1; } }', 'php')).toEqual([]);
});
});
describe('scanFileWith', () => {
it('detects language from path and scans injected content', () => {
const reader = (p) => (p.endsWith('.php') ? '<?php system($c);' : '');
const findings = scanFileWith('app/Foo.php', reader);
expect(findings.map((f) => f.name)).toContain('system');
});
it('returns [] for unknown language path without calling reader', () => {
let called = false;
const reader = () => { called = true; return ''; };
expect(scanFileWith('notes.txt', reader)).toEqual([]);
expect(called).toBe(false);
});
it('returns [] when reader throws (unreadable file)', () => {
const reader = () => { throw new Error('ENOENT'); };
expect(scanFileWith('app/Foo.php', reader)).toEqual([]);
});
});