opentide

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 phases

This 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::G0006

threat.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 logs

Author 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: false

Validation 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 --strict

On 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-DEADBEEF0000

Re-run:

opentide validate

Each 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 exist

opentide 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 coverage

info 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 sentinel
error: 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 --all
Deploy 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 docs

This 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

On this page