Security events
securityWrite security events in OCSF with ATT&CK tags, detect brute force, stuffing and probing in the app, and stream them to your own SIEM.
security-eventsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
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
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 }import { security } from "mainsdk/security";
// 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();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);Drivers
1 drivers. Select one to see its line in main.yaml.
security:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-SECURITY-0001 | Checkmainsdk check, before deploy | A security value is invalid: an unknown severity, sink or detector, or a detector threshold below 2 |
MAIN-SECURITY-0002 | Checkmainsdk check, before deploy | to includes otel and a region the app stores data in has no endpoint (no regions.<r> and no telemetry.otel) |
MAIN-SECURITY-0003 | RuntimeError responses and logs | See the spec |
MAIN-SECURITY-0004 | RuntimeError responses and logs | See the spec |
MAIN-SECURITY-0005 | RuntimeError responses and logs | See the spec |
MAIN-SECURITY-0010 | RuntimeError responses and logs | See the spec |
MAIN-SECURITY-0011 | RuntimeError responses and logs | See the spec |
MAIN-SECURITY-0012 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Event type | Phase 1 | 0.3 | security-events#contract |
| security.emit and security.on | Phase 1 | 0.3 | security-events#emit |
| Producers: sign-in refusals, access denials and refused egress (others pending) | Phase 1 | 0.3 | security-events#producers |
| Brute force, credential stuffing and access probing detectors | Phase 1 | 0.3 | security-events#detectors |
| Log, OTLP and events sinks by region | Phase 1 | 0.3 | security-events#sinks |
| Bounded export buffer | Phase 1 | 0.3 | security-events#buffer |
| mainsdk logs --security and the security line in mainsdk info | Phase 1 | 0.3 | security-events#cli |
| Overhead budget | Phase 1 | 0.3 | security-events#budget |
| Sigma rules for the detections | Phase 1 | 0.3 | security-events#sigma |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Fleet | Correlation across apps and managed SIEM connectors (spec fleet-defend) |
Quick start
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 }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. 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
logis always on: the local event store and terminal. Read it withmainsdk logs --security.otelsends events at or aboveminto the OTLP exporter. Each event goes to the endpoint of its tenant's region (security.events.regions.<region>, elsetelemetry.otel).eventspublishessecurity.<type>through theeventsinterface.
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.
CLI
mainsdk logs --security [--json] [--follow]
mainsdk infomainsdk 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-0001andMAIN-SECURITY-0002: checksMAIN-SECURITY-0003,-0004and-0005: export buffer full, a subscriber threw, a badsecurity.emitMAIN-SECURITY-0010,-0011and-0012: brute force, credential stuffing, access probing
Each code's cause and fix is in 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.
| Driver | Provider | |
|---|---|---|
builtin | Built in (log, OTLP and events sinks) | default |
Testing
createTestApp() from @mainsdk/testing runs the events module with no collector. Emit an event, flush, and read it back with app.security.recent().
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: high and critical events raise an alert.
- Logs, audit and telemetry: the local event store that
logalways writes. - Signals: detections the app records and reacts to.
- Bot defence: block automated abuse at the door.
Was this page helpful?