SIEM forwarding
fremforge writes every state-changing admin action to a per-tenant audit log with a hash-chained integrity guarantee (see Audit chain integrity). The same events can be fanned out to your SIEM as soon as they’re written, so your detection rules see them in seconds rather than at the next CSV export.
Coming soon: the same stream will also carry one event per security finding lifecycle change: a vulnerability, licence, SAST, secret, malware or package-policy finding being opened, reopened, dismissed or resolved. See Finding events.
This page covers the forwarder shape, the wire contract for verification, and how popular SIEMs ingest it.
How delivery works
audit-emit writes the chained row and, in the same database transaction, queues the event for every enabled endpoint whose action prefixes match. A delivery worker sends each endpoint’s queue in order, one or many events per request depending on the endpoint’s format. An event is removed from the queue only when your receiver answers 2xx.
The settings live under Org admin → Integration → SIEM forwarding (/<org>/_admin/siem), and the same settings are in the REST API (/_app/api/v1/orgs/<org>/siem/endpoints).
The SIEM forwarding page has the Add endpoint form and the endpoints list: name, URL (credentials masked), status, last delivery and the number of events waiting. Each endpoint’s name opens its own page (/<org>/_admin/siem/<endpoint id>), with one card per setting:
- Overview: status, the last failure reason, the action-prefix filter, Disable/Enable and Send test event.
- Auth header: set, rotate or remove it.
- Signature: what the HMAC covers.
- Delivery: waiting events, the oldest one, the next attempt, delivered and dropped totals, and the format and batching settings.
- Replay: send past events again.
- Delete endpoint: type the endpoint’s name to confirm.
Configuring an endpoint
- Go to Org admin → SIEM forwarding → Add endpoint.
- Enter a Name (free text, unique within the org) and the URL of your receiver:
https://…, orsyslog+tls://host:portfor syslog over TLS.http://,localhost, RFC1918 and metadata addresses are refused; use a publicly reachable receiver. - Pick the Format (see Formats and batching). The remaining steps are under Advanced options and can also be changed later on the endpoint’s page.
- Optionally narrow the Action prefixes allowlist (e.g.
forgejo.push, billing.). Empty means forward everything. Each prefix is matched withaction.startsWith(prefix). - Optionally turn on Include registry access log to also receive one event per package or container registry request (
registry.access.*). It is off by default; see Registry access log. The image gates’ pull decisions,registry.pull.blockedandregistry.pull.would_block, are audit events, not access events, so they are forwarded without this option, one per image digest, gate and decision per hour; see Container pull gates. Coming soon: Include finding events, ticked by default, will make the endpoint also receive one event per security finding lifecycle change (security.finding.*), starting from the moment it is created. Until it ships, the form does not show it. See Finding events. - Optionally set an Auth header: a header name and a secret value, sent on every request (for example
AuthorizationandSplunk <token>). See Authentication. - Choose what the signature covers (timestamp and body by default, or body only).
- Set the batch size, request size and wait.
- Click Create endpoint. The HMAC shared secret is shown once on the next screen; copy it into your receiver config. fremforge never shows it again. If you lose it, delete the endpoint and add it again.
The endpoint goes live immediately; the confirmation links to its page. Send test event sends a synthetic fremforge.siem.test event in the endpoint’s format, with its headers and signature, and shows the response status and the first 200 bytes of the receiver’s answer.
Credentials in the URL. Some collectors take their credential in the URL (?token=, ?key=&secret=, or user:password@). fremforge stores the URL as entered encrypted, and shows it everywhere else (admin page, API, test result, audit log) with every query value and the user info replaced by ***. Only the delivery uses the full URL.
Authentication
Two mechanisms, usable together:
- Auth header. One header per endpoint, name and value of your choice. The value is encrypted at rest and is write-only: no page, API response, log line or audit event contains it. The audit log records the header name and when it was set. To rotate, save a new value; the old one stops being sent at once, so add the new key to your collector first. Header names that would break the request are refused:
Host,Content-Length,Content-Type,Content-Encoding,Transfer-Encoding,Connection,Keep-Alive,Upgrade,TE,Trailer,Expect,Accept-Encoding,User-Agent,Cookie,Proxy-*andX-Fremforge-*. Values are printable ASCII, up to 4096 characters. - HMAC signature. Every request carries
X-Fremforge-Signature: sha256=<hex>, keyed by the endpoint’s HMAC secret. By default it covers<timestamp>.<body>, so your receiver can reject an old request by its timestamp. With Signature covers: body only it covers the raw body alone, which is what receivers with built-in body verification (Elastic’s HTTP endpoint input) check. A body-only receiver cannot reject a replayed request by age; deduplicate by eventidinstead.
Wire contract
Every HTTPS delivery is a POST:
| Header | Value |
|---|---|
Content-Type | application/json (json, json_array), application/x-ndjson (ndjson), text/plain; charset=utf-8 (cef) |
User-Agent | fremforge-siem-forwarder/1 |
X-Fremforge-Timestamp | Unix seconds (string) when fremforge built the request |
X-Fremforge-Signature | sha256=<hex> over <timestamp>.<body>, or over <body> in body-only mode |
Idempotency-Key | The event id for a single event; for a batch, a SHA-256 of the batch’s event ids in order |
X-Fremforge-Event-Count | Number of events in the body |
| your auth header | When set |
Each event has this shape (top-level keys in this order; fields.* keys sorted alphabetically so the bytes are stable):
{
"id": "00000000-0000-0000-0000-000000000000",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"actor": "alice",
"action": "policy.update.api",
"fields": { "...": "..." },
"created_at": "2026-05-15T13:42:11.123Z",
"hash_chain": {
"prev_hash": "ab12...",
"this_hash": "cd34..."
}
}id is the audit event’s id. It is the same on every retry and replay of that event: use it to deduplicate.
actor is a bare Forgejo username for an action by a person (in the web console, and for many API calls, the user who minted the token), token:<id> for other API calls, operator:<email> for a fremverk staff action, and other <kind>:<id> forms for system components.
hash_chain.this_hash is the same hash anchored to OBS Object Lock, so your SIEM can prove an event predates a known anchor.
A registry access event (only on an endpoint with include_access_log) has the same shape, with action registry.access.<action> and "hash_chain": null: access events are not part of the audit hash chain. Its fields are the event as the access-log API returns it.
A finding event (coming soon; only on an endpoint with include_finding_events) will also have this shape, with action security.finding.<transition> and "hash_chain": null.
Formats and batching
| Format | Body | Events per request |
|---|---|---|
json (default) | One event object | 1 |
ndjson | One event object per line, each line ending in \n | Up to max events |
json_array | A JSON array of event objects | Up to max events |
cef | One CEF line per event, each ending in \n | Up to max events |
For the batched formats:
- Max events per request: 1 to 1000 (100 when you switch to a batched format without choosing).
- Max request size: 1 KiB to 4 MiB (default 1 MiB). A batch closes before it would exceed it; a single event is always sent.
- Max wait: 0 to 60 000 ms (default 0). How long a partly filled batch waits for more events before it is sent.
Events are delivered in the order they were written.
CEF field mapping
CEF:0|fremverk|fremforge|1|<action>|<action>|<severity>|<extension>
| CEF | Value |
|---|---|
| Signature ID, Name | action |
| Severity | 9 when hash_chain.this_hash is chain-broken; 7 when action contains fail, denied, deny, blocked, violation, malware, secret, breach or tamper; 5 when it contains delete, revoke, disable, remove, bypass, suspend or dismiss; otherwise 3 |
rt | created_at, milliseconds since the epoch |
externalId | id (the dedupe key) |
suser | actor |
act | action |
cat | action up to the first . |
cs1Label=tenantId, cs1 | tenant_id |
cs2Label=fields, cs2 | fields as JSON |
cs3Label=hashChainThis, cs3 | hash_chain.this_hash |
cs4Label=hashChainPrev, cs4 | hash_chain.prev_hash, empty when there is none |
dvchost | frem.sh |
Header fields escape \ and |; extension values escape \, =, and line breaks (\n, \r). A value from an audit event therefore cannot start a new field or a new line.
Finding events
Coming soon. Finding events are not available yet. This section describes them ahead of their release; until then the console and the API do not offer
include_finding_eventsor the snapshot, and nosecurity.finding.*event is sent. The changelog announces the release.
An endpoint with Include finding events on (include_finding_events in the API) receives one event each time a security finding changes state. Every finding type fremforge reports is covered:
alert_category | Finding | Source |
|---|---|---|
dependency_vulnerability | A vulnerable dependency on the default branch | Dependency scan |
image_vulnerability | A vulnerability or misconfiguration in a container image in your registry (proxied images included) or in a repository scan | Image scan |
licence | A dependency under a forbidden or restricted licence | Licence scan |
sast | A static-analysis finding on the default branch | SAST |
secret | A committed secret (never the secret itself) | Secret scan |
malware | A malicious package (OSV MAL-*), or an upload refused by the antivirus scan (ClamAV) | Dependency and registry scans, upload scanning |
package_policy | A package-registry file the package policy blocks or warns on | Package policy |
Findings of pull-request scans are not included: they come and go with each pull request. The default branch is where a finding’s lifecycle is tracked. Package-policy events are sent only for organisations where package-policy results are visible.
Event catalogue
action | When |
|---|---|
security.finding.opened | The finding is detected for the first time. |
security.finding.reopened | The finding is detected again after it was resolved (transition_reason reappeared), or its dismissal was withdrawn before it expired (undismissed). |
security.finding.dismissed | Someone dismissed the finding (risk accepted, false positive), an allowlist covers it, or an antivirus finding was overridden. actor is the user who dismissed it, or system:<kind>. |
security.finding.dismissal_expired | A time-limited dismissal ran out and the finding is open again. This includes an exception that reached its expiry. |
security.finding.exception_revoked | An approved exception that dismissed the finding was revoked, and the finding is open again. transition_reason is the trigger: manual (someone revoked it), severity_raised, new_vulnerability or approver_left. actor is the person who revoked it, or system:exception-revocation. |
security.finding.dismissal_revoked | fremforge revoked a dismissal that relied on a reachability verdict of not reachable, because a later scan contradicts it, and the finding is open again. transition_reason is reachability_changed, finding.revocation says why, and actor is system:exception-revocation. (Before 2026-10-12 this was sent as dismissal_expired.) A revoked exception is exception_revoked with the same reason. |
security.finding.resolved | The finding is no longer detected: fixed, removed, superseded by a newer scan that no longer reports it, or the package file deleted. |
security.finding.known_exploited | A dependency or image vulnerability that is open or dismissed became listed as exploited in the wild by ENISA’s EU Vulnerability Database or CISA’s KEV catalogue. Sent once per listing. transition_reason is listed (the list added it since), or new (the finding was already listed when first seen; it is sent with its opened, in either order). known_exploited names the CVEs and the lists, and actor is system:known-exploited-feed. A dismissed finding is reported too: the risk it was accepted under has changed. |
security.finding.snapshot | One per open finding, sent once when you request a snapshot. |
Changes are picked up within about a minute. A finding keeps one identity across rescans, so a rescan that reports the same finding again sends nothing; fields.original_alert_id is the same on every event about one finding.
What is sent, and from when
- Only changes from the opt-in onwards. An endpoint receives events for changes that happen after it started receiving them (
finding_events_sincein the API). Turning finding events on does not send your existing findings. - Snapshot of open findings. To load what is open now, use Send snapshot of open findings on the endpoint’s page, or
POST /_app/api/v1/orgs/<org>/siem/endpoints/<id>/finding-snapshot. It queues onesecurity.finding.snapshotevent per open finding, to that endpoint only. It can be taken once per endpoint; a second request answers409. - Default. An endpoint created after the release includes finding events unless you untick the box (or send
"include_finding_events": false). Endpoints that exist before the release are left as they were, without finding events, so a receiver built for audit events does not start receiving a new event family unannounced; turn them on per endpoint on its page or withPATCH … {"include_finding_events": true}. - Action prefixes apply as for audit events. An endpoint with prefixes receives finding events only if one of them matches, for example
security.finding.. - Delivery, batching, retry, replay, HMAC, the auth header, and the retention of undelivered events are the same as for audit events. The event
idis the same on every retry and replay: deduplicate on it.
Fields
fields of a finding event:
| Field | Value | Cortex XDM |
|---|---|---|
alert_name | Short title, e.g. CVE-2024-3094 in xz-utils (frem.sh/acme/api@sha256:…) | xdm.alert.name |
alert_category | The finding type (table above) | xdm.alert.category |
alert_severity | critical, high, medium, low or informational | xdm.alert.severity |
alert_description | Advisory summary or rule message, at most 500 characters, or null | xdm.alert.description |
original_alert_id | Stable finding id (32 hex characters), the same on every event about one finding | xdm.alert.original_alert_id |
org | Organisation slug | |
finding.type, finding.source | Finding type, and the scanner that reported it | |
finding.state, finding.previous_state | open, dismissed or resolved; the state before (null when first seen) | |
finding.transition, finding.transition_reason | The transition (as in action) and why: new, reappeared, undismissed, dismissal_expired, manual, severity_raised, new_vulnerability, approver_left, reachability_changed, dismissed, new_dismissed, not_detected, superseded, no_longer_present, allowed, package_deleted, listed, snapshot | |
finding.first_seen_at, finding.state_changed_at | When the finding was first seen; when it entered its current state | |
finding.key | The identity the finding keeps across rescans, e.g. dep:acme/api:GHSA-…:npm:lodash@4.17.20 | |
finding.row_id | The finding’s current id in the findings API; it changes when a rescan replaces the row | |
finding.console_url | Where the finding is shown in the console | |
finding.dismissal | {by, reason, expires_at, approved_by, exception_id} on a dismissed event, else null. approved_by and exception_id are set when an approved exception dismissed the finding | |
finding.exception | {id, status, approved_by, revoked_by, revoked_reason, revoked_detail} of the exception that ended, on exception_revoked and on a dismissal_expired that ended an exception; else null | |
finding.revocation | {reason, detail, revoked_verdict, revoked_at} on dismissal_revoked: the trigger (reachability_changed), what changed, the later verdict that contradicted not reachable, and when; else null | |
cvss | {score, vector} when the advisory has one, else null | |
identifiers | cve, ghsa, mal and other advisory id lists; rule_id (SAST, secret and image-misconfiguration rules, package-policy rules); licence; signature (ClamAV) | |
component | The affected component: purl, name, version, ecosystem, fixed_version, sha256. Null for SAST and secrets. For an image vulnerability the package inside the image has a name but no purl; the image’s purl is in location.image.purl. | |
location | kind (repository, image, package or upload), repository, path, line, commit_sha; image {reference, purl, proxied, tags}, with reference in the public frem.sh/<org>/<image>@sha256:<digest> form; package {type, owner, name, version, file, sha256}; upload_kind | xdm.target.resource.name (reference, repository or package) |
policy | Package-policy events only: {verdict, reasons} | |
known_exploited | On every event: {cve_ids, sources} when the finding is a dependency or image vulnerability listed as exploited in the wild (sources: euvd for ENISA EUVD, cisa_kev for CISA KEV), else null. The CVE ids are the finding’s own or its advisory’s aliases. |
Example (json format):
{
"id": "6f0b1f7e-2c55-4c55-9a0e-6b8f8f7e1a10",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"actor": "system:finding-lifecycle",
"action": "security.finding.opened",
"fields": {
"alert_category": "dependency_vulnerability",
"alert_description": "Command injection in lodash",
"alert_name": "GHSA-35jh-r3h4-6jhm in lodash 4.17.20",
"alert_severity": "high",
"component": { "purl": "pkg:npm/lodash@4.17.20", "name": "lodash", "version": "4.17.20", "ecosystem": "npm", "fixed_version": "4.17.21", "sha256": null },
"cvss": { "score": 7.2, "vector": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H" },
"finding": {
"id": "3c1d2a8e5b0f4e6a9d7c1b2a3e4f5a6b",
"key": "dep:acme/api:GHSA-35jh-r3h4-6jhm:npm:lodash@4.17.20",
"type": "dependency_vulnerability",
"source": "dep-scan",
"state": "open",
"previous_state": null,
"transition": "opened",
"transition_reason": "new",
"first_seen_at": "2026-10-08T09:12:40.120Z",
"state_changed_at": "2026-10-08T09:13:02.457Z",
"row_id": "0d6c7c43-2b1f-4f5e-8a3e-0b2f9f1c2d3e",
"console_url": "https://frem.sh/acme/_admin/code-security/deps",
"dismissal": null
},
"identifiers": { "cve": [], "ghsa": ["GHSA-35jh-r3h4-6jhm"], "mal": [], "other": [], "rule_id": null, "licence": null, "signature": null },
"known_exploited": null,
"location": { "kind": "repository", "repository": "acme/api", "path": "package-lock.json", "line": null, "commit_sha": null, "image": null, "package": null, "upload_kind": null },
"org": "acme",
"original_alert_id": "3c1d2a8e5b0f4e6a9d7c1b2a3e4f5a6b"
},
"created_at": "2026-10-08T09:13:02.457Z",
"hash_chain": null
}Finding events are not part of the audit hash chain. The change to an endpoint’s finding-event setting, and a snapshot request, are audit events (siem.endpoint.finding_events.update, siem.endpoint.finding_snapshot).
Finding events in CEF
A finding event uses the CEF mapping with these differences:
| CEF | Value |
|---|---|
| Name | alert_name (Signature ID stays the action) |
| Severity | While the finding is open (opened, reopened, dismissal_expired, exception_revoked, dismissal_revoked, snapshot): 10 critical, 8 high, 5 medium, 3 low, 1 informational. 1 for dismissed and resolved, so good news does not raise an alert like the finding did. known_exploited is at least 8, whatever the finding’s severity |
cat | alert_category (the finding type) |
msg | alert_name |
outcome | finding.state |
reason | finding.transition_reason |
start | finding.first_seen_at, milliseconds since the epoch |
cs5Label=findingId, cs5 | original_alert_id |
cs6Label=purl, cs6 | component.purl, when there is one |
flexString1Label=identifiers, flexString1 | The CVE, GHSA, MAL and other ids, rule id, licence and signature, comma-separated |
flexString2Label=location, flexString2 | The image reference, repository or package |
cfp1Label=cvssScore, cfp1 | cvss.score, when there is one |
filePath | location.path |
fileHash | component.sha256 (antivirus findings, package files) |
cs3 and cs4 are empty, as for registry access events. Over syslog, the RFC 5424 severity follows the CEF severity as for audit events: a critical finding is sent at 2 (critical), a resolved one at 6 (informational).
Syslog over TLS
An endpoint URL of the form syslog+tls://host[:port] (default port 6514) delivers over syslog instead of HTTPS:
- Transport: TCP with TLS 1.2 or later, framed by octet counting (RFC 5425). fremforge verifies the receiver’s certificate against the public CA set, for the host name in the URL, so the receiver needs a publicly trusted certificate. Use port 6514 or 443; other ports are not reachable from fremforge.
- Message: one RFC 5424 message per event,
<PRI>1 TIMESTAMP frem.sh fremforge - MSGID - MSG. PRI is facility 13 (log audit) × 8 + severity: 2 for CEF severity 9, 4 for 7, 5 for 5, otherwise 6. MSGID is theaction(up to 32 characters). MSG is a UTF-8 byte-order mark followed by the CEF line (formatcef) or the JSON event (any other format). - Batching: up to max events messages within max request size share one connection.
- No acknowledgement. Syslog has no application-level answer. A batch counts as delivered when every message has been written and your receiver closed the connection cleanly. A refused connection, a TLS failure, or a receiver that closes before the batch is written fails the batch, which is retried whole. A receiver that accepts the connection and then discards what it reads looks delivered. Deduplicate on
externalId(CEF) orid(JSON). - The auth header and the HMAC signature are HTTPS-only. Restrict your listener to fremforge with TLS and, if you need to, a source-address allowlist.
Verifying the signature
The HMAC is computed over the exact bytes of the body, in the order they arrive. Do not parse and re-serialise before verifying.
Node.js receiver (timestamp and body):
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(req, body) {
const sig = req.headers['x-fremforge-signature'];
const ts = req.headers['x-fremforge-timestamp'];
if (!sig?.startsWith('sha256=') || !ts) throw new Error('missing headers');
// 5-minute replay window. Adjust to your tolerance.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error('stale');
const expected = createHmac('sha256', SHARED_SECRET)
.update(`${ts}.${body}`)
.digest('hex');
const got = sig.slice(7); // strip "sha256="
if (expected.length !== got.length) throw new Error('bad sig');
if (!timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(got, 'hex'))) {
throw new Error('bad sig');
}
}Python receiver (timestamp and body):
import hmac, hashlib, time
def verify(headers, raw_body: bytes) -> None:
sig = headers.get('X-Fremforge-Signature', '')
ts = headers.get('X-Fremforge-Timestamp', '')
if not sig.startswith('sha256=') or not ts:
raise ValueError('missing headers')
if abs(time.time() - int(ts)) > 300:
raise ValueError('stale')
expected = hmac.new(
SHARED_SECRET.encode(),
f'{ts}.'.encode() + raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig[7:]):
raise ValueError('bad sig')In body-only mode, drop the timestamp: HMAC-SHA256 over raw_body.
Failure handling, retry and replay
- Each request has a 10 second timeout.
- A non-
2xxanswer, a timeout or a connection error leaves the events queued. The endpoint backs off: 5 seconds, doubling with each consecutive failure, at most 10 minutes, with ±20 % jitter. When your receiver answers again, the queue drains in order. - After 5 consecutive failures the endpoint shows
failingwith the last failure reason. Delivery keeps retrying; nothing needs toggling. Disabling an endpoint stops new events being queued for it; enabling it again resumes delivery at once. - A
413halves the batch. A single event your receiver still refuses as too large is dropped and counted. - Undelivered events are kept for your organisation’s audit retention (Org admin → Auth policy; 90 days by default), and at most 200 000 per endpoint. Older ones are dropped and counted.
- The Delivery card on the endpoint’s page (and the API’s
deliveryblock,dropped_total) shows the number of waiting events, the oldest one, the next attempt, and totals delivered and dropped (shown as discarded in some languages). An event is dropped, and never delivered to that endpoint unless you replay it, in these cases only: your receiver answered413to that event on its own; it was still waiting when it became older than your audit retention; the endpoint already had 200 000 events waiting, in which case the oldest go first; or the audit event itself no longer exists, which does not happen inside the retention period. Nothing else drops an event: a failing or disabled receiver only makes events wait. - Replay queues your audit events again from a point in time (and, once finding events are available, on an endpoint that includes them, its finding events of the same window, never older than when the endpoint started receiving them): Replay from (UTC) on the endpoint’s page, or
POST /_app/api/v1/orgs/<org>/siem/endpoints/<id>/replaywith{"since": "<ISO 8601>", "until": "<ISO 8601, optional>"}.sincemay be at most your audit retention ago. One request queues at most 100 000 events and returnsnext_sincewhen more remain. Events still waiting are not queued twice; replayed events keep theirid.
Delivery is at least once: a retry after a lost answer, or a replay, sends an event again. Deduplicate on id.
Popular SIEMs
| SIEM | Ingests over HTTPS via | fremforge settings | Direct from fremforge? |
|---|---|---|---|
| Cortex XSIAM | HTTP log collector | Auth header Authorization: <API key>, format json | Yes |
| Splunk | HTTP Event Collector, /services/collector/raw | Auth header Authorization: Splunk <token>, format ndjson | Yes |
| Microsoft Sentinel | Azure Monitor Logs Ingestion API | Needs a short-lived Entra ID token | No, relay |
| Elastic | Elastic Agent / Filebeat HTTP endpoint input | Signature covers body only, optionally a secret header | Yes |
| IBM QRadar | HTTP Receiver log source, or a TLS Syslog log source | Format cef | Yes, if the listener is publicly reachable |
| Google SecOps | Webhook feed | URL with ?key=&secret=, format ndjson | Yes |
Cortex XSIAM
Ingests third-party JSON through an HTTP log collector (Settings → Data Sources & Integrations → + Add New → HTTP). Choose Log Format: JSON; the Vendor and Product you enter name the dataset <vendor>_<product>_raw. The collector URL has the form https://api-<tenant external URL>/logs/v1/event and takes its API key in Authorization (Palo Alto docs).
Setup: URL = the collector URL; auth header Authorization = the collector’s API key; format json.
Example parsing and data model rules for fremforge_audit_raw
Parsing rule (Settings → Configurations → Data Management → Parsing Rules), so _time is fremforge’s event time rather than arrival time. created_at always has three fractional digits and a Z:
[INGEST:vendor="fremforge", product="audit", target_dataset="fremforge_audit_raw", no_hit=keep]
alter _time = parse_timestamp("%Y-%m-%dT%H:%M:%E3SZ", created_at);Data model rule (Settings → Configurations → Data Management → Data Model Rules):
[MODEL: dataset="fremforge_audit_raw"]
filter action != "fremforge.siem.test"
| alter
xdm.event.id = id,
xdm.event.type = "fremforge_audit",
xdm.event.original_event_type = action,
xdm.event.operation_sub_type = action,
xdm.event.description = to_json_string(fields),
xdm.observer.vendor = "fremforge",
xdm.observer.product = "fremforge",
xdm.observer.unique_identifier = tenant_id,
xdm.source.user.identifier = actor;Both assume XSIAM stores the top-level JSON keys as columns. If your dataset shows only _raw_log, read values with json_extract_scalar(_raw_log, "$.created_at") and so on.
Data model rule for finding events
Finding events (coming soon) arrive in the same dataset. Their alert fields are named after XDM’s, so they map one to one. When you use it next to the audit rule above, add | filter not (action ~= "^security\.finding\.") to the audit rule so each event is modelled once:
[MODEL: dataset="fremforge_audit_raw"]
filter action ~= "^security\.finding\."
| alter
xdm.event.id = id,
xdm.event.type = "fremforge_finding",
xdm.event.original_event_type = action,
xdm.event.outcome = json_extract_scalar(fields, "$.finding.state"),
xdm.event.outcome_reason = json_extract_scalar(fields, "$.finding.transition_reason"),
xdm.alert.name = json_extract_scalar(fields, "$.alert_name"),
xdm.alert.category = json_extract_scalar(fields, "$.alert_category"),
xdm.alert.severity = json_extract_scalar(fields, "$.alert_severity"),
xdm.alert.description = json_extract_scalar(fields, "$.alert_description"),
xdm.alert.original_alert_id = json_extract_scalar(fields, "$.original_alert_id"),
xdm.target.resource.name = coalesce(json_extract_scalar(fields, "$.location.image.reference"), json_extract_scalar(fields, "$.location.repository"), json_extract_scalar(fields, "$.location.package.name")),
xdm.observer.vendor = "fremforge",
xdm.observer.product = "fremforge",
xdm.observer.unique_identifier = tenant_id,
xdm.source.user.identifier = actor;The HTTP log collector ingests these events as log records. To raise an XSIAM alert from them, add a correlation rule on the dataset, for example on action = "security.finding.opened" and alert_severity critical or high, and group by original_alert_id so the later events about the same finding (dismissed, resolved) attach to it.
Splunk
Ingests over the HTTP Event Collector. /services/collector/raw takes the body as it is; set sourcetype=_json on the URL so each line is one event. The token goes in Authorization: Splunk <token>. With indexer acknowledgement on, the raw endpoint also needs a channel GUID; put it on the URL as channel=<GUID> (Splunk docs).
Setup: URL https://<hec-host>:8088/services/collector/raw?sourcetype=_json; auth header Authorization = Splunk <token>; format ndjson.
Microsoft Sentinel
Ingests custom JSON through the Azure Monitor Logs Ingestion API, into a custom _CL table defined by a data collection rule (DCR). The request goes to {endpoint}/dataCollectionRules/{dcrImmutableId}/streams/{streamName}?api-version=2023-01-01 with an Entra ID bearer token from the client-credentials flow (scope https://monitor.azure.com/.default), and the body must be a JSON array (Microsoft docs). The older HTTP Data Collector API reached end of support on 14 September 2026; do not build on it.
Setup: the token expires after about an hour, so a static auth header cannot carry it. Put the relay in between: it fetches and caches the token and forwards the body. Use format json_array, so the body already is the array the API wants. Map created_at to TimeGenerated in the DCR transformation.
Elastic
Ingests through the HTTP endpoint input of Elastic Agent (the Custom HTTP Endpoint Logs integration) or Filebeat. It listens on its own port (default 8000), can terminate TLS, and can check an HMAC over the body (Elastic docs).
Setup: signature covers body only; in the input set hmac.header: X-Fremforge-Signature, hmac.key: <the endpoint's HMAC secret>, hmac.type: sha256 and hmac.prefix: "sha256=". To use the input’s secret-header option as well, set the same header name and value as the endpoint’s auth header. Format json, or ndjson for batches. Index actor, action and tenant_id as keyword.
IBM QRadar
Ingests HTTP POST bodies through a log source using the HTTP Receiver protocol. QRadar runs an HTTP or HTTPS server on an Event Collector (default port 12469); HTTPS needs a CA-issued certificate, and mutual TLS is optional (IBM docs). CEF lines are parsed by QRadar’s Universal CEF DSM.
Setup: URL = the receiver’s public HTTPS URL, or syslog+tls://<collector>:6514 for a TLS Syslog log source; format cef. fremforge cannot present a client certificate, so leave mutual TLS off. When the Event Collector is not reachable from the internet, a reverse proxy or syslog relay at your edge forwards to it.
Google SecOps (Chronicle)
Ingests through a webhook feed (Settings → Feeds → Add new, source type Webhook, then pick a log type). Requests go to the feed’s ...:importPushLogs endpoint with an API key and the feed’s secret, as X-goog-api-key and X-Webhook-Access-Key headers, or as ?key=<API key>&secret=<secret>. Several newline-separated JSON objects can share one request, up to 4 MB (Google docs). There is no prebuilt fremforge parser; you need a log type and parser for these events.
Setup: URL = the feed endpoint with ?key=<API key>&secret=<secret> (stored encrypted and shown with ***); format ndjson; max request size at most 4096 KiB.
A signature-verifying relay
Sentinel, and any receiver that needs more than one header or a short-lived token, use a small service: verify the fremforge signature, then forward the body unchanged with the SIEM’s credential. This version uses only the Python standard library; set TARGET_URL and TARGET_AUTH for your SIEM. For Sentinel, add the token fetch; with format json_array the body is already the array it wants. MAX_BODY matches the default 1 MiB request size. It forwards a correctly signed event with the credential, answers 401 to a wrong signature or a timestamp more than five minutes old, and 502 when the target is unreachable.
"""fremforge SIEM forwarder -> SIEM relay: verify the signature, forward with the SIEM's credential."""
import hashlib
import hmac
import os
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ["FREMFORGE_SIEM_SECRET"].encode()
TARGET_URL = os.environ["TARGET_URL"] # e.g. https://api-<tenant>/logs/v1/event
TARGET_AUTH = os.environ["TARGET_AUTH"] # e.g. "<XSIAM API key>" or "Splunk <HEC token>"
MAX_SKEW_S = 300
MAX_BODY = 1024 * 1024
class Relay(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
if length <= 0 or length > MAX_BODY:
return self.reply(413)
body = self.rfile.read(length)
sig = self.headers.get("X-Fremforge-Signature", "")
ts = self.headers.get("X-Fremforge-Timestamp", "")
if not sig.startswith("sha256=") or not ts.isdigit():
return self.reply(401)
if abs(time.time() - int(ts)) > MAX_SKEW_S:
return self.reply(401)
expected = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig[7:]):
return self.reply(401)
req = urllib.request.Request(
TARGET_URL,
data=body,
method="POST",
headers={"Authorization": TARGET_AUTH, "Content-Type": "application/json"},
)
try:
# fremforge gives up after 10 s; answer before that.
with urllib.request.urlopen(req, timeout=4) as res:
status = res.status
except urllib.error.HTTPError as e:
status = e.code
except OSError:
status = 0
# Pass failure back, so fremforge keeps the event and retries it.
self.reply(200 if 200 <= status < 300 else 502)
def reply(self, code):
self.send_response(code)
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, fmt, *args):
pass
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("PORT", "8080"))), Relay).serve_forever()Run it behind your usual TLS terminator: fremforge delivers only to public https:// URLs. Keep the relay’s answer under 10 seconds, and return a non-2xx when the SIEM refused the event. A relay that always answers 200 loses events: fremforge treats a 2xx as delivered and removes the event from the queue.
Generic webhook
Any HTTPS endpoint that returns 2xx once it has accepted the request. Verify with the snippets above. This suits self-hosted SIEMs (Wazuh, Graylog, …) and HTTP intakes such as Logflare or Datadog.
Operator-side health
fremverk operators see every endpoint’s state on the operator portal’s SIEM health page. An alarm fires when an endpoint’s oldest undelivered event is more than 24 hours old, and we contact your organisation’s owners. Alert on the absence of fremforge events in your SIEM as well.
What’s not on the wire
- Syslog over UDP or plain TCP. Syslog is TLS only.
- Client certificates (mutual TLS) towards your receiver.
- More than one custom header per endpoint.
- Cross-tenant fan-out. Each endpoint is bound to one tenant. There is no operator-side mirror; events from your org never leave the per-tenant chain.
Related
- Audit chain integrity, what’s actually in the events, and how to verify the chain end-to-end.
- Webhooks, same wire-shape semantics for repo + org events, signed and delivered the same way.
- Hosted runner model, where runner-emitted events come from in the chain.