From 8934c512d0689542f63299c362e34d87c843f50b Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Wed, 30 Sep 2026 23:25:37 +0000 Subject: [PATCH 1/8] mailsec: customer sample submission (submit-sample, withdraw-sample, submission list/get/withdraw) An organization that has opted in can copy one message at a time to LimaCharlie to help improve detection, then list and withdraw what it sent. Adds the SDK methods and CLI commands with local validation of the category and reason, a non-zero exit on a refused submission, docs and tests. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 15 + doc/README.md | 2 +- doc/cli/README.md | 2 +- doc/cli/email-security.md | 30 +- doc/sdk/security-products.md | 2 + limacharlie/commands/mailsec.py | 291 +++++++++++++++++- limacharlie/discovery.py | 2 + limacharlie/help_topics.py | 6 + limacharlie/sdk/mailsec.py | 249 ++++++++++++++- .../unit/test_cli_lazy_loading_regression.py | 2 +- tests/unit/test_cli_mailsec_samples.py | 194 ++++++++++++ tests/unit/test_sdk_mailsec.py | 126 ++++++++ 12 files changed, 911 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_cli_mailsec_samples.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6049eaac..e87fdadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ - The deprecated, ignored `--banner` flag and `banner=` argument are removed. Needs an API release that serves `POST /banner/preview` and the `text` field. +### Email Security — customer sample submission + +- New `Mailsec.submit_sample`, `withdraw_sample`, `list_submissions`, + `get_submission` and `withdraw_submission`, and the CLI commands + `mailsec message submit-sample --category missed_threat|false_positive|other --reason "..."`, + `mailsec message withdraw-sample `, and + `mailsec submission list|get|withdraw`. An organization that has opted in + (a `mailsec_policy` record of type `sample_submission`) can copy one message + at a time to LimaCharlie to help improve detection; the copy is deleted after + 400 days or as soon as it is withdrawn, and `submission get` shows when + LimaCharlie staff opened it. Category and reason are checked locally, a + refused submission exits non-zero, and `Mailsec.act_on_message` refuses + `submit_sample` so the category and reason cannot be skipped. Needs an API + release that serves the new routes. + ### Cloud Security — code-scan pushes retry when the service is busy - `CloudSec.ingest_code_results` (and so `cloudsec code ingest` and diff --git a/doc/README.md b/doc/README.md index 06514fba..3ae69137 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,7 +18,7 @@ | [Hive & Data Stores](cli/hive-data.md) | hive, secret, lookup, playbook, note, sop, adapter, cloud-sensor, extension | | [Infrastructure](cli/infrastructure.md) | sync, output, artifact, payload, yara, integrity, logging, exfil | | [Cloud Security & Code Security](cli/cloud-security.md) | cloudsec (findings, inventory, graph, compliance, CAASM, code lane, container images, fleet, exports) | -| [Email Security](cli/email-security.md) | mailsec (onboarding, coverage, triage, EML, remediation, campaigns, reports, rules, tenant purge) | +| [Email Security](cli/email-security.md) | mailsec (onboarding, coverage, triage, EML, remediation, campaigns, reports, sample submission, rules, tenant purge) | | [Other Commands](cli/other-commands.md) | api, arl, usp, spotcheck, job, schema, completion, help/discover | ## SDK Reference diff --git a/doc/cli/README.md b/doc/cli/README.md index 70f1bd33..c2474b6f 100644 --- a/doc/cli/README.md +++ b/doc/cli/README.md @@ -161,7 +161,7 @@ limacharlie schema dr create | [Hive & Data Stores](hive-data.md) | hive, secret, lookup, playbook, note, sop, adapter, cloud-sensor, extension | | [Infrastructure](infrastructure.md) | sync, output, artifact, payload, yara, integrity, logging, exfil | | [Cloud Security & Code Security](cloud-security.md) | cloudsec (findings, inventory, graph, compliance, CAASM, code lane, container images, fleet, exports) | -| [Email Security](email-security.md) | mailsec (onboarding, coverage, triage, EML, remediation, campaigns, reports, rules, tenant purge) | +| [Email Security](email-security.md) | mailsec (onboarding, coverage, triage, EML, remediation, campaigns, reports, sample submission, rules, tenant purge) | | [Other Commands](other-commands.md) | api, arl, usp, spotcheck, job, schema, completion, help/discover, case | ## See Also diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index b5c8db2f..db8982b1 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -4,7 +4,7 @@ Install or upgrade with `python -m pip install --upgrade limacharlie`. See [installation](../getting-started.md#installation) for setup. -Commands for the LimaCharlie Email Security surface: mailbox coverage, the message triage queue and its drawer, the justified raw-EML download, analyst verdict revision, per-message and bulk remediation at the provider, campaigns, sender profiles, the action audit trail, the abuse-mailbox report queue, standalone EML analysis, custom-rule validation and backtest, the connection preflight, and the tenant purge. +Commands for the LimaCharlie Email Security surface: mailbox coverage, the message triage queue and its drawer, the justified raw-EML download, analyst verdict revision, per-message and bulk remediation at the provider, campaigns, sender profiles, the action audit trail, the abuse-mailbox report queue, customer sample submission, standalone EML analysis, custom-rule validation and backtest, the connection preflight, and the tenant purge. Four permissions rather than the usual get/set pair, because the product asks to be trusted with four different things: @@ -12,7 +12,7 @@ Four permissions rather than the usual get/set pair, because the product asks to |---|---| | `mailsec.get` | Read the product's own view: the queue, the drawer, campaigns, senders, the audit trail | | `mailsec.set` | Change detection behaviour and triage state | -| `mailsec.act` | Remediate live mail, revise verdicts and test provider connections | +| `mailsec.act` | Remediate live mail, revise verdicts, submit and withdraw samples, and test provider connections | | `mailsec.get.eml` | Download original message bytes; also requires `mailsec.get` and a logged justification | Connection testing and verdict revision require `mailsec.act`. Connection records @@ -197,6 +197,32 @@ limacharlie mailsec action get A sweep's `--reason` lands on the sweep's own record and on every member's audit row. Repeating a sweep is idempotent per member, so a double run collapses onto the rows it already wrote; `--attempt` is how you ask for a deliberate second run — a retry after a provider outage recorded *beside* what failed rather than over it. It is an opaque handle, at most 128 characters, refused rather than truncated. Neither field is part of the confirmation token, so adding either one after previewing does not invalidate it. +## Sample submission + +An organization can opt in to let its analysts copy **one message at a time** to LimaCharlie so detection can improve. It is off by default, never automatic, and only a person can do it: D&R rules, automations and the AI agent are refused. + +**Submitting sends the message to LimaCharlie.** The original message (attachments included) is stored, compressed and encrypted, in a LimaCharlie-owned bucket in the same datacenter as your Email Security data, with a metadata row: the message id, your category and reason, your identity, the time, the verdict, score and matched rule ids at that time, the sender, subject, mailbox address and size. It is deleted automatically after 400 days. Only LimaCharlie staff working on detection quality can open it, through a tool that records every access; `submission get` shows you how many times and when. **Withdraw at any time**: the stored copy and its metadata are deleted. + +Opt in with a `mailsec_policy` record of type `sample_submission`: + +```bash +echo '{"policy_type": "sample_submission", "enabled": true}' > opt-in.json +limacharlie hive set --hive-name mailsec_policy --key sample-submission --input-file opt-in.json --enabled +``` + +Submitting and withdrawing need `mailsec.act`; listing and reading need `mailsec.get`. `--category` and `--reason` are both required (reason: 1 to 1024 characters, kept with the submission). The categories are `missed_threat` (we called it benign or unknown and it is a threat), `false_positive` (we flagged it and it is legitimate) and `other`. + +```bash +limacharlie mailsec message submit-sample --category missed_threat --reason "credential phish we did not flag" +limacharlie mailsec message withdraw-sample +limacharlie mailsec submission list +limacharlie mailsec submission list --category false_positive --since 2026-09-01T00:00:00Z --limit 100 +limacharlie mailsec submission get # includes when LimaCharlie staff opened it +limacharlie mailsec submission withdraw +``` + +A refused submission (the organization has not opted in, the datacenter has no submissions store, or the message's raw copy is no longer stored) comes back as `result: failed` with an `error`; the command prints the reason and exits non-zero. Submitting a message that already has an active submission returns `result: skipped` with the existing `submission_id`. `submission list` always returns `enabled` (the organization opted in) and `available` (the datacenter has a store), so an empty list can be told apart from a feature that is off. It is paginated: pass `next_cursor` back as `--cursor`, verbatim, until it is empty; `--limit` is 1 to 200. `reviews` in `submission get` is one timestamp per time LimaCharlie staff opened the copy, never the reviewer's identity. Withdrawing an unknown, already-withdrawn or expired submission is a 404. + ## Reports, analysis & rules ```bash diff --git a/doc/sdk/security-products.md b/doc/sdk/security-products.md index dbc39c3c..393c196d 100644 --- a/doc/sdk/security-products.md +++ b/doc/sdk/security-products.md @@ -87,6 +87,8 @@ print(mail.analyze(eml_b64=encoded, org_domains=["corp.example"])) `mailsec.get` permits structured reads, `mailsec.set` changes triage and rules, and `mailsec.act` remediates provider mail, revises verdicts, and tests connections. Original-byte downloads require both `mailsec.get` and `mailsec.get.eml`. Provider records use `mailsec_provider.*` and credentials use `secret.*`; policy and `dr-mail` Hives reuse `mailsec.get/set`. +An organization that has opted in (a `mailsec_policy` record of type `sample_submission`) can copy one message at a time to LimaCharlie to improve detection. `submit_sample(msg_uuid, category, reason)` needs `mailsec.act`, sends the original message to LimaCharlie (deleted after 400 days, or on `withdraw_sample(msg_uuid)` / `withdraw_submission(submission_id)`), and raises `ValueError` for an unknown category (`missed_threat`, `false_positive`, `other`) or a reason that is blank or over 1024 characters. A refusal comes back as `result: "failed"` with `error`, not as an exception. `list_submissions()` (paginated, always returns `enabled` and `available`) and `get_submission()` (includes `reviews`, one timestamp per time LimaCharlie staff opened the copy) need `mailsec.get`. + Start with `alert_only`. Manual provider actions need an explicit `force=True` override in that mode; inspect the action and audit outcome. Bulk and campaign actions use preview and confirmation so the executed selection matches what was reviewed. See the [Email Security CLI reference](../cli/email-security.md) for these workflows, verdict revisions, campaigns, user reports, and offboarding. ## Pagination and filters diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index d87e2473..52beb0c0 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -3,8 +3,8 @@ Commands for the ``/mailsec`` API surface: the coverage screen, the message index and its drawer, the justified raw-EML download, bulk remediation across a selection you name, campaigns and campaign-wide sweeps, sender profiles, the -action audit trail, the abuse-mailbox report queue, standalone EML analysis, -custom-rule validation and backtest, the connection preflight, the +action audit trail, the abuse-mailbox report queue, customer sample submission, +standalone EML analysis, custom-rule validation and backtest, the connection preflight, the served onboarding guide, and the irreversible tenant purge. Four permissions rather than the usual get/set pair, because mailsec asks to be @@ -45,7 +45,7 @@ from ..cli import pass_context from ..client import Client from ..sdk.organization import Organization -from ..sdk.mailsec import BULK_ACTIONS, DISPOSITIONS, Mailsec, normalize_bulk_selection +from ..sdk.mailsec import BULK_ACTIONS, DISPOSITIONS, SAMPLE_CATEGORIES, 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 @@ -190,6 +190,118 @@ limacharlie mailsec message revisions 0057db2b-... """ +_EXPLAIN_MESSAGE_SUBMIT_SAMPLE = """\ +Copy ONE message to LimaCharlie so detection can improve. Requires +mailsec.act, and the organization must have opted in first. + +THIS SENDS THE MESSAGE TO LIMACHARLIE. The original message, attachments +included, is copied (compressed and encrypted) to a LimaCharlie-owned +store in the same datacenter as your Email Security data, together with +the category and reason you give, your identity, the time, the verdict, +score and matched rule ids, the sender, subject, mailbox address and +size. It is kept for 400 days and then deleted automatically. Only +LimaCharlie staff working on detection quality can open it, through a +tool that records every access; `mailsec submission get` shows how many +times and when. No other customer can see it. + +WITHDRAW AT ANY TIME: `mailsec message withdraw-sample ` or +`mailsec submission withdraw ` deletes the copy and its +metadata. + +It is off by default. To opt in, save a mailsec_policy record of type +sample_submission: + + echo '{"policy_type": "sample_submission", "enabled": true}' > opt-in.json + limacharlie hive set --hive-name mailsec_policy --key sample-submission \ + --input-file opt-in.json --enabled + +Nothing is ever submitted automatically, and only a person can submit: +D&R rules, automations and the AI agent are refused. One message per +call. + +--category (required): + missed_threat we called it benign or unknown and it is a threat + false_positive we flagged it and it is legitimate + other +--reason (required, 1 to 1024 characters) is kept with the submission. + +Submitting changes no verdict and performs no remediation. Submitting a +message that already has an active submission succeeds and reports +skipped. A refusal is reported as result=failed with the reason (not +opted in, no submissions store in this datacenter, or the message's raw +copy is no longer stored), and this command exits non-zero. + +Examples: + limacharlie mailsec message submit-sample 0057db2b-... --category missed_threat --reason "credential phish we did not flag" + limacharlie mailsec message submit-sample 0057db2b-... --category false_positive --reason "internal newsletter" +""" + +_EXPLAIN_MESSAGE_WITHDRAW_SAMPLE = """\ +Withdraw the sample submitted from this message. Requires mailsec.act. + +Deletes LimaCharlie's stored copy of the message and its metadata (a +hard delete), then records the withdrawal in the audit trail. Only a +person can withdraw; automation is refused. --reason is optional, at +most 1024 characters. + +If you have the submission id rather than the message id, use +`limacharlie mailsec submission withdraw `. + +Examples: + limacharlie mailsec message withdraw-sample 0057db2b-... + limacharlie mailsec message withdraw-sample 0057db2b-... --reason "sent by mistake" +""" + +_EXPLAIN_SUBMISSION_LIST = """\ +The samples this organization has submitted to LimaCharlie, newest +first. Requires mailsec.get. + +The response always carries two flags, so an empty list is never +ambiguous: + enabled the organization has opted in (mailsec_policy record of + type sample_submission) + available this datacenter has a submissions store + +Each submission shows the category and reason, who submitted it and +when, when it expires (400 days after submission), the verdict, score +and matched rules at the time, and review_count / last_reviewed_at: how +often LimaCharlie staff have opened the stored copy. + +--limit is 1 to 200 (default 50). Pass next_cursor back as --cursor, +verbatim, to read the next page; an empty next_cursor means the last. + +Examples: + limacharlie mailsec submission list + limacharlie mailsec submission list --category missed_threat --since 2026-09-01T00:00:00Z + limacharlie mailsec submission list --limit 200 --cursor +""" + +_EXPLAIN_SUBMISSION_GET = """\ +One submission, and who at LimaCharlie has looked at it. Requires +mailsec.get. + +`reviews` lists each time LimaCharlie staff opened the stored copy: a +timestamp per access, never the reviewer's identity. An empty list means +nobody has opened it. An unknown id is a 404. + +Examples: + limacharlie mailsec submission get 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b +""" + +_EXPLAIN_SUBMISSION_WITHDRAW = """\ +Withdraw a submission by id. Requires mailsec.act. + +Deletes LimaCharlie's stored copy and its metadata (a hard delete), then +records the withdrawal in the audit trail. It cannot be undone: to share +the message again, submit it again. + +An unknown, already-withdrawn or expired id is a 404. A second +withdrawal never deletes anything twice. + +Examples: + limacharlie mailsec submission withdraw 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b +""" + _EXPLAIN_MESSAGE_BULK_ACTION = """\ Remediate a set of messages you name, in bulk. Requires mailsec.act. @@ -605,6 +717,45 @@ def _note_force_required(ctx: click.Context, response: Any, rerun: str, forced: ) +def _note_sample_result(ctx: click.Context, result: Any, *, submitting: bool) -> None: + """Say what a sample action did, on stderr, and fail loudly when it did not. + + A refused submission (not opted in, no store in this datacenter, raw copy + aged out) is an HTTP 200 carrying ``result: failed``, so it would otherwise + exit 0 and read like a success. It exits 1 here so a script cannot mistake + it for one. The response itself is printed untouched. + """ + if not isinstance(result, dict): + return + outcome = result.get("result") + if outcome == "failed": + note(ctx, f"Not {'submitted' if submitting else 'withdrawn'}: " + f"{result.get('error') or 'the action failed'}") + ctx.exit(1) + if not submitting: + return + sid = result.get("submission_id") + if outcome == "skipped": + note(ctx, "This message already has an active submission; nothing was sent again.") + else: + note(ctx, "A copy of this message was sent to LimaCharlie. It is kept for 400 days " + "and deleted automatically after that.") + if sid: + note(ctx, f"Withdraw it at any time: limacharlie mailsec submission withdraw {sid}") + + +def _note_submission_flags(ctx: click.Context, response: Any) -> None: + """Explain an empty submissions list that comes from a switch, not from nothing sent.""" + if not isinstance(response, dict): + return + if response.get("available") is False: + note(ctx, "Sample submission is not available in this datacenter.") + elif response.get("enabled") is False: + note(ctx, "Sample submission is not enabled for this organization. Opt in with a " + "mailsec_policy record of type sample_submission (see " + "`limacharlie mailsec message submit-sample --ai-help`).") + + def _get_mailsec(ctx: click.Context) -> Mailsec: client = Client( oid=ctx.obj.oid, @@ -909,6 +1060,8 @@ def group() -> None: campaign ... Campaigns and campaign-wide sweeps sender get A sender's history with this org action get One record from the action audit trail + submission ... Samples you chose to copy to LimaCharlie (list, get, + withdraw); send one with `message submit-sample` analyze Parse and score an EML without ingesting it report ... Abuse-mailbox report queue (list, get, resolve, reopen) rule ... Custom rule validation and backtest @@ -1039,7 +1192,7 @@ def groups_confirm(ctx, job_id, confirmation, wait, timeout, poll_interval) -> N @group.group("message") def message_group() -> None: """The message index, drawer, raw EML, similar mail, actions, verdict revision, - and bulk remediation across a selection you name.""" + sample submission, and bulk remediation across a selection you name.""" @group.group("campaign") @@ -1057,6 +1210,11 @@ def action_group() -> None: """The action audit trail.""" +@group.group("submission") +def submission_group() -> None: + """Samples this org copied to LimaCharlie (list, get, withdraw).""" + + @group.group("report") def report_group() -> None: """The abuse-mailbox report queue (list, get, resolve, reopen).""" @@ -1340,6 +1498,66 @@ def message_action(ctx, msg_uuid, action_name, reason, attempt, text, force) -> _note_force_required(ctx, result, "Re-run with --force to perform it.", force) +@message_group.command("submit-sample") +@click.argument("msg_uuid") +@click.option("--category", required=True, type=click.Choice(list(SAMPLE_CATEGORIES)), + help="missed_threat: we called it benign and it is a threat. " + "false_positive: we flagged it and it is legitimate. other.") +@click.option("--reason", required=True, + help="Why you are submitting it (1 to 1024 characters). Kept with the submission.") +@click.option("--attempt", default=None, help="Caller-supplied idempotency token.") +@pass_context +def message_submit_sample(ctx, msg_uuid, category, reason, attempt) -> None: + """Copy ONE message to LimaCharlie to improve detection (mailsec.act). + + \b + THIS SENDS THE MESSAGE TO LIMACHARLIE: the original message + (attachments included), your reason and identity, and the verdict + snapshot are stored, encrypted, in a LimaCharlie-owned store in your + datacenter for 400 days. Only LimaCharlie staff working on detection + quality can open it, and every access is recorded and shown in + `mailsec submission get`. The organization must have opted in. + Withdraw at any time with `mailsec message withdraw-sample` or + `mailsec submission withdraw`, which deletes the copy. + + \b + Example: + limacharlie mailsec message submit-sample 0057db2b-... --category missed_threat --reason "credential phish we did not flag" + """ + ms = _get_mailsec(ctx) + try: + result = ms.submit_sample(msg_uuid, category, reason, attempt=attempt) + except ValueError as e: + raise click.BadParameter(str(e), param_hint="--reason") + _output(ctx, result) + _note_sample_result(ctx, result, submitting=True) + + +@message_group.command("withdraw-sample") +@click.argument("msg_uuid") +@click.option("--reason", default=None, help="Optional note for the audit row (at most 1024 characters).") +@click.option("--attempt", default=None, help="Caller-supplied idempotency token.") +@pass_context +def message_withdraw_sample(ctx, msg_uuid, reason, attempt) -> None: + """Withdraw the sample submitted from a message (mailsec.act). + + \b + Deletes LimaCharlie's stored copy of the message and its metadata. + If you have the submission id, use `mailsec submission withdraw`. + + \b + Example: + limacharlie mailsec message withdraw-sample 0057db2b-... + """ + ms = _get_mailsec(ctx) + try: + result = ms.withdraw_sample(msg_uuid, reason=reason, attempt=attempt) + except ValueError as e: + raise click.BadParameter(str(e), param_hint="--reason") + _output(ctx, result) + _note_sample_result(ctx, result, submitting=False) + + @message_group.command("revise") @click.argument("msg_uuid") @click.option("--verdict", required=True, @@ -1640,6 +1858,66 @@ def action_get(ctx, action_id) -> None: _output(ctx, _get_mailsec(ctx).get_action(action_id)) +# --------------------------------------------------------------------------- +# Sample submissions +# --------------------------------------------------------------------------- + +@submission_group.command("list") +@click.option("--category", default=None, type=click.Choice(list(SAMPLE_CATEGORIES)), + help="Only this category.") +@click.option("--since", default=None, help="Lower time bound (RFC3339).") +@click.option("--until", default=None, help="Upper time bound (RFC3339).") +@click.option("--limit", default=None, type=click.IntRange(1, 200), help="Page size (1-200, default 50).") +@click.option("--cursor", default=None, help="next_cursor from a previous page, passed back verbatim.") +@pass_context +def submission_list(ctx, category, since, until, limit, cursor) -> None: + """Samples this org copied to LimaCharlie, newest first (mailsec.get). + + \b + The response always says whether the org has opted in (enabled) and + whether this datacenter can store samples (available). + + \b + Example: + limacharlie mailsec submission list --category missed_threat + """ + ms = _get_mailsec(ctx) + result = ms.list_submissions( + category=category, since=since, until=until, limit=limit, cursor=cursor, + ) + _output(ctx, result) + _note_submission_flags(ctx, result) + + +@submission_group.command("get") +@click.argument("submission_id") +@pass_context +def submission_get(ctx, submission_id) -> None: + """One submission and when LimaCharlie staff opened it (mailsec.get). + + \b + Example: + limacharlie mailsec submission get 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b + """ + _output(ctx, _get_mailsec(ctx).get_submission(submission_id)) + + +@submission_group.command("withdraw") +@click.argument("submission_id") +@pass_context +def submission_withdraw(ctx, submission_id) -> None: + """Withdraw a submission: delete LimaCharlie's copy (mailsec.act). + + \b + Hard delete of the stored message and its metadata. Cannot be undone. + + \b + Example: + limacharlie mailsec submission withdraw 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b + """ + _output(ctx, _get_mailsec(ctx).withdraw_submission(submission_id)) + + # --------------------------------------------------------------------------- # Reports # --------------------------------------------------------------------------- @@ -1910,6 +2188,8 @@ def tenant_purge(ctx, confirm, reason) -> None: register_explain("mailsec.message.action", _EXPLAIN_MESSAGE_ACTION) register_explain("mailsec.message.revise", _EXPLAIN_MESSAGE_REVISE) register_explain("mailsec.message.revisions", _EXPLAIN_MESSAGE_REVISIONS) +register_explain("mailsec.message.submit-sample", _EXPLAIN_MESSAGE_SUBMIT_SAMPLE) +register_explain("mailsec.message.withdraw-sample", _EXPLAIN_MESSAGE_WITHDRAW_SAMPLE) register_explain("mailsec.message.bulk-action", _EXPLAIN_MESSAGE_BULK_ACTION) register_explain("mailsec.message.bulk-status", _EXPLAIN_MESSAGE_BULK_STATUS) register_explain("mailsec.campaign.list", _EXPLAIN_CAMPAIGN_LIST) @@ -1917,6 +2197,9 @@ def tenant_purge(ctx, confirm, reason) -> None: register_explain("mailsec.campaign.action", _EXPLAIN_CAMPAIGN_ACTION) register_explain("mailsec.sender.get", _EXPLAIN_SENDER_GET) register_explain("mailsec.action.get", _EXPLAIN_ACTION_GET) +register_explain("mailsec.submission.list", _EXPLAIN_SUBMISSION_LIST) +register_explain("mailsec.submission.get", _EXPLAIN_SUBMISSION_GET) +register_explain("mailsec.submission.withdraw", _EXPLAIN_SUBMISSION_WITHDRAW) register_explain("mailsec.report.list", _EXPLAIN_REPORT_LIST) register_explain("mailsec.report.get", _EXPLAIN_REPORT_GET) register_explain("mailsec.report.resolve", _EXPLAIN_REPORT_RESOLVE) diff --git a/limacharlie/discovery.py b/limacharlie/discovery.py index 6862a34b..637dc757 100644 --- a/limacharlie/discovery.py +++ b/limacharlie/discovery.py @@ -275,6 +275,8 @@ "mailsec group preview", "mailsec group status", "mailsec group confirm", + "mailsec message submit-sample", "mailsec message withdraw-sample", + "mailsec submission list", "mailsec submission get", "mailsec submission withdraw", "mailsec campaign list", "mailsec campaign get", "mailsec campaign action", "mailsec sender get", "mailsec action get", diff --git a/limacharlie/help_topics.py b/limacharlie/help_topics.py index 028d873b..3adb1650 100644 --- a/limacharlie/help_topics.py +++ b/limacharlie/help_topics.py @@ -856,6 +856,12 @@ limacharlie mailsec campaign list limacharlie mailsec report list --status open +# Opt-in only: copy ONE message to LimaCharlie to improve detection, then list +# or withdraw it (withdrawing deletes LimaCharlie's copy) +limacharlie mailsec message submit-sample --category missed_threat --reason "phish we did not flag" +limacharlie mailsec submission list +limacharlie mailsec submission withdraw + Full onboarding: limacharlie help email-security """ diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index ece1f8c6..7566dd22 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -4,7 +4,9 @@ coverage screen, the message index and its drawer, the justified raw-EML download, analyst verdict revision and its history, bulk remediation across a caller-supplied selection, campaigns, sender profiles, the action audit trail, -the abuse-mailbox report queue and its reopen, standalone EML analysis, +the abuse-mailbox report queue and its reopen, customer sample submission +(copy one message to LimaCharlie, list and withdraw what was sent), standalone +EML analysis, custom-rule validation and backtest, the provider connection preflight, the served onboarding guide, and the irreversible tenant purge. @@ -78,6 +80,20 @@ _MAX_PURGE_REASON_LEN = 1024 +# Customer sample submission. A person may copy ONE message at a time to +# LimaCharlie so detection can improve, when the org has opted in. +# +# Unlike the remediation vocabulary above, the category set IS validated +# client-side: it is closed, the server refuses anything else with a 400, and a +# submission is a disclosure of somebody's mail, so the caller should learn +# from a local error that a typo was not sent rather than after the round trip. +# The reason is bounded the same way the server bounds it (1..1024 characters +# after trimming; required for a submission, optional for a withdrawal). +SAMPLE_CATEGORIES = ("missed_threat", "false_positive", "other") +_MAX_SAMPLE_REASON_LEN = 1024 +_MAX_SUBMISSION_PAGE = 200 + + # The bulk remediation vocabulary, for documentation and for building help text. # # It is the per-message vocabulary plus ``move_to_spam`` and minus @@ -168,6 +184,36 @@ def normalize_bulk_selection(msg_uuids: Any) -> list[str]: return out +def _check_sample_reason(reason: Any, *, required: bool) -> str: + """Trim and bound the reason on a sample submission or withdrawal. + + The server trims and then requires 1..1024 characters for a submission + (and at most 1024 for a withdrawal). Checking here, on the trimmed text, + means a caller learns the limit locally and sends exactly what the server + will record. + """ + if reason is None: + if required: + raise ValueError( + "a sample submission needs a reason: it is kept with the submission " + "so the person reviewing it knows why you sent it" + ) + return "" + if not isinstance(reason, str): + raise ValueError("reason must be text") + text = reason.strip() + if required and not text: + raise ValueError( + "a sample submission needs a reason: it is kept with the submission " + "so the person reviewing it knows why you sent it" + ) + if len(text) > _MAX_SAMPLE_REASON_LEN: + raise ValueError( + f"reason is {len(text)} characters; at most {_MAX_SAMPLE_REASON_LEN} are allowed" + ) + return text + + def _seg(value: str) -> str: """Escape one caller-supplied path segment. @@ -601,6 +647,8 @@ def act_on_message( msg_uuid: The message to act on. action: ``quarantine_message``, ``trash_message``, ``restore_message``, ``banner_message``, ``unbanner_message``. + Sample submission has its own methods: see + :meth:`submit_sample` and :meth:`withdraw_sample`. reason: Free-text justification recorded on the audit row. attempt: Caller-supplied idempotency token. text: ``banner_message`` only: plain-text wording for this one @@ -624,6 +672,14 @@ def act_on_message( ``force=True`` performs the action. """ _check_force(force) + if action == "submit_sample": + # Sending it here would skip the category and reason the server + # requires (and the local checks on them), and it copies a message + # to LimaCharlie, so point at the method that asks for both. + raise ValueError( + "use submit_sample(msg_uuid, category, reason) for action " + "'submit_sample': it needs a category and a reason" + ) body: dict[str, Any] = {"action": action} for key, val in (("reason", reason), ("attempt", attempt), ("text", text)): if val is not None: @@ -1267,6 +1323,197 @@ def get_action(self, action_id: str) -> dict[str, Any]: why, and what the provider actually did.""" return self._get(f"actions/{_seg(action_id)}") + # ------------------------------------------------------------------ + # Customer sample submission + # ------------------------------------------------------------------ + # + # An organization can opt in (``mailsec_policy`` record of type + # ``sample_submission``, ``{"enabled": true}``; off by default) to let its + # analysts copy ONE message at a time to LimaCharlie so detection quality + # can improve. Nothing is ever submitted automatically. The copy is the + # message's original bytes, compressed and encrypted, kept in a + # LimaCharlie-owned bucket in the same datacenter as the organization's + # Email Security data, plus a metadata row; both are deleted automatically + # 400 days after submission, or immediately on withdrawal. + + def submit_sample( + self, + msg_uuid: str, + category: str, + reason: str, + *, + attempt: str | None = None, + ) -> dict[str, Any]: + """Copy ONE message to LimaCharlie to help improve detection. + + Requires ``mailsec.act`` and an organization that has opted in + (``mailsec_policy`` record of type ``sample_submission``). This sends + the message's original bytes (attachments included) and the metadata + listed below to a LimaCharlie-owned store in the organization's own + datacenter. It is explicit, one message per call, and never done + automatically. Only a person can do it: the backend refuses the same + action from a D&R rule, an automation or the AI agent. + + What is kept: the original raw message (compressed and encrypted), and + a row with the message id, your category and reason, your identity, + the time, the verdict, score and matched rule ids at that time, the + sender, subject, mailbox address and size. Retention is 400 days, then + deleted automatically. Only LimaCharlie staff working on detection + quality can open a submission, through a tool that records every + access; :meth:`get_submission` shows how many times and when. Withdraw + at any time with :meth:`withdraw_sample` or + :meth:`withdraw_submission`: the copy and its metadata are deleted. + + Args: + msg_uuid: The message to submit. + category: ``missed_threat`` (we called it benign or unknown and + it is a threat), ``false_positive`` (we flagged it and it is + legitimate) or ``other``. + reason: Why you are submitting it, 1 to 1024 characters after + trimming. Required, and kept with the submission. + attempt: Caller-supplied idempotency token. + + Returns: + The action record. ``result: ok`` and ``skipped`` carry + ``submission_id``; ``skipped`` means this message already has an + active submission. ``result: failed`` carries ``error`` with the + stable reason (not opted in, no submissions store in this + datacenter, or the message's raw copy is no longer stored): a + refused submission is an HTTP 200 with ``failed`` inside, not an + exception. + + Raises: + ValueError: If ``category`` is not one of the three, or ``reason`` + is blank or longer than 1024 characters. + """ + if category not in SAMPLE_CATEGORIES: + raise ValueError( + f"category must be one of {', '.join(SAMPLE_CATEGORIES)}; got {category!r}" + ) + text = _check_sample_reason(reason, required=True) + body: dict[str, Any] = {"action": "submit_sample", "category": category, "reason": text} + if attempt is not None: + body["attempt"] = attempt + return self._post(f"messages/{_seg(msg_uuid)}/actions", body) + + def withdraw_sample( + self, + msg_uuid: str, + *, + reason: str | None = None, + attempt: str | None = None, + ) -> dict[str, Any]: + """Withdraw the submission made from one message. Requires ``mailsec.act``. + + Deletes LimaCharlie's stored copy of the message and its metadata (a + hard delete), then records the withdrawal in the audit trail. Like + :meth:`submit_sample`, only a person can do this. + + Args: + msg_uuid: The message whose submission to withdraw. + reason: Optional note recorded on the audit row, at most 1024 + characters. + attempt: Caller-supplied idempotency token. + + Returns: + The action record; read ``result`` and ``error`` as for + :meth:`submit_sample`. + + Raises: + ValueError: If ``reason`` is longer than 1024 characters. + """ + body: dict[str, Any] = {"action": "withdraw_sample"} + text = _check_sample_reason(reason, required=False) + if text: + body["reason"] = text + if attempt is not None: + body["attempt"] = attempt + return self._post(f"messages/{_seg(msg_uuid)}/actions", body) + + def list_submissions( + self, + *, + category: str | None = None, + since: str | None = None, + until: str | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> dict[str, Any]: + """The samples this organization has submitted to LimaCharlie. + + Requires ``mailsec.get``. Newest first. + + Args: + category: Only this category: ``missed_threat``, + ``false_positive`` or ``other``. + since: Lower time bound (RFC 3339). + until: Upper time bound (RFC 3339). + limit: Page size, 1 to 200 (the server default is 50). + cursor: ``next_cursor`` from the previous page, passed back + verbatim. + + Returns: + ``{"enabled", "available", "submissions", "next_cursor"}``. + ``enabled`` is whether the organization has opted in; + ``available`` is whether this datacenter has a submissions store. + Both are always present, so an empty list can be told apart from a + feature that is off. A non-empty ``next_cursor`` means more pages. + + Raises: + ValueError: If ``category`` or ``limit`` is out of range. + """ + if category is not None and category not in SAMPLE_CATEGORIES: + raise ValueError( + f"category must be one of {', '.join(SAMPLE_CATEGORIES)}; got {category!r}" + ) + if limit is not None and not ( + isinstance(limit, int) and not isinstance(limit, bool) + and 1 <= limit <= _MAX_SUBMISSION_PAGE + ): + raise ValueError(f"limit must be between 1 and {_MAX_SUBMISSION_PAGE}; got {limit!r}") + pairs: list[tuple[str, str]] = [] + for key, val in ( + ("category", category), + ("since", since), + ("until", until), + ("limit", limit), + ("cursor", cursor), + ): + _add_scalar(pairs, key, val) + return self._get("submissions", pairs) + + def get_submission(self, submission_id: str) -> dict[str, Any]: + """One submission, and who at LimaCharlie has opened it. + + Requires ``mailsec.get``. + + Args: + submission_id: The submission id (from :meth:`list_submissions`). + + Returns: + ``{"submission": {...}, "reviews": [{"ts": ...}]}``. ``reviews`` + lists each time LimaCharlie staff opened the stored copy: a count + and timestamps only, never the reviewer's identity. An unknown id + is a 404 error. + """ + return self._get(f"submissions/{_seg(submission_id)}") + + def withdraw_submission(self, submission_id: str) -> dict[str, Any]: + """Withdraw a submission by id. Requires ``mailsec.act``. + + Deletes LimaCharlie's stored copy and the metadata row (a hard + delete), then records the audit. + + Args: + submission_id: The submission id (from :meth:`list_submissions`). + + Returns: + ``{"withdrawn": true, "submission_id": ..., "action_id": ...}``. + An unknown, already-withdrawn or expired id is a 404 error: a + second withdrawal never deletes anything twice. + """ + return self._delete(f"submissions/{_seg(submission_id)}") + # ------------------------------------------------------------------ # Standalone analysis # ------------------------------------------------------------------ diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index d9892307..c647e522 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -194,7 +194,7 @@ "mailsec": frozenset({ "coverage", "analyze", "onboarding", "message", "campaign", "sender", "action", "report", "rule", "banner", "connection", "tenant", "provider-quarantine", "release-request", - "group", + "group", "submission", }), "lookup": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "note": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), diff --git a/tests/unit/test_cli_mailsec_samples.py b/tests/unit/test_cli_mailsec_samples.py new file mode 100644 index 00000000..437cd7d5 --- /dev/null +++ b/tests/unit/test_cli_mailsec_samples.py @@ -0,0 +1,194 @@ +"""CLI tests for customer sample submission (message submit-sample / +withdraw-sample and the submission group). + +The real SDK runs over a mocked transport, so these cover Click parsing, the +local validation, the request that would be sent and the exit code together. +""" + +import json +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from limacharlie.cli import cli + +OID = "11111111-2222-3333-4444-555555555555" +MSG = "0057db2b-0000-4000-8000-000000000001" +SID = "3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b" + + +def invoke(*args, returns=None): + org = MagicMock() + org.oid = OID + org.client.request.return_value = returns if returns is not None else {} + with patch("limacharlie.commands.mailsec.Client"), patch( + "limacharlie.commands.mailsec.Organization", return_value=org + ): + result = CliRunner(mix_stderr=False).invoke( + cli, ["--oid", OID, "--output", "json", "mailsec", *args] + ) + return result, org.client.request + + +def sent_body(request): + return json.loads(request.call_args.kwargs["raw_body"]) + + +class TestSubmitSample: + def test_sends_category_and_reason_and_says_how_to_withdraw(self): + result, request = invoke( + "message", "submit-sample", MSG, "--category", "missed_threat", + "--reason", "credential phish", returns={"result": "ok", "submission_id": SID}, + ) + assert result.exit_code == 0, result.stderr + assert request.call_args.args == ("POST", f"mailsec/{OID}/messages/{MSG}/actions") + assert sent_body(request) == { + "action": "submit_sample", "category": "missed_threat", "reason": "credential phish", + } + assert json.loads(result.stdout)["submission_id"] == SID + assert "copy of this message was sent to LimaCharlie" in result.stderr + assert f"mailsec submission withdraw {SID}" in result.stderr + + def test_skipped_says_nothing_new_was_sent(self): + result, _ = invoke( + "message", "submit-sample", MSG, "--category", "other", "--reason", "x", + returns={"result": "skipped", "submission_id": SID}, + ) + assert result.exit_code == 0 + assert "already has an active submission" in result.stderr + + def test_a_refusal_exits_nonzero_with_the_servers_reason(self): + result, _ = invoke( + "message", "submit-sample", MSG, "--category", "other", "--reason", "x", + returns={"result": "failed", + "error": "sample submission is not enabled for this organization"}, + ) + assert result.exit_code == 1 + assert "sample submission is not enabled for this organization" in result.stderr + assert "Not submitted" in result.stderr + # The response is still printed untouched for a script. + assert json.loads(result.stdout)["result"] == "failed" + + def test_category_and_reason_are_required(self): + for args in (["--reason", "x"], ["--category", "other"]): + result, request = invoke("message", "submit-sample", MSG, *args) + assert result.exit_code == 2 + request.assert_not_called() + + def test_unknown_category_is_a_usage_error(self): + result, request = invoke( + "message", "submit-sample", MSG, "--category", "phish", "--reason", "x", + ) + assert result.exit_code == 2 + assert "missed_threat" in result.stderr + request.assert_not_called() + + def test_blank_and_overlong_reasons_are_usage_errors_and_send_nothing(self): + for reason in (" ", "x" * 1025): + result, request = invoke( + "message", "submit-sample", MSG, "--category", "other", "--reason", reason, + ) + assert result.exit_code == 2, reason + assert "--reason" in result.stderr + request.assert_not_called() + + def test_generic_action_command_refuses_submit_sample(self): + result, request = invoke("message", "action", MSG, "--action", "submit_sample") + assert result.exit_code != 0 + request.assert_not_called() + + def test_help_states_the_copy_and_the_way_back(self): + result, _ = invoke("message", "submit-sample", "--help") + text = " ".join(result.stdout.split()) + assert "SENDS THE MESSAGE TO LIMACHARLIE" in text + assert "400 days" in text + assert "withdraw-sample" in text + + +class TestWithdrawSample: + def test_sends_withdraw_action(self): + result, request = invoke( + "message", "withdraw-sample", MSG, "--reason", "sent by mistake", + returns={"result": "ok"}, + ) + assert result.exit_code == 0, result.stderr + assert sent_body(request) == {"action": "withdraw_sample", "reason": "sent by mistake"} + + def test_reason_is_optional(self): + result, request = invoke("message", "withdraw-sample", MSG, returns={"result": "ok"}) + assert result.exit_code == 0 + assert sent_body(request) == {"action": "withdraw_sample"} + + def test_a_refusal_exits_nonzero(self): + result, _ = invoke( + "message", "withdraw-sample", MSG, + returns={"result": "failed", "error": "no active submission"}, + ) + assert result.exit_code == 1 + assert "Not withdrawn" in result.stderr + + +class TestSubmissionGroup: + def test_list_forwards_filters_and_prints_the_response(self): + body = {"enabled": True, "available": True, "submissions": [], "next_cursor": "c2"} + result, request = invoke( + "submission", "list", "--category", "false_positive", "--since", "2026-09-01T00:00:00Z", + "--until", "2026-09-30T00:00:00Z", "--limit", "25", "--cursor", "c1", returns=body, + ) + assert result.exit_code == 0, result.stderr + assert request.call_args.args == ("GET", f"mailsec/{OID}/submissions") + assert dict(request.call_args.kwargs["query_params"]) == { + "category": "false_positive", "since": "2026-09-01T00:00:00Z", + "until": "2026-09-30T00:00:00Z", "limit": "25", "cursor": "c1", + } + assert json.loads(result.stdout) == body + assert result.stderr == "" + + def test_list_explains_an_org_that_has_not_opted_in(self): + result, _ = invoke( + "submission", "list", + returns={"enabled": False, "available": True, "submissions": [], "next_cursor": ""}, + ) + assert result.exit_code == 0 + assert "not enabled for this organization" in result.stderr + + def test_list_explains_a_datacenter_without_a_store(self): + result, _ = invoke( + "submission", "list", + returns={"enabled": True, "available": False, "submissions": [], "next_cursor": ""}, + ) + assert result.exit_code == 0 + assert "not available in this datacenter" in result.stderr + + def test_list_limit_out_of_range_is_a_usage_error(self): + for limit in ("0", "201"): + result, request = invoke("submission", "list", "--limit", limit) + assert result.exit_code == 2 + request.assert_not_called() + + def test_get(self): + result, request = invoke( + "submission", "get", SID, + returns={"submission": {"submission_id": SID}, "reviews": [{"ts": "2026-09-30T00:00:00Z"}]}, + ) + assert result.exit_code == 0, result.stderr + assert request.call_args.args == ("GET", f"mailsec/{OID}/submissions/{SID}") + assert len(json.loads(result.stdout)["reviews"]) == 1 + + def test_withdraw(self): + result, request = invoke( + "submission", "withdraw", SID, + returns={"withdrawn": True, "submission_id": SID, "action_id": "a1"}, + ) + assert result.exit_code == 0, result.stderr + assert request.call_args.args == ("DELETE", f"mailsec/{OID}/submissions/{SID}") + assert json.loads(result.stdout)["withdrawn"] is True + + def test_ai_help_is_registered_for_every_command(self): + for path in ( + ("message", "submit-sample"), ("message", "withdraw-sample"), + ("submission", "list"), ("submission", "get"), ("submission", "withdraw"), + ): + result, _ = invoke(*path, "--ai-help") + assert result.exit_code == 0, path + assert "mailsec.act" in result.stdout or "mailsec.get" in result.stdout, path diff --git a/tests/unit/test_sdk_mailsec.py b/tests/unit/test_sdk_mailsec.py index 7c1162f8..a9cb1c12 100644 --- a/tests/unit/test_sdk_mailsec.py +++ b/tests/unit/test_sdk_mailsec.py @@ -945,3 +945,129 @@ def test_timeout_returns_job_and_unknown_is_refused(self, ms, mock_org): mock_org.client.request.return_value = {"report": {"status": "open"}} with pytest.raises(ValueError, match="incomplete"): ms.wait_for_report_resolution("r", "malicious", remediation=self.request) + + +class TestSampleSubmission: + """Customer sample submission copies one message to LimaCharlie, so the + closed category vocabulary and the reason bounds are enforced locally: + a typo is a local error rather than a request that was sent.""" + + def test_submit_posts_the_action_with_category_and_reason(self, ms, mock_org): + ms.submit_sample("msg-1", "missed_threat", "credential phish we did not flag") + url, body = _post_call(mock_org) + assert url == f"mailsec/{OID}/messages/msg-1/actions" + assert body == { + "action": "submit_sample", + "category": "missed_threat", + "reason": "credential phish we did not flag", + } + + def test_submit_forwards_attempt_only_when_given(self, ms, mock_org): + ms.submit_sample("msg-1", "other", "x", attempt="retry-1") + _, body = _post_call(mock_org) + assert body["attempt"] == "retry-1" + ms.submit_sample("msg-1", "other", "x") + _, body = _post_call(mock_org) + assert "attempt" not in body + + @pytest.mark.parametrize("category", ["missed_threat", "false_positive", "other"]) + def test_all_three_categories_are_accepted(self, ms, mock_org, category): + ms.submit_sample("msg-1", category, "why") + _, body = _post_call(mock_org) + assert body["category"] == category + + @pytest.mark.parametrize("category", ["", "phish", "MISSED_THREAT", None]) + def test_unknown_category_is_refused_and_nothing_is_sent(self, ms, mock_org, category): + with pytest.raises(ValueError, match="category must be one of"): + ms.submit_sample("msg-1", category, "why") + mock_org.client.request.assert_not_called() + + def test_reason_is_trimmed_before_sending(self, ms, mock_org): + ms.submit_sample("msg-1", "other", " padded ") + _, body = _post_call(mock_org) + assert body["reason"] == "padded" + + @pytest.mark.parametrize("reason", [None, "", " \n\t"]) + def test_blank_or_missing_reason_is_refused(self, ms, mock_org, reason): + with pytest.raises(ValueError, match="needs a reason"): + ms.submit_sample("msg-1", "other", reason) + mock_org.client.request.assert_not_called() + + def test_reason_at_the_limit_is_allowed_and_one_over_is_refused(self, ms, mock_org): + ms.submit_sample("msg-1", "other", "x" * 1024) + _, body = _post_call(mock_org) + assert len(body["reason"]) == 1024 + mock_org.client.request.reset_mock() + with pytest.raises(ValueError, match="at most 1024"): + ms.submit_sample("msg-1", "other", "x" * 1025) + mock_org.client.request.assert_not_called() + + def test_the_bound_applies_after_trimming(self, ms, mock_org): + ms.submit_sample("msg-1", "other", " " + "x" * 1024 + " ") + _, body = _post_call(mock_org) + assert len(body["reason"]) == 1024 + + def test_act_on_message_will_not_send_submit_sample(self, ms, mock_org): + # It would skip the category and reason the server requires. + with pytest.raises(ValueError, match="submit_sample"): + ms.act_on_message("msg-1", "submit_sample", reason="why") + mock_org.client.request.assert_not_called() + + def test_withdraw_sample_posts_the_action(self, ms, mock_org): + ms.withdraw_sample("msg-1") + url, body = _post_call(mock_org) + assert url == f"mailsec/{OID}/messages/msg-1/actions" + assert body == {"action": "withdraw_sample"} + + def test_withdraw_sample_reason_is_optional_trimmed_and_bounded(self, ms, mock_org): + ms.withdraw_sample("msg-1", reason=" sent by mistake ", attempt="a1") + _, body = _post_call(mock_org) + assert body == {"action": "withdraw_sample", "reason": "sent by mistake", "attempt": "a1"} + ms.withdraw_sample("msg-1", reason=" ") + _, body = _post_call(mock_org) + assert "reason" not in body + with pytest.raises(ValueError, match="at most 1024"): + ms.withdraw_sample("msg-1", reason="x" * 1025) + + def test_list_submissions_defaults_send_no_query(self, ms, mock_org): + ms.list_submissions() + url, qp = _get_call(mock_org) + assert url == f"mailsec/{OID}/submissions" + assert qp is None + + def test_list_submissions_forwards_filters_and_cursor_verbatim(self, ms, mock_org): + ms.list_submissions( + category="false_positive", since="2026-09-01T00:00:00Z", + until="2026-09-30T00:00:00Z", limit=200, cursor="opaque+/=", + ) + _, qp = _get_call(mock_org) + assert dict(qp) == { + "category": "false_positive", "since": "2026-09-01T00:00:00Z", + "until": "2026-09-30T00:00:00Z", "limit": "200", "cursor": "opaque+/=", + } + + @pytest.mark.parametrize("limit", [0, -1, 201, True]) + def test_list_submissions_limit_bounds(self, ms, mock_org, limit): + with pytest.raises(ValueError, match="between 1 and 200"): + ms.list_submissions(limit=limit) + mock_org.client.request.assert_not_called() + + def test_list_submissions_refuses_unknown_category(self, ms, mock_org): + with pytest.raises(ValueError, match="category must be one of"): + ms.list_submissions(category="phish") + mock_org.client.request.assert_not_called() + + def test_get_and_withdraw_submission_routes(self, ms, mock_org): + ms.get_submission("abc123") + url, qp = _get_call(mock_org) + assert url == f"mailsec/{OID}/submissions/abc123" + assert qp is None + ms.withdraw_submission("abc123") + args, _ = mock_org.client.request.call_args + assert args[0] == "DELETE" + assert args[1] == f"mailsec/{OID}/submissions/abc123" + + def test_submission_id_is_escaped_as_one_path_segment(self, ms, mock_org): + ms.withdraw_submission("a/../b") + args, _ = mock_org.client.request.call_args + assert args[1] == f"mailsec/{OID}/submissions/a%2F..%2Fb" From da25d5c7be24e3cf05ad8d3156a5184db9325120 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Wed, 30 Sep 2026 23:36:17 +0000 Subject: [PATCH 2/8] mailsec: rename the opt-in record to sample_sharing; unknown submission ids are not errors Follows the updated wire contract: the policy record type is sample_sharing, GET /submissions/{id} of an unknown id returns submission: null, and DELETE of an unknown or already-withdrawn id returns withdrawn: false. The CLI says so on stderr instead of treating either as a failure. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- doc/cli/email-security.md | 8 ++--- doc/sdk/security-products.md | 2 +- limacharlie/commands/mailsec.py | 48 +++++++++++++++++--------- limacharlie/sdk/mailsec.py | 25 ++++++++------ tests/unit/test_cli_mailsec_samples.py | 20 +++++++++++ tests/unit/test_sdk_mailsec.py | 6 ++++ 7 files changed, 78 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e87fdadb..3311d1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ `mailsec message submit-sample --category missed_threat|false_positive|other --reason "..."`, `mailsec message withdraw-sample `, and `mailsec submission list|get|withdraw`. An organization that has opted in - (a `mailsec_policy` record of type `sample_submission`) can copy one message + (a `mailsec_policy` record of type `sample_sharing`) can copy one message at a time to LimaCharlie to help improve detection; the copy is deleted after 400 days or as soon as it is withdrawn, and `submission get` shows when LimaCharlie staff opened it. Category and reason are checked locally, a diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index db8982b1..ee4f749a 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -203,11 +203,11 @@ An organization can opt in to let its analysts copy **one message at a time** to **Submitting sends the message to LimaCharlie.** The original message (attachments included) is stored, compressed and encrypted, in a LimaCharlie-owned bucket in the same datacenter as your Email Security data, with a metadata row: the message id, your category and reason, your identity, the time, the verdict, score and matched rule ids at that time, the sender, subject, mailbox address and size. It is deleted automatically after 400 days. Only LimaCharlie staff working on detection quality can open it, through a tool that records every access; `submission get` shows you how many times and when. **Withdraw at any time**: the stored copy and its metadata are deleted. -Opt in with a `mailsec_policy` record of type `sample_submission`: +Opt in with a `mailsec_policy` record of type `sample_sharing`: ```bash -echo '{"policy_type": "sample_submission", "enabled": true}' > opt-in.json -limacharlie hive set --hive-name mailsec_policy --key sample-submission --input-file opt-in.json --enabled +echo '{"policy_type": "sample_sharing", "enabled": true}' > opt-in.json +limacharlie hive set --hive-name mailsec_policy --key sample-sharing --input-file opt-in.json --enabled ``` Submitting and withdrawing need `mailsec.act`; listing and reading need `mailsec.get`. `--category` and `--reason` are both required (reason: 1 to 1024 characters, kept with the submission). The categories are `missed_threat` (we called it benign or unknown and it is a threat), `false_positive` (we flagged it and it is legitimate) and `other`. @@ -221,7 +221,7 @@ limacharlie mailsec submission get # includes when LimaCha limacharlie mailsec submission withdraw ``` -A refused submission (the organization has not opted in, the datacenter has no submissions store, or the message's raw copy is no longer stored) comes back as `result: failed` with an `error`; the command prints the reason and exits non-zero. Submitting a message that already has an active submission returns `result: skipped` with the existing `submission_id`. `submission list` always returns `enabled` (the organization opted in) and `available` (the datacenter has a store), so an empty list can be told apart from a feature that is off. It is paginated: pass `next_cursor` back as `--cursor`, verbatim, until it is empty; `--limit` is 1 to 200. `reviews` in `submission get` is one timestamp per time LimaCharlie staff opened the copy, never the reviewer's identity. Withdrawing an unknown, already-withdrawn or expired submission is a 404. +A refused submission (the organization has not opted in, the datacenter has no submissions store, or the message's raw copy is no longer stored) is reported like any other failed action, with the reason in `error`; the command prints the reason and exits non-zero. Submitting a message that already has an active submission returns `result: skipped` with the existing `submission_id`. `submission list` always returns `enabled` (the organization opted in) and `available` (the datacenter has a store), so an empty list can be told apart from a feature that is off. It is paginated: pass `next_cursor` back as `--cursor`, verbatim, until it is empty; `--limit` is 1 to 200. `reviews` in `submission get` is one timestamp per time LimaCharlie staff opened the copy, never the reviewer's identity. An unknown id is not an error: `submission get` returns `submission: null` and `submission withdraw` (or withdrawing an already-withdrawn or expired submission) returns `withdrawn: false`, and the command says so on stderr. ## Reports, analysis & rules diff --git a/doc/sdk/security-products.md b/doc/sdk/security-products.md index 393c196d..e877a71a 100644 --- a/doc/sdk/security-products.md +++ b/doc/sdk/security-products.md @@ -87,7 +87,7 @@ print(mail.analyze(eml_b64=encoded, org_domains=["corp.example"])) `mailsec.get` permits structured reads, `mailsec.set` changes triage and rules, and `mailsec.act` remediates provider mail, revises verdicts, and tests connections. Original-byte downloads require both `mailsec.get` and `mailsec.get.eml`. Provider records use `mailsec_provider.*` and credentials use `secret.*`; policy and `dr-mail` Hives reuse `mailsec.get/set`. -An organization that has opted in (a `mailsec_policy` record of type `sample_submission`) can copy one message at a time to LimaCharlie to improve detection. `submit_sample(msg_uuid, category, reason)` needs `mailsec.act`, sends the original message to LimaCharlie (deleted after 400 days, or on `withdraw_sample(msg_uuid)` / `withdraw_submission(submission_id)`), and raises `ValueError` for an unknown category (`missed_threat`, `false_positive`, `other`) or a reason that is blank or over 1024 characters. A refusal comes back as `result: "failed"` with `error`, not as an exception. `list_submissions()` (paginated, always returns `enabled` and `available`) and `get_submission()` (includes `reviews`, one timestamp per time LimaCharlie staff opened the copy) need `mailsec.get`. +An organization that has opted in (a `mailsec_policy` record of type `sample_sharing`) can copy one message at a time to LimaCharlie to improve detection. `submit_sample(msg_uuid, category, reason)` needs `mailsec.act`, sends the original message to LimaCharlie (deleted after 400 days, or on `withdraw_sample(msg_uuid)` / `withdraw_submission(submission_id)`), and raises `ValueError` for an unknown category (`missed_threat`, `false_positive`, `other`) or a reason that is blank or over 1024 characters. A refusal comes back as `result: "failed"` with `error`, not as an exception. `list_submissions()` (paginated, always returns `enabled` and `available`) and `get_submission()` (includes `reviews`, one timestamp per time LimaCharlie staff opened the copy) need `mailsec.get`. Start with `alert_only`. Manual provider actions need an explicit `force=True` override in that mode; inspect the action and audit outcome. Bulk and campaign actions use preview and confirmation so the executed selection matches what was reviewed. See the [Email Security CLI reference](../cli/email-security.md) for these workflows, verdict revisions, campaigns, user reports, and offboarding. diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index 52beb0c0..be571fd0 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -209,10 +209,10 @@ metadata. It is off by default. To opt in, save a mailsec_policy record of type -sample_submission: +sample_sharing: - echo '{"policy_type": "sample_submission", "enabled": true}' > opt-in.json - limacharlie hive set --hive-name mailsec_policy --key sample-submission \ + echo '{"policy_type": "sample_sharing", "enabled": true}' > opt-in.json + limacharlie hive set --hive-name mailsec_policy --key sample-sharing \ --input-file opt-in.json --enabled Nothing is ever submitted automatically, and only a person can submit: @@ -227,9 +227,9 @@ Submitting changes no verdict and performs no remediation. Submitting a message that already has an active submission succeeds and reports -skipped. A refusal is reported as result=failed with the reason (not -opted in, no submissions store in this datacenter, or the message's raw -copy is no longer stored), and this command exits non-zero. +skipped. A refusal (not opted in, no submissions store in this +datacenter, or the message's raw copy is no longer stored) is reported +with the reason, and this command exits non-zero. Examples: limacharlie mailsec message submit-sample 0057db2b-... --category missed_threat --reason "credential phish we did not flag" @@ -259,7 +259,7 @@ The response always carries two flags, so an empty list is never ambiguous: enabled the organization has opted in (mailsec_policy record of - type sample_submission) + type sample_sharing) available this datacenter has a submissions store Each submission shows the category and reason, who submitted it and @@ -282,7 +282,8 @@ `reviews` lists each time LimaCharlie staff opened the stored copy: a timestamp per access, never the reviewer's identity. An empty list means -nobody has opened it. An unknown id is a 404. +nobody has opened it. An unknown id is not an error: the response is +{"submission": null, "reviews": []}. Examples: limacharlie mailsec submission get 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b @@ -295,8 +296,9 @@ records the withdrawal in the audit trail. It cannot be undone: to share the message again, submit it again. -An unknown, already-withdrawn or expired id is a 404. A second -withdrawal never deletes anything twice. +An unknown, already-withdrawn or expired id is not an error: the +response is withdrawn:false (no action_id) and the command says so. A +second withdrawal never deletes anything twice. Examples: limacharlie mailsec submission withdraw 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b @@ -721,9 +723,10 @@ def _note_sample_result(ctx: click.Context, result: Any, *, submitting: bool) -> """Say what a sample action did, on stderr, and fail loudly when it did not. A refused submission (not opted in, no store in this datacenter, raw copy - aged out) is an HTTP 200 carrying ``result: failed``, so it would otherwise - exit 0 and read like a success. It exits 1 here so a script cannot mistake - it for one. The response itself is printed untouched. + aged out) can come back as a response carrying ``result: failed`` rather + than as an API error, which would otherwise exit 0 and read like a success. + It exits 1 here so a script cannot mistake it for one. The response itself + is printed untouched. """ if not isinstance(result, dict): return @@ -752,7 +755,7 @@ def _note_submission_flags(ctx: click.Context, response: Any) -> None: note(ctx, "Sample submission is not available in this datacenter.") elif response.get("enabled") is False: note(ctx, "Sample submission is not enabled for this organization. Opt in with a " - "mailsec_policy record of type sample_submission (see " + "mailsec_policy record of type sample_sharing (see " "`limacharlie mailsec message submit-sample --ai-help`).") @@ -1895,11 +1898,18 @@ def submission_list(ctx, category, since, until, limit, cursor) -> None: def submission_get(ctx, submission_id) -> None: """One submission and when LimaCharlie staff opened it (mailsec.get). + \b + An unknown id prints {"submission": null} and a note; it is not an error. + \b Example: limacharlie mailsec submission get 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b """ - _output(ctx, _get_mailsec(ctx).get_submission(submission_id)) + result = _get_mailsec(ctx).get_submission(submission_id) + _output(ctx, result) + if isinstance(result, dict) and result.get("submission") is None: + note(ctx, f"Submission {submission_id} was not found: it may never have existed, " + f"or it was withdrawn or has expired.") @submission_group.command("withdraw") @@ -1910,12 +1920,18 @@ def submission_withdraw(ctx, submission_id) -> None: \b Hard delete of the stored message and its metadata. Cannot be undone. + An unknown or already-withdrawn id deletes nothing and says so + (withdrawn: false); it is not an error. \b Example: limacharlie mailsec submission withdraw 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b """ - _output(ctx, _get_mailsec(ctx).withdraw_submission(submission_id)) + result = _get_mailsec(ctx).withdraw_submission(submission_id) + _output(ctx, result) + if isinstance(result, dict) and result.get("withdrawn") is False: + note(ctx, f"Nothing was deleted: submission {submission_id} was not found or was " + f"already withdrawn (or has expired).") # --------------------------------------------------------------------------- diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 7566dd22..21382d2e 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -1328,7 +1328,7 @@ def get_action(self, action_id: str) -> dict[str, Any]: # ------------------------------------------------------------------ # # An organization can opt in (``mailsec_policy`` record of type - # ``sample_submission``, ``{"enabled": true}``; off by default) to let its + # ``sample_sharing``, ``{"enabled": true}``; off by default) to let its # analysts copy ONE message at a time to LimaCharlie so detection quality # can improve. Nothing is ever submitted automatically. The copy is the # message's original bytes, compressed and encrypted, kept in a @@ -1347,7 +1347,7 @@ def submit_sample( """Copy ONE message to LimaCharlie to help improve detection. Requires ``mailsec.act`` and an organization that has opted in - (``mailsec_policy`` record of type ``sample_submission``). This sends + (``mailsec_policy`` record of type ``sample_sharing``). This sends the message's original bytes (attachments included) and the metadata listed below to a LimaCharlie-owned store in the organization's own datacenter. It is explicit, one message per call, and never done @@ -1376,11 +1376,10 @@ def submit_sample( Returns: The action record. ``result: ok`` and ``skipped`` carry ``submission_id``; ``skipped`` means this message already has an - active submission. ``result: failed`` carries ``error`` with the - stable reason (not opted in, no submissions store in this - datacenter, or the message's raw copy is no longer stored): a - refused submission is an HTTP 200 with ``failed`` inside, not an - exception. + active submission. A refusal (not opted in, no submissions store + in this datacenter, or the message's raw copy is no longer + stored) is reported like any other failed action, with the reason + in ``error``; check ``result`` as well as catching exceptions. Raises: ValueError: If ``category`` is not one of the three, or ``reason`` @@ -1494,7 +1493,8 @@ def get_submission(self, submission_id: str) -> dict[str, Any]: ``{"submission": {...}, "reviews": [{"ts": ...}]}``. ``reviews`` lists each time LimaCharlie staff opened the stored copy: a count and timestamps only, never the reviewer's identity. An unknown id - is a 404 error. + is not an error: it returns ``{"submission": None, "reviews": []}``, + so branch on ``submission`` being ``None``. """ return self._get(f"submissions/{_seg(submission_id)}") @@ -1508,9 +1508,12 @@ def withdraw_submission(self, submission_id: str) -> dict[str, Any]: submission_id: The submission id (from :meth:`list_submissions`). Returns: - ``{"withdrawn": true, "submission_id": ..., "action_id": ...}``. - An unknown, already-withdrawn or expired id is a 404 error: a - second withdrawal never deletes anything twice. + ``{"withdrawn": true, "submission_id": ..., "action_id": ...}`` + when a copy was deleted. An unknown, already-withdrawn or expired + id is not an error: it returns ``{"withdrawn": false, + "submission_id": ...}`` with no ``action_id``, so a second + withdrawal is harmless and never deletes anything twice. Check + ``withdrawn``. """ return self._delete(f"submissions/{_seg(submission_id)}") diff --git a/tests/unit/test_cli_mailsec_samples.py b/tests/unit/test_cli_mailsec_samples.py index 437cd7d5..06526f01 100644 --- a/tests/unit/test_cli_mailsec_samples.py +++ b/tests/unit/test_cli_mailsec_samples.py @@ -166,6 +166,15 @@ def test_list_limit_out_of_range_is_a_usage_error(self): assert result.exit_code == 2 request.assert_not_called() + def test_get_of_an_unknown_id_is_a_note_not_an_error(self): + result, _ = invoke( + "submission", "get", SID, returns={"submission": None, "reviews": []}, + ) + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout) == {"submission": None, "reviews": []} + assert "not found" in result.stderr + assert SID in result.stderr + def test_get(self): result, request = invoke( "submission", "get", SID, @@ -183,6 +192,17 @@ def test_withdraw(self): assert result.exit_code == 0, result.stderr assert request.call_args.args == ("DELETE", f"mailsec/{OID}/submissions/{SID}") assert json.loads(result.stdout)["withdrawn"] is True + assert result.stderr == "" + + def test_withdraw_of_an_unknown_or_already_withdrawn_id_says_so(self): + result, _ = invoke( + "submission", "withdraw", SID, + returns={"withdrawn": False, "submission_id": SID}, + ) + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout) == {"withdrawn": False, "submission_id": SID} + assert "Nothing was deleted" in result.stderr + assert "already withdrawn" in result.stderr def test_ai_help_is_registered_for_every_command(self): for path in ( diff --git a/tests/unit/test_sdk_mailsec.py b/tests/unit/test_sdk_mailsec.py index a9cb1c12..d41c453d 100644 --- a/tests/unit/test_sdk_mailsec.py +++ b/tests/unit/test_sdk_mailsec.py @@ -1067,6 +1067,12 @@ def test_get_and_withdraw_submission_routes(self, ms, mock_org): assert args[0] == "DELETE" assert args[1] == f"mailsec/{OID}/submissions/abc123" + def test_unknown_submission_is_returned_as_is_not_raised(self, ms, mock_org): + mock_org.client.request.return_value = {"submission": None, "reviews": []} + assert ms.get_submission("nope") == {"submission": None, "reviews": []} + mock_org.client.request.return_value = {"withdrawn": False, "submission_id": "nope"} + assert ms.withdraw_submission("nope") == {"withdrawn": False, "submission_id": "nope"} + def test_submission_id_is_escaped_as_one_path_segment(self, ms, mock_org): ms.withdraw_submission("a/../b") args, _ = mock_org.client.request.call_args From 18baa8210e8e8ac9c2f27247e75d73fccd50e60f Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 05:09:15 +0000 Subject: [PATCH 3/8] Clarify sample withdrawal and bounded review history --- doc/cli/email-security.md | 2 +- limacharlie/commands/mailsec.py | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index ee4f749a..584f6927 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -221,7 +221,7 @@ limacharlie mailsec submission get # includes when LimaCha limacharlie mailsec submission withdraw ``` -A refused submission (the organization has not opted in, the datacenter has no submissions store, or the message's raw copy is no longer stored) is reported like any other failed action, with the reason in `error`; the command prints the reason and exits non-zero. Submitting a message that already has an active submission returns `result: skipped` with the existing `submission_id`. `submission list` always returns `enabled` (the organization opted in) and `available` (the datacenter has a store), so an empty list can be told apart from a feature that is off. It is paginated: pass `next_cursor` back as `--cursor`, verbatim, until it is empty; `--limit` is 1 to 200. `reviews` in `submission get` is one timestamp per time LimaCharlie staff opened the copy, never the reviewer's identity. An unknown id is not an error: `submission get` returns `submission: null` and `submission withdraw` (or withdrawing an already-withdrawn or expired submission) returns `withdrawn: false`, and the command says so on stderr. +A refused submission (the organization has not opted in, the datacenter has no submissions store, or the message's raw copy is no longer stored) is reported like any other failed action, with the reason in `error`; the command prints the reason and exits non-zero. Submitting a message that already has an active submission returns `result: skipped` with the existing `submission_id`. `submission list` always returns `enabled` (the organization opted in) and `available` (the datacenter has a store), so an empty list can be told apart from a feature that is off. It is paginated: pass `next_cursor` back as `--cursor`, verbatim, until it is empty; `--limit` is 1 to 200. `reviews` in `submission get` is one timestamp per recorded staff review access, never the reviewer's identity. Access is recorded before decryption and can include failed attempts. At most 200 timestamps are returned, with `reviews_truncated` indicating a partial history; counts and latest time remain complete. An unknown id is not an error: `submission get` returns `submission: null` and `submission withdraw` (or withdrawing an already-deleted submission) returns `withdrawn: false`, and the command says so on stderr. Withdrawal remains available after opt-out, provider disconnect or message-index expiry. An expired submission is still cleaned up if metadata remains. ## Reports, analysis & rules diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index be571fd0..af8fe2a5 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -277,12 +277,14 @@ """ _EXPLAIN_SUBMISSION_GET = """\ -One submission, and who at LimaCharlie has looked at it. Requires +One submission and its recorded staff review accesses. Requires mailsec.get. -`reviews` lists each time LimaCharlie staff opened the stored copy: a -timestamp per access, never the reviewer's identity. An empty list means -nobody has opened it. An unknown id is not an error: the response is +`reviews` lists recorded staff review accesses: a timestamp per attempt, +never the reviewer's identity. Access is recorded before decryption, so +failed attempts can be included. At most 200 timestamps are returned; +reviews_truncated identifies a partial list, while the total count and +latest-review time remain complete. An unknown id is not an error: the response is {"submission": null, "reviews": []}. Examples: @@ -296,9 +298,10 @@ records the withdrawal in the audit trail. It cannot be undone: to share the message again, submit it again. -An unknown, already-withdrawn or expired id is not an error: the -response is withdrawn:false (no action_id) and the command says so. A -second withdrawal never deletes anything twice. +An unknown or already-deleted id returns withdrawn:false (no action_id), +and the command says so. An expired submission is still cleaned up if +its metadata remains. Withdrawal works after opt-out or provider disconnect. +A second withdrawal never deletes anything twice. Examples: limacharlie mailsec submission withdraw 3f1c9b7e5a2d4c8e9a0b1c2d3e4f5a6b From c3e1976b3e0cfb106468d402f2f79a7bb7b7532b Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 05:12:42 +0000 Subject: [PATCH 4/8] Align sample SDK method documentation with cleanup and review results --- limacharlie/sdk/mailsec.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 21382d2e..0ccc4d5c 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -1482,7 +1482,7 @@ def list_submissions( return self._get("submissions", pairs) def get_submission(self, submission_id: str) -> dict[str, Any]: - """One submission, and who at LimaCharlie has opened it. + """One submission and its recorded staff review accesses. Requires ``mailsec.get``. @@ -1491,8 +1491,10 @@ def get_submission(self, submission_id: str) -> dict[str, Any]: Returns: ``{"submission": {...}, "reviews": [{"ts": ...}]}``. ``reviews`` - lists each time LimaCharlie staff opened the stored copy: a count - and timestamps only, never the reviewer's identity. An unknown id + lists up to200 access-attempt timestamps, oldest first, never the reviewer's + identity. Access is recorded before decryption and can include failures. + ``reviews_truncated`` identifies a partial history; ``review_count`` and + ``last_reviewed_at`` in the submission include all accesses. An unknown id is not an error: it returns ``{"submission": None, "reviews": []}``, so branch on ``submission`` being ``None``. """ @@ -1509,8 +1511,9 @@ def withdraw_submission(self, submission_id: str) -> dict[str, Any]: Returns: ``{"withdrawn": true, "submission_id": ..., "action_id": ...}`` - when a copy was deleted. An unknown, already-withdrawn or expired - id is not an error: it returns ``{"withdrawn": false, + when a copy was deleted. Withdrawal remains available after opt-out or + provider disconnect, and expired copies are cleaned up if metadata remains. + An unknown or already-deleted id returns ``{"withdrawn": false, "submission_id": ...}`` with no ``action_id``, so a second withdrawal is harmless and never deletes anything twice. Check ``withdrawn``. From 9d13108f670cde7ea7d6a7c31d0a230b83cb377f Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 08:39:44 +0000 Subject: [PATCH 5/8] Document Owner consent and analyst opt-out for sample submission --- doc/cli/email-security.md | 6 +++++- limacharlie/commands/mailsec.py | 9 +++++++-- limacharlie/sdk/mailsec.py | 5 ++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index 584f6927..7bfa5a51 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -203,7 +203,11 @@ An organization can opt in to let its analysts copy **one message at a time** to **Submitting sends the message to LimaCharlie.** The original message (attachments included) is stored, compressed and encrypted, in a LimaCharlie-owned bucket in the same datacenter as your Email Security data, with a metadata row: the message id, your category and reason, your identity, the time, the verdict, score and matched rule ids at that time, the sender, subject, mailbox address and size. It is deleted automatically after 400 days. Only LimaCharlie staff working on detection quality can open it, through a tool that records every access; `submission get` shows you how many times and when. **Withdraw at any time**: the stored copy and its metadata are deleted. -Opt in with a `mailsec_policy` record of type `sample_sharing`: +The organization Owner can opt in with a `mailsec_policy` record of type +`sample_sharing` (`mailsec.set`, `billing.ctrl` and `user.ctrl` for the organization). +Anyone with `mailsec.set` can opt out by writing `enabled: false` on an active +record without expiry. Deleting, disabling or expiring a sharing record requires +Owner authority because an earlier enabled record can become effective. ```bash echo '{"policy_type": "sample_sharing", "enabled": true}' > opt-in.json diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index af8fe2a5..ec6a5e45 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -208,13 +208,18 @@ `mailsec submission withdraw ` deletes the copy and its metadata. -It is off by default. To opt in, save a mailsec_policy record of type -sample_sharing: +It is off by default. The organization Owner can opt in by saving a +mailsec_policy record of type sample_sharing (mailsec.set, billing.ctrl +and user.ctrl for this organization): echo '{"policy_type": "sample_sharing", "enabled": true}' > opt-in.json limacharlie hive set --hive-name mailsec_policy --key sample-sharing \ --input-file opt-in.json --enabled +Anyone with mailsec.set can opt out: write enabled:false on an active +record without expiry. Removing, disabling or expiring the record needs +Owner authority because an earlier enabled record could become effective. + Nothing is ever submitted automatically, and only a person can submit: D&R rules, automations and the AI agent are refused. One message per call. diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 0ccc4d5c..0fb0336b 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -1347,7 +1347,10 @@ def submit_sample( """Copy ONE message to LimaCharlie to help improve detection. Requires ``mailsec.act`` and an organization that has opted in - (``mailsec_policy`` record of type ``sample_sharing``). This sends + (``mailsec_policy`` record of type ``sample_sharing``). Enabling + sharing requires the organization Owner (``mailsec.set``, + ``billing.ctrl`` and ``user.ctrl``); anyone with ``mailsec.set`` can + turn it off with an active, nonexpiring ``enabled: false`` record. This sends the message's original bytes (attachments included) and the metadata listed below to a LimaCharlie-owned store in the organization's own datacenter. It is explicit, one message per call, and never done From ff01709437b16e01ddc79b332c2d0d95f3fd5d0e Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 14:51:18 +0000 Subject: [PATCH 6/8] Describe submitted-message access in general terms --- doc/cli/email-security.md | 6 +++--- doc/sdk/security-products.md | 2 +- limacharlie/commands/mailsec.py | 19 +++++++++---------- limacharlie/sdk/mailsec.py | 7 +++---- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index 7bfa5a51..2755bdda 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -201,7 +201,7 @@ A sweep's `--reason` lands on the sweep's own record and on every member's audit An organization can opt in to let its analysts copy **one message at a time** to LimaCharlie so detection can improve. It is off by default, never automatic, and only a person can do it: D&R rules, automations and the AI agent are refused. -**Submitting sends the message to LimaCharlie.** The original message (attachments included) is stored, compressed and encrypted, in a LimaCharlie-owned bucket in the same datacenter as your Email Security data, with a metadata row: the message id, your category and reason, your identity, the time, the verdict, score and matched rule ids at that time, the sender, subject, mailbox address and size. It is deleted automatically after 400 days. Only LimaCharlie staff working on detection quality can open it, through a tool that records every access; `submission get` shows you how many times and when. **Withdraw at any time**: the stored copy and its metadata are deleted. +**Submitting sends the message to LimaCharlie.** The original message (attachments included) is stored, compressed and encrypted, in a LimaCharlie-owned bucket in the same datacenter as your Email Security data, with a metadata row: the message id, your category and reason, your identity, the time, the verdict, score and matched rule ids at that time, the sender, subject, mailbox address and size. It is deleted automatically after 400 days. Submitted messages are used by LimaCharlie to improve detection. Access is restricted and every access is recorded; `submission get` shows you how many times and when. **Withdraw at any time**: the stored copy and its metadata are deleted. The organization Owner can opt in with a `mailsec_policy` record of type `sample_sharing` (`mailsec.set`, `billing.ctrl` and `user.ctrl` for the organization). @@ -221,11 +221,11 @@ limacharlie mailsec message submit-sample --category missed_threat -- limacharlie mailsec message withdraw-sample limacharlie mailsec submission list limacharlie mailsec submission list --category false_positive --since 2026-09-01T00:00:00Z --limit 100 -limacharlie mailsec submission get # includes when LimaCharlie staff opened it +limacharlie mailsec submission get # includes when LimaCharlie accessed it limacharlie mailsec submission withdraw ``` -A refused submission (the organization has not opted in, the datacenter has no submissions store, or the message's raw copy is no longer stored) is reported like any other failed action, with the reason in `error`; the command prints the reason and exits non-zero. Submitting a message that already has an active submission returns `result: skipped` with the existing `submission_id`. `submission list` always returns `enabled` (the organization opted in) and `available` (the datacenter has a store), so an empty list can be told apart from a feature that is off. It is paginated: pass `next_cursor` back as `--cursor`, verbatim, until it is empty; `--limit` is 1 to 200. `reviews` in `submission get` is one timestamp per recorded staff review access, never the reviewer's identity. Access is recorded before decryption and can include failed attempts. At most 200 timestamps are returned, with `reviews_truncated` indicating a partial history; counts and latest time remain complete. An unknown id is not an error: `submission get` returns `submission: null` and `submission withdraw` (or withdrawing an already-deleted submission) returns `withdrawn: false`, and the command says so on stderr. Withdrawal remains available after opt-out, provider disconnect or message-index expiry. An expired submission is still cleaned up if metadata remains. +A refused submission (the organization has not opted in, the datacenter has no submissions store, or the message's raw copy is no longer stored) is reported like any other failed action, with the reason in `error`; the command prints the reason and exits non-zero. Submitting a message that already has an active submission returns `result: skipped` with the existing `submission_id`. `submission list` always returns `enabled` (the organization opted in) and `available` (the datacenter has a store), so an empty list can be told apart from a feature that is off. It is paginated: pass `next_cursor` back as `--cursor`, verbatim, until it is empty; `--limit` is 1 to 200. `reviews` in `submission get` is one timestamp per recorded access, never the accessing identity. Access is recorded before decryption and can include failed attempts. At most 200 timestamps are returned, with `reviews_truncated` indicating a partial history; counts and latest time remain complete. An unknown id is not an error: `submission get` returns `submission: null` and `submission withdraw` (or withdrawing an already-deleted submission) returns `withdrawn: false`, and the command says so on stderr. Withdrawal remains available after opt-out, provider disconnect or message-index expiry. An expired submission is still cleaned up if metadata remains. ## Reports, analysis & rules diff --git a/doc/sdk/security-products.md b/doc/sdk/security-products.md index e877a71a..2b4a065d 100644 --- a/doc/sdk/security-products.md +++ b/doc/sdk/security-products.md @@ -87,7 +87,7 @@ print(mail.analyze(eml_b64=encoded, org_domains=["corp.example"])) `mailsec.get` permits structured reads, `mailsec.set` changes triage and rules, and `mailsec.act` remediates provider mail, revises verdicts, and tests connections. Original-byte downloads require both `mailsec.get` and `mailsec.get.eml`. Provider records use `mailsec_provider.*` and credentials use `secret.*`; policy and `dr-mail` Hives reuse `mailsec.get/set`. -An organization that has opted in (a `mailsec_policy` record of type `sample_sharing`) can copy one message at a time to LimaCharlie to improve detection. `submit_sample(msg_uuid, category, reason)` needs `mailsec.act`, sends the original message to LimaCharlie (deleted after 400 days, or on `withdraw_sample(msg_uuid)` / `withdraw_submission(submission_id)`), and raises `ValueError` for an unknown category (`missed_threat`, `false_positive`, `other`) or a reason that is blank or over 1024 characters. A refusal comes back as `result: "failed"` with `error`, not as an exception. `list_submissions()` (paginated, always returns `enabled` and `available`) and `get_submission()` (includes `reviews`, one timestamp per time LimaCharlie staff opened the copy) need `mailsec.get`. +An organization that has opted in (a `mailsec_policy` record of type `sample_sharing`) can copy one message at a time to LimaCharlie to improve detection. `submit_sample(msg_uuid, category, reason)` needs `mailsec.act`, sends the original message to LimaCharlie (deleted after 400 days, or on `withdraw_sample(msg_uuid)` / `withdraw_submission(submission_id)`), and raises `ValueError` for an unknown category (`missed_threat`, `false_positive`, `other`) or a reason that is blank or over 1024 characters. A refusal comes back as `result: "failed"` with `error`, not as an exception. `list_submissions()` (paginated, always returns `enabled` and `available`) and `get_submission()` (includes `reviews`, one timestamp per recorded access to the copy) need `mailsec.get`. Start with `alert_only`. Manual provider actions need an explicit `force=True` override in that mode; inspect the action and audit outcome. Bulk and campaign actions use preview and confirmation so the executed selection matches what was reviewed. See the [Email Security CLI reference](../cli/email-security.md) for these workflows, verdict revisions, campaigns, user reports, and offboarding. diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index ec6a5e45..b520a622 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -199,9 +199,8 @@ store in the same datacenter as your Email Security data, together with the category and reason you give, your identity, the time, the verdict, score and matched rule ids, the sender, subject, mailbox address and -size. It is kept for 400 days and then deleted automatically. Only -LimaCharlie staff working on detection quality can open it, through a -tool that records every access; `mailsec submission get` shows how many +size. It is kept for 400 days and then deleted automatically. Submitted messages are used by LimaCharlie to improve detection. +Access is restricted and every access is recorded; `mailsec submission get` shows how many times and when. No other customer can see it. WITHDRAW AT ANY TIME: `mailsec message withdraw-sample ` or @@ -270,7 +269,7 @@ Each submission shows the category and reason, who submitted it and when, when it expires (400 days after submission), the verdict, score and matched rules at the time, and review_count / last_reviewed_at: how -often LimaCharlie staff have opened the stored copy. +often the submission has been accessed the stored copy. --limit is 1 to 200 (default 50). Pass next_cursor back as --cursor, verbatim, to read the next page; an empty next_cursor means the last. @@ -282,14 +281,14 @@ """ _EXPLAIN_SUBMISSION_GET = """\ -One submission and its recorded staff review accesses. Requires +One submission and its recorded accesses. Requires mailsec.get. -`reviews` lists recorded staff review accesses: a timestamp per attempt, +`reviews` lists recorded accesses: a timestamp per attempt, never the reviewer's identity. Access is recorded before decryption, so failed attempts can be included. At most 200 timestamps are returned; reviews_truncated identifies a partial list, while the total count and -latest-review time remain complete. An unknown id is not an error: the response is +latest access time remain complete. An unknown id is not an error: the response is {"submission": null, "reviews": []}. Examples: @@ -1525,8 +1524,8 @@ def message_submit_sample(ctx, msg_uuid, category, reason, attempt) -> None: THIS SENDS THE MESSAGE TO LIMACHARLIE: the original message (attachments included), your reason and identity, and the verdict snapshot are stored, encrypted, in a LimaCharlie-owned store in your - datacenter for 400 days. Only LimaCharlie staff working on detection - quality can open it, and every access is recorded and shown in + datacenter for 400 days. Submitted messages are used by LimaCharlie to + improve detection. Access is restricted and every access is recorded in `mailsec submission get`. The organization must have opted in. Withdraw at any time with `mailsec message withdraw-sample` or `mailsec submission withdraw`, which deletes the copy. @@ -1904,7 +1903,7 @@ def submission_list(ctx, category, since, until, limit, cursor) -> None: @click.argument("submission_id") @pass_context def submission_get(ctx, submission_id) -> None: - """One submission and when LimaCharlie staff opened it (mailsec.get). + """One submission and when LimaCharlie accessed it (mailsec.get). \b An unknown id prints {"submission": null} and a note; it is not an error. diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 0fb0336b..ae8fa694 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -1361,9 +1361,8 @@ def submit_sample( a row with the message id, your category and reason, your identity, the time, the verdict, score and matched rule ids at that time, the sender, subject, mailbox address and size. Retention is 400 days, then - deleted automatically. Only LimaCharlie staff working on detection - quality can open a submission, through a tool that records every - access; :meth:`get_submission` shows how many times and when. Withdraw + deleted automatically. Submitted messages are used by LimaCharlie to improve detection. + Access is restricted and every access is recorded; :meth:`get_submission` shows how many times and when. Withdraw at any time with :meth:`withdraw_sample` or :meth:`withdraw_submission`: the copy and its metadata are deleted. @@ -1485,7 +1484,7 @@ def list_submissions( return self._get("submissions", pairs) def get_submission(self, submission_id: str) -> dict[str, Any]: - """One submission and its recorded staff review accesses. + """One submission and its recorded accesses. Requires ``mailsec.get``. From f22dd9625cc0d055ffd16dda6021d58260d2f0bf Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 15:31:45 +0000 Subject: [PATCH 7/8] Clarify submission access timestamps in CLI help --- limacharlie/commands/mailsec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index b520a622..4c59cab6 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -269,7 +269,7 @@ Each submission shows the category and reason, who submitted it and when, when it expires (400 days after submission), the verdict, score and matched rules at the time, and review_count / last_reviewed_at: how -often the submission has been accessed the stored copy. +often the stored copy has been accessed and when. --limit is 1 to 200 (default 50). Pass next_cursor back as --cursor, verbatim, to read the next page; an empty next_cursor means the last. From f636ed4f7877a73a082eb1d81f8577491b91ee95 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 1 Oct 2026 23:21:43 +0000 Subject: [PATCH 8/8] Describe sample access neutrally Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- limacharlie/commands/mailsec.py | 2 +- limacharlie/sdk/mailsec.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3311d1f2..2a27f7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ (a `mailsec_policy` record of type `sample_sharing`) can copy one message at a time to LimaCharlie to help improve detection; the copy is deleted after 400 days or as soon as it is withdrawn, and `submission get` shows when - LimaCharlie staff opened it. Category and reason are checked locally, a + the stored copy was accessed. Category and reason are checked locally, a refused submission exits non-zero, and `Mailsec.act_on_message` refuses `submit_sample` so the category and reason cannot be skipped. Needs an API release that serves the new routes. diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index 4c59cab6..3297c184 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -285,7 +285,7 @@ mailsec.get. `reviews` lists recorded accesses: a timestamp per attempt, -never the reviewer's identity. Access is recorded before decryption, so +never who accessed it. Access is recorded before decryption, so failed attempts can be included. At most 200 timestamps are returned; reviews_truncated identifies a partial list, while the total count and latest access time remain complete. An unknown id is not an error: the response is diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index ae8fa694..31771af1 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -196,7 +196,7 @@ def _check_sample_reason(reason: Any, *, required: bool) -> str: if required: raise ValueError( "a sample submission needs a reason: it is kept with the submission " - "so the person reviewing it knows why you sent it" + "to record why you sent it" ) return "" if not isinstance(reason, str): @@ -205,7 +205,7 @@ def _check_sample_reason(reason: Any, *, required: bool) -> str: if required and not text: raise ValueError( "a sample submission needs a reason: it is kept with the submission " - "so the person reviewing it knows why you sent it" + "to record why you sent it" ) if len(text) > _MAX_SAMPLE_REASON_LEN: raise ValueError( @@ -1493,8 +1493,8 @@ def get_submission(self, submission_id: str) -> dict[str, Any]: Returns: ``{"submission": {...}, "reviews": [{"ts": ...}]}``. ``reviews`` - lists up to200 access-attempt timestamps, oldest first, never the reviewer's - identity. Access is recorded before decryption and can include failures. + lists up to 200 access-attempt timestamps, oldest first, never who + accessed it. Access is recorded before decryption and can include failures. ``reviews_truncated`` identifies a partial history; ``review_count`` and ``last_reviewed_at`` in the submission include all accesses. An unknown id is not an error: it returns ``{"submission": None, "reviews": []}``,