> For the complete documentation index, see [llms.txt](https://docs.scanner.dev/scanner/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.scanner.dev/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-rules-as-code/writing-detection-rules.md).

# Writing Detection Rules

## File requirements

* Each detection rule should have its own YAML file. The file should validate against our [#schema](#schema "mention").
* The file name should end with `.yaml` or `.yml` extension. Files with other extensions will be ignored.
* The file should have `# schema: https://scanner.dev/schema/scanner-detection-rule.v1.json` as its first line. This is how we determine whether to a YAML file is a detection rule. YAML files without this comment will be ignored.

## Schema

The schema for Scanner detection rules is at <https://scanner.dev/schema/scanner-detection-rule.v1.json>.

The schema contains information on the properties, including descriptions, requirements, and valid values. This can be used as a reference to write your detection rules and to validate your YAML documents.

## Detection Rule

A detection rule YAML consists of:

* `name` - detection rule name
* `description` - description of detection rule
* `enabled` - one of `Active`, `Staging`, or `Paused`. `Active` detection rules run and send detection events to event sinks. `Staging` detection rules run but do not send detection events to event sinks. `Paused` detection rules do not run.
* `severity` - severity of the detection rule. Scanner uses [OCSF Severity ID](https://schema.ocsf.io/1.1.0/classes/detection_finding) standards for severity tags. Refer to the [schema](https://scanner.dev/schema/scanner-detection-rule.v1.json) for more details.
* `query_text` - the query for the detection rule
* `tags`- associated tags for the detection rule. See [Detection Rule Tags](#detection-rule-tags), below.
* `time_range_s` - the lookback period (in seconds). Must be minute granularity (for example, 60 seconds is valid, but 30 seconds is not).
* `run_frequency_s` - how frequently to run the detection rule (in seconds). Must be minute granularity and <= `time_range_s`.
* `dedup_window_s` - the deduplication window (in seconds). If this detection rule alerts multiple times within this time window with the same dedup keys in the alert output, only the first alert will be sent. See [Deduplicating detection events](/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules.md#deduplicating-detection-events) for more information.
* `alert_per_row` - if `true`, emit one detection alert per row of the query's results table instead of a single alert containing the whole (truncated) table. Defaults to `false`. Useful when each row represents an independent finding that should be triaged, deduplicated, or routed on its own.
* `event_sink_keys` - keys for event sinks to send detection events to. See [Detection Rule Event Sinks](#detection-rule-event-sinks), below.
* `alert_template`- custom detection alert formatting. See [Alert Template](#alert-template) below.
* `tests` - a list of test cases and expected results to apply to this detection rule. See [Detection Rule Tests](#detection-rule-tests), below.

### Example

Below is an example YAML for a detection rule. It looks for updates to AWS SecurityHub findings, which may indicate evasion of security measures.

It is configured to run every 60 seconds and look for possible threat activity in the last 5 minutes of logs. It will trigger alerts with `High` severity level and send them to the alert event sinks that are associated with the key `high_severity_alerts`.

```yaml
# schema: https://scanner.dev/schema/scanner-detection-rule.v1.json
name: AWS SecurityHub Findings Evasion
enabled: true
description: |
  Detects the modification of the findings on SecurityHub.

  References:
  * https://docs.aws.amazon.com/cli/latest/reference/securityhub/

  False Positives:
  * System or Network administrator behaviors
  * DEV, UAT, SAT environment. You should apply this rule with PROD environment only.
severity: High
query_text: |
  @scnr.source_type="aws:cloudtrail"
  eventSource="securityhub.amazonaws.com"
  eventName=(
    BatchUpdateFindings
    DeleteInsight
    UpdateFindings
    UpdateInsight
  )
  | stats
    min(@scnr.datetime) as firstTime,
    max(@scnr.datetime) as lastTime,
    count() as eventCount
    by
    userIdentity.arn,
    eventSource,
    eventName,
    awsRegion
tags:
  - techniques.ta0005.defense_evasion
time_range_s: 300
run_frequency_s: 60
event_sink_keys:
  - high_severity_alerts
alert_template:
  info:
    - label: Severity
      value: "{{@alert.severity}}"
    - label: Description
      value: "{{@alert.description}}"
  actions:
    - label: "View Runbook"
      value: "https://runbooks.com/aws_security_hub_findings_evasion"
tests:
  - name: Test alert is triggered when SecurityHub findings modified
    now_timestamp: "2024-08-21T00:03:00.000Z"
    dataset_format: FlatTable
    dataset_inline: |
      {"@scnr.datetime":"2024-08-21T00:02:30.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"securityhub.amazonaws.com","eventName":"BatchUpdateFindings","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","awsRegion":"us-west-2"}
      {"@scnr.datetime":"2024-08-21T00:02:00.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"s3.amazonaws.com","eventName":"GetObject","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","requestParameters.bucketName":"mybucket","requestParameters.key":"exampleobject"}
      {"@scnr.datetime":"2024-08-21T00:01:50.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"securityhub.amazonaws.com","eventName":"UpdateFindings","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","awsRegion":"us-west-2"}
      {"@scnr.datetime":"2024-08-21T00:01:30.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"securityhub.amazonaws.com","eventName":"DeleteInsight","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","awsRegion":"us-west-2"}
      {"@scnr.datetime":"2024-08-21T00:01:20.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"lambda.amazonaws.com","eventName":"InvokeFunction","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","requestParameters.functionName":"my-function"}
      {"@scnr.datetime":"2024-08-21T00:01:10.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"securityhub.amazonaws.com","eventName":"UpdateInsight","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","awsRegion":"us-west-2"}
    expected_detection_result: true
  - name: Test no alert is triggered when SecurityHub findings not modified
    now_timestamp: "2024-08-21T00:03:00.000Z"
    dataset_format: FlatTable
    dataset_inline: |
      {"@scnr.datetime":"2024-08-21T00:02:30.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"s3.amazonaws.com","eventName":"GetObject","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","requestParameters.bucketName":"mybucket","requestParameters.key":"exampleobject"}
      {"@scnr.datetime":"2024-08-21T00:02:00.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"lambda.amazonaws.com","eventName":"InvokeFunction","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","requestParameters.functionName":"my-function"}
      {"@scnr.datetime":"2024-08-21T00:01:50.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"dynamodb.amazonaws.com","eventName":"PutItem","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","requestParameters.tableName":"my-table"}
      {"@scnr.datetime":"2024-08-21T00:01:20.000Z","@scnr.source_type":"aws:cloudtrail","eventSource":"rds.amazonaws.com","eventName":"CreateDBInstance","userIdentity.arn":"arn:aws:iam::123456789012:user/JohnDoe","requestParameters.dBInstanceIdentifier":"mydb"}
    expected_detection_result: false
```

## Index Specifiers

To include an index specifier in your query text, you must use the full index specifier format, which is `@index={ UUID | "alias" }`. While `@index=alias` works on Scanner web and API, this format cannot be used for detection rules because aliases can change.

To get the full index specifier format, tab-to-complete an index specifier in the Scanner search box and copy-paste it into your detection rule file.

Below is a valid index specifier:

```yaml
query_text: |
  @index={ 00000000-0000-0000-0000-000000000000 | "cloudtrail_logs" }
```

Below are examples of invalid index specifiers. These will result in `QueryParseErrors`.

```yaml
query_text: |
  @index=00000000-0000-0000-0000-000000000000
```

```yaml
query_text: |
  @index=cloudtrail_logs
```

Using `index` rather than `@index` will search for a column called `index` rather than filter a Scanner index.

## Detection Rule Tags

Tags are specified as an array in YAML. Each tag must begin with an ASCII letter, and contain only ASCII alphanumeric characters, `.`, `-`, or `_`.

Scanner has default MITRE tags that you can use or you can create your own. See [MITRE Tags](/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/mitre-tags.md) for the full list of default tags.

### Example

Below is an example of a detection rule's tags. This should be in the same file as your detection rule.

```yaml
tags:
  - techniques.ta0006.credential_access
  - tactics.t1586.compromise_accounts
```

## Detection Rule Event Sinks

You can specify keys that identify which event sinks a detection rule should log events to. You can assign one or more actual event sinks to each key in the settings for the sync source; see [Getting Started](https://docs.scanner.dev/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-rules-as-code/pages/jTTouUAzg7YJIv71YotF#id-3.-add-a-sync-source).

Event sink keys can just be specified as an array in YAML. Each event sink key must begin with an ASCII letter, and contain only ASCII alphanumeric characters, `-`, or `_`.

### Example

Below is an example of a detection rule's event sink keys. This should be in the same file as your detection rule.

```yaml
event_sink_keys:
  - high_severity_alerts
  - soar_response_flow
```

## Alert Template

You can customize detection alerts by providing an `alert_template`. The `alert_template` has two optional objects: `info` and `actions`. Both accept arrays of items with `label`, `value`, and `use_for_dedup` fields.

`actions` will appear as buttons in Slack and as links in Markdown. The values in the `actions` section should be URLs. If the value is an invalid URL or not a URL, the key/value will appear as text in the alert, rather than as a button or link.

See [Detection Alert Formatting](/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-alert-formatting.md) for more information on the `info` and `actions` sections, as well as information on templating values.

`use_for_dedup` controls whether the field is used for deduplication; see [Deduplicating detection events](/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules.md#deduplicating-detection-events).

Note: `info` and `actions` also accept pairs of strings, but this format is deprecated. We recommend migrating to the `label` and `value` format.

### Example

Below is an example of an alert template. This should be in the same file as your detection rule.

```yaml
alert_template:
  info:
    - label: Severity
      value: "{{@alert.severity}}"
    - label: Description
      value: "{{@alert.description}}"
      use_for_dedup: true
  actions:
    - label: "View Runbook"
      value: "https://runbooks.com/aws_security_hub_findings_evasion"
```

## Detection Rule Tests

You can specify tests in your YAML file to run against the detection rule.

A detection rule test consists of:

* `name` - test name
* `now_timestamp` - timestamp to start the test (optional)
* `dataset_inline` - a list of JSON log events for the test
* `dataset_format` - how to interpret each log event line (optional; see [Dataset Format](#dataset-format))
* `expected_detection_result` - whether the given dataset triggers a detection event

If `now_timestamp` is not provided, Scanner will use the timestamp of the latest log event as the `now_timestamp`. In both cases, the timestamp is rounded to the next `run_frequency_s`. For example, if `run_frequency_s` is 60 seconds and `now_timestamp` is `2024-07-05T10:09:34.123Z`, the timestamp will be rounded up to `2024-07-05T10:10:00.000Z` (the next minute). Note that query and detection windows are inclusive-exclusive, so log events with timestamp exactly equal to (or greater than) `now_timestamp` will *not* be counted in the test run!

Detection rules will not sync if there are failing tests.

### Dataset

Test datasets are a list of JSON log events. There are a few ways to write test datasets:

1. You can export results from Scanner as JSON lines and use the results as a test dataset. To export results, run a query in Scanner and click the arrow next to **Run** and select **JSON Lines** under **Export Results.**
2. You can copy-paste `raw_event`s from Scanner search results. The copy-pasted result is not guaranteed to be valid JSON because values may not be properly escaped.
3. You can write your own JSON log events. A valid JSON log event **must** specify a timestamp in RFC 3339 format via the reserved `@scnr.datetime` column. (A top-level `timestamp` field is also accepted as a legacy-only fallback for older test cases, but new tests should use `@scnr.datetime`. When both are present, `@scnr.datetime` takes priority.) The event can contain any number of additional key/value pairs. An example log event:

```json
{
  "@scnr.datetime": "2024-07-05T10:12:34.123Z",
  "errorMessage": "unauthorized",
  "eventSource": "s3.amazonaws.com"
}
```

### Dataset Format

The optional per-test `dataset_format` field controls how each JSON line in `dataset_inline` is turned into the columns your query runs against (see [Log Event Structure](/scanner/using-scanner-complete-feature-reference/data-ingestion/log-event-structure.md) for how ingestion maps events onto columns). **We strongly recommend you explicitly set this to `FlatTable` or `RawJson`** — the default (`LegacyFlatten`) exists only for backwards compatibility (see the warning below). There are three formats:

* `FlatTable` - Reads each event as an already-flattened column table: every key is a column name verbatim. Any non-flat event (e.g. anything containing an object or array value) is rejected. Use this when you paste events exported from Scanner as JSON Lines, since those are already in post-flatten column form. You can set reserved fields like `@scnr.source_type` directly as flat keys.
* `RawJson` - Reads each event as raw nested JSON and flattens it exactly the way live ingestion would. Use this when you paste raw, nested log events as they would arrive at ingestion. Note that a flat dotted key like `"a.b"` flattens the way ingestion flattens it — to a column **distinct** from the nested path `{"a": {"b": ...}}` — so reserved fields must be written nested (e.g. `"@scnr": {"source_type": "aws:cloudtrail"}`), not as flat `"@scnr.<key>"` keys.
* `LegacyFlatten` (default) - A backwards-compatibility format used by older test cases that predate `dataset_format`. It is the default only so those tests keep working, and it is **not recommended for new tests** because it can produce results that don't match real ingestion (see the warning below).

In every format, Scanner fills in the reserved system fields so test events match what real ingestion produces. Exactly three system fields are user-settable (in `RawJson` they are set via a nested `@scnr` object; in `FlatTable` and `LegacyFlatten` via flat keys):

* `@scnr.datetime` - the event timestamp, in RFC 3339. Falls back to the legacy top-level `timestamp` field when unset. The queryable `@scnr.datetime` and `@scnr.time_ns` columns are then both derived from that one timestamp — they always agree, and you set them only via the timestamp.
* `@scnr.source_type` - defaults to `custom:generic` when unset. For backwards compatibility, a value that isn't a known Scanner source type is preserved verbatim — note that this differs from real ingestion, which only ever produces known source types.
* `@scnr.source_type_custom_name` - defaults to `scnr:test` when unset. Regardless of what you set, this field is blank whenever the source type isn't `custom:generic` (a custom name only applies to `custom:generic`).

All other reserved fields are filled in by Scanner, ignoring any values you write for them: `@index` / `@index_id` are set to fixed test values (in every format), and the event-identity fields are derived from the event's timestamp and its position in the dataset. Setting any other `@scnr*` key (any key beginning with `@scnr`) is unspecified behavior that may change without warning. (Exception: under `LegacyFlatten`, values you write for `@scnr*` keys other than `@scnr.datetime` / `@scnr.time_ns` are preserved verbatim, taking precedence over the derived values.)

{% hint style="warning" %}
**Avoid relying on the default `LegacyFlatten` format.** When `dataset_format` is omitted, tests fall back to `LegacyFlatten`, which exists only for backwards compatibility with older test cases. While it's forgiving and flexible, it also has undesirable rough edges:

* **Ambiguous path collisions.** It flattens by naively concatenating nested path segments with `.`, so both `{"a": {"b": 1}}` and `{"a.b": 1}` collapse into the same column `a.b`. When a nested path and a flat dotted key collide, one silently overwrites the other (the flat key wins), masking bugs in your events or query.
* **Does not match ingestion.** It does not flatten events the way live ingestion does, so a test can pass (or fail) for reasons that won't hold against real data.
* **Reserved fields can be overridden.** Values you write for reserved `@scnr*` keys (other than `@scnr.datetime` / `@scnr.time_ns`) are preserved verbatim, overriding the values Scanner would derive — letting test events carry system-field values that real ingestion could never produce.

If you care about your tests reflecting production behavior, set `dataset_format` to `FlatTable` or `RawJson` instead.
{% endhint %}

### Example

Below is an example of a detection rule test. This should be in the same file as your detection rule.

```yaml
tests:
  - name: Test unauthorized event present
    now_timestamp: 2024-07-05T11:00:00.000Z
    dataset_format: FlatTable
    dataset_inline: |
      {"@scnr.datetime":"2024-07-05T10:12:34.123Z","errorMessage":"unauthorized","eventSource":"s3.amazonaws.com"}
      {"@scnr.datetime":"2024-07-05T10:12:45.123Z","eventSource":"lambda.amazonaws.com"}
      {"@scnr.datetime":"2024-07-05T10:13:34.123Z","errorMessage":"unauthorized","eventSource":"s3.amazonaws.com"}
      {"@scnr.datetime":"2024-07-05T10:14:34.123Z","errorMessage":"unauthorized","eventSource":"s3.amazonaws.com"}
      {"@scnr.datetime":"2024-07-05T10:15:34.123Z","errorMessage":"unauthorized","eventSource":"s3.amazonaws.com"}
      {"@scnr.datetime":"2024-07-05T10:16:34.123Z","errorMessage":"unauthorized","eventSource":"s3.amazonaws.com"}
      {"@scnr.datetime":"2024-07-05T10:17:34.123Z","errorMessage":"unauthorized","eventSource":"s3.amazonaws.com"}
      {"@scnr.datetime":"2024-07-05T10:18:34.123Z","errorMessage":"unauthorized","eventSource":"s3.amazonaws.com"}
    expected_detection_result: true
```

## Offline Validation

While working on your detection rules, it can be valuable to test them as-is without needing to push to Github and sync with Scanner. We provide tools to do so:

* A JSON schema that describes detection rules can be found at <https://scanner.dev/schema/scanner-detection-rule.v1.json>, which can be used to validate the YAML files directly.
* Scanner provides a [CLI](/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-rules-as-code/cli.md) and [public API endpoints](/scanner/using-scanner-complete-feature-reference/developer-tools/api.md) that can be used to validate the rule files, and also to run the provided tests.

{% hint style="warning" %}
Sync is all-or-nothing: if **any** detection rule in your repository is invalid or has a failing test, the entire sync will not run. This is by design — rules can depend on each other (for example, one rule writes to the detections index and another analyzes it), so an invalid rule can break the full detection graph. Validate locally or in CI before pushing and merging to catch issues early.
{% endhint %}

## Validating Rules in CI with GitHub Actions

[GitHub Actions](https://docs.github.com/en/actions) run automated checks on every push and pull request in your repository. If a check fails, it appears directly on the PR — and you can configure branch protection rules to block merging until it passes.

Scanner provides a [GitHub Action](https://github.com/scanner-inc/validate-detection-rules) that validates your detection rule YAML files and optionally runs their inline tests as part of your CI/CD pipeline.

### Repository layout

A typical detection rules repository looks like this:

```
my-detections/
├── .github/
│   └── workflows/
│       └── validate-detection-rules.yml
├── rules/
│   ├── aws/
│   │   └── cloudtrail/
│   │       ├── securityhub_findings_evasion.yml
│   │       └── malicious_lambda_layer.yml
│   └── gcp/
│       └── audit/
│           └── suspicious_iam_grant.yml
└── README.md
```

### Setup

Add the workflow file at `.github/workflows/validate-detection-rules.yml`:

```yaml
name: Validate Detection Rules
on: [push, pull_request]

jobs:
  validate:
    name: Validate Detection Rules
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: scanner-inc/validate-detection-rules@v0.2.0
        with:
          scanner_api_url: ${{ secrets.SCANNER_API_URL }}
          scanner_api_key: ${{ secrets.SCANNER_API_KEY }}
          dir: "rules" # path to your rules directory; use "." if the entire repo is detection rules
          recursive: true
          check_action: "validate_and_run_tests"
```

The `check_action` input controls what the action checks:

* **`validate_only`** (default) — checks YAML structure and schema validity.
* **`validate_and_run_tests`** — also runs the inline `tests` defined in each rule file.

Configure the following secrets in your repository settings (Settings → Secrets and variables → Actions). See the [API docs](/scanner/using-scanner-complete-feature-reference/developer-tools/api.md) for where to find your API URL and key.

* `SCANNER_API_URL`: Your Scanner instance API URL
* `SCANNER_API_KEY`: Your Scanner API key

#### Organization Actions permissions

If your organization restricts which Actions and reusable workflows are allowed to run, you may need to explicitly allow the Scanner action. Go to your organization or repository **Settings → Actions → General** and, under **"Allow or block specified actions and reusable workflows"**, add:

```
scanner-inc/validate-detection-rules@*,
```

{% hint style="info" %}
The GitHub Action uses `scanner-cli` under the hood. For faster local iteration you can run `scanner-cli validate` and `scanner-cli run-tests` directly — see the [CLI docs](/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-rules-as-code/cli.md).
{% endhint %}

### Blocking pull requests

When the action detects an invalid rule or a failing test, the check fails and the details appear on the pull request. For a real example of what a failing check looks like, see [this PR](https://github.com/scanner-inc/detection-rules-example/pull/4).

To require the check to pass before merging, configure a [branch protection rule](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-a-branch-protection-rule) or [ruleset](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) on your default branch and add the validation workflow as a required status check.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.scanner.dev/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-rules-as-code/writing-detection-rules.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
