SIEM events (OCSF)¶
qed ships OCSF-shaped JSON so a decision lands in the same dashboard as one
from an identity provider or a cloud audit log.
observability:
siem:
format: ocsf
webhook_env: QED_SIEM_WEBHOOK # a URL with a token in it
stdout: false # one JSON object per line
webhook_env rather than webhook, for the same reason the database DSN is
named rather than inlined: a Splunk HEC or Slack webhook URL carries its token
in the path or query, and qed.yaml is a file somebody commits. webhook
accepts a literal for a URL that genuinely has no secret in it.
It is a copy, not the record¶
This matters more than anything else on the page. The chain is the
authority; qed-verify checks it. A SIEM event is derived, unsigned, and
designed to be dropped when a webhook is down. Nothing here is on the decision
path, nothing is retried into the chain, and a SIEM that never receives an
event changes nothing about what was decided or recorded.
Every event says so, in unmapped:
"qed_record_hash": "5f9168e7…",
"qed_key_id": "ed25519:4575180164371e99",
"qed_verify_with": "qed export | qed-verify --keys <public dir>"
The mapping¶
| Evidence record | OCSF class | Why |
|---|---|---|
verdict |
Authorization (3003) | the class for "a request was allowed or denied" |
approval_decided |
Authorization (3003) | an authorization decision made by a person rather than a rule |
| everything else | API Activity (6003) | a call that happened, with the operation named |
Approvals are deliberately not an Account Change or Entity Management class. Splitting "who allowed this?" across two dashboards is the outcome worth avoiding.
Authorization¶
{
"class_uid": 3003, "class_name": "Authorization",
"activity_id": 1, "activity_name": "Assign Privileges",
"action": "ALLOW",
"severity_id": 1,
"actor": {"user": {"uid": "<principal hash>"}},
"authorizations": [
{"decision": "ALLOW", "policy": {"uid": "AG-DEF-001", "name": "No rule matched…"}}
],
"metadata": {"labels": ["policy_hash:95e66abd…"]}
}
activity_id is OCSF's: 1 allowed, 2 denied, 0 unknown.
REQUIRE_APPROVAL is 0 — OCSF has no third value, and forcing it to Allow
would report a grant that has not happened. The real decision is always in
action, verbatim.
authorizations carries the rules that decided it. Absent, not empty, when no
rule matched: an OCSF consumer reads an empty list as "we looked and there were
none", which is not what a default-verdict decision means.
Severity comes from what the decision did¶
| enforce | shadow | |
|---|---|---|
BLOCK |
4 High | 3 Medium |
REQUIRE_APPROVAL |
3 Medium | 2 Low |
ALLOW |
1 Informational | 1 Informational |
Not from the rule's own severity. A CRITICAL rule that fired in shadow mode stopped nothing, and paging on it would train people to ignore the channel.
ungated_window is the one API Activity record that is 4 High: an adapter
proceeded without a verdict.
What never leaves¶
The chain's own discipline holds here too, because a SIEM is a system with different access controls:
- principals only as hashes —
actor.user.uidis thehuman_hash, with no name, email or IP. Resolving it is your job, in your systems, with dataqednever held; - no target —
api.operationis the verb (postgres.execute_sql), neverdb:prod/orders. A target is the half of an action that names somebody's data; - no payload — not the redacted parameters, not the SQL, nothing.
tests/evidence/test_ocsf.py asserts each of those against a payload
containing exactly the things that must not appear.
Correlating¶
Three keys, in increasing order of usefulness:
"observables": [{"name": "record.hash", "type_id": 10, "value": "5f9168e7…"}],
"raw_data": "qed:qed-prod:4821",
"unmapped": {"qed_proposal_id": "01JBPROPOSAL…", "qed_seq": 4821}
qed_proposal_id is the join: a verdict event carries no action name — the
proposal event has it — and putting the two together is what a SIEM is good at.
index=qed qed_type=verdict action=BLOCK
| join qed_proposal_id [search index=qed qed_type=proposal]
| table _time actor.user.uid api.operation action authorizations{}.policy.uid
record.hash with type_id: 10 (Fingerprint) is how somebody gets from an
alert back to the evidence entry:
Delivery¶
A bounded queue and one background thread:
sendappends and returns. It never blocks, never retries inline, never raises;- when the queue is full (10,000 events, about a minute of a busy instance) the oldest is dropped. Dropping the newest would mean a burst hid itself;
- one worker, so events arrive roughly in order and a slow endpoint cannot open fifty connections;
- failures are counted and logged, not retried forever.
That last choice is deliberate. A webhook down for an hour has lost an hour of
events, and an operator needs to know that — which a count says and an infinite
retry queue does not. qed doctor reports sent, failed and dropped, with
the URL reduced to scheme, host and port because that output ends up in support
tickets.
Testing it without a SIEM¶
One JSON object per line, which is what every log collector reads — and what
docker logs | jq reads too.