Audit-Logging Cookbook
Sechs produktionsreife Rezepte für Security-Audit-Logging und Telemetrie. Vom Dev-Setup (Stderr) über Compliance-Audit (NDJSON + logrotate) bis SIEM-Forwarding (Syslog) und Distributed-Tracing (OTLP-Collector). Trait-Surface, LogLevel-Skala und Sink-Architektur in der Audit-Logging Reference.
1 · Stderr (Dev-Default)
Schnellster Einstieg — kein File-Handling, kein Network. Geht direkt auf stderr und wird von journalctl/docker logs/kubectl logs eingesammelt.
use zerodds_security::logging::LogLevel;
use zerodds_security_runtime::SecurityBundle;
use zerodds_security_logging::StderrLoggingPlugin;
let sink = StderrLoggingPlugin::with_level(LogLevel::Warning);
let security_bundle = SecurityBundle::builder()
.logging_plugin(Box::new(sink))
.build();
▶ Runnable example: rust-audit-securitybundle-realpart
Output-Format:
2026-05-15T10:22:33Z CRITICAL [0102030405060708090a0b0c0d0e0f10] auth.handshake.failed: cert chain rejected
2026-05-15T10:22:34Z WARNING [aabbccddeeff0011...] pki.cert.soft_expiry: cert expires in 12 days
Severity-Cutoff über with_level(...). Default ohne Argument: LogLevel::Warning (Emergency–Warning passieren, Notice–Debug verworfen). Für Dev-Setup mit Trace-Logs:
let sink = StderrLoggingPlugin::with_level(LogLevel::Debug); // alles loggen
Production-Tipp: Stderr ist OK für stateless Container mit Sidecar-Collector (Fluent Bit, Vector, Promtail). Für Audit-Compliance brauchst du Persistenz + Retention — siehe Recipe 2 (NDJSON).
2 · NDJSON-File + logrotate
Compliance-tauglicher Persistenz-Sink. Eine Zeile = ein Event = ein JSON-Objekt. Direkt jq-/Loki-/Splunk-tauglich. Rotation extern via logrotate(8).
Code
use zerodds_security_logging::JsonLinesLoggingPlugin;
use zerodds_security::logging::LogLevel;
let sink = JsonLinesLoggingPlugin::open(
"/var/log/zerodds/audit.ndjson",
LogLevel::Notice, // Notice + Warning + Error + Critical + Alert + Emergency
)?;
▶ Runnable example: rust-audit-loggingplugin
systemd-Unit + Pfad-Permissions
# /etc/systemd/system/zerodds-publisher.service
[Service]
ExecStartPre=/usr/bin/install -d -m 0750 -o zerodds -g audit /var/log/zerodds
User=zerodds
Group=audit
# Audit-File darf nur vom Service geschrieben, vom Audit-Reader gelesen werden.
logrotate-Config
# /etc/logrotate.d/zerodds-audit
/var/log/zerodds/audit.ndjson {
daily
rotate 90 # 90 Tage Retention (anpassen auf Compliance-Window)
compress # gz
delaycompress # neueste rotated File bleibt für 1 Tag uncomprimiert
missingok
notifempty
create 0640 zerodds audit
copytruncate # ZeroDDS kennt kein SIGHUP-Reopen — copytruncate vermeidet File-Handle-Bruch
}
Warum copytruncate? JsonLinesLoggingPlugin hält ein File-Handle offen. Ein normaler Rotate (mv + create) ließe das alte File-Handle weiter in die neue inode schreiben. copytruncate kopiert die alte Datei und truncated das Original — Handle bleibt valid, Daten landen im richtigen File. Trade-off: kurzes Race-Fenster, wenige Bytes können doppelt landen. Für Audit-Compliance akzeptabel.
3 · Fan-Out — Stderr (Operator) + NDJSON (Auditor)
Operator will journalctl -f live mitlesen, Auditor will eine append-only JSONL-Datei. Beide sehen die gleichen Events — wichtig für Forensik (Operator-Log und Audit-Log dürfen nicht divergieren).
use zerodds_security_logging::{
use zerodds_security_runtime::SecurityBundle;
StderrLoggingPlugin,
JsonLinesLoggingPlugin,
FanOutLoggingPlugin,
};
use zerodds_security::logging::LogLevel;
let stderr_sink = StderrLoggingPlugin::with_level(LogLevel::Warning);
let audit_sink = JsonLinesLoggingPlugin::open(
"/var/log/zerodds/audit.ndjson",
LogLevel::Notice,
)?;
let fanout = FanOutLoggingPlugin::new()
.with(stderr_sink) // Operator-Tier (Warning-Cutoff)
.with(audit_sink); // Audit-Tier (Notice-Cutoff)
let security_bundle = SecurityBundle::builder()
.logging_plugin(Box::new(fanout))
.build();
Jeder Sink hat seinen eigenen Severity-Cutoff. Im Beispiel sehen Operator und Auditor unterschiedliche Subset-Tiefen — Operator sieht Warnings aufwärts (knapper), Auditor sieht Notice aufwärts (vollständiger). Wichtig: kein Sink darf Emergency verlieren — beide Cutoffs müssen darunter liegen.
Konsistenz-Garantie: Fan-Out broadcastet synchron in Reihenfolge — wenn Sink 1 blockiert, blockiert Sink 2 auch. Das ist by-design (keine Re-Ordering-Risiken), aber: jeder Sink darf nicht länger blockieren, als der Tick warten kann. Für 100-1000 Events/s ist das mit lokaler Datei + stderr unkritisch.
4 · Syslog → SIEM (Splunk, Graylog, rsyslog)
Enterprise-SIEM-Integration. Syslog-UDP an einen lokalen rsyslog/syslog-ng-Daemon, der dann zum SIEM forwarded.
Code
use zerodds_security_logging::SyslogLoggingPlugin;
use zerodds_security::logging::LogLevel;
let sink = SyslogLoggingPlugin::connect(
"127.0.0.1:514".parse()?, // Lokaler rsyslog (SocketAddr)
"zerodds", // App-Name im Syslog-Stream
"edge-01", // Hostname im Syslog-Stream
LogLevel::Notice,
)?;
▶ Runnable example: rust-audit-syslog
rsyslog-Config (Forwarder)
# /etc/rsyslog.d/40-zerodds.conf
# ZeroDDS taggt mit "zerodds" und LOCAL0-Facility — separate Datei + Forward.
local0.* /var/log/zerodds-syslog.log
local0.* @@siem.internal:6514;RSYSLOG_SyslogProtocol23Format
# @@ = TCP, einfaches @ = UDP. Für SIEM-Forward TCP + TLS empfohlen.
# Audit-trail-Flag: niemals droppen
$ActionResumeRetryCount -1
Splunk-Forwarder (Alternative)
Splunk-Universal-Forwarder liest die NDJSON-Datei aus Recipe 2 direkter und sauberer als Syslog. Empfehlung wenn Splunk vorhanden: NDJSON + Forwarder statt Syslog-UDP (höhere Granularität, JSON-Felder bleiben strukturiert).
UDP-Caveat: Syslog-UDP ist lossy — Datagramme >1024 Byte können fragmentiert / verworfen werden. Für Audit-Compliance ist das ein Risiko. Bevorzugt: lokaler rsyslog + TLS-Forward zum SIEM (kein UDP über Netz), oder direkt Recipe 2 (NDJSON) + Filebeat/Forwarder.
5 · OTLP-Collector (Jaeger, Tempo, Honeycomb)
Distributed-Tracing-Layer. Separater Pfad vom Security-Audit — OTLP ist operative Telemetrie. Liefert Spans (Discovery-Phasen, Reader-Match-Latency), Histogramme (Sample-Size, Publish-Throughput) und Events (Component-Lifecycle).
Code
use std::time::Duration;
use zerodds_observability_otlp::{OtlpConfig, OtlpExporter};
let cfg = OtlpConfig {
host: "otel-collector.internal".into(),
port: 4318,
service_name: "zerodds-publisher".into(),
service_version: env!("CARGO_PKG_VERSION").into(),
timeout: Duration::from_secs(5),
};
let exporter = std::sync::Arc::new(OtlpExporter::new(cfg));
// Im Tick / pro Sample:
exporter.add_span(Span { /* ... */ });
exporter.add_histogram(Histogram { /* ... */ });
// Background-Flusher (z.B. tokio Interval):
let flush_handle = exporter.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(10));
loop {
tick.tick().await;
if let Err(e) = flush_handle.flush() {
eprintln!("otlp flush failed: {e:?}");
}
}
});
▶ Runnable example: rust-audit-otlp
Jaeger-compose-Setup
# docker-compose.yml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.94.0
command: ["--config=/etc/otel-collector.yaml"]
ports:
- "4318:4318" # OTLP/HTTP
volumes:
- ./otel-collector.yaml:/etc/otel-collector.yaml:ro
jaeger:
image: jaegertracing/all-in-one:1.55
ports:
- "16686:16686" # Jaeger-UI
# otel-collector.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [jaeger]
Audit ≠ Telemetrie. Auditor liest NDJSON-File aus Recipe 2 — nicht Jaeger. OTLP ist für Operator-Dashboard, SLO-Monitoring und Performance-Investigation. Beide Schichten parallel laufen lassen.
6 · Sensitive-Data-Scrub
Audit-Logs landen in Standard-Storage (NDJSON-File, Syslog, SIEM) — sie dürfen keine Geheimnisse enthalten. LoggingPlugin::log bekommt (level, participant, category, message). Was wo passen darf:
| Feld | Was rein darf | Was niemals rein darf |
|---|---|---|
participant | Participant-GUID (16 Byte Identity-Reference) | Identity-Token, Cert-Inhalt, Private-Key-Bytes |
category | Bekannte Dot-Notation-Kategorie | Free-form User-Input (Injection-Risiko) |
message | Cert-Subject (CN, OU), Cert-Fingerprint, Topic-Name, Endpoint-Handle | Symmetric-Keys, Shared-Secrets, Nonces, Sample-Payloads, PII (User-IDs, IBANs) |
Scrub-Wrapper-Pattern
Wenn ein dritt-Plugin Custom-Events emittiert (z.B. ein eigener Auth-Plugin der Tokens loggt), Wrap es:
use zerodds_security::logging::{LogLevel, LoggingPlugin};
pub struct ScrubbingLogger<P: LoggingPlugin> {
inner: P,
}
impl<P: LoggingPlugin> LoggingPlugin for ScrubbingLogger<P> {
fn log(&self, level: LogLevel, participant: [u8; 16], category: &str, message: &str) {
// Replace bekannte Geheimnis-Patterns.
let scrubbed = scrub_secrets(message);
self.inner.log(level, participant, category, &scrubbed);
}
fn plugin_class_id(&self) -> &str {
self.inner.plugin_class_id()
}
}
fn scrub_secrets(msg: &str) -> String {
use std::sync::OnceLock;
use regex::Regex;
static RE_HEX: OnceLock<Regex> = OnceLock::new();
let re_hex = RE_HEX.get_or_init(|| {
// 64+ hex chars = wahrscheinlich Key/Nonce/Hash
Regex::new(r"\b[0-9a-fA-F]{64,}\b").unwrap()
});
re_hex.replace_all(msg, "[REDACTED-HEX]").into_owned()
}
▶ Runnable example: rust-audit-scrubbing
Compliance-Checkliste
- Kein Plaintext-Sample-Payload — wenn ein Crypto-Failure-Event den Payload loggt, ist das eine PII/Geheim-Leakage.
- Keine Shared-Secrets — Authentication-Handshake-Failures dürfen niemals den Shared-Secret im Message-Body haben (cert-fingerprints ok, secret-bytes nie).
- Cert-Subjects ja, Cert-Bodies nein — CN, OU, expiry-date sind audit-relevant; ganze PEM-Blocks im Log sind redundant + leakage-risk.
- Periodischer Audit-Log-Review — automatisierter Scan mit jq + Regex auf bekannten Secret-Patterns (Hex-Strings >64 Char, Base64-Blocks >100 Char).
Trust-Boundary: Audit-Log landet im SIEM/Auditor-Storage. Wer Zugriff darauf hat, sieht alles. Behandle den Audit-Log als quasi-public innerhalb der Organisation — die Operator-Klasse hat Zugriff, also gehört kein Material rein, das nicht für Operator OK ist.
Audit Logging Cookbook
Six production-ready recipes for security audit logging and telemetry. From the dev setup (stderr) through compliance audit (NDJSON + logrotate) to SIEM forwarding (syslog) and distributed tracing (OTLP collector). Trait surface, LogLevel scale and sink architecture in the Audit Logging Reference.
1 · Stderr (dev default)
The fastest start — no file handling, no network. Goes straight to stderr and is collected by journalctl/docker logs/kubectl logs.
use zerodds_security::logging::LogLevel;
use zerodds_security_runtime::SecurityBundle;
use zerodds_security_logging::StderrLoggingPlugin;
let sink = StderrLoggingPlugin::with_level(LogLevel::Warning);
let security_bundle = SecurityBundle::builder()
.logging_plugin(Box::new(sink))
.build();
▶ Runnable example: rust-audit-securitybundle-realpart
Output format:
2026-05-15T10:22:33Z CRITICAL [0102030405060708090a0b0c0d0e0f10] auth.handshake.failed: cert chain rejected
2026-05-15T10:22:34Z WARNING [aabbccddeeff0011...] pki.cert.soft_expiry: cert expires in 12 days
Severity cutoff via with_level(...). Default without an argument: LogLevel::Warning (Emergency–Warning pass, Notice–Debug dropped). For a dev setup with trace logs:
let sink = StderrLoggingPlugin::with_level(LogLevel::Debug); // log everything
Production tip: stderr is fine for stateless containers with a sidecar collector (Fluent Bit, Vector, Promtail). For audit compliance you need persistence + retention — see recipe 2 (NDJSON).
2 · NDJSON file + logrotate
A compliance-grade persistence sink. One line = one event = one JSON object. Directly jq/Loki/Splunk-ready. Rotation external via logrotate(8).
Code
use zerodds_security_logging::JsonLinesLoggingPlugin;
use zerodds_security::logging::LogLevel;
let sink = JsonLinesLoggingPlugin::open(
"/var/log/zerodds/audit.ndjson",
LogLevel::Notice, // Notice + Warning + Error + Critical + Alert + Emergency
)?;
▶ Runnable example: rust-audit-loggingplugin
systemd unit + path permissions
# /etc/systemd/system/zerodds-publisher.service
[Service]
ExecStartPre=/usr/bin/install -d -m 0750 -o zerodds -g audit /var/log/zerodds
User=zerodds
Group=audit
# the audit file may only be written by the service, read by the audit reader.
logrotate config
# /etc/logrotate.d/zerodds-audit
/var/log/zerodds/audit.ndjson {
daily
rotate 90 # 90 days retention (adjust to the compliance window)
compress # gz
delaycompress # the newest rotated file stays uncompressed for 1 day
missingok
notifempty
create 0640 zerodds audit
copytruncate # ZeroDDS has no SIGHUP reopen — copytruncate avoids a file-handle break
}
Why copytruncate? JsonLinesLoggingPlugin keeps a File handle open. A normal rotate (mv + create) would let the old file handle keep writing into the new inode. copytruncate copies the old file and truncates the original — the handle stays valid, data lands in the right file. Trade-off: a short race window, a few bytes may land twice. Acceptable for audit compliance.
3 · Fan-out — stderr (operator) + NDJSON (auditor)
The operator wants to read journalctl -f live, the auditor wants an append-only JSONL file. Both see the same events — important for forensics (the operator log and the audit log must not diverge).
use zerodds_security_logging::{
use zerodds_security_runtime::SecurityBundle;
StderrLoggingPlugin,
JsonLinesLoggingPlugin,
FanOutLoggingPlugin,
};
use zerodds_security::logging::LogLevel;
let stderr_sink = StderrLoggingPlugin::with_level(LogLevel::Warning);
let audit_sink = JsonLinesLoggingPlugin::open(
"/var/log/zerodds/audit.ndjson",
LogLevel::Notice,
)?;
let fanout = FanOutLoggingPlugin::new()
.with(stderr_sink) // operator tier (Warning cutoff)
.with(audit_sink); // audit tier (Notice cutoff)
let security_bundle = SecurityBundle::builder()
.logging_plugin(Box::new(fanout))
.build();
Each sink has its own severity cutoff. In the example, operator and auditor see different subset depths — the operator sees Warnings upward (tighter), the auditor sees Notice upward (more complete). Important: no sink may lose Emergency — both cutoffs must be below it.
Consistency guarantee: fan-out broadcasts synchronously in order — if sink 1 blocks, sink 2 blocks too. That's by design (no re-ordering risks), but: no sink may block longer than the tick can wait. For 100-1000 events/s this is uncritical with a local file + stderr.
4 · Syslog → SIEM (Splunk, Graylog, rsyslog)
Enterprise SIEM integration. Syslog UDP to a local rsyslog/syslog-ng daemon that then forwards to the SIEM.
Code
use zerodds_security_logging::SyslogLoggingPlugin;
use zerodds_security::logging::LogLevel;
let sink = SyslogLoggingPlugin::connect(
"127.0.0.1:514".parse()?, // local rsyslog (SocketAddr)
"zerodds", // app name in the syslog stream
"edge-01", // hostname in the syslog stream
LogLevel::Notice,
)?;
▶ Runnable example: rust-audit-syslog
rsyslog config (forwarder)
# /etc/rsyslog.d/40-zerodds.conf
# ZeroDDS tags with "zerodds" and the LOCAL0 facility — separate file + forward.
local0.* /var/log/zerodds-syslog.log
local0.* @@siem.internal:6514;RSYSLOG_SyslogProtocol23Format
# @@ = TCP, a single @ = UDP. For a SIEM forward, TCP + TLS is recommended.
# audit-trail flag: never drop
$ActionResumeRetryCount -1
Splunk forwarder (alternative)
The Splunk universal forwarder reads the NDJSON file from recipe 2 more directly and cleanly than syslog. Recommendation when Splunk is present: NDJSON + forwarder instead of syslog UDP (higher granularity, JSON fields stay structured).
UDP caveat: syslog UDP is lossy — datagrams >1024 bytes can be fragmented / dropped. For audit compliance that's a risk. Prefer: a local rsyslog + TLS forward to the SIEM (no UDP over the network), or recipe 2 (NDJSON) + Filebeat/forwarder directly.
5 · OTLP collector (Jaeger, Tempo, Honeycomb)
The distributed-tracing layer. A separate path from the security audit — OTLP is operational telemetry. Provides spans (discovery phases, reader-match latency), histograms (sample size, publish throughput) and events (component lifecycle).
Code
use std::time::Duration;
use zerodds_observability_otlp::{OtlpConfig, OtlpExporter};
let cfg = OtlpConfig {
host: "otel-collector.internal".into(),
port: 4318,
service_name: "zerodds-publisher".into(),
service_version: env!("CARGO_PKG_VERSION").into(),
timeout: Duration::from_secs(5),
};
let exporter = std::sync::Arc::new(OtlpExporter::new(cfg));
// in the tick / per sample:
exporter.add_span(Span { /* ... */ });
exporter.add_histogram(Histogram { /* ... */ });
// background flusher (e.g. a tokio interval):
let flush_handle = exporter.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(10));
loop {
tick.tick().await;
if let Err(e) = flush_handle.flush() {
eprintln!("otlp flush failed: {e:?}");
}
}
});
▶ Runnable example: rust-audit-otlp
Jaeger compose setup
# docker-compose.yml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.94.0
command: ["--config=/etc/otel-collector.yaml"]
ports:
- "4318:4318" # OTLP/HTTP
volumes:
- ./otel-collector.yaml:/etc/otel-collector.yaml:ro
jaeger:
image: jaegertracing/all-in-one:1.55
ports:
- "16686:16686" # Jaeger UI
# otel-collector.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [jaeger]
Audit ≠ telemetry. The auditor reads the NDJSON file from recipe 2 — not Jaeger. OTLP is for the operator dashboard, SLO monitoring and performance investigation. Run both layers in parallel.
6 · Sensitive-data scrub
Audit logs land in standard storage (NDJSON file, syslog, SIEM) — they must contain no secrets. LoggingPlugin::log receives (level, participant, category, message). What may go where:
| Field | What may go in | What must never go in |
|---|---|---|
participant | participant GUID (16-byte identity reference) | identity token, cert content, private-key bytes |
category | a known dot-notation category | free-form user input (injection risk) |
message | cert subject (CN, OU), cert fingerprint, topic name, endpoint handle | symmetric keys, shared secrets, nonces, sample payloads, PII (user IDs, IBANs) |
Scrub-wrapper pattern
If a third-party plugin emits custom events (e.g. your own auth plugin logging tokens), wrap it:
use zerodds_security::logging::{LogLevel, LoggingPlugin};
pub struct ScrubbingLogger<P: LoggingPlugin> {
inner: P,
}
impl<P: LoggingPlugin> LoggingPlugin for ScrubbingLogger<P> {
fn log(&self, level: LogLevel, participant: [u8; 16], category: &str, message: &str) {
// replace known secret patterns.
let scrubbed = scrub_secrets(message);
self.inner.log(level, participant, category, &scrubbed);
}
fn plugin_class_id(&self) -> &str {
self.inner.plugin_class_id()
}
}
fn scrub_secrets(msg: &str) -> String {
use std::sync::OnceLock;
use regex::Regex;
static RE_HEX: OnceLock<Regex> = OnceLock::new();
let re_hex = RE_HEX.get_or_init(|| {
// 64+ hex chars = probably a key/nonce/hash
Regex::new(r"\b[0-9a-fA-F]{64,}\b").unwrap()
});
re_hex.replace_all(msg, "[REDACTED-HEX]").into_owned()
}
▶ Runnable example: rust-audit-scrubbing
Compliance checklist
- No plaintext sample payload — if a crypto-failure event logs the payload, that's a PII/secret leak.
- No shared secrets — authentication handshake failures must never carry the shared secret in the message body (cert fingerprints OK, secret bytes never).
- Cert subjects yes, cert bodies no — CN, OU, expiry date are audit-relevant; whole PEM blocks in the log are redundant + a leak risk.
- Periodic audit-log review — an automated scan with jq + regex for known secret patterns (hex strings >64 chars, base64 blocks >100 chars).
Trust boundary: the audit log lands in SIEM/auditor storage. Whoever has access to it sees everything. Treat the audit log as quasi-public within the organisation — the operator class has access, so nothing belongs in it that isn't OK for an operator to see.