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
48 changes: 48 additions & 0 deletions doc/cli/email-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,51 @@ admin consent. Message trace also requires Microsoft's documented Exchange
service-principal prerequisite; activity requires unified audit logging. Missing
grants report `not_granted`. Pending, stale or error coverage must be checked
before treating an empty list as evidence that no messages were blocked.

## Disposition and release

Disposition is an analyst decision independent of the engine verdict. Values are
`malicious`, `spam`, `graymail`, `benign`, and `simulation`. Notes support up to
1024 characters. Use `none` to filter messages awaiting a decision. Message list and
detail return `disposition: null` when no decision is set or it was cleared.

```bash
limacharlie mailsec message disposition <MSG_UUID> --disposition spam --note "Reviewed"
limacharlie mailsec message disposition <MSG_UUID> --clear
limacharlie mailsec message list --disposition none
limacharlie mailsec message bulk-disposition --input-file ids.json --disposition simulation
limacharlie mailsec message release <MSG_UUID> --reason "Confirmed safe" --mode analyst
```

Bulk disposition accepts 1–500 unique message IDs and returns one ordered outcome per ID.
A missing message reports its own error without affecting the others. An error starting
with `not confirmed, retry the same decision` means the outcome is unknown; repeating the
same request is safe because identical decisions are no-ops.
A benign disposition repairs sender flagged history; malicious contributes once.
Neither changes the engine verdict nor triggers policy automations.
Release restores placement and records a benign verdict and disposition together.
It requires `mailsec.act`; alert-only organizations must explicitly add `--force`.
Retrying the same successful release does not add another verdict revision.

Report resolution uses the same dispositions and needs `mailsec.set`. Optional
remediation additionally requires `mailsec.act` and uses preview then confirmation:

```bash
limacharlie mailsec report resolve <REPORT_ID> --disposition malicious --scope message --action quarantine_message
limacharlie mailsec report resolve <REPORT_ID> --disposition malicious --scope message --action quarantine_message --confirm <TOKEN> --reason "Confirmed threat"
```

Scope can be `message` or `campaign`. Preview keeps the report open, as do failed,
withheld, or partial remediation attempts. Successful resolution classifies the
linked original without changing its engine verdict. Report detail shows
`resolution_reply_status`; an ambiguous provider send is not automatically retried.


For report remediation across copies of the same message, use `--scope group`
with a UUID `--attempt` reused through preview, confirmation and polling.
Unconfirmed requests return `remediation_preview: {job, confirmation}`; confirmed
requests may return `remediation_pending: true`. Add `--wait` (maximum 300
seconds) to poll for a ready preview or a resolved report. Timeout exits with
code 2, retaining the job; repeat with the same attempt and confirmation to
resume. A failed or withheld job needs a new attempt and fresh confirmation.
Only the reported original is classified; every selected copy is remediated.
94 changes: 85 additions & 9 deletions limacharlie/commands/mailsec.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from ..cli import pass_context
from ..client import Client
from ..sdk.organization import Organization
from ..sdk.mailsec import BULK_ACTIONS, Mailsec, normalize_bulk_selection
from ..sdk.mailsec import BULK_ACTIONS, DISPOSITIONS, Mailsec, normalize_bulk_selection
from ..output import format_output, detect_output_format
from ..discovery import register_explain
from ._input_helpers import load_file, load_stdin
Expand Down Expand Up @@ -158,7 +158,7 @@
Revise a message's verdict as an analyst. Requires mailsec.act.

This records a human triage decision over the scorer's — it is a
disposition, not a remediation — and appends to the message's verdict
verdict revision — and appends to the message's verdict
history rather than overwriting it. --rationale is required and audited:
at least one, at most ten, each <= 280 characters.

Expand All @@ -179,7 +179,7 @@
The verdict revision history for one message, oldest first.

Each entry is who decided (mode/actor), the verdict they set, when, and
the rationale they gave — the audit of how a message's disposition moved
the rationale they gave — the audit of how a message's verdict moved
over time.

--limit bounds the history returned. Check revisions_truncated before
Expand Down Expand Up @@ -430,7 +430,7 @@
an outcome that already holds.

Examples:
limacharlie mailsec report resolve <report_id> --disposition true_positive
limacharlie mailsec report resolve <report_id> --disposition malicious
"""

_EXPLAIN_REPORT_REOPEN = """\
Expand Down Expand Up @@ -1053,6 +1053,7 @@ def onboarding(ctx, provider, project_id, sa_email, topic, subscription) -> None
@click.option("--campaign-id", default=None, help="Only members of this campaign.")
@click.option("--state", multiple=True, help="Message state (repeatable).")
@click.option("--direction", multiple=True, help="inbound|outbound|internal (repeatable).")
@click.option("--disposition", default=None, type=click.Choice([*DISPOSITIONS, "none"]), help="Independent analyst/SOAR disposition; none selects untriaged.")
@click.option("--lane", default=None, type=click.Choice(["live", "backfill"]),
help="Processing lane. Cannot be combined with --mailbox, --sender-email or --campaign-id.")
@click.option("--user-reported", is_flag=True, default=False, help="Only mail a person reported.")
Expand All @@ -1068,7 +1069,7 @@ def onboarding(ctx, provider, project_id, sa_email, topic, subscription) -> None
@click.option("--limit", default=None, type=int, help="Page size.")
@pass_context
def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id, state,
direction, lane, user_reported, no_user_reported, min_score, link_domain,
direction, lane, disposition, user_reported, no_user_reported, min_score, link_domain,
attachment_sha256, q, since, until, cursor, limit) -> None:
"""The message index — the triage queue.

Expand All @@ -1082,6 +1083,8 @@ def message_list(ctx, verdict, mailbox, sender_email, sender_domain, campaign_id
ms = _get_mailsec(ctx)
try:
lane_params = {"lane": lane} if lane is not None else {}
if disposition is not None:
lane_params["disposition"] = disposition
result = ms.list_messages(
verdict=list(verdict) or None,
mailbox=mailbox,
Expand Down Expand Up @@ -1559,17 +1562,42 @@ def report_get(ctx, report_id) -> None:
@report_group.command("resolve")
@click.argument("report_id")
@click.option("--disposition", required=True,
type=click.Choice(["true_positive", "false_positive", "benign"]),
type=click.Choice(DISPOSITIONS),
help="What was decided. 'unknown' is deliberately not offered.")
@click.option("--scope", type=click.Choice(["message", "group", "campaign"]), default=None)
@click.option("--action", type=click.Choice(BULK_ACTIONS), default=None)
@click.option("--confirm", default=None, help="Token from remediation_preview; omit to preview without resolving.")
@click.option("--attempt", default=None, help="Required UUID for group scope, reused for preview, confirmation and polling.")
@click.option("--wait", is_flag=True, help="Wait for the group preview or confirmed resolution; timeout leaves the durable job running.")
@click.option("--timeout", type=click.IntRange(1, 300), default=300, show_default=True)
@click.option("--reason", default="")
@click.option("--force", is_flag=True, help=_FORCE_HELP)
@pass_context
def report_resolve(ctx, report_id, disposition) -> None:
def report_resolve(ctx, report_id, disposition, scope, action, confirm, attempt, wait, timeout, reason, force) -> None:
"""Close a report with a disposition (mailsec.set).

\b
Example:
limacharlie mailsec report resolve <report_id> --disposition true_positive
limacharlie mailsec report resolve <report_id> --disposition malicious
"""
_output(ctx, _get_mailsec(ctx).resolve_report(report_id, disposition))
if bool(scope) != bool(action):
raise click.UsageError("--scope and --action must be provided together")
if not scope and (confirm or reason or force or attempt):
raise click.UsageError("remediation flags require --scope and --action")
if wait and scope != "group":
raise click.UsageError("--wait requires --scope group")
kwargs = {}
if scope:
kwargs["remediation"] = {"scope": scope, "action": action, "reason": reason, "force": force}
if attempt:
kwargs["remediation"]["attempt"] = attempt
if confirm:
kwargs["remediation"]["confirm"] = confirm
ms = _get_mailsec(ctx)
result = ms.wait_for_report_resolution(report_id, disposition, timeout=timeout, **kwargs) if wait else ms.resolve_report(report_id, disposition, **kwargs)
_output(ctx, result)
if wait and result.get("wait_timed_out"):
ctx.exit(2)


@report_group.command("reopen")
Expand Down Expand Up @@ -1825,3 +1853,51 @@ def release_request_list(ctx, connection, status, since, until, cursor, limit) -

register_explain("mailsec.provider-quarantine.list", "Microsoft provider delivery observations. Inspect coverage before interpreting an empty list. Requires optional ExchangeMessageTrace.Read.All consent.")
register_explain("mailsec.release-request.list", "Microsoft hosted-quarantine release activity. This command observes requests and never approves a release. Requires optional ActivityFeed.Read consent and unified audit logging.")

@message_group.command("disposition")
@click.argument("msg_uuid")
@click.option("--disposition", type=click.Choice(DISPOSITIONS), default=None)
@click.option("--clear", is_flag=True, help="Remove the decision with attribution.")
@click.option("--note", default="", help="Optional decision note (max 1024 characters).")
@pass_context
def message_disposition(ctx, msg_uuid, disposition, clear, note) -> None:
"""Set or clear disposition independently of the verdict (mailsec.set)."""
try:
_output(ctx, _get_mailsec(ctx).set_disposition(msg_uuid, disposition, clear=clear, note=note))
except ValueError as exc:
raise click.UsageError(str(exc)) from exc


@message_group.command("bulk-disposition")
@click.option("--msg-uuids", multiple=True, help="Stable message ids (repeatable or comma-separated).")
@click.option("--input", "input_file", default=None, type=click.Path(exists=True, dir_okay=False))
@click.option("--disposition", type=click.Choice(DISPOSITIONS), default=None)
@click.option("--clear", is_flag=True)
@click.option("--note", default="")
@pass_context
def message_bulk_disposition(ctx, msg_uuids, input_file, disposition, clear, note) -> None:
"""Set an independent disposition on at most 500 messages (mailsec.set)."""
try:
ids = _bulk_selection(msg_uuids, input_file)
result = _get_mailsec(ctx).set_bulk_disposition(ids, disposition, clear=clear, note=note)
except ValueError as exc:
raise click.UsageError(str(exc)) from exc
_output(ctx, result)
if any(item.get("error") for item in result.get("results", [])):
ctx.exit(1)


@message_group.command("release")
@click.argument("msg_uuid")
@click.option("--reason", required=True)
@click.option("--mode", default="analyst", type=click.Choice(["analyst", "ai"]))
@click.option("--force", is_flag=True, help=_FORCE_HELP)
@pass_context
def message_release(ctx, msg_uuid, reason, mode, force) -> None:
"""Restore and classify a message as benign (mailsec.act)."""
try:
result = _get_mailsec(ctx).release_message(msg_uuid, reason=reason, mode=mode, force=force)
except ValueError as exc:
raise click.UsageError(str(exc)) from exc
_output(ctx, result)
_note_force_required(ctx, result, "Re-run with --force to release it.", force)
1 change: 1 addition & 0 deletions limacharlie/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@
"mailsec message list", "mailsec message get", "mailsec message eml",
"mailsec message similar", "mailsec message action",
"mailsec message revise", "mailsec message revisions",
"mailsec message disposition", "mailsec message bulk-disposition", "mailsec message release",
"mailsec message bulk-action", "mailsec message bulk-status",
"mailsec campaign list", "mailsec campaign get", "mailsec campaign action",
"mailsec sender get",
Expand Down
Loading
Loading