feat(audit): harden API audit log redaction and schema
- Change query_json column from TEXT to JSON, ip/user_agent to CHAR(64) for PII redaction compliance - Update repository, service, and views for hardened schema - Add architecture contract test for security logging redaction - Add search resource provider test coverage - Include DB migration script for existing installs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,9 +15,9 @@ final class AuditSearchResourceProvider implements SearchResourceProvider
|
||||
'api-audit' => [
|
||||
'label' => t('API audit'),
|
||||
'permission' => 'audit.api.view',
|
||||
'count_sql' => "select count(*) from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\')",
|
||||
'preview_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc limit ?",
|
||||
'result_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc",
|
||||
'count_sql' => "select count(*) from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\' or user_agent like ? escape '\\\\')",
|
||||
'preview_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\' or user_agent like ? escape '\\\\') order by created_at desc limit ?",
|
||||
'result_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\' or user_agent like ? escape '\\\\') order by created_at desc",
|
||||
],
|
||||
'system-audit' => [
|
||||
'label' => t('System audit'),
|
||||
|
||||
@@ -68,7 +68,13 @@ class ApiAuditLogRepository
|
||||
RepoQuery::addLikeFilter(
|
||||
$where,
|
||||
$params,
|
||||
['api_audit_log.path', 'api_audit_log.request_id', 'api_audit_log.ip', 'api_audit_log.error_code'],
|
||||
[
|
||||
'api_audit_log.path',
|
||||
'api_audit_log.request_id',
|
||||
'api_audit_log.error_code',
|
||||
'api_audit_log.ip',
|
||||
'api_audit_log.user_agent',
|
||||
],
|
||||
$search
|
||||
);
|
||||
|
||||
@@ -331,8 +337,41 @@ class ApiAuditLogRepository
|
||||
$log['user_email'] = (string) ($user['email'] ?? '');
|
||||
$log['tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
|
||||
$log['tenant_description'] = (string) ($tenant['description'] ?? '');
|
||||
$log['ip_hash'] = trim((string) ($log['ip'] ?? ''));
|
||||
$log['user_agent_hash'] = trim((string) ($log['user_agent'] ?? ''));
|
||||
$log['query_keys'] = $this->extractQueryKeys($log['query_json'] ?? null);
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractQueryKeys(mixed $queryJson): array
|
||||
{
|
||||
if (!is_string($queryJson) || trim($queryJson) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($queryJson, true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keysRaw = $decoded['keys'] ?? null;
|
||||
if (!is_array($keysRaw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
foreach ($keysRaw as $value) {
|
||||
$key = trim((string) $value);
|
||||
if ($key === '' || in_array($key, $keys, true)) {
|
||||
continue;
|
||||
}
|
||||
$keys[] = $key;
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,33 @@ use MintyPHP\Module\Audit\Repository\ApiAuditLogRepository;
|
||||
class ApiAuditService
|
||||
{
|
||||
private const RETENTION_DAYS = 90;
|
||||
private const MAX_USER_AGENT_LENGTH = 255;
|
||||
private const MAX_STRING_LENGTH = 500;
|
||||
private const MAX_ARRAY_ITEMS = 30;
|
||||
private const MAX_QUERY_KEYS = 30;
|
||||
private const ALLOWED_QUERY_KEYS = [
|
||||
'active',
|
||||
'status',
|
||||
'method',
|
||||
'order',
|
||||
'dir',
|
||||
'sort',
|
||||
'page',
|
||||
'per_page',
|
||||
'limit',
|
||||
'offset',
|
||||
'cursor',
|
||||
'from',
|
||||
'to',
|
||||
'created_from',
|
||||
'created_to',
|
||||
'tenant_id',
|
||||
'tenant_ids',
|
||||
'user_id',
|
||||
'user_ids',
|
||||
'id',
|
||||
'ids',
|
||||
'request_id',
|
||||
'error_code',
|
||||
'search',
|
||||
];
|
||||
|
||||
private ?array $context = null;
|
||||
|
||||
@@ -51,7 +75,7 @@ class ApiAuditService
|
||||
'request_id' => (string) ($requestContext['request_id'] ?? RequestContext::id()),
|
||||
'method' => (string) ($requestContext['method'] ?? substr($method !== '' ? $method : 'GET', 0, 8)),
|
||||
'path' => (string) ($requestContext['path'] ?? substr($path, 0, 255)),
|
||||
'query_json' => $this->buildRedactedQueryJson($this->requestRuntime->queryParams()),
|
||||
'query_json' => $this->buildQueryMetadataJson($this->requestRuntime->queryParams()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -98,18 +122,10 @@ class ApiAuditService
|
||||
$requestContext = RequestContext::context();
|
||||
|
||||
$ip = trim((string) ($requestContext['ip'] ?? $this->requestRuntime->ip()));
|
||||
if ($ip === '') {
|
||||
$ip = null;
|
||||
} elseif (strlen($ip) > 45) {
|
||||
$ip = substr($ip, 0, 45);
|
||||
}
|
||||
$ipHash = $ip !== '' ? RequestContext::hashValue($ip) : null;
|
||||
|
||||
$userAgent = trim((string) ($requestContext['user_agent'] ?? $this->requestRuntime->userAgent()));
|
||||
if ($userAgent === '') {
|
||||
$userAgent = null;
|
||||
} elseif (strlen($userAgent) > self::MAX_USER_AGENT_LENGTH) {
|
||||
$userAgent = substr($userAgent, 0, self::MAX_USER_AGENT_LENGTH);
|
||||
}
|
||||
$userAgentHash = $userAgent !== '' ? RequestContext::hashValue($userAgent) : null;
|
||||
|
||||
$userId = ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0;
|
||||
$tenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tenantId() ?? 0) : 0;
|
||||
@@ -128,8 +144,8 @@ class ApiAuditService
|
||||
'tenant_id' => $tenantId > 0 ? $tenantId : null,
|
||||
'api_token_id' => $apiTokenId > 0 ? $apiTokenId : null,
|
||||
'token_tenant_id' => $tokenTenantId > 0 ? $tokenTenantId : null,
|
||||
'ip' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
'ip' => $ipHash,
|
||||
'user_agent' => $userAgentHash,
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -159,60 +175,73 @@ class ApiAuditService
|
||||
return $this->apiAuditLogRepository->purgeOlderThanDays(self::RETENTION_DAYS);
|
||||
}
|
||||
|
||||
private function buildRedactedQueryJson(array $query): ?string
|
||||
private function buildQueryMetadataJson(array $query): ?string
|
||||
{
|
||||
if (!$query) {
|
||||
return null;
|
||||
}
|
||||
$redacted = $this->redactArray($query);
|
||||
if ($redacted === []) {
|
||||
|
||||
$keys = $this->extractAllowedQueryKeys($query);
|
||||
if ($keys === []) {
|
||||
return null;
|
||||
}
|
||||
$encoded = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$encoded = json_encode(
|
||||
['keys' => $keys],
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
|
||||
return is_string($encoded) ? $encoded : null;
|
||||
}
|
||||
|
||||
private function redactArray(array $data): array
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractAllowedQueryKeys(array $query): array
|
||||
{
|
||||
$result = [];
|
||||
$i = 0;
|
||||
foreach ($data as $rawKey => $value) {
|
||||
if ($i >= self::MAX_ARRAY_ITEMS) {
|
||||
$keys = [];
|
||||
foreach ($query as $rawKey => $_value) {
|
||||
if (count($keys) >= self::MAX_QUERY_KEYS) {
|
||||
break;
|
||||
}
|
||||
$key = trim((string) $rawKey);
|
||||
if ($key === '') {
|
||||
|
||||
$key = $this->normalizeQueryKey((string) $rawKey);
|
||||
if ($key === '' || $this->isSensitiveKey($key) || !$this->isAllowedQueryKey($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isSensitiveKey($key)) {
|
||||
$result[$key] = '[REDACTED]';
|
||||
} else {
|
||||
$result[$key] = $this->normalizeValue($value);
|
||||
if (!in_array($key, $keys, true)) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
return $result;
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
private function normalizeQueryKey(string $rawKey): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $this->redactArray($value);
|
||||
}
|
||||
|
||||
if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$string = trim((string) $value);
|
||||
if ($string === '') {
|
||||
$key = strtolower(trim($rawKey));
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
if (strlen($string) > self::MAX_STRING_LENGTH) {
|
||||
return substr($string, 0, self::MAX_STRING_LENGTH);
|
||||
|
||||
$bracketPos = strpos($key, '[');
|
||||
if ($bracketPos !== false) {
|
||||
$key = substr($key, 0, $bracketPos);
|
||||
}
|
||||
return $string;
|
||||
|
||||
$dotPos = strpos($key, '.');
|
||||
if ($dotPos !== false) {
|
||||
$key = substr($key, 0, $dotPos);
|
||||
}
|
||||
|
||||
return trim($key);
|
||||
}
|
||||
|
||||
private function isAllowedQueryKey(string $key): bool
|
||||
{
|
||||
return in_array($key, self::ALLOWED_QUERY_KEYS, true);
|
||||
}
|
||||
|
||||
private function isSensitiveKey(string $key): bool
|
||||
@@ -233,6 +262,9 @@ class ApiAuditService
|
||||
'api_key',
|
||||
'client_secret',
|
||||
'key',
|
||||
'email',
|
||||
'to_email',
|
||||
'uuid',
|
||||
], true)) {
|
||||
return true;
|
||||
}
|
||||
@@ -241,6 +273,8 @@ class ApiAuditService
|
||||
|| str_contains($key, 'secret')
|
||||
|| str_contains($key, 'password')
|
||||
|| str_contains($key, 'authorization')
|
||||
|| str_contains($key, 'email')
|
||||
|| str_contains($key, 'uuid')
|
||||
|| str_ends_with($key, '_key');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user