[ 'label' => 'Failed login attempts', 'description' => 'Observe repeated failed sign-in attempts for the same principal.', 'threshold_count' => 5, 'window_seconds' => 900, 'subject_type' => 'login_principal', ], 'bookings_created' => [ 'label' => 'Booking creations', 'description' => 'Observe high booking creation volume for a customer.', 'threshold_count' => 25, 'window_seconds' => 86400, 'subject_type' => 'customer', ], 'vehicles_created' => [ 'label' => 'Vehicle creations', 'description' => 'Observe high vehicle creation volume for a customer.', 'threshold_count' => 20, 'window_seconds' => 86400, 'subject_type' => 'customer', ], 'requests_per_ip' => [ 'label' => 'Requests per IP', 'description' => 'Observe high API request volume from one source IP.', 'threshold_count' => 300, 'window_seconds' => 60, 'subject_type' => 'ip', ], 'requests_per_customer' => [ 'label' => 'Requests per customer', 'description' => 'Observe high API request volume for one customer context.', 'threshold_count' => 600, 'window_seconds' => 60, 'subject_type' => 'customer', ], ]; public function summary(): array { $this->ensureReady(); return [ 'settings' => $this->settings(), 'firewall' => [ 'active_rules' => $this->countRows('security_firewall_rules', "enabled = 1 AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())"), 'block_rules' => $this->countRows('security_firewall_rules', "action = 'block' AND enabled = 1 AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())"), 'watch_rules' => $this->countRows('security_firewall_rules', "action = 'watch' AND enabled = 1 AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())"), ], 'incidents' => [ 'open' => $this->countRows('security_incidents', "status = 'open'"), 'acknowledged' => $this->countRows('security_incidents', "status = 'acknowledged'"), 'resolved' => $this->countRows('security_incidents', "status = 'resolved'"), 'recent' => $this->listIncidents(['limit' => 10])['incidents'], ], ]; } public function settings(): array { $this->ensureReady(); global $db; $rows = $db->fetch_all($db->query('SELECT * FROM security_policy_rules ORDER BY rule_key ASC')); $byKey = []; foreach ($rows as $row) { $byKey[(string)$row['rule_key']] = $this->publicPolicyRule($row); } $rules = []; foreach (self::DEFAULT_RULES as $key => $descriptor) { $rules[] = [ ...$descriptor, ...($byKey[$key] ?? []), 'rule_key' => $key, 'mode' => 'observe', ]; } return [ 'mode' => 'observe', 'rules' => $rules, 'available_rule_keys' => array_keys(self::DEFAULT_RULES), 'exemption_permission' => 'superuser_security_limits_exempt', ]; } public function updateSettings(array $payload, ?int $actorUserId = null): array { $this->ensureReady(); $rules = $payload['rules'] ?? null; if (!is_array($rules)) { throw new \InvalidArgumentException('Missing settings rules.'); } foreach ($rules as $rule) { if (!is_array($rule)) { throw new \InvalidArgumentException('Invalid policy rule payload.'); } $this->upsertPolicyRule($rule, $actorUserId); } $this->recordSecurityChange('SECURITY_SETTINGS_UPDATED', 'Updated security observe settings.', $actorUserId); return $this->settings(); } public function listFirewallRules(array $filters = []): array { $this->ensureReady(); global $db; $where = ['deleted_at IS NULL']; if (isset($filters['enabled']) && $filters['enabled'] !== '') { $where[] = 'enabled = ' . ($this->toBool($filters['enabled'], false) ? '1' : '0'); } if (!empty($filters['action'])) { $where[] = "action = '" . $db->escape_string((string)$filters['action']) . "'"; } if (!empty($filters['target_type'])) { $where[] = "target_type = '" . $db->escape_string((string)$filters['target_type']) . "'"; } if (!empty($filters['search'])) { $search = $db->escape_string((string)$filters['search']); $where[] = "(target_value LIKE '%{$search}%' OR route_pattern LIKE '%{$search}%' OR reason LIKE '%{$search}%')"; } $limit = $this->clampLimit($filters['limit'] ?? 100, 1, 500); $result = $db->query( 'SELECT * FROM security_firewall_rules WHERE ' . implode(' AND ', $where) . ' ORDER BY priority ASC, id DESC LIMIT ' . $limit ); return [ 'rules' => array_map(fn(array $row): array => $this->publicFirewallRule($row), $db->fetch_all($result)), ]; } public function createFirewallRule(array $payload, ?int $actorUserId = null): array { $this->ensureReady(); global $db; $rule = $this->normalizeFirewallPayload($payload); $metadata = $this->jsonEncode($rule['metadata'] ?? []); $expiresAt = $this->nullableDateSql($rule['expires_at']); $routePattern = $this->nullableStringSql($rule['route_pattern']); $reason = $this->nullableStringSql($rule['reason']); $actor = $actorUserId === null ? 'NULL' : (string)$actorUserId; $db->query( "INSERT INTO security_firewall_rules (action, target_type, target_value, route_pattern, priority, reason, enabled, expires_at, metadata_json, created_by) VALUES ( '" . $db->escape_string($rule['action']) . "', '" . $db->escape_string($rule['target_type']) . "', '" . $db->escape_string($rule['target_value']) . "', {$routePattern}, " . (int)$rule['priority'] . ", {$reason}, " . ($rule['enabled'] ? 1 : 0) . ", {$expiresAt}, '" . $db->escape_string($metadata) . "', {$actor} )" ); $created = $this->getFirewallRule((int)$db->insert_id()); $this->recordSecurityChange('SECURITY_FIREWALL_RULE_CREATED', 'Created firewall rule #' . $created['id'], $actorUserId); return $created; } public function updateFirewallRule(int $id, array $payload, ?int $actorUserId = null): array { $this->ensureReady(); $existing = $this->getFirewallRule($id); global $db; $rule = $this->normalizeFirewallPayload([...$existing, ...$payload], true); $metadata = $this->jsonEncode($rule['metadata'] ?? []); $db->query( "UPDATE security_firewall_rules SET action = '" . $db->escape_string($rule['action']) . "', target_type = '" . $db->escape_string($rule['target_type']) . "', target_value = '" . $db->escape_string($rule['target_value']) . "', route_pattern = " . $this->nullableStringSql($rule['route_pattern']) . ", priority = " . (int)$rule['priority'] . ", reason = " . $this->nullableStringSql($rule['reason']) . ", enabled = " . ($rule['enabled'] ? 1 : 0) . ", expires_at = " . $this->nullableDateSql($rule['expires_at']) . ", metadata_json = '" . $db->escape_string($metadata) . "' WHERE id = " . (int)$id . " AND deleted_at IS NULL" ); $updated = $this->getFirewallRule($id); $this->recordSecurityChange('SECURITY_FIREWALL_RULE_UPDATED', 'Updated firewall rule #' . $id, $actorUserId); return $updated; } public function deleteFirewallRule(int $id, ?int $actorUserId = null): array { $this->ensureReady(); $this->getFirewallRule($id); global $db; $db->query('UPDATE security_firewall_rules SET deleted_at = NOW(), enabled = 0 WHERE id = ' . (int)$id); $this->recordSecurityChange('SECURITY_FIREWALL_RULE_DELETED', 'Deleted firewall rule #' . $id, $actorUserId); return ['deleted' => true, 'id' => $id]; } public function getFirewallRule(int $id): array { $this->ensureReady(); global $db; $result = $db->query('SELECT * FROM security_firewall_rules WHERE id = ' . (int)$id . ' AND deleted_at IS NULL LIMIT 1'); $row = $result ? $result->fetch_assoc() : null; if (!is_array($row)) { throw new \RuntimeException('Firewall rule not found.'); } return $this->publicFirewallRule($row); } public function listIncidents(array $filters = []): array { $this->ensureReady(); global $db; $where = ['1 = 1']; foreach (['status', 'type', 'severity'] as $field) { if (!empty($filters[$field])) { $where[] = $field . " = '" . $db->escape_string((string)$filters[$field]) . "'"; } } if (!empty($filters['search'])) { $search = $db->escape_string((string)$filters['search']); $where[] = "(title LIKE '%{$search}%' OR source_ip LIKE '%{$search}%' OR route_path LIKE '%{$search}%')"; } $limit = $this->clampLimit($filters['limit'] ?? 50, 1, 200); $result = $db->query( 'SELECT * FROM security_incidents WHERE ' . implode(' AND ', $where) . ' ORDER BY FIELD(status, "open", "acknowledged", "resolved", "false_positive"), last_seen_at DESC LIMIT ' . $limit ); return [ 'incidents' => array_map(fn(array $row): array => $this->publicIncident($row), $db->fetch_all($result)), ]; } public function incidentDetail(int $id): array { $this->ensureReady(); $incident = $this->getIncident($id); $incident['notes'] = $this->incidentNotes($id); return $incident; } public function updateIncident(int $id, array $payload, ?int $actorUserId = null): array { $this->ensureReady(); $this->getIncident($id); $status = trim((string)($payload['status'] ?? '')); if (!in_array($status, self::INCIDENT_STATUSES, true)) { throw new \InvalidArgumentException('Invalid incident status.'); } global $db; $resolvedBy = in_array($status, ['resolved', 'false_positive'], true) && $actorUserId !== null ? (string)$actorUserId : 'NULL'; $resolvedAt = in_array($status, ['resolved', 'false_positive'], true) ? 'NOW()' : 'NULL'; $db->query( "UPDATE security_incidents SET status = '" . $db->escape_string($status) . "', resolved_by = {$resolvedBy}, resolved_at = {$resolvedAt} WHERE id = " . (int)$id ); $this->recordSecurityChange('SECURITY_INCIDENT_UPDATED', 'Updated security incident #' . $id . ' to ' . $status, $actorUserId); return $this->incidentDetail($id); } public function addIncidentNote(int $id, string $note, ?int $actorUserId = null): array { $this->ensureReady(); $this->getIncident($id); $note = trim($note); if ($note === '') { throw new \InvalidArgumentException('Incident note cannot be empty.'); } if (strlen($note) > 4000) { throw new \InvalidArgumentException('Incident note is too long.'); } global $db; $actor = $actorUserId === null ? 'NULL' : (string)$actorUserId; $db->query( "INSERT INTO security_incident_notes (incident_id, note, created_by) VALUES (" . (int)$id . ", '" . $db->escape_string($note) . "', {$actor})" ); $this->recordSecurityChange('SECURITY_INCIDENT_NOTE_CREATED', 'Added note to security incident #' . $id, $actorUserId); return $this->incidentDetail($id); } public function inspectRequest(string $routeTemplate, string $method): void { $this->ensureReady(); $context = $this->requestContext($routeTemplate, $method); $this->inspectFirewall($context); $this->observePolicyEvent('requests_per_ip', 'ip', (string)$context['source_ip'], $context); if (!empty($context['customer_number'])) { $this->observePolicyEvent( 'requests_per_customer', 'customer', (string)$context['customer_number'], $context ); } } public function observeLoginFailure(string $principalType, string|int $identifier, array $metadata = []): void { $this->safeObservePolicyEvent( 'failed_login_attempts', 'login_principal', $principalType . ':' . (string)$identifier, $this->requestContext('/auth/login', 'POST', $metadata) ); } public function observeBookingCreated(int $customerNumber, array $metadata = []): void { $this->safeObservePolicyEvent( 'bookings_created', 'customer', (string)$customerNumber, $this->requestContext('/order-bookings', 'POST', ['customer_number' => $customerNumber, ...$metadata]) ); } public function observeVehicleCreated(int $customerNumber, array $metadata = []): void { $this->safeObservePolicyEvent( 'vehicles_created', 'customer', (string)$customerNumber, $this->requestContext('/vehicles', 'POST', ['customer_number' => $customerNumber, ...$metadata]) ); } public function recordSecurityChange(string $action, string $message, ?int $actorUserId = null): void { try { (new logs_o())->add('security', 'global', 1, $actorUserId ?? 0, $action, $message); $this->upsertIncident([ 'incident_key' => 'security_change:' . sha1($action . ':' . $message . ':' . date('Y-m-d H:i')), 'type' => 'security_change', 'severity' => 'low', 'title' => $message, 'user_id' => $actorUserId, 'metadata' => ['action' => $action], ]); } catch (Throwable) { // Audit logging must not block the requested mutation. } } private function ensureReady(): void { security_schema_bootstrap::ensureTables(); $this->seedDefaultPolicyRules(); } private function seedDefaultPolicyRules(): void { static $seeded = false; if ($seeded) { return; } global $db; foreach (self::DEFAULT_RULES as $key => $rule) { $db->query( "INSERT IGNORE INTO security_policy_rules (rule_key, enabled, threshold_count, window_seconds, mode, exempt_permission_nodes_json) VALUES ( '" . $db->escape_string($key) . "', 1, " . (int)$rule['threshold_count'] . ", " . (int)$rule['window_seconds'] . ", 'observe', '[]' )" ); } $seeded = true; } private function upsertPolicyRule(array $input, ?int $actorUserId): void { global $db; $key = trim((string)($input['rule_key'] ?? '')); if (!isset(self::DEFAULT_RULES[$key])) { throw new \InvalidArgumentException('Unknown security policy rule: ' . $key); } $enabled = $this->toBool($input['enabled'] ?? true, true); $threshold = (int)($input['threshold_count'] ?? $input['threshold'] ?? 0); $window = (int)($input['window_seconds'] ?? 0); if ($threshold < 1 || $threshold > 1000000) { throw new \InvalidArgumentException('Invalid threshold for ' . $key . '.'); } if ($window < 30 || $window > 2678400) { throw new \InvalidArgumentException('Invalid window for ' . $key . '.'); } $exemptions = $this->normalizePermissionList($input['exempt_permission_nodes'] ?? []); $actor = $actorUserId === null ? 'NULL' : (string)$actorUserId; $json = $db->escape_string($this->jsonEncode($exemptions)); $keySql = $db->escape_string($key); $db->query( "INSERT INTO security_policy_rules (rule_key, enabled, threshold_count, window_seconds, mode, exempt_permission_nodes_json, updated_by) VALUES ('{$keySql}', " . ($enabled ? 1 : 0) . ", {$threshold}, {$window}, 'observe', '{$json}', {$actor}) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), threshold_count = VALUES(threshold_count), window_seconds = VALUES(window_seconds), mode = 'observe', exempt_permission_nodes_json = VALUES(exempt_permission_nodes_json), updated_by = VALUES(updated_by)" ); } private function inspectFirewall(array $context): void { global $response; $matches = []; foreach ($this->activeFirewallRules() as $rule) { if ($this->firewallRuleMatches($rule, $context)) { $matches[] = $rule; } } if ($matches === []) { return; } foreach ($matches as $rule) { if ($rule['action'] === 'allow') { return; } } foreach ($matches as $rule) { if ($rule['action'] === 'watch') { $this->createFirewallIncident($rule, $context); } } foreach ($matches as $rule) { if ($rule['action'] === 'block') { $this->createFirewallIncident($rule, $context); $response->error([ 'message' => 'Request blocked by security firewall.', 'firewall_rule_id' => (int)$rule['id'], ], 403); } } } private function activeFirewallRules(): array { global $db; $result = $db->query( "SELECT * FROM security_firewall_rules WHERE enabled = 1 AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY priority ASC, id ASC" ); return $db->fetch_all($result); } private function firewallRuleMatches(array $rule, array $context): bool { $routePattern = trim((string)($rule['route_pattern'] ?? '')); if ($routePattern !== '' && !$this->routeMatches($routePattern, (string)$context['route_path'], (string)$context['route_template'])) { return false; } $target = trim((string)$rule['target_value']); return match ((string)$rule['target_type']) { 'ip' => $target === (string)$context['source_ip'], 'cidr' => $this->ipInCidr((string)$context['source_ip'], $target), 'customer' => $target !== '' && $target === (string)($context['customer_number'] ?? ''), 'user' => $target !== '' && $target === (string)($context['user_id'] ?? ''), 'route' => $this->routeMatches($target, (string)$context['route_path'], (string)$context['route_template']), default => false, }; } private function observePolicyEvent(string $ruleKey, string $subjectType, string $subjectKey, array $context): void { $rule = $this->policyRule($ruleKey); if (!$rule || !(bool)$rule['enabled']) { return; } if ($this->isExempt($rule, $context)) { return; } global $db; $metadata = $this->jsonEncode($context['metadata'] ?? []); $db->query( "INSERT INTO security_policy_events (rule_key, subject_type, subject_key, route_path, route_template, method, source_ip, customer_number, user_id, subuser_id, metadata_json) VALUES ( '" . $db->escape_string($ruleKey) . "', '" . $db->escape_string($subjectType) . "', '" . $db->escape_string($subjectKey) . "', " . $this->nullableStringSql($context['route_path'] ?? null) . ", " . $this->nullableStringSql($context['route_template'] ?? null) . ", " . $this->nullableStringSql($context['method'] ?? null) . ", " . $this->nullableStringSql($context['source_ip'] ?? null) . ", " . $this->nullableIntSql($context['customer_number'] ?? null) . ", " . $this->nullableIntSql($context['user_id'] ?? null) . ", " . $this->nullableIntSql($context['subuser_id'] ?? null) . ", '" . $db->escape_string($metadata) . "' )" ); $count = $this->countPolicyEvents($ruleKey, $subjectType, $subjectKey, (int)$rule['window_seconds']); if ($count >= (int)$rule['threshold_count']) { $this->upsertIncident([ 'incident_key' => 'policy:' . $ruleKey . ':' . $subjectType . ':' . sha1($subjectKey), 'type' => 'policy_threshold', 'severity' => $this->severityForPolicyCount($count, (int)$rule['threshold_count']), 'title' => $this->policyIncidentTitle($ruleKey, $subjectKey, $count, (int)$rule['window_seconds']), 'source_ip' => $context['source_ip'] ?? null, 'customer_number' => $context['customer_number'] ?? null, 'user_id' => $context['user_id'] ?? null, 'subuser_id' => $context['subuser_id'] ?? null, 'route_path' => $context['route_path'] ?? null, 'route_template' => $context['route_template'] ?? null, 'method' => $context['method'] ?? null, 'related_rule_id' => (int)$rule['id'], 'metadata' => [ 'rule_key' => $ruleKey, 'subject_type' => $subjectType, 'subject_key' => $subjectKey, 'count' => $count, 'threshold_count' => (int)$rule['threshold_count'], 'window_seconds' => (int)$rule['window_seconds'], 'mode' => 'observe', ], ]); } } private function safeObservePolicyEvent(string $ruleKey, string $subjectType, string $subjectKey, array $context): void { try { $this->ensureReady(); $this->observePolicyEvent($ruleKey, $subjectType, $subjectKey, $context); } catch (Throwable $throwable) { error_log('[security-policy] observe failed: ' . $throwable->getMessage()); } } private function policyRule(string $ruleKey): ?array { global $db; $result = $db->query( "SELECT * FROM security_policy_rules WHERE rule_key = '" . $db->escape_string($ruleKey) . "' LIMIT 1" ); $row = $result ? $result->fetch_assoc() : null; return is_array($row) ? $row : null; } private function countPolicyEvents(string $ruleKey, string $subjectType, string $subjectKey, int $windowSeconds): int { global $db; $cutoff = date('Y-m-d H:i:s', time() - max(30, $windowSeconds)); $result = $db->query( "SELECT COUNT(*) AS count FROM security_policy_events WHERE rule_key = '" . $db->escape_string($ruleKey) . "' AND subject_type = '" . $db->escape_string($subjectType) . "' AND subject_key = '" . $db->escape_string($subjectKey) . "' AND created_at >= '" . $db->escape_string($cutoff) . "'" ); $row = $result ? $result->fetch_assoc() : ['count' => 0]; return (int)($row['count'] ?? 0); } private function isExempt(array $rule, array $context): bool { $exemptions = $this->decodeJsonArray($rule['exempt_permission_nodes_json'] ?? '[]'); if ($exemptions === []) { return false; } try { $auth = new authentication(); $subuser = $auth->get_subuser(); if ($subuser !== false) { $customerNumber = isset($context['customer_number']) ? (int)$context['customer_number'] : null; foreach ($exemptions as $permission) { $node = subusers_permission_node_key::tryFrom((string)$permission); if ($node !== null && $customerNumber !== null && $subuser->hasPermission($node, $customerNumber)) { return true; } } } $user = $auth->get_user(); if ($user !== false) { foreach ($exemptions as $permission) { if ($user->hasPermission((string)$permission)) { return true; } } } } catch (Throwable) { return false; } return false; } private function createFirewallIncident(array $rule, array $context): void { $this->upsertIncident([ 'incident_key' => 'firewall:' . (int)$rule['id'] . ':' . sha1((string)$context['source_ip'] . ':' . (string)($context['customer_number'] ?? '') . ':' . (string)$context['route_path']), 'type' => 'firewall_' . (string)$rule['action'], 'severity' => $rule['action'] === 'block' ? 'high' : 'medium', 'title' => ucfirst((string)$rule['action']) . ' firewall rule matched: ' . (string)$rule['target_type'] . ' ' . (string)$rule['target_value'], 'source_ip' => $context['source_ip'] ?? null, 'customer_number' => $context['customer_number'] ?? null, 'user_id' => $context['user_id'] ?? null, 'subuser_id' => $context['subuser_id'] ?? null, 'route_path' => $context['route_path'] ?? null, 'route_template' => $context['route_template'] ?? null, 'method' => $context['method'] ?? null, 'related_firewall_rule_id' => (int)$rule['id'], 'metadata' => [ 'action' => $rule['action'], 'target_type' => $rule['target_type'], 'target_value' => $rule['target_value'], 'reason' => $rule['reason'] ?? null, ], ]); } private function upsertIncident(array $incident): void { global $db; $metadata = $this->jsonEncode($incident['metadata'] ?? []); $key = $db->escape_string((string)$incident['incident_key']); $type = $db->escape_string((string)$incident['type']); $severity = in_array(($incident['severity'] ?? 'medium'), self::INCIDENT_SEVERITIES, true) ? (string)$incident['severity'] : 'medium'; $title = $db->escape_string(substr((string)$incident['title'], 0, 255)); $db->query( "INSERT INTO security_incidents (incident_key, type, severity, status, title, source_ip, customer_number, user_id, subuser_id, route_path, route_template, method, related_rule_id, related_firewall_rule_id, metadata_json) VALUES ( '{$key}', '{$type}', '" . $db->escape_string($severity) . "', 'open', '{$title}', " . $this->nullableStringSql($incident['source_ip'] ?? null) . ", " . $this->nullableIntSql($incident['customer_number'] ?? null) . ", " . $this->nullableIntSql($incident['user_id'] ?? null) . ", " . $this->nullableIntSql($incident['subuser_id'] ?? null) . ", " . $this->nullableStringSql($incident['route_path'] ?? null) . ", " . $this->nullableStringSql($incident['route_template'] ?? null) . ", " . $this->nullableStringSql($incident['method'] ?? null) . ", " . $this->nullableIntSql($incident['related_rule_id'] ?? null) . ", " . $this->nullableIntSql($incident['related_firewall_rule_id'] ?? null) . ", '" . $db->escape_string($metadata) . "' ) ON DUPLICATE KEY UPDATE severity = VALUES(severity), title = VALUES(title), status = IF(status IN ('resolved', 'false_positive'), status, status), occurrence_count = occurrence_count + 1, last_seen_at = NOW(), metadata_json = VALUES(metadata_json)" ); } private function requestContext(string $routeTemplate, string $method, array $metadata = []): array { $path = explode('?', $_SERVER['REQUEST_URI'] ?? $routeTemplate)[0]; $context = [ 'route_template' => $routeTemplate, 'route_path' => $path, 'method' => strtoupper($method), 'source_ip' => $this->sourceIp(), 'customer_number' => $metadata['customer_number'] ?? null, 'user_id' => null, 'subuser_id' => null, 'metadata' => $metadata, ]; try { $auth = new authentication(); $subuser = $auth->get_subuser(); if ($subuser !== false) { $context['subuser_id'] = (int)$subuser->id; $target = $auth->get_subuser_customer_number_target(); if ($target !== false && $target !== null) { $context['customer_number'] = (int)$target; } } $user = $auth->get_user(); if ($user !== false) { $context['user_id'] = (int)$user->id; if ($context['customer_number'] === null && isset($user->customer_number)) { $context['customer_number'] = (int)$user->customer_number->value(); } } } catch (Throwable) { // Missing or invalid auth should not stop observation. } return $context; } private function sourceIp(): string { $forwarded = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')); if ($forwarded !== '') { $parts = array_map('trim', explode(',', $forwarded)); if (($parts[0] ?? '') !== '') { return substr($parts[0], 0, 64); } } return substr((string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'), 0, 64); } private function routeMatches(string $pattern, string $routePath, string $routeTemplate): bool { $pattern = trim($pattern); if ($pattern === '') { return false; } if ($pattern === $routePath || $pattern === $routeTemplate) { return true; } if (str_ends_with($pattern, '*')) { $prefix = rtrim(substr($pattern, 0, -1), '*'); return str_starts_with($routePath, $prefix) || str_starts_with($routeTemplate, $prefix); } return false; } private function ipInCidr(string $ip, string $cidr): bool { if (!str_contains($cidr, '/')) { return false; } [$network, $bits] = explode('/', $cidr, 2); $ipLong = ip2long($ip); $networkLong = ip2long($network); $bitsInt = (int)$bits; if ($ipLong === false || $networkLong === false || $bitsInt < 0 || $bitsInt > 32) { return false; } $mask = -1 << (32 - $bitsInt); return ($ipLong & $mask) === ($networkLong & $mask); } private function normalizeFirewallPayload(array $payload, bool $partial = false): array { $action = trim((string)($payload['action'] ?? '')); $targetType = trim((string)($payload['target_type'] ?? '')); $targetValue = trim((string)($payload['target_value'] ?? '')); if (!in_array($action, self::FIREWALL_ACTIONS, true)) { throw new \InvalidArgumentException('Invalid firewall action.'); } if (!in_array($targetType, self::FIREWALL_TARGET_TYPES, true)) { throw new \InvalidArgumentException('Invalid firewall target type.'); } if ($targetValue === '') { throw new \InvalidArgumentException('Firewall target value is required.'); } if ($targetType === 'ip' && filter_var($targetValue, FILTER_VALIDATE_IP) === false) { throw new \InvalidArgumentException('Invalid firewall IP target.'); } if ($targetType === 'cidr' && !$this->isValidCidr($targetValue)) { throw new \InvalidArgumentException('Invalid firewall CIDR target.'); } return [ 'action' => $action, 'target_type' => $targetType, 'target_value' => $targetValue, 'route_pattern' => trim((string)($payload['route_pattern'] ?? '')) ?: null, 'priority' => max(1, min(10000, (int)($payload['priority'] ?? 100))), 'reason' => trim((string)($payload['reason'] ?? '')) ?: null, 'enabled' => $this->toBool($payload['enabled'] ?? true, true), 'expires_at' => $this->normalizeDate($payload['expires_at'] ?? null), 'metadata' => is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [], ]; } private function isValidCidr(string $cidr): bool { if (!str_contains($cidr, '/')) { return false; } [$ip, $bits] = explode('/', $cidr, 2); return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && ctype_digit($bits) && (int)$bits >= 0 && (int)$bits <= 32; } private function getIncident(int $id): array { global $db; $result = $db->query('SELECT * FROM security_incidents WHERE id = ' . (int)$id . ' LIMIT 1'); $row = $result ? $result->fetch_assoc() : null; if (!is_array($row)) { throw new \RuntimeException('Security incident not found.'); } return $this->publicIncident($row); } private function incidentNotes(int $incidentId): array { global $db; $result = $db->query( 'SELECT * FROM security_incident_notes WHERE incident_id = ' . (int)$incidentId . ' ORDER BY created_at ASC, id ASC' ); return array_map(static fn(array $row): array => [ 'id' => (int)$row['id'], 'incident_id' => (int)$row['incident_id'], 'note' => (string)$row['note'], 'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null, 'created_at' => $row['created_at'] ?? null, ], $db->fetch_all($result)); } private function publicPolicyRule(array $row): array { return [ 'id' => (int)$row['id'], 'rule_key' => (string)$row['rule_key'], 'enabled' => (bool)$row['enabled'], 'threshold_count' => (int)$row['threshold_count'], 'window_seconds' => (int)$row['window_seconds'], 'mode' => 'observe', 'exempt_permission_nodes' => $this->decodeJsonArray($row['exempt_permission_nodes_json'] ?? '[]'), 'updated_by' => isset($row['updated_by']) ? (int)$row['updated_by'] : null, 'updated_at' => $row['updated_at'] ?? null, ]; } private function publicFirewallRule(array $row): array { return [ 'id' => (int)$row['id'], 'action' => (string)$row['action'], 'target_type' => (string)$row['target_type'], 'target_value' => (string)$row['target_value'], 'route_pattern' => $row['route_pattern'] ?? null, 'priority' => (int)$row['priority'], 'reason' => $row['reason'] ?? null, 'enabled' => (bool)$row['enabled'], 'expires_at' => $row['expires_at'] ?? null, 'metadata' => $this->decodeJsonArray($row['metadata_json'] ?? '{}'), 'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null, 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } private function publicIncident(array $row): array { return [ 'id' => (int)$row['id'], 'incident_key' => (string)$row['incident_key'], 'type' => (string)$row['type'], 'severity' => (string)$row['severity'], 'status' => (string)$row['status'], 'title' => (string)$row['title'], 'source_ip' => $row['source_ip'] ?? null, 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null, 'subuser_id' => isset($row['subuser_id']) ? (int)$row['subuser_id'] : null, 'route_path' => $row['route_path'] ?? null, 'route_template' => $row['route_template'] ?? null, 'method' => $row['method'] ?? null, 'related_rule_id' => isset($row['related_rule_id']) ? (int)$row['related_rule_id'] : null, 'related_firewall_rule_id' => isset($row['related_firewall_rule_id']) ? (int)$row['related_firewall_rule_id'] : null, 'occurrence_count' => (int)$row['occurrence_count'], 'metadata' => $this->decodeJsonArray($row['metadata_json'] ?? '{}'), 'first_seen_at' => $row['first_seen_at'] ?? null, 'last_seen_at' => $row['last_seen_at'] ?? null, 'resolved_by' => isset($row['resolved_by']) ? (int)$row['resolved_by'] : null, 'resolved_at' => $row['resolved_at'] ?? null, 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } private function countRows(string $table, string $where): int { global $db; $result = $db->query("SELECT COUNT(*) AS count FROM {$table} WHERE {$where}"); $row = $result ? $result->fetch_assoc() : ['count' => 0]; return (int)($row['count'] ?? 0); } private function normalizePermissionList(mixed $value): array { if (!is_array($value)) { return []; } $permissions = []; foreach ($value as $permission) { $permission = trim((string)$permission); if ($permission !== '' && preg_match('/^[A-Za-z0-9_:-]+$/', $permission)) { $permissions[] = $permission; } } sort($permissions); return array_values(array_unique($permissions)); } private function clampLimit(mixed $value, int $min, int $max): int { return max($min, min($max, (int)$value)); } private function toBool(mixed $value, bool $default): bool { if (is_bool($value)) { return $value; } if ($value === null) { return $default; } $normalized = strtolower(trim((string)$value)); if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { return true; } if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { return false; } return $default; } private function normalizeDate(mixed $value): ?string { $value = trim((string)($value ?? '')); if ($value === '') { return null; } $timestamp = strtotime($value); if ($timestamp === false) { throw new \InvalidArgumentException('Invalid date value.'); } return date('Y-m-d H:i:s', $timestamp); } private function nullableStringSql(mixed $value): string { global $db; if ($value === null || $value === '') { return 'NULL'; } return "'" . $db->escape_string(substr((string)$value, 0, 1024)) . "'"; } private function nullableDateSql(mixed $value): string { return $this->nullableStringSql($value); } private function nullableIntSql(mixed $value): string { if ($value === null || $value === '') { return 'NULL'; } return (string)(int)$value; } private function jsonEncode(mixed $value): string { $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return is_string($json) ? $json : '{}'; } private function decodeJsonArray(mixed $value): array { $decoded = json_decode((string)$value, true); return is_array($decoded) ? $decoded : []; } private function severityForPolicyCount(int $count, int $threshold): string { if ($count >= $threshold * 4) { return 'critical'; } if ($count >= $threshold * 2) { return 'high'; } return 'medium'; } private function policyIncidentTitle(string $ruleKey, string $subjectKey, int $count, int $windowSeconds): string { $label = self::DEFAULT_RULES[$ruleKey]['label'] ?? $ruleKey; return $label . ' threshold reached for ' . $subjectKey . ' (' . $count . ' in ' . $windowSeconds . 's)'; } }