Rules of your own.
GAThe library covers attacks every workspace faces. Your own rules cover the ones only you know about: the admin who should never act from a script, the repository nobody clones from home. A workspace rule is written in the same format as the library, runs on the same stream, and earns its stage the same way. Write one under Shield → TillTell → Rules → New rule, or copy a library rule and tune it.
A rule, top to bottom
Rules are YAML in a Sigma-compatible shape. This one is the starting point the editor opens with. It checks, saves and passes its tests as written:
id: team-admin-from-script
title: Admin change from a scripted client
kind: event
level: medium
description: A workspace role or key changed from curl or a scripting library rather than a browser.
tags:
- attack.t1098
detection:
change:
action:
- iam.role_changed
- iam.api_key_created
scripted:
actor.user_agent|re|i: ^(curl|wget|python-requests|go-http-client)/
condition: change and scripted
falsepositives:
- Your own automation, if it changes roles or keys
tests:
- name: a role change from curl fires
record:
kind: event
action: iam.role_changed
actor: { user_agent: curl/8.4.0 }
expect: match
- name: the same change from a browser stays quiet
record:
kind: event
action: iam.role_changed
actor: { user_agent: Mozilla/5.0 }
expect: no_match
| Key | What it holds |
|---|---|
id | Lowercase letters, digits and dashes, 3 to 64 characters, starting with a letter. It can’t change once saved. Ids starting with tell- belong to the library, and new, validate and backtest are reserved. |
title | Up to 200 characters. Findings carry it. |
kind | Always event for a workspace rule: rules read the security event stream. |
level | informational, low, medium, high or critical. |
tags | At least one ATT&CK technique, like attack.t1098. Tactic tags such as attack.persistence are allowed too. Techniques put the rule on the coverage map. |
requires_trust | server to ignore events an app SDK reported. Leave it out to accept both. |
detection | Named selections and a condition. See below. |
correlation | Optional. Fire on a pattern across events instead of one event. |
response | Optional suggest_playbook: one of the library playbooks. A live rule offers it with each finding. |
tests | Required. At least one that fires and one that stays quiet. |
stage | Optional. draft saves the rule without running it. Anything else starts in shadow. |
description, falsepositives, references, remediation | Optional notes shown with the rule. remediation says how to fix what it finds, in up to 2,000 characters. |
Sigma metadata such as status, logsource, author and date is accepted and ignored, so a Sigma rule moves over with small changes. Any other key is an error. A version key is ignored with a warning: TillTell numbers each save itself.
Selections, fields and modifiers
A selection is a map of field to value, and every field in it must match. A list of maps matches when any of them does. A list of values matches when any value does, unless the field carries |all. Fields are dot paths into the event:
| Field | Holds |
|---|---|
action | What happened, like auth.login_failed, iam.role_changed, secret.reveal or repo.force_push. An action no product records yet is a warning. |
outcome | success, failure or denied. |
source | The product that wrote it, like tillauth, tillsecrets, tillforge, tillark or workspace. |
trust | server or client. |
actor.type, actor.id | Who: user, service or unknown, and their id. |
actor.ip, actor.country, actor.asn, actor.user_agent, actor.session_id | Where from, when known. |
target.type, target.id, target.name | What it was done to. |
project_id | The project, for project-scoped events. |
raw.… | Product-specific detail, like raw.role on a role change. |
Modifiers
Add modifiers to a field with a pipe: actor.user_agent|startswith. Plain values compare whole and ignore case, and accept Sigma wildcards: * for any run of characters and ? for one.
| Modifier | Matches when |
|---|---|
contains, startswith, endswith | The text holds, begins or ends with the value. Add cased to respect case. |
re | An RE2 regex finds a match. Case-sensitive unless you add i. Add m for multi-line anchors and s for a dot that matches newlines. |
cidr | The address is inside the range, like 10.0.0.0/8 or 2001:db8::/32. |
gt, gte, lt, lte | The number compares as written. |
exists | The field is present (true) or absent (false). |
all | Every value in the list matches, not just one. |
A value of null matches a field that is missing. Text operators and regexes read the first 8,192 characters of a field.
The condition
The condition combines selections with and, or, not and parentheses. 1 of admin_* and all of admin_* match against selections whose names start with admin_, and 1 of them or all of them against every selection. Sigma’s pipe aggregations, like | count() > 5, are not supported: use a correlation block.
{500} cost the most.Patterns across events
A correlation block makes the rule fire on a pattern instead of a single event. Events that match the detection feed it, grouped by up to five group-by fields, inside a timespan written like 30s, 5m, 12h or 30d. Counting, ordering and travel rules fire once per group per timespan. A first-seen rule fires on each new value.
| Type | Fires when | Also takes |
|---|---|---|
event_count | The group’s matching events reach the condition, like { gte: 20 }. | condition |
value_count | The group shows enough distinct values of field. | field, condition |
temporal | Every selection in steps has matched for the group, in any order. | steps: 2 to 10 selection names |
temporal_ordered | The steps matched in the order written. | steps |
geo_velocity | Two events for the group are further apart than travel allows, compared in km/h. | condition, min_distance_km (default 100) |
first_seen | A value of field the group hasn’t used in the timespan appears, once the group has history. | field, min_history (default 3) |
Counts only grow as events arrive, so counting conditions take gt, gte or eq. An event missing a group-by field counts toward no one. This rule fires when someone with a history of clones pulls a repository from a new address:
id: team-clone-new-address
title: Repository cloned from a new address
kind: event
level: medium
description: Someone with a history of clones pulled a repository from an address they have not used in thirty days.
tags: [attack.collection, attack.t1213.003]
requires_trust: server
falsepositives:
- A new office, home connection or VPN exit
detection:
clone: { action: repo.clone, outcome: success }
condition: clone
correlation:
type: first_seen
group-by: [actor.id]
field: actor.ip
timespan: 30d
min_history: 5
tests:
- name: a clone from a new address after a week of usual ones fires
sequence:
- record: { action: repo.clone, outcome: success, org_id: o1, trust: server, source: tillforge, actor: { id: u1, ip: 198.51.100.7 }, timestamp: "2026-01-01T08:00:00Z" }
repeat: 5
every: 1d
- record: { action: repo.clone, outcome: success, org_id: o1, trust: server, source: tillforge, actor: { id: u1, ip: 203.0.113.50 }, timestamp: "2026-01-07T08:00:00Z" }
expect_fires: 1
- name: clones from the usual address stay quiet
sequence:
- record: { action: repo.clone, outcome: success, org_id: o1, trust: server, source: tillforge, actor: { id: u1, ip: 198.51.100.7 }, timestamp: "2026-01-01T08:00:00Z" }
repeat: 8
every: 1d
expect_fires: 0
Every rule proves itself
A rule can’t be saved until its tests pass, and it needs at least one test that fires and one that stays quiet. A rule that matches single events tests with a record and expect: match or expect: no_match. A correlation rule tests with a sequence of records replayed in order, each optionally sent repeat times every so far apart, and expect_fires: how many findings the sequence should produce. A record’s timestamp sets where its run starts.
The tests run on every check and every save, inside 2 seconds and 2,000 replayed events. A rule holds at most 50 tests.
One workspace can’t slow another
Workspace rules run beside every other workspace’s, so each is held to a budget:
| Limit | Value |
|---|---|
| Rules per workspace | 100 |
| Value comparisons per event, across every field | 1,000 |
| Compiled regex size, across every pattern | 2,000 |
| Correlation timespan | 7 days; 90 for first_seen |
| Rule text | 64 KB |
The check shows a rule’s cost against these limits before you save. Detection also times each rule as it runs. A rule that spends more than its share matching one batch of events is paused: it stops running, the rules page says why, and owners and admins see it in their notifications. Resume it from the rules page, or save a new version.
Saving, stages and versions
- A new rule starts in shadow: it records findings and never alerts. Promote it through the same stages as a library rule, and the same false-positive arithmetic demotes it.
- Each save is a new version. A save that changes the detection, correlation, trust or suggested playbook of an advisory or live rule drops it back to shadow and clears any automation confirmation, so the new logic earns its stage again. Wording changes keep the stage.
- The editor refuses to overwrite a version someone else saved after you opened it.
- Deleting a rule stops it and removes its links. Its findings, verdicts and history stay.
- Library rules can’t be edited. Copy one into a new rule, which gets a
team-id you can change before saving.
Owners and admins write, save, delete and resume rules. Any member can check one. Every save, deletion, pause and resume lands in the workspace audit log.
Try it before it runs
The editor’s Try on recent events replays up to 5,000 of your workspace’s most recent events, from the last hour up to the last 7 days, through the rule as written. It shows how many matched, how many times it would have fired, and up to 20 of those events. Nothing is saved or recorded, and drill events are left out. A rule with failing tests can still be backtested, as long as it compiles.
From the terminal
# check a rule without saving: problems, tests and cost; exits 1 on problems
tilldev shield tell rules validate rule.yaml
# what it would have fired on in the last three days
tilldev shield tell rules backtest rule.yaml --hours 72
# save it (starts in shadow), then a new version of it
tilldev shield tell rules create rule.yaml
tilldev shield tell rules update team-admin-from-script rule.yaml --version 1
# read it from stdin instead of a file
cat rule.yaml | tilldev shield tell rules validate -
# after a pause, and when it's no longer wanted
tilldev shield tell rules resume team-admin-from-script
tilldev shield tell rules rm team-admin-from-script --yesThe same operations are on the HTTP API under /api/shield/tell/rules, documented in the API reference. Validate in CI to keep rules in a repository and catch a broken one before it ships.