PlatformAudit & Operations
Node
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.
crates/observability/chio-siem/src/manager.rscrates/observability/chio-siem/src/exporters/at fe56570Sequence 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).
The ExporterManager cursor pull
The manager is configured with a SiemConfig:
pub struct SiemConfig {
/// Path to the Chio kernel receipt SQLite database.
pub db_path: PathBuf,
/// Interval between polls for new receipts. Default: 5 seconds.
pub poll_interval: Duration,
/// Maximum number of receipts to read per poll cycle. Default: 100.
pub batch_size: usize,
/// Maximum number of retry attempts per exporter before DLQ. Default: 3.
pub max_retries: u32,
/// Base backoff in milliseconds for exponential retry (actual: base * 2^attempt). Default: 500.
pub base_backoff_ms: u64,
/// Maximum capacity of the dead-letter queue. Default: 1000.
pub dlq_capacity: usize,
/// Optional per-exporter batch rate limit. None means unlimited.
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. SIEM polling is an
/// operator surface and must not run with tenant-scoped authority.
pub read_context: ReceiptReadContext,
/// Optional path to the SIEM-owned RW cursor store (per-exporter high-water
/// mark). When set, 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. None keeps the legacy advance-regardless behavior. Distinct
/// from the read-only receipt DB.
pub cursor_db_path: Option<PathBuf>,
}On each tick the manager:
- Reuses the persistent read-only SQLite connection opened once at construction, so no new connection opens 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. It also lifts four of the wrapper's verification fields into a Splunk fields object: receipt_kind, boundary_class, result, and authorized. Those are indexed fields rather than event body, so detection content can filter on authorized without parsing the receipt.
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
Decision is an internally tagged enum: #[serde(tag = "verdict", rename_all = "snake_case")]. A deny is therefore {"verdict":"deny","reason":...,"guard":...} and the guard sits at event.decision.guard, one level up from where an externally tagged enum would put it.
sourcetype="chio:receipt" event.decision.verdict="deny" event.decision.guard="kernel"
| stats sum(event.metadata.financial.attempted_cost) as total_attempted
by event.capability_id
| sort - total_attemptedBudget denials carry "guard":"kernel", not a guard name: the charge is refused in the kernel's own deny path rather than by a pipeline guard, and metadata.financial.attempted_cost is populated only on denial receipts. A query filtered on a guard name would return nothing. Guard-attributed denials look like this instead:
sourcetype="chio:receipt" event.decision.verdict="deny" event.decision.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 used to prevent 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,
/// True when the receipt id, receipt signature, and action parameter hash verify.
pub authoritative: bool,
/// True when the embedded receipt signature verifies against the embedded kernel key.
pub signature_valid: bool,
/// True when the receipt id matches the canonical receipt body.
pub receipt_id_valid: bool,
/// True when the action parameter hash matches the canonical action parameters.
pub parameter_hash_valid: bool,
/// True when the receipt signer is pinned as a trusted kernel signer.
pub signer_trusted: bool,
/// True only for authoritative Chio-mediated allow receipts 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, and 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 grant_index, cost_charged, currency, budget_remaining, budget_total, delegation_depth, root_budget_holder, and settlement_status as dedicated fields, plus four that are omitted from the wire when absent: payment_reference, cost_breakdown, oracle_evidence, and attempted_cost, which is populated only on denial receipts.
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