BuildOperations
SIEM Export
chio-wall siem-export tails the receipt log and sends decisions to SIEM and SOC backends with bounded retries and a dead-letter queue.
Design Constraints
The exporter runs separately from the kernel. The kernel trusted computing base (TCB) never loads an HTTP client. Instead, the SIEM manager opens its own read-only SQLite connection to the receipt database and scans forward using a sequence cursor.
- Read-only access: the exporter opens one read-only connection at construction (
SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX) and reuses it across every poll cycle. It can never mutate the receipt store. - Separate process: SIEM export is a distinct product binary,
chio-wall, that does not link againstchio-kernel. The kernel and sidecar processes never load an HTTP client; export runs out of band against the receipt database. - Idempotent exporters: the HTTP exporters accept a retried event. With a persistent delivery cursor, a failed exporter retries within its configured bounds and does not silently skip the event.
Running siem-export
Run SIEM export as its own product binary. It is not a feature flag on the chio CLI and there is no chio trust export subcommand. The export path ships as its own product binary, chio-wall(crates/products/chio-wall/, public_entrypoint = true), which drives the chio-siem ExporterManager serve loop. Point it at the receipt database and a persistent cursor store:
# Run the at-least-once SIEM export serve loop until interrupted.
$ chio-wall siem-export \
--receipt-db /var/lib/chio/receipts.sqlite3 \
--cursor-db /var/lib/chio/chio-wall-siem-cursor.sqlite3The two arguments are both required: --receipt-db is the read-only kernel receipt log, and --cursor-db is the SIEM-owned read/write store that persists each exporter's high-water mark. Export sinks and alert backends are configured through environment variables (see Product configuration); at least one SOC export sink must be configured or siem-export fails closed at startup.
Architecture
The exporter manager sits alongside the kernel process. It reads directly from the receipt SQLite file, wraps each row in a SiemEvent, and fans the batch out to every registered exporter.
Sequence cursor, not timestamps
seq, not by timestamps. That avoids clock-skew bugs and guarantees in-order delivery per receipt log.Exporters and Sinks
crates/observability/chio-siem/src/exporters/ ships seven exporter modules, not two. Splunk HEC and Elasticsearch are the worked examples below; the rest register the same way through manager.add_exporter.
| Exporter | Module | Sink |
|---|---|---|
| Splunk HEC | splunk.rs | Splunk HTTP Event Collector |
| Elasticsearch | elastic.rs | Elasticsearch _bulk NDJSON |
| OCSF | ocsf_exporter.rs | Any OCSF 1.3.0 Authorization sink |
| CEF | cef.rs | ArcSight / CEF syslog collectors |
| Datadog | datadog.rs | Datadog logs intake |
| Sumo Logic | sumo_logic.rs | Sumo Logic HTTP source |
| Webhook | webhook.rs | Generic bearer-auth webhook |
Two more modules sit alongside the exporters: alerting.rs carries the PagerDuty and OpsGenie alert backends, and metrics_sink.rs exposes a Prometheus scrape sink. chio-wall siem-export wires all of these from environment variables (see Product configuration).
ExporterManager Cursor Pull
The manager is configured with a SiemConfig:
pub struct SiemConfig {
/// Path to the kernel receipt SQLite file.
pub db_path: PathBuf,
/// How often to poll the receipt log. Default: 5 seconds.
pub poll_interval: Duration,
/// Max receipts per poll. Default: 100.
pub batch_size: usize,
/// Max retries per exporter per batch. Default: 3.
pub max_retries: u32,
/// Base backoff, doubled on each retry. Default: 500 ms.
pub base_backoff_ms: u64,
/// Dead-letter queue capacity. Default: 1000 entries.
pub dlq_capacity: usize,
/// Optional per-exporter batch throttle.
pub rate_limit: Option<RateLimitConfig>,
/// Kernel public keys trusted to produce authoritative receipts.
pub trusted_kernel_keys: BTreeSet<String>,
/// Explicit read authority for local receipt polling.
pub read_context: ReceiptReadContext,
/// Optional path to the SIEM-owned RW cursor store (per-exporter
/// high-water mark). Some(path) makes delivery at-least-once; None keeps
/// the legacy advance-regardless behavior.
pub cursor_db_path: Option<PathBuf>,
}On each tick the manager:
- Reuses the persistent read-only SQLite connection opened once at construction — no new connection per tick.
- Runs
SELECT seq, raw_json FROM chio_tool_receipts WHERE seq > cursor ORDER BY seq ASC LIMIT batch_size. - Parses each row into a
SiemEvent. - Calls
export_batchon every registered exporter. - Advances the cursor, persisting each exporter's acked high-water mark to
cursor_db_pathwhen set.
When cursor_db_path is set — which the shipped chio-wall siem-export binary always does via --cursor-db — delivery is at-least-once: the read cursor resumes at min(acked_seq), so a failed exporter forces bounded redelivery instead of a silent skip. The in-memory, reset-to-zero behavior is only the legacy fallback when no cursor store is configured, and it is safe only because the HTTP exporters dedupe on receipt identity (Splunk HEC on timestamp + receipt ID, Elasticsearch on idempotent _id upsert).
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
manager.run(cancel_rx).await;
// To stop gracefully:
let _ = cancel_tx.send(true);Batching and Rate Limiting
If rate_limit is configured, each exporter gets its own token bucket keyed by exporter name. When a bucket is empty the manager waits for capacity before sending the next batch. Burst traffic is delayed rather than silently dropped.
siem:
db_path: /var/lib/chio/receipts.sqlite
poll_interval_ms: 5000
batch_size: 100
max_retries: 3
base_backoff_ms: 500
dlq_capacity: 1000
rate_limit:
splunk_hec:
capacity: 500 # burst ceiling in receipts
refill_per_sec: 50 # sustained rate
elasticsearch:
capacity: 1000
refill_per_sec: 200Retry Policy and Dead-Letter Queue
Each exporter gets up to max_retries attempts per batch. Backoff doubles on each failure: 500 ms, 1 s, 2 s by default. When all retries are exhausted, the failed events go to the bounded DeadLetterQueue.
| Failure Mode | Behavior |
|---|---|
| Transient 5xx | Retry up to max_retries, doubling backoff. |
| Network error | Same retry loop as 5xx. |
| Elasticsearch partial failure | Surfaced as ExportError::PartialFailure; only the failed entries are retried. |
| All retries exhausted | Event lands in the DLQ. The cursor still advances. |
| DLQ full | Oldest entry is dropped and a tracing::error is logged. |
// Inspect DLQ depth from operator tooling:
let dlq_len = manager.dlq_len();DLQ events are not auto-retried
Splunk HEC
The Splunk exporter POSTs newline-separated JSON envelopes to {endpoint}/services/collector/event. Each envelope wraps the full ChioReceipt under the event key with time, sourcetype, and optional index / host fields.
use chio_egress_contract::HttpEgressContract;
use chio_siem::exporters::splunk::{SplunkConfig, SplunkHecExporter};
use std::collections::BTreeSet;
use std::time::Duration;
// Pin the exact scheme + authority the exporter may reach. SplunkHecExporter::new
// rejects a missing contract with ExportError::HttpError, so this is required in
// production, not optional.
let egress_contract = HttpEgressContract {
tenant_egress_namespace: "siem:splunk:splunk.example.com:8088".to_string(),
allowed_schemes: BTreeSet::from(["https".to_string()]),
allowed_authority_set: BTreeSet::from(["splunk.example.com:8088".to_string()]),
deny_loopback: true,
deny_link_local: true,
deny_ipv6_ula: true,
max_redirect_chain: 3,
max_response_bytes: 1024 * 1024,
};
let config = SplunkConfig {
endpoint: "https://splunk.example.com:8088".to_string(),
hec_token: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".to_string(),
sourcetype: "chio:receipt".to_string(),
index: Some("chio_audit".to_string()),
host: Some("chio-node-01".to_string()),
timeout: Duration::from_secs(30),
egress_contract: Some(egress_contract),
};
let exporter = SplunkHecExporter::new(config)?;
manager.add_exporter(Box::new(exporter));The Authorization header is Splunk {hec_token}. TLS is handled by reqwest against the system native certificate store. Every dispatch — and every redirect hop — runs through the HttpEgressContract; a config that omits it compiles but fails at new() with "Splunk HEC exporter requires an HttpEgressContract". The timeout caps a stalled collector so it cannot block the manager poll loop.
Sample SPL
sourcetype="chio:receipt" event.decision.deny.guard="monetary_budget"
| stats sum(event.metadata.financial.attempted_cost) as total_attempted
by event.capability_id
| sort - total_attemptedsourcetype="chio:receipt" event.decision.deny.guard="egress-allowlist"
| stats count by event.tool_server, event.tool_name
| sort - countElasticsearch Bulk
The Elasticsearch exporter POSTs NDJSON to {endpoint}/_bulk. Each receipt produces two lines: an index action keyed on receipt.id as _id (making the write idempotent), and the full receipt document. Partial failures (HTTP 200 with errors: true) are detected and surfaced as ExportError::PartialFailure.
use chio_egress_contract::HttpEgressContract;
use chio_siem::exporters::elastic::{
ElasticAuthConfig, ElasticConfig, ElasticsearchExporter,
};
use std::collections::BTreeSet;
use std::time::Duration;
let egress_contract = HttpEgressContract {
tenant_egress_namespace: "siem:elastic:es.example.com:9200".to_string(),
allowed_schemes: BTreeSet::from(["https".to_string()]),
allowed_authority_set: BTreeSet::from(["es.example.com:9200".to_string()]),
deny_loopback: true,
deny_link_local: true,
deny_ipv6_ula: true,
max_redirect_chain: 3,
max_response_bytes: 1024 * 1024,
};
let config = ElasticConfig {
endpoint: "https://es.example.com:9200".to_string(),
index_name: "chio-receipts".to_string(),
auth: ElasticAuthConfig::ApiKey("base64encodedkey==".to_string()),
// or Basic { username, password }
timeout: Duration::from_secs(30),
egress_contract: Some(egress_contract),
};
let exporter = ElasticsearchExporter::new(config)?;
manager.add_exporter(Box::new(exporter));Like the Splunk exporter, ElasticsearchExporter::new rejects a missing egress_contract with "Elasticsearch exporter requires an HttpEgressContract", so the contract is a required production dependency, not an optional one.
Sample Elasticsearch DSL
POST chio-receipts/_search
{
"query": {
"bool": {
"filter": [
{ "term": { "decision.verdict": "deny" } },
{ "range": { "timestamp": { "gte": "now-24h/h" } } }
]
}
},
"aggs": {
"by_guard": {
"terms": { "field": "decision.guard", "size": 20 }
}
}
}POST chio-receipts/_search
{
"size": 0,
"query": { "term": { "decision.verdict": "allow" } },
"aggs": {
"by_subject": {
"terms": {
"field": "metadata.financial.root_budget_holder",
"size": 10
},
"aggs": {
"spend": {
"sum": { "field": "metadata.financial.cost_charged" }
}
}
}
}
}The SiemEvent Wrapper
Each receipt is wrapped in a SiemEvent. Beyond the raw ChioReceipt, the wrapper carries the verification and semantic fields a SIEM needs to tell a genuine kernel-mediated allow from a trace or advisory observation, plus the hoisted financial metadata.
pub struct SiemEvent {
/// The full ChioReceipt as stored in the kernel receipt database.
pub receipt: ChioReceipt,
/// Semantic receipt class; keeps trace/advisory observations from being
/// rendered as authorization decisions.
pub receipt_kind: String,
/// Runtime mediation boundary for this receipt.
pub boundary_class: String,
/// Human-facing semantic result label.
pub result: String,
/// receipt id, receipt signature, and action parameter hash all verify.
pub authoritative: bool,
/// Embedded receipt signature verifies against the embedded kernel key.
pub signature_valid: bool,
/// receipt id matches the canonical receipt body.
pub receipt_id_valid: bool,
/// action parameter hash matches the canonical action parameters.
pub parameter_hash_valid: bool,
/// Receipt signer is pinned as a trusted kernel signer.
pub signer_trusted: bool,
/// True only for an authoritative Chio-mediated allow at a prevent boundary.
pub authorized: bool,
/// Financial metadata extracted from receipt.metadata["financial"], if present.
pub financial: Option<FinancialReceiptMetadata>,
}authorized is the field detection content should key on: it is true only for an authoritative, Chio-mediated allow at a prevent boundary — every one of authoritative, signature_valid, receipt_id_valid, parameter_hash_valid, and signer_trusted holding. receipt_kind and boundary_class carry the semantic class so a downstream rule never mistakes a shadow or advisory observation for an enforced decision.
The financial field is extracted from receipt.metadata["financial"]. It exposes cost_charged, currency, budget_remaining, budget_total, delegation_depth, root_budget_holder, settlement_status, and attempted_cost as dedicated fields.
OCSF Mapping
Chio includes an OCSF exporter (exporters/ocsf_exporter.rs); siem_event_to_ocsf normalizes each SiemEvent to the OCSF 1.3.0 Authorization event class (category 3, Identity & Access Management; class_uid 3002), preserving the wrapper's signer-trust and verification state. It does not recompute authorization from the receipt alone. The field mappings are:
| Chio Receipt Field | OCSF Field | Notes |
|---|---|---|
id | metadata.uid | UUIDv7, directly usable for pivoting |
timestamp | time | Unix seconds · multiplied by 1000 for OCSF millis |
tool_server | dst_endpoint.name | Tool server handling the call |
tool_name | api.operation | Tool invoked |
action.parameters | api.request.data | Redacted tool-call parameters |
decision (verdict) | activity_id / status_id / severity_id | Drives the activity, status, and severity trio (plus type_uid) |
decision.reason (Deny) | status_detail | Human-facing deny reason |
decision.guard (Deny) | unmapped.chio.guard | Kebab-case guard name on deny |
policy_hash | policy.uid | Policy the decision resolved against |
capability_id | observables[*], unmapped.chio.capability_id | Capability exercised |
evidence[] | enrichments[*] | One enrichment per guard |
tenant_id | unmapped.chio.tenant_id | Present when the receipt is tenant-scoped |
| full canonical JSON | raw_data | Serialize failures fall back to an Unknown event that still carries class_uid 3002 |
Guard names are stable
forbidden-path, path-allowlist, shell-command, egress-allowlist, mcp-tool, secret-leak, patch-integrity, velocity. Detection content can key on these directly without worrying about label drift.Product Configuration
chio-wall siem-export takes its receipt and cursor databases as flags; every sink and alert backend is configured through environment variables.
| Variable | Effect |
|---|---|
CHIO_SIEM_WEBHOOK_URL, CHIO_SIEM_WEBHOOK_BEARER_TOKEN | Generic webhook SOC export sink |
CHIO_SIEM_ALERT_PAGERDUTY_ROUTING_KEY, CHIO_SIEM_ALERT_PAGERDUTY_ENDPOINT | PagerDuty alert backend |
CHIO_SIEM_ALERT_OPSGENIE_API_KEY, CHIO_SIEM_ALERT_OPSGENIE_ENDPOINT | OpsGenie alert backend |
CHIO_SIEM_METRICS_ADDR | Overrides the Prometheus scrape bind (default 127.0.0.1:9090) |
At least one SOC sink is mandatory
siem-export fails closed at startup unless at least one SOC export sink is configured. A configured alert backend alone does not satisfy this: alerting and export are separate obligations.Operational Notes
- Place the exporter next to the kernel: the read-only SQLite connection works best on the same host as the writer. Remote file systems work but the poll interval may need to be larger.
- Monitor DLQ depth: any sustained non-zero
dlq_lenindicates the downstream SIEM is failing. Page on it. - Rotate HEC tokens and API keys: because idempotency is handled per-exporter, you can run two exporters with different credentials during a rotation window without double-writing.
- Test restart: restart the exporter and confirm no duplicates land in the index. Both exporters dedupe, but it is worth verifying your index template does not strip the receipt ID.
Do not disable the kernel receipt log