projektmotor / ids-sensor-bundle
Symfony-Sensor-Bundle für Angriffserkennung: erfasst, normalisiert, redigiert und versendet sicherheitsrelevante Events
Package info
github.com/projektmotor/ids-sensor-bundle
Type:symfony-bundle
pkg:composer/projektmotor/ids-sensor-bundle
Requires
- php: >=8.2
- ext-json: *
- ext-mbstring: *
- projektmotor/ids-event-data: ^0.1
- psr/log: ^1.1|^2.0|^3.0
- symfony/config: ^6.4|^7.0
- symfony/console: ^6.4|^7.0
- symfony/dependency-injection: ^6.4|^7.0
- symfony/event-dispatcher: ^6.4|^7.0
- symfony/framework-bundle: ^6.4|^7.0
- symfony/http-foundation: ^6.4|^7.0
- symfony/http-kernel: ^6.4|^7.0
- symfony/messenger: ^6.4|^7.0
- symfony/service-contracts: ^2.5|^3.0
- symfony/uid: ^6.4|^7.0
- symfony/yaml: ^6.4|^7.0
Requires (Dev)
- ext-redis: *
- friendsofphp/php-cs-fixer: ^3.60
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^10.5
- symfony/browser-kit: ^6.4|^7.0
- symfony/clock: ^6.4|^7.0
- symfony/filesystem: ^6.4|^7.0
- symfony/redis-messenger: ^6.4|^7.0
- symfony/security-bundle: ^6.4|^7.0
- symfony/security-core: ^6.4|^7.0
Suggests
- ext-apcu: Beschleunigt Zähler, Heartbeat-Drosselung und Circuit-Breaker-Zustand
- symfony/redis-messenger: Für den empfohlenen Redis-Streams-Transport
- symfony/security-bundle: Aktiviert die Security-Sensorebene (Konzept 2.1.2)
README
Intrusion detection sensors for Symfony applications. Captures security-relevant events, normalizes them into a fixed wire format, and ships them over Redis Streams to a separately operated collector — without ever slowing down the application it watches.
flowchart LR
subgraph app["Your Symfony application"]
direction TB
code["Application code"]
sensor["IdsSensorBundle<br/><small>capture · normalize · redact</small>"]
code -.->|"kernel, security<br/>and business events"| sensor
end
broker[("Redis Stream")]
subgraph collector["Separately operated collector"]
direction TB
consumer["IdsBackendBundle<br/><small>receive · detect · alert</small>"]
db[("PostgreSQL")]
consumer --> db
end
sensor -->|"write-only (XADD)"| broker
broker -->|"read"| consumer
classDef capture fill:#E1F5EE,stroke:#0F6E56,color:#085041
classDef transport fill:#F1EFE8,stroke:#5F5E5A,color:#3A3936
classDef data fill:#EEEDFE,stroke:#534AB7,color:#332C7A
class code,sensor capture
class broker,consumer transport
class db data
style app fill:#FBFBF9,stroke:#C8C6BE,color:#5F5E5A
style collector fill:#FBFBF9,stroke:#C8C6BE,color:#5F5E5A
Loading
The sensor runs inside the application it monitors. If that application is compromised, so is the sensor — which is why it may only ever write to the broker, never read or delete.
What it produces
One event, exactly as the collector receives it:
{
"schema_version": 1,
"event_id": "b3f1e6b0-6e3a-4c9a-9f2e-2a6a2f4b9c11",
"timestamp": "2026-08-13T10:15:32.421Z",
"layer": "kernel",
"event_type": "kernel.exception",
"correlation_id": "req-7f2a1c",
"event_severity": "warning",
"application_id": "shop-api",
"instance_id": "web-03",
"environment": "prod",
"actor": {
"user": null,
"ip": "203.0.113.42",
"session_id_hash": "a3f9c1d8e4b27a05",
"client_fingerprint": "c71b04ae9f3d62"
},
"payload": {
"exception_class": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException",
"exception_message": "No route found for GET /wp-admin/setup-config.php",
"http_status": 404
}
}
Nothing is normalized during the request. Capturing happens under a hard budget of
1500 µs (5 ms p99 ceiling for all sensors combined); normalizing, redacting and
shipping happen on kernel.terminate, after the response has left. See
Request lifecycle.
What it detects — and what it does not
This table comes before the installation instructions on purpose. Anyone who installs the bundle and overlooks the business layer believes something is monitored that is not.
| Layer | After composer require |
Work in your application |
|---|---|---|
| Kernel | active, no code required | none |
| Security | active if SecurityBundle is present | none |
| Business | inactive | implement an interface, dispatch events |
Kernel and security layers reliably detect failed attacks — scanning, brute force, denied authorizations, error bursts. They see failures because a failure leaves a trace in the framework: a 403, a 404, an exception.
Successful attacks that use the application as intended produce no signal there. An attacker with a valid session who sets a discount to 100 %, retrieves another customer's order for which no voter exists, or exports data in quantities no human needs, produces nothing but HTTP 200. No amount of tightening the kernel rules compensates for this. The only remedy is the business layer — and that requires application code.
Details: Observation layers.
Requirements
| PHP | ≥ 8.2 |
| Symfony | ^6.4 | ^7.0 |
| Broker | Redis with Streams (XADD), reachable from the application |
| Required extensions | ext-json |
| Recommended | ext-redis (via symfony/redis-messenger), ext-apcu (cross-process throttling) |
Installation
composer require projektmotor/ids-sensor-bundle composer require symfony/redis-messenger
The second line is not optional. symfony/redis-messenger is a dev dependency of this
bundle, because the choice of transport belongs to the application and ext-redis should
not be forced. Symfony only registers bridge factories for packages the application itself
requires. Without it, the first thing you will see is
No transport supports Messenger DSN redis://….
Symfony Flex registers the bundle automatically. Without Flex, add it to
config/bundles.php:
return [ // ... ProjektMotor\IdsSensor\IdsSensorBundle::class => ['all' => true], ];
Minimal configuration
Four values are mandatory:
# config/packages/ids_sensor.yaml ids_sensor: application_id: 'shop-api' instance_id: '%env(HOSTNAME)%' environment: '%env(APP_ENV)%' session_hash: key: '%env(IDS_SESSION_HASH_KEY)%' transport: dsn: '%env(IDS_REDIS_DSN)%'
Generate the HMAC key — a dedicated one, at least 32 characters, deliberately not
APP_SECRET:
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
Then verify the installation:
php bin/console ids:sensor:setup-check
A non-zero exit code means detection is ineffective. Run it in your deployment pipeline,
and do not defuse it with || true — the whole point is that misconfiguration surfaces at
deploy time rather than during the post-mortem of an incident.
Full reference: Configuration.
Capturing business events
The only layer that needs application code — and the only one that can see successful attacks. Implement one interface:
use ProjektMotor\IdsSensor\Contract\SecurityRelevantBusinessEvent; use ProjektMotor\IdsEventData\Vocabulary\Severity; final class OrderAmountOverridden implements SecurityRelevantBusinessEvent { public function __construct( private readonly int $orderId, private readonly string $actorId, private readonly float $newAmount, ) { } public function getEventName(): string { return 'order.amount_overridden'; } public function getSeverityHint(): string { return Severity::Warning->value; } public function getActorId(): ?string { return $this->actorId; } public function getPayload(): array { return ['order_id' => $this->orderId, 'new_amount' => $this->newAmount]; } }
Then dispatch it the way you already dispatch domain events — the sensor listens on the
decorated event_dispatcher, and your business code contains no reference to the IDS:
$this->eventDispatcher->dispatch(new OrderAmountOverridden(...));
Two alternatives exist for code bases that do not dispatch domain events, or deployments that reject decorating the dispatcher: Business layer.
Runtime models
The sensor ships after the response has been sent. Whether network access is permitted at that point depends on the runtime:
| Runtime | Response detachable | Delivery |
|---|---|---|
| PHP-FPM, LiteSpeed, FrankenPHP, RoadRunner | yes | direct to Redis |
| mod_php | no | local spool |
| CLI, Messenger workers | n/a | direct to Redis |
Under mod_php,
ids:sensor:spool:flushis mandatory. It is the only delivery path there. Without a cron entry or systemd timer, the sensor writes, nobody collects, and the spool fills up and discards. The same applies toids:sensor:heartbeat.
Why this is not merely conservative, and what it costs: Delivery path.
Commands
| Command | Purpose |
|---|---|
ids:sensor:setup-check |
Operational check. Exit code ≠ 0 means detection is ineffective. |
ids:sensor:spool:flush |
Drains the spool towards the broker. Mandatory under mod_php. |
ids:sensor:heartbeat |
Sends a liveness signal. For cron or a systemd timer. |
Documentation
The doc/ directory explains every core concept, one document each, with
a diagram per concept. Written in German.
| Overview | Observation layers | Event format |
| Request lifecycle | Delivery path | Confidentiality |
| Operations | Configuration | Business layer |
The full specification of both bundles — including the collector, its database schema and
the detection rules — is doc/concept/concept-v1.md.
Public API and versioning
Semantic versioning applies to:
ProjektMotor\IdsSensor\Contract\*— the interfaces your application implements or injects- the
IdsSensorBundleclass and allids_sensorconfiguration keys - the emitted JSON, versioned via
schema_version
Everything else, including all service IDs, is @internal and may change in any
version. The rule is readable from the directory layout and enforced by
tests/Unit/ArchitectureTest.php: whatever lives under
Contract/ is public, every other file carries @internal.
The wire format itself — field names, enums, value objects, frame — lives in its own
package, projektmotor/ids-event-data
(ProjektMotor\IdsEventData\*), and is versioned there, where semantic versioning covers
the package in full. The collector side consumes the very same package; that is why it
depends on nothing at all — not even on Symfony.
Changes are recorded in CHANGELOG.md.
Contributing
No local PHP installation required — everything runs through Docker:
make install # composer install make test # unit + integration make stan # PHPStan level 8 make cs-fix # php-cs-fixer make test-redis # against a real broker
How src/ is laid out and why — which namespace belongs to which section of the concept,
and when its code runs — is documented in doc/concept/structure.md. Read it
before moving anything: the layout carries five promises that
ArchitectureTest enforces.
If the service wiring changes, the container fingerprint is the actual review artefact — it compares 15 configuration variants definition by definition:
docker compose run --rm -e IDS_UPDATE_FINGERPRINTS=1 php \
vendor/bin/phpunit tests/Integration/ContainerFingerprintTest.php
Read the resulting diff. That, not the green test run, is the check.
License
MIT — see LICENSE.