Search by

projektmotor / ids-event-data

projektmotor

Das Drahtformat der IDS-Ereignisse: Feldnamen, Wertelisten, Value Objects, Frame — reines PHP, ohne Framework

Package info

github.com/projektmotor/ids-event-data

pkg:composer/projektmotor/ids-event-data

Statistics

Installs: 80

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

0.6.0 2026-09-01 22:12 UTC

This package is auto-updated.

Last update: 2026-09-01 22:20:33 UTC


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.