Stream audit events to a SIEM
Stream audit events to a SIEM
Goal: mirror one environment's hash-chained audit trail to a customer's Splunk HTTP
Event Collector (HEC). The same steps work for Elastic ECS, Graylog GELF, an
ArcSight/CEF collector, or a generic JSON endpoint — only the Destination changes.
See SIEM audit streaming for the mental model and the isolation guarantee.
1. Register the stream — inside the environment
A stream is environment-owned: it is created stamped with the current environment, and only that environment's audit entries will ever flow to it. Create it while acting as the target environment.
use Cbox\Id\Kernel\Tenancy\Contracts\EnvironmentContext;
use Cbox\LaravelSiem\Contracts\LogStreams;
use Cbox\LaravelSiem\Enums\Destination;
$registered = app(EnvironmentContext::class)->runAs($productionEnvironment, function () {
return app(LogStreams::class)->create(
name: 'acme-splunk',
destination: Destination::SplunkHec,
endpointUrl: 'https://http-inputs-acme.splunkcloud.com/services/collector',
secret: $hecToken, // the customer's HEC token — stored encrypted at rest
);
});
// The plaintext secret is revealed exactly once, here. Only ciphertext is persisted.
$registered->secret; // capture now if you generated it; it is gone after
$registered->stream->id; // the stream id
The endpoint URL is SSRF-checked before it is stored — a stream pointing at a
loopback, private, link-local, reserved, or cloud-metadata address is refused. (An
on-prem single-tenant install that must reach an internal collector can disable the
guard with siem.http.verify_url — never do this in a multi-tenant deployment; see
the security note.)
2. Record audit events as usual
Nothing about recording changes. Every entry recorded while that environment is active is mirrored to the stream in the same transaction:
use Cbox\Id\Kernel\Audit\Contracts\AuditLog;
use Cbox\Id\Kernel\Audit\ValueObjects\AuditEvent;
app(AuditLog::class)->record(AuditEvent::forUser('user.login', $userId));
With no stream configured for the environment this is a no-op beyond the normal record — deny-by-default, no overhead.
3. Deliver
Delivery is asynchronous, off the request thread. The platform schedules a per-minute pump for every enabled stream across every environment (each job reconstructs its own stream's environment before delivering). Just run the scheduler and a queue worker:
php artisan schedule:work # dispatches cbox-id:audit-streams:pump every minute
php artisan queue:work # runs the per-stream PumpAuditStream jobs
To drive it by hand instead — for example from your own scheduler — disable the built-in schedule and call the command yourself:
// config/cbox-id.php
'audit_streaming' => ['schedule' => false],
php artisan cbox-id:audit-streams:pump
4. Refine the mapping (optional)
DefaultSiemEventMapper derives a sensible EventCategory / Outcome / Severity
from the action's prefix (documented on the class). To tune it for your action
vocabulary, bind your own SiemEventMapper — but keep the two invariants the
receiver relies on: the event id must be the entry hash, and the context must
carry sequence, hash, prev_hash, and organization_id.
use Cbox\Id\AuditStreaming\Contracts\SiemEventMapper;
$this->app->singleton(SiemEventMapper::class, MyMapper::class);
Testing it
The package ships InteractsWithAuditStreaming, which swaps the HTTP sink for an
in-memory fake and pumps synchronously:
$sink = $this->fakeAuditStreamSink();
$this->runAsEnvironment('env_prod', function () {
$this->registerAuditStream('splunk');
app(AuditLog::class)->record(AuditEvent::forUser('user.login', 'user_1'));
});
$this->pumpAuditStream($streamId);
$sink->assertSentTo('splunk'); // the event was delivered — no network