siem

Get alerts into a SIEM: format any map (or an alert from detect.run()) as CEF, LEEF or a syslog line, send syslog over UDP/TCP, and forward events to Splunk (HTTP Event Collector), Elasticsearch (bulk API), Microsoft Sentinel (Log Analytics data collector API), IBM QRadar (HTTP Receiver and reference sets) or Google SecOps / Chronicle (webhook feeds). Every sender has a dry-run mode that gives back the exact payload instead of sending it. Tokens and keys are never printed.

Formatting (cef, leef, syslog_line, event) and dry runs work everywhere, including the playground. Sending to a SIEM (send_syslog, splunk, elastic, sentinel) needs the TB app on your computer.

import siem

Example

import siem

let alert = {
  "rule": "Brute force",
  "level": "high",
  "by": "203.0.113.42",
  "by_field": "ip",
  "count": 12,
}
say(siem.cef(alert))
say(siem.syslog_line("disk almost full", {"host": "web01", "app": "tb", "severity": "warning"}))

# Dry run: see the exact Splunk HEC payload without sending anything.
let hec = siem.splunk("https://splunk.example:8088", "not-a-real-token", {"dry_run": true})
let preview = hec.send([alert])
say(preview.headers.Authorization, preview.body)

Functions

cef(event, options?)

returns text

One line of CEF (ArcSight's Common Event Format), like: "CEF:0|TB|detect|3.0.0|brute_force|Brute force|8|src=... suser=... cnt=..."

event is an alert from detect.run() or any map.

Event fields

  • "vendor", "product", "version", "id" override the header.
  • "severity" overrides the header's severity (0-10).
  • "level" sets the severity (critical = 10).
  • Other fields become extensions with standard CEF keys: ip -> src, user -> suser, message -> msg, and so on.

Options

  • keys a map {your_field: cef_key} for your own key names.
Visualizehow siem.cef works

siem.cef(alert, {"keep_names": false}) gives "CEF:0|TB|detect|3.2.0|brute_force|Brute force|10|rt=1773306845000 src=203.0.113.42 cnt=2…

siem.cef: inputs flowing into the function and the resulteventBrute force (critical)by 203.0.113.42, 22 failuresoptions?keep_namesfalsesiem.cefresult · one CEF line for ArcSightCEF:0|TB|detect|3.2.0|brute_force|Bruteforce|10|rt=1773306845000 src=203.0.113.42cnt=22 reason=5+ failed logins within 600seconds from one source; then deploy logged in successfully cs1Label=userscs1=["you","admin","ubuntu","test","oracle","postgres","deploy","git","user","pi"]cs2Label=success_after cs2=true… 5 more lines

Real result: worked out by running this example with TB while the site was built.

leef(event, options?)

returns text

One line of LEEF 2.0 (IBM QRadar's format), tab-separated attributes: "LEEF:2.0|TB|detect|3.0.0|brute_force|sev=8<tab>src=...". Same event shape as cef(). options: {"delimiter": "^", "keys": {...}}.

Visualizehow siem.leef works

siem.leef({"name": "Brute force", "level": "high", "ip": "203.0.113.42", "user": "root"}, {"delimiter": "^"}) gives "LEEF:2.0|TB|detect|3.2.0|brute_force|^|sev=8^cat=high^eventName=Brute force^src=203.0.11…

siem.leef: inputs flowing into the function and the resulteventname"Brute force"level"high"ip"203.0.113.42"user"root"options?delimiter"^"siem.leefresult · one LEEF 2.0 line for QRadarLEEF:2.0|TB|detect|3.2.0|brute_force|^|sev=8^cat=high^eventName=Bruteforce^src=203.0.113.42^usrName=youeventname"Brute force"level"high"ip"203.0.113.42"user"root"options?{} (empty map)siem.leefresult · one LEEF 2.0 line for QRadarLEEF:2.0|TB|detect|3.2.0|brute_force|sev=8⇥cat=high⇥eventName=Bruteforce⇥src=203.0.113.42⇥usrName=you

Real result: worked out by running this example with TB while the site was built.

syslog_line(message_or_event, options?)

returns text

A syslog line, RFC 5424 by default, like

"<PRI>1 2026-03-12T08:02:11.000Z host app - - [tb@32473 level=\"high\" src=\"...\"] Brute force"

An alert map's fields go into the structured data.

Options

  • format "rfc3164" for the classic "<PRI>Mar 12 08:02:11 host app: message".
  • facility like "local0" (default: user).
  • severity like "warning", or 0-7 (default: from the alert's level).
  • host, app, time, msgid values for those parts of the line.
Visualizehow siem.syslog_line works

siem.syslog_line({"name": "Brute force", "level": "high", "ip": "203.0.113.42"}, {"host": "web01", "app": "tb", "time": 1773300131}) gives "<11>1 2026-03-12T07:22:11.000Z web01 tb - - [tb@32473 level=\"high\" ip=\"203.0.113.42\"…

siem.syslog_line: inputs flowing into the function and the resultmessage_or_eventname"Brute force"level"high"ip"203.0.113.42"options?host"web01"app"tb"time1773300131siem.syslog_lineresult · a syslog line (RFC 5424)<11>1 2026-03-12T07:22:11.000Z web01 tb - - [tb@32473 level="high" ip="203.0.113.42"]Brute forcemessage_or_eventname"Brute force"level"high"ip"203.0.113.42"options?host"web01"app"tb"time1773300131format"rfc3164"siem.syslog_lineresult · a syslog line (RFC 5424)<11>Mar 12 07:22:11 web01 tb: Brute force

Real result: worked out by running this example with TB while the site was built.

send_syslog(host, port, text_or_lines, proto?)

returns map

Send one syslog line (or a list of lines) to a syslog server over "udp" (default) or "tcp" (one line per message, newline-framed). Gives back {"sent": lines, "bytes"}. Only send to servers you run.

Visualizehow siem.send_syslog works

siem.send_syslog("10.0.0.5", 514, siem.cef(alert), "udp") gives {"sent": 1, "bytes": 318}

siem.send_syslog: your computer talking to another oneyour computersyslog server10.0.0.5514udpsends: one line per messagetext_or_linessiem.cef(alert)proto?"udp"result · mapsent1bytes318

Illustration: example values, drawn from what the function does.

event(alert)

returns map

The flat event that cef(), leef() and the senders build from an alert. Handy to see what will be sent, or to tweak it before sending.

Fields

  • "name", "level", "id", "time" from the alert.
  • "severity" 0-10.
  • The alert's by field, under its own name.
  • count, description and tags.
  • ip, user, host and message from the first record.
Visualizehow siem.event works

siem.event(alert) gives {"vendor": "TB", "product": "detect", "version": "3.2.0", "id": "brute_force", "name": "B…

siem.event: inputs flowing into the function and the resultalertdetect.brute_force(...)[0]nested: records, users ...siem.eventresult · the flat event the senders usename"Brute force"severity10level"critical"ip"203.0.113.42"count22user"you"host"web01"

Real result: worked out by running this example with TB while the site was built.

splunk(url, token, options?)

returns sender

A Splunk HTTP Event Collector client. The token is never printed.

Methods

  • send(events) posts each map as an event. Alerts from detect.run() are flattened with siem.event().

Options

  • index, source, host set on every event.
  • sourcetype the source type (default: "tb:detect").
  • dry_run true to get the payload back instead of sending.
  • timeout seconds to wait.
  • verify false skips the TLS certificate check. Only for a test server with a self-signed certificate: anyone on the network could then read the token.

Returns (from send)

  • "sent", "status", "ok", "response".
  • In dry-run: "dry_run": true, "url", "headers" (token hidden), "body", "count".

Example

let s = siem.splunk("https://splunk.example:8088", "my-token", {"dry_run": true})
let out = s.send([{"user": "Tanisha", "action": "login"}])
say(out.body)
Visualizehow siem.splunk works

siem.splunk("https://splunk.example:8088", "TOKEN", {"dry_run": true, "index": "security"}).send([alert]) gives {"dry_run": true, "url": "https://splunk.example:8088/services/collector/event", "headers…

siem.splunk: your computer talking to another oneyour computerSplunk HEChttps://splunk.examp…sends: POST /services/collectorgets back: 200 OKtoken"TOKEN"options?dry_runtrueindex"security"sender.send([alert]) with dry_run gives backdry_runtrueurl"https://splunk.example:8088/servic…count1body"{\"event\": {\"name\": \"Brute for…

Real result: worked out by running this example with TB while the site was built.

elastic(url, auth?, options?)

returns sender

An Elasticsearch / OpenSearch client for the bulk API.

auth can be:

  • nil no login.
  • {"user", "password"} basic auth.
  • {"api_key": "..."} an API key.
  • text a bearer token.

Methods

  • index(name, docs) indexes each map into the index name. Alerts from detect.run() are flattened and get @timestamp.

Options

  • dry_run true to get the payload back instead of sending.
  • timeout seconds to wait.
  • verify false skips the TLS certificate check. Only for a test server with a self-signed certificate: anyone on the network could then read the token.

Returns (from index)

  • "sent", "status", "response".
  • "errors" how many documents failed.

Example

let auth = {"user": "tb", "password": "secret"}
let es = siem.elastic("https://es.example:9200", auth, {"dry_run": true})
let out = es.index("alerts-tb", [{"user": "Tanisha"}])
say(out.body)
Visualizehow siem.elastic works

siem.elastic("https://es.example:9200", {"user": "tb", "password": "x"}, {"dry_run": true}).index("alerts-tb", [alert]) gives {"dry_run": true, "url": "https://es.example:9200/_bulk", "headers": {"Content-Type": "ap…

siem.elastic: your computer talking to another oneyour computerElasticsearchhttps://es.example:9…sends: POST /_bulkgets back: items indexedauth?user"tb"password"…"options?dry_runtruees.index("alerts-tb", [alert]) with dry_run gives backdry_runtrueurl"https://es.example:9200/_bulk"count1

Real result: worked out by running this example with TB while the site was built.

sentinel(workspace_id, key, options?)

returns sender

A Microsoft Sentinel / Log Analytics client. It uses the HTTP Data Collector API with the shared-key signature.

key is the workspace's primary or secondary key (base64).

Methods

  • send(log_type, records) posts records to a custom table. send("TBAlerts", records) creates the table TBAlerts_CL.

Options

  • dry_run true to get the payload back instead of sending.
  • timeout seconds to wait.
  • verify false skips the TLS certificate check. Only for a test server with a self-signed certificate: anyone on the network could then read the token.
  • url another endpoint (for tests and sovereign clouds).
  • time_field the record field holding the event time.

Returns (from send)

  • "sent", "status", "ok".

Example

let s = siem.sentinel("my-workspace-id", "c2VjcmV0", {"dry_run": true})
let out = s.send("TBAlerts", [{"user": "Tanisha"}])
say(out.url)
Visualizehow siem.sentinel works

siem.sentinel("0b1d2c3e-workspace", "c2hhcmVkLWtleQ==", {"dry_run": true}).send("TBAlerts", [alert]) gives {"dry_run": true, "url": "https://0b1d2c3e-workspace.ods.opinsights.azure.com/api/logs?ap…

siem.sentinel: your computer talking to another oneyour computerLog Analytics0b1d2c3e-workspacesends: POST signed recordsgets back: table TBAlerts_CLkey"c2hhcmVkLWtleQ=="options?dry_runtrues.send("TBAlerts", [alert]) with dry_run gives backdry_runtrueurl"https://0b1d2c3e-workspace.ods.opi…count1

Real result: worked out by running this example with TB while the site was built.

qradar(url, options?)

returns sender

An IBM QRadar client.

Methods

  • send(events) posts events as LEEF lines to url, a log source using QRadar's HTTP Receiver protocol.
  • reference_set(name, values) adds values (IPs, hashes...) to a reference set with the REST API (POST /api/reference_data/sets/bulk_load/name).

reference_set() authenticates with an authorized service token read only from the environment variable QRADAR_SEC_TOKEN; it is never printed.

Options

  • console the console address for the REST API (default: url without its port).
  • version the REST API version header (default: "20.0").
  • dry_run true to get the payload back instead of sending.
  • timeout seconds to wait.
  • verify false skips the TLS certificate check. Only for a test server with a self-signed certificate: anyone on the network could then read the token.

Example

let q = siem.qradar("https://qradar.example:12469", {"dry_run": true})
let out = q.reference_set("TB Bad IPs", ["203.0.113.42"])
say(out.url)
Visualizehow siem.qradar works

siem.qradar("https://qradar.example:12469", {"dry_run": true}).send([alert]) gives {"dry_run": true, "url": "https://qradar.example:12469", "headers": {"Content-Type": "tex…

siem.qradar: your computer talking to another oneyour computerQRadarhttps://qradar.examp…sends: POST LEEF linesgets back: 200 OKoptions?dry_runtrueq.send([alert]) with dry_run gives backdry_runtrueurl"https://qradar.example:12469"count1body"LEEF:2.0|TB|detect|3.2.0|brute_for…

Real result: worked out by running this example with TB while the site was built.

chronicle(url, options?)

returns sender

A Google Security Operations (Chronicle) client for an HTTPS webhook feed.

url is the feed's endpoint, ending in :importPushLogs.

The API key and the feed's secret key come only from the environment variables CHRONICLE_API_KEY and CHRONICLE_SECRET. They travel in headers, never in the URL, and are never printed.

Methods

  • send(events) posts the events as newline-delimited JSON. Alerts from detect.run() are flattened with siem.event().

Options

  • dry_run true to get the payload back instead of sending.
  • timeout seconds to wait.
  • verify false skips the TLS certificate check. Only for a test server with a self-signed certificate: anyone on the network could then read the token.

Example

let feed = "https://us-chronicle.googleapis.com/v1alpha/projects/p"
feed += "/locations/us/instances/i/feeds/f:importPushLogs"
let c = siem.chronicle(feed, {"dry_run": true})
say(c.send([{"user": "Tanisha", "action": "login"}]).body)
Visualizehow siem.chronicle works

siem.chronicle("https://us-chronicle.googleapis.com/v1alpha/projects/p/locations/us/instances/i/feeds/f:importPushLogs", {"dry_run": true}).send([alert]) gives {"dry_run": true, "url": "https://us-chronicle.googleapis.com/v1alpha/projects/p/location…

siem.chronicle: your computer talking to another oneyour computerGoogle SecOpshttps://us-chronicle…sends: POST :importPushLogsgets back: 200 OKoptions?dry_runtruec.send([alert]) with dry_run gives backdry_runtrueurl"https://us-chronicle.googleapis.co…count1

Real result: worked out by running this example with TB while the site was built.

Values

NameValueWhat it is
siem.severities{"informational": 2, "low": 3, "medium": 5, "high": 8, "critical": 10}

Alert level -> CEF severity (0-10) used by cef() and the senders.

Visualizehow siem.severities works

siem.severities gives {"informational": 2, "low": 3, "medium": 5, "high": 8, "critical": 10}

The value siem.severitiessiem.severitiesinformational2low3medium5high8critical10what it isAlert level -> CEF severity (0-10) used bycef() and the senders.

No inputs: call it as it is.

Real value, read from TB while the site was built.