msg('laravel.name'); } public function getDescription(): string { return 'Detects Laravel-specific security vulnerabilities'; } public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array { $vulnerabilities = []; // Reset state for each file $this->hasFillable = false; $this->hasGuarded = false; $this->isEloquentModel = false; $this->currentClassName = ''; // Analyze PHP AST $this->traverse($ast, function (Node $node) use ($filePath, $taintTracker, &$vulnerabilities) { // Track class definitions for Mass Assignment check if ($node instanceof Node\Stmt\Class_) { $this->analyzeClass($node, $filePath, $vulnerabilities); } // Check for raw SQL methods if ($node instanceof Node\Expr\MethodCall) { $this->checkRawSqlMethod($node, $filePath, $taintTracker, $vulnerabilities); $this->checkMassAssignmentMethods($node, $filePath, $taintTracker, $vulnerabilities); } // Check for DB::raw() static calls and Model::create() mass assignment if ($node instanceof Node\Expr\StaticCall) { $this->checkDbRaw($node, $filePath, $taintTracker, $vulnerabilities); $this->checkStaticMassAssignment($node, $filePath, $vulnerabilities); } // Check route definitions for auth/throttle middleware if ($node instanceof Node\Expr\StaticCall || $node instanceof Node\Expr\MethodCall) { $this->checkRouteMiddleware($node, $filePath, $vulnerabilities); } // Check validation rules for file uploads if ($node instanceof Node\Expr\Array_) { $this->checkFileValidation($node, $filePath, $vulnerabilities); } return null; }); // Check Blade templates for CSRF if (str_ends_with($filePath, '.blade.php')) { $bladeVulns = $this->analyzeBladeForCsrf($filePath); $vulnerabilities = array_merge($vulnerabilities, $bladeVulns); } return $vulnerabilities; } /** * Analyze class for Mass Assignment vulnerabilities */ private function analyzeClass(Node\Stmt\Class_ $node, string $filePath, array &$vulnerabilities): void { $this->currentClassName = $node->name ? $node->name->toString() : ''; $this->hasFillable = false; $this->hasGuarded = false; $this->isEloquentModel = false; // Check if class extends Eloquent Model if ($node->extends) { $parentClass = $node->extends->toString(); if (str_contains($parentClass, 'Model') || str_contains($parentClass, 'Authenticatable') || str_contains($parentClass, 'Pivot')) { $this->isEloquentModel = true; } } if (!$this->isEloquentModel) { return; } // Check for $fillable or $guarded property foreach ($node->stmts as $stmt) { if ($stmt instanceof Node\Stmt\Property) { foreach ($stmt->props as $prop) { $propName = $prop->name->toString(); if ($propName === 'fillable') { $this->hasFillable = true; } if ($propName === 'guarded') { $this->hasGuarded = true; } } } } // Report if neither fillable nor guarded is defined if (!$this->hasFillable && !$this->hasGuarded) { $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_HIGH, $this->msg('laravel.mass_assignment', ['class' => $this->currentClassName]), $filePath, $node->getStartLine(), $node, $this->msg('laravel.mass_assignment.rec'), [], 'CWE-915', 'A5:2017-Broken Access Control' ); } } /** * Check for static mass assignment like Model::create($request->all()) */ private function checkStaticMassAssignment( Node\Expr\StaticCall $node, string $filePath, array &$vulnerabilities ): void { $methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : ''; if (!in_array($methodName, ['create', 'insert', 'upsert'])) { return; } $args = $this->getArguments($node); if (empty($args)) { return; } $firstArg = $args[0]->value; // Check for $request->all() or request()->all() if ($this->isRequestAll($firstArg)) { $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_HIGH, $this->msg('laravel.mass_assignment_all', ['method' => $methodName]), $filePath, $node->getStartLine(), $node, $this->msg('laravel.mass_assignment_all.rec'), [], 'CWE-915', 'A5:2017-Broken Access Control' ); } } /** * Check for dangerous Mass Assignment method calls */ private function checkMassAssignmentMethods( Node\Expr\MethodCall $node, string $filePath, TaintTracker $taintTracker, array &$vulnerabilities ): void { $methodName = $this->getCallName($node); if (!in_array($methodName, ['create', 'fill', 'update', 'insert'])) { return; } $args = $this->getArguments($node); if (empty($args)) { return; } $firstArg = $args[0]->value; // Check for $request->all() or request()->all() if ($this->isRequestAll($firstArg)) { $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_HIGH, $this->msg('laravel.mass_assignment_all', ['method' => $methodName]), $filePath, $node->getStartLine(), $node, $this->msg('laravel.mass_assignment_all.rec'), [], 'CWE-915', 'A5:2017-Broken Access Control' ); } } /** * Check if expression is $request->all() or similar */ private function isRequestAll(Node $expr): bool { // $request->all() if ($expr instanceof Node\Expr\MethodCall) { $methodName = $this->getCallName($expr); if ($methodName === 'all') { // Check if called on $request variable if ($expr->var instanceof Node\Expr\Variable) { $varName = $this->getVariableName($expr->var); if (strtolower($varName) === 'request') { return true; } } // Check if called on request() helper if ($expr->var instanceof Node\Expr\FuncCall) { $funcName = $this->getCallName($expr->var); if ($funcName === 'request') { return true; } } } } // request()->all() via static call Request::all() if ($expr instanceof Node\Expr\StaticCall) { $className = $expr->class instanceof Node\Name ? $expr->class->toString() : ''; $methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : ''; if (str_contains($className, 'Request') && $methodName === 'all') { return true; } } return false; } /** * Check raw SQL methods for injection vulnerabilities */ private function checkRawSqlMethod( Node\Expr\MethodCall $node, string $filePath, TaintTracker $taintTracker, array &$vulnerabilities ): void { $methodName = $this->getCallName($node); if (!in_array($methodName, self::RAW_QUERY_METHODS)) { return; } $args = $this->getArguments($node); if (empty($args)) { return; } $sqlArg = $args[0]->value; $hasBindings = count($args) >= 2; // Check if SQL string contains variables without bindings if ($this->containsUnsafeInterpolation($sqlArg) && !$hasBindings) { $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_CRITICAL, $this->msg('laravel.raw_sql', ['method' => $methodName]), $filePath, $node->getStartLine(), $node, $this->msg('laravel.raw_sql.rec'), [], 'CWE-89', 'A1:2017-Injection' ); } } /** * Check DB::raw() for injection */ private function checkDbRaw( Node\Expr\StaticCall $node, string $filePath, TaintTracker $taintTracker, array &$vulnerabilities ): void { $className = $node->class instanceof Node\Name ? $node->class->toString() : ''; $methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : ''; if (!str_contains($className, 'DB') || $methodName !== 'raw') { return; } $args = $this->getArguments($node); if (empty($args)) { return; } $sqlArg = $args[0]->value; if ($this->containsUnsafeInterpolation($sqlArg)) { $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_CRITICAL, $this->msg('laravel.db_raw'), $filePath, $node->getStartLine(), $node, $this->msg('laravel.db_raw.rec'), [], 'CWE-89', 'A1:2017-Injection' ); } } /** * Check if expression contains unsafe variable interpolation */ private function containsUnsafeInterpolation(Node $expr): bool { // Variable directly if ($expr instanceof Node\Expr\Variable) { return true; } // String interpolation "SELECT * FROM {$table}" if ($expr instanceof Node\Scalar\Encapsed || $expr instanceof Node\Scalar\InterpolatedString) { return true; } // Concatenation "SELECT * FROM " . $table if ($expr instanceof Node\Expr\BinaryOp\Concat) { return $this->containsUnsafeInterpolation($expr->left) || $this->containsUnsafeInterpolation($expr->right); } // Property fetch $this->table or $model->table if ($expr instanceof Node\Expr\PropertyFetch) { return true; } // Array access $tables[$key] if ($expr instanceof Node\Expr\ArrayDimFetch) { return true; } return false; } /** * Check route definitions for missing middleware */ private function checkRouteMiddleware( Node $node, string $filePath, array &$vulnerabilities ): void { // Only check in route files if (!str_contains($filePath, 'routes/') && !str_contains($filePath, 'routes\\')) { return; } $methodName = ''; $isRouteCall = false; // For static calls like Route::get() if ($node instanceof Node\Expr\StaticCall) { $className = $node->class instanceof Node\Name ? $node->class->toString() : ''; if (str_contains($className, 'Route')) { $isRouteCall = true; $methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : ''; } } if (!$isRouteCall) { return; } // Check Route::get/post/put/delete etc. if (!in_array($methodName, ['get', 'post', 'put', 'patch', 'delete', 'any'])) { return; } $args = $this->getArguments($node); if (empty($args)) { return; } // Get route path $routePath = ''; if ($args[0]->value instanceof Node\Scalar\String_) { $routePath = strtolower($args[0]->value->value); } // Check if this is a sensitive route without auth middleware $isSensitive = false; foreach (self::SENSITIVE_ROUTE_PATTERNS as $pattern) { if (str_contains($routePath, $pattern)) { $isSensitive = true; break; } } // Check if this is an auth route without throttle $isAuthRoute = false; foreach (self::AUTH_ROUTE_PATTERNS as $pattern) { if (str_contains($routePath, $pattern)) { $isAuthRoute = true; break; } } // We need to check the method chain for middleware // This is a simplified check - actual middleware might be in group // For now, we flag sensitive routes as informational if ($isSensitive) { $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_LOW, $this->msg('laravel.route_auth', ['route' => $routePath]), $filePath, $node->getStartLine(), $node, $this->msg('laravel.route_auth.rec'), [], 'CWE-862', 'A5:2017-Broken Access Control' ); } if ($isAuthRoute && in_array($methodName, ['post', 'put', 'patch'])) { $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_LOW, $this->msg('laravel.route_throttle', ['route' => $routePath]), $filePath, $node->getStartLine(), $node, $this->msg('laravel.route_throttle.rec'), [], 'CWE-307', 'A2:2017-Broken Authentication' ); } } /** * Check file validation rules */ private function checkFileValidation( Node\Expr\Array_ $node, string $filePath, array &$vulnerabilities ): void { foreach ($node->items as $item) { if ($item === null) { continue; } // Look for file/image validation rules if (!($item->value instanceof Node\Scalar\String_) && !($item->value instanceof Node\Expr\Array_)) { continue; } $rules = $this->extractValidationRules($item->value); // Check if there's 'extensions' without 'mimes' or 'mimetypes' $hasExtensions = false; $hasMimes = false; $hasFile = false; foreach ($rules as $rule) { $ruleLower = strtolower($rule); if (str_starts_with($ruleLower, 'extensions')) { $hasExtensions = true; } if (str_starts_with($ruleLower, 'mimes') || str_starts_with($ruleLower, 'mimetypes')) { $hasMimes = true; } if ($ruleLower === 'file' || $ruleLower === 'image') { $hasFile = true; } } if ($hasFile && $hasExtensions && !$hasMimes) { $keyName = ''; if ($item->key instanceof Node\Scalar\String_) { $keyName = $item->key->value; } $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_MEDIUM, $this->msg('laravel.file_validation', ['field' => $keyName]), $filePath, $node->getStartLine(), $node, $this->msg('laravel.file_validation.rec'), [], 'CWE-434', 'A8:2017-Insecure Deserialization' ); } } } /** * Extract validation rules from node */ private function extractValidationRules(Node $node): array { $rules = []; if ($node instanceof Node\Scalar\String_) { // Pipe-separated rules: 'required|file|extensions:jpg' $rules = explode('|', $node->value); } elseif ($node instanceof Node\Expr\Array_) { // Array rules: ['required', 'file', 'extensions:jpg'] foreach ($node->items as $item) { if ($item && $item->value instanceof Node\Scalar\String_) { $rules[] = $item->value->value; } } } return $rules; } /** * Analyze Blade template for CSRF protection */ private function analyzeBladeForCsrf(string $filePath): array { $vulnerabilities = []; $content = file_get_contents($filePath); if ($content === false) { return []; } // Find all forms with POST/PUT/PATCH/DELETE methods $formPattern = '/
tag $formEnd = stripos($content, '', $offset); if ($formEnd === false) { continue; } $formContent = substr($content, $offset, $formEnd - $offset); // Check if form has @csrf or csrf_field() or _token input $hasCsrf = preg_match('/@csrf\b/', $formContent) || preg_match('/csrf_field\s*\(\s*\)/', $formContent) || preg_match('/name\s*=\s*["\']_token["\']/', $formContent); if (!$hasCsrf) { $vulnerabilities[] = $this->createVulnerability( $this->msg('laravel.name'), Vulnerability::SEVERITY_HIGH, $this->msg('laravel.csrf_missing'), $filePath, $line, null, $this->msg('laravel.csrf_missing.rec'), [], 'CWE-352', 'A8:2013-CSRF' ); } } } return $vulnerabilities; } /** * Get line number from byte offset */ private function getLineFromOffset(string $content, int $offset): int { return substr_count(substr($content, 0, $offset), "\n") + 1; } }