1056 lines
42 KiB
PHP
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)';
|
|
}
|
|
}
|