Tutorial: your first detection
Build a complete threat → objective → rule chain, validate it, fix an error, and dry-run a deploy — end to end.
The quickstart runs commands against an existing repo. This tutorial is different: you will author a real detection chain from nothing and take it through the whole lifecycle. By the end you will have a threat, an objective, and a Sentinel rule that reference each other, pass validation, and are ready to deploy.
Budget 15–20 minutes. You need Python 3.10+ and opentide installed — see Installation.
Everything here mirrors the conformance fixtures. If you get stuck, those files are known-good references.
Scaffold a repository
opentide setup --yes \
--name "Tutorial Detections" \
--org "Example Corp" \
--platform sentinel \
--path ./tutorial-detections
cd tutorial-detections
export OPENTIDE_REPO_ROOT="$PWD"You now have objects/{threats,objectives,rules}/, a .opentide/ config directory, and platform scaffolding. See Repository setup for what each piece is.
Generate the framework artifacts
Before validation can work, the schemas and templates must exist:
opentide generate✓ docs · 7 files · 1.2s · docs/
✓ exports · 3 files · 0.0s · .opentide/exports/
✓ vocabularies · 0 files · 0.0s
✓ templates · 4 files · 1.9s · .opentide/templates/
✓ schemas · 6 files · 2.6s · .opentide/schemas/
✓ snippets · 1 file · 0.0s · .vscode/
✓ Generated 21 files in 5.8s · 6 phasesThis is the output with --no-color, in a pipe or in CI: one line per finished phase, with the files it wrote, how long it took and where they went, then the outcome line. At a terminal the phases draw as a live checklist with a spinner on the running one. Phase lines go to stderr and the outcome line to stdout, so opentide generate > log.txt keeps the phases on your terminal. Artifacts land in .opentide/templates/, .opentide/schemas/, and the IDE router at .opentide/schemas/opentide.schema.json.
Your editor can now validate YAML under objects/ because opentide setup vscode maps objects/**/*.yaml to the router, which applies the schema revision each object names in metadata.schema.
Author the threat
Create objects/threats/simulated-actor.yaml:
name: Simulated Actor
criticality: High
metadata:
uuid: 00000000-0000-4000-8001-000000000001
schema: threat::1.0
version: 1
created: 2026-01-01
modified: 2026-01-02
tlp: clear
author: Tutorial Author
organisation:
uuid: 00000000-0000-4000-8000-000000000099
name: Example Corp
threat:
description: Simulated threat actor exercising credential access
severity: Substantial incident
impact:
- Data Breach
leverage:
- Information Gathering
viability: Likely
terrain: Endpoint workstations and user devices.
surface:
- OS::Windows::Desktop
mitre_attack:
- T1059
actors:
- name: att&ck::G0006threat.impact and threat.leverage are lists with one vocabulary name per item. Add another - … line for each extra name; a single string, or several names joined with ;, fails validation.
threat.actors is a list of objects. name is a scoped actors-vocabulary ID (att&ck::G0006 is APT1). Optional sighting and references record why that actor is attributed. opentide generate enriches name when it writes the objects export.
Generate real UUIDs for your own content (python -c "import uuid; print(uuid.uuid4())"). The zero-padded UUIDs here match the fixtures so the tutorial is easy to follow — never hand-copy UUIDs into real objects.
Author the objective
The objective covers the threat (by UUID) and declares the signals that satisfy it. Create objects/objectives/credential-access-objective.yaml:
name: Credential Access Objective
metadata:
uuid: 00000000-0000-4000-8002-000000000001
schema: objective::1.0
version: 1
created: 2026-01-01
modified: 2026-01-02
tlp: clear
author: Tutorial Author
organisation:
uuid: 00000000-0000-4000-8000-000000000099
name: Example Corp
composition:
strategy: Combined
description: Compose signals for credential access detection
objective:
priority: High
type: Threat
description: Detect credential access techniques
composition:
strategy: Combined
description: Compose signals for credential access detection
threats:
- 00000000-0000-4000-8001-000000000001 # the threat from step 3
signals:
- name: Suspicious logon signal
uuid: 00000000-0000-4000-8099-000000000001
description: Suspicious authentication activity
severity: Medium
methodology: Statistical
entities: [Host::Hostname]
data:
availability: Complete
requirements: Security event logsAuthor the rule
The rule implements the objective (via detection_model) and carries a Sentinel query. Create objects/rules/sentinel-kql-rule.yaml:
name: Sentinel KQL Rule
metadata:
uuid: 00000000-0000-4000-8003-000000000001
schema: rule::1.0
version: 1
created: 2026-01-01
modified: 2026-01-02
tlp: clear
author: Tutorial Author
organisation:
uuid: 00000000-0000-4000-8000-000000000099
name: Example Corp
description: Detects credential access via suspicious process creation
status: STAGING
severity: High
techniques: [T1059]
detection_model: 00000000-0000-4000-8002-000000000001 # the objective from step 4
response:
alert_severity: High
configurations:
sentinel:
enabled: true
name: Sentinel KQL Rule
status: STAGING
query: |
SecurityEvent
| where EventID == 4688
| take 1
scheduling:
frequency: PT1H
lookback: PT2H
alert:
title: Sentinel KQL Rule
suppression: false
grouping:
event: SingleAlert
alert:
enabled: falseValidation accepts a Sentinel block without grouping, but a deploy needs grouping.event and grouping.alert, so the rule sets both. See the Sentinel platform contract.
Validate
opentide validate --strict
opentide lint --strictOn success the CLI logs that all content passed validation (exit 0). Filenames must match slugify(name) (for example sentinel-kql-rule.yaml) and recommended metadata.author / metadata.organisation must be present. For a machine-readable report:
opentide --json validate{
"checks": {
"id-uniqueness": { "check": "id-uniqueness", "status": "passed" },
"uuid-format": { "check": "uuid-format", "status": "passed" },
"schema": { "check": "schema", "status": "passed" },
"configuration": { "check": "configuration", "status": "passed" }
},
"report": {
"ok": true,
"issues": [],
"warnings": [],
"stats": { "objects_checked": 3, "wall_ms": "..." }
},
"workspace": "...",
"ok": true,
"status": "passed",
"message": "Validation passed"
}See validate.
Break it on purpose
Understanding failure output is half the job. Change the rule's detection_model to a UUID that does not exist:
detection_model: 00000000-0000-4000-8002-DEADBEEF0000Re-run:
opentide validateEach finding prints as a code frame: its code, the file, line and column, the line itself with the value underlined, and what to do. A dangling detection_model is an invalid_ref from the cross-object reference check (not the threat chaining check). The process exits 1, and the last line counts the errors (✗ 1 error in 1 file · 3 objects checked · 0.9s):
error[invalid_ref]: Unknown objective or signal reference '00000000-0000-4000-8002-DEADBEEF0000'
╭─▶ objects/rules/sentinel-kql-rule.yaml:17:18
│
16 │ techniques: [T1059]
17 │ detection_model: 00000000-0000-4000-8002-DEADBEEF0000 # the objective from step 4
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ no object has this id
│
╰─ help: opentide info objectives lists the ones that existopentide explain invalid_ref says what the code means and how to fix it. With --json, the same finding is in report.issues with line, column, help and explain. This is the dangling reference anti-pattern from the object model. Restore the correct UUID and validation passes again.
Check the coverage graph
opentide info
opentide --json info --technique T1059 coverageinfo reports the package version, your object counts, and each platform's deploy/validate capabilities. The coverage query returns the same payload plus a coverage object listing the rules whose techniques include T1059, trimmed here to that key:
{
"coverage": {
"technique": "T1059",
"rules": ["00000000-0000-4000-8003-000000000001"],
"count": 1
}
}Coverage counts rules only; the objective and threat your rule links to are not listed. To see the chain itself, use the Mermaid diagram in generate docs output or the MCP get_chaining tool. See info.
Validate the query and dry-run the deploy
Sentinel supports query validation. A bare validate query does not scan the KQL. It exits 2 and names --live:
opentide validate query --platform sentinelerror: Query validation checks the tenant. Re-run with --live.--live sends the catalogue queries to Azure Monitor. That needs tenant credentials; the Azure SDK ships with opentide. Then dry-run the deploy. The rule's status is STAGING, one of the default statuses, which maps to the PREVIEW strategy, so a PREVIEW run deploys it. --all reads every rule file instead of only the ones the last commit changed:
opentide deploy --platform sentinel --dry-run --strategy PREVIEW --allDeploy plan · strategy PREVIEW · 1 platform, 0 tenants · dry run, nothing is sent
sentinel · 0 tenants
+ create or update Sentinel KQL Rule …000000000001 status STAGING
warning: sentinel has no tenants configured in .opentide/configurations/platforms/sentinel.toml, so a real deploy would stop
Plan: 0 to create, 0 to update, 1 to create or update, 0 to disable, 0 to delete
help: add (or uncomment) a [[tenants]] entry in .opentide/configurations/platforms/sentinel.toml--dry-run selects the rules without touching the platform. In CI, the pull request job runs --strategy PREVIEW and the default-branch job --strategy RELEASE; see Statuses, strategies and tenants. This scaffold still has [[tenants]] commented out, so the dry-run warns that a real deploy would stop and leaves Sentinel out of deployed until you uncomment a tenant. The plan lists each rule with what a deploy would do to it: Sentinel writes a rule by its UUID, so opentide cannot tell a create from an update without asking Sentinel and says create or update. opentide --json deploy --platform sentinel --dry-run --strategy PREVIEW --all returns the selected rules, the tenants each would reach, and a payload preview for each rule (see deploy output). A real deploy needs credentials; remove --dry-run when you are ready.
Generate documentation
opentide generate docsThis renders wiki-style markdown for each object under docs/rules, docs/objectives, and docs/threats, including a Mermaid diagram of the chain you just built. See generate docs.
What you built
flowchart LR
Rule["Sentinel KQL Rule<br/>rule::1.0 · STAGING"] -->|detection_model| Objective["Credential Access<br/>objective::1.0"]
Objective -->|threats| Threat["Simulated Actor<br/>threat::1.0"]
Next steps
- Detection-as-code — the daily authoring and review loop.
- CI/CD — gate this validation on every pull request.
- Configuration — real credentials, statuses, and promotion.
- Troubleshooting — when a step above does not behave.