Performs OAuth 2.0 scope minimization reviews to identify over-authorized third-party app integrations, excessive API scopes, unused token grants, and high-risk consent patterns across IdPs and SaaS platforms. Useful for audits, API permission reviews, and third-party risk assessments.
npx claudepluginhub killvxk/cybersecurity-skills-zhThis skill uses the workspace's default tool permissions.
- 对第三方应用 OAuth 权限的年度或季度审查
Performs OAuth 2.0 scope minimization reviews to identify over-permissioned third-party apps, excessive API scopes, unused token grants, and risky consents in Entra ID, Okta, and SaaS platforms. For audits and compliance.
Performs OAuth 2.0 scope minimization reviews for third-party integrations, identifying excessive scopes, unused grants, and risky consents in providers like Entra ID, Okta.
Detects high-risk OAuth app consent grants in Azure AD/Entra ID using Microsoft Graph API, audit logs, and permission analysis to identify illicit consent attacks.
Share bugs, ideas, or general feedback.
不适用于审查同一信任边界内第一方应用的权限;OAuth 范围最小化专注于第三方和跨边界的同意授权。
枚举所有 OAuth 应用注册及委托权限:
"""
OAuth 授权清点 - Microsoft Entra ID
枚举所有应用注册、服务主体、
委托权限及应用权限授予情况。
"""
import requests
import json
from collections import defaultdict
class EntraOAuthAuditor:
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"
response = requests.post(url, data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://graph.microsoft.com/.default"
})
return response.json()["access_token"]
def get_all_service_principals(self):
"""获取所有服务主体(企业应用)。"""
apps = []
url = f"{self.base_url}/servicePrincipals?$top=999&$select=id,appId,displayName,appOwnerOrganizationId,servicePrincipalType,accountEnabled,createdDateTime"
while url:
response = requests.get(url, headers=self.headers)
data = response.json()
apps.extend(data.get("value", []))
url = data.get("@odata.nextLink")
return apps
def get_oauth2_permission_grants(self):
"""获取所有委托权限授予(用户同意)。"""
grants = []
url = f"{self.base_url}/oauth2PermissionGrants?$top=999"
while url:
response = requests.get(url, headers=self.headers)
data = response.json()
grants.extend(data.get("value", []))
url = data.get("@odata.nextLink")
return grants
def get_app_role_assignments(self, sp_id):
"""获取服务主体的应用权限分配。"""
url = f"{self.base_url}/servicePrincipals/{sp_id}/appRoleAssignments"
response = requests.get(url, headers=self.headers)
return response.json().get("value", [])
def build_permission_inventory(self):
"""构建全面的 OAuth 权限清单。"""
service_principals = self.get_all_service_principals()
delegated_grants = self.get_oauth2_permission_grants()
# 将服务主体 ID 映射到名称
sp_map = {sp["id"]: sp for sp in service_principals}
inventory = []
# 处理委托权限
for grant in delegated_grants:
sp = sp_map.get(grant["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"),
"publisher_tenant": sp.get("appOwnerOrganizationId"),
"is_third_party": sp.get("appOwnerOrganizationId") != self.tenant_id,
"permission_type": "Delegated",
"scope": scope,
"consent_type": grant.get("consentType"), # AllPrincipals 或 Principal
"principal_id": grant.get("principalId"),
"granted_date": sp.get("createdDateTime"),
"is_enabled": sp.get("accountEnabled", True)
})
# 处理应用权限
for sp in service_principals:
app_roles = self.get_app_role_assignments(sp["id"])
for role in app_roles:
inventory.append({
"app_name": sp.get("displayName"),
"app_id": sp.get("id"),
"publisher_tenant": sp.get("appOwnerOrganizationId"),
"is_third_party": sp.get("appOwnerOrganizationId") != self.tenant_id,
"permission_type": "Application",
"scope": role.get("appRoleId"),
"consent_type": "AdminConsent",
"granted_date": role.get("createdDateTime"),
"is_enabled": sp.get("accountEnabled", True)
})
return inventory
根据数据访问敏感性对权限进行分类:
"""
OAuth 范围风险分类
根据数据敏感性和访问广度将 API 范围映射到风险等级。
"""
MICROSOFT_GRAPH_SCOPE_RISK = {
# 严重 - 完全管理或不受限制的访问
"critical": {
"scopes": [
"Directory.ReadWrite.All",
"RoleManagement.ReadWrite.Directory",
"Application.ReadWrite.All",
"AppRoleAssignment.ReadWrite.All",
"Mail.ReadWrite",
"Mail.Send",
"Files.ReadWrite.All",
"Sites.FullControl.All",
"User.ReadWrite.All",
"Group.ReadWrite.All",
"MailboxSettings.ReadWrite",
"full_access_as_app",
],
"risk_description": "可读写所有数据、修改目录或模拟用户",
"review_frequency": "每月",
"requires_admin_consent": True
},
# 高 - 广泛读取访问或敏感数据写入
"high": {
"scopes": [
"Mail.Read",
"Mail.Read.Shared",
"Calendars.ReadWrite",
"Contacts.ReadWrite",
"Files.Read.All",
"Sites.Read.All",
"User.Read.All",
"Group.Read.All",
"Directory.Read.All",
"AuditLog.Read.All",
"SecurityEvents.ReadWrite.All",
"TeamSettings.ReadWrite.All",
],
"risk_description": "对组织敏感数据具有广泛读取访问",
"review_frequency": "每季度",
"requires_admin_consent": True
},
# 中 - 范围限定的数据访问
"medium": {
"scopes": [
"Calendars.Read",
"Contacts.Read",
"Files.ReadWrite",
"Sites.ReadWrite.All",
"Tasks.ReadWrite",
"Notes.ReadWrite.All",
"Chat.ReadWrite",
"ChannelMessage.Send",
"Team.ReadBasic.All",
],
"risk_description": "对特定数据类型具有写入能力的范围限定访问",
"review_frequency": "每半年"
},
# 低 - 最小或仅用户配置文件访问
"low": {
"scopes": [
"User.Read",
"openid",
"profile",
"email",
"offline_access",
"Calendars.Read.Shared",
"People.Read",
"User.ReadBasic.All",
],
"risk_description": "基本用户配置文件或最小范围限定访问",
"review_frequency": "每年"
}
}
def classify_scope_risk(scope):
"""对单个 OAuth 范围按风险等级分类。"""
for risk_level, config in MICROSOFT_GRAPH_SCOPE_RISK.items():
if scope in config["scopes"]:
return {
"scope": scope,
"risk_level": risk_level,
"description": config["risk_description"],
"review_frequency": config["review_frequency"]
}
# 未知范围默认为高风险
return {
"scope": scope,
"risk_level": "high",
"description": "未知范围 - 需要手动分类",
"review_frequency": "每季度"
}
def analyze_app_risk(app_permissions):
"""计算应用权限的综合风险评分。"""
risk_weights = {"critical": 40, "high": 20, "medium": 10, "low": 2}
total_score = 0
classified_scopes = []
for perm in app_permissions:
classification = classify_scope_risk(perm["scope"])
classified_scopes.append(classification)
total_score += risk_weights.get(classification["risk_level"], 10)
# 应用类型权限(vs 委托权限)的额外风险加分
app_type_permissions = [p for p in app_permissions if p["permission_type"] == "Application"]
total_score += len(app_type_permissions) * 15
# 管理员同意的广泛访问的额外风险加分
admin_consent = [p for p in app_permissions if p["consent_type"] == "AllPrincipals"]
total_score += len(admin_consent) * 10
if total_score >= 100:
aggregate_risk = "CRITICAL"
elif total_score >= 60:
aggregate_risk = "HIGH"
elif total_score >= 30:
aggregate_risk = "MEDIUM"
else:
aggregate_risk = "LOW"
return {
"total_score": total_score,
"aggregate_risk": aggregate_risk,
"scope_count": len(app_permissions),
"critical_scopes": len([s for s in classified_scopes if s["risk_level"] == "critical"]),
"high_scopes": len([s for s in classified_scopes if s["risk_level"] == "high"]),
"classified_scopes": classified_scopes
}
检测请求权限超出功能需求的应用:
"""
过度授权检测
识别 OAuth 范围相对于其功能而言过度的应用。
"""
def detect_over_permissions(inventory, approved_apps_catalog):
"""
将实际权限与已批准的范围目录进行比对,
找出过度授权的应用。
"""
findings = []
# 按应用分组权限
app_permissions = defaultdict(list)
for perm in inventory:
app_permissions[perm["app_name"]].append(perm)
for app_name, permissions in app_permissions.items():
# 与已批准目录进行比对
approved = approved_apps_catalog.get(app_name)
if not approved:
# 未知/未批准的应用
findings.append({
"app_name": app_name,
"finding_type": "UNAPPROVED_APPLICATION",
"severity": "HIGH",
"detail": f"应用不在已批准目录中,共有 {len(permissions)} 个权限授予",
"scopes": [p["scope"] for p in permissions],
"recommendation": "审查并批准或撤销所有权限"
})
continue
approved_scopes = set(approved.get("approved_scopes", []))
actual_scopes = set(p["scope"] for p in permissions)
# 找出过多范围(已授予但未批准)
excessive = actual_scopes - approved_scopes
if excessive:
risk = analyze_app_risk([p for p in permissions if p["scope"] in excessive])
findings.append({
"app_name": app_name,
"finding_type": "EXCESSIVE_SCOPES",
"severity": risk["aggregate_risk"],
"detail": f"{len(excessive)} 个超出已批准列表的范围",
"excessive_scopes": list(excessive),
"approved_scopes": list(approved_scopes),
"recommendation": "移除过多范围或更新已批准目录"
})
# 找出未使用的范围(已批准但活动日志显示无 API 调用)
# 这需要与 API 活动日志进行关联
unused = approved_scopes - actual_scopes
if unused:
findings.append({
"app_name": app_name,
"finding_type": "UNUSED_APPROVED_SCOPES",
"severity": "LOW",
"detail": f"{len(unused)} 个已批准范围当前未授予",
"unused_scopes": list(unused)
})
# 检查过于宽泛的权限
broad_patterns = [
("Mail.ReadWrite", "Mail.Read", "仅需读取权限时却授予了邮件写入权限"),
("Files.ReadWrite.All", "Files.Read.All", "仅需读取权限时却授予了所有文件写入权限"),
("Directory.ReadWrite.All", "Directory.Read.All", "仅需读取权限时却授予了目录写入权限"),
("User.ReadWrite.All", "User.Read.All", "仅需读取权限时却授予了用户写入权限"),
]
for broad, narrow, description in broad_patterns:
if broad in actual_scopes:
findings.append({
"app_name": app_name,
"finding_type": "OVERLY_BROAD_SCOPE",
"severity": "MEDIUM",
"detail": description,
"current_scope": broad,
"recommended_scope": narrow,
"recommendation": f"从 {broad} 降级为 {narrow}"
})
return findings
识别不再活跃使用的 OAuth 令牌:
"""
令牌使用情况审计
分析登录日志和 API 活动,识别过期的 OAuth 授权。
"""
def audit_token_usage(auditor, days_inactive=90):
"""识别近期无 API 活动的 OAuth 授权。"""
# 获取服务主体的登录活动
url = f"{auditor.base_url}/auditLogs/signIns"
params = {
"$filter": f"createdDateTime ge {(datetime.utcnow() - timedelta(days=days_inactive)).isoformat()}Z and signInEventTypes/any(t: t eq 'servicePrincipal')",
"$top": 999
}
active_apps = set()
while url:
response = requests.get(url, headers=auditor.headers, params=params)
data = response.json()
for signin in data.get("value", []):
active_apps.add(signin.get("appId"))
url = data.get("@odata.nextLink")
params = {}
# 与所有已授权应用进行比对
all_grants = auditor.get_oauth2_permission_grants()
sp_map = {sp["id"]: sp for sp in auditor.get_all_service_principals()}
stale_grants = []
for grant in all_grants:
sp = sp_map.get(grant["clientId"], {})
app_id = sp.get("appId")
if app_id and app_id not in active_apps:
stale_grants.append({
"app_name": sp.get("displayName", "Unknown"),
"app_id": app_id,
"scopes": grant.get("scope", "").split(),
"consent_type": grant.get("consentType"),
"is_third_party": sp.get("appOwnerOrganizationId") != auditor.tenant_id,
"days_inactive": days_inactive,
"recommendation": "撤销 - {days_inactive} 天内无 API 活动"
})
return sorted(stale_grants, key=lambda x: len(x["scopes"]), reverse=True)
创建并执行范围最小化修复计划:
"""
OAuth 范围修复
生成并执行范围缩减操作。
"""
def generate_remediation_plan(findings, stale_grants):
"""创建优先级排序的修复计划。"""
plan = []
# 优先级 1:撤销未批准的应用
for f in findings:
if f["finding_type"] == "UNAPPROVED_APPLICATION":
plan.append({
"priority": 1,
"action": "REVOKE_ALL_PERMISSIONS",
"app_name": f["app_name"],
"reason": "未批准的第三方应用",
"impact": f"移除 {len(f['scopes'])} 个权限授予",
"risk_if_not_addressed": "CRITICAL"
})
# 优先级 2:移除已批准应用的过多范围
for f in findings:
if f["finding_type"] == "EXCESSIVE_SCOPES":
plan.append({
"priority": 2,
"action": "REMOVE_EXCESSIVE_SCOPES",
"app_name": f["app_name"],
"scopes_to_remove": f["excessive_scopes"],
"reason": "超出已批准目录的范围",
"risk_if_not_addressed": f["severity"]
})
# 优先级 3:降级过于宽泛的范围
for f in findings:
if f["finding_type"] == "OVERLY_BROAD_SCOPE":
plan.append({
"priority": 3,
"action": "DOWNGRADE_SCOPE",
"app_name": f["app_name"],
"current_scope": f["current_scope"],
"target_scope": f["recommended_scope"],
"reason": f["detail"],
"risk_if_not_addressed": "MEDIUM"
})
# 优先级 4:撤销过期授权
for grant in stale_grants:
plan.append({
"priority": 4,
"action": "REVOKE_STALE_GRANT",
"app_name": grant["app_name"],
"scopes_to_revoke": grant["scopes"],
"reason": f"{grant['days_inactive']} 天内无 API 活动",
"risk_if_not_addressed": "MEDIUM"
})
return sorted(plan, key=lambda x: x["priority"])
def execute_scope_reduction(auditor, grant_id, scopes_to_remove):
"""从 OAuth 权限授予中移除特定范围。"""
# 获取当前授权
url = f"{auditor.base_url}/oauth2PermissionGrants/{grant_id}"
response = requests.get(url, headers=auditor.headers)
current_grant = response.json()
current_scopes = set(current_grant.get("scope", "").split())
updated_scopes = current_scopes - set(scopes_to_remove)
if not updated_scopes:
# 移除整个授权
requests.delete(url, headers=auditor.headers)
return {"action": "grant_deleted", "grant_id": grant_id}
else:
# 使用缩减后的范围进行更新
update_body = {"scope": " ".join(updated_scopes)}
requests.patch(url, headers=auditor.headers, json=update_body)
return {
"action": "scopes_reduced",
"grant_id": grant_id,
"removed": list(scopes_to_remove),
"remaining": list(updated_scopes)
}
| 术语 | 定义 |
|---|---|
| OAuth 范围 | 定义授予客户端应用的特定 API 访问级别的权限字符串(例如:Mail.Read、Files.ReadWrite.All) |
| 委托权限 | 代表已登录用户行使的 OAuth 范围,受应用权限和用户自身访问权的双重限制 |
| 应用权限 | 无需用户上下文直接授予应用的 OAuth 范围,可访问所有用户数据(高风险) |
| 管理员同意 | 由管理员做出的租户级权限授予,无需个人同意即可应用于所有用户 |
| 范围最小化 | 将 OAuth 权限减少到应用功能所需最小集合的安全原则 |
| 过期授权 | 保持活跃但近期无 API 使用记录的 OAuth 权限,表明集成已废弃或过时 |
背景:一次钓鱼攻击导致管理员账户被入侵,调查发现攻击者注册了一个带有 Mail.ReadWrite 和 Files.ReadWrite.All 范围的恶意 OAuth 应用,窃取了 6 个月的电子邮件。组织需要进行全面的 OAuth 范围审查。
处理方法:
注意事项:
OAUTH 范围最小化审查报告
=========================================
租户: corp.onmicrosoft.com
审查周期: 2026-02-01 至 2026-02-24
应用总数: 147
第三方应用: 98
第一方应用: 49
权限清单
OAuth 授权总数: 487
委托权限: 312
应用权限: 175
管理员同意: 89
用户同意: 223
风险分类
严重风险应用: 7
- UnknownCRMApp (Mail.ReadWrite, Files.ReadWrite.All - 未批准)
- LegacySync (Directory.ReadWrite.All - 过多权限)
- DevToolX (Application.ReadWrite.All - 权限过于宽泛)
高风险应用: 18
中风险应用: 34
低风险应用: 88
发现问题
未批准应用: 12(立即撤销)
过多范围: 23 个应用的范围超出已批准列表
过于宽泛的权限: 15 个应用可降级处理
过期授权(90天以上): 31 个应用近期无 API 活动
修复计划
优先级 1(立即): 12 个未批准应用撤销
优先级 2(本周): 23 个过多范围移除
优先级 3(本月): 15 个范围降级
优先级 4(下季度): 31 个过期授权撤销
预计范围缩减: 总权限的 34%