diff --git a/CHANGELOG.md b/CHANGELOG.md index 6049eaac..2a27f7b2 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_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 + 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. + ### 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..2755bdda 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,36 @@ 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. 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). +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 +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`. + +```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 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 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 ```bash diff --git a/doc/sdk/security-products.md b/doc/sdk/security-products.md index dbc39c3c..2b4a065d 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_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. ## Pagination and filters diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index d87e2473..3297c184 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,127 @@ 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. 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 +`mailsec submission withdraw ` deletes the copy and its +metadata. + +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. + +--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 (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" + 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_sharing) + 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 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. + +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 its recorded accesses. Requires +mailsec.get. + +`reviews` lists recorded accesses: a timestamp per attempt, +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 +{"submission": null, "reviews": []}. + +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 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 +""" + _EXPLAIN_MESSAGE_BULK_ACTION = """\ Remediate a set of messages you name, in bulk. Requires mailsec.act. @@ -605,6 +726,46 @@ 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) 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 + 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_sharing (see " + "`limacharlie mailsec message submit-sample --ai-help`).") + + def _get_mailsec(ctx: click.Context) -> Mailsec: client = Client( oid=ctx.obj.oid, @@ -909,6 +1070,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 +1202,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 +1220,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 +1508,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. 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. + + \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 +1868,79 @@ 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 accessed 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 + """ + 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") +@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. + 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 + """ + 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).") + + # --------------------------------------------------------------------------- # Reports # --------------------------------------------------------------------------- @@ -1910,6 +2211,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 +2220,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..31771af1 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 " + "to record 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 " + "to record 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,205 @@ 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_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 + # 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_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 + 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. 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. + + 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. 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`` + 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 its recorded accesses. + + Requires ``mailsec.get``. + + Args: + submission_id: The submission id (from :meth:`list_submissions`). + + Returns: + ``{"submission": {...}, "reviews": [{"ts": ...}]}``. ``reviews`` + 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": []}``, + so branch on ``submission`` being ``None``. + """ + 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": ...}`` + 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``. + """ + 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..06526f01 --- /dev/null +++ b/tests/unit/test_cli_mailsec_samples.py @@ -0,0 +1,214 @@ +"""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_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, + 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 + 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 ( + ("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..d41c453d 100644 --- a/tests/unit/test_sdk_mailsec.py +++ b/tests/unit/test_sdk_mailsec.py @@ -945,3 +945,135 @@ 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_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 + assert args[1] == f"mailsec/{OID}/submissions/a%2F..%2Fb"