projektmotor / ids-event-data
Das Drahtformat der IDS-Ereignisse: Feldnamen, Wertelisten, Value Objects, Frame — reines PHP, ohne Framework
Requires
- php: >=8.2
- ext-mbstring: *
Requires (Dev)
- ext-json: *
- friendsofphp/php-cs-fixer: ^3.60
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Das Drahtformat der IDS-Ereignisse: Feldnamen, geschlossene Wertelisten, Value Objects
und der Transport-Umschlag. Reines PHP, kein Framework, keine Laufzeitabhängigkeit außer
ext-mbstring.
Das Format ist die Paketgrenze. Sensor und Collector kennen voneinander nichts außer dem JSON, das dieses Paket beschreibt — keine gemeinsame Bibliothek, keine PHP-Serialisierung, keine Klassennamen auf der Leitung. Dieses Paket ist die gemeinsame Lesart davon.
| Konsument | Rolle |
|---|---|
projektmotor/ids-sensor-bundle |
erzeugt Events in diesem Format und versendet sie |
projektmotor/ids-backend-bundle |
empfängt sie und wertet sie aus |
Weil beide dasselbe Paket lesen, kann keiner der beiden das Format einseitig verschieben, ohne dass es auffällt. Genau dafür existiert es — nicht wegen des Codes, der darin steht.
Installation
composer require projektmotor/ids-event-data
use ProjektMotor\IdsEventData\Vocabulary\Severity; return Severity::Critical->value; // 'critical'
Drei Ebenen der Verschachtelung
flowchart TB
subgraph frame["Frame — die Sendung (3.3)"]
direction TB
fmeta["frame_id · identity · flushed_at<br/>dispatch_path · counters<br/>process_epoch · pid"]
subgraph event["Event — die Beobachtung (3.)"]
direction TB
emeta["event_id · timestamp · layer<br/>event_type · correlation_id<br/>event_severity · application_id<br/>environment_id · sensor_id"]
actor["actor — wer<br/>user · ip · session_id_hash<br/>client_fingerprint"]
payload["payload — was (3.1)<br/>Struktur je event_type"]
raw["raw — der Rohbeleg<br/>nur bei warning/critical"]
end
end
classDef transport fill:#F1EFE8,stroke:#5F5E5A,color:#3A3936
classDef data fill:#EEEDFE,stroke:#534AB7,color:#332C7A
class fmeta transport
class emeta,actor,payload,raw data
style frame fill:#FBFBF9,stroke:#5F5E5A,color:#3A3936
style event fill:#FCFCFF,stroke:#534AB7,color:#332C7A
Loading
Ein Frame umhüllt die Events eines Requests; ein Event trägt seinen Payload und optional
den Rohbeleg. Der Frame ist kein Event und ändert das Event-Schema nicht — deshalb
liegen dispatch_path und die Zählerstände dort und nicht im Event: sie sind
Eigenschaften der Sendung, nicht einer Beobachtung.
Die Verzeichnisse unter src/ spiegeln genau diese Verschachtelung. Von oben nach unten
gelesen ist das das JSON von außen nach innen:
Frame/ Frame DispatchPath was auf der Leitung liegt
Event/ EventSchema NormalizedEvent was im Frame liegt
Actor SensorIdentity
Payload/ KernelPayload SecurityPayload was im Event liegt
ResourceReference
Vocabulary/ Layer Severity die geschlossenen Wertelisten
Die Abhängigkeiten zeigen dabei nur nach unten: Vocabulary/ und Payload/ importieren
nichts, Event/ liest aus Vocabulary/, Frame/ aus Event/. Das prüft
tests/Unit/ArchitectureTest.php mit — ebenso wie die Zusage, dass keine Datei
irgendetwas Fremdes importiert.
Ein Event, wie es ankommt
{
"event_id": "b3f1e6b0-6e3a-4c9a-9f2e-2a6a2f4b9c11",
"timestamp": "2026-08-13T10:15:32.421Z",
"layer": "kernel",
"event_type": "kernel.exception",
"correlation_id": "0198f2c1-6e3a-7c9a-9f2e-2a6a2f4b9c11",
"event_severity": "warning",
"application_id": "9b1c4f80-2a77-4d3e-9c15-7e2b6a4f0d31",
"environment_id": "3f6d21ac-58b0-4e91-a7c4-11d9e0b8c522",
"sensor_id": "c40a7e13-9d62-4b88-8f05-6a1e3c72b9d4",
"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
}
}
Die zwölf Felder in EventSchema::MANDATORY_FIELDS sowie die vier actor.*-Felder sind
Pflicht — immer vorhanden, unabhängig von der Ebene. Die actor.*-Felder sind dabei
ausdrücklich nullable: bei kernel.request liegt meist noch kein Security-Token vor,
bei zustandslosen API-Requests existiert keine Session, im CLI-Kontext kein HTTP-Kontext.
Die geschlossenen Wertelisten
Drei Felder haben eine feste, endliche Wertemenge. Sie entsprechen exakt den ENUM-Typen im Datenbankschema des Collectors — ein neuer Fall ist dort eine Migration auf der Gegenseite, nicht ein lokales Hinzufügen.
| Feld | Werte | Klasse |
|---|---|---|
layer |
kernel · security · business |
Vocabulary\Layer |
event_severity |
info · warning · critical |
Vocabulary\Severity |
Beide sind collectorseitig ENUM-Spalten. Ein Wert, den der Collector nicht kennt, lässt
das Einfügen scheitern — deshalb sind sie geschlossen, und ein neuer Wert ist ein
Fassungswechsel.
Die Umgebung war bis Fassung 1 ebenfalls ein Vokabular mit prod, staging, dev.
Sie ist es nicht mehr: Umgebungen werden im Collector registriert und frei benannt, der
Sensor kennt nur ihre environment_id. Damit entfällt der Fehler, den man am leichtesten
machte und der völlig lautlos blieb — eine nicht abbildbare Umgebung landete über einen
Vorgabewert in der falschen Auswertung.
Severity trägt zusätzlich zwei Prädikate, weil an ihnen Politik hängt und nicht nur
ein Wert:
| Methode | wahr für | wozu |
|---|---|---|
carriesRaw() |
warning, critical |
ob das raw-Feld überhaupt übertragen wird |
isSampleable() |
info |
ob das Event weggesampelt werden darf |
Der Payload je Ebene
Der variable Teil. Immer ein flaches oder maximal zweistufig verschachteltes Objekt.
layer |
Feldnamen definiert in | Beispiele |
|---|---|---|
kernel |
Payload\KernelPayload |
method, path, route, http_status, exception_class, command |
security |
Payload\SecurityPayload |
firewall, authenticator, attribute, resource, decision, target_user |
| beide | Payload\ResourceReference |
resource_type, resource_id |
business |
— | frei; die Anwendung liefert ihn selbst |
Für die Business-Ebene gibt es bewusst keine feste Struktur — was ein Vorfall bedeutet, weiß nur die Anwendung.
ResourceReference steht quer zu den Ebenen, weil dieselbe Aussage aus zwei Quellen
kommt: auf der Security-Ebene aus dem Subjekt einer Autorisierungsentscheidung, auf der
Kernel-Ebene aus Route und Routenparametern. Zwei Definitionen wären dieselbe
Zeichenkette an zwei Orten.
Die Kernel-Ebene deckt zwei Einstiegspunkte ab
Neben kernel.request, kernel.exception und kernel.response stehen dort
console.command und console.error. Die Ebene heißt nach dem Einstiegspunkt des
Frameworks, nicht nach HTTP — und Layer bleibt dadurch bei drei Fällen. Ein vierter
wäre ein neuer Wert in einem geschlossenen Vokabular und damit eine neue Fassung samt
Datenbankmigration beim Collector; event_type ist dagegen offen.
Das raw-Feld
raw trägt den unverarbeiteten Rohbeleg und ist kein Pflichtfeld. Es wird nur für
warning und critical übertragen, weil es über 95 % des Datenvolumens ausmacht und die
Masse aller Events info ist.
Deshalb hält NormalizedEvent es als \Closure und wertet sie erst in toArray() aus —
und auch dort nur, wenn die Severity das Feld überhaupt trägt. Der info-Pfad zahlt für
Header-Kopien und Redaktion damit nichts. Was der Rohbeleg enthält und was darin
unkenntlich gemacht wird, entscheidet der Konsument; dieses Paket legt nur fest, wann das
Feld auf der Leitung erscheint.
Was am Format verbindlich ist
schema_version ist nicht konfigurierbar. Der Sensor sendet genau eine Version. Wäre
sie einstellbar, könnte eine kompromittierte Anwendung eine alte Version behaupten und
damit collectorseitig den nachsichtigen Pfad auslösen.
Sie steht seit Fassung 2 im Frame und nicht im Event: Eine Sendung hat eine Fassung, und die gehört einmal in den Umschlag statt zweihundertmal hinein.
Die Bump-Regeln (vollständig in Konzept 3.7):
- kein Bump bei additiven, optionalen Feldern — der Collector ignoriert Unbekanntes
- Bump beim Entfernen, Umbenennen oder Umtypisieren eines Pflichtfeldes, beim Hinzufügen eines Pflichtfeldes, bei geänderter Bedeutung eines Feldes oder geändertem Hash-Verfahren
Die Zeile zum Hinzufügen kam mit Fassung 4 dazu und schloss dieselbe Art von Lücke wie zuvor die Umbenennung eines Zählers: Ein Feld, das sofort als Pflichtfeld entsteht, ist weder „additiv und optional" noch „von optional auf Pflicht gehoben" und fiel damit durch beide Listen.
frame_id ist der Fall, an dem sie aufgefallen ist. Die Kennung entsteht, wenn der Frame
gebaut wird, und überlebt asDeferred() unverändert — daran hängt die
Duplikaterkennung des Collectors. Erzeugt wird sie vom Konsumenten, nicht hier: Das Paket
importiert nichts, und eine Entropiequelle in einem Value Object wäre die erste Logik in
elf Klassen ohne Logik.
Der Zeitstempel ist auf Y-m-d\TH:i:s.v\Z festgelegt — UTC, Millisekunden, literales Z.
Das Z ist dabei literal und keine Zeitzonenangabe: wer einen Zeitpunkt in einer
anderen Zone formatiert, muss ihn vorher umrechnen, sonst ist die Beschriftung falsch.
Öffentliche API
Semantic Versioning gilt für das gesamte Paket. Es gibt hier nichts Internes: jede
Konstante, jeder Enum-Wert und jeder Feldname in toArray() ist Vertragstext, auf den
sich die Gegenseite verlässt. tests/Unit/ArchitectureTest::testNothingIsInternal() hält
das fest.
Änderungen stehen in CHANGELOG.md.
Entwicklung
Keine Infrastruktur nötig — das Paket hat weder Container noch Broker noch Datenbank:
composer install vendor/bin/phpunit # Tests vendor/bin/phpstan analyse # statische Analyse, Level 9 vendor/bin/php-cs-fixer fix --dry-run --diff # Coding Standards
Die Probe, an der alles hängt — das Paket darf nichts Fremdes kennen:
grep -rEn 'Symfony\\|Psr\\' src/ ; echo "exit=$?" # erwartet: keine Treffer, exit=1
Lizenz
MIT — siehe LICENSE.