forked from fa/breadcrumb-the-shire
Resolve all non-false-positive PHPStan 2 findings, reducing the baseline from 486 to 382 entries (only unused-public false positives remain). Categories fixed: - Remove 39 redundant type checks (is_string, is_array, is_object, method_exists) - Remove 12 no-op array_values() on already-sequential lists - Simplify 13 null-coalesce on guaranteed offsets - Remove 8 always-true instanceof checks - Replace 12 redundant test assertions (assertTrue(true) → addToAssertionCount) - Simplify 6 unnecessary nullsafe operators (?-> → ->) - Fix 6 always-true !== '' comparisons - Fix remaining: unset.offset, return.unusedType, staticMethod narrowing 53 files changed across core/, modules/, pages/, and tests/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
509 lines
16 KiB
PHP
509 lines
16 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Audit\Service;
|
|
|
|
use DateTimeImmutable;
|
|
use DateTimeZone;
|
|
use MintyPHP\Http\RequestContext;
|
|
use MintyPHP\Http\SessionStoreInterface;
|
|
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
|
|
use MintyPHP\Service\Security\RateLimiterService;
|
|
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
|
|
|
class FrontendTelemetryIngestService
|
|
{
|
|
private const INGEST_RATE_SCOPE = 'frontend.telemetry.ingest';
|
|
private const SESSION_STATE_KEY = 'module.audit.frontend_telemetry_state';
|
|
|
|
private const INGEST_MAX_HITS = 80;
|
|
private const INGEST_WINDOW_SECONDS = 300;
|
|
private const INGEST_BLOCK_SECONDS = 120;
|
|
private const FAST_DROP_MAX_HITS = 30;
|
|
private const FAST_DROP_WINDOW_SECONDS = 60;
|
|
|
|
private const DEDUPE_WINDOW_SECONDS = 300;
|
|
|
|
private const MESSAGE_MAX_LENGTH = 280;
|
|
private const FINGERPRINT_MAX_LENGTH = 96;
|
|
private const META_MAX_ENTRIES = 12;
|
|
private const META_VALUE_MAX_LENGTH = 140;
|
|
|
|
private const EVENT_WARN_ONCE = 'frontend.warn_once';
|
|
private const EVENT_AJAX_ERROR = 'frontend.ajax_error';
|
|
|
|
/** @var array<string, string> */
|
|
private const EVENT_KEY_MAP = [
|
|
self::EVENT_WARN_ONCE => 'warn_once',
|
|
self::EVENT_AJAX_ERROR => 'ajax_error',
|
|
];
|
|
|
|
/** @var array<int, string> */
|
|
private const ALLOWED_META_KEYS = [
|
|
'action',
|
|
'code',
|
|
'component',
|
|
'error_code',
|
|
'feature',
|
|
'http_method',
|
|
'http_status',
|
|
'location',
|
|
'module',
|
|
'page_request_id',
|
|
'request_path',
|
|
'source',
|
|
'status_text',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly SystemAuditService $systemAuditService,
|
|
private readonly SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway,
|
|
private readonly RateLimiterService $rateLimiterService,
|
|
private readonly SessionStoreInterface $sessionStore
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
* @return array{accepted:bool, reason:string}
|
|
*/
|
|
public function ingest(array $input): array
|
|
{
|
|
if (!$this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled()) {
|
|
return ['accepted' => false, 'reason' => 'disabled'];
|
|
}
|
|
|
|
$payload = $this->normalizePayload($input);
|
|
if ($payload === null) {
|
|
return ['accepted' => false, 'reason' => 'invalid'];
|
|
}
|
|
|
|
if (!$this->isAllowedEvent($payload['event_type'])) {
|
|
return ['accepted' => false, 'reason' => 'event_not_allowed'];
|
|
}
|
|
|
|
if (!$this->shouldSample($this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate())) {
|
|
return ['accepted' => false, 'reason' => 'sampled_out'];
|
|
}
|
|
|
|
$now = time();
|
|
$sessionState = $this->loadSessionState();
|
|
if ($this->applyBurstLimit($sessionState, $now)) {
|
|
$this->saveSessionState($sessionState);
|
|
return ['accepted' => false, 'reason' => 'burst_limited'];
|
|
}
|
|
|
|
$subject = $this->subjectKey();
|
|
|
|
$rateDecision = $this->rateLimiterService->hit(
|
|
self::INGEST_RATE_SCOPE,
|
|
$subject,
|
|
self::INGEST_MAX_HITS,
|
|
self::INGEST_WINDOW_SECONDS,
|
|
self::INGEST_BLOCK_SECONDS
|
|
);
|
|
if (!(bool) ($rateDecision['allowed'] ?? true)) {
|
|
$this->saveSessionState($sessionState);
|
|
return ['accepted' => false, 'reason' => 'rate_limited'];
|
|
}
|
|
|
|
if ($this->isDuplicateAndTouch($sessionState, $payload['fingerprint_hash'], $now)) {
|
|
$this->saveSessionState($sessionState);
|
|
return ['accepted' => false, 'reason' => 'duplicate'];
|
|
}
|
|
|
|
$this->saveSessionState($sessionState);
|
|
|
|
$outcome = $payload['severity'] === 'error'
|
|
? SystemAuditOutcome::Failed->value
|
|
: SystemAuditOutcome::Success->value;
|
|
|
|
$recordId = $this->systemAuditService->record($payload['event_type'], $outcome, [
|
|
'target_type' => 'frontend',
|
|
'metadata' => [
|
|
'severity' => $payload['severity'],
|
|
'message' => $payload['message'],
|
|
'fingerprint_hash' => $payload['fingerprint_hash'],
|
|
'occurred_at' => $payload['occurred_at'],
|
|
'meta' => $payload['meta'],
|
|
],
|
|
]);
|
|
|
|
if ($recordId === null) {
|
|
return ['accepted' => false, 'reason' => 'record_failed'];
|
|
}
|
|
|
|
return ['accepted' => true, 'reason' => 'logged'];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
* @return array{
|
|
* event_type:string,
|
|
* severity:string,
|
|
* message:string,
|
|
* fingerprint_hash:string,
|
|
* occurred_at:string,
|
|
* meta: array<string, mixed>
|
|
* }|null
|
|
*/
|
|
private function normalizePayload(array $input): ?array
|
|
{
|
|
$eventType = $this->normalizeEventType((string) ($input['event_type'] ?? ''));
|
|
if ($eventType === '') {
|
|
return null;
|
|
}
|
|
|
|
$message = $this->sanitizeText((string) ($input['message'] ?? ''), self::MESSAGE_MAX_LENGTH);
|
|
if ($message === '') {
|
|
return null;
|
|
}
|
|
|
|
$meta = $this->normalizeMeta($input['meta'] ?? []);
|
|
$route = trim((string) ($meta['location'] ?? ''));
|
|
|
|
$fingerprint = $this->sanitizeFingerprint((string) ($input['fingerprint'] ?? ''));
|
|
if ($fingerprint === '') {
|
|
$fingerprint = $this->sanitizeFingerprint(
|
|
'v1_' . substr(hash('sha256', $eventType . '|' . $route . '|' . $message), 0, 24)
|
|
);
|
|
}
|
|
if ($fingerprint === '') {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'event_type' => $eventType,
|
|
'severity' => $this->normalizeSeverity((string) ($input['severity'] ?? ''), $eventType),
|
|
'message' => $message,
|
|
'fingerprint_hash' => RequestContext::hashValue($fingerprint),
|
|
'occurred_at' => $this->normalizeOccurredAt((string) ($input['occurred_at'] ?? '')),
|
|
'meta' => $meta,
|
|
];
|
|
}
|
|
|
|
private function normalizeEventType(string $eventType): string
|
|
{
|
|
$eventType = strtolower(trim($eventType));
|
|
if ($eventType === '') {
|
|
return '';
|
|
}
|
|
|
|
if (str_starts_with($eventType, 'frontend.')) {
|
|
return isset(self::EVENT_KEY_MAP[$eventType]) ? $eventType : '';
|
|
}
|
|
|
|
foreach (self::EVENT_KEY_MAP as $name => $short) {
|
|
if ($eventType === $short) {
|
|
return $name;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function normalizeSeverity(string $severity, string $eventType): string
|
|
{
|
|
$severity = strtolower(trim($severity));
|
|
if (in_array($severity, ['info', 'warning', 'error'], true)) {
|
|
return $severity;
|
|
}
|
|
|
|
return $eventType === self::EVENT_AJAX_ERROR ? 'error' : 'warning';
|
|
}
|
|
|
|
private function normalizeOccurredAt(string $occurredAt): string
|
|
{
|
|
$occurredAt = trim($occurredAt);
|
|
if ($occurredAt !== '') {
|
|
try {
|
|
$date = new DateTimeImmutable($occurredAt);
|
|
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
|
|
} catch (\Throwable) {
|
|
// Fall back to now below.
|
|
}
|
|
}
|
|
|
|
return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
|
}
|
|
|
|
private function sanitizeFingerprint(string $value): string
|
|
{
|
|
$value = strtolower(trim($value));
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$value = preg_replace('/[^a-z0-9:_-]/', '', $value) ?? '';
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
return substr($value, 0, self::FINGERPRINT_MAX_LENGTH);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeMeta(mixed $rawMeta): array
|
|
{
|
|
$source = [];
|
|
if (is_array($rawMeta)) {
|
|
$source = $rawMeta;
|
|
} elseif (is_string($rawMeta) && trim($rawMeta) !== '') {
|
|
$decoded = json_decode($rawMeta, true);
|
|
if (is_array($decoded)) {
|
|
$source = $decoded;
|
|
}
|
|
}
|
|
|
|
if ($source === []) {
|
|
return [];
|
|
}
|
|
|
|
$meta = [];
|
|
$count = 0;
|
|
foreach ($source as $rawKey => $rawValue) {
|
|
if ($count >= self::META_MAX_ENTRIES) {
|
|
break;
|
|
}
|
|
|
|
$key = strtolower(trim((string) $rawKey));
|
|
if ($key === '' || !in_array($key, self::ALLOWED_META_KEYS, true)) {
|
|
continue;
|
|
}
|
|
|
|
if ($key === 'location' || $key === 'request_path') {
|
|
$normalizedPath = $this->sanitizePath((string) $rawValue);
|
|
if ($normalizedPath === '') {
|
|
continue;
|
|
}
|
|
$meta[$key] = $normalizedPath;
|
|
$count++;
|
|
continue;
|
|
}
|
|
|
|
if ($key === 'http_status') {
|
|
$status = (int) $rawValue;
|
|
if ($status < 100 || $status > 599) {
|
|
continue;
|
|
}
|
|
$meta[$key] = $status;
|
|
$count++;
|
|
continue;
|
|
}
|
|
|
|
if ($key === 'page_request_id') {
|
|
$requestId = strtolower(trim((string) $rawValue));
|
|
if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $requestId) !== 1) {
|
|
continue;
|
|
}
|
|
$meta[$key] = $requestId;
|
|
$count++;
|
|
continue;
|
|
}
|
|
|
|
$value = $this->sanitizeText((string) $rawValue, self::META_VALUE_MAX_LENGTH);
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
$meta[$key] = $value;
|
|
$count++;
|
|
}
|
|
|
|
return $meta;
|
|
}
|
|
|
|
private function sanitizePath(string $value): string
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
if (filter_var($value, FILTER_VALIDATE_URL)) {
|
|
$parsedPath = parse_url($value, PHP_URL_PATH);
|
|
$value = is_string($parsedPath) ? $parsedPath : '';
|
|
}
|
|
|
|
$value = (string) preg_replace('/[?#].*/', '', $value);
|
|
$value = preg_replace('/\/[0-9]{3,}(?=\/|$)/', '/:id', $value) ?? $value;
|
|
$value = preg_replace(
|
|
'/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i',
|
|
':uuid',
|
|
$value
|
|
) ?? $value;
|
|
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
if (!str_starts_with($value, '/')) {
|
|
$value = '/' . $value;
|
|
}
|
|
|
|
return substr($value, 0, 255);
|
|
}
|
|
|
|
private function sanitizeText(string $value, int $maxLength): string
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
|
$value = preg_replace('/([?&][a-z0-9_.-]{1,64}=)[^&#\s]+/i', '$1[REDACTED]', $value) ?? $value;
|
|
$value = preg_replace('/(^|\s)(token|access_token|refresh_token|id_token|authorization|api_key|secret)=([^\s]+)/i', '$1$2=[REDACTED]', $value) ?? $value;
|
|
$value = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $value) ?? $value;
|
|
$value = preg_replace(
|
|
'/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i',
|
|
'[UUID]',
|
|
$value
|
|
) ?? $value;
|
|
$value = preg_replace('/\b[a-f0-9]{24,}\b/i', '[TOKEN]', $value) ?? $value;
|
|
|
|
return substr($value, 0, max(1, $maxLength));
|
|
}
|
|
|
|
private function isAllowedEvent(string $eventType): bool
|
|
{
|
|
$eventKey = self::EVENT_KEY_MAP[$eventType] ?? null;
|
|
if ($eventKey === null) {
|
|
return false;
|
|
}
|
|
|
|
$allowed = $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents();
|
|
return in_array($eventKey, $allowed, true);
|
|
}
|
|
|
|
private function shouldSample(float $sampleRate): bool
|
|
{
|
|
if ($sampleRate <= 0.0) {
|
|
return false;
|
|
}
|
|
if ($sampleRate >= 1.0) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
$max = 10000;
|
|
$threshold = (int) floor($sampleRate * $max);
|
|
return random_int(1, $max) <= max(1, $threshold);
|
|
} catch (\Throwable) {
|
|
$threshold = (int) floor($sampleRate * 10000);
|
|
return mt_rand(1, 10000) <= max(1, $threshold);
|
|
}
|
|
}
|
|
|
|
private function subjectKey(): string
|
|
{
|
|
$session = $this->sessionStore->all();
|
|
$userId = (int) ($session['user']['id'] ?? 0);
|
|
if ($userId > 0) {
|
|
return 'user:' . $userId;
|
|
}
|
|
|
|
$sessionId = trim(session_id());
|
|
if ($sessionId !== '') {
|
|
return 'session:' . $sessionId;
|
|
}
|
|
|
|
$requestContext = RequestContext::context();
|
|
$ip = trim((string) ($requestContext['ip'] ?? ''));
|
|
if ($ip !== '') {
|
|
return 'ip:' . RequestContext::hashValue($ip);
|
|
}
|
|
|
|
return 'anonymous';
|
|
}
|
|
|
|
/**
|
|
* @return array{dedupe: array<string,int>, burst: array{window_start:int,count:int}}
|
|
*/
|
|
private function loadSessionState(): array
|
|
{
|
|
$rawState = $this->sessionStore->get(self::SESSION_STATE_KEY, []);
|
|
if (!is_array($rawState)) {
|
|
$rawState = [];
|
|
}
|
|
|
|
$dedupe = is_array($rawState['dedupe'] ?? null) ? $rawState['dedupe'] : [];
|
|
$burst = is_array($rawState['burst'] ?? null) ? $rawState['burst'] : [];
|
|
|
|
return [
|
|
'dedupe' => $dedupe,
|
|
'burst' => [
|
|
'window_start' => is_int($burst['window_start'] ?? null) ? (int) $burst['window_start'] : 0,
|
|
'count' => is_int($burst['count'] ?? null) ? max(0, (int) $burst['count']) : 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
|
|
*/
|
|
private function saveSessionState(array $state): void
|
|
{
|
|
$this->sessionStore->set(self::SESSION_STATE_KEY, $state);
|
|
}
|
|
|
|
/**
|
|
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
|
|
*/
|
|
private function applyBurstLimit(array &$state, int $now): bool
|
|
{
|
|
$windowStart = $state['burst']['window_start'];
|
|
$count = max(0, $state['burst']['count']);
|
|
|
|
if ($windowStart <= 0 || ($now - $windowStart) >= self::FAST_DROP_WINDOW_SECONDS) {
|
|
$windowStart = $now;
|
|
$count = 0;
|
|
}
|
|
|
|
if ($count >= self::FAST_DROP_MAX_HITS) {
|
|
$state['burst'] = [
|
|
'window_start' => $windowStart,
|
|
'count' => $count,
|
|
];
|
|
return true;
|
|
}
|
|
|
|
$state['burst'] = [
|
|
'window_start' => $windowStart,
|
|
'count' => $count + 1,
|
|
];
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
|
|
*/
|
|
private function isDuplicateAndTouch(array &$state, string $fingerprintHash, int $now): bool
|
|
{
|
|
$seen = $state['dedupe'];
|
|
|
|
$cutoff = $now - self::DEDUPE_WINDOW_SECONDS;
|
|
foreach ($seen as $key => $timestamp) {
|
|
if ($timestamp < $cutoff) {
|
|
unset($seen[$key]);
|
|
}
|
|
}
|
|
|
|
if (isset($seen[$fingerprintHash]) && (int) $seen[$fingerprintHash] >= $cutoff) {
|
|
$seen[$fingerprintHash] = $now;
|
|
$isDuplicate = true;
|
|
} else {
|
|
$seen[$fingerprintHash] = $now;
|
|
$isDuplicate = false;
|
|
}
|
|
|
|
if (count($seen) > 120) {
|
|
arsort($seen, SORT_NUMERIC);
|
|
$seen = array_slice($seen, 0, 120, true);
|
|
}
|
|
$state['dedupe'] = $seen;
|
|
return $isDuplicate;
|
|
}
|
|
}
|