*/ private const FORBIDDEN_KEY_SUBSTRINGS = [ 'access_token', 'refresh_token', 'client_secret', 'authorization', 'password', 'cookie', 'set-cookie', ]; /** * @return array */ public static function sanitizeReport(array $report): array { $sanitized = []; $schemaVersion = self::sanitizeShortString($report['schema_version'] ?? null, fallback: null); if ($schemaVersion !== null) { $sanitized['schema_version'] = $schemaVersion; } $flow = self::sanitizeShortString($report['flow'] ?? null, fallback: null); if ($flow !== null) { $sanitized['flow'] = $flow; } $generatedAt = self::sanitizeShortString($report['generated_at'] ?? null, fallback: null); if ($generatedAt !== null) { $sanitized['generated_at'] = $generatedAt; } if (is_array($report['identity'] ?? null)) { $identity = self::sanitizeIdentity((array) $report['identity']); if ($identity !== []) { $sanitized['identity'] = $identity; } } $summary = is_array($report['summary'] ?? null) ? (array) $report['summary'] : []; $summary = self::sanitizeSummary($summary); if ($summary !== null) { $sanitized['summary'] = $summary; } $checks = is_array($report['checks'] ?? null) ? (array) $report['checks'] : []; $checks = self::sanitizeChecks($checks); if ($checks !== null) { $sanitized['checks'] = $checks; } return $sanitized; } /** * @param array $identity * @return array */ private static function sanitizeIdentity(array $identity): array { $sanitized = []; foreach ($identity as $key => $value) { if (! is_string($key) || trim($key) === '') { continue; } if (self::containsForbiddenKeySubstring($key)) { continue; } if (is_int($value)) { $sanitized[$key] = $value; continue; } if (! is_string($value)) { continue; } $value = self::sanitizeValueString($value); if ($value !== null) { $sanitized[$key] = $value; } } return $sanitized; } /** * @param array $summary * @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null */ private static function sanitizeSummary(array $summary): ?array { $overall = $summary['overall'] ?? null; if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) { return null; } $counts = is_array($summary['counts'] ?? null) ? (array) $summary['counts'] : []; foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) { if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) { return null; } } return [ 'overall' => $overall, 'counts' => [ 'total' => $counts['total'], 'pass' => $counts['pass'], 'fail' => $counts['fail'], 'warn' => $counts['warn'], 'skip' => $counts['skip'], 'running' => $counts['running'], ], ]; } /** * @param array $checks * @return array>|null */ private static function sanitizeChecks(array $checks): ?array { if ($checks === []) { return []; } $sanitized = []; foreach ($checks as $check) { if (! is_array($check)) { continue; } $key = self::sanitizeShortString($check['key'] ?? null, fallback: null); $title = self::sanitizeShortString($check['title'] ?? null, fallback: null); $reasonCode = self::sanitizeShortString($check['reason_code'] ?? null, fallback: null); if ($key === null || $title === null || $reasonCode === null) { continue; } $status = $check['status'] ?? null; if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) { continue; } $severity = $check['severity'] ?? null; if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) { continue; } $messageRaw = $check['message'] ?? null; if (! is_string($messageRaw) || trim($messageRaw) === '') { continue; } $blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false; $sanitized[] = [ 'key' => $key, 'title' => $title, 'status' => $status, 'severity' => $severity, 'blocking' => $blocking, 'reason_code' => $reasonCode, 'message' => self::sanitizeMessage($messageRaw), 'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []), 'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []), ]; } return $sanitized; } /** * @param array $evidence * @return array */ private static function sanitizeEvidence(array $evidence): array { $sanitized = []; foreach ($evidence as $pointer) { if (! is_array($pointer)) { continue; } $kind = $pointer['kind'] ?? null; if (! is_string($kind) || trim($kind) === '') { continue; } if (self::containsForbiddenKeySubstring($kind)) { continue; } $value = $pointer['value'] ?? null; if (is_int($value)) { $sanitized[] = ['kind' => trim($kind), 'value' => $value]; continue; } if (! is_string($value)) { continue; } $sanitizedValue = self::sanitizeValueString($value); if ($sanitizedValue === null) { continue; } $sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue]; } return $sanitized; } /** * @param array $nextSteps * @return array */ private static function sanitizeNextSteps(array $nextSteps): array { $sanitized = []; foreach ($nextSteps as $step) { if (! is_array($step)) { continue; } $label = self::sanitizeShortString($step['label'] ?? null, fallback: null); $url = self::sanitizeShortString($step['url'] ?? null, fallback: null); if ($label === null || $url === null) { continue; } $sanitized[] = [ 'label' => $label, 'url' => $url, ]; } return $sanitized; } private static function sanitizeMessage(mixed $message): string { if (! is_string($message)) { return '—'; } $message = trim(str_replace(["\r", "\n"], ' ', $message)); $message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message; $message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message; $message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message; $message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message; $message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message; $message = str_ireplace( ['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '], '[REDACTED]', $message, ); $message = trim($message); return $message === '' ? '—' : substr($message, 0, 240); } private static function sanitizeShortString(mixed $value, ?string $fallback): ?string { if (! is_string($value)) { return $fallback; } $value = trim($value); if ($value === '') { return $fallback; } if (self::containsForbiddenKeySubstring($value)) { return $fallback; } return substr($value, 0, 200); } private static function sanitizeValueString(string $value): ?string { $value = trim($value); if ($value === '') { return null; } if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $value)) { return null; } if (strlen($value) > 512) { return null; } if (preg_match('/\b[A-Za-z0-9\-\._~\+\/]{128,}\b/', $value)) { return null; } $lower = strtolower($value); foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) { if (str_contains($lower, $needle)) { return null; } } return $value; } private static function containsForbiddenKeySubstring(string $value): bool { $lower = strtolower($value); foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) { if (str_contains($lower, $needle)) { return true; } } return false; } }