---
title: "Security events"
description: "Write security events in OCSF with ATT&CK tags, detect brute force, stuffing and probing in the app, and stream them to your own SIEM."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "security-events"
canonical: "https://mainsdk.dev/docs/interfaces/security"
updated: "2026-10-28"
---

# Security events

> Write security events in OCSF with ATT&CK tags, detect brute force, stuffing and probing in the app, and stream them to your own SIEM.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/security · Updated: 2026-10-28

Write security events in OCSF with ATT&CK tags, detect brute force, stuffing and probing in the app, and stream them to your own SIEM.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/security-events.md
- Category: Security and privacy

Calls:

- `security.emit({ type, severity, message, attrs?, attack? })`
- `security.on(type, fn)`

In main.yaml:

```yaml
security:
  events:
    to: [log, otel]
    min: low
    regions:
      eu: { endpoint: https://otel.eu.acme.dev:4318, headers: { authorization: secret:OTEL_EU_TOKEN } }
  detect:
    brute-force: { failures: 10, window: 5m }
    stuffing: { accounts: 20, window: 5m }
    probing: { denials: 20, window: 1m }
```

Guarantees, whatever the driver:

- Every event is OCSF with ATT&CK tags and no personal data (keyed user hash, trimmed IP)
- Never on the request path; export is buffered and drops events below high first
- Each event goes to the endpoint of its tenant's region
- Detectors run in the app with bounded counters


## Drivers

Pick one in main.yaml with `security.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (log, OTLP and events sinks) | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-SECURITY-0001` | check | A `security` value is invalid: an unknown severity, sink or detector, or a detector threshold below 2 |
| `MAIN-SECURITY-0002` | check | `to` includes `otel` and a region the app stores data in has no endpoint (no `regions.<r>` and no `telemetry.otel`) |
| `MAIN-SECURITY-0003` | runtime |  |
| `MAIN-SECURITY-0004` | runtime |  |
| `MAIN-SECURITY-0005` | runtime |  |
| `MAIN-SECURITY-0010` | runtime |  |
| `MAIN-SECURITY-0011` | runtime |  |
| `MAIN-SECURITY-0012` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Event type | 1 | 0.3 | `security-events#contract` |
| security.emit and security.on | 1 | 0.3 | `security-events#emit` |
| Producers: sign-in refusals, access denials and refused egress (others pending) | 1 | 0.3 | `security-events#producers` |
| Brute force, credential stuffing and access probing detectors | 1 | 0.3 | `security-events#detectors` |
| Log, OTLP and events sinks by region | 1 | 0.3 | `security-events#sinks` |
| Bounded export buffer | 1 | 0.3 | `security-events#buffer` |
| mainsdk logs --security and the security line in mainsdk info | 1 | 0.3 | `security-events#cli` |
| Overhead budget | 1 | 0.3 | `security-events#budget` |
| Sigma rules for the detections | 1 | 0.3 | `security-events#sigma` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| fleet | Correlation across apps and managed SIEM connectors (spec fleet-defend) |


## Quick start

```yaml title="main.yaml"
security:
  events:
    to: [log, otel]
    min: low
    regions:
      eu: { endpoint: https://otel.eu.acme.dev:4318, headers: { authorization: secret:OTEL_EU_TOKEN } }
  detect:
    brute-force: { failures: 10, window: 5m }
    stuffing: { accounts: 20, window: 5m }
    probing: { denials: 20, window: 1m }
```

```ts title="app/example.ts"
import { security } from "mainsdk";

// the app's own event: only app.* types; the SDK writes the catalogue events itself
security.emit({ type: "app.refund_spike", severity: "medium", message: "Refunds above the daily norm", attrs: { count: 41 } });

// read every sign-in refusal after it is written
const stop = security.on("auth.*", (e) => console.log(e.type, e.severity));
stop();
```

## Configuration

The `security` block of `main.yaml` sets where events go (`security.events`) and the detector thresholds (`security.detect`), as in the quick start. See the `security.*` rows in [main.yaml reference](https://mainsdk.dev/docs/reference/main-yaml). `mainsdk check` reports an invalid value as `MAIN-SECURITY-0001` and an `otel` sink with no endpoint for a region the app stores data in as `MAIN-SECURITY-0002`.

## What you get

- Every event is OCSF with ATT&CK tags and no personal data (keyed user hash, trimmed IP)
- Never on the request path; export is buffered and drops events below high first
- Each event goes to the endpoint of its tenant's region
- Detectors run in the app with bounded counters

## Calls

| Call | What it does |
| --- | --- |
| `security.emit({ type, severity, message, attrs?, attack? })` | Writes the app's own event. The type must start with `app.`; attributes must be in `telemetry@1`. A bad type is refused with `MAIN-SECURITY-0005`. |
| `security.on(type, fn)` | Calls `fn` for each event of that type (`auth.*` and `*` also work) after it is written. Returns a function that stops listening. A subscriber that throws is `MAIN-SECURITY-0004`; the others still run. |

## What the SDK writes for you

The SDK writes catalogue events from the places where an attack becomes meaningful. Each is OCSF, tagged with MITRE ATT&CK (OWASP LLM ids for model events), explained in one sentence and free of personal data: `actor.user` is a keyed hash and `actor.ip` is trimmed. The hash key is random for each process, so a hash is stable inside one run of the app and meaningless outside it.

| Event | Severity | Written when |
| --- | --- | --- |
| `auth.failed` | info | A sign-in is refused |
| `auth.brute_force`, `auth.stuffing` | high | A detector fires on sign-in refusals |
| `access.probing` | high | One actor is denied many different resources |
| `access.cross_tenant` | high | An access decision crosses a tenant |
| `egress.refused` | medium | An outbound call is refused (`MAIN-NET-0002`, `MAIN-NET-0003`) |

The detectors report; they never block. Counters are bounded, and no key is a raw id or address.

### Wired today, and not yet

Wired: sign-in refusals from auth, access denials (`access.decision`) and refused egress.

Not all producers are called yet. These types exist in the catalogue and as the module's `record` function, but their call sites are not wired: `auth.failed` from every sign-in refusal point, `inbound.csrf`, `inbound.cors`, `inbound.csp`, `ai.injection`, canary events and `seal.mismatch`. Until they are, those events do not appear.

## Where events go

- `log` is always on: the local event store and terminal. Read it with `mainsdk logs --security`.
- `otel` sends events at or above `min` to the OTLP exporter. Each event goes to the endpoint of its tenant's region (`security.events.regions.<region>`, else `telemetry.otel`).
- `events` publishes `security.<type>` through the `events` interface.

Sigma rules for the detections ship with the SDK, so the same alerts can run in your SIEM. High and critical events also raise an [alert](https://mainsdk.dev/docs/interfaces/alerts).

## CLI

```text
mainsdk logs --security [--json] [--follow]
mainsdk info
```

`mainsdk info` shows a `security` line with the sinks, the lowest severity sent, the detector thresholds and event counts by type.

## Fail static

Events never sit on the request path. Writing to the log is a queued step; export is buffered. If the buffer is full, events below `high` are dropped first and the app logs `MAIN-SECURITY-0003` once a minute with the count. If an endpoint is down, the app keeps serving and keeps the log. No sink can refuse a request.

## Errors and checks

- `MAIN-SECURITY-0001` and `MAIN-SECURITY-0002`: checks
- `MAIN-SECURITY-0003`, `-0004` and `-0005`: export buffer full, a subscriber threw, a bad `security.emit`
- `MAIN-SECURITY-0010`, `-0011` and `-0012`: brute force, credential stuffing, access probing

Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

The only driver is `builtin`, the default, with the log, OTLP and events sinks built in. You choose where events go with `security.events.to`, not with a driver, and no call changes. See [Where events go](#where-events-go).

| Driver | Provider | |
| --- | --- | --- |
| `builtin` | Built in (log, OTLP and events sinks) | default |

## Testing

`createTestApp()` from [`@mainsdk/testing`](https://mainsdk.dev/docs/interfaces/testing) runs the events module with no collector. Emit an event, flush, and read it back with `app.security.recent()`.

```ts title="app/refunds.test.ts"
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp({ yaml: "schema: 1\nproject: shop\n" });
app.security.emit({ type: "app.refund_spike", severity: "medium", message: "Refunds above the daily norm", attrs: { count: 41 } });
await app.security.flush();
assert.equal(app.security.recent("app.refund_spike").length, 1);
```

## Next

- [Alerts](https://mainsdk.dev/docs/interfaces/alerts): high and critical events raise an alert.
- [Logs, audit and telemetry](https://mainsdk.dev/docs/interfaces/log): the local event store that `log` always writes.
- [Signals](https://mainsdk.dev/docs/interfaces/signals): detections the app records and reacts to.
- [Bot defence](https://mainsdk.dev/docs/interfaces/bots): block automated abuse at the door.
