Pular para o conteúdo principal
ImmutableLog logo
Voltar
LaravelPHP

Integração com Laravel

Integre o ImmutableLog em qualquer aplicação Laravel com um middleware terminável. O método `terminate()` roda depois da resposta ser enviada ao cliente — ou seja, o envio é naturalmente fire-and-forget, sem adicionar latência.

Instalação

O middleware usa o Guzzle (já presente na maioria dos projetos Laravel) e o ramsey/uuid (incluso no framework).

bash
# Guzzle já vem no Laravel; instale se necessário:
composer require guzzlehttp/guzzle

Código do middleware

Salve em `app/Http/Middleware/ImmutableLogMiddleware.php`. O `handle()` registra timestamp e request_id; o `terminate()` monta o evento e envia após a resposta. Query params passam por `redact()`.

php
<?php
// app/Http/Middleware/ImmutableLogMiddleware.php
namespace App\Http\Middleware;

use Closure;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Ramsey\Uuid\Uuid;

class ImmutableLogMiddleware
{
    // Chaves cujos valores sao mascarados antes do envio (PII/segredos).
    private const SENSITIVE = [
        'password', 'pwd', 'senha', 'token', 'access_token', 'refresh_token', 'api_key',
        'apikey', 'secret', 'client_secret', 'authorization', 'auth', 'credit_card',
        'card_number', 'cvv', 'cpf', 'otp', 'code',
    ];

    public function handle(Request $request, Closure $next)
    {
        $request->attributes->set(
            'imtbl.request_id',
            $request->header('X-Request-Id') ?? Uuid::uuid4()->toString()
        );
        $request->attributes->set('imtbl.started_at', microtime(true));

        return $next($request);
    }

    // terminate() roda DEPOIS da resposta ser enviada — fire-and-forget natural.
    public function terminate(Request $request, $response): void
    {
        try {
            $apiKey = env('IMTBL_API_KEY');
            if (empty($apiKey)) {
                return;
            }
            if (in_array($request->getPathInfo(), ['/health', '/healthz', '/metrics'], true)) {
                return;
            }

            $startedAt = $request->attributes->get('imtbl.started_at', microtime(true));
            $latencyMs = (int) ((microtime(true) - $startedAt) * 1000);
            $status    = $response->getStatusCode();
            $requestId = $request->attributes->get('imtbl.request_id');

            $kind = $status >= 400 ? 'error'
                : ($status >= 300 ? 'info' : ($status >= 200 ? 'success' : 'info'));
            $eventName = $request->attributes->get('imtbl.event_name')
                ?? 'http.' . $request->method() . '.' . $request->path();

            $context = ['ip' => $request->ip(), 'user_agent' => $request->userAgent() ?? 'unknown'];
            if ($user = $request->user()) {
                $context['user_id'] = $user->getAuthIdentifier();
                $context['email']   = $user->email ?? null;
            }

            $payload = [
                'id'        => Uuid::uuid4()->toString(),
                'kind'      => $kind,
                'message'   => $request->method() . ' /' . $request->path() . ' -> ' . $status,
                'timestamp' => gmdate('c'),
                'context'   => $context,
                'request'   => [
                    'request_id'   => $requestId,
                    'method'       => $request->method(),
                    'path'         => '/' . $request->path(),
                    // query params passam por redact() — nunca enviar segredos em claro.
                    'query_params' => self::redact($request->query()) ?: null,
                ],
                'metrics'   => ['latency_ms' => $latencyMs, 'status_code' => $status],
                'severity'  => $kind === 'error' ? 'high' : 'low',
            ];
            if ($kind === 'error') {
                $payload['error'] = [
                    'status_code' => $status,
                    'retryable'   => in_array($status, [408, 429, 500, 502, 503, 504], true),
                ];
            }

            // Trilha: attribute imtbl.trail -> header X-Imtbl-Trail.
            $trail = self::sanitizeTrail(
                $request->attributes->get('imtbl.trail') ?? $request->header('X-Imtbl-Trail')
            );

            // Todos os valores de meta precisam ser strings.
            $meta = [
                'type'       => $kind,
                'event_name' => $eventName,
                'service'    => env('IMTBL_SERVICE_NAME', 'laravel-service'),
                'request_id' => $requestId,
                'env'        => app()->environment(),
            ];
            if ($trail !== null) {
                $meta['immutable_trail'] = $trail;
            }

            $headers = [
                'Authorization' => "Bearer {$apiKey}",
                'Content-Type'  => 'application/json',
                // Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
                'Idempotency-Key'         => "{$eventName}-{$requestId}",
                'Request-Id'              => $requestId,
                'X-Client-TZ'             => 'America/Sao_Paulo',
                'X-Client-Offset-Minutes' => '-180',
            ];
            if ($trail !== null) {
                $headers['X-Imtbl-Trail'] = $trail;
            }

            (new Client(['timeout' => 5]))->post(
                (env('IMTBL_URL') ?: 'https://api.immutablelog.com') . '/v1/events',
                ['headers' => $headers, 'json' => ['payload' => json_encode($payload), 'meta' => $meta]]
            );
        } catch (\Throwable $e) {
            // Never let audit logging break the application.
        }
    }

    private static function redact(array $data): array
    {
        foreach ($data as $k => $v) {
            if (is_string($k) && in_array(strtolower($k), self::SENSITIVE, true)) {
                $data[$k] = '***REDACTED***';
            } elseif (is_array($v)) {
                $data[$k] = self::redact($v);
            }
        }
        return $data;
    }

    // Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
    private static function sanitizeTrail(?string $value): ?string
    {
        if ($value === null) {
            return null;
        }
        $v = trim($value);
        if ($v === '') {
            return null;
        }
        $v = str_replace(':', '-', $v);
        if (mb_strlen($v) > 256) {
            $v = mb_substr($v, 0, 256);
        }
        return $v;
    }
}

Registro do middleware

No Laravel 11+, registre no `bootstrap/app.php`. Em versões anteriores, adicione à propriedade `$middleware` do `app/Http/Kernel.php`.

php
<?php
// Laravel 11+ — bootstrap/app.php
use App\Http\Middleware\ImmutableLogMiddleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        // Global: captura todas as requisições.
        $middleware->append(ImmutableLogMiddleware::class);
    })
    ->create();

// Laravel <= 10 — app/Http/Kernel.php
// protected $middleware = [
//     // ...
//     \App\Http\Middleware\ImmutableLogMiddleware::class,
// ];

Nunca coloque o token no código. Defina IMTBL_API_KEY no .env e garanta que o .env está no .gitignore.

Como funciona

handle()

Registra request_id e timestamp de início

next($request)

A aplicação processa e a resposta é enviada ao cliente

terminate()

Roda após a resposta — monta e envia o evento

Evento e trilha customizados

Defina os atributos `imtbl.event_name` e `imtbl.trail` na request dentro de qualquer controller. O `terminate()` lê esses valores ao montar o evento.

php
<?php
use Illuminate\Http\Request;

class PaymentController
{
    public function store(Request $request)
    {
        // Sobrescreve o nome do evento e agrupa numa trilha auditavel.
        $request->attributes->set('imtbl.event_name', 'payment.created');
        $request->attributes->set('imtbl.trail', 'order-7782');

        // ... lógica de negócio / business logic ...

        return response()->json(['ok' => true]);
    }
}

Padrão automático: http.METHOD.path.

Trilha imutável (immutable_trail)

A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Defina o atributo `imtbl.trail` na request, ou propague entre serviços com o header `X-Imtbl-Trail`. O valor passa por `sanitizeTrail` antes do envio.

php
Route::post('/flows/{id}/run', function (Request $request, string $id) {
    $request->attributes->set('imtbl.trail', "flow-{$id}");
    // ... business logic ...
    return response()->json(['status' => 'ok']);
});

A trilha não pode ser vazia, exceder 256 caracteres ou conter `:` — violações retornam `400 invalid_immutable_trail`. O `sanitizeTrail` corrige o valor antes do envio.

Resposta e códigos de status

A API de ingestão é assíncrona. Sucesso retorna 202 (novo) ou 200 (duplicata idempotente). Como o middleware roda em terminate(), esses códigos importam principalmente se você enviar eventos manualmente.

StatusSignificado
202Evento aceito e enfileirado (novo)
200Idempotency-Key já existente — duplicate: true
400Idempotency-Key ausente/vazia ou invalid_immutable_trail
403Assinatura inativa/expirada, escopo ou retenção
413payload_too_large (limite do servidor: 16KB)
429monthly_limit_exceeded — não fazer retry
503mempool_full — transitório, pode dar retry

Campos de meta

Além do payload, o objeto meta carrega os campos que o SIEM usa para normalizar (ECS) e enriquecer o evento. Todos os valores de meta são strings.

Campo em metaO que faz no SIEM
event_nameO rótulo do evento (ex.: http.GET.auth-me-user, Pagamento erro) — o identificador que aparece na listagem. Vira event.action e deriva event.category (ex.: nome com loginauthentication).
client_ipVira source.ip com precedência máxima e proveniência client_asserted. Ver a seção abaixo.
immutable_trailVira imtbl.immutable_trail — agrupa eventos relacionados para investigação (ex.: um pedido, um usuário).
typeSeveridade do evento: error / warning / info / success. Colore o badge na listagem. (event_type é aceito como sinônimo.)
service, env, request_idMetadados: indexados e consultáveis no painel (busca/filtros).

client_ip — IP do usuário final

O problema que resolve

Entre o navegador e o core há proxies/ALB. O IP que o core observa na conexão é o do salto anterior (o backend do cliente ou um proxy da AWS), não o do usuário. Sem ação, source.ip seria o IP do proxy — inútil para geo, threat e detecção por IP. (Exemplo real: o XFF chegava 44.192.13.3 (AWS) enquanto o usuário era 179.110.4.205.)

A solução

O backend do cliente é o único que enxerga o IP real do navegador — então ele encaminha esse IP em meta.client_ip ao chamar POST /v1/events.

Como o SIEM trata

  • Valida que é um IP (v4/v6). Um valor não-IP é ignorado — não derruba o evento.
  • Escreve em source.ip com precedência máxima (ver ordem abaixo).
  • Carimba imtbl.source_ip_origin = "client_asserted" — a UI mostra o selo “IP do usuário”.
  • Alimenta GeoIP, threat intel e regras de detecção por IP.
Precedência de source.ip:client_ipX-Forwarded-For (1º hop)X-Real-IPIP de conexão

Proveniência e confiança

client_ip é asserido pelo tenant — o backend do cliente afirma o IP, e é spoofável por quem controla aquele backend. Porém é auto-contido ao próprio tenant (não cruza a fronteira de outro tenant). Um hit de threat/geo sobre um client_asserted é uma alegação, não uma observação de borda.

Como obter o IP no seu backend

Pegue o primeiro IP do X-Forwarded-For (o mais próximo do usuário); no fallback, use o IP de conexão.

php
function client_ip(): ?string {
    $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
    if ($xff !== '') {
        return trim(explode(',', $xff)[0]); // primeiro hop = IP do usuario
    }
    return $_SERVER['REMOTE_ADDR'] ?? null;
}

$meta = ['event_name' => 'user.login', 'client_ip' => client_ip()];

Exemplo de payload

json
{
  "payload": "{\"user\":\"bob\",\"action\":\"login\"}",
  "meta": {
    "event_name": "user.login",
    "client_ip": "179.110.4.205",
    "immutable_trail": "user-bob"
  }
}

Nota de PII: client_ip é PII e vai selado no bloco imutável (permanente). Decida conscientemente entre evidência forense e “direito ao esquecimento” antes de enviá-lo.

Esta documentação reflete o comportamento atual da integração. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.