#!/usr/bin/env python3 """Agent for performing OAuth scope minimization review. Audits OAuth 2.0 permission grants in Microsoft Entra ID (Azure AD) to identify over-permissioned apps, stale grants, and excessive scopes. """ import requests import json import sys from collections import defaultdict from datetime import datetime SCOPE_RISK = { "critical": [ "Directory.ReadWrite.All", "Application.ReadWrite.All", "Mail.ReadWrite", "Mail.Send", "Files.ReadWrite.All", "Sites.FullControl.All", "User.ReadWrite.All", "RoleManagement.ReadWrite.Directory", ], "high": [ "Mail.Read", "Files.Read.All", "User.Read.All", "Group.Read.All", "Directory.Read.All", "AuditLog.Read.All", "Calendars.ReadWrite", "Contacts.ReadWrite", ], "medium": [ "Calendars.Read", "Files.ReadWrite", "Tasks.ReadWrite", "Chat.ReadWrite", "ChannelMessage.Send", ], "low": [ "User.Read", "openid", "profile", "email", "offline_access", "People.Read", "User.ReadBasic.All", ], } class OAuthScopeAuditor: """Audits OAuth permission grants via Microsoft Graph API.""" def __init__(self, tenant_id, client_id, client_secret): self.tenant_id = tenant_id self.base_url = "https://graph.microsoft.com/v1.0" self.token = self._get_token(client_id, client_secret) self.headers = {"Authorization": f"Bearer {self.token}"} def _get_token(self, client_id, client_secret): url = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" resp = requests.post(url, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": "https://graph.microsoft.com/.default", }, timeout=30) resp.raise_for_status() return resp.json()["access_token"] def _paginated_get(self, url): results = [] while url: resp = requests.get(url, headers=self.headers, timeout=30) resp.raise_for_status() data = resp.json() results.extend(data.get("value", [])) url = data.get("@odata.nextLink") return results def get_service_principals(self): """Get all enterprise applications (service principals).""" return self._paginated_get( f"{self.base_url}/servicePrincipals?$top=999" "&$select=id,appId,displayName,appOwnerOrganizationId,accountEnabled,createdDateTime" ) def get_oauth_grants(self): """Get all delegated permission grants.""" return self._paginated_get( f"{self.base_url}/oauth2PermissionGrants?$top=999" ) def classify_scope(self, scope): """Classify a scope by risk level.""" for level, scopes in SCOPE_RISK.items(): if scope in scopes: return level return "high" def build_permission_inventory(self): """Build complete OAuth permission inventory.""" sps = self.get_service_principals() grants = self.get_oauth_grants() sp_map = {sp["id"]: sp for sp in sps} inventory = [] for grant in grants: sp = sp_map.get(grant.get("clientId"), {}) scopes = grant.get("scope", "").split() for scope in scopes: if not scope: continue inventory.append({ "app_name": sp.get("displayName", "Unknown"), "app_id": grant.get("clientId"), "scope": scope, "risk_level": self.classify_scope(scope), "consent_type": grant.get("consentType"), "is_third_party": sp.get("appOwnerOrganizationId") != self.tenant_id, "is_enabled": sp.get("accountEnabled", True), }) return inventory def find_over_permissioned(self, inventory, approved_scopes=None): """Find apps with excessive or unapproved scopes.""" findings = [] app_perms = defaultdict(list) for perm in inventory: app_perms[perm["app_name"]].append(perm) for app_name, perms in app_perms.items(): critical = [p for p in perms if p["risk_level"] == "critical"] high = [p for p in perms if p["risk_level"] == "high"] if critical: findings.append({ "app_name": app_name, "severity": "CRITICAL", "finding": f"{len(critical)} critical scopes granted", "critical_scopes": [p["scope"] for p in critical], "is_third_party": perms[0].get("is_third_party", False), }) elif len(high) > 3: findings.append({ "app_name": app_name, "severity": "HIGH", "finding": f"{len(high)} high-risk scopes granted", "high_scopes": [p["scope"] for p in high], }) return findings def find_broad_permissions(self, inventory): """Detect overly broad permissions that could be narrowed.""" downgrades = [ ("Mail.ReadWrite", "Mail.Read"), ("Files.ReadWrite.All", "Files.Read.All"), ("Directory.ReadWrite.All", "Directory.Read.All"), ("User.ReadWrite.All", "User.Read.All"), ] findings = [] app_scopes = defaultdict(set) for perm in inventory: app_scopes[perm["app_name"]].add(perm["scope"]) for app_name, scopes in app_scopes.items(): for broad, narrow in downgrades: if broad in scopes: findings.append({ "app_name": app_name, "current_scope": broad, "recommended_scope": narrow, "recommendation": f"Downgrade from {broad} to {narrow}", }) return findings def generate_report(self): """Generate comprehensive OAuth scope review report.""" inventory = self.build_permission_inventory() risk_counts = defaultdict(int) for perm in inventory: risk_counts[perm["risk_level"]] += 1 third_party = [p for p in inventory if p.get("is_third_party")] report = { "tenant_id": self.tenant_id, "report_date": datetime.utcnow().isoformat(), "total_permissions": len(inventory), "risk_breakdown": dict(risk_counts), "third_party_permissions": len(third_party), "over_permissioned": self.find_over_permissioned(inventory), "broad_permissions": self.find_broad_permissions(inventory), "unique_apps": len(set(p["app_name"] for p in inventory)), } print(json.dumps(report, indent=2)) return report def main(): if len(sys.argv) < 4: print("Usage: agent.py ") sys.exit(1) tenant_id = sys.argv[1] client_id = sys.argv[2] client_secret = sys.argv[3] auditor = OAuthScopeAuditor(tenant_id, client_id, client_secret) auditor.generate_report() if __name__ == "__main__": main()