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>
317 lines
10 KiB
PHP
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);
|
|
}
|
|
}
|