site_id check (verifies ownership) * * Exemption: * - Add @auth-exempt comment with reason for public data fetch */ class ModelFetchAuthCheck_CodeQualityRule extends CodeQualityRule_Abstract { /** * Get the unique rule identifier */ public function get_id(): string { return 'PHP-MODEL-FETCH-01'; } /** * Get human-readable rule name */ public function get_name(): string { return 'Model Fetch Authentication Check'; } /** * Get rule description */ public function get_description(): string { return 'Validates that model fetch() methods with #[Ajax_Endpoint_Model_Fetch] have authentication checks'; } /** * Get file patterns this rule applies to */ public function get_file_patterns(): array { return ['*_model.php', '*_Model.php']; } /** * Whether this rule is called during manifest scan */ public function is_called_during_manifest_scan(): bool { return false; // Only run during rsx:check } /** * Get default severity for this rule */ public function get_default_severity(): string { return 'high'; } /** * Patterns that indicate an auth check is present */ private const AUTH_CHECK_PATTERNS = [ 'Session::is_logged_in', 'Session::get_user', 'Session::get_user_id', 'Session::get_site_id', 'Permission::has_permission', 'Permission::has_role', 'response_unauthorized', '->has_permission(', '->has_role(', '->site_id', // Checking ownership via site_id 'get_site_id()', // Session site check ]; /** * 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; } // Only check model files (must extend Rsx_Model_Abstract) if (!isset($metadata['extends']) || $metadata['extends'] !== 'Rsx_Model_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; } // Get public static methods from metadata $methods = $metadata['public_static_methods'] ?? []; // Check if fetch() method exists if (!isset($methods['fetch'])) { return; } $fetch_info = $methods['fetch']; $attributes = $fetch_info['attributes'] ?? []; // Check if fetch has Ajax_Endpoint_Model_Fetch attribute $has_fetch_attribute = false; foreach ($attributes as $attr_name => $attr_data) { $short_name = basename(str_replace('\\', '/', $attr_name)); if ($short_name === 'Ajax_Endpoint_Model_Fetch') { $has_fetch_attribute = true; break; } } // Skip if fetch doesn't have the attribute (not exposed via ORM) if (!$has_fetch_attribute) { return; } // Get line number for fetch method $line_number = $fetch_info['line'] ?? 1; // Check if method has @auth-exempt comment if ($this->method_has_auth_exempt($original_contents, 'fetch', $line_number)) { return; } // Check if method body has auth check $method_body = $this->extract_method_body($contents, 'fetch'); if ($method_body && $this->body_has_auth_check($method_body)) { return; } // Violation found - no auth check $code_snippet = "#[Ajax_Endpoint_Model_Fetch]\npublic static function fetch(\$id)"; $this->add_violation( $file_path, $line_number, "Model fetch() method has no authentication check", $code_snippet, $this->build_suggestion($class_name), 'high' ); } /** * 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 $class_name): string { $suggestions = []; $suggestions[] = "Model fetch() method needs an authentication/authorization check."; $suggestions[] = ""; $suggestions[] = "Option 1: Check user is logged in and verify ownership:"; $suggestions[] = " #[Ajax_Endpoint_Model_Fetch]"; $suggestions[] = " public static function fetch(\$id)"; $suggestions[] = " {"; $suggestions[] = " if (!Session::is_logged_in()) {"; $suggestions[] = " return null; // or response_unauthorized()"; $suggestions[] = " }"; $suggestions[] = " \$record = static::find(\$id);"; $suggestions[] = " if (!\$record || \$record->site_id !== Session::get_site_id()) {"; $suggestions[] = " return null; // Wrong site or not found"; $suggestions[] = " }"; $suggestions[] = " return \$record;"; $suggestions[] = " }"; $suggestions[] = ""; $suggestions[] = "Option 2: If this is intentionally public data, add @auth-exempt:"; $suggestions[] = " /**"; $suggestions[] = " * @auth-exempt Public reference data (countries, etc.)"; $suggestions[] = " */"; $suggestions[] = " #[Ajax_Endpoint_Model_Fetch]"; $suggestions[] = " public static function fetch(\$id) { ... }"; return implode("\n", $suggestions); } }