Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/email-security/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ that fires on volume. A refused attempt carries `result: refused` and a
| `eml_never_stored` | The message exists but no raw copy was written at ingest |
| `eml_expired` | The raw copy aged out of its retention lane |
| `read_failed` | The object is there and could not be read |
| `audit_write_failed` | The access record for this download could not be written (or an earlier record for it could not be read), so nothing was served |
| `eml_store_not_configured` | This deployment has no raw-message store |
| `internal_error` | The service could not complete the read (an index-store failure, not an object failure) |

Expand Down Expand Up @@ -309,10 +310,10 @@ faster, which is worth doing for its own sake.

| Route | Does |
|---|---|
| `POST /messages/{msg_uuid}/actions` | Perform a typed action on one message. Body: `action` (`quarantine_message`, `trash_message`, `move_to_spam`, `restore_message`, `banner_message`, `unbanner_message`), optional `force` (boolean; see [alert-only overrides](#explicit-override-in-alert-only-mode)), optional `reason`, optional `attempt` (idempotency token — omit to collapse onto the existing attempt). `banner_message` uses the organization's own banner, rendered from its `mailsec_policy` record of type `banners`; the body's `banner` field is **deprecated and ignored** and will be removed. Requires `mailsec.act` |
| `POST /messages/{msg_uuid}/actions` | Perform a typed action on one message. Body: `action` (`quarantine_message`, `trash_message`, `move_to_spam`, `restore_message`, `banner_message`, `unbanner_message`), optional `force` (boolean; see [alert-only overrides](#explicit-override-in-alert-only-mode)), optional `reason`, optional `attempt` (idempotency token — omit to collapse onto the existing attempt). `banner_message` uses the organization's own banner, rendered from its `mailsec_policy` record of type `banners`; its optional `text` (plain text, at most 512 characters; refused on any other action) replaces the wording for that one banner. No caller supplies HTML. Requires `mailsec.act` |
| `POST /campaigns/{campaign_id}/actions` | Sweep a campaign. Same body plus `confirm`. **Without `confirm` this previews** and changes nothing, returning the member ids, the distinct mailboxes, the counts and a `confirm` token derived from that exact member set. With `confirm` it executes exactly that set; a campaign that grew since the preview is refused. Capped at 500 members. `reason` is recorded on **every member's** audit row and on the sweep's own row (`action_id` in the response); `attempt` (bounded at 128 characters, refused not truncated) mints a new row per member, so a deliberate retry is recorded beside what it retried instead of over it. Neither is part of the `confirm` token. Requires `mailsec.act` |
| `POST /actions/bulk/execute` | Execute a previewed bulk remediation. Returns a `bulk_id` immediately and the provider work proceeds in the background. Requires `mailsec.act`. See [Bulk Remediation](remediation.md) |
| `POST /reports/{report_id}/resolve` | Record a triage outcome. Body: `disposition` — one of `true_positive`, `false_positive`, `benign`. Resolving an already-resolved report succeeds and reports `already_resolved`, so two analysts clicking at once is not an error. Requires `mailsec.set` |
| `POST /reports/{report_id}/resolve` | Record a triage outcome. Body: `disposition` — one of `malicious`, `spam`, `graymail`, `benign`, `simulation`. Resolving an already-resolved report succeeds and reports `already_resolved`, so two analysts clicking at once is not an error. Requires `mailsec.set` |
| `POST /reports/{report_id}/reopen` | Put a resolved report back in the queue — see [`POST /reports/{report_id}/reopen`](#post-reportsreport_idreopen). Requires `mailsec.set` |
| `POST /messages/{msg_uuid}/verdict` | Re-judge one message — see [`POST /messages/{msg_uuid}/verdict`](#post-messagesmsg_uuidverdict). Requires `mailsec.act` |
| `POST /connections/{record}/test` | Probe a configured connection and report each requirement independently: the credential, each scope, a real directory read, and — for Google Workspace — the notification subscription and topic. Every check carries `id`, `name`, `required`, `status`, and on failure `detail` and `remediation`. A failed **optional** check leaves `ok` true. Body: `include_watch` (Workspace only; the one probe with a side effect — it establishes an idempotent, self-expiring push watch). Takes a **record name, not a credential**. Requires `mailsec.act` |
Expand Down Expand Up @@ -399,6 +400,7 @@ close a report must be able to reopen one, or a mis-click is permanent.
|---|---|
| `POST /analyze` | Parse a raw message into the Message Data Model and judge it with the organization’s enabled `dr-mail` rules and resolved scoring policy. **Nothing is ingested or stored**: no index row is written, no raw copy kept, and the organization's mail history is unchanged. Body: `eml_b64` (preferred) or `eml`, plus optional `org_domains` and `direction` — one of `inbound`, `internal`, `outbound`; anything else is refused with a `400` rather than analysed, because many default detections apply to inbound mail only and a mistyped direction would silently answer a lower verdict. Omit it to judge with no direction. Tenant context it cannot have — your sender history, your VIP list — is named explicitly in the payload rather than silently missing. Requires `mailsec.get` |
| `POST /actions/bulk/preview` | Preview a bulk remediation over a caller-supplied selection: reports each message's current state and the distinct-mailbox blast radius, and mints the `confirm` token derived from that exact selection. **Nothing is changed and no job is created** — it is a `POST` only because up to 500 message ids do not belong in a query string. Requires `mailsec.get`, like the campaign preview it mirrors. See [Bulk Remediation](remediation.md) |
| `POST /banner/preview` | Render a candidate [`banners` policy](policy.md#banners) exactly as recipients would see it, without saving. Body: `banner` (the record's fields, without `policy_type`), optional `verdict` (which variant to preview) and optional `text` (an action's wording). Answers 200 with `valid: true`, the rendered `html`, supported `colors` and field `limits`, or `valid: false` and the validator's reason. Requires `mailsec.get` |
| `POST /rules/validate` | Compile a candidate `dr-mail` rule and report its errors without saving it. Body: `rule` (object), optional `rule_id`. Runs the same validator the `dr-mail` Hive applies on save, including lookup existence checks when the API's Hive metadata access is configured. Response blocks receive shape and size checks; full response compilation happens in the collector. See [Custom Rules](custom-rules.md#validation). An invalid rule is a `200` carrying `valid: false` and the reason, not an error response. Requires `mailsec.get` |
| `POST /rules/backtest` | Evaluate a candidate `pre_verdict` rule over re-parsed stored messages. Original pipeline enrichments are not reconstructed. Lookups use current records when the Hive resolver is configured; `post_verdict` rules are refused. See [backtest limitations](custom-rules.md#what-a-backtest-can-evaluate). Body: `rule`, optional `rule_id`, `since`, `until`. Every response carries a `coverage_note` and counts what it could not examine (`skipped_no_raw`, `skipped_unparse`, `truncated`). `precision` is `null` — not `0` — when nothing it matched has an analyst disposition yet. Every message in the window is re-read from storage, which makes this the most expensive read on the surface: it is subject to the [replay budget](#the-replay-budget). Requires `mailsec.get` |

Expand Down
165 changes: 159 additions & 6 deletions docs/email-security/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ cloud and identity data — which is what makes "a phish was delivered, and then
that user's endpoint ran a new binary" one rule instead of two products and a
spreadsheet.

## The sensor
## The sensors

Each mail connection appears as **one cloud sensor** on platform `email`. The
mailbox is a field on the event, not an identity: a ten-thousand-mailbox tenant
is one sensor, not ten thousand.
Each protected mailbox appears as its own sensor on platform `email`, keyed by
the provider's stable mailbox id and named by its normalized primary address.
Mailbox-scoped events carry `mailbox: {id, address, upn}`; `upn` is present when
known and can differ from the address on Microsoft 365. Events without a mailbox
use the connection's sensor (`mailsec-<connection name>`). The `email` platform
does not count against the sensor quota. Watch coverage and `EMAIL_INGEST_ERROR`
to assess ingestion health; the connection sensor's online state reflects only
its own events.

## The events

Expand Down Expand Up @@ -158,8 +163,30 @@ idempotency all apply unchanged — there is exactly one remediation path in thi
product.

A rule does not supply banner HTML: `banner_message` uses the organization's own
banner from its [`banners` policy record](policy.md#banners), rendered
server-side into a fixed escaped template. Automated bannering also requires
banner from its [`banners` policy record](policy.md#banners) (title, colour,
logo and the wording for the message's verdict), rendered server-side into a
fixed escaped template. A rule may add one plain-text `text` (at most 512
characters, no `<` or `>`) that replaces the wording for that banner only, for
example to name the reason the rule fired:

```yaml
respond:
- action: extension request
extension name: ext-email-security
extension action: banner_message
extension request:
msg_uuid: '{{ .event.msg_uuid }}'
text: "Payment details changed in this message. Confirm by phone before paying."
```

A `text` that contains markup, control characters or is over 512 characters is
**dropped** and the banner goes out with the organization's own wording, because
a rule's values are usually templated from the message and a sender must not be
able to decide whether the warning appears. `text` on any action other than
`banner_message` is ignored. Because `text` replaces the organization's
wording, avoid templating sender-controlled fields (the display name, the
subject) into it: whatever you put there is shown to the recipient as part of
the warning. Automated bannering also requires
`enabled` on the [`banners` record](policy.md#banners); without it a rule's
`banner_message` is decided and audited but the mailbox is not touched
(`alert_only`). Bannering asked for by a person — console, API, CLI — is not
Expand Down Expand Up @@ -195,6 +222,120 @@ From there the detection flows into Cases, Outputs and everything else that
consumes detections. A report is the highest-signal thing your users will ever
hand you, so treating it as a first-class detection is usually right.

## Counting events per mailbox or per user

A rule that fires on one event is often not what you want. "One malicious message
landed in a mailbox" is routine; "the same mailbox received five in an hour" is an
attack on a person. LimaCharlie's D&R [suppression](../8-reference/response-actions.md#suppression)
does the counting, and Email Security events carry the identity to count by, so no
mail-specific feature is needed.

The pattern is a `report` action whose suppression is **global** and whose `keys`
include the mailbox or user. Global means the counter is shared across the
organization, so it is scoped by the key alone. The action is skipped until the
count reaches `min_count`, and then fires up to `max_count` times in the `period`.
Setting both to the same number fires once, on the Nth event, and stays quiet for
the rest of the window.

| Parameter | Use for per-user counters |
|---|---|
| `is_global` | `true`. The counter is organization-wide and the key decides what is counted together |
| `keys` | A constant label, so two rules never share a counter, then the field to count by, for example `'{{ .event.mailbox.address }}'` |
| `min_count`, `max_count` | The threshold. Set both to `N` to fire once when the Nth event arrives |
| `period` | The window: `s`, `m` or `h`, from 1 second to 720 hours |

The window is fixed, not sliding: it starts at the first counted event for a key and
the counter resets when it expires. See the platform's
[Behavioral Detection](../3-detection-response/behavioral-detection.md) page for the
full set of patterns and its limitations.

The field to count by depends on the event:

| Event | Field | Holds |
|---|---|---|
| Any event about a mailbox (`EMAIL_MESSAGE`, `EMAIL_VERDICT`, `EMAIL_ACTION`, and the other mailbox-scoped events) | `event/mailbox/address` (template `{{ .event.mailbox.address }}`) | The protected mailbox the event is about. The same `mailbox` object also carries `id` (the provider's stable handle) and `upn` (the sign-in name, which can differ from the address on Microsoft 365), so `{{ .event.mailbox.upn }}` can key a counter by sign-in identity |
| `EMAIL_USER_REPORT` | `event/reporter` (template `{{ .event.reporter }}`) | The address that sent the report to the abuse mailbox, or `unknown` when the report had no usable sender. Its `mailbox` is the abuse mailbox the report arrived in, not the person who reported, so count reports per person with `reporter` |

### Example: five malicious messages to one mailbox in an hour

```yaml
# Detect
op: and
rules:
- op: is
path: routing/event_type
value: EMAIL_MESSAGE
- op: is
path: event/verdict/verdict
value: malicious
- op: is
path: event/direction
value: inbound
```

```yaml
# Respond
- action: report
name: email-mailbox-malicious-burst
priority: 3
suppression:
is_global: true
min_count: 5
max_count: 5
period: 1h
keys:
- 'email-malicious-per-mailbox'
- '{{ .event.mailbox.address }}'
```

The detection fires once, when a mailbox receives its fifth malicious inbound
message inside the hour, and carries the triggering `EMAIL_MESSAGE` so the
responder can see the mailbox and the message. It counts the verdict the rule pack
gave at ingest. A message that only becomes malicious later, through an analyst, AI
or detonation revision, arrives as an `EMAIL_VERDICT` and is not counted by this
rule.

### Example: three user reports from one person in a day

```yaml
# Detect
op: and
rules:
- op: is
path: routing/event_type
value: EMAIL_USER_REPORT
- op: exists
path: event/automated_sender
not: true
```

```yaml
# Respond
- action: report
name: email-reporter-repeat
priority: 2
suppression:
is_global: true
min_count: 3
max_count: 3
period: 24h
keys:
- 'email-reports-per-reporter'
- '{{ .event.reporter }}'
```

`automated_sender` is present, and `true`, only on reports that came from a
machine, so the second condition leaves those out of the count. A person who
reports three messages in a day is either being targeted or is the most alert
member of your staff, and in both cases an analyst wants to know.

!!! tip "Chain a counter onto a detection"
The same suppression can count detections instead of events, using the
`target: detection` chaining described in
[Behavioral Detection](../3-detection-response/behavioral-detection.md#cardinality-detection).
That is how you count *distinct* values, for example the number of different
senders that hit one mailbox, rather than the number of events.

## Watching your own coverage

`EMAIL_INGEST_ERROR` is the event to alert on. A mail security product that
Expand Down Expand Up @@ -225,6 +366,18 @@ The **Email Security → Hunt** screen runs ordinary LCQL search over
filters can also be opened in the Query Console. Other emitted `EMAIL_*` events
are searchable in the Query Console and through `limacharlie search`.

**Body contains** matches a case-insensitive phrase in any of the message's
current authored thread, visible HTML text or plain-text part. The phrase is
limited to 256 characters and cannot contain control characters or line breaks.
It searches the body text retained in the event, subject to ingestion limits;
it does not fetch the original EML. Narrow the time window and other filters
before searching bodies, because the search reads message text across the window.

Before running, Hunt estimates the search cost. Small priced searches can start
immediately; larger searches ask you to confirm. If the estimate is unavailable
or unpriced, Hunt says so and asks before running, rather than treating the
search as free. The estimate is a guide; the final charge can differ.

These searches cover retained telemetry, independently of the Email Security
message index and raw-message retention. They can find older emitted messages
when telemetry is retained longer than the index. Initial historical backfill
Expand Down
4 changes: 2 additions & 2 deletions docs/email-security/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ limacharlie mailsec action get <action_id>
# Abuse-mailbox reports
limacharlie mailsec report list --status open --oldest-first
limacharlie mailsec report get <report_id>
limacharlie mailsec report resolve <report_id> --disposition true_positive
limacharlie mailsec report resolve <report_id> --disposition malicious
limacharlie mailsec report reopen <report_id>

# Custom rules
Expand Down Expand Up @@ -399,7 +399,7 @@ limacharlie mailsec campaign action "$CAMPAIGN" --action quarantine_message \
# Resolve the oldest open report
REPORT=$(limacharlie mailsec report list --status open --oldest-first --limit 1 \
--output json | jq -r '.reports[0].report_id')
limacharlie mailsec report resolve "$REPORT" --disposition true_positive
limacharlie mailsec report resolve "$REPORT" --disposition malicious
```

Because the CLI is the whole surface, it is also how an
Expand Down
Loading
Loading