has_permission(', '->has_role(', ]; /** * Check a file for violations */ public function check(string $file_path, string $contents, array $metadata = []): void { // Read original file content (not sanitized) for comment checking $original_contents = file_get_contents($file_path); // Skip if file-level exception comment is present if (strpos($original_contents, '@' . $this->get_id() . '-EXCEPTION') !== false) { return; } // Skip if class-level @auth-exempt comment is present (all endpoints public) if (strpos($original_contents, '@auth-exempt') !== false) { // Check if @auth-exempt appears before class definition (in class docblock) // Use regex to find actual class definition, not 'class' in use statements if (preg_match('/^(abstract\s+)?class\s+\w+/m', $original_contents, $matches, PREG_OFFSET_CAPTURE)) { $class_pos = $matches[0][1]; $exempt_pos = strpos($original_contents, '@auth-exempt'); if ($exempt_pos !== false && $exempt_pos < $class_pos) { return; } } } // Only check controller files (must extend Rsx_Controller_Abstract) if (!isset($metadata['extends']) || $metadata['extends'] !== 'Rsx_Controller_Abstract') { return; } // Skip archived files if (str_contains($file_path, '/archive/') || str_contains($file_path, '/archived/')) { return; } // Get the class name $class_name = $metadata['class'] ?? null; if (!$class_name) { return; } // Check if pre_dispatch has auth check $pre_dispatch_has_auth = $this->pre_dispatch_has_auth_check($contents, $metadata); // Get public static methods from metadata $methods = $metadata['public_static_methods'] ?? []; foreach ($methods as $method_name => $method_info) { // Skip pre_dispatch itself if ($method_name === 'pre_dispatch') { continue; } // Check if method has endpoint attributes $has_endpoint_attr = false; $endpoint_type = null; $attributes = $method_info['attributes'] ?? []; foreach ($attributes as $attr_name => $attr_data) { $short_name = basename(str_replace('\\', '/', $attr_name)); if (in_array($short_name, ['Route', 'SPA', 'Ajax_Endpoint'])) { $has_endpoint_attr = true; $endpoint_type = $short_name; break; } } // Skip methods without endpoint attributes if (!$has_endpoint_attr) { continue; } // Get line number for this method $line_number = $method_info['line'] ?? 1; // Check if method has @auth-exempt comment if ($this->method_has_auth_exempt($original_contents, $method_name, $line_number)) { continue; } // If pre_dispatch has auth check, this endpoint is covered if ($pre_dispatch_has_auth) { continue; } // Check if method body has auth check $method_body = $this->extract_method_body($contents, $method_name); if ($method_body && $this->body_has_auth_check($method_body)) { continue; } // Violation found - no auth check $code_snippet = "#[{$endpoint_type}]\npublic static function {$method_name}(...)"; $this->add_violation( $file_path, $line_number, "Endpoint '{$method_name}' has no authentication check", $code_snippet, $this->build_suggestion($method_name, $class_name), 'high' ); } } /** * Check if pre_dispatch method has an auth check */ private function pre_dispatch_has_auth_check(string $contents, array $metadata): bool { $methods = $metadata['public_static_methods'] ?? []; if (!isset($methods['pre_dispatch'])) { return false; } $pre_dispatch_body = $this->extract_method_body($contents, 'pre_dispatch'); if (!$pre_dispatch_body) { return false; } return $this->body_has_auth_check($pre_dispatch_body); } /** * Check if a code body has an auth check pattern */ private function body_has_auth_check(string $body): bool { foreach (self::AUTH_CHECK_PATTERNS as $pattern) { if (str_contains($body, $pattern)) { return true; } } return false; } /** * Check if a method has @auth-exempt comment */ private function method_has_auth_exempt(string $contents, string $method_name, int $method_line): bool { $lines = explode("\n", $contents); // Check the 10 lines before the method definition for @auth-exempt $start_line = max(0, $method_line - 11); $end_line = $method_line - 1; for ($i = $start_line; $i <= $end_line && $i < count($lines); $i++) { $line = $lines[$i]; if (str_contains($line, '@auth-exempt')) { return true; } // Stop if we hit another method definition if (preg_match('/^\s*public\s+static\s+function\s+/', $line) && !str_contains($line, $method_name)) { break; } } return false; } /** * Extract method body from file contents */ private function extract_method_body(string $contents, string $method_name): ?string { // Pattern to match method definition $pattern = '/public\s+static\s+function\s+' . preg_quote($method_name, '/') . '\s*\([^)]*\)[^{]*\{/s'; if (!preg_match($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) { return null; } $start_pos = $matches[0][1] + strlen($matches[0][0]) - 1; $brace_count = 1; $pos = $start_pos + 1; $length = strlen($contents); while ($pos < $length && $brace_count > 0) { $char = $contents[$pos]; if ($char === '{') { $brace_count++; } elseif ($char === '}') { $brace_count--; } $pos++; } return substr($contents, $start_pos, $pos - $start_pos); } /** * Build suggestion for fixing the violation */ private function build_suggestion(string $method_name, string $class_name): string { $suggestions = []; $suggestions[] = "Endpoint '{$method_name}' needs an authentication check."; $suggestions[] = ""; $suggestions[] = "Option 1: Add auth check to pre_dispatch() (recommended for all endpoints in controller):"; $suggestions[] = " public static function pre_dispatch(Request \$request, array \$params = [])"; $suggestions[] = " {"; $suggestions[] = " if (!Session::is_logged_in()) {"; $suggestions[] = " return response_unauthorized();"; $suggestions[] = " }"; $suggestions[] = " return null;"; $suggestions[] = " }"; $suggestions[] = ""; $suggestions[] = "Option 2: Add auth check at start of method body:"; $suggestions[] = " if (!Session::is_logged_in()) {"; $suggestions[] = " return response_unauthorized();"; $suggestions[] = " }"; $suggestions[] = ""; $suggestions[] = "Option 3: Mark as public endpoint with @auth-exempt comment:"; $suggestions[] = " /**"; $suggestions[] = " * @auth-exempt Public endpoint for webhook receivers"; $suggestions[] = " */"; $suggestions[] = " #[Ajax_Endpoint]"; $suggestions[] = " public static function {$method_name}(...)"; return implode("\n", $suggestions); } }