diff --git a/doc/cli/email-security.md b/doc/cli/email-security.md index 1642ee0b..f6e55a07 100644 --- a/doc/cli/email-security.md +++ b/doc/cli/email-security.md @@ -275,3 +275,26 @@ initial decision and completion instants, plus integer millisecond intervals: An absent interval is unknown; a measured zero is zero. `clock_skew: true` reports that a negative interval was clamped to zero. The sender's Date header is not used to calculate these intervals. + +## Microsoft provider quarantine visibility + +```bash +limacharlie mailsec provider-quarantine list --status quarantined --output yaml +limacharlie mailsec release-request list --status requested --output yaml +``` + +Both commands require `mailsec.get` and return `coverage` alongside rows and an +opaque `next_cursor`. Keep filters unchanged when passing `--cursor`. Both accept +`--connection`, `--since`, `--until`, and `--limit` (1–1000). + +Provider delivery statuses are `quarantined`, `filteredAsSpam`, and `failed`. +A failed delivery is not a quarantine verdict. Release activity statuses are +`requested`, `released`, and `denied`. These commands observe Microsoft activity; +they do not approve or perform release. + +Optional Microsoft application permissions are `ExchangeMessageTrace.Read.All` +in Microsoft Graph and `ActivityFeed.Read` in Office 365 Management APIs, with +admin consent. Message trace also requires Microsoft's documented Exchange +service-principal prerequisite; activity requires unified audit logging. Missing +grants report `not_granted`. Pending, stale or error coverage must be checked +before treating an empty list as evidence that no messages were blocked. diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index 13334a3e..ce6de08b 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -1775,3 +1775,53 @@ def tenant_purge(ctx, confirm, reason) -> None: register_explain("mailsec.rule.backtest", _EXPLAIN_RULE_BACKTEST) register_explain("mailsec.connection.test", _EXPLAIN_CONNECTION_TEST) register_explain("mailsec.tenant.purge", _EXPLAIN_TENANT_PURGE) + + +@group.group("provider-quarantine") +def provider_quarantine_group() -> None: + """Microsoft provider delivery filtering and quarantine observations.""" + + +@provider_quarantine_group.command("list") +@click.option("--connection", default=None, help="Connection record name.") +@click.option("--status", type=click.Choice(["quarantined", "filteredAsSpam", "failed"]), default=None) +@click.option("--since", default=None, help="Inclusive timestamp, RFC3339 or Unix seconds.") +@click.option("--until", default=None, help="Exclusive timestamp, RFC3339 or Unix seconds.") +@click.option("--cursor", default=None, help="Opaque cursor; keep filters unchanged.") +@click.option("--limit", type=click.IntRange(1, 1000), default=None) +@pass_context +def provider_quarantine_list(ctx, connection, status, since, until, cursor, limit) -> None: + """List provider observations with independent permission and freshness coverage. + + Failed means delivery failed, not quarantined. Unavailable coverage is not + evidence that Microsoft blocked no messages. + """ + _output(ctx, _get_mailsec(ctx).list_provider_quarantine( + connection=connection, status=status, since=since, until=until, + cursor=cursor, limit=limit, + )) + + +@group.group("release-request") +def release_request_group() -> None: + """Microsoft hosted-quarantine release requests and release activity.""" + + +@release_request_group.command("list") +@click.option("--connection", default=None, help="Connection record name.") +@click.option("--status", type=click.Choice(["requested", "released", "denied"]), default=None) +@click.option("--since", default=None, help="Inclusive timestamp, RFC3339 or Unix seconds.") +@click.option("--until", default=None, help="Exclusive timestamp, RFC3339 or Unix seconds.") +@click.option("--cursor", default=None, help="Opaque cursor; keep filters unchanged.") +@click.option("--limit", type=click.IntRange(1, 1000), default=None) +@pass_context +def release_request_list(ctx, connection, status, since, until, cursor, limit) -> None: + """Observe release requests and releases/denials; this command approves nothing.""" + _output(ctx, _get_mailsec(ctx).list_release_requests( + connection=connection, status=status, since=since, until=until, + cursor=cursor, limit=limit, + )) + + +register_explain("mailsec.provider-quarantine.list", "Microsoft provider delivery observations. Inspect coverage before interpreting an empty list. Requires optional ExchangeMessageTrace.Read.All consent.") +register_explain("mailsec.release-request.list", "Microsoft hosted-quarantine release activity. This command observes requests and never approves a release. Requires optional ActivityFeed.Read consent and unified audit logging.") diff --git a/limacharlie/discovery.py b/limacharlie/discovery.py index bccd478d..a8e29fb4 100644 --- a/limacharlie/discovery.py +++ b/limacharlie/discovery.py @@ -264,6 +264,7 @@ "description": "Email security: provider onboarding, mailbox coverage, mail triage, verdicts, campaigns, remediation, and reports", "commands": [ "mailsec coverage", + "mailsec provider-quarantine list", "mailsec release-request list", "mailsec message list", "mailsec message get", "mailsec message eml", "mailsec message similar", "mailsec message action", "mailsec message revise", "mailsec message revisions", diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index e9e1afeb..1e6281a7 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -1045,6 +1045,59 @@ def analyze( # Abuse-mailbox report queue # ------------------------------------------------------------------ + def list_provider_quarantine( + self, *, connection: str | None = None, status: str | None = None, + since: str | int | None = None, until: str | int | None = None, + cursor: str | None = None, limit: int | None = None, + ) -> dict[str, Any]: + """List Microsoft provider delivery observations and coverage. + + Args: + connection: Optional connection record name. + status: quarantined, filteredAsSpam or failed (case sensitive). + since: Inclusive provider timestamp, RFC3339 or Unix seconds. + until: Exclusive provider timestamp, RFC3339 or Unix seconds. + cursor: Opaque cursor; keep filters unchanged between pages. + limit: Page size, maximum 1000. + + Returns: + dict: provider_quarantine, next_cursor and coverage. Failed means + delivery failed, not hosted quarantine. An empty list with + unavailable coverage does not establish that nothing was blocked. + """ + pairs: list[tuple[str, str]] = [] + for key, val in (("connection", connection), ("status", status), + ("since", since), ("until", until), + ("cursor", cursor), ("limit", limit)): + _add_scalar(pairs, key, val) + return self._get("provider-quarantine", pairs) + + def list_release_requests( + self, *, connection: str | None = None, status: str | None = None, + since: str | int | None = None, until: str | int | None = None, + cursor: str | None = None, limit: int | None = None, + ) -> dict[str, Any]: + """List Microsoft quarantine release activity and coverage. + + Args: + connection: Optional connection record name. + status: requested, released or denied (lowercase). + since: Inclusive provider timestamp, RFC3339 or Unix seconds. + until: Exclusive provider timestamp, RFC3339 or Unix seconds. + cursor: Opaque cursor; keep filters unchanged between pages. + limit: Page size, maximum 1000. + + Returns: + dict: release_requests, next_cursor and coverage. This method + observes requests and release/denial history; it approves nothing. + """ + pairs: list[tuple[str, str]] = [] + for key, val in (("connection", connection), ("status", status), + ("since", since), ("until", until), + ("cursor", cursor), ("limit", limit)): + _add_scalar(pairs, key, val) + return self._get("release-requests", pairs) + def list_reports( self, *, diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 5fa58a1b..1ca9841f 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -193,7 +193,7 @@ "logging": frozenset({"create", "delete", "get", "list"}), "mailsec": frozenset({ "coverage", "analyze", "onboarding", "message", "campaign", "sender", - "action", "report", "rule", "banner", "connection", "tenant", + "action", "report", "rule", "banner", "connection", "tenant", "provider-quarantine", "release-request", }), "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.py b/tests/unit/test_cli_mailsec.py index e4b95528..dcfb477c 100644 --- a/tests/unit/test_cli_mailsec.py +++ b/tests/unit/test_cli_mailsec.py @@ -380,3 +380,27 @@ def test_force_help_uses_the_agreed_wording(self): result = CliRunner().invoke(cli, ["mailsec", *path, "--help"], terminal_width=10000) assert result.exit_code == 0, result.output assert expected in " ".join(result.output.split()), path + + +def test_provider_visibility_commands_preserve_coverage_and_validate_status(): + for command, method, status in [ + ("provider-quarantine", "list_provider_quarantine", "failed"), + ("release-request", "list_release_requests", "requested"), + ]: + with ( + patch("limacharlie.commands.mailsec.Client"), + patch("limacharlie.commands.mailsec.Organization"), + patch("limacharlie.commands.mailsec.Mailsec") as factory, + ): + api = MagicMock() + factory.return_value = api + getattr(api, method).return_value = {"coverage": [{"state": "not_granted"}], "next_cursor": "opaque"} + args = ["--oid", "11111111-2222-3333-4444-555555555555", "--output", "json", "mailsec", command, "list"] + result = CliRunner().invoke(cli, args + ["--status", status, "--connection", "m365", "--cursor", "opaque+/=", "--limit", "25"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["coverage"][0]["state"] == "not_granted" + getattr(api, method).assert_called_once_with(connection="m365", status=status, since=None, until=None, cursor="opaque+/=", limit=25) + result = CliRunner().invoke(cli, args + ["--status", "unknown"]) + assert result.exit_code == 2 + result = CliRunner().invoke(cli, args + ["--limit", "1001"]) + assert result.exit_code == 2 diff --git a/tests/unit/test_sdk_mailsec.py b/tests/unit/test_sdk_mailsec.py index eff4bde4..de85872b 100644 --- a/tests/unit/test_sdk_mailsec.py +++ b/tests/unit/test_sdk_mailsec.py @@ -844,3 +844,17 @@ def test_the_bound_itself_is_accepted(self, ms, mock_org): ms.purge_tenant("tok", reason="x" * 1024) _, kwargs = mock_org.client.request.call_args assert ("reason", "x" * 1024) in kwargs["query_params"] + +class TestProviderVisibility: + @pytest.mark.parametrize("method,path,status", [ + ("list_provider_quarantine", "provider-quarantine", "filteredAsSpam"), + ("list_release_requests", "release-requests", "denied"), + ]) + def test_tenant_filters_and_opaque_cursor_reach_the_transport(self, ms, mock_org, method, path, status): + response = {path.replace("-", "_"): [], "coverage": [{"state": "not_granted"}], "next_cursor": "next"} + mock_org.client.request.return_value = response + result = getattr(ms, method)(connection="m365", status=status, since="2026-09-01T00:00:00Z", until="2026-09-02T00:00:00Z", cursor="opaque+/=", limit=25) + url, pairs = _get_call(mock_org) + assert url == f"mailsec/{OID}/{path}" + assert dict(pairs) == {"connection": "m365", "status": status, "since": "2026-09-01T00:00:00Z", "until": "2026-09-02T00:00:00Z", "cursor": "opaque+/=", "limit": "25"} + assert result == response