Chio/Docs

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 against chio-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:

bash
# 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.sqlite3

The 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.

rendering…
Kernel writes receipts into an append-only SQLite log; the exporter manager pulls by sequence cursor and fans out to Splunk HEC and Elasticsearch.

Sequence cursor, not timestamps

The manager scans by monotonically increasing 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.

ExporterModuleSink
Splunk HECsplunk.rsSplunk HTTP Event Collector
Elasticsearchelastic.rsElasticsearch _bulk NDJSON
OCSFocsf_exporter.rsAny OCSF 1.3.0 Authorization sink
CEFcef.rsArcSight / CEF syslog collectors
Datadogdatadog.rsDatadog logs intake
Sumo Logicsumo_logic.rsSumo Logic HTTP source
Webhookwebhook.rsGeneric 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:

crates/observability/chio-siem/src/manager.rs
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:

  1. Reuses the persistent read-only SQLite connection opened once at construction — no new connection per tick.
  2. Runs SELECT seq, raw_json FROM chio_tool_receipts WHERE seq > cursor ORDER BY seq ASC LIMIT batch_size.
  3. Parses each row into a SiemEvent.
  4. Calls export_batch on every registered exporter.
  5. Advances the cursor, persisting each exporter's acked high-water mark to cursor_db_path when 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).

rust
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.

chio-siem.yaml
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: 200

Retry 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 ModeBehavior
Transient 5xxRetry up to max_retries, doubling backoff.
Network errorSame retry loop as 5xx.
Elasticsearch partial failureSurfaced as ExportError::PartialFailure; only the failed entries are retried.
All retries exhaustedEvent lands in the DLQ. The cursor still advances.
DLQ fullOldest entry is dropped and a tracing::error is logged.
rust
// Inspect DLQ depth from operator tooling:
let dlq_len = manager.dlq_len();

DLQ events are not auto-retried

Events in the DLQ are not automatically retried. They are lost unless you drain and re-feed them. Because both exporters are idempotent, re-feeding DLQ contents is safe and produces no duplicates.

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.

rust
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

spl/denied-budget.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_attempted
spl/egress-denials-by-tool.spl
sourcetype="chio:receipt" event.decision.deny.guard="egress-allowlist"
| stats count by event.tool_server, event.tool_name
| sort - count

Elasticsearch 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.

rust
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

es/deny-by-guard.json
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 }
    }
  }
}
es/top-spenders.json
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.

crates/observability/chio-siem/src/event.rs
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 FieldOCSF FieldNotes
idmetadata.uidUUIDv7, directly usable for pivoting
timestamptimeUnix seconds · multiplied by 1000 for OCSF millis
tool_serverdst_endpoint.nameTool server handling the call
tool_nameapi.operationTool invoked
action.parametersapi.request.dataRedacted tool-call parameters
decision (verdict)activity_id / status_id / severity_idDrives the activity, status, and severity trio (plus type_uid)
decision.reason (Deny)status_detailHuman-facing deny reason
decision.guard (Deny)unmapped.chio.guardKebab-case guard name on deny
policy_hashpolicy.uidPolicy the decision resolved against
capability_idobservables[*], unmapped.chio.capability_idCapability exercised
evidence[]enrichments[*]One enrichment per guard
tenant_idunmapped.chio.tenant_idPresent when the receipt is tenant-scoped
full canonical JSONraw_dataSerialize failures fall back to an Unknown event that still carries class_uid 3002

Guard names are stable

Guard identifiers on deny receipts are drawn from a fixed kebab-case set: 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.

VariableEffect
CHIO_SIEM_WEBHOOK_URL, CHIO_SIEM_WEBHOOK_BEARER_TOKENGeneric webhook SOC export sink
CHIO_SIEM_ALERT_PAGERDUTY_ROUTING_KEY, CHIO_SIEM_ALERT_PAGERDUTY_ENDPOINTPagerDuty alert backend
CHIO_SIEM_ALERT_OPSGENIE_API_KEY, CHIO_SIEM_ALERT_OPSGENIE_ENDPOINTOpsGenie alert backend
CHIO_SIEM_METRICS_ADDROverrides 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_len indicates 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

The SIEM path reads from the kernel receipt log. Dropping or truncating that log breaks the audit chain and the SIEM stream at the same time. The receipt log is append-only for a reason. Leave it that way.