Detection-as-code, end to end.
The quickstart shipped one detection in a single command. This is the version you run in production: a detection is a versioned, reviewed artifact in Git that is linted, tested against fixtures, back-tested against history, and promoted through environments — never hand-edited in a console.
Anatomy of a detection plan
A detection plan is declarative YAML. Three blocks do the work: when describes the events to match on the live stream, then describes what happens when they match, and the metadata above them makes the rule reviewable and attributable.
| Field | Purpose |
|---|---|
id | Stable slug. Renaming it creates a new detection; keep it for the life of the rule. |
severity | LOW · MEDIUM · HIGH · CRITICAL. Drives case priority and default response gates. |
tactic | MITRE ATT&CK tactic / technique IDs. Powers coverage maps and reporting. |
when.stream | Which fabric stream to evaluate against (identity.signin, endpoint.process, …). |
when.window | Correlation window for stateful matches. Omit for stateless, per-event rules. |
then.contain | Optional response action, with its own approval gate. See the response engine. |
YAMLdetections/svc-account-from-corp-ip.yml version: 1 id: svc-account-from-corp-ip title: Service account authenticated from a corporate IP severity: HIGH tactic: [TA0006] # Credential Access owner: detections@acme.io when: stream: identity.signin match: actor.type: service_account net.src_geo.cidr_label: corp-egress window: 5m then: create_case: true contain: action: disable-actor requires_approval: true
Test before you ship
Two commands stand between a draft and production. td detect test runs the plan against checked-in fixtures — synthetic events with expected verdicts — so the rule's logic is pinned by assertions. td detect backtest replays it across real history on the fabric, which is how you find the false positives a fixture never anticipated.
SHELLtest + backtest $ td detect test ./detections/svc-account-from-corp-ip.yml -> schema valid -> 4 fixtures . 4 passed ok all assertions green (38ms) $ td detect backtest ./detections/svc-account-from-corp-ip.yml --since 30d -> replaying 1.84B events from the fabric... -> 3 matches . 0 in allowlisted windows ok backtest complete - review at /detections/_preview/backtest
Promote through environments
Detections live in a Git repository and move with your normal review flow. A pull request runs lint, fixture tests, and a bounded back-test in CI; merging to main deploys to production with a green-gate so a regression never ships.
YAML.github/workflows/detections.yml name: detections on: [pull_request, push] jobs: validate: steps: - run: td detect lint ./detections - run: td detect test ./detections - run: td detect backtest ./detections --since 14d --max-fp 0 deploy: if: github.ref == 'refs/heads/main' steps: - run: td detect deploy ./detections --env prod --require-green
| Stage | Runs | Gate |
|---|---|---|
| Pull request | lint · test · 14-day back-test | Zero new false positives |
| Staging | Shadow-evaluate on live stream, no actions | 48h soak, match rate within band |
| Production | Full evaluation + gated response | Auto-rollback on error-rate spike |
Tune to cut false positives
The fastest way to lose a SOC's trust is a noisy rule. Tune in the plan itself — keep the exceptions in version control next to the logic they qualify, each with a reason and a ticket — rather than muting alerts downstream.
YAMLrefining the match when: stream: identity.signin match: actor.type: service_account net.src_geo.cidr_label: corp-egress window: 5m except: - actor.id: svc-backup # nightly job, ticket SEC-1182 - net.src_geo.cidr_label: vpn-admins confidence: min: 0.7 baseline: per_entity # learn normal per service account
Prefer baseline: per_entity over a global threshold wherever an entity has a stable rhythm. A service account that signs in from one CIDR every night is held to its own history, so the same rule that is quiet for it still fires loudly the first time it appears somewhere new.
Operate live detections
Once a rule is live, it is observable like any other production service. List detections by evaluation lag to find the expensive ones, and roll back to any prior version in seconds — every deploy is an immutable, signed revision on the fabric.
SHELLobserve + rollback $ td detect ls --env prod --sort eval_lag ID TITLE STATE MATCH/24H EVAL p50/p99 det_2vK4nT svc-account-from-corp-ip LIVE 3 218ms / 612ms det_91Lc0a kerberoasting-burst LIVE 11 240ms / 705ms det_77Qd1x oauth-grant-to-new-app PAUSED - - $ td detect rollback det_2vK4nT --to v4 ok det_2vK4nT reverted to v4 (was v5) . live in 1.2s
requires_approval: true queues its response for analyst sign-off. Flip it to autonomous only once the rule has soaked and you have gated it to a risk tier — see SOAR playbook authoring.Where to go next
- Detection pipeline — the five streaming stages your plan compiles into.
- SOAR playbook authoring — wire the response side of
then. - API reference — manage detections programmatically.