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
4 changes: 4 additions & 0 deletions docs/email-security/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Shared behaviours:
| `GET /reports` | The user-report queue. Params: `status[]` (`open`, `triaging`, `resolved`), `oldest_first`, `cursor`, `limit` |
| `GET /reports/{report_id}` | One report: who reported it, the message they reported, the original once located across the tenant's mailboxes, and its triage state |
| `GET /senders/{key}` | The accumulated profile for one correspondent. `key` is qualified (`email:someone@corp.example` or `domain:corp.example`) or a bare address or domain. A key with no profile says so explicitly rather than returning a zeroed profile |
| `GET /submissions` | `{enabled, available, submissions, next_cursor}` — the samples your organization copied to LimaCharlie. Filters: `category` (`missed_threat`, `false_positive`, `other`), `since`, `until` (RFC 3339), `limit` (1-200, default 50), `cursor`. `enabled` is whether the organization opted in and `available` is whether the datacenter has a submissions store; both are always present. See [Sample Submission](sample-submission.md). Requires `mailsec.get` |
| `GET /submissions/{submission_id}` | `{submission, reviews, reviews_truncated}` — one submission and up to 200 review-access timestamps (never who); counts and latest-review time include all accesses. An unknown id is not an error: it returns `{"submission": null, "reviews": []}`. Requires `mailsec.get` |
| `GET /actions/{action_id}` | `{action}` — one audit entry expanded, **including the JSON request payload the message timeline omits**. For a raw-message download that payload carries the access justification. Gated on `mailsec.get`: reading who did what to a message is part of reading the product |
| `GET /onboarding` | `{scopes, steps, script}` — the setup steps, OAuth scopes and `gcloud` commands for connecting a tenant, for rendering in a setup flow. Each step carries a `console` and, where verifiable, a `verified_by` naming the connection-test check that proves it. Params: `provider` (`gworkspace` default, or `m365`), `project_id`, `sa_email`, `topic`, `subscription` — supply them and the commands come back ready to run rather than templated |
| `GET /tenant` | `{confirmation, expires_in_seconds, warning}` — the tenant-purge preview. Returns the warning describing exactly what a purge removes, and mints the single-use `confirmation` token that [`DELETE /tenant`](#delete-tenant) requires. **It changes nothing.** The token expires after `expires_in_seconds` (300). Requires Owner-level authority, not `mailsec.get` |
Expand Down Expand Up @@ -311,6 +313,8 @@ 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`; 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 /messages/{msg_uuid}/actions` with `submit_sample` or `withdraw_sample` | Copy one message to LimaCharlie, or withdraw that copy. `submit_sample` requires `category` (`missed_threat`, `false_positive`, `other`) and a `reason` of 1-1024 characters; `withdraw_sample` takes an optional `reason`. Only a person can run either: D&R rules, automations and the AI agent are refused. Submission requires organization opt-in; withdrawal remains available after opt-out or provider disconnect. See [Sample Submission](sample-submission.md). Requires `mailsec.act` |
| `DELETE /submissions/{submission_id}` | Withdraw a submission: hard-deletes the stored copy and its metadata and returns `{withdrawn: true, submission_id, action_id}`. An unknown or already-deleted id returns `{withdrawn: false, submission_id}` with no `action_id`; an expired id is still cleaned up if metadata remains. 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 `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` |
Expand Down
27 changes: 25 additions & 2 deletions docs/email-security/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The `limacharlie mailsec` command group covers the Email Security API surface:
the coverage screen, the message index and drawer, the audited raw-EML download,
verdict revisions, campaigns and campaign-wide sweeps, bulk remediation over a
selection you name, sender profiles, the action audit trail, the abuse-mailbox
report queue, custom-rule validation and backtest, the connection preflight, the
report queue, sample submission, custom-rule validation and backtest, the connection preflight, the
served onboarding guide, and the tenant purge.

Commands take the global options (`--oid`,
Expand Down Expand Up @@ -55,7 +55,7 @@ trusted with four separable things — plus one command that is not any of them:
|---|---|
| `mailsec.get` | Read the product's own view: queue, drawer, campaigns, senders, audit trail |
| `mailsec.set` | Change triage state — resolving a user report |
| `mailsec.act` | Remediate live mail at the provider |
| `mailsec.act` | Remediate live mail at the provider; submit and withdraw samples |
| `mailsec.get.eml` | Download the original bytes of a message; requires a logged justification |
| `mailsec.act` **and** `billing.ctrl` **and** `user.ctrl` | `tenant purge`, in both its preview and its destructive form. Owner-level authority, the same trio deleting the organization requires — there is no separate "owner" permission |

Expand Down Expand Up @@ -97,6 +97,13 @@ limacharlie mailsec message bulk-action --action quarantine_message --input-file
limacharlie mailsec message bulk-action --action quarantine_message --input-file uuids.txt --confirm <token> --reason "INC-4471"
limacharlie mailsec message bulk-status <bulk_id>

# Sample submission (opt-in): copy ONE message to LimaCharlie, list it, withdraw it
limacharlie mailsec message submit-sample <msg_uuid> --category missed_threat --reason "credential phish we did not flag"
limacharlie mailsec message withdraw-sample <msg_uuid>
limacharlie mailsec submission list --category false_positive --since 2026-09-01T00:00:00Z
limacharlie mailsec submission get <submission_id>
limacharlie mailsec submission withdraw <submission_id>

# Campaigns: one attack, triaged once
limacharlie mailsec campaign list --min-members 3
limacharlie mailsec campaign get <campaign_id>
Expand Down Expand Up @@ -301,6 +308,22 @@ carries the outcome** — `0` only when the job completed and something was acte
on. The full contract, including every `state`, `result` and count, is in
[Bulk Remediation](remediation.md#from-the-cli).

### Submitting a sample sends the message to LimaCharlie

`message submit-sample` copies one message to LimaCharlie, so it is opt-in, explicit and
one message per call. The organization must have opted in with a `sample_sharing`
[policy record](policy.md#sample_sharing); `--category`
(`missed_threat`, `false_positive`, `other`) and `--reason` (1 to 1024 characters) are
both required and are checked before anything is sent. A refusal (not opted in, no store
in the datacenter, raw copy no longer stored) is reported like any other failed action, with the reason in
`error`; the command prints the reason and exits non-zero. `submission list` prints the `enabled` and
`available` flags, so an empty list can be told apart from a feature that is off, and
pages with `--cursor`. `submission get` shows recorded access times for the copy, and
`submission withdraw` (or `message withdraw-sample`) deletes it. An unknown id is not an
error: `submission get` returns `submission: null` and `submission withdraw` returns
`withdrawn: false`, and the command says so on stderr. See
[Sample Submission](sample-submission.md).

### Revising a verdict is `mailsec.act`, not `mailsec.set`

`message revise` records a human disposition over the scorer's, appending to the
Expand Down
2 changes: 2 additions & 0 deletions docs/email-security/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ it at the provider.
| **Remediation** | Typed, idempotent, audited actions performed at the provider: quarantine, trash, move to spam, restore, apply and remove a warning banner. Available by policy automation, from the console, from a D&R rule, from the API and from the CLI. |
| **Campaigns** | Messages the engine attributed to one attack are clustered, so a campaign that hit forty mailboxes is triaged once and swept once. |
| **User reports** | An abuse mailbox becomes an SLA queue: reports are joined back to the original message across the whole tenant, robots that mail the abuse address are auto-resolved out of the queue, and reporters can be sent a templated acknowledgement. |
| **Sample submission** | Opt-in, one message at a time: an analyst can send LimaCharlie a copy of a message the engine got wrong, and list and withdraw what was sent. See [Sample Submission](sample-submission.md). |
| **Telemetry** | `EMAIL_MESSAGE`, `EMAIL_VERDICT` (every verdict decision — the engine's own at ingest, then each override), `EMAIL_ACTION`, `EMAIL_USER_REPORT` and `EMAIL_INGEST_ERROR` land in the same lake as your EDR, cloud and identity telemetry — so "phish delivered, then that user's endpoint ran a new binary" is one D&R rule. |
| **Configuration as data** | Connections, policy and custom rules are Hive records, so everything is API-first and git-syncable from day one. |
| **AI assistant access** | [MCP tools](mcp.md) for read-only coverage and triage, with separately permissioned diagnostics and responses. Requires a server version containing MailSec support. |
Expand Down Expand Up @@ -110,6 +111,7 @@ Managing connections and policy uses the ordinary Hive permissions for the
| [Bulk Remediation](remediation.md) | Acting on a set of messages you named: preview, confirm, execute, poll |
| [Campaigns](campaigns.md) | Clustering and campaign-wide sweeps |
| [User Reports](user-reports.md) | The abuse mailbox and the report SLA queue |
| [Sample Submission](sample-submission.md) | Opt-in: send LimaCharlie a copy of one message the engine got wrong, and withdraw it |
| [Detections & Verdicts](detections.md) | How a verdict is produced, and what the rules can read |
| [Custom Rules](custom-rules.md) | Writing, validating and backtesting your own mail rules |
| [IOC & Reputation Feeds](ioc-feeds.md) | Mirroring a threat feed into a lookup and matching messages against it |
Expand Down
11 changes: 9 additions & 2 deletions docs/email-security/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ limacharlie mailsec message action <msg_uuid> \

Actions require `mailsec.act`.

Two more actions, `submit_sample` and `withdraw_sample`, do not touch the message's
placement: they copy it to LimaCharlie to help improve detection, or delete that copy.
They are opt-in, only a person can run them, and alert-only mode does not withhold them. See
[Sample Submission](sample-submission.md).

To act on many messages at once — a filtered page of this queue, or a selection
you built elsewhere — see [Bulk Remediation](remediation.md). It is the same
executor and the same audit trail, with a preview and a confirmation over the set
Expand Down Expand Up @@ -290,8 +295,10 @@ provider outage — pass a new `attempt` token.

### Enforcement

In an alert-only organization, actions from **every source**, including analysts,
are recorded but withheld. The response reports `force_required: true`.
In an alert-only organization, actions that change a mailbox, from **every
source**, including analysts, are recorded but withheld. (`submit_sample` and
`withdraw_sample` change no mailbox and are not withheld; see
[Sample Submission](sample-submission.md#alert-only-mode-does-not-apply).) The response reports `force_required: true`.
To perform that action deliberately, repeat the request with JSON `force: true`,
use the console's explicit override confirmation, or pass `--force` in the CLI:

Expand Down
32 changes: 30 additions & 2 deletions docs/email-security/policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ fleet-wide policy are a script, not a UI workflow.
| Hive | Records | Purpose |
|---|---|---|
| `mailsec_provider` | one per mail connection | which tenant to protect, with which credential — see [Connecting Providers](providers.md) |
| `mailsec_policy` | many, discriminated by `policy_type` | automations, exclusions, VIPs, thresholds, banners, retention, reporter replies, hunt defaults, clustering |
| `mailsec_policy` | many, discriminated by `policy_type` | automations, exclusions, VIPs, thresholds, banners, retention, reporter replies, sample submission, hunt defaults, clustering |
| `dr-mail` | one per rule | all mail rules, including installed defaults — see [Custom Rules](custom-rules.md) |

<span id="managed_rules"></span>
Expand Down Expand Up @@ -39,7 +39,7 @@ How each type composes:
| `exclusions` | Concatenated — a set of independent suppressions |
| `vips` | Union, deduplicated and sorted |
| `thresholds` | Last writer wins per field, with the ordering invariant re-checked afterwards |
| `banners`, `reporter_reply`, `hunt_defaults`, `clustering` | Last writer wins per field |
| `banners`, `reporter_reply`, `sample_sharing`, `hunt_defaults`, `clustering` | Last writer wins per field |
| `retention` | **Minimum** wins — the shortest horizon for each field; see [Retention](#retention) |

### Unknown fields are refused
Expand Down Expand Up @@ -571,6 +571,8 @@ A tenant purge permanently deletes, for one organization:
- user (abuse-mailbox) reports
- stored raw messages and their parsed copies
- link-detonation results
- sample submissions: every message your analysts copied to LimaCharlie, and its
metadata (see [Sample Submission](sample-submission.md))
- the organization's Email Security provider connection and policy configuration

It also **stops the mail connections at Microsoft 365 and Google Workspace**, so
Expand Down Expand Up @@ -749,6 +751,32 @@ Template keys must be verdicts. Values are plain text (no `<` or `>`), capped at

---

## `sample_sharing`

Lets your analysts copy one message at a time to LimaCharlie so detection can
improve. See [Sample Submission](sample-submission.md) for what is kept, where,
for how long and how to withdraw.

```yaml
policy_type: sample_sharing
enabled: true
```

| Field | Default | |
|---|---|---|
| `enabled` | `false` | Opt-in. Submitting copies a message to LimaCharlie, so without this record (or with `enabled: false`) every submit request is refused |

The record is closed: `enabled` is the only field, unknown fields are refused, and
a record that sets nothing is refused. A suggested record name is
`sample-sharing`. Turning it on requires `mailsec.set` and the organization Owner's
`billing.ctrl` and `user.ctrl` authority. Turning it off needs only `mailsec.set`:
write `enabled: false` on an active record without expiry. Removing, disabling or
expiring an override requires Owner authority because an earlier enabled record
could become effective. Nothing is ever submitted automatically, and D&R rules,
automations and the AI agent cannot submit even when the record is on.

---

## `hunt_defaults`

This legacy record type remains accepted for compatibility, but no current
Expand Down
Loading
Loading