359 lines
10 KiB
PHP
359 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Support\Verification;
|
|
|
|
final class VerificationReportSanitizer
|
|
{
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
private const FORBIDDEN_KEY_SUBSTRINGS = [
|
|
'access_token',
|
|
'refresh_token',
|
|
'client_secret',
|
|
'authorization',
|
|
'password',
|
|
'cookie',
|
|
'set-cookie',
|
|
];
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed> $identity
|
|
* @return array<string, int|string>
|
|
*/
|
|
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<string, mixed> $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<int, mixed> $checks
|
|
* @return array<int, array<string, mixed>>|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<int, mixed> $evidence
|
|
* @return array<int, array{kind: string, value: int|string}>
|
|
*/
|
|
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<int, mixed> $nextSteps
|
|
* @return array<int, array{label: string, url: string}>
|
|
*/
|
|
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;
|
|
}
|
|
}
|