Files
api/services/nginx/app/classes/security_policy_service.php
T

1056 lines
42 KiB
PHP

<?php
namespace classes;
use modules\subusers\helpers\subusers_permission_node_key;
use objects\logs_o;
use Throwable;
require_once __DIR__ . '/security_schema_bootstrap.php';
class security_policy_service
{
private const FIREWALL_ACTIONS = ['allow', 'watch', 'block'];
private const FIREWALL_TARGET_TYPES = ['ip', 'cidr', 'customer', 'user', 'route'];
private const INCIDENT_STATUSES = ['open', 'acknowledged', 'resolved', 'false_positive'];
private const INCIDENT_SEVERITIES = ['low', 'medium', 'high', 'critical'];
private const DEFAULT_RULES = [
'failed_login_attempts' => [
'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)';
}
}