diff --git a/.env.example b/.env.example index 7d25398..5055327 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,7 @@ ANGLERFISH_APP_CREDENTIAL_MODE="auto" # ANGLERFISH_MONITOR_STATE_FILE="/path/to/monitor-state.json" # default: ~/.anglerfish/monitor-state.json # ANGLERFISH_MONITOR_ALERT_LOG="/path/to/alerts.jsonl" # JSONL alert log # ANGLERFISH_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/" +# ANGLERFISH_TEAMS_WEBHOOK_URL="https://outlook.office.com/webhook/" # ANGLERFISH_MONITOR_NO_CONSOLE="true" # suppress console alert output (daemon mode) # Optional custom template directory (YAML templates, see CONTRIBUTING.md): diff --git a/README.md b/README.md index f6e1b44..d836021 100644 --- a/README.md +++ b/README.md @@ -285,19 +285,21 @@ anglerfish monitor --records-dir ~/.anglerfish/records anglerfish monitor --once --records-dir ~/.anglerfish/records anglerfish monitor --records-dir ~/.anglerfish/records \ --alert-log ~/.anglerfish/alerts.jsonl \ - --slack-webhook-url https://hooks.slack.com/services/... + --slack-webhook-url https://hooks.slack.com/services/... \ + --teams-webhook-url https://outlook.office.com/webhook/... ``` The monitor flags can also be set via environment variables (`ANGLERFISH_MONITOR_STATE_FILE`, `ANGLERFISH_MONITOR_ALERT_LOG`, -`ANGLERFISH_SLACK_WEBHOOK_URL`, `ANGLERFISH_MONITOR_NO_CONSOLE`); CLI flags -take precedence. See `.env.example` for the full variable set. +`ANGLERFISH_SLACK_WEBHOOK_URL`, `ANGLERFISH_TEAMS_WEBHOOK_URL`, +`ANGLERFISH_MONITOR_NO_CONSOLE`); CLI flags take precedence. See +`.env.example` for the full variable set. Unified Audit Log polling is delayed, not an immediate stream. Microsoft does not guarantee a return time for audit records; core service records are typically available after 60 to 90 minutes. See [Microsoft audit search guidance](https://learn.microsoft.com/en-us/purview/audit-search). To keep the poll loop running unattended, [`examples/anglerfish-monitor.service`](examples/anglerfish-monitor.service) is an optional sample systemd unit that supervises `anglerfish monitor --no-console`. It is a convenience example for operators, not a built-in daemon mode. -The no third-party data plane claim applies to detection. Optional Slack alerting sends post-detection notifications to the configured webhook. +The no third-party data plane claim applies to detection. Optional Slack and Teams alerting send post-detection notifications to the configured webhooks. Suppress known-good actors: diff --git a/docs/architecture.md b/docs/architecture.md index da1c26f..c2c07c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,7 +74,7 @@ Outlook draft records store a per-deployment `canary_id`, hidden-folder metadata - `cleanup ` - `demo-access ` - `list [--records-dir DIR]` -- `monitor [--once] [--interval N] [--exclude-app-id ID] [--alert-log PATH] [--slack-webhook-url URL]` +- `monitor [--once] [--interval N] [--exclude-app-id ID] [--alert-log PATH] [--slack-webhook-url URL] [--teams-webhook-url URL]` - `verify [RECORD] [--records-dir DIR]` Operational note: diff --git a/src/anglerfish/alerts.py b/src/anglerfish/alerts.py index 805e5b9..f117909 100644 --- a/src/anglerfish/alerts.py +++ b/src/anglerfish/alerts.py @@ -34,10 +34,12 @@ def __init__( console: Console | None = None, alert_log: str | Path | None = None, slack_webhook_url: str | None = None, + teams_webhook_url: str | None = None, ): self._console = console self._alert_log = Path(alert_log) if alert_log else None self._slack_webhook_url = slack_webhook_url + self._teams_webhook_url = teams_webhook_url def dispatch(self, alert: CanaryAlert) -> None: """Send an alert to all configured channels.""" @@ -59,6 +61,12 @@ def dispatch(self, alert: CanaryAlert) -> None: except Exception: logger.warning("Slack alert POST failed", exc_info=True) + if self._teams_webhook_url is not None: + try: + _post_teams(self._teams_webhook_url, alert) + except Exception: + logger.warning("Teams alert POST failed", exc_info=True) + # ------------------------------------------------------------------ # Console channel @@ -160,3 +168,44 @@ def _post_slack(url: str, alert: CanaryAlert) -> None: if not resp.ok: # Do not log the webhook URL itself; it is a bearer secret. logger.warning("Slack POST returned HTTP %d", resp.status_code) + + +# ------------------------------------------------------------------ +# Microsoft Teams channel +# ------------------------------------------------------------------ + + +def _post_teams(url: str, alert: CanaryAlert) -> None: + """POST a MessageCard alert to a Microsoft Teams incoming webhook.""" + if urlsplit(url).scheme != "https": + logger.warning("Teams webhook URL is not https; skipping Teams alert") + return + facts = [ + {"name": "Type", "value": alert.canary_type}, + {"name": "Canary", "value": alert.template_name}, + {"name": "Operation", "value": alert.operation}, + {"name": "Accessed by", "value": alert.accessed_by}, + {"name": "Source IP", "value": alert.source_ip}, + {"name": "Timestamp", "value": alert.timestamp}, + {"name": "Artifact", "value": alert.artifact_label}, + {"name": "Record", "value": alert.record_path}, + ] + if alert.client_info: + facts.append({"name": "Client", "value": alert.client_info}) + payload = { + "@type": "MessageCard", + "@context": "https://schema.org/extensions", + "summary": f"Canary Alert: {alert.canary_type} canary accessed", + "themeColor": "C4314B", + "title": "Canary access detected", + "sections": [ + { + "activityTitle": f"{alert.template_name} accessed by {alert.accessed_by}", + "facts": facts, + "markdown": True, + } + ], + } + resp = requests.post(url, json=payload, timeout=10, allow_redirects=False) + if not resp.ok: + logger.warning("Teams POST returned HTTP %d", resp.status_code) diff --git a/src/anglerfish/cli/_main.py b/src/anglerfish/cli/_main.py index 5ef4d01..85833f5 100644 --- a/src/anglerfish/cli/_main.py +++ b/src/anglerfish/cli/_main.py @@ -363,6 +363,13 @@ def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: dest="slack_webhook_url", help="Slack incoming webhook URL for alert notifications.", ) + monitor_parser.add_argument( + "--teams-webhook-url", + default=None, + metavar="URL", + dest="teams_webhook_url", + help="Microsoft Teams incoming webhook URL for alert notifications.", + ) monitor_parser.add_argument( "--no-console", action="store_true", diff --git a/src/anglerfish/cli/monitor.py b/src/anglerfish/cli/monitor.py index 0da4a23..0d5acf6 100644 --- a/src/anglerfish/cli/monitor.py +++ b/src/anglerfish/cli/monitor.py @@ -44,6 +44,7 @@ def _run_monitor(args: argparse.Namespace, console: Console) -> int: MONITOR_NO_CONSOLE, MONITOR_SLACK_WEBHOOK, MONITOR_STATE_FILE, + MONITOR_TEAMS_WEBHOOK, TENANT_ID, ) from ..monitor import CanaryIndex, _TokenManager, load_records, render_demo_alert, run_monitor @@ -102,10 +103,12 @@ def _run_monitor(args: argparse.Namespace, console: Console) -> int: no_console = args.no_console or MONITOR_NO_CONSOLE alert_log = args.alert_log or MONITOR_ALERT_LOG or None slack_webhook = getattr(args, "slack_webhook_url", None) or MONITOR_SLACK_WEBHOOK or None + teams_webhook = getattr(args, "teams_webhook_url", None) or MONITOR_TEAMS_WEBHOOK or None dispatcher = AlertDispatcher( console=None if no_console else console, alert_log=alert_log, slack_webhook_url=slack_webhook, + teams_webhook_url=teams_webhook, ) # Token manager for automatic refresh. diff --git a/src/anglerfish/config.py b/src/anglerfish/config.py index 585dbfd..2470d8b 100644 --- a/src/anglerfish/config.py +++ b/src/anglerfish/config.py @@ -26,6 +26,7 @@ MONITOR_STATE_FILE = os.environ.get("ANGLERFISH_MONITOR_STATE_FILE", "").strip() MONITOR_ALERT_LOG = os.environ.get("ANGLERFISH_MONITOR_ALERT_LOG", "").strip() MONITOR_SLACK_WEBHOOK = os.environ.get("ANGLERFISH_SLACK_WEBHOOK_URL", "").strip() +MONITOR_TEAMS_WEBHOOK = os.environ.get("ANGLERFISH_TEAMS_WEBHOOK_URL", "").strip() MONITOR_NO_CONSOLE = os.environ.get("ANGLERFISH_MONITOR_NO_CONSOLE", "").strip().lower() in ( "1", "true", diff --git a/tests/test_alerts.py b/tests/test_alerts.py index a3b34cc..1d11c11 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -99,11 +99,12 @@ def test_dispatch_all_channels(tmp_path): console=console, alert_log=log_path, slack_webhook_url="https://hooks.slack.com/services/T/B/xxx", + teams_webhook_url="https://outlook.office.com/webhook/xxx", ) dispatcher.dispatch(_sample_alert()) assert log_path.is_file() - mock_post.assert_called_once() + assert mock_post.call_count == 2 def test_console_failure_does_not_block_jsonl(tmp_path): @@ -174,3 +175,46 @@ def test_dispatch_slack_non_ok_response_does_not_block_other_channels(tmp_path): dispatcher = AlertDispatcher(alert_log=log_path, slack_webhook_url="https://hooks.slack.com/services/x") dispatcher.dispatch(_sample_alert()) # must not raise on a non-2xx Slack response assert log_path.is_file() # JSONL channel still wrote + + +# ------------------------------------------------------------------ +# Microsoft Teams channel +# ------------------------------------------------------------------ + + +def test_dispatch_teams_posts_message_card(): + with patch("anglerfish.alerts.requests.post") as mock_post: + mock_post.return_value.ok = True + dispatcher = AlertDispatcher(teams_webhook_url="https://outlook.office.com/webhook/xxx") + dispatcher.dispatch(_sample_alert()) + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + payload = call_kwargs[1]["json"] + assert payload["@type"] == "MessageCard" + assert payload["title"] == "Canary access detected" + assert call_kwargs[1]["timeout"] == 10 + + +def test_dispatch_teams_failure_does_not_raise(): + with patch("anglerfish.alerts.requests.post") as mock_post: + mock_post.side_effect = ConnectionError("network down") + dispatcher = AlertDispatcher(teams_webhook_url="https://outlook.office.com/webhook/xxx") + dispatcher.dispatch(_sample_alert()) + + +def test_dispatch_teams_skips_non_https_url(): + with patch("anglerfish.alerts.requests.post") as mock_post: + dispatcher = AlertDispatcher(teams_webhook_url="http://insecure.example/webhook") + dispatcher.dispatch(_sample_alert()) + mock_post.assert_not_called() + + +def test_dispatch_teams_non_ok_response_does_not_block_other_channels(tmp_path): + log_path = tmp_path / "alerts.jsonl" + with patch("anglerfish.alerts.requests.post") as mock_post: + mock_post.return_value.ok = False + mock_post.return_value.status_code = 404 + dispatcher = AlertDispatcher(alert_log=log_path, teams_webhook_url="https://outlook.office.com/webhook/xxx") + dispatcher.dispatch(_sample_alert()) + assert log_path.is_file() diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 1f5aee9..18ec4c5 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -194,6 +194,10 @@ def test_monitor_interval(self): args = _parse_args(["monitor", "--interval", "120"]) assert args.interval == 120 + def test_monitor_teams_webhook_url(self): + args = _parse_args(["monitor", "--teams-webhook-url", "https://outlook.office.com/webhook/x"]) + assert args.teams_webhook_url == "https://outlook.office.com/webhook/x" + def test_monitor_interval_rejects_zero(self): with pytest.raises(SystemExit): _parse_args(["monitor", "--interval", "0"]) diff --git a/tests/test_cli_monitor.py b/tests/test_cli_monitor.py index 0055703..46098ab 100644 --- a/tests/test_cli_monitor.py +++ b/tests/test_cli_monitor.py @@ -32,6 +32,7 @@ def _make_args(**overrides): "no_console": False, "alert_log": None, "slack_webhook_url": None, + "teams_webhook_url": None, } defaults.update(overrides) return argparse.Namespace(**defaults) @@ -135,6 +136,7 @@ def test_run_monitor_wires_dependencies_and_runs(tmp_path, monkeypatch): records_dir=str(tmp_path), no_console=True, slack_webhook_url="https://hooks.slack.com/services/x", + teams_webhook_url="https://outlook.office.com/webhook/x", alert_log=str(tmp_path / "alerts.jsonl"), state_file=str(tmp_path / "state.json"), exclude_app_ids=["AbC", " "], @@ -164,6 +166,7 @@ def fake_run_monitor(audit_client, canary_index, **kwargs): # no_console -> dispatcher suppresses console; Slack + state wired through. assert captured["dispatcher"]._console is None assert captured["dispatcher"]._slack_webhook_url == "https://hooks.slack.com/services/x" + assert captured["dispatcher"]._teams_webhook_url == "https://outlook.office.com/webhook/x" assert captured["state_manager"] is not None assert captured["token_manager"] is not None