Source: main (497baf0) Excluded: live tenant exports, generated artifacts, and dev-only tooling.
445 lines
16 KiB
Python
445 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Probe tenant audit logs to detect configuration changes and decide whether to trigger a backup pipeline.
|
|
|
|
This script is designed to run inside an Azure Function timer trigger or locally for testing.
|
|
It queries Microsoft Graph audit endpoints for the cheapest possible signal that a configuration
|
|
change occurred since the last check, then applies a debouncer so that a burst of changes during
|
|
an admin sprint results in a single backup run after a configurable quiet window.
|
|
|
|
Usage (local testing):
|
|
python3 scripts/probe_tenant_changes.py \
|
|
--token "$GRAPH_TOKEN" \
|
|
--state-path ./probe-state.json \
|
|
--quiet-window-minutes 15 \
|
|
--cooldown-minutes 30
|
|
|
|
Usage (Azure Function wrapper):
|
|
python3 scripts/probe_tenant_changes.py \
|
|
--token "$GRAPH_TOKEN" \
|
|
--state-json '{"intune":{"last_check":"2026-04-20T10:00:00+00:00"},...}' \
|
|
--quiet-window-minutes 15 \
|
|
--cooldown-minutes 30
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import urllib.parse
|
|
from typing import Any
|
|
|
|
# scripts/ is not guaranteed to be on PYTHONPATH when loaded by the Function wrapper,
|
|
# so we tolerate a relative import failure and fall back to an absolute import.
|
|
try:
|
|
from scripts.common import request_json
|
|
except ImportError:
|
|
from common import request_json # type: ignore[no-redef]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_INTUNE_AUDIT_URL = "https://graph.microsoft.com/beta/deviceManagement/auditEvents"
|
|
_ENTRA_AUDIT_URL = "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits"
|
|
|
|
# Target resource types in Entra that map to the categories exported by export_entra_baseline.py.
|
|
_ENTRA_TARGET_TYPES = (
|
|
"ConditionalAccessPolicy",
|
|
"NamedLocation",
|
|
"AuthenticationStrengthPolicy",
|
|
"Application",
|
|
"ServicePrincipal",
|
|
)
|
|
|
|
_DEFAULT_STATE: dict[str, Any] = {
|
|
"intune": {"last_check": None},
|
|
"entra": {"last_check": None},
|
|
"debouncer": {
|
|
"state": "idle",
|
|
"first_event_at": None,
|
|
"trigger_after": None,
|
|
"cooldown_until": None,
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token acquisition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _acquire_graph_token(client_id: str, client_secret: str, tenant_id: str) -> str:
|
|
"""Acquire a Graph access token via client credentials flow."""
|
|
url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
|
body = urllib.parse.urlencode(
|
|
{
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"scope": "https://graph.microsoft.com/.default",
|
|
"grant_type": "client_credentials",
|
|
}
|
|
).encode("utf-8")
|
|
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
payload = json.loads(resp.read().decode("utf-8"))
|
|
access_token = payload.get("access_token")
|
|
if not access_token:
|
|
raise RuntimeError("Token endpoint did not return an access_token.")
|
|
return str(access_token)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--token", default="", help="Microsoft Graph bearer token (direct).")
|
|
parser.add_argument("--client-id", default="", help="Entra app client ID (alternative to --token).")
|
|
parser.add_argument("--client-secret", default="", help="Entra app client secret (alternative to --token).")
|
|
parser.add_argument("--tenant-id", default="", help="Entra tenant ID (alternative to --token).")
|
|
parser.add_argument(
|
|
"--state-path",
|
|
default="",
|
|
help="Path to a local JSON state file (used for local testing).",
|
|
)
|
|
parser.add_argument(
|
|
"--state-json",
|
|
default="",
|
|
help="Raw JSON state string (used when the caller manages persistence, e.g. Azure Table Storage).",
|
|
)
|
|
parser.add_argument(
|
|
"--quiet-window-minutes",
|
|
type=int,
|
|
default=15,
|
|
help="Minutes of silence after the last detected change before triggering a backup.",
|
|
)
|
|
parser.add_argument(
|
|
"--cooldown-minutes",
|
|
type=int,
|
|
default=30,
|
|
help="Minimum minutes between two triggered backup runs.",
|
|
)
|
|
parser.add_argument(
|
|
"--now",
|
|
default="",
|
|
help="Override the current time (ISO 8601). Useful for tests.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_state(path: str, json_str: str) -> dict[str, Any]:
|
|
if json_str:
|
|
return json.loads(json_str)
|
|
if path:
|
|
p = pathlib.Path(path)
|
|
if p.exists():
|
|
return json.loads(p.read_text(encoding="utf-8"))
|
|
return json.loads(json.dumps(_DEFAULT_STATE))
|
|
|
|
|
|
def _save_state(path: str, state: dict[str, Any]) -> None:
|
|
if path:
|
|
pathlib.Path(path).write_text(
|
|
json.dumps(state, indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _parse_iso(value: str | None) -> dt.datetime | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
return parsed.astimezone(dt.timezone.utc)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _format_iso(value: dt.datetime) -> str:
|
|
return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_intune_filter(since: dt.datetime, until: dt.datetime) -> str:
|
|
since_str = since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
until_str = until.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
return (
|
|
f"activityDateTime ge {since_str}"
|
|
f" and activityDateTime le {until_str}"
|
|
f" and activityResult eq 'Success'"
|
|
f" and ActivityOperationType ne 'Get'"
|
|
)
|
|
|
|
|
|
def _build_entra_filter(since: dt.datetime, until: dt.datetime) -> str:
|
|
since_str = since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
until_str = until.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
type_clauses = " or ".join(
|
|
f"targetResources/any(t: t/type eq '{t}')" for t in _ENTRA_TARGET_TYPES
|
|
)
|
|
return (
|
|
f"activityDateTime ge {since_str}"
|
|
f" and activityDateTime le {until_str}"
|
|
f" and result eq 'success'"
|
|
f" and ({type_clauses})"
|
|
)
|
|
|
|
|
|
def _fetch_latest_event(url: str, token: str) -> dict[str, Any] | None:
|
|
"""Return the single latest matching audit event, or None if nothing found."""
|
|
try:
|
|
payload = request_json(url, token=token, timeout=30, max_retries=2)
|
|
except Exception as exc:
|
|
# Defensive: log and treat as no event so a transient Graph failure does
|
|
# not wedge the debouncer in an armed state forever.
|
|
print(f"Warning: Graph query failed ({exc})", file=sys.stderr)
|
|
return None
|
|
|
|
value = payload.get("value")
|
|
if isinstance(value, list) and value:
|
|
event = value[0]
|
|
if isinstance(event, dict):
|
|
return event
|
|
return None
|
|
|
|
|
|
def _get_latest_intune_event(
|
|
token: str, since: dt.datetime, until: dt.datetime
|
|
) -> dict[str, Any] | None:
|
|
filter_str = _build_intune_filter(since, until)
|
|
params = {
|
|
"$filter": filter_str,
|
|
"$orderby": "activityDateTime desc",
|
|
"$top": "1",
|
|
"$select": "id,activityDateTime,activityType,activityOperationType",
|
|
}
|
|
url = f"{_INTUNE_AUDIT_URL}?{urllib.parse.urlencode(params)}"
|
|
return _fetch_latest_event(url, token)
|
|
|
|
|
|
def _get_latest_entra_event(
|
|
token: str, since: dt.datetime, until: dt.datetime
|
|
) -> dict[str, Any] | None:
|
|
filter_str = _build_entra_filter(since, until)
|
|
params = {
|
|
"$filter": filter_str,
|
|
"$orderby": "activityDateTime desc",
|
|
"$top": "1",
|
|
"$select": "id,activityDateTime,activityDisplayName",
|
|
}
|
|
url = f"{_ENTRA_AUDIT_URL}?{urllib.parse.urlencode(params)}"
|
|
return _fetch_latest_event(url, token)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Debouncer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _evaluate_debouncer(
|
|
state: dict[str, Any],
|
|
intune_event: dict[str, Any] | None,
|
|
entra_event: dict[str, Any] | None,
|
|
now: dt.datetime,
|
|
quiet_window: dt.timedelta,
|
|
cooldown: dt.timedelta,
|
|
) -> tuple[bool, dict[str, Any], str]:
|
|
"""Return (should_trigger, updated_state, human_readable_reason)."""
|
|
|
|
deb = dict(state.get("debouncer") or {})
|
|
deb_state = str(deb.get("state") or "idle")
|
|
|
|
# Extract event timestamps if present
|
|
intune_time: dt.datetime | None = None
|
|
entra_time: dt.datetime | None = None
|
|
if intune_event:
|
|
intune_time = _parse_iso(intune_event.get("activityDateTime"))
|
|
if entra_event:
|
|
entra_time = _parse_iso(entra_event.get("activityDateTime"))
|
|
|
|
latest_event_time = max(
|
|
(t for t in (intune_time, entra_time) if t is not None), default=None
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Cooldown check
|
|
# ------------------------------------------------------------------
|
|
if deb_state == "cooldown":
|
|
cooldown_until = _parse_iso(deb.get("cooldown_until"))
|
|
if cooldown_until is not None and now < cooldown_until:
|
|
reason = (
|
|
f"In cooldown until {_format_iso(cooldown_until)}; "
|
|
f"{int(intune_event is not None) + int(entra_event is not None)} event(s) ignored."
|
|
)
|
|
return False, state, reason
|
|
# Cooldown expired → fall through to idle logic
|
|
deb = {
|
|
"state": "idle",
|
|
"first_event_at": None,
|
|
"trigger_after": None,
|
|
"cooldown_until": None,
|
|
}
|
|
deb_state = "idle"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Idle or armed
|
|
# ------------------------------------------------------------------
|
|
if latest_event_time is None:
|
|
# No changes in this window
|
|
if deb_state == "armed":
|
|
trigger_after = _parse_iso(deb.get("trigger_after"))
|
|
if trigger_after is not None and now >= trigger_after:
|
|
# Quiet window satisfied — fire
|
|
deb = {
|
|
"state": "cooldown",
|
|
"first_event_at": None,
|
|
"trigger_after": None,
|
|
"cooldown_until": _format_iso(now + cooldown),
|
|
}
|
|
reason = "Quiet window satisfied; no new events since last check."
|
|
state["debouncer"] = deb
|
|
return True, state, reason
|
|
# Still waiting
|
|
reason = f"Armed, waiting for quiet window until {_format_iso(trigger_after)}."
|
|
state["debouncer"] = deb
|
|
return False, state, reason
|
|
# Idle, no changes
|
|
reason = "No changes detected."
|
|
state["debouncer"] = deb
|
|
return False, state, reason
|
|
|
|
# There is at least one new event
|
|
if deb_state == "idle":
|
|
# First change in a while — arm the debouncer
|
|
trigger_after = now + quiet_window
|
|
deb = {
|
|
"state": "armed",
|
|
"first_event_at": _format_iso(latest_event_time),
|
|
"trigger_after": _format_iso(trigger_after),
|
|
"cooldown_until": None,
|
|
}
|
|
reason = (
|
|
f"Change detected at {_format_iso(latest_event_time)}; "
|
|
f"armed, trigger scheduled for {_format_iso(trigger_after)}."
|
|
)
|
|
state["debouncer"] = deb
|
|
return False, state, reason
|
|
|
|
if deb_state == "armed":
|
|
# Extend the quiet window because activity is still ongoing
|
|
trigger_after = now + quiet_window
|
|
first_event = deb.get("first_event_at") or _format_iso(latest_event_time)
|
|
deb = {
|
|
"state": "armed",
|
|
"first_event_at": first_event,
|
|
"trigger_after": _format_iso(trigger_after),
|
|
"cooldown_until": None,
|
|
}
|
|
workloads: list[str] = []
|
|
if intune_event:
|
|
workloads.append("intune")
|
|
if entra_event:
|
|
workloads.append("entra")
|
|
reason = (
|
|
f"Additional change detected at {_format_iso(latest_event_time)} "
|
|
f"({'/'.join(workloads)}); quiet window extended to {_format_iso(trigger_after)}."
|
|
)
|
|
state["debouncer"] = deb
|
|
return False, state, reason
|
|
|
|
# Defensive fallback
|
|
reason = f"Unexpected debouncer state '{deb_state}'; resetting to idle."
|
|
state["debouncer"] = {
|
|
"state": "idle",
|
|
"first_event_at": None,
|
|
"trigger_after": None,
|
|
"cooldown_until": None,
|
|
}
|
|
return False, state, reason
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
|
|
token = args.token.strip()
|
|
if not token:
|
|
if args.client_id and args.client_secret and args.tenant_id:
|
|
token = _acquire_graph_token(args.client_id, args.client_secret, args.tenant_id)
|
|
else:
|
|
print(
|
|
"ERROR: Provide --token, or all three of --client-id, --client-secret, --tenant-id.",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
quiet_window = dt.timedelta(minutes=args.quiet_window_minutes)
|
|
cooldown = dt.timedelta(minutes=args.cooldown_minutes)
|
|
|
|
now = _parse_iso(args.now) or dt.datetime.now(dt.timezone.utc)
|
|
# Truncate to second for cleaner output
|
|
now = now.replace(microsecond=0)
|
|
|
|
state = _load_state(args.state_path, args.state_json)
|
|
|
|
# Initialise missing last_check values to a safe default (24 hours ago).
|
|
# This prevents a brand-new state file from scanning the entire audit log history.
|
|
default_since = now - dt.timedelta(hours=24)
|
|
intune_since = _parse_iso(state.get("intune", {}).get("last_check")) or default_since
|
|
entra_since = _parse_iso(state.get("entra", {}).get("last_check")) or default_since
|
|
|
|
# ------------------------------------------------------------------
|
|
# Query Graph
|
|
# ------------------------------------------------------------------
|
|
intune_event = _get_latest_intune_event(token, intune_since, now)
|
|
entra_event = _get_latest_entra_event(token, entra_since, now)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Debounce
|
|
# ------------------------------------------------------------------
|
|
trigger, state, reason = _evaluate_debouncer(
|
|
state, intune_event, entra_event, now, quiet_window, cooldown
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Advance watermarks regardless of trigger decision so the next run
|
|
# does not re-scan the same window.
|
|
# ------------------------------------------------------------------
|
|
state.setdefault("intune", {})["last_check"] = _format_iso(now)
|
|
state.setdefault("entra", {})["last_check"] = _format_iso(now)
|
|
|
|
_save_state(args.state_path, state)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Emit decision
|
|
# ------------------------------------------------------------------
|
|
result = {
|
|
"trigger": trigger,
|
|
"reason": reason,
|
|
"checked_at": _format_iso(now),
|
|
"intune_event": intune_event,
|
|
"entra_event": entra_event,
|
|
"new_state": state,
|
|
}
|
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|