1
0
Files
breadcrumb-the-shire/modules/audit/lib/Module/Audit/Service/FrontendTelemetryIngestService.php
fs 570e3923b7 refactor(audit): remove factory chain and repository interfaces
Remove AuditRepositoryFactory and AuditServicesFactory — two-layer
factory chain replaced by direct DI container wiring. Delete 4
single-consumer repository interfaces. Register all 4 repositories
and SystemAuditRedactionService directly in container. Update all
service constructors to type-hint concrete classes. Fix architecture
test and documentation references to deleted factories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:33:59 +01:00

513 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 (!is_string($key)) {
unset($seen[$key]);
continue;
}
if (!is_int($timestamp) || $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;
}
}