diff --git a/src/Rules/LaravelSecurityRule.php b/src/Rules/LaravelSecurityRule.php new file mode 100644 index 0000000..12261a3 --- /dev/null +++ b/src/Rules/LaravelSecurityRule.php @@ -0,0 +1,636 @@ +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 = '/]*\bmethod\s*=\s*["\']?(POST|PUT|PATCH|DELETE)["\']?[^>]*>/i'; + + if (preg_match_all($formPattern, $content, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $index => $match) { + $formTag = $match[0]; + $offset = $match[1]; + $line = $this->getLineFromOffset($content, $offset); + + // Find the closing 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; + } +} diff --git a/src/SecurityLinter.php b/src/SecurityLinter.php index 5d6bc8f..19fcbc4 100644 --- a/src/SecurityLinter.php +++ b/src/SecurityLinter.php @@ -18,6 +18,7 @@ use SecurityLinter\Rules\PathTraversalRule; use SecurityLinter\Rules\AuthenticationRule; use SecurityLinter\Rules\CsrfSessionRule; use SecurityLinter\Rules\InsecureConfigRule; +use SecurityLinter\Rules\LaravelSecurityRule; /** * PHP/Laravel Security Linter @@ -73,6 +74,7 @@ class SecurityLinter new AuthenticationRule(), new CsrfSessionRule(), new InsecureConfigRule(), + new LaravelSecurityRule(), ]; }