Insider Risk Detection with Elastic DaC

Build a validated TOML detection rule library for insider risk using Elastic's DaC CLI.

Introduction

30 Second Summary

Every organization faces the risk of compromised accounts and insider threats. The first warning signs hide in authentication logs: repeated failed MFA attempts, sessions from two countries at once, travel patterns that no human could produce.

In this project, you will build a custom detection rules library for Elastic Security using the Detection-as-Code workflow. You will write, validate, and export insider risk detection rules that are ready to deploy to a live SIEM.

What You'll Build

You run a single CLI command against your finished library and watch every rule validate cleanly, export to NDJSON for Kibana import, and follow your organization's exact kebab-case naming convention.

By the end of this project, you'll have:

  • A KQL-based detection rule that fires when a single user racks up multiple failed MFA challenges, flagging credential stuffing before the attacker breaks through.
  • An EQL-based detection rule that correlates sign-ins across countries, catching impossible travel the moment a compromised account lights up from two locations at once.
  • A YAML configuration layer that enforces your org's naming convention and makes every rule pass validation without fighting Elastic's upstream defaults.
  • Secret Mission: A Claude Code slash command that turns a plain English threat description into a fully validated TOML rule file in seconds.

Are there any prerequisites?

This project assumes you have Python 3.14.6 installed, VS Code with Claude Code ready to go, and basic familiarity with terminal commands. No prior detection engineering experience is needed.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters. You're creating a production-ready insider risk detection rules library using Elastic's Detection-as-Code tooling.

Set Up the Detection-Rules Environment

The detection-rules CLI is the backbone of Elastic's Detection-as-Code workflow. Without it, you cannot validate TOML rules against the schema or export them for Kibana import.

In this step, you'll clone the repository, install the CLI tooling inside a Python virtual environment, and create the custom rules directory where your organization's insider risk rules will live.

In this step, get ready to:
  • Clone the Elastic detection-rules repository and install the CLI.
  • Create a custom rules directory with YAML configuration files.
  • Verify the CLI loads your custom directory with zero errors.
Clone the repository and install the CLI

Elastic's detection-rules repository contains the CLI that validates rule schemas and exports rules to NDJSON format. You'll install it in editable mode inside a virtual environment so the python -m detection_rules command becomes available.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the repository, create a virtual environment, and install the CLI by running these commands:
git clone https://github.com/elastic/detection-rules.git
cd detection-rules
python -m venv .venv
source .venv/bin/activate
pip install -e .

What do these commands do?

  • git clone downloads the entire detection-rules repository to your Desktop.
  • python -m venv .venv creates an isolated Python environment so dependencies don't conflict with your system packages.
  • source .venv/bin/activate activates the virtual environment for your current terminal session.
  • pip install -e . installs the detection-rules package in editable mode, making the CLI available.

The pip install step takes a minute or two as it downloads dependencies.

  • Confirm the CLI installed successfully by running:
python -m detection_rules --help

You should see an ASCII banner followed by a list of available commands including create-rule, validate-all, validate-rule, and export-rules-from-repo.

Seeing an error during installation?

  • If you see a Python version error, confirm you are running Python 3.12 or higher with python --version. The detection-rules CLI requires Python 3.12+.
  • If source .venv/bin/activate fails, make sure you are inside the detection-rules directory.
  • If pip install -e . fails with a permissions error, confirm your virtual environment is activated (you should see (.venv) at the start of your terminal prompt).

Help me troubleshoot my detection-rules CLI installation. I ran pip install -e . inside the detection-rules directory but got an error.

Create the custom rules directory

Your custom detection rules live in a separate directory alongside the cloned repo. This keeps your organization's rules isolated from upstream Elastic rules, making version control and CI/CD straightforward.

  • Create the directory structure for your custom rules by running these commands:
mkdir -p ../custom-rules/rules ../custom-rules/etc
  • Confirm the directories were created by running:
ls ../custom-rules

You should see etc and rules listed.

  • Create the main configuration file by running this command:
cat << 'EOF' > ../custom-rules/_config.yaml
rule_dirs:
  - rules

files:
  deprecated_rules: etc/deprecated_rules.json
  packages: etc/packages.yaml
  stack_schema_map: etc/stack-schema-map.yaml
  version_lock: etc/version.lock.json

no_tactic_filename: True
bypass_optional_elastic_validation: True
bypass_version_lock: True
normalize_kql_keywords: True

testing:
  config: etc/test_config.yaml
EOF

What does _config.yaml control?

  • rule_dirs tells the CLI where to find your TOML rule files.
  • no_tactic_filename: True prevents the CLI from prepending MITRE tactic names to filenames, which lets you use your org's kebab-case naming convention.
  • bypass_optional_elastic_validation: True skips Elastic-specific checks (like requiring "Elastic" in the author field) that don't apply to custom rules.
  • bypass_version_lock: True disables version-lock enforcement so you can iterate freely during development.
  • Create all the supporting configuration files in the etc/ directory by running these commands:
cat << 'EOF' > ../custom-rules/etc/packages.yaml
package:
  name: custom-insider-risk-rules
  version: 0.1.0
EOF

cat << 'EOF' > ../custom-rules/etc/stack-schema-map.yaml
8.17.0:
  beats: 8.17.0
  ecs: 8.11.0
  endgame: 8.4.0
EOF

cat << 'EOF' > ../custom-rules/etc/test_config.yaml
skipped_tests:
  - tests.test_all_rules.TestRuleFiles.test_rule_file_name_tactic
EOF

echo '{}' > ../custom-rules/etc/deprecated_rules.json
echo '{}' > ../custom-rules/etc/version.lock.json

What do these files do?

  • packages.yaml names your custom rules package and its version for the CLI's packaging system.
  • stack-schema-map.yaml maps an Elastic Stack version to its schema dependencies so the CLI knows which fields are valid.
  • test_config.yaml skips the filename-tactic unit test since your org uses kebab-case names without tactic prefixes.
  • deprecated_rules.json and version.lock.json are empty placeholders the CLI expects to exist.
  • Confirm all files were created by running:
ls ../custom-rules/etc

You should see deprecated_rules.json, packages.yaml, stack-schema-map.yaml, test_config.yaml, and version.lock.json listed.

✔️ Awesome, I've got everything!

Great. Make sure you've saved all the files and you're still in the detection-rules directory with your virtual environment active.

ⓧ I'd like to double check the full code

Scroll up and confirm these files match exactly:

  • ../custom-rules/_config.yaml (the heredoc you ran for the main config).
  • ../custom-rules/etc/packages.yaml (package name and version).
  • ../custom-rules/etc/stack-schema-map.yaml (the 8.17.0 entry).
  • ../custom-rules/etc/test_config.yaml (skipped_tests with the filename tactic test).
  • ../custom-rules/etc/deprecated_rules.json and ../custom-rules/etc/version.lock.json (both just {}).

You can view any file's contents with cat:

cat ../custom-rules/_config.yaml
Verify the tooling loads your custom directory

The CLI reads the CUSTOM_RULES_DIR environment variable to find your configuration. Once set, every CLI command operates against your custom directory instead of the upstream Elastic rules.

  • Point the CLI to your custom rules directory by running:
export CUSTOM_RULES_DIR=../custom-rules

Before you run validation, what do you think the output will say? Your rules/ directory is empty. Will it error, or succeed with zero rules?

  • Run full validation to confirm the CLI loads your configuration without errors:
python -m detection_rules validate-all

You should see the detection-rules banner followed by Rule validation successful. The CLI found your config, loaded the empty rules directory, and confirmed there are no schema violations.

Seeing a configuration file not found error?

  • Make sure you are running the command from inside the detection-rules directory (where you ran pip install -e .). The relative path ../custom-rules resolves from your current working directory.
  • Check that your _config.yaml file exists at ../custom-rules/_config.yaml with ls ../custom-rules/_config.yaml.
  • Verify the environment variable is set by running echo $CUSTOM_RULES_DIR. It should print ../custom-rules.

Help me debug why python -m detection_rules validate-all cannot find my custom-rules config. I set CUSTOM_RULES_DIR but still get an error.

Your environment is locked and loaded. Next up, you'll write your first insider risk TOML rule and see it rendered by the CLI.

Write Your First Insider Risk TOML Rule

Your custom rules directory is ready to hold detection rules. The challenge now is writing a rule that passes the strict schema the detection-rules CLI enforces.

Every TOML rule file needs two mandatory sections: [metadata] for repo-only context and [rule] for the actual detection logic Kibana imports. In this step you will write a KQL-based rule that detects multiple failed MFA attempts, a common insider risk signal.

In this step, get ready to:
  • Create the TOML rule file with the metadata section.
  • Write the rule definition with required fields, a KQL query, and MITRE ATT&CK mapping.
  • View the rendered rule as JSON to confirm schema compliance.
Create the rule file with the metadata section

Your organization uses a kebab-case naming convention for rule files: <data-source>-<keywords>.toml. The first rule targets Microsoft 365 audit logs for failed MFA attempts.

  • Create the file custom-rules/rules/o365-multiple-failed-mfa-attempts.toml and add the metadata section by pasting this code:
[metadata]
creation_date = "2026/07/29"
maturity = "development"
updated_date = "2026/07/29"

What does the metadata section do?

The [metadata] table holds repo-only context that gets stripped when you export to Kibana. It tracks when the rule was created, when it was last updated, and its maturity stage.

  • creation_date uses the format YYYY/MM/DD (slashes, not hyphens).
  • maturity can be development, production, or deprecated. New rules start in development.
  • Save the file.
  • Confirm the file was created by running:
ls custom-rules/rules/

You should see o365-multiple-failed-mfa-attempts.toml listed in the output.

File not showing up?

  • Make sure you created the file inside the correct path. It belongs in the rules subdirectory of your custom-rules folder, not inside the detection-rules repo itself.
  • Check that the filename is exactly o365-multiple-failed-mfa-attempts.toml with hyphens, not underscores.

Help me find my custom-rules directory

Write the rule definition fields

The [rule] section is what Elastic's detection engine actually imports and executes. It requires specific fields like name, risk_score, severity, and a unique rule_id.

  • In o365-multiple-failed-mfa-attempts.toml, add the [rule] section below the metadata by pasting this code:
[rule]
author = ["Custom"]
description = """
Detects when a single user account generates five or more failed multi-factor authentication challenges
within a 10-minute window in Microsoft 365. This pattern may indicate credential stuffing, brute force
attempts against a compromised password, or an account takeover attempt where the attacker lacks the
second factor.
"""
from = "now-15m"
interval = "5m"
index = ["logs-o365.audit-*"]
language = "kuery"
license = "Elastic License v2"
name = "Microsoft 365 Multiple Failed MFA Attempts by User"
risk_score = 47
rule_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
severity = "medium"
tags = [
    "Domain: Cloud",
    "Data Source: Microsoft 365",
    "Use Case: Identity and Access Audit",
    "Tactic: Credential Access",
    "Use Case: Insider Risk",
]
timestamp_override = "event.ingested"
type = "query"

What do these fields mean?

  • rule_id is a UUIDv4 that uniquely identifies this rule across all deployments.
  • risk_score is an integer from 1 to 100. A score of 47 maps to medium severity (22-47 = medium).
  • from and interval control how far back the rule looks and how often it runs.
  • type = "query" tells Elastic this rule uses a simple query filter. Other types include eql, threshold, and machine_learning.
  • Save the file.
Add the detection query and MITRE ATT&CK mapping

The query is the core detection logic. This rule uses KQL (Kibana Query Language) to match failed MFA challenge events in Microsoft 365 audit logs.

  • Below the type = "query" line you just added, paste the query and threat mapping:
query = '''
event.dataset: "o365.audit" and event.category: "authentication" and
event.action: "UserLoginFailed" and
o365.audit.LogonError: "MfaChallengeNotCompleted" and event.outcome: "failure"
'''

[[rule.threat]]
framework = "MITRE ATT&CK"

[[rule.threat.technique]]
id = "T1110"
name = "Brute Force"
reference = "https://attack.mitre.org/techniques/T1110/"

[rule.threat.tactic]
id = "TA0006"
name = "Credential Access"
reference = "https://attack.mitre.org/tactics/TA0006/"

What does this code do?

  • The query field filters for events where the Office 365 audit log shows a login failure specifically caused by an incomplete MFA challenge (MfaChallengeNotCompleted).
  • The [[rule.threat]] array maps this rule to the MITRE ATT&CK framework. T1110 (Brute Force) under TA0006 (Credential Access) tells analysts exactly what adversary behavior this rule detects.
  • The triple-quoted ''' syntax in TOML lets you write multi-line strings without escaping.
  • Save o365-multiple-failed-mfa-attempts.toml.

Before you run the view command, what do you think the output will look like? Will it show TOML or a different format?

  • Render your completed rule by running:
python -m detection_rules view-rule custom-rules/rules/o365-multiple-failed-mfa-attempts.toml

The CLI outputs your rule as pretty-printed JSON (the API format Kibana expects). You should see all your fields rendered: name, risk_score, severity, your KQL query, and the MITRE ATT&CK threat mapping with T1110 and TA0006.

Why JSON output for a TOML rule?

The view-rule command defaults to API format (JSON) because that is what Kibana's detection engine actually imports. The TOML file is your human-friendly authoring format. The CLI translates between the two.

Seeing a schema error?

  • If you see an error about a missing required field, double-check that your [rule] section includes all required fields: author, description, name, risk_score, rule_id, severity, and type.
  • Make sure risk_score is an integer (no quotes around the number). TOML distinguishes between strings and integers.
  • Check that the [metadata] section appears before [rule] in your file. TOML tables are order-sensitive when parsed by the CLI.

Help me debug my TOML rule schema error

✔️ Awesome, I've got everything!

Great. Make sure you have saved o365-multiple-failed-mfa-attempts.toml with all three sections: metadata, rule fields, and the query with threat mapping.

ⓧ I'd like to double check the full code

Scroll up and confirm your file contains these three chunks in order:

  • The [metadata] section with creation_date, maturity, and updated_date.
  • The [rule] section with all required fields from author through type = "query".
  • The query field and the [[rule.threat]] mapping block at the bottom.

Make sure there is a blank line between the [metadata] section and the [rule] section, and between the query and the threat mapping.

Your first detection rule passes schema validation and renders cleanly. Next up, you will tackle the naming convention conflict between your org's kebab-case filenames and Elastic's upstream expectations.

Fix the Naming Convention Conflict

Your rule validated against the schema in the last step, but Elastic's tooling also enforces a filename convention. The upstream regex only allows lowercase letters, digits, and underscores. It also prepends the primary MITRE tactic to every filename.

Your organization uses kebab-case with no tactic prefix. When you run validation, the tool will flag your filename as non-compliant. This step teaches you the config toggles that make the Detection-as-Code tooling respect your org's convention.

In this step, get ready to:
  • Run validation and observe the filename conflict.
  • Update the config to enforce your org's kebab-case naming convention.
  • Re-run validation and confirm the fix works.
Run validation and observe the naming conflict

The detection-rules CLI computes the "correct" filename by converting your rule name to lowercase, replacing non-alphanumeric characters with underscores, and prepending the primary MITRE ATT&CK tactic. Your kebab-case filename will not match that expectation.

Before you run the next command, what do you think will happen when the tool sees a hyphenated filename that breaks its underscore-only regex?

  • Run the validation command against your rule:
python -m detection_rules validate-rule custom-rules/rules/o365-multiple-failed-mfa-attempts.toml

You will see a warning indicating your rule path does not match the required path. The tool computed credential_access_microsoft_365_multiple_failed_mfa_attempts_by_user.toml as the "correct" name.

Why does this warning exist?

Elastic's upstream repository uses a tactic-prefixed, underscore-only convention to keep thousands of community rules sorted. For a custom org library, your team chose a different convention (<data-source>-<keywords>.toml) that makes rules easier to find by integration.

You need to tell the tooling to stop enforcing the upstream pattern.

Update the config to disable tactic-prefix naming

Two key config options resolve this conflict. no_tactic_filename stops the tactic from being prepended to the computed filename. bypass_optional_elastic_validation skips Elastic-specific naming and author checks entirely.

  • Open custom-rules/_config.yaml in VS Code.
  • Below the version_lock line inside the files: section, add the following org-specific overrides:
no_tactic_filename: True
bypass_optional_elastic_validation: True
bypass_version_lock: True
normalize_kql_keywords: True

testing:
  config: etc/test_config.yaml

What do these settings do?

  • no_tactic_filename: True prevents the MITRE tactic name from being prepended to rule filenames.
  • bypass_optional_elastic_validation: True skips Elastic-specific checks like requiring "Elastic" in the author field.
  • bypass_version_lock: True lets you manage rule versions directly in your TOML files instead of a lock file.
  • normalize_kql_keywords: True standardizes keyword casing in your KQL queries during validation.
  • testing: config: etc/test_config.yaml points to a test config that disables the filename tactic unit test.
  • Save _config.yaml.

Your full _config.yaml should now look like this:

rule_dirs:
  - rules

files:
  deprecated_rules: etc/deprecated_rules.json
  packages: etc/packages.yaml
  stack_schema_map: etc/stack-schema-map.yaml
  version_lock: etc/version.lock.json

no_tactic_filename: True
bypass_optional_elastic_validation: True
bypass_version_lock: True
normalize_kql_keywords: True

testing:
  config: etc/test_config.yaml
  • Now create the test config file that disables the filename tactic test by running:
cat > custom-rules/etc/test_config.yaml << 'EOF'
skipped_tests:
  - tests.test_all_rules.TestRuleFiles.test_rule_file_name_tactic
EOF

Why skip this specific test?

The test_rule_file_name_tactic unit test enforces that every rule filename starts with its primary MITRE tactic. Since your org convention deliberately omits the tactic prefix, you need to skip this test to avoid false failures when running the full test suite.

  • Confirm the file was created by running:
cat custom-rules/etc/test_config.yaml

You should see the skipped_tests list with the tactic filename test entry.

Config not loading?

  • Make sure _config.yaml is at the root of your custom-rules/ directory, not inside etc/.
  • Confirm your CUSTOM_RULES_DIR environment variable is still set by running echo $CUSTOM_RULES_DIR. It should print ../custom-rules.
  • Check that YAML indentation uses spaces, not tabs. The testing: key must be flush left with config: indented two spaces below it.

Help me debug why my custom _config.yaml settings are not taking effect

Re-run validation and confirm the fix

With both config options set and the test config in place, the CLI should now accept your kebab-case filename without complaint. Before you run the command, do you think the warning will still appear or will it be gone?

  • Run the same validation command again:
python -m detection_rules validate-rule custom-rules/rules/o365-multiple-failed-mfa-attempts.toml

This time the command completes with "Rule validation successful" and zero warnings about filename mismatches. Your rule validates cleanly against the schema while following your org's kebab-case naming convention.

Confirm with validate-all too

You can also run python -m detection_rules validate-all to validate every rule in your custom directory at once. With one rule, you should see it pass with no errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved both _config.yaml and test_config.yaml.

ⓧ I'd like to double check the full code

Here is the full custom-rules/_config.yaml at this point:

rule_dirs:
  - rules

files:
  deprecated_rules: etc/deprecated_rules.json
  packages: etc/packages.yaml
  stack_schema_map: etc/stack-schema-map.yaml
  version_lock: etc/version.lock.json

no_tactic_filename: True
bypass_optional_elastic_validation: True
bypass_version_lock: True
normalize_kql_keywords: True

testing:
  config: etc/test_config.yaml

And custom-rules/etc/test_config.yaml:

skipped_tests:
  - tests.test_all_rules.TestRuleFiles.test_rule_file_name_tactic

Your naming convention conflict is resolved. Next up, you will write a more complex EQL rule that detects impossible travel and maps to multiple MITRE ATT&CK techniques.

Write an EQL Rule with Full MITRE ATT&CK Mapping

Your first rule used KQL for a simple field-value match. Insider risk scenarios like impossible travel require correlating multiple authentication events in time order.

EQL (Event Query Language) is more expressive than KQL for sequence-based detections. In this step, you will write an EQL rule detecting concurrent sessions from geographically implausible locations, add analyst investigation guidance, and map the rule to multiple MITRE ATT&CK techniques.

In this step, get ready to:
  • Create a second TOML rule file with the EQL rule type and metadata for Azure integration.
  • Add investigation fields and a triage guide for analyst workflows.
  • Validate both rules together and export to NDJSON for Kibana import.
Create the EQL rule file with metadata

Your organization's naming convention is <data-source>-<keywords>.toml. For an Azure Entra ID impossible travel rule, the filename starts with the data source (azure-entra) followed by descriptive keywords.

  • Create a new file called azure-entra-concurrent-sessions-impossible-travel.toml inside custom-rules/rules/.
  • Add the following [metadata] section at the top of the file:
[metadata]
creation_date = "2026/07/29"
integration = ["azure"]
maturity = "development"
updated_date = "2026/07/29"

What does the integration field do?

The integration field in [metadata] tells the DaC tooling which Elastic integration this rule depends on. Setting it to ["azure"] signals that the rule requires Azure sign-in log data to be ingested before it can fire.

  • Confirm the file exists by running:
ls custom-rules/rules/

You should see both azure-entra-concurrent-sessions-impossible-travel.toml and o365-multiple-failed-mfa-attempts.toml listed.

Don't see the new file?

Make sure you created the file inside custom-rules/rules/ and not in the root detection-rules/ directory. The path must be custom-rules/rules/azure-entra-concurrent-sessions-impossible-travel.toml.

help me find where my file was created

Write the core rule fields and EQL query

The [rule] section defines everything Elastic's detection engine needs to execute the rule. For an EQL rule, you set type = "eql" and language = "eql" instead of the KQL equivalents.

  • Below the [metadata] section, add the opening of the [rule] section with identity and scheduling fields:
[rule]
author = ["Custom"]
description = """
Detects successful interactive Microsoft Entra ID sign-ins for the same user from two different countries
within a 30-minute window. Rapid geographic shifts that exceed plausible travel speed indicate either
VPN or proxy use, or a compromised account being accessed from a separate location by an adversary.
Non-interactive sign-in categories are excluded because backend token refresh activity routinely egresses
through cloud regions unrelated to the user.
"""
false_positives = [
    "Users on corporate VPN infrastructure egressing through a distant region.",
    "Legitimate travel with rapid airport connections and mobile sign-ins.",
    "Cloud-hosted virtual desktop deployments where employees sign in interactively from a cloud ASN.",
]
from = "now-35m"
interval = "5m"
index = ["logs-azure.signinlogs-*"]
language = "eql"
license = "Elastic License v2"
name = "Entra ID Concurrent Sessions from Impossible Travel Locations"

What do these fields do?

The false_positives array documents known benign scenarios that could trigger this rule. Analysts use these during triage to quickly eliminate noise.

The from = "now-35m" field tells the engine to look back 35 minutes on each run. Combined with the 5-minute interval, this gives overlap to catch events near the boundary.

  • Directly below those fields (still inside [rule]), add the scoring, tags, type, and the EQL query:
risk_score = 73
rule_id = "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"
severity = "high"
tags = [
    "Domain: Cloud",
    "Data Source: Azure",
    "Data Source: Microsoft Entra ID",
    "Use Case: Identity and Access Audit",
    "Use Case: Insider Risk",
    "Tactic: Initial Access",
    "Resources: Investigation Guide",
]
timestamp_override = "event.ingested"
type = "eql"

query = '''
authentication where event.dataset == "azure.signinlogs" and
  event.outcome == "success" and
  azure.signinlogs.properties.is_interactive == true and
  source.geo.country_name != null
'''

How does the EQL query work?

The authentication where clause filters events by the ECS event category. Each condition narrows the results to only successful, interactive sign-ins that have a resolved geographic location.

A risk_score of 73 maps to "high" severity in Elastic's scoring model (the range 48-73 is high, 74-100 is critical).

  • Save the file.
  • Verify the rule loads by running:
python -m detection_rules view-rule custom-rules/rules/azure-entra-concurrent-sessions-impossible-travel.toml

You should see JSON output with all your fields populated, including the EQL query string and the tags array.

Seeing a ValidationError?

If you see a marshmallow validation error, check that type = "eql" and language = "eql" are both present. EQL rules require both fields. Also confirm your rule_id is a valid UUID format with hyphens.

help me debug my EQL rule validation error

Add investigation guidance and analyst fields

Production detection rules include a note field with triage steps and an investigation_fields section listing the ECS fields analysts should examine first.

  • In azure-entra-concurrent-sessions-impossible-travel.toml, find the line name = "Entra ID Concurrent Sessions from Impossible Travel Locations".
  • Add the following note field directly below it (before risk_score):
note = """## Triage and analysis

### Investigating Entra ID Concurrent Sessions from Impossible Travel Locations

Microsoft Entra ID is accessible globally. Legitimate users authenticate from one location at a time.
Two successful interactive sign-ins for the same user separated by a distance and time delta implying
travel faster than a commercial airliner cannot be the same human physically moving.

### Possible investigation steps

- Examine the geographic locations in `source.geo.country_name` for both sign-in events.
- Compare user-agent strings and OS fingerprints between the two sessions for divergence.
- Check the ASN of the non-baseline sign-in. Hosting-provider ASNs for interactive sign-ins are suspicious.
- Cross-reference Entra audit logs for MFA method registration changes or app consent grants in the same window.
- Validate against the user's known VPN egress ranges and travel schedule.

### False positive analysis

- Corporate VPN with distant egress points will trigger this rule. Exclude known VPN ASNs via exceptions.
- Mobile users at airports with layovers may show rapid country changes.

### Response and remediation

- If confirmed malicious, force password reset and MFA re-enrollment.
- Revoke active sessions and OAuth tokens for the compromised user.
- Review Entra audit logs for persistence mechanisms (app registrations, recovery info changes).
- Notify the user and their manager per insider risk escalation procedures.
"""

Why include a note field?

The note field renders as a markdown investigation guide inside Kibana's rule detail page. It gives analysts a step-by-step playbook so they can triage alerts without switching to a wiki.

  • Below the closing query = '''...''' block, add the investigation fields section:
[rule.investigation_fields]
field_names = [
    "@timestamp",
    "user.name",
    "user.id",
    "source.ip",
    "source.geo.country_name",
    "source.geo.city_name",
    "user_agent.original",
    "azure.signinlogs.properties.app_display_name",
    "azure.signinlogs.properties.client_app_used",
    "azure.signinlogs.properties.resource_display_name",
]

What are investigation fields?

The [rule.investigation_fields] section tells Kibana which columns to surface in the alert detail flyout. Analysts see these fields first, which speeds up triage.

  • Save the file and run view-rule again to confirm the note and investigation fields appear in the JSON output:
python -m detection_rules view-rule custom-rules/rules/azure-entra-concurrent-sessions-impossible-travel.toml

You should see a note key in the JSON with your markdown triage guide, and an investigation_fields object listing the field names.

  • Now add the MITRE ATT&CK threat mappings at the end of the file, below [rule.investigation_fields]:
[[rule.threat]]
framework = "MITRE ATT&CK"

[[rule.threat.technique]]
id = "T1078"
name = "Valid Accounts"
reference = "https://attack.mitre.org/techniques/T1078/"

[[rule.threat.technique.subtechnique]]
id = "T1078.004"
name = "Cloud Accounts"
reference = "https://attack.mitre.org/techniques/T1078/004/"

[rule.threat.tactic]
id = "TA0001"
name = "Initial Access"
reference = "https://attack.mitre.org/tactics/TA0001/"

[[rule.threat]]
framework = "MITRE ATT&CK"

[[rule.threat.technique]]
id = "T1078"
name = "Valid Accounts"
reference = "https://attack.mitre.org/techniques/T1078/"

[rule.threat.tactic]
id = "TA0005"
name = "Defense Evasion"
reference = "https://attack.mitre.org/tactics/TA0005/"

Why two threat blocks?

A single technique can map to multiple tactics. T1078 (Valid Accounts) applies to both Initial Access (an attacker using stolen credentials to get in) and Defense Evasion (an attacker blending in with legitimate user activity).

The first block also includes T1078.004 (Cloud Accounts) as a subtechnique, which narrows the mapping to cloud-specific credential abuse.

  • Save azure-entra-concurrent-sessions-impossible-travel.toml.
Validate both rules and export to NDJSON

With both rules complete, you can now run full validation across your entire custom rules directory and produce the NDJSON file that Kibana imports.

  • Before you run this, predict: do you think both rules will pass validation cleanly, or will the kebab-case filename trigger a warning again?
  • Run full validation by running:
python -m detection_rules validate-all

Both rules pass with zero errors. The no_tactic_filename: True config you set in the previous step means no filename warnings appear for either rule.

  • Export your rules to NDJSON format for Kibana import by running:
python -m detection_rules export-rules-from-repo

The CLI produces an NDJSON file containing your rules in the format Kibana's detection engine expects. This is the file you would import into a live Elastic Security instance.

Validation failing on the new rule?

If you see a schema error, check that your [rule] section includes all required fields: author, description, name, risk_score, rule_id, severity, type.

Also verify that CUSTOM_RULES_DIR is still set. Run echo $CUSTOM_RULES_DIR and confirm it outputs ../custom-rules.

help me fix my validate-all errors

✔️ Awesome, I've got everything!

Great. Double check you have saved azure-entra-concurrent-sessions-impossible-travel.toml and that both rules validate cleanly.

ⓧ I'd like to double check the full code

Your azure-entra-concurrent-sessions-impossible-travel.toml file is built from the chunks above. Scroll up and confirm these sections appear in order:

  • The [metadata] section with creation_date, integration, maturity, and updated_date.
  • The [rule] section starting with author through name.
  • The note field with the triage and analysis guide.
  • The risk_score through query fields.
  • The [rule.investigation_fields] section with the field_names array.
  • Two [[rule.threat]] blocks mapping T1078 to TA0001 and TA0005.

Your custom rules library now has two validated detection rules covering different insider risk scenarios. Next up, you will tackle the Secret Mission and build a Claude Code slash command that automates the entire rule scaffolding process.

Secret mission

Build a Claude Code Slash Command for Rule Scaffolding

Detection engineers create dozens of rules. Manually copying boilerplate metadata, generating UUIDs, applying naming conventions, and looking up MITRE technique IDs is tedious and error-prone. Can you build a Claude Code slash command that automates all of this from a natural language description, letting you go from idea to validated rule in seconds?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • A local clone of the detection-rules/ repository (includes the Python virtual environment and .claude/commands/ directory).
  • The custom-rules/ directory containing your TOML rules and YAML configuration files.

Keep everything running

No action needed. Choose this if you are still actively building detection rules or plan to expand your library soon.

  • The repo and rules live on your local machine with no ongoing cost.
  • Your virtual environment stays ready to use whenever you cd back into detection-rules/ and activate it.
  • Your /scaffold-rule Claude Code slash command remains available for generating new rules.

Pause - I'll come back to this later

Shut down the active virtual environment to free up your terminal session. Your files stay intact on disk.

  • Deactivate the virtual environment by running:
deactivate

When you are ready to come back, re-activate by running:

cd ~/Desktop/detection-rules
source .venv/bin/activate
export CUSTOM_RULES_DIR=../custom-rules

Everything will be right where you left it.

Delete - I don't want to use this again

Remove all project directories and start fresh. This permanently deletes your detection rules, configuration, and the cloned repository.

  • Deactivate the virtual environment first by running:
deactivate
  • Remove both project directories by running:

macOS

cd ~/Desktop
rm -rf detection-rules
rm -rf custom-rules

Windows

cd ~\Desktop
rmdir /s /q detection-rules
rmdir /s /q custom-rules
  • Confirm the directories are gone by running:
ls ~/Desktop

You should no longer see detection-rules or custom-rules in the output.

Nice Work!

Nice Work!

Nice work! You just built a production-ready insider risk detection rules library using Elastic's Detection-as-Code workflow.

You've learned how to:

  • Build a custom Detection-as-Code rules library from scratch with YAML configuration that enforces your organization's kebab-case, data-source-first naming convention.
  • Write insider risk TOML detection rules using both KQL and EQL query languages, with full MITRE ATT&CK technique and tactic mappings.
  • Resolve the conflict between Elastic's upstream naming conventions and your org's custom kebab-case pattern using no_tactic_filename and bypass_optional_elastic_validation config options, then validate and export rules to NDJSON format ready for Kibana import.
  • (Secret Mission) Created a Claude Code slash command that automates rule scaffolding from natural language descriptions, enforcing all org conventions automatically.

Ready to quiz yourself?