Files
rspade_system/app/RSpade/CodeQuality/Rules/PHP/EndpointAuthCheck_CodeQualityRule.php
root 29c657f7a7 Exclude tests directory from framework publish
Add 100+ automated unit tests from .expect file specifications
Add session system test
Add rsx:constants:regenerate command test
Add rsx:logrotate command test
Add rsx:clean command test
Add rsx:manifest:stats command test
Add model enum system test
Add model mass assignment prevention test
Add rsx:check command test
Add migrate:status command test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-25 03:59:58 +00:00

317 lines
10 KiB
PHP

<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* EndpointAuthCheckRule - Validates that controller endpoints have auth checks
*
* This rule ensures that all #[Route], #[SPA], and #[Ajax_Endpoint] methods
* have authentication checks, either:
* - In the method body itself, OR
* - In a pre_dispatch() method in the same controller
*
* Valid auth check patterns:
* - Session::is_logged_in()
* - Session::get_user()
* - Permission::has_permission()
* - Permission::has_role()
* - response_unauthorized()
*
* Exemption:
* - Add @auth-exempt comment with reason to skip check for public endpoints
*/
class EndpointAuthCheck_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'PHP-AUTH-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Endpoint Authentication Check';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Validates that controller endpoints (#[Route], #[SPA], #[Ajax_Endpoint]) have authentication checks';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.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',
'Permission::has_permission',
'Permission::has_role',
'Permission::authenticated',
'Permission::require_permission',
'Permission::require_role',
'response_unauthorized',
'->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);
}
}