## 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>
498 lines
15 KiB
Rust
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());
|
|
}
|
|
}
|