#!/usr/bin/env python3
"""
Donjon - Microsoft 365 / Entra ID Posture Scanner  (scanner item #9)

Read-only Microsoft Graph API posture assessment for Microsoft 365 / Entra ID
(formerly Azure AD) tenants. Targets the GovSecAI / CMMC use case: every check
maps to NIST SP 800-171 Rev2 and/or NIST SP 800-53 Rev5 controls.

============================================================================
STATUS: DESIGN + SCAFFOLD ONLY  (2026-06-10)
============================================================================
This module is NOT yet wired into web/api.py `_list_scanners` or the dashboard
and MUST NOT be advertised as a shippable scanner until it has been
functionally verified against a REAL Microsoft 365 tenant (no-fake-features
rule). It has been exercised only against MOCKED Graph API JSON. The check
*logic* is tested; the Graph *transport* (auth, paging, throttling) is not.

------------------------------------------------------------------------------
AIR-GAP NOTE
------------------------------------------------------------------------------
This scanner is INHERENTLY ONLINE. It assesses a cloud tenant via the
Microsoft Graph API (https://graph.microsoft.com) and therefore cannot run in
an air-gapped deployment. This is by design: the asset being assessed (the
M365 tenant) is itself a cloud control plane. For air-gapped customers this
scanner is simply not applicable and should be skipped (status='skipped').

------------------------------------------------------------------------------
AUTHENTICATION  (credentials NEVER hardcoded)
------------------------------------------------------------------------------
Uses an Entra ID app registration with the OAuth2 client-credentials flow and
READ-ONLY application permissions (admin consent required):

    Policy.Read.All                 - Conditional Access, auth methods
    Directory.Read.All              - users, roles, guests, org settings
    Application.Read.All            - enterprise apps, service principals
    RoleManagement.Read.Directory  - directory role assignments
    AuditLog.Read.All               - sign-in / legacy-auth telemetry
    Reports.Read.All                - authentication method registration
    SecurityEvents.Read.All        - Defender / secure score
    Exchange / SharePoint posture is read via Graph + (optionally) the
    Office 365 Management APIs with equivalent read-only scopes.

Credentials are resolved from config/env, in this order, and are NEVER written
to logs or findings:

    M365_TENANT_ID       (env)  or  config 'm365.tenant_id'
    M365_CLIENT_ID       (env)  or  config 'm365.client_id'
    M365_CLIENT_SECRET   (env)  or  config 'm365.client_secret'   (secret)
    -- OR certificate auth --
    M365_CERT_PATH / M365_CERT_THUMBPRINT  (preferred over secret)

If no credentials are configured the scan returns status='skipped' rather than
emitting fake findings.

------------------------------------------------------------------------------
DESIGN: separation of transport from rule logic
------------------------------------------------------------------------------
`_collect_posture()` turns raw Graph responses into a flat, provider-agnostic
"posture" dict. The rule engine (`_run_rules`) evaluates declarative rule dicts
against that posture dict. This is the same pattern CloudScanner uses for CIS
benchmarks: the identical rule logic runs against LIVE Graph data and against
canned MOCK JSON, so tests exercise the real decision code, not a stub.
"""

import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

# ---------------------------------------------------------------------------
# Path bootstrap
# ---------------------------------------------------------------------------
sys.path.insert(0, str(Path(__file__).parent.parent / 'lib'))

try:
    from .base import BaseScanner
except ImportError:
    from base import BaseScanner


GRAPH_BASE = "https://graph.microsoft.com/v1.0"
GRAPH_BETA = "https://graph.microsoft.com/beta"
TOKEN_ENDPOINT = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
GRAPH_SCOPE = "https://graph.microsoft.com/.default"

# Read-only application permissions required (documentation / preflight).
REQUIRED_SCOPES = [
    "Policy.Read.All",
    "Directory.Read.All",
    "Application.Read.All",
    "RoleManagement.Read.Directory",
    "AuditLog.Read.All",
    "Reports.Read.All",
    "SecurityEvents.Read.All",
]


class M365Scanner(BaseScanner):
    """Microsoft 365 / Entra ID read-only posture scanner (Graph API)."""

    SCANNER_NAME = "m365"
    SCANNER_DESCRIPTION = (
        "Microsoft 365 / Entra ID posture assessment via read-only "
        "Microsoft Graph API (Conditional Access, MFA, legacy auth, "
        "OAuth grants, PIM, guests, Exchange, SharePoint, Secure Score)"
    )

    def __init__(self, session_id: Optional[str] = None):
        super().__init__(session_id)
        self._token: Optional[str] = None
        self._tenant_id: Optional[str] = None

    # =======================================================================
    # Credential resolution (env first, then config; never hardcoded)
    # =======================================================================

    def _resolve_credentials(self) -> Optional[Dict[str, str]]:
        """Resolve Entra app-registration credentials from env / config.

        Returns None if insufficient credentials are configured, in which
        case the scan is SKIPPED (not failed, not faked).
        """
        def _get(env_key: str, cfg_key: str) -> Optional[str]:
            val = os.environ.get(env_key)
            if val:
                return val
            try:
                return self.config.get(cfg_key)
            except Exception:
                return None

        tenant = _get("M365_TENANT_ID", "m365.tenant_id")
        client = _get("M365_CLIENT_ID", "m365.client_id")
        secret = _get("M365_CLIENT_SECRET", "m365.client_secret")
        cert_path = _get("M365_CERT_PATH", "m365.cert_path")
        cert_thumb = _get("M365_CERT_THUMBPRINT", "m365.cert_thumbprint")

        if not tenant or not client:
            return None
        if not secret and not (cert_path and cert_thumb):
            return None

        creds = {"tenant_id": tenant, "client_id": client}
        if secret:
            creds["client_secret"] = secret
        else:
            creds["cert_path"] = cert_path
            creds["cert_thumbprint"] = cert_thumb
        return creds

    # =======================================================================
    # Graph transport  (live path; NOT exercised by mocked tests)
    # =======================================================================

    def _acquire_token(self, creds: Dict[str, str]) -> Optional[str]:
        """Acquire an app-only Graph token via client-credentials flow.

        Live-only. Returns None on failure. Implemented with msal if present,
        falling back to a raw token request. The secret is never logged.
        """
        self._tenant_id = creds["tenant_id"]
        try:
            import msal  # type: ignore

            authority = f"https://login.microsoftonline.com/{creds['tenant_id']}"
            if "client_secret" in creds:
                app = msal.ConfidentialClientApplication(
                    creds["client_id"], authority=authority,
                    client_credential=creds["client_secret"],
                )
            else:
                with open(creds["cert_path"], "r") as fh:
                    private_key = fh.read()
                app = msal.ConfidentialClientApplication(
                    creds["client_id"], authority=authority,
                    client_credential={
                        "private_key": private_key,
                        "thumbprint": creds["cert_thumbprint"],
                    },
                )
            result = app.acquire_token_for_client(scopes=[GRAPH_SCOPE])
            token = result.get("access_token") if result else None
            if not token:
                self.warn("Graph token acquisition returned no access_token")
            return token
        except ImportError:
            self.warn(
                "msal not installed; install 'msal' for live M365 scanning"
            )
            return None
        except Exception as exc:
            self.scan_logger.debug(f"Token acquisition error: {exc}")
            self.warn("Failed to acquire Graph token")
            return None

    def _graph_get(self, path: str, beta: bool = False) -> Any:
        """GET a Graph resource (with paging). Live-only; returns None on err.

        Tests bypass this by injecting a pre-built posture dict, so the HTTP
        path is not invoked under mock.
        """
        try:
            import urllib.request
            import urllib.error
            import json as _json

            base = GRAPH_BETA if beta else GRAPH_BASE
            url = path if path.startswith("http") else f"{base}{path}"
            items: List[Any] = []
            while url:
                req = urllib.request.Request(
                    url, headers={"Authorization": f"Bearer {self._token}"}
                )
                with urllib.request.urlopen(req, timeout=60) as resp:
                    data = _json.loads(resp.read().decode("utf-8"))
                if "value" in data:
                    items.extend(data["value"])
                    url = data.get("@odata.nextLink")
                else:
                    return data
            return {"value": items}
        except Exception as exc:
            self.scan_logger.debug(f"Graph GET {path} error: {exc}")
            return None

    # =======================================================================
    # Posture collection (live path).  Transforms Graph JSON -> posture dict.
    # =======================================================================

    def _collect_posture(self) -> Dict[str, Any]:
        """Collect raw Graph data and reduce it to a flat posture dict.

        Live-only. The shape returned here is exactly what the mocked tests
        feed into `_run_rules`, so the rule logic is identical in both paths.
        Each sub-collector is defensive: a None Graph response degrades that
        slice of posture rather than aborting the scan (status -> partial).
        """
        p: Dict[str, Any] = {}

        ca = self._graph_get("/identity/conditionalAccess/policies")
        p["conditional_access"] = self._reduce_conditional_access(ca)

        authmethods = self._graph_get(
            "/policies/authenticationMethodsPolicy"
        )
        p["legacy_auth_blocked"] = self._reduce_legacy_auth(ca)

        reg = self._graph_get(
            "/reports/authenticationMethods/userRegistrationDetails", beta=True
        )
        p["mfa_registration"] = self._reduce_mfa_registration(reg)

        apps = self._graph_get("/servicePrincipals")
        grants = self._graph_get("/oauth2PermissionGrants")
        p["oauth_grants"] = self._reduce_oauth_grants(apps, grants)

        roles = self._graph_get("/directoryRoles")
        p["admin_roles"] = self._reduce_admin_roles(roles)

        org = self._graph_get("/organization")
        guests = self._graph_get(
            "/users?$filter=userType eq 'Guest'&$count=true"
        )
        p["guest_access"] = self._reduce_guest_access(org, guests)

        p["secure_score"] = self._reduce_secure_score(
            self._graph_get("/security/secureScores?$top=1")
        )
        return p

    # Each _reduce_* is intentionally small/pure so it can be unit-tested
    # directly against canned Graph JSON.

    @staticmethod
    def _reduce_conditional_access(ca: Any) -> Dict[str, Any]:
        policies = (ca or {}).get("value", []) if isinstance(ca, dict) else []
        enabled = [x for x in policies if x.get("state") == "enabled"]
        mfa_policies = [
            x for x in enabled
            if "mfa" in (x.get("grantControls", {}) or {}).get(
                "builtInControls", []
            )
        ]
        all_users_mfa = any(
            "All" in (x.get("conditions", {}).get("users", {}) or {})
            .get("includeUsers", [])
            for x in mfa_policies
        )
        return {
            "total": len(policies),
            "enabled": len(enabled),
            "mfa_policies": len(mfa_policies),
            "all_users_mfa": all_users_mfa,
        }

    @staticmethod
    def _reduce_legacy_auth(ca: Any) -> bool:
        """True if a CA policy blocks legacy (other) auth clients."""
        policies = (ca or {}).get("value", []) if isinstance(ca, dict) else []
        for x in policies:
            if x.get("state") != "enabled":
                continue
            clients = (x.get("conditions", {}) or {}).get(
                "clientAppTypes", []
            )
            blocks = "block" in (
                (x.get("grantControls", {}) or {}).get("builtInControls", [])
            )
            if blocks and (
                "exchangeActiveSync" in clients or "other" in clients
            ):
                return True
        return False

    @staticmethod
    def _reduce_mfa_registration(reg: Any) -> Dict[str, Any]:
        users = (reg or {}).get("value", []) if isinstance(reg, dict) else []
        total = len(users)
        capable = sum(1 for u in users if u.get("isMfaCapable"))
        return {
            "total_users": total,
            "mfa_capable": capable,
            "coverage_pct": round(100.0 * capable / total, 1) if total else 0.0,
        }

    @staticmethod
    def _reduce_oauth_grants(apps: Any, grants: Any) -> Dict[str, Any]:
        grant_list = (grants or {}).get("value", []) if isinstance(grants, dict) else []
        # High-risk delegated scopes granted org-wide (consentType=AllPrincipals)
        risky_scopes = {
            "Mail.Read", "Mail.ReadWrite", "Mail.Send",
            "Files.Read.All", "Files.ReadWrite.All",
            "Directory.ReadWrite.All", "full_access_as_app",
        }
        risky = []
        for g in grant_list:
            scopes = set((g.get("scope") or "").split())
            if g.get("consentType") == "AllPrincipals" and (scopes & risky_scopes):
                risky.append({
                    "client_id": g.get("clientId"),
                    "scopes": sorted(scopes & risky_scopes),
                })
        return {"total_grants": len(grant_list), "risky_grants": risky}

    @staticmethod
    def _reduce_admin_roles(roles: Any) -> Dict[str, Any]:
        role_list = (roles or {}).get("value", []) if isinstance(roles, dict) else []
        ga = next(
            (r for r in role_list
             if r.get("displayName") == "Global Administrator"),
            None,
        )
        ga_count = len((ga or {}).get("members", [])) if ga else 0
        return {"global_admins": ga_count, "roles": len(role_list)}

    @staticmethod
    def _reduce_guest_access(org: Any, guests: Any) -> Dict[str, Any]:
        guest_count = (guests or {}).get("@odata.count")
        if guest_count is None and isinstance(guests, dict):
            guest_count = len(guests.get("value", []))
        return {"guest_count": guest_count or 0}

    @staticmethod
    def _reduce_secure_score(scores: Any) -> Dict[str, Any]:
        items = (scores or {}).get("value", []) if isinstance(scores, dict) else []
        if not items:
            return {"available": False}
        s = items[0]
        current = s.get("currentScore")
        maximum = s.get("maxScore")
        pct = round(100.0 * current / maximum, 1) if maximum else None
        return {"available": True, "current": current,
                "max": maximum, "percent": pct}

    # =======================================================================
    # Rule engine  (shared by live and mocked paths)
    # =======================================================================

    def _build_rules(self, p: Dict[str, Any],
                     scan_type: str) -> List[Dict[str, Any]]:
        """Declarative posture rules. `enabled=True` => emit a finding.

        Each rule carries its NIST 800-171 Rev2 and 800-53 Rev5 control
        mapping. Mapping rationale is summarized in NIST_CONTROL_MAP below.
        """
        ca = p.get("conditional_access", {})
        mfa = p.get("mfa_registration", {})
        oauth = p.get("oauth_grants", {})
        roles = p.get("admin_roles", {})
        guests = p.get("guest_access", {})
        ex = p.get("exchange", {})
        sp = p.get("sharepoint", {})
        score = p.get("secure_score", {})

        risky_grants = oauth.get("risky_grants", []) or []
        ga = roles.get("global_admins", 0)

        rules = [
            # -- Entra ID: Conditional Access coverage --
            {
                "enabled": ca.get("enabled", 0) == 0,
                "severity": "HIGH",
                "title": "No Enabled Conditional Access Policies",
                "type": "m365_conditional_access",
                "asset": "entra:conditionalAccess",
                "desc": ("No Conditional Access policies are enabled; access "
                         "controls (MFA, device, location) are not enforced."),
                "fix": "Create and enable baseline Conditional Access policies.",
                "method": "graph_api",
            },
            # -- Entra ID: MFA enforced for all users --
            {
                "enabled": not ca.get("all_users_mfa", False),
                "severity": "HIGH",
                "title": "MFA Not Enforced for All Users via Conditional Access",
                "type": "m365_mfa",
                "asset": "entra:conditionalAccess",
                "desc": ("No enabled Conditional Access policy requires MFA "
                         "for all users; privileged and standard accounts may "
                         "authenticate with a password alone."),
                "fix": "Add a CA policy requiring MFA for All Users.",
                "method": "graph_api",
            },
            # -- Entra ID: MFA registration coverage --
            {
                "enabled": mfa.get("total_users", 0) > 0
                and mfa.get("coverage_pct", 0) < 95.0,
                "severity": "MEDIUM",
                "title": "Incomplete MFA Registration Coverage",
                "type": "m365_mfa",
                "asset": "entra:authenticationMethods",
                "desc": (f"Only {mfa.get('coverage_pct', 0)}% of users are MFA "
                         f"capable ({mfa.get('mfa_capable', 0)}/"
                         f"{mfa.get('total_users', 0)}); target is >=95%."),
                "fix": "Drive MFA registration via a registration-campaign CA policy.",
                "method": "graph_api",
            },
            # -- Entra ID: legacy auth blocked --
            {
                "enabled": not p.get("legacy_auth_blocked", False),
                "severity": "HIGH",
                "title": "Legacy Authentication Not Blocked",
                "type": "m365_legacy_auth",
                "asset": "entra:conditionalAccess",
                "desc": ("Legacy authentication protocols (basic auth, "
                         "ActiveSync, other clients) are not blocked; these "
                         "bypass MFA and are heavily abused in password spray."),
                "fix": "Enable a CA policy blocking legacy authentication clients.",
                "method": "graph_api",
            },
            # -- Entra ID: risky / over-privileged OAuth grants --
            {
                "enabled": len(risky_grants) > 0,
                "severity": "HIGH",
                "title": f"{len(risky_grants)} Over-Privileged Org-Wide OAuth Grants",
                "type": "m365_oauth_grant",
                "asset": "entra:oauth2PermissionGrants",
                "desc": (f"{len(risky_grants)} OAuth grants consented for all "
                         f"users expose high-risk scopes (mail/files/directory "
                         f"read-write), a common data-exfiltration and "
                         f"illicit-consent vector."),
                "fix": ("Review and revoke org-wide consent grants; restrict "
                        "user consent to verified publishers / low-risk scopes."),
                "method": "graph_api",
                "raw": risky_grants,
            },
            # -- Entra ID: admin role sprawl (PIM) --
            {
                "enabled": ga > 4,
                "severity": "MEDIUM",
                "title": f"Excessive Global Administrators ({ga})",
                "type": "m365_admin_roles",
                "asset": "entra:directoryRoles",
                "desc": (f"{ga} accounts hold the Global Administrator role; "
                         f"Microsoft recommends fewer than 5 and using PIM "
                         f"just-in-time activation instead of standing access."),
                "fix": ("Reduce standing Global Admins to <5 and move privileged "
                        "access to PIM eligible (just-in-time) assignments."),
                "method": "graph_api",
            },
            {
                "enabled": ga == 0,
                "severity": "INFO",
                "title": "Global Administrator Count Could Not Be Determined",
                "type": "m365_admin_roles",
                "asset": "entra:directoryRoles",
                "desc": ("No Global Administrator members were returned; verify "
                         "RoleManagement.Read.Directory consent and role "
                         "activation."),
                "fix": "Confirm read permissions on directory role assignments.",
                "method": "graph_api",
            },
            # -- Entra ID: guest / external access --
            {
                "enabled": guests.get("guest_count", 0) > 0,
                "severity": "LOW",
                "title": f"Guest / External Accounts Present ({guests.get('guest_count', 0)})",
                "type": "m365_guest_access",
                "asset": "entra:users",
                "desc": (f"{guests.get('guest_count', 0)} guest (external) "
                         f"accounts exist in the tenant; review external "
                         f"collaboration scope and access-review cadence."),
                "fix": ("Enable periodic access reviews for guests and restrict "
                        "guest invite/permission defaults."),
                "method": "graph_api",
            },
            # -- Exchange Online: mailbox audit logging --
            {
                "enabled": ex.get("audit_disabled", False),
                "severity": "MEDIUM",
                "title": "Exchange Mailbox Audit Logging Disabled",
                "type": "m365_exchange_audit",
                "asset": "exchange:organizationConfig",
                "desc": ("Mailbox audit logging is disabled org-wide; mailbox "
                         "access and admin actions are not recorded, hampering "
                         "incident response."),
                "fix": "Enable organization-wide mailbox audit logging.",
                "method": "graph_api",
            },
            # -- Exchange Online: external auto-forwarding --
            {
                "enabled": ex.get("external_forwarding_allowed", False),
                "severity": "HIGH",
                "title": "External Email Auto-Forwarding Allowed",
                "type": "m365_exchange_forwarding",
                "asset": "exchange:remoteDomain",
                "desc": ("Automatic forwarding of email to external domains is "
                         "permitted; this is a primary data-exfiltration path "
                         "used after account compromise."),
                "fix": "Block automatic external forwarding via outbound spam policy.",
                "method": "graph_api",
            },
            # -- SharePoint / OneDrive: external sharing posture --
            {
                "enabled": sp.get("sharing_capability") == "ExternalUserAndGuestSharing",
                "severity": "MEDIUM",
                "title": "SharePoint/OneDrive Allows Anonymous (Anyone) Sharing",
                "type": "m365_sharepoint_sharing",
                "asset": "sharepoint:tenantSettings",
                "desc": ("Tenant sharing capability permits anonymous 'Anyone' "
                         "links; content can be shared without authentication."),
                "fix": ("Restrict sharing to 'New and existing guests' or "
                        "'Only people in your organization'."),
                "method": "graph_api",
            },
            # -- Defender / Secure Score --
            {
                "enabled": score.get("available") and (score.get("percent") or 100) < 50.0,
                "severity": "MEDIUM",
                "title": f"Low Microsoft Secure Score ({score.get('percent')}%)",
                "type": "m365_secure_score",
                "asset": "security:secureScore",
                "desc": (f"Microsoft Secure Score is {score.get('percent')}% "
                         f"({score.get('current')}/{score.get('max')}), below "
                         f"the 50% baseline; many recommended controls are "
                         f"unimplemented."),
                "fix": "Action high-impact Secure Score recommendations in Defender.",
                "method": "graph_api",
            },
        ]
        return rules

    def _run_rules(self, posture: Dict[str, Any], scan_type: str) -> None:
        """Evaluate posture rules and emit findings for the failing ones."""
        for rule in self._build_rules(posture, scan_type):
            if not rule.get("enabled"):
                continue
            self.add_finding(
                severity=rule["severity"],
                title=rule["title"],
                description=rule["desc"],
                affected_asset=rule["asset"],
                finding_type=rule["type"],
                remediation=rule["fix"],
                raw_data=rule.get("raw"),
                detection_method=rule.get("method", "graph_api"),
            )

    # =======================================================================
    # NIST control mapping  (800-171 Rev2 + 800-53 Rev5)
    # =======================================================================
    #
    # finding_type -> {'nist_800_171': [...], 'nist_800_53': [...], 'why': ...}
    #
    NIST_CONTROL_MAP: Dict[str, Dict[str, Any]] = {
        "m365_conditional_access": {
            "nist_800_171": ["3.1.1", "3.1.2", "3.1.20"],
            "nist_800_53": ["AC-2", "AC-3", "AC-17", "SC-7"],
            "why": "CA enforces account, access, and boundary control policy.",
        },
        "m365_mfa": {
            "nist_800_171": ["3.5.3"],
            "nist_800_53": ["IA-2(1)", "IA-2(2)"],
            "why": "Multifactor authentication for network/privileged access.",
        },
        "m365_legacy_auth": {
            "nist_800_171": ["3.5.3", "3.5.4", "3.1.1"],
            "nist_800_53": ["IA-2(1)", "IA-2(8)", "AC-17"],
            "why": "Legacy protocols bypass MFA and replay protection.",
        },
        "m365_oauth_grant": {
            "nist_800_171": ["3.1.5", "3.1.6", "3.4.6"],
            "nist_800_53": ["AC-6", "AC-6(1)", "CM-7"],
            "why": "Least privilege; restrict functionality/over-broad delegation.",
        },
        "m365_admin_roles": {
            "nist_800_171": ["3.1.5", "3.1.6", "3.1.7"],
            "nist_800_53": ["AC-6", "AC-6(1)", "AC-6(2)", "AC-6(5)"],
            "why": "Least privilege and limiting privileged accounts (PIM/JIT).",
        },
        "m365_guest_access": {
            "nist_800_171": ["3.1.1", "3.1.3"],
            "nist_800_53": ["AC-2", "AC-21", "AC-4"],
            "why": "Control external accounts and information sharing.",
        },
        "m365_exchange_audit": {
            "nist_800_171": ["3.3.1", "3.3.2"],
            "nist_800_53": ["AU-2", "AU-3", "AU-12"],
            "why": "Audit record creation and accountability for mailbox events.",
        },
        "m365_exchange_forwarding": {
            "nist_800_171": ["3.1.3", "3.13.1"],
            "nist_800_53": ["AC-4", "AC-20", "SC-7"],
            "why": "Control information flow / exfiltration to external domains.",
        },
        "m365_sharepoint_sharing": {
            "nist_800_171": ["3.1.3", "3.1.22"],
            "nist_800_53": ["AC-3", "AC-4", "AC-21", "AC-22"],
            "why": "Control sharing and publicly accessible content.",
        },
        "m365_secure_score": {
            "nist_800_171": ["3.11.2", "3.12.1"],
            "nist_800_53": ["RA-5", "CA-2", "CA-7"],
            "why": "Continuous monitoring / security control assessment posture.",
        },
    }

    @classmethod
    def nist_controls_for(cls, finding_type: str) -> Dict[str, Any]:
        """Return the NIST mapping for a given finding type (empty if none)."""
        return cls.NIST_CONTROL_MAP.get(finding_type, {})

    # =======================================================================
    # Main entry point
    # =======================================================================

    def scan(self, targets: List[str] = None,
             scan_type: str = "standard",
             posture: Optional[Dict[str, Any]] = None, **kwargs) -> Dict:
        """Execute the M365 / Entra posture scan.

        Args:
            targets: ignored (tenant is implied by credentials); accepted for
                interface compatibility with other scanners.
            scan_type: 'quick' | 'standard' | 'deep'.
            posture: TEST/INJECTION HOOK. If provided, the live Graph
                collection is skipped and these canned posture values are
                evaluated directly. This is how mocked tests exercise the
                rule engine without a live tenant. In production this is None.

        Returns:
            Scan summary dict (see BaseScanner.get_summary()).
        """
        self.start_time = datetime.now(timezone.utc)
        self.scan_status = "running"
        self.scan_logger.info(f"Starting {scan_type} M365/Entra posture scan")

        # --- Injected posture (mocked tests / offline rule replay) ---
        if posture is not None:
            self.scan_logger.info(
                "Evaluating injected posture (no live Graph call)"
            )
            self._run_rules(posture, scan_type)
            self.end_time = datetime.now(timezone.utc)
            self.set_status("complete")
            return self.get_summary()

        # --- Live path: require credentials, else SKIP (never fake) ---
        creds = self._resolve_credentials()
        if not creds:
            self.end_time = datetime.now(timezone.utc)
            self.set_status(
                "skipped",
                "No M365 credentials configured (set M365_TENANT_ID / "
                "M365_CLIENT_ID and a secret or certificate).",
            )
            return self.get_summary()

        self._token = self._acquire_token(creds)
        if not self._token:
            self.end_time = datetime.now(timezone.utc)
            self.set_status("failed", "Could not acquire Microsoft Graph token")
            return self.get_summary()

        live_posture = self._collect_posture()
        self._run_rules(live_posture, scan_type)

        self.end_time = datetime.now(timezone.utc)
        # If any posture slice failed to collect we flagged warnings; the
        # status reflects partial coverage in that case.
        self.set_status("partial" if self.warnings else "complete")
        self.save_results()
        return self.get_summary()
