Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<your-webhook>"
# ANGLERFISH_TEAMS_WEBHOOK_URL="https://outlook.office.com/webhook/<your-webhook>"
# ANGLERFISH_MONITOR_NO_CONSOLE="true" # suppress console alert output (daemon mode)

# Optional custom template directory (YAML templates, see CONTRIBUTING.md):
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Outlook draft records store a per-deployment `canary_id`, hidden-folder metadata
- `cleanup <record>`
- `demo-access <record>`
- `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:
Expand Down
49 changes: 49 additions & 0 deletions src/anglerfish/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -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)
7 changes: 7 additions & 0 deletions src/anglerfish/cli/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/anglerfish/cli/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/anglerfish/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 45 additions & 1 deletion tests/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
4 changes: 4 additions & 0 deletions tests/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
3 changes: 3 additions & 0 deletions tests/test_cli_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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", " "],
Expand Down Expand Up @@ -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

Expand Down