Insider Threat Detection-as-Code Pipeline

Build and validate TOML-based insider threat detection rules with CI/CD.

Introduction

30 Second Summary

The biggest data breaches don't always come from hackers on the outside. Sometimes the threat is already inside the building, using legitimate access to quietly move sensitive data where it shouldn't go.

In this project, you will build a detection-as-code pipeline that catches insider threat behavior at a fintech company. You'll author detection rules that spot data exfiltration, correlate suspicious behavior across multiple log sources, and ship validated rules through an automated CI pipeline.

What You'll Build

You'll push a detection rule to GitHub, open a pull request, and watch an automated pipeline validate the rule's schema, run unit tests, and produce a deployment-ready artifact you could import straight into a production SIEM.

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

  • A validated insider threat rule that detects data exfiltration attempts through Island Browser DLP violations, ready to fire on real telemetry.
  • An EQL sequence rule that correlates authentication anomalies with exfiltration signals, reducing false positives by requiring multiple events within a time window.
  • A GitHub Actions CI pipeline that automatically validates every rule change, runs tests, and exports deployment-ready NDJSON on every pull request.
  • Secret Mission: A threshold rule that detects bulk data access patterns during off-hours.

Are there any prerequisites?

This project assumes you have Python 3.12+ installed, Git installed, and a GitHub account.

You should also be familiar with detection engineering concepts and query languages like KQL or EQL.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. You're stepping into a detection engineering role at a fintech company, and your first task is authoring insider threat detections that actually work in production.

Set Up the Detection-Rules Framework

Before you can write or validate a single detection rule, you need the tooling that makes detection-as-code possible. Elastic's detection-rules repository provides a Python CLI that validates TOML rule schemas, runs unit tests, and exports deployment-ready NDJSON for Kibana.

In this step, you will clone that repository, install its dependencies, and create a custom rules directory configured for Island Browser log fields. This gives you the foundation to author insider threat rules that the framework can validate against your custom schema.

In this step, get ready to:
  • Clone the elastic/detection-rules repo and install all Python dependencies.
  • Create a custom rules directory with configuration for Island Browser fields.
  • Wire a custom schema so the framework validates non-ECS field names in your queries.
Clone the repository and install dependencies

The detection-rules repository contains both Elastic's prebuilt rules and the Python CLI you will use to validate, test, and export your own rules. Cloning it gives you access to the full framework.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the elastic/detection-rules repository by running:
git clone https://github.com/elastic/detection-rules.git
  • Move into the cloned repository by running:
cd detection-rules
  • Install all dependencies by running:
make

What does make do here?

The make command creates a Python virtual environment at env/detection-rules-build/ and installs all required packages including the kql and kibana libraries.

This takes 2-3 minutes because it clones sub-dependencies from GitHub. Wait until you see your terminal prompt return before continuing.

  • Activate the virtual environment by running:
source env/detection-rules-build/bin/activate

On Windows?

The make command and source activation require a Unix-like shell. Use WSL (Windows Subsystem for Linux) or Git Bash to follow along.

If using WSL, activate with: source env/detection-rules-build/bin/activate (same command).

Help me troubleshoot the make command on Windows

Before you run the next command, what commands do you expect to see listed in the help output for a detection-rules CLI?

  • Confirm the framework installed correctly by running:
python -m detection_rules --help

You should see a list of available commands including validate-all, test, and export-rules-from-repo.

Seeing ModuleNotFoundError?

Make sure you activated the virtual environment first. Your terminal prompt should show (detection-rules-build) at the beginning.

If make failed partway through, delete the env/ folder and run make again.

Help me fix this ModuleNotFoundError

Create the custom rules directory structure

Your insider threat rules live in a separate directory from Elastic's prebuilt content. This keeps your custom work isolated and portable.

  • Navigate back to your Desktop by running:
cd ~/Desktop
  • Create the custom rules directory with its required subdirectories by running:
mkdir -p custom-rules/rules custom-rules/etc
  • Confirm the directory structure was created by running:
ls custom-rules

You should see etc and rules listed in the output.

  • Create the _config.yaml configuration file by running:
cat > custom-rules/_config.yaml << 'EOF'
custom_rules_dir: ./rules
bypass_optional_elastic_validation: true
EOF

What does this config do?

The custom_rules_dir: ./rules line tells the framework where your TOML rule files live, relative to this config file.

The bypass_optional_elastic_validation: true flag disables naming conventions that only apply to Elastic's internal prebuilt rules. Without it, your custom rules would fail validation on cosmetic rules designed for Elastic's own content.

  • Verify the config file was written correctly by running:
cat custom-rules/_config.yaml

You should see the two configuration lines you just wrote.

Add the Island Browser custom schema

Island Browser log fields like verdict and top_level_url are not part of the Elastic Common Schema (ECS). Without a custom schema definition, the framework would reject any query referencing these fields as unknown.

  • Create the Island Browser schema file by running:
cat > custom-rules/etc/island_browser_schema.json << 'EOF'
{
  "properties": {
    "island": {
      "properties": {
        "verdict": {"type": "keyword"},
        "verdict_reason": {"type": "keyword"},
        "top_level_url": {"type": "keyword"},
        "os_user_name": {"type": "keyword"},
        "public_ip": {"type": "ip"},
        "country": {"type": "keyword"},
        "rule_name": {"type": "keyword"}
      }
    }
  }
}
EOF

What does this schema define?

Each field maps to an Elasticsearch field type. Most fields are keyword (exact match strings), while public_ip uses the ip type for proper IP address handling.

The island parent object groups these fields under a namespace, so your rules will reference them as island.verdict, island.top_level_url, and so on.

  • Wire the schema into the framework by creating stack-schema-map.yaml:
cat > custom-rules/etc/stack-schema-map.yaml << 'EOF'
# Stack schema map wiring Island Browser custom schema
8.14.0:
  beats: "{DETECTION_RULES_DIR}/etc/beats_schema.json"
  custom: "./etc/island_browser_schema.json"
EOF

What does the stack-schema-map do?

This file tells the detection-rules framework which schema files to load when validating queries. The beats entry points to the standard Elastic schema from the cloned repo. The custom entry loads your Island Browser field definitions on top of it.

The {DETECTION_RULES_DIR} placeholder is resolved by the framework at runtime to point at the cloned detection-rules repository.

  • Verify both files were created by running:
ls custom-rules/etc

You should see island_browser_schema.json and stack-schema-map.yaml listed.

  • Set the CUSTOM_RULES_DIR environment variable so the framework knows where your custom rules live:
export CUSTOM_RULES_DIR=~/Desktop/custom-rules

Why set CUSTOM_RULES_DIR?

The detection-rules CLI reads this environment variable to find your _config.yaml and load your custom rules directory. Without it, the CLI defaults to Elastic's internal rules and will not see your insider threat rules.

Need to re-export later?

The export command only lasts for your current terminal session. If you close and reopen your terminal, run the export command again before using any detection-rules commands.

Help me fix a missing config file or CUSTOM_RULES_DIR error

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files and your terminal shows (detection-rules-build) in the prompt.

ⓧ I'd like to double check the full code

Here are the three config files you should have in your custom-rules directory. Compare each one against yours.

custom_rules_dir: ./rules
bypass_optional_elastic_validation: true
{
  "properties": {
    "island": {
      "properties": {
        "verdict": {"type": "keyword"},
        "verdict_reason": {"type": "keyword"},
        "top_level_url": {"type": "keyword"},
        "os_user_name": {"type": "keyword"},
        "public_ip": {"type": "ip"},
        "country": {"type": "keyword"},
        "rule_name": {"type": "keyword"}
      }
    }
  }
}
# Stack schema map wiring Island Browser custom schema
8.14.0:
  beats: "{DETECTION_RULES_DIR}/etc/beats_schema.json"
  custom: "./etc/island_browser_schema.json"

Your detection-rules framework is installed, your custom schema is wired, and you are ready to author rules. Next up, you will write your first insider threat detection rule targeting Island Browser DLP violations.

Write Your First Insider Threat Rule

Your detection-rules framework is installed, configured, and ready to validate rules. The next step is to produce an actual detection rule that targets a real insider threat scenario.

A single working rule that passes validation proves your entire tooling pipeline works end-to-end. It gives you a visible artifact you can iterate on, export, and eventually deploy to a live Elastic SIEM.

In this step, get ready to:
  • Author a TOML detection rule targeting Island Browser DLP violations using Claude Code.
  • Validate, view, and export the rule as deployment-ready NDJSON.
Author the DLP exfiltration detection rule

Your first rule targets Island Browser DLP (Data Loss Prevention) violations. When an employee at your fintech company attempts to copy sensitive data to an external destination or download files to personal storage, Island Browser blocks the action and logs the event. Your rule catches these blocked events as potential exfiltration attempts.

  • Start a Claude Code interactive session in your custom rules directory by running:
claude
  • Once the Claude Code session starts, type the following prompt and press Enter:
Create a file at rules/insider_threat_island_dlp_exfil.toml that is an Elastic detection rule in TOML format. It should detect Island Browser DLP violation events where the verdict is Blocked and the action involves data leaving approved boundaries (clipboard_upload or file_download to drive.google.com, dropbox.com, or wetransfer.com). Use KQL (kuery language), target the index pattern logs-island.browser-*, include MITRE ATT&CK mapping to T1567 Exfiltration Over Web Service under tactic TA0010 Exfiltration, set risk_score to 73, severity to high, and tag it with Insider Threat, Island Browser, DLP, Exfiltration, Fintech. Use rule_id a1b2c3d4-e5f6-7890-abcd-ef1234567890 and author NextWork.

What does this prompt ask Claude Code to generate?

You are asking Claude Code to create a complete TOML detection rule file. The prompt specifies the exact detection logic (KQL query filtering on island.verdict and island.verdict_reason fields), the target index pattern, threat mapping, and metadata.

Claude Code will propose the file contents and ask for your permission before writing. Press Enter to approve when prompted.

  • After Claude Code writes the file, verify it exists by running this in a separate terminal (or type /exit to leave Claude Code first):
ls rules/

You should see insider_threat_island_dlp_exfil.toml listed in the output.

Now verify your rule file matches the expected structure. Open rules/insider_threat_island_dlp_exfil.toml and confirm it contains the metadata section and core rule fields shown below.

[metadata]
creation_date = "2024/01/01"
integration = ["island"]
maturity = "production"
updated_date = "2024/01/01"

[rule]
author = ["NextWork"]
description = "Detects Island Browser DLP violation events where data is blocked from leaving approved boundaries, including copy/paste to external destinations and downloads to personal storage."
from = "now-9m"
index = ["logs-island.browser-*"]
language = "kuery"
license = "Elastic License v2"
name = "Island Browser DLP Exfiltration Attempt"
query = """
island.verdict: "Blocked" and island.verdict_reason: ("clipboard_upload" or "file_download") and island.top_level_url: ("drive.google.com" or "dropbox.com" or "wetransfer.com")
"""
risk_score = 73
rule_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
severity = "high"
tags = ["Insider Threat", "Island Browser", "DLP", "Exfiltration", "Fintech"]
type = "query"

What do these fields mean?

  • index tells Elastic which data source to query. logs-island.browser-* matches all Island Browser log indices.
  • language is set to kuery (Kibana Query Language), which is the simplest query language for single-event detections.
  • risk_score of 73 and severity of high reflect that a blocked exfiltration attempt in a fintech environment is a serious signal worth investigating.
  • rule_id is a UUID that uniquely identifies this rule across your fleet.

Next, confirm the file also contains the MITRE ATT&CK threat mapping section at the bottom:

[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1567"
name = "Exfiltration Over Web Service"
reference = "https://attack.mitre.org/techniques/T1567/"
[rule.threat.tactic]
id = "TA0010"
name = "Exfiltration"
reference = "https://attack.mitre.org/tactics/TA0010/"

Why map to MITRE ATT&CK?

Mapping rules to the ATT&CK framework lets your SOC team quickly understand what attacker behavior a detection targets. T1567 (Exfiltration Over Web Service) under tactic TA0010 (Exfiltration) tells analysts this rule catches data leaving the organization through web-based channels.

  • If Claude Code produced slightly different field values or formatting, edit the file manually to match the two reference blocks above exactly.
  • Save rules/insider_threat_island_dlp_exfil.toml.

Claude Code didn't create the file?

If Claude Code printed the file contents but did not write them to disk, you may have declined the permission prompt. Run claude again and re-enter the prompt, pressing Enter to approve the file write.

Alternatively, create the file manually by copying the two code blocks above into a new file at rules/insider_threat_island_dlp_exfil.toml.

Help me create the TOML rule file manually

✔️ Awesome, I've got everything!

Great. Confirm you have saved rules/insider_threat_island_dlp_exfil.toml with both sections (metadata + rule fields and the MITRE ATT&CK threat mapping).

ⓧ I'd like to double check the full code

Scroll up and confirm your file contains both chunks in order:

  • The [metadata] section and full [rule] section (including the KQL query, risk_score, severity, tags, and type).
  • The [[rule.threat]] MITRE ATT&CK mapping block at the end of the file.

Both chunks together form the complete file. Nothing else should be in it.

Validate, view, and export the rule

Your TOML file exists on disk, but that does not mean it passes the detection-rules framework's schema checks. The framework validates field types, required attributes, and query syntax.

Before you run the next command, predict: will your rule pass validation on the first try, or will the framework flag something?

  • Validate your rule against the detection-rules schema by running:
python -m detection_rules validate-rule rules/insider_threat_island_dlp_exfil.toml

You should see the rule pass validation with no errors. The output confirms that all required fields are present and the KQL query is syntactically valid.

Seeing a validation error?

If the error mentions a missing required field, open your TOML file and compare it against the reference blocks above. Common issues include a missing rule_id or an incorrectly quoted query value (it must use triple-quoted strings """).

If the error references an unknown field like island.verdict, confirm your etc/stack-schema-map.yaml correctly references ./etc/island_browser_schema.json from Step 1.

Help me debug my validation error

  • View the rule rendered in a human-readable format by running:
python -m detection_rules view-rule rules/insider_threat_island_dlp_exfil.toml

You should see the rule displayed with all fields formatted clearly, including the name Island Browser DLP Exfiltration Attempt, the full KQL query, and the MITRE ATT&CK mapping.

Why use view-rule?

The view-rule command renders the TOML file in a structured, readable format. This is useful for code review and for quickly confirming that a rule's logic matches your intent before exporting it to production.

  • Export all rules in your custom rules directory to NDJSON format by running:
python -m detection_rules export-rules-from-repo

The command produces an NDJSON file containing your rule in the format that Kibana's Detection Engine expects for bulk import. You should see output confirming the export completed and naming the output file.

What is NDJSON?

NDJSON (Newline Delimited JSON) is the format Elastic's Detection Engine uses for rule import and export. Each line in the file is a self-contained JSON object representing one rule. You can import this file directly into Kibana through the Security UI or the Bulk Import API.

Export command not finding your rules?

Make sure the CUSTOM_RULES_DIR environment variable is set to your custom rules directory, or that you are running the command from within that directory. The framework needs to know where to look for your TOML files.

Help me fix my export command

Your first insider threat detection rule is authored, validated, and exported. Next, you will discover why a single-event rule like this one fires too broadly to be operationally useful.

Discover the Single-Event Detection Limit

Your DLP rule validates cleanly and exports to NDJSON. But does it actually catch the right events? More importantly, does it catch only the right events?

Your rule fires on every blocked action, including legitimate policy enforcement and accidental triggers. Without correlation, the signal-to-noise ratio makes the rule operationally useless. In this step, you will prove that limitation with unit tests and a second rule.

In this step, get ready to:
  • Write unit tests that expose how your DLP rule matches both malicious and benign events indiscriminately.
  • Add a second KQL rule targeting Okta account manipulation and validate it.
  • Observe the fundamental limitation of single-event detections for insider threat scenarios.
Write unit tests for your DLP rule

A rule that passes schema validation tells you the structure is correct. A unit test tells you which events actually trigger the rule's query logic.

  • Create a test file for your DLP rule by running this command:
touch rules/insider_threat_island_dlp_exfil_tests.toml
  • Confirm the file exists by running:
ls rules/

You should see both insider_threat_island_dlp_exfil.toml and insider_threat_island_dlp_exfil_tests.toml listed.

  • Open rules/insider_threat_island_dlp_exfil_tests.toml and paste the following test definitions:
# Unit tests for insider_threat_island_dlp_exfil rule
[[tests]]
name = "True positive: employee copying PII to personal Google Drive"
expected_rule_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
[tests.event]
"island.verdict" = "Blocked"
"island.verdict_reason" = "clipboard_upload"
"island.top_level_url" = "drive.google.com"
"island.os_user_name" = "jsmith"

[[tests]]
name = "False positive: employee blocked during legitimate compliance workflow"
expected_rule_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
[tests.event]
"island.verdict" = "Blocked"
"island.verdict_reason" = "clipboard_upload"
"island.top_level_url" = "drive.google.com"
"island.os_user_name" = "compliance_user"

What do these tests do?

Each [[tests]] block defines a synthetic event to run against your rule's query. The expected_rule_id links the test event to the specific rule it should match.

The first test models a genuine insider threat: an employee (jsmith) copying customer PII to a personal Google Drive. The second models a benign scenario: a compliance team member blocked by the same DLP policy during a routine workflow.

Notice that both events have identical field values for verdict, verdict_reason, and top_level_url. Only the username differs.

  • Save rules/insider_threat_island_dlp_exfil_tests.toml.

Before you run the tests, think about this: both test events have the same verdict, same reason, and same destination URL. Do you think the rule will match both events, or only the malicious one?

  • Run the unit tests by running this command:
python -m detection_rules test

Both test events match the rule indiscriminately. The rule cannot tell the difference between jsmith exfiltrating customer data and compliance_user being blocked during a legitimate workflow.

Why did both events match?

Your KQL query checks for island.verdict: "Blocked" and specific verdict reasons and URLs. It has no concept of user intent, timing, or surrounding context.

Any event with those field values matches, regardless of whether the activity is malicious or routine. In production, this means your SOC receives an alert every time DLP blocks anything at those destinations.

Tests not running?

Make sure you are running the command from the root of the detection-rules repository, not from inside your custom rules directory. The CUSTOM_RULES_DIR environment variable must point at your custom rules folder for the framework to discover your test file.

If you see import errors, confirm you activated the virtual environment with source env/detection-rules-build/bin/activate before running the command.

Help me troubleshoot my detection-rules test command failing

✔️ Awesome, I've got everything!

Great. Double check you have saved rules/insider_threat_island_dlp_exfil_tests.toml and that both test events matched your rule.

ⓧ I'd like to double check the full code

Here is the complete test file. Confirm yours matches exactly:

# Unit tests for insider_threat_island_dlp_exfil rule
[[tests]]
name = "True positive: employee copying PII to personal Google Drive"
expected_rule_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
[tests.event]
"island.verdict" = "Blocked"
"island.verdict_reason" = "clipboard_upload"
"island.top_level_url" = "drive.google.com"
"island.os_user_name" = "jsmith"

[[tests]]
name = "False positive: employee blocked during legitimate compliance workflow"
expected_rule_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
[tests.event]
"island.verdict" = "Blocked"
"island.verdict_reason" = "clipboard_upload"
"island.top_level_url" = "drive.google.com"
"island.os_user_name" = "compliance_user"
Add a second KQL rule for Okta account manipulation

Insider threats rarely announce themselves with a single dramatic action. They prepare. A common pre-positioning move is resetting a password or changing MFA factors to lock out recovery before acting.

  • Use Claude Code to create a second rule file at rules/insider_threat_okta_account_manipulation.toml with the following content:
[metadata]
creation_date = "2024/01/01"
integration = ["okta"]
maturity = "production"
updated_date = "2024/01/01"

[rule]
author = ["NextWork"]
description = "Detects Okta audit log events indicating password resets or MFA factor changes that may represent insider pre-positioning."
from = "now-9m"
index = ["logs-okta.system-*"]
language = "kuery"
license = "Elastic License v2"
name = "Okta Account Manipulation - Password Reset or MFA Change"
query = """
event.dataset: "okta.system" and event.action: ("user.account.reset_password" or "user.mfa.factor.update" or "user.mfa.factor.deactivate")
"""
risk_score = 47
rule_id = "b2c3d4e5-f6a7-8901-bcde-f12345678901"
severity = "medium"
tags = ["Insider Threat", "Okta", "Account Manipulation", "Fintech"]
type = "query"

What does this rule detect?

This rule watches Okta audit logs for password resets, MFA factor updates, and MFA factor deactivations. These actions are how an insider establishes persistence before exfiltrating data.

The query targets event.dataset: "okta.system" to scope to Okta system events specifically, then filters on the three most concerning action types for insider pre-positioning.

  • Now add the MITRE ATT&CK mapping to the end of the same file. Append this below the type = "query" line:
[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1098"
name = "Account Manipulation"
reference = "https://attack.mitre.org/techniques/T1098/"
[rule.threat.tactic]
id = "TA0003"
name = "Persistence"
reference = "https://attack.mitre.org/tactics/TA0003/"

Why T1098 Account Manipulation?

MITRE ATT&CK technique T1098 covers adversaries modifying existing accounts to maintain or elevate access. Password resets and MFA changes fit under the Persistence tactic (TA0003) because they let an insider preserve compromised access to victim systems.

  • Save rules/insider_threat_okta_account_manipulation.toml.
  • Validate the new rule by running:
python -m detection_rules validate-rule rules/insider_threat_okta_account_manipulation.toml

You should see the rule pass validation with no errors.

This rule also fires on routine IT operations. When help desk resets a password for a locked-out employee, or when someone updates their MFA app after getting a new phone, this rule generates the same alert as a malicious insider preparing for data theft.

Validation failing on the Okta rule?

Check that the rule_id is a valid UUID format. Also confirm the [[rule.threat]] section is at the end of the file, not nested inside the [rule] block.

If you see a KQL parse error, make sure the query field uses triple-quoted strings and the parentheses around the or clauses match exactly.

Help me fix my Okta rule validation error

✔️ Awesome, I've got everything!

Great. Confirm both rule files are saved and that the Okta rule passed validation.

ⓧ I'd like to double check the full code

Your Okta rule file has two sections. Here is the main rule body:

[metadata]
creation_date = "2024/01/01"
integration = ["okta"]
maturity = "production"
updated_date = "2024/01/01"

[rule]
author = ["NextWork"]
description = "Detects Okta audit log events indicating password resets or MFA factor changes that may represent insider pre-positioning."
from = "now-9m"
index = ["logs-okta.system-*"]
language = "kuery"
license = "Elastic License v2"
name = "Okta Account Manipulation - Password Reset or MFA Change"
query = """
event.dataset: "okta.system" and event.action: ("user.account.reset_password" or "user.mfa.factor.update" or "user.mfa.factor.deactivate")
"""
risk_score = 47
rule_id = "b2c3d4e5-f6a7-8901-bcde-f12345678901"
severity = "medium"
tags = ["Insider Threat", "Okta", "Account Manipulation", "Fintech"]
type = "query"

And here is the MITRE ATT&CK mapping that goes at the end of the same file:

[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1098"
name = "Account Manipulation"
reference = "https://attack.mitre.org/techniques/T1098/"
[rule.threat.tactic]
id = "TA0003"
name = "Persistence"
reference = "https://attack.mitre.org/tactics/TA0003/"
Name the problem: single-event rules cannot distinguish intent

You now have two validated KQL rules. Both detect real insider threat indicators. Both also fire on completely routine activity.

Why is this a problem in production?

Single-event rules match one log entry at a time with no awareness of what happened before or after. They cannot express the pattern that makes insider threats distinctive: event A followed by event B within a time window.

A DLP block followed by an MFA change within 10 minutes is far more suspicious than either event alone. But your current rules have no way to express that correlation. Each rule evaluates events in isolation.

The next step introduces EQL sequences to solve exactly this problem.

Before you run the final check, think about what you expect. You have two rules in your repository now. Will python -m detection_rules test pass for both, or will only one rule's tests run?

  • Confirm the full test suite passes by running:
python -m detection_rules test

All tests pass. Both the true positive and false positive events match your DLP rule. That is the designed outcome for this step. The false positive matching is the gap you will close with EQL sequences next.

You have proven the ceiling of single-event detection logic. Your rules are structurally correct but operationally noisy. Next, you will write an EQL sequence rule that correlates signals across multiple log sources and finally separates routine activity from suspicious behavior chains.

Correlate Signals with an EQL Sequence Rule

In the previous step, you saw how single-event KQL rules fire indiscriminately on both genuine insider threats and routine business activity. The false positive problem is baked in: one event at a time cannot distinguish intent.

Real insider threats unfold as a chain of events across multiple log sources. EQL sequences let you express "event A followed by event B within a time window" as a single detection, dramatically reducing false positives. In this step, you will write a sequence rule that correlates an anomalous Okta authentication with a subsequent Island Browser DLP violation.

In this step, get ready to:
  • Author an EQL sequence rule that correlates Okta auth anomalies with Island Browser exfiltration signals.
  • Validate the entire three-rule set and export deployment-ready NDJSON.
Author the EQL sequence rule with Claude Code

EQL's sequence by syntax joins events from different data sources using a shared field value and constrains them to a time window with maxspan. This lets you say "an Okta auth event from the same user, followed by a DLP block within 10 minutes" in a single query.

  • In Claude Code, prompt it to create a new file rules/insider_threat_correlated_exfil_sequence.toml with the following requirements:
Create a TOML detection rule at rules/insider_threat_correlated_exfil_sequence.toml

Requirements:
- Type: eql
- Sequence: Okta authentication from a proxy (okta.security_context.is_proxy == true) followed by an Island Browser DLP violation (verdict Blocked, reason clipboard_upload or file_download)
- Join on user.name with maxspan=10m
- Index patterns: logs-okta.system-* and logs-island.browser-*
- MITRE mapping: T1078.004 (Valid Accounts: Cloud Accounts) under Initial Access tactic, and T1567.002 (Exfiltration to Cloud Storage) under Exfiltration tactic
- Severity: critical, risk_score: 91
- rule_id: c3d4e5f6-a7b8-9012-cdef-123456789012
- Tags: Insider Threat, Island Browser, Okta, EQL, Sequence, Exfiltration, Fintech

Why give Claude Code this much detail?

Detection rules have strict schema requirements. Providing the exact rule_id, MITRE technique IDs, and field names ensures Claude Code produces a rule that passes validation on the first try.

Claude Code should generate a file that starts with the metadata and rule configuration. Verify the first section of the file matches this:

[metadata]
creation_date = "2024/01/01"
integration = ["okta", "island"]
maturity = "production"
updated_date = "2024/01/01"

[rule]
author = ["NextWork"]
description = "Detects a correlated insider threat sequence: an Okta authentication event from an unusual location or device followed within 10 minutes by an Island Browser DLP violation, joined on user identity."
from = "now-15m"
index = ["logs-okta.system-*", "logs-island.browser-*"]
language = "eql"
license = "Elastic License v2"
name = "Insider Threat: Anomalous Auth Followed by DLP Exfiltration"
query = """
sequence by user.name with maxspan=10m
  [authentication where event.dataset == "okta.system" and okta.security_context.is_proxy == true]
  [any where event.dataset == "island.browser" and island.verdict == "Blocked" and island.verdict_reason in ("clipboard_upload", "file_download")]
"""
risk_score = 91
rule_id = "c3d4e5f6-a7b8-9012-cdef-123456789012"
severity = "critical"
tags = ["Insider Threat", "Island Browser", "Okta", "EQL", "Sequence", "Exfiltration", "Fintech"]
type = "eql"

What does the EQL query do?

  • sequence by user.name with maxspan=10m requires both events to share the same user.name value and occur within 10 minutes of each other.
  • The first bracket matches an Okta authentication event where the request came through a proxy, indicating an unusual location or anonymised connection.
  • The second bracket matches an Island Browser DLP block where data was stopped from leaving approved boundaries.
  • Only when BOTH events fire for the SAME user within the time window does this rule generate an alert.

Now verify the MITRE ATT&CK mapping section at the bottom of the file matches this:

[[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 = "T1567"
name = "Exfiltration Over Web Service"
reference = "https://attack.mitre.org/techniques/T1567/"
[[rule.threat.technique.subtechnique]]
id = "T1567.002"
name = "Exfiltration to Cloud Storage"
reference = "https://attack.mitre.org/techniques/T1567/002/"
[rule.threat.tactic]
id = "TA0010"
name = "Exfiltration"
reference = "https://attack.mitre.org/tactics/TA0010/"

Why two threat mappings?

This rule detects behaviour that spans two ATT&CK tactics. The anomalous authentication maps to T1078.004 (Valid Accounts: Cloud Accounts) under Initial Access.

The DLP exfiltration maps to T1567.002 (Exfiltration to Cloud Storage) under the Exfiltration tactic. Mapping both gives SOC analysts full context when triaging an alert.

  • Confirm your file is saved at rules/insider_threat_correlated_exfil_sequence.toml.
  • Validate just this rule by running:
python -m detection_rules validate-rule rules/insider_threat_correlated_exfil_sequence.toml
  • You should see the rule pass validation with no errors.

Seeing a schema validation error?

  • Check that type = "eql" and language = "eql" both appear in your [rule] section. EQL rules require both fields to be set to eql.
  • Verify the sequence by clause uses user.name (a standard ECS field) as the join key. Mismatched field types across the two event sources will cause validation failures.
  • Make sure the triple-quoted query string """...""" has no trailing spaces after the closing triple quotes.

help me fix an EQL sequence rule validation error in the detection-rules framework

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

Your rules/insider_threat_correlated_exfil_sequence.toml file combines the two sections above in order. Scroll up and confirm:

  • The [metadata] and [rule] section (first code block) is at the top of the file.
  • The two [[rule.threat]] blocks (second code block) follow immediately after, with no blank line between the type = "eql" line and the first [[rule.threat]].
  • There are no other sections or extra lines.
Validate and export the complete rule set

The sequence by syntax requires that the join key (user.name) exists and has a compatible type in both the Okta and Island Browser event schemas. Running validate-all checks all three rules as a unit, catching cross-rule conflicts.

  • Before you run the next command, predict: will all three rules pass, or will one fail? Why?
  • Run the full validation across your rule set:
python -m detection_rules validate-all
  • You should see all three rules (two KQL, one EQL) pass validation with no errors.

Why does sequence by need matching field types?

EQL joins events by comparing field values at query time. If one source maps user.name as a keyword and another maps it as text, the join silently fails. The detection-rules framework catches these mismatches during schema validation.

  • Run the unit tests to confirm all rules pass:
python -m detection_rules test
  • You should see test results passing for your DLP rule (the tests you wrote in the previous step).
  • Export the complete rule set to NDJSON by running:
python -m detection_rules export-rules-from-repo
  • Confirm the output file contains all three rules. The NDJSON output is ready for direct import into Kibana's Detection Engine.

What is NDJSON?

NDJSON (Newline Delimited JSON) is a format where each line is a self-contained JSON object. Kibana's Bulk Import API expects rules in this format so it can process them one at a time without loading the entire file into memory.

Export command not producing output?

  • Make sure your CUSTOM_RULES_DIR environment variable is set, or that you are running the command from the directory containing _config.yaml.
  • Confirm all three .toml files are inside the rules/ directory referenced by _config.yaml.

help me troubleshoot the export-rules-from-repo command not finding my rules

You now have a three-rule detection set: two single-event KQL rules for individual signals, and one EQL sequence rule that correlates them into a high-confidence alert. Next up, you will wire a GitHub Actions CI pipeline so that every rule change is automatically validated before it reaches production.

Wire Up GitHub Actions CI

Your three detection rules all validate and pass tests locally. But detection-as-code only earns its name when rule changes go through the same automated review that application code does.

In this step, you will create a GitHub Actions workflow that runs schema validation, unit tests, and NDJSON export on every pull request. This turns your local validation into an automatic gate that blocks bad rules from reaching production.

In this step, get ready to:
  • Create a CI workflow that validates and tests your rules on every PR.
  • Push your detection rules project to GitHub and confirm CI passes with green checks.
Create the CI workflow file

GitHub Actions workflows live in a .github/workflows/ directory at the root of your repository. The workflow file defines what runs, when it triggers, and in what order.

  • Create the workflow directory structure by running this command from your custom-rules directory:
mkdir -p .github/workflows
  • Confirm the directory was created by running:
ls .github/workflows

You should see an empty directory listing with no errors.

  • Create the workflow file with the CI triggers and dependency setup by pasting the following into .github/workflows/detection-rules-ci.yml:
name: Detection Rules CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  validate-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: true

      - name: Set up Python 3.12
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install detection-rules dependencies
        run: |
          cd detection-rules
          make

What does this workflow do so far?

  • on: triggers the workflow on pull requests and pushes to main. Every PR gets validated before merge.
  • submodules: true tells the checkout action to also clone the detection-rules submodule. Without it, CI would not have the framework code.
  • make inside detection-rules/ creates the Python virtual environment and installs all framework dependencies, exactly as you did locally in Step 1.
  • Add the validation, test, and export steps below the last line of the file:
      - name: Validate all rules
        env:
          CUSTOM_RULES_DIR: ${{ github.workspace }}/custom-rules
        run: |
          cd detection-rules
          python -m detection_rules validate-all

      - name: Run unit tests
        env:
          CUSTOM_RULES_DIR: ${{ github.workspace }}/custom-rules
        run: |
          cd detection-rules
          python -m detection_rules test

      - name: Export rules to NDJSON
        env:
          CUSTOM_RULES_DIR: ${{ github.workspace }}/custom-rules
        run: |
          cd detection-rules
          python -m detection_rules export-rules-from-repo --outfile ../export/rules.ndjson

      - name: Upload NDJSON artifact
        uses: actions/upload-artifact@v4
        with:
          name: detection-rules-ndjson
          path: export/rules.ndjson

What do these steps do?

  • CUSTOM_RULES_DIR tells the detection-rules framework where to find your TOML rules. The ${{ github.workspace }} variable resolves to the repo root on the CI runner.
  • validate-all checks every rule's TOML schema against the framework's expected structure. A malformed rule fails here before it reaches production.
  • test runs the unit tests you wrote, confirming query logic matches expected events.
  • export-rules-from-repo converts your TOML rules into NDJSON format that Kibana's Bulk Import API accepts. The artifact is attached to every successful workflow run.
  • Save .github/workflows/detection-rules-ci.yml.

YAML indentation errors?

YAML is whitespace-sensitive. Each - name: step must be indented at exactly 6 spaces (under steps:). If your editor uses tabs instead of spaces, the workflow will fail to parse.

Help me fix YAML indentation in my workflow file

Push to GitHub and open a pull request

The workflow file is ready. Now you need a GitHub repository so the Actions runner can execute it on every PR.

  • Navigate to your Desktop by running:
cd ~/Desktop
  • Create a new project directory that will become your GitHub repository, then initialise it with Git by running:
mkdir insider-threat-dac
cd insider-threat-dac
git init
  • Add the detection-rules repo as a Git submodule by running:
git submodule add https://github.com/elastic/detection-rules.git detection-rules

Why a submodule?

A Git submodule pins the detection-rules framework at a specific commit inside your repo. CI checks out both your code and the framework together, so the workflow can run make and the detection-rules CLI without a separate clone step.

  • Copy your custom rules directory and workflow into the new project by running:
cp -r ~/Desktop/custom-rules .
cp -r ~/Desktop/custom-rules/.github .
  • Confirm the project structure by running:
ls -la

You should see detection-rules/, custom-rules/, and .github/ listed in the output.

  • Create a new GitHub repository using the GitHub CLI by running:
gh repo create insider-threat-dac --public --source=. --remote=origin

gh command not found?

If you do not have the GitHub CLI installed, create the repository manually on github.com instead. Click **New repository**, name it insider-threat-dac, set it to **Public**, then copy the remote URL and run:

git remote add origin https://github.com/YOUR-USERNAME/insider-threat-dac.git

Help me push my project to GitHub without the gh CLI

  • Commit all files and push to a new branch by running:
git add .
git commit -m "Add insider threat detection rules with CI pipeline"
git checkout -b feature/insider-threat-rules
git push -u origin feature/insider-threat-rules
  • Open a pull request against main by running:
gh pr create --title "Add insider threat detection rules" --body "Initial rule set with CI validation" --base main

No gh CLI? Open the PR on GitHub

If you created the repo manually, navigate to your repository on github.com. GitHub will show a banner prompting you to open a pull request for your recently pushed branch. Click **Compare & pull request**.

Before you check the result, predict: do you think the workflow will pass on the first run, or will something fail?

  • Open your PR on GitHub and click the **Checks** tab (or the status checks section on the PR) to watch the workflow run.

You should see green checks for all steps: checkout, Python setup, dependency install, validate-all, test, and the NDJSON export. The workflow run also shows a downloadable artifact named detection-rules-ndjson attached to it.

Seeing a red X on the workflow?

  • Click the failed step to read the error log. The most common issue is the submodule not cloning properly. Confirm you ran git submodule add and that .gitmodules exists in your repo root.
  • If validate-all fails, check that custom-rules/ is at the repo root and contains your _config.yaml, etc/, and rules/ directories.
  • If the export step fails with a path error, verify the --outfile ../export/rules.ndjson path is relative to the detection-rules/ directory.

Help me debug my failing CI workflow

✔️ Awesome, I've got everything!

Great. Double check you have saved your workflow file and that CI shows all green checks on your PR.

ⓧ I'd like to double check the full code

Your .github/workflows/detection-rules-ci.yml file is built from two chunks above. Scroll up and confirm both are present in order:

  • Chunk 1: the workflow name, triggers, job definition, checkout with submodules, Python setup, and dependency install (ends with make).
  • Chunk 2: the validate-all step, test step, export step with --outfile ../export/rules.ndjson, and the upload-artifact step.

The two chunks together form the complete workflow. Ensure there is no extra blank line between them and that indentation is consistent at 6 spaces for each step.

Your detection-as-code pipeline is live. Every time you or a teammate pushes a rule change, CI validates the schema, runs unit tests, and produces a deployment-ready NDJSON artifact automatically. No bad rule slips through without the gate catching it.

Secret mission

Threshold Rule for Off-Hours Bulk Access

Your three rules catch individual violations and correlated sequences. But what about the insider who never triggers a DLP block because they are just browsing sensitive systems? A threshold rule fires only when a count of matching events exceeds a limit within a time window, catching bulk reconnaissance that single-event rules miss entirely.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on local tools and GitHub's free tier. There are no ongoing cloud costs or paid services to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A GitHub repository containing your detection rules and CI workflow (free for public repos, 2000 free Actions minutes/month for private repos).
  • Local project files on your Desktop (the detection-rules clone, custom-rules directory, and workflow files).

Keep everything running

No action needed. Choose this if you plan to keep building on your detection rules.

  • Your GitHub repository stays public as a portfolio piece demonstrating detection-as-code skills.
  • Your local project files remain on your Desktop for continued development.
  • GitHub Actions will continue to run on every PR at no cost for public repositories.

Pause - I'll come back to this later

Nothing to pause. There are no running services, containers, or active processes in this project.

  • Your GitHub repository and Actions workflow only consume resources when a PR or push triggers the workflow.
  • Your local files sit on disk with no background processes.
  • Come back any time and pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Delete your GitHub repository by navigating to your repo page, clicking the Settings tab, scrolling to the Danger Zone section at the bottom, and clicking Delete this repository. Confirm by typing the repository name when prompted.
  • Delete your local project files by running this command in your terminal:
cd ~/Desktop
rm -rf detection-rules custom-rules .github export

What does this command do?

This removes the cloned detection-rules repository, your custom-rules directory with all TOML rule files, the GitHub Actions workflow directory, and the NDJSON export folder from your Desktop.

Nice Work!

Nice Work!

You just built a production-grade detection-as-code pipeline for insider threat scenarios. Your repository follows the same framework that Elastic's detection engineering team uses to ship rules to thousands of security teams.

Here's what you accomplished:

  • Authored TOML-based detection rules targeting insider threat scenarios using KQL for single-event detections and EQL sequences for correlated multi-event behavioral patterns.
  • Experienced the real boundary between single-event and multi-event detection logic by watching a naive KQL rule fire indiscriminately, then fixing it with an EQL sequence that correlates authentication anomalies with data exfiltration signals.
  • Wired a GitHub Actions CI pipeline that validates rule schema, runs unit tests, and exports deployment-ready NDJSON on every pull request.
  • Secret Mission: Built a threshold rule that detects off-hours bulk access patterns, catching behavior that is individually legitimate but collectively anomalous.

Ready to quiz yourself?