stemedb/applications/aphoria/src/extractors/laravel_security.rs
jordan 157dbbb9eb feat: Complete Aphoria Phase 8-9 + UAT suite (90/90 tests passing)
## Phase 8: Enterprise Extractor Improvements 
- 14 security extractors (TLS, JWT, SQL injection, XSS, etc.)
- 10 framework-specific extractors (Spring, Django, Rails, etc.)
- Config file security detection (YAML, TOML)

## Phase 9: Autonomous Extractor Generation 
- Shadow mode executor with TP/FP tracking
- Graduation pipeline with confidence thresholds
- Auto-rollback on regression detection
- Cross-project pattern syncing

## UAT Suite Complete (14 scripts, 90 tests)
- test-core-detection.sh (6 tests)
- test-declarative-extractors.sh (5 tests)
- test-domain-frameworks.sh (5 tests)
- test-domain-unreal.sh (3 tests)
- test-llm-extraction.sh (6 tests)
- test-eval-harness.sh (5 tests)
- test-cross-language.sh (3 tests)
- test-precommit-performance.sh (4 tests)
- test-output-formats.sh (8 tests)
- test-drift-detection.sh (6 tests)
- test-exit-codes.sh (12 tests)
+ 3 more scripts

## Other Changes
- Updated roadmap to mark Phase 8-9 complete
- Added .gitignore entries for build artifacts
- Updated pre-commit: 800 line limit, exclude tests/data/cmd

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:50:55 -07:00

498 lines
15 KiB
Rust

//! Laravel security extractor.
//!
//! Detects security misconfigurations in Laravel applications:
//! - APP_DEBUG enabled in production
//! - Empty or weak APP_KEY
//! - Mass assignment vulnerabilities
//! - SQL injection via DB::raw
//! - CSRF protection bypassed
//! - Insecure session/cookie settings
use regex::Regex;
use stemedb_core::types::ObjectValue;
use super::traits::build_claim;
use super::Extractor;
use crate::types::{ExtractedClaim, Language};
/// Extractor for Laravel security misconfigurations.
#[allow(dead_code)]
pub struct LaravelSecurityExtractor {
// .env patterns
app_debug_true: Regex,
app_key_empty: Regex,
session_secure_false: Regex,
session_http_only_false: Regex,
// PHP config patterns
debug_hardcoded: Regex,
key_hardcoded: Regex,
cors_wildcard_credentials: Regex,
// PHP code patterns
csrf_except_all: Regex,
csrf_except_api: Regex,
mass_assignment_all: Regex,
mass_assignment_fill: Regex,
db_raw_interpolation: Regex,
db_select_interpolation: Regex,
eval_request: Regex,
exec_request: Regex,
shell_exec_request: Regex,
}
impl Default for LaravelSecurityExtractor {
fn default() -> Self {
Self::new()
}
}
impl LaravelSecurityExtractor {
/// Create a new Laravel security extractor.
///
/// # Panics
/// Panics if any regex pattern is invalid (programmer error).
#[allow(clippy::expect_used)]
pub fn new() -> Self {
Self {
// .env patterns
app_debug_true: Regex::new(r"(?i)^APP_DEBUG\s*=\s*true").expect("valid regex"),
app_key_empty: Regex::new(r"(?i)^APP_KEY\s*=\s*$").expect("valid regex"),
session_secure_false: Regex::new(r"(?i)^SESSION_SECURE_COOKIE\s*=\s*false")
.expect("valid regex"),
session_http_only_false: Regex::new(r"(?i)^SESSION_HTTP_ONLY\s*=\s*false")
.expect("valid regex"),
// PHP config patterns
debug_hardcoded: Regex::new(r#"['"]debug['"]\s*=>\s*true"#).expect("valid regex"),
key_hardcoded: Regex::new(r#"['"]key['"]\s*=>\s*['"][^'"]{1,50}['"]"#)
.expect("valid regex"),
cors_wildcard_credentials: Regex::new(
r#"['"]allowed_origins['"]\s*=>\s*\[\s*['"]?\*['"]?\s*\][^]]*['"]supports_credentials['"]\s*=>\s*true"#,
)
.expect("valid regex"),
// PHP code patterns
csrf_except_all: Regex::new(r#"protected\s+\$except\s*=\s*\[\s*['"]?\*['"]?\s*\]"#)
.expect("valid regex"),
csrf_except_api: Regex::new(r#"\$except\s*=\s*\[[^\]]*['"]api/\*['"]"#)
.expect("valid regex"),
mass_assignment_all: Regex::new(r"::\s*create\s*\(\s*\$request->all\s*\(\s*\)\s*\)")
.expect("valid regex"),
mass_assignment_fill: Regex::new(r"->fill\s*\(\s*\$request->all\s*\(\s*\)\s*\)")
.expect("valid regex"),
db_raw_interpolation: Regex::new(r#"DB::raw\s*\([^)]*\.\s*\$"#)
.expect("valid regex"),
db_select_interpolation: Regex::new(r#"DB::select\s*\(\s*['"][^'"]*\{\$"#)
.expect("valid regex"),
eval_request: Regex::new(r"eval\s*\(\s*\$request").expect("valid regex"),
exec_request: Regex::new(r"exec\s*\(\s*\$request").expect("valid regex"),
shell_exec_request: Regex::new(r"shell_exec\s*\(\s*\$request").expect("valid regex"),
}
}
fn check_env_patterns(
&self,
path_segments: &[String],
content: &str,
file: &str,
) -> Vec<ExtractedClaim> {
let mut claims = Vec::new();
for (line_idx, line) in content.lines().enumerate() {
let line_num = line_idx + 1;
// APP_DEBUG=true
if let Some(m) = self.app_debug_true.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "debug_mode"],
"enabled",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
1.0,
"Laravel APP_DEBUG enabled - must be false in production",
));
}
// APP_KEY empty
if let Some(m) = self.app_key_empty.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "app_key"],
"missing",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
1.0,
"Laravel APP_KEY is empty - encryption will fail",
));
}
// SESSION_SECURE_COOKIE=false
if let Some(m) = self.session_secure_false.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "session_cookie", "secure"],
"enabled",
ObjectValue::Boolean(false),
file,
line_num,
m.as_str(),
1.0,
"Laravel session cookie not marked secure",
));
}
// SESSION_HTTP_ONLY=false
if let Some(m) = self.session_http_only_false.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "session_cookie", "httponly"],
"enabled",
ObjectValue::Boolean(false),
file,
line_num,
m.as_str(),
1.0,
"Laravel session cookie accessible to JavaScript",
));
}
}
claims
}
fn check_php_patterns(
&self,
path_segments: &[String],
content: &str,
file: &str,
) -> Vec<ExtractedClaim> {
let mut claims = Vec::new();
for (line_idx, line) in content.lines().enumerate() {
let line_num = line_idx + 1;
// Debug hardcoded
if let Some(m) = self.debug_hardcoded.find(line) {
// Skip if using env()
if !line.contains("env(") {
claims.push(build_claim(
path_segments,
&["laravel", "debug_mode"],
"hardcoded",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
0.9,
"Laravel debug mode hardcoded to true",
));
}
}
// CSRF except all
if let Some(m) = self.csrf_except_all.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "csrf"],
"exempt",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
1.0,
"Laravel CSRF protection disabled for all routes",
));
}
// CSRF except API
if let Some(m) = self.csrf_except_api.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "csrf", "api_exempt"],
"vulnerable",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
0.7,
"Laravel CSRF protection disabled for API routes",
));
}
// Mass assignment via create()
if let Some(m) = self.mass_assignment_all.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "mass_assignment"],
"vulnerable",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
0.95,
"Laravel mass assignment via ::create($request->all())",
));
}
// Mass assignment via fill()
if let Some(m) = self.mass_assignment_fill.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "mass_assignment"],
"vulnerable",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
0.95,
"Laravel mass assignment via ->fill($request->all())",
));
}
// DB::raw interpolation
if let Some(m) = self.db_raw_interpolation.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "sql_injection"],
"vulnerable",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
0.95,
"Laravel SQL injection via DB::raw() with interpolation",
));
}
// DB::select interpolation
if let Some(m) = self.db_select_interpolation.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "sql_injection"],
"vulnerable",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
0.95,
"Laravel SQL injection via DB::select() with interpolation",
));
}
// Command injection
if let Some(m) = self.eval_request.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "code_injection"],
"vulnerable",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
1.0,
"Laravel code injection via eval() with request data",
));
}
if let Some(m) = self.exec_request.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "command_injection"],
"vulnerable",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
1.0,
"Laravel command injection via exec() with request data",
));
}
if let Some(m) = self.shell_exec_request.find(line) {
claims.push(build_claim(
path_segments,
&["laravel", "command_injection"],
"vulnerable",
ObjectValue::Boolean(true),
file,
line_num,
m.as_str(),
1.0,
"Laravel command injection via shell_exec() with request data",
));
}
}
claims
}
}
impl Extractor for LaravelSecurityExtractor {
fn name(&self) -> &str {
"laravel_security"
}
fn languages(&self) -> &[Language] {
&[Language::Php, Language::Dotenv]
}
fn extract(
&self,
path_segments: &[String],
content: &str,
language: Language,
file: &str,
) -> Vec<ExtractedClaim> {
let mut claims = Vec::new();
// Check if this looks like a Laravel file
let is_laravel = content.contains("Laravel")
|| content.contains("laravel")
|| content.contains("Illuminate")
|| content.contains("APP_KEY")
|| content.contains("APP_DEBUG")
|| file.contains("artisan")
|| file.contains("app/Http");
if !is_laravel {
return claims;
}
match language {
Language::Dotenv => {
claims.extend(self.check_env_patterns(path_segments, content, file));
}
Language::Php => {
claims.extend(self.check_php_patterns(path_segments, content, file));
}
_ => {}
}
claims
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_debug_true() {
let extractor = LaravelSecurityExtractor::new();
let content = r#"
APP_NAME=Laravel
APP_ENV=production
APP_KEY=base64:abcdef...
APP_DEBUG=true
"#;
let claims = extractor.extract(&["env".to_string()], content, Language::Dotenv, ".env");
assert!(claims.iter().any(|c| c.concept_path.contains("debug_mode")));
}
#[test]
fn test_app_key_empty() {
let extractor = LaravelSecurityExtractor::new();
let content = r#"
APP_NAME=Laravel
APP_KEY=
APP_DEBUG=false
"#;
let claims = extractor.extract(&["env".to_string()], content, Language::Dotenv, ".env");
assert!(claims.iter().any(|c| c.concept_path.contains("app_key")));
}
#[test]
fn test_mass_assignment() {
let extractor = LaravelSecurityExtractor::new();
let content = r#"
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function store(Request $request)
{
return User::create($request->all());
}
}
"#;
let claims =
extractor.extract(&["php".to_string()], content, Language::Php, "UserController.php");
assert!(claims.iter().any(|c| c.concept_path.contains("mass_assignment")));
}
#[test]
fn test_csrf_exempt_all() {
let extractor = LaravelSecurityExtractor::new();
let content = r#"
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
protected $except = ['*'];
}
"#;
let claims =
extractor.extract(&["php".to_string()], content, Language::Php, "VerifyCsrfToken.php");
assert!(claims.iter().any(|c| c.concept_path.contains("csrf")));
}
#[test]
fn test_db_raw_injection() {
let extractor = LaravelSecurityExtractor::new();
let content = r#"
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SearchController extends Controller
{
public function search(Request $request)
{
return DB::raw("SELECT * FROM users WHERE name = '" . $request->name . "'");
}
}
"#;
let claims =
extractor.extract(&["php".to_string()], content, Language::Php, "SearchController.php");
assert!(claims.iter().any(|c| c.concept_path.contains("sql_injection")));
}
#[test]
fn test_non_laravel_file_skipped() {
let extractor = LaravelSecurityExtractor::new();
let content = r#"
<?php
$debug = true;
"#;
let claims = extractor.extract(&["php".to_string()], content, Language::Php, "random.php");
// Should not detect since file doesn't look like Laravel
assert!(claims.is_empty());
}
}