Install the "app-registration-posture" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/app-registration-posture into .claude/skills/app-registration-posture/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "app-registration-posture", then confirm the skill loads.
Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Type this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
skills CLI
$ npx skills add SCStelz/security-investigator --skill app-registration-posture -a codex
Project install goes to .agents/skills/; add -g for ~/.codex/skills/.
Install the "app-registration-posture" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/app-registration-posture into .agents/skills/app-registration-posture/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "app-registration-posture", then confirm the skill loads.
Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
skills CLI
$ npx skills add SCStelz/security-investigator --skill app-registration-posture -a cursor
Project install goes to .agents/skills/; add -g for ~/.cursor/skills/.
Install the "app-registration-posture" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/app-registration-posture into .cursor/skills/app-registration-posture/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "app-registration-posture", then confirm the skill loads.
Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
skills CLI
$ npx skills add SCStelz/security-investigator --skill app-registration-posture -a gemini-cli
Project install goes to .agents/skills/; add -g for ~/.gemini/skills/.
Install the "app-registration-posture" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/app-registration-posture into .gemini/skills/app-registration-posture/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "app-registration-posture", then confirm the skill loads.
Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Installs for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
skills CLI
$ npx skills add SCStelz/security-investigator --skill app-registration-posture -a github-copilot
Project install goes to .agents/skills/; add -g for ~/.copilot/skills/.
Install the "app-registration-posture" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/app-registration-posture into .github/skills/app-registration-posture/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "app-registration-posture", then confirm the skill loads.
GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
skills CLI
$ npx skills add SCStelz/security-investigator --skill app-registration-posture -a opencode
OpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
Install the "app-registration-posture" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/app-registration-posture into .opencode/skills/app-registration-posture/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "app-registration-posture", then confirm the skill loads.
OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Facts
Skill name
app-registration-posture
GitHub stars
249
Token cost
~21k tokens
SKILL.md length
5,844 words
Files
2
Skills in repo
22
Repo updated
First seen
Licence
MIT
At a glance
Audit Entra ID app registration and service principal security posture.
Works in 12 steps: Prerequisites → Graph API Posture Inventory (Steps P1–P7) → KQL Chain Detection (Q1–Q8) → …
Keywords like app registration posture
SKILL.md covers Purpose, Threat Landscape: Why App…, 📑 TABLE OF CONTENTS and ⚠️ CRITICAL WORKFLOW RULES -…, plus 6 more sections
Reaches learn.microsoft.com and attack.mitre.org
What it does
App Registration Posture is an agent skill from SCStelz/security-investigator. Audit Entra ID app registration and service principal security posture. Triggers on keywords like "app registration posture", "service principal permissions", "dangerous app permissions", "app ownership", "app credential abuse", "SPN lateral movement", "app consent grant", "overprivileged apps", "cross-tenant SPN", "app registration kill chain", "app persistence", "credential add chain", "Graph API permissions audit". Combines Graph API current-state inventory (dangerous permissions, ownership, credential…
Its SKILL.md is about 21k tokens, which your agent loads only when the skill is triggered. The skill folder holds 1 other file (for example `svg-widgets.yaml`).
It sits in Security, covering Red teaming and adversary simulation. It works with Microsoft Entra ID and Microsoft Sentinel. The repository describes itself as: Automated security investigation tool using Microsoft MCP Servers, GitHub Copilot, Python Modules and custom copilot-instructions. The licence is MIT.
When your agent uses it
Keywords like app registration posture
Service principal permissions
Dangerous app permissions
App credential abuse
Example prompts
“app registration posture”
“service principal permissions”
“dangerous app permissions”
“/app-registration-posture”
Workflow steps
12 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit 51e1385. It shows what the files ask for, not the result of running them.
Tool permissions
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Runs code
No scripts in the folder and no shell commands in SKILL.md (its code samples are kql, markdown and powershell).
From the folder's file list and the shell code blocks in SKILL.md.
Network
Hosts in commands or code, which the agent is likely to contact:
learn.microsoft.com
attack.mitre.org
guardz.com
microsoft.com
From URLs in SKILL.md, links to its own repository left out.
Credentials
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Context cost
App Registration Posture loads about 21k tokens when it runs. Until then it costs about 220 tokens; SKILL.md has 5,844 words of instructions outside code blocks.
Always· name and description, kept in context so the agent knows when to use it
~220
When it runs· the whole SKILL.md, loaded when a task matches
~21k
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
Safety
Auto-check passed
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
Download SKILL.mdSave it as .claude/skills/app-registration-posture/SKILL.md (or your agent's skills folder). This skill also uses 1 other file; get the full folder from GitHub.
name
app-registration-posture
description
Audit Entra ID app registration and service principal security posture. Triggers on keywords like "app registration posture", "service principal permissions", "dangerous app permissions", "app ownership", "app credential abuse", "SPN lateral movement", "app consent grant", "overprivileged apps", "cross-tenant SPN", "app registration kill chain", "app persistence", "credential add chain", "Graph API permissions audit". Combines Graph API current-state inventory (dangerous permissions, ownership, credential hygiene) with KQL chain detection (AuditLogs, AADServicePrincipalSignInLogs, AADUserRiskEvents, MicrosoftGraphActivityLogs) for posture assessment covering permission concentration, owner risk, credential hygiene, cross-tenant exposure, and active abuse signals. Includes 5-dimension App Permission Risk Score. Inline chat or markdown output.
This skill audits the security posture of Entra ID App Registrations and Service Principals across your organization, combining Graph API current-state inventory with KQL attack chain detection to create a comprehensive assessment.
App Registrations are a growing persistence and lateral movement vector. Attackers who compromise a user with app ownership can add credentials (secrets/certificates), disconnect from the user session, and authenticate as the service principal — inheriting all the app's permissions. This is the exact pattern documented in the Guardz research and used in the SolarWinds/Solorigate attack.
What this skill covers:
Domain
Key Questions Answered
Data Source
🔐 Permission Inventory
Which apps have dangerous Graph API permissions? How concentrated are critical permissions?
Graph API
👤 Owner Risk
Which app owners are non-admin users (phishing targets)? Are owners currently risky? Ownerless apps?
Graph API + Q1
🔑 Credential Hygiene
Stale secrets, multi-credential apps, long-lived credentials, cert+secret anomalies
Graph API
🌐 Cross-Tenant Exposure
Foreign SPNs authenticating into your tenant with dangerous permissions
🔴 URL Registry — Canonical Links for Report Generation
MANDATORY: When generating reports, copy URLs verbatim from this registry. NEVER construct, guess, or paraphrase a URL. If a URL is not in this registry, omit the hyperlink entirely and use plain text.
The attack pattern is well-documented and increasingly exploited:
User compromised → discovers app ownership → adds credential (secret/cert) →
disconnects from user session → authenticates AS the app (SPN) →
uses app permissions for lateral movement / data exfiltration / privilege escalation
Why app registrations are attractive to attackers:
Factor
Risk
Persistence beyond user compromise
Revoking the user's password doesn't revoke the app credential — the SPN continues to operate
Non-admin users as owners
Standard users can own apps with Application.ReadWrite.All — if phished, the attacker inherits those permissions
Permissions outlive their creators
App permissions persist even after the admin who granted them leaves the org
Cross-tenant trust
Multi-tenant apps create implicit trust relationships that survive account remediation
Low visibility
SPN sign-ins are in a separate log table (AADServicePrincipalSignInLogs) that many SOCs don't monitor
MITRE ATT&CK Mapping:
Technique
ID
Kill Chain Stage
Detection Query
Additional Cloud Credentials
T1098.001
Persistence
Q2, Q3
Additional Cloud Roles
T1098.003
Privilege Escalation
Q6
Cloud Accounts
T1078.004
Initial Access / Persistence
Q1
Application Access Token
T1550.001
Lateral Movement
Q2, Q5
SAML/OAuth Tokens
T1606.002
Credential Access
Q4
Impersonation
T1656
Defense Evasion
Q4
Q8 note: Q8 (App Governance & OAuth Incident Cross-Reference) is a detection validation query, not a technique-specific detector. It cross-references existing Defender detections spanning multiple techniques above against Phase 1 findings.
Dual data source skill: This skill uses BOTH Graph API (via Graph MCP) for current-state posture AND KQL (via RunAdvancedHuntingQuery) for historical chain detection. Both phases are required for a complete assessment.
Graph API before KQL: Run Phase 1 (Graph API posture) first — it identifies the dangerous apps. Phase 2 (KQL chains) then checks whether those apps show historical abuse signals.
Use RunAdvancedHuntingQuery for all KQL queries. All tables used (AuditLogs, AADServicePrincipalSignInLogs, AADUserRiskEvents, MicrosoftGraphActivityLogs, AlertInfo, AlertEvidence) are available in Advanced Hunting. AH is free for Analytics-tier tables. Data Lake fallback only if AH fails or lookback > 30 days (note: AlertInfo/AlertEvidence are AH-only).
ASK the user for output format before generating the report:
Inline chat summary (quick review in chat)
Markdown file report (detailed, archived to reports/app-registration-posture/)
Both (markdown + inline summary)
⛔ MANDATORY: Evidence-based analysis only — Report ONLY what query results show. Use the explicit absence pattern (✅ No [finding] detected) when queries return 0 results. Never guess or assume.
AuditLogs dynamic fields require special handling — Always extract with tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName). See Schema Pitfalls.
Graph API: query from the permission side, not the app side — Don't enumerate all app registrations (could be 1000+). Query appRoleAssignedTo on the Microsoft Graph service principal to get all dangerous grants in ~3 API calls. See Phase 1 Scaling Strategy.
Run KQL queries in parallel batches where possible — Q1–Q8 are all independent and can run in parallel.
Time tracking — Report elapsed time after each phase completion.
⛔ PROHIBITED ACTIONS
Action
Status
Enumerating all app registrations individually via Graph API
❌ PROHIBITED — use appRoleAssignedTo approach
Querying requiredResourceAccess for granted permissions
❌ PROHIBITED — shows requested, not granted perms
Querying ServicePrincipal for ownership (/servicePrincipals/{id}?$expand=owners)
❌ PROHIBITED — ownership is on Application object
Joining AuditLog operations on TargetResources[0].id across operation types
❌ PROHIBITED — AppId ≠ SPNId for same app
Reporting 0 KQL results without sanity-checking the query logic
❌ PROHIBITED
Fabricating URLs not in the URL Registry
❌ PROHIBITED
Schema Pitfalls
Read these before modifying any query in this skill.
Pitfall
Details
Workaround
Application ObjectId ≠ ServicePrincipal ObjectId
The same app has different GUIDs in TargetResources[0].id depending on operation type. Credential operations → Application ObjectId; permission/consent operations → ServicePrincipal ObjectId
Join on displayName or Actor when correlating across operation types (see Q6)
Ownership target name in modifiedProperties
For "Add owner to application", TargetResources[0] is the new owner (User type). The app name is in TargetResources[0].modifiedProperties[1].newValue (field Application.DisplayName)
Extract with tostring(parse_json(tostring(ModProps[1].newValue)))
OperationName trailing spaces
Credential operations have trailing spaces: "Update application – Certificates and secrets management "
Preserve trailing spaces in filters or use has instead of ==
InitiatedBy is dynamic
Always extract with tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName)
Never use dot-notation directly
Consent targets structure
"Consent to application": Target[0] = the app receiving consent. "Add delegated permission grant": Target[0] = the resource API (e.g., Microsoft Graph), Target[1] = the app
Check OperationName before assuming Target[0] is the app
Cross-tenant SPNs have no local app object
GET /v1.0/applications?$filter=displayName eq 'X' returns empty for SPNs owned by foreign tenants
Identify via AADServicePrincipalSignInLogs where AppOwnerTenantId != AADTenantId (Q4). These can only be managed by the owning tenant
SP owners ≠ Application owners
/servicePrincipals/{id}?$expand=owners often returns empty even when the Application has owners
Always query the Application object for ownership
requiredResourceAccess ≠ granted permissions
The Application object's requiredResourceAccess shows what the app requests, not what's been granted
Use appRoleAssignedTo for granted permissions — this is the authoritative source
Red team apps may have owners stripped
Attack simulation tools often remove ownership post-creation
Fall back to AuditLogs "Add application" operation to find the original creator
Dangerous Permissions Reference
Application-level Graph API grants that this skill flags:
Permission
Risk
Attack Use
Application.ReadWrite.All
🔴 Critical
Create/modify any app registration — further persistence
AppRoleAssignment.ReadWrite.All
🔴 Critical
Grant itself or any app any permission — golden ticket
RoleManagement.ReadWrite.Directory
🔴 Critical
Assign any directory role to any principal
Directory.ReadWrite.All
🔴 Critical
Read/write all directory objects
Policy.ReadWrite.ConditionalAccess
🔴 Critical
Disable CA policies — defense evasion
Mail.ReadWrite
🟠 High
Read any user's mailbox — data exfiltration
Mail.Send
🟠 High
Send email as any user — phishing, BEC
Mail.Read
🟠 High
Read any user's mail — reconnaissance
MailboxSettings.ReadWrite
🟠 High
Create forwarding rules — silent exfiltration
User.ReadWrite.All
🟠 High
Modify any user account — credential reset
Group.ReadWrite.All
🟠 High
Modify group membership — privilege escalation
Files.ReadWrite.All
🟠 High
Access all SharePoint/OneDrive files
Sites.ReadWrite.All
🟠 High
Full SharePoint site access
SecurityEvents.ReadWrite.All
🟡 Medium
Read/modify security alerts — cover tracks
User.Export.All
🟡 Medium
Export all user data — bulk exfiltration
Exchange.ManageAsApp
🟡 Medium
Full Exchange management — mailbox access
Permission risk classification for scoring:
Critical (🔴): Permissions that enable self-elevation or directory-wide control — 5 permissions listed above
High (🟠): Permissions that enable data access or account manipulation — 8 permissions listed above
Medium (🟡): Permissions that enable reconnaissance or secondary access — 3 permissions listed above
🔴 Delegated vs Application Permissions — Risk Model
This skill focuses on application permissions (appRoleAssignments) because they represent unattended, user-independent privilege. Delegated permissions (oauth2PermissionGrants) are a fundamentally different risk category. Do not conflate the two.
Why This Distinction Matters
Factor
Application Permissions (appRoleAssignments)
Delegated Permissions (oauth2PermissionGrants)
Identity
App acts as its own identity — no user context required
App acts on behalf of a signed-in user
Effective permissions
The full granted scope — the app CAN do everything the permission allows
Intersection of app's delegated scope AND the user's own Entra roles — the app can only do what the user could already do
Unattended access
✅ Yes — runs 24/7 via client credentials or managed identity
❌ No — requires a user session (interactive or refresh token)
Blast radius
The permission itself IS the blast radius — Directory.ReadWrite.All means full directory write for the app, regardless of who triggered it
Bounded by the user's roles — a standard user with Directory.ReadWrite.All delegated consent still can't write to the directory because they lack the Entra role
Token theft impact
Stolen app credential = full permission scope, no MFA challenge
Stolen user token = only the user's own effective permissions, bounded by their roles
Risk priority
🔴 Primary concern — this skill's focus
🟡 Secondary concern — relevant only for privileged admin accounts
What AllPrincipals Delegated Consent Actually Does
An AllPrincipals (admin consent) delegated grant removes the per-user consent prompt — it does NOT grant users abilities beyond their existing Entra roles. The practical impact:
Standard users: Effectively no additional risk. The app can request tokens with broad scopes, but the effective permissions are still limited by the user's role assignments. A user without Exchange Admin role cannot manage mailboxes even if Mail.ReadWrite is consented.
Privileged admins: Marginal incremental risk. The consent prompt is removed as a speed bump, so a stolen admin session can silently acquire tokens with the consented scopes — but the admin could have granted that consent themselves in one click anyway.
Token theft for admins: The real scenario where delegated consent matters. An attacker with a stolen Global Admin refresh token can silently use any AllPrincipals-consented scope without triggering a consent dialog. However, the admin already had the ability to do everything those scopes enable.
How This Affects Skill Analysis
Phase 1 (P2) queries appRoleAssignedTo — these are application permissions. This is correct and intentional. The Dangerous Permissions Reference table above applies to application-level grants only.
Chain detection queries (Q1, Q3, Q6) detect "Consent to application" and "Add delegated permission grant" in AuditLogs — these detect the act of granting consent, which is a valid abuse signal regardless of permission type (a compromised user granting broad consent is suspicious). The risk assessment should focus on what the user then DOES with the consented access, not on the scope list itself.
When assessing consent grants in chain detection output:
A compromised user adding application permissions (Add app role assignment to service principal) = 🔴 Critical — the app gains independent, unattended access
A compromised user granting delegated consent (Consent to application, Add delegated permission grant) = 🟠 High if the user is a privileged admin, 🟡 Medium for standard users — the effective permissions are bounded by the user's roles
Do NOT overstate delegated AllPrincipals consent risk. Reporting 100+ delegated scopes as "dangerous" without explaining the intersection model misleads stakeholders into believing any user can exploit those scopes. Always qualify: "Effective delegated permissions are limited to what each user's Entra roles already allow."
When Delegated Permissions ARE Concerning
Despite the lower baseline risk, flag delegated consents when:
Scenario
Why It Matters
AllPrincipals consent on a 3rd-party (non-Microsoft) app with broad scopes
The app vendor could be compromised, and the consent enables data access for any admin session
A compromised admin granting broad delegated consent may be preparing for token-based lateral movement
App has BOTH application permissions AND broad delegated consent
Dual permission model = dual attack surface
AllPrincipals consent for offline_access + sensitive scopes on a public client app
Enables refresh token persistence without re-authentication
⛔ PROHIBITED Actions
Action
Status
Stating that AllPrincipals delegated consent gives "any user" access to the scoped resources
❌ PROHIBITED — effective permissions = intersection with user's roles
Rating delegated consent scopes at the same severity as identical application permission scopes
❌ PROHIBITED — application permissions are unattended and user-independent
Omitting the delegated-vs-application distinction when presenting permission findings
❌ PROHIBITED — always clarify which permission type is being discussed
Ignoring delegated consent entirely
❌ PROHIBITED — it is a secondary risk that matters for privileged accounts
App Permission Risk Score Formula
The App Permission Risk Score is a composite risk indicator summarizing the security posture of your organization's app registration and service principal fleet. Higher scores indicate greater risk.
Each dimension contributes 0–20 points to a maximum of 100:
Dimension
Max
🟢 Low (0–5)
🟡 Medium (6–12)
🔴 High (13–20)
Permission Concentration
20
0–2 apps with dangerous perms; 0 critical-tier perms
3–5 apps with dangerous perms; ≤1 app with ≥3 critical-tier perms
>5 apps with dangerous perms OR ≥2 apps with ≥3 critical-tier perms OR any app with AppRoleAssignment.ReadWrite.All (golden ticket → auto 16+)
Owner Risk
20
All flagged apps have admin owners; 0 ownerless dangerous apps
1–2 ownerless dangerous apps; OR non-admin owner on 🟠-level app
≥3 ownerless apps with dangerous perms OR non-admin owner on 🔴-level app OR any app owner with active Identity Protection risk (atRisk/confirmedCompromised)
Credential Hygiene
20
All apps ≤1 active credential; all secrets <180 days old; 0 dormant privileged apps
Any app with 2 active secrets; OR any secret 180d–730d old; OR 1 dormant privileged app
Any app with ≥3 active secrets + critical perms; OR any secret >730d old (2yr); OR cert+secret on same critical app
Cross-Tenant Exposure
20
0 foreign SPNs with dangerous perms
1–2 foreign SPNs with 🟠-level perms; all from known/identified partner tenants
Any foreign SPN with 🔴 critical perms (AppRoleAssignment.ReadWrite.All, Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory, Policy.ReadWrite.ConditionalAccess) OR foreign SPN from unidentified tenant
Active Abuse Signals
20
Q1–Q8 all return 0 non-pipeline results
Q1–Q7 return only 🟡-priority results (after pipeline collapse); OR only suspiciousAuthAppApproval self-referencing chains; OR Q8 returns only App Governance “Unused”/“Expiring” alerts with no XDR/MCAS overlap
Q1 returns any chain with adminConfirmedUserCompromised or confirmedCompromised (→ auto 15+); OR Q6 returns 🔴-priority cred→consent chain from a user with active Identity Protection risk; OR Q8 returns apps with DetectionBreadth ≥2 (multi-source detections) or any Attack Disruption incident
Scoring Anchors (Deterministic Rules)
Apply these anchors BEFORE adjusting within bands. They set a floor for the dimension score:
Condition
Dimension
Minimum Score
AppRoleAssignment.ReadWrite.All granted to ANY app
Permission Concentration
16
Any app owner has adminConfirmedUserCompromised
Owner Risk
15
Any secret >730 days old on an app with critical perms
Credential Hygiene
14
Foreign SPN with AppRoleAssignment.ReadWrite.All
Cross-Tenant Exposure
17
Q1 chain with adminConfirmedUserCompromised → app consent
Active Abuse Signals
15
Q8 returns any Attack Disruption incident for an app in Phase 1
Active Abuse Signals
16
Q8 returns app with DetectionBreadth ≥3 AND in Phase 1 flagged list
Active Abuse Signals
14
All Q1–Q8 non-pipeline results = 0
Active Abuse Signals
≤5 (cap)
Interpretation Scale
Score
Rating
Action
0–20
✅ Healthy
Normal posture, routine monitoring
21–45
🟡 Elevated
Review — minor permission sprawl or credential age detected
46–70
🟠 Concerning
Investigate — multiple risk signals across dimensions
71–100
🔴 Critical
Immediate remediation — active abuse chains or critical permission concentration
Execution Workflow
Phase 0: Prerequisites
Confirm Graph MCP (mcp_graph-mcp-ser) is available for posture queries
Confirm RunAdvancedHuntingQuery is available for chain detection
Ask user for output format (inline / markdown / both)
Ask user for lookback period (default: 30 days for KQL queries)
Phase 1: Graph API Posture Inventory (Steps P1–P7)
Sequential — each step depends on the previous.
Step
Purpose
API Call(s)
P1
Find Microsoft Graph service principal ID in tenant
1 call
P2
List ALL application permission grants to Microsoft Graph
1 call (paginated) — save to temp/p2_grants.json
P3
Resolve permission GUIDs to human-readable names
1 call — run in parallel with P2 — save to temp/p3_approles.json
P4
Filter to dangerous permissions (PowerShell script)
0 API calls — joins P2+P3 JSON, outputs flagged apps
P5
Resolve owners for flagged apps
N calls (only flagged apps)
P6
Assess owner risk (directory roles)
M calls (only flagged owners)
P7
Credential hygiene check (from P5 response)
0 calls
Total: 3 + N + M calls (typically < 20 for most tenants)
Phase 2: KQL Chain Detection (Q1–Q8)
Run in parallel — no dependencies between queries. Q8 uses a 90-day lookback (incident data is sparser); Q1–Q7 use 30 days.
Query
Purpose
Tables
Kill Chain Stage
Q1
Risky User → App Operations Chain
AADUserRiskEvents + AuditLogs
Compromise → App Abuse
Q2
Credential Add → SPN Activation
AuditLogs + AADServicePrincipalSignInLogs
Persistence → SPN Impersonation
Q3
Ownership Add → Credential Modification Chain
AuditLogs (self-join)
Privilege Escalation → Persistence
Q4
Cross-Tenant SPN Sign-Ins
AADServicePrincipalSignInLogs
Lateral Movement (cross-tenant)
Q5
Credential Add → SPN Graph API Lateral Movement
AuditLogs + MicrosoftGraphActivityLogs
Lateral Movement / Data Exfiltration
Q6
Credential Add → Permission Escalation Chain
AuditLogs (self-join)
Persistence → Privilege Escalation
Q7
Multi-App Ownership Spread
AuditLogs
Persistence (breadth)
Q8
App Governance & OAuth Incident Cross-Reference
AlertInfo + AlertEvidence
Detection Validation
Phase 3: Score Computation & Report Generation
Compute per-dimension scores from Phase 1 and Phase 2 data
Sum dimension scores for composite App Permission Risk Score
Generate report in requested output mode
Report total elapsed time
Phase 1: Graph API Posture Inventory
Scaling Strategy: Don't enumerate all app registrations (could be 1000+). Query from the permission grant side — find what's been granted dangerous permissions, then resolve owners only for those flagged apps.
Step P1: Find the Microsoft Graph Service Principal ID
The Microsoft Graph resource service principal is the target of all application permission grants. Its well-known AppId is 00000003-0000-0000-c000-000000000000, but its ObjectId varies per tenant.
GET /v1.0/servicePrincipals?$filter=appId eq '00000003-0000-0000-c000-000000000000'&$select=id,displayName
Save the returned id — you'll need it for Steps P2 and P3.
Step P2: List ALL Application Permission Grants to Microsoft Graph
This single call returns every app in the tenant that has been granted application-level permissions (not delegated) to Microsoft Graph.
GET /v1.0/servicePrincipals/{graph-sp-id}/appRoleAssignedTo
?$select=principalDisplayName,principalId,principalType,appRoleId,createdDateTime
&$top=999
Returns: One row per permission grant. Each row contains:
principalDisplayName — app name
principalId — ServicePrincipal ObjectId
appRoleId — permission GUID
createdDateTime — when the permission was granted
Post-processing: Group by principalDisplayName to get the per-app permission list.
⚠️ Large Response Handling: P2 can return hundreds of rows (one per permission grant across all apps). When the response is large:
Save P2 and P3 responses to temp/ as JSON files before processing — this prevents data loss if context gets truncated
Run P2 and P3 in parallel — they are independent (P3 only needs the Graph SP ID from P1, same as P2)
Use PowerShell for the GUID→name join and dangerous-permission filter — do NOT attempt to parse large JSON in-context. Write a script that:
Loads P2 grants + P3 appRoles from the saved JSON files
Builds the appRoleId → value lookup map
Filters to dangerous permissions
Groups by app name
Outputs the flagged-app summary (app name, dangerous perms, grant dates, principalId)
Only bring the filtered summary back into context — the full P2/P3 data stays in temp files for reference
This script replaces the manual P3/P4 steps — it does the GUID resolution AND dangerous-permission filtering in one pass.
Step P3: Resolve Permission GUIDs to Names
Run in parallel with P2 — both only need the Graph SP ID from P1.
GET /v1.0/servicePrincipals/{graph-sp-id}/appRoles
Returns: Complete list of Microsoft Graph permission definitions with id (GUID), value (e.g., Mail.ReadWrite), and displayName.
Save the response to temp/p3_approles.json. The PowerShell script from P2 loads this file to build the GUID→name lookup.
Step P4: Filter to Dangerous Permissions
Handled by the PowerShell script in P2. The script performs GUID→name join, dangerous-permission filter, and per-app grouping in one pass. No additional API calls needed.
Output: A table of flagged apps with their dangerous permission list, permission risk level, and grant dates.
Step P5: Resolve Owners for Flagged Apps
Only for apps flagged in P4, retrieve owners from the Application object (NOT the ServicePrincipal):
GET /v1.0/applications?$filter=displayName eq '{flagged-app-name}'
&$select=id,appId,displayName,passwordCredentials,keyCredentials
&$expand=owners($select=id,displayName,userPrincipalName)
Repeat for each flagged app. Important:
Cross-tenant SPNs return empty results (no local Application object)
Red team apps may have owners stripped post-creation
For ownerless apps, fall back to AuditLogs "Add application" to find original creator
Step P6: Assess Owner Risk
For each owner found in P5:
Check directory roles — is the owner a privileged admin or a standard user?
GET /v1.0/roleManagement/directory/roleAssignments
?$filter=principalId eq '{owner-id}'
&$expand=roleDefinition($select=displayName)
Non-admin owners of apps with critical permissions = the Guardz attack vector.
Check Identity Protection risk — feed owner.userPrincipalName into Q1 to detect active risk events. An owner currently flagged by Identity Protection who owns a dangerous app is the highest-priority finding.
Show full SKILL.md (2,333 more words)Show less
Step P7: Credential Hygiene Check
The P5 response includes passwordCredentials and keyCredentials. Assess:
Check
Field
Risk
Multiple active secrets
passwordCredentials[] where endDateTime > now
🟠 Multiple access methods — harder to revoke
Long-lived secrets
endDateTime > 2 years from startDateTime
🟠 Stale credential risk — may leak without detection
No credentials at all
Empty passwordCredentials + keyCredentials
🟢 App can't be used for SPN auth (lower risk)
Certificate + Secret both active
Both arrays non-empty
🟡 Review — cert is expected, secret alongside is unusual
Phase 2: KQL Chain Detection Queries
All queries below are verified against live data. Use them exactly as written, substituting only the lookback period and chain windows where noted.
Tool: Use RunAdvancedHuntingQuery for all queries. All tables are Analytics-tier — AH queries are free. Fall back to mcp_sentinel-data_query_lake only for lookback > 30 days.
Query 1: Risky User → App Operations Chain (HIGHEST SIGNAL)
Purpose: Detect users with active Identity Protection risk detections who then perform app credential, ownership, or consent operations.
Kill Chain Stage: Compromise → App Abuse
Tables:AADUserRiskEvents + AuditLogs
Why high signal: A user flagged by Identity Protection performing app credential operations within days is strong evidence of the exact attack pattern described in the Guardz research.
kql
// Chain Detection: Users with active risk → app credential/ownership operations
let lookback = 30d;
let chainWindow = 7d; // Risk event → app operation within 7 days
// Step 1: Users with unresolved or confirmed risk
let RiskyUsers = AADUserRiskEvents
| where TimeGenerated > ago(lookback)
| where RiskState in ("atRisk", "confirmedCompromised")
| summarize
RiskEvents = count(),
RiskTypes = make_set(RiskEventType, 5),
MaxRiskLevel = max(RiskLevel),
EarliestRisk = min(TimeGenerated),
LatestRisk = max(TimeGenerated)
by UserPrincipalName;
// Step 2: App credential/ownership/consent operations by those users
AuditLogs
| where TimeGenerated > ago(lookback)
| where Category == "ApplicationManagement"
| where OperationName has_any ("credential", "secret", "certificate", "owner", "consent", "permission")
| extend InitiatedByUser = tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName)
| where isnotempty(InitiatedByUser)
| extend Target = parse_json(tostring(TargetResources))[0]
| extend TargetAppName = coalesce(
tostring(Target.displayName),
tostring(parse_json(tostring(parse_json(tostring(Target.modifiedProperties))[1].newValue))))
| join kind=inner RiskyUsers on $left.InitiatedByUser == $right.UserPrincipalName
| where TimeGenerated between (EarliestRisk .. (LatestRisk + chainWindow))
| project
RiskDetectedAt = EarliestRisk,
AppOperationAt = TimeGenerated,
TimeDeltaHours = datetime_diff('hour', TimeGenerated, EarliestRisk),
User = InitiatedByUser,
RiskTypes,
MaxRiskLevel,
RiskEvents,
OperationName,
TargetApp = TargetAppName,
CorrelationId
| order by RiskDetectedAt desc
Triage Priority:
🔴 Critical:MaxRiskLevel = high + credential add operation → likely active compromise
🟠 High:MaxRiskLevel = medium + ownership add → attacker positioning for persistence
🟡 Medium:MaxRiskLevel = low + consent grant → may be suspiciousAuthAppApproval self-referencing
Tuning:
Tighten chainWindow to 1d for higher precision
Add | where RiskTypes !has "suspiciousAuthAppApproval" to exclude consent-flagging-consent loops
Query 2: Credential Add → SPN Activation from New Origin
Purpose: After a credential is added to an app, detect when the SPN authenticates from a new IP within 72 hours. This is the SolarWinds "backdoor credential → authenticate as the app" pattern.
Kill Chain Stage: Persistence → SPN Impersonation
Tables:AuditLogs + AADServicePrincipalSignInLogs
kql
// Chain Detection: Credential added → SPN signs in within 72h
let lookback = 30d;
let activationWindow = 72h;
// Step 1: Credential additions with actor and target
let CredentialAdds = AuditLogs
| where TimeGenerated > ago(lookback)
| where Category == "ApplicationManagement"
| where OperationName in~ (
"Update application – Certificates and secrets management ",
"Add service principal credentials"
)
| extend InitiatedByUser = tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName)
| extend InitiatedByApp = tostring(parse_json(tostring(InitiatedBy)).app.displayName)
| extend Actor = iff(isnotempty(InitiatedByUser), InitiatedByUser, InitiatedByApp)
| extend Target = parse_json(tostring(TargetResources))[0]
| extend TargetAppName = tostring(Target.displayName)
| extend TargetAppId = tostring(Target.id)
| extend ModifiedProps = parse_json(tostring(Target.modifiedProperties))
| extend KeyDescription = tostring(ModifiedProps[0].newValue)
| extend CredentialType = case(
KeyDescription has "AsymmetricX509Cert", "Certificate",
KeyDescription has "Password", "Client Secret",
"Unknown")
| project CredAddTime = TimeGenerated, Actor, TargetAppName, TargetAppId, CredentialType, CorrelationId;
// Step 2: SPN sign-ins after credential add
CredentialAdds
| join kind=inner (
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(lookback)
| where ResultType == "0" // successful only
| project SPNSignInTime = TimeGenerated, AppId, ServicePrincipalName, IPAddress,
Location, ResourceDisplayName, ClientCredentialType,
ServicePrincipalCredentialKeyId
) on $left.TargetAppId == $right.AppId
| where SPNSignInTime between (CredAddTime .. (CredAddTime + activationWindow))
| summarize
SPNSignIns = count(),
DistinctIPs = dcount(IPAddress),
IPs = make_set(IPAddress, 10),
Resources = make_set(ResourceDisplayName, 5),
CredTypes = make_set(ClientCredentialType, 5),
FirstSignIn = min(SPNSignInTime),
LastSignIn = max(SPNSignInTime)
by CredAddTime, Actor, TargetAppName, TargetAppId, CredentialType, CorrelationId
| extend HoursToActivation = datetime_diff('hour', FirstSignIn, CredAddTime)
| order by CredAddTime desc
Triage Priority:
🔴 Critical:HoursToActivation < 1 + new IP not in SPN's historical baseline
Purpose: Detect the exact Guardz attack sequence — user is added as app owner, then credentials are modified on that app within 7 days. The SameActorAsNewOwner flag is key: if the newly added owner immediately creates a credential, that's the attacker using ownership to establish persistence.
🟡 Medium: Different actors (admin added owner, owner later legitimately rotated creds)
Query 4: SPN Cross-Tenant Sign-Ins
Purpose: Detect service principals owned by external tenants authenticating into your tenant. Multi-tenant app abuse was the core SolarWinds persistence mechanism.
Kill Chain Stage: Lateral Movement (cross-tenant)
Tables:AADServicePrincipalSignInLogs
kql
// Detect cross-tenant SPN authentication — foreign SPNs accessing local resources
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(30d)
| where ResultType == "0"
| where isnotempty(AppOwnerTenantId)
| where AppOwnerTenantId != AADTenantId
| summarize
SignIns = count(),
DistinctIPs = dcount(IPAddress),
IPs = make_set(IPAddress, 5),
Resources = make_set(ResourceDisplayName, 10),
CredTypes = make_set(ClientCredentialType, 5),
Locations = make_set(Location, 5),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by ServicePrincipalName, AppId, AppOwnerTenantId, AADTenantId
| order by SignIns desc
Enhancement — New Cross-Tenant SPNs (first seen in last 7d vs 30d baseline):
kql
let recent = 7d;
let baseline = 30d;
let RecentCrossTenant = AADServicePrincipalSignInLogs
| where TimeGenerated > ago(recent)
| where ResultType == "0"
| where AppOwnerTenantId != AADTenantId
| distinct AppId, ServicePrincipalName, AppOwnerTenantId;
let BaselineCrossTenant = AADServicePrincipalSignInLogs
| where TimeGenerated between (ago(baseline) .. ago(recent))
| where ResultType == "0"
| where AppOwnerTenantId != AADTenantId
| distinct AppId;
RecentCrossTenant
| join kind=leftanti BaselineCrossTenant on AppId
| project ServicePrincipalName, AppId, AppOwnerTenantId
Query 5: Credential Add → SPN Graph API Lateral Movement
Purpose: After a credential is added, track what Graph API calls the SPN makes. Categorizes API endpoints into sensitive categories to identify lateral movement and data exfiltration.
Kill Chain Stage: Lateral Movement / Data Exfiltration
Tables:AuditLogs + MicrosoftGraphActivityLogs
Prerequisite:MicrosoftGraphActivityLogs must be ingested (requires Entra ID P1/P2 + diagnostic settings enabled).
kql
// Chain Detection: Credential added → SPN Graph API calls within 72h
let lookback = 30d;
let monitorWindow = 72h;
// Step 1: Apps that had credentials added
let CredentialAdds = AuditLogs
| where TimeGenerated > ago(lookback)
| where Category == "ApplicationManagement"
| where OperationName in~ (
"Update application – Certificates and secrets management ",
"Add service principal credentials"
)
| extend Target = parse_json(tostring(TargetResources))[0]
| extend TargetAppId = tostring(Target.id)
| extend TargetAppName = tostring(Target.displayName)
| extend Actor = tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName)
| project CredAddTime = TimeGenerated, Actor, TargetAppName, TargetAppId;
// Step 2: Graph API calls by those apps after credential add
CredentialAdds
| join kind=inner (
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(lookback)
| where isnotempty(ServicePrincipalId)
| project GraphCallTime = TimeGenerated, AppId, RequestMethod, RequestUri,
ResponseStatusCode, ServicePrincipalId
) on $left.TargetAppId == $right.AppId
| where GraphCallTime between (CredAddTime .. (CredAddTime + monitorWindow))
| extend EndpointCategory = case(
RequestUri has "/roleManagement/", "Role Management",
RequestUri has_any ("/applications/", "/servicePrincipals/"), "App/SPN Management",
RequestUri has "/users/", "User Enumeration",
RequestUri has "/groups/", "Group Enumeration",
RequestUri has "/identity/conditionalAccess/", "CA Policy Access",
RequestUri has "/policies/", "Policy Management",
RequestUri has "/security/", "Security Data",
RequestUri has_any ("/mail/", "/messages", "/mailFolders"), "Email Access",
RequestUri has_any ("/drives/", "/sites/"), "File Access",
RequestUri has "/auditLogs/", "Audit Log Access",
"Other")
| where EndpointCategory != "Other"
| summarize
GraphCalls = count(),
Methods = make_set(RequestMethod, 5),
SampleUris = make_set(RequestUri, 3),
SuccessRate = round(100.0 * countif(ResponseStatusCode >= 200 and ResponseStatusCode < 300) / count(), 1)
by CredAddTime, Actor, TargetAppName, TargetAppId, EndpointCategory
| order by CredAddTime desc, GraphCalls desc
Triage Priority:
🔴 Critical:Role Management or App/SPN Management → privilege escalation / further persistence
🔴 Critical:Email Access → data exfiltration (SolarWinds primary objective)
🟠 High:CA Policy Access or Policy Management → defense evasion
Purpose: After adding a credential (persistence), detect the attacker granting additional permissions or consenting to broader API access on the same app.
Schema Note: Credential operations and consent operations use different ID spaces for the same app (Application ObjectId vs ServicePrincipal ObjectId). This query joins on Actor + TargetAppName to bridge the gap.
kql
// Chain Detection: Credential added → permission/consent on same app within 7d
let lookback = 30d;
let escalationWindow = 7d;
// Step 1: Credential additions
let CredentialAdds = AuditLogs
| where TimeGenerated > ago(lookback)
| where Category == "ApplicationManagement"
| where OperationName in~ (
"Update application – Certificates and secrets management ",
"Add service principal credentials"
)
| extend Target = parse_json(tostring(TargetResources))[0]
| extend TargetAppName = tostring(Target.displayName)
| where isnotempty(TargetAppName)
| extend CredActor = tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName)
| where isnotempty(CredActor)
| project CredAddTime = TimeGenerated, CredActor, TargetAppName;
// Step 2: Permission grants by same actor on same-named app
let PermissionGrants = AuditLogs
| where TimeGenerated > ago(lookback)
| where Category == "ApplicationManagement"
| where OperationName in~ (
"Add delegated permission grant",
"Consent to application",
"Add app role assignment to service principal"
)
| extend EscActor = tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName)
| where isnotempty(EscActor)
| extend Target0 = parse_json(tostring(TargetResources))[0]
| extend PermAppName = case(
OperationName =~ "Consent to application", tostring(Target0.displayName),
tostring(Target0.displayName))
| project PermOpTime = TimeGenerated, EscActor, PermAppName, EscalationOp = OperationName;
// Join: same actor + same app + credential first then permission
CredentialAdds
| join kind=inner PermissionGrants on $left.CredActor == $right.EscActor, $left.TargetAppName == $right.PermAppName
| where PermOpTime between (CredAddTime .. (CredAddTime + escalationWindow))
| project
CredAddTime,
PermissionOpTime = PermOpTime,
HoursGap = datetime_diff('hour', PermOpTime, CredAddTime),
Actor = CredActor,
TargetAppName,
EscalationOp
| order by CredAddTime desc
🟡 Medium:Add app role assignment with larger gap → possibly legitimate
Query 7: Multi-App Ownership Spread
Purpose: Detect a single user being added as owner to multiple applications within a rolling window. Attackers spread ownership across apps to maximize blast radius.
Kill Chain Stage: Persistence (breadth)
Tables:AuditLogs
kql
// Detect lateral ownership expansion — one user becoming owner of many apps
let lookback = 30d;
AuditLogs
| where TimeGenerated > ago(lookback)
| where Category == "ApplicationManagement"
| where OperationName in~ ("Add owner to application", "Add owner to service principal")
| extend Target0 = parse_json(tostring(TargetResources))[0]
| extend NewOwnerUPN = tostring(Target0.userPrincipalName)
| extend ModProps = parse_json(tostring(Target0.modifiedProperties))
| extend TargetAppName = tostring(parse_json(tostring(ModProps[1].newValue)))
| extend TargetAppId = tostring(parse_json(tostring(ModProps[0].newValue)))
| extend InitiatedByUser = tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName)
| extend Actor = iff(isnotempty(InitiatedByUser), InitiatedByUser, tostring(parse_json(tostring(InitiatedBy)).app.displayName))
| where isnotempty(NewOwnerUPN)
| summarize
AppsOwned = dcount(TargetAppId),
AppNames = make_set(TargetAppName, 10),
OwnershipOps = count(),
FirstAdd = min(TimeGenerated),
LastAdd = max(TimeGenerated),
AddedBy = make_set(Actor, 5)
by NewOwnerUPN
| extend SpreadWindowHours = datetime_diff('hour', LastAdd, FirstAdd)
| where AppsOwned >= 3
| order by AppsOwned desc
Purpose: Surface existing Defender detections (App Governance, MCAS, Defender XDR attack disruptions) for apps in our posture assessment. Creates a cross-reference between our Graph API + KQL findings and what Microsoft's own detection products already flagged — confirming known threats and highlighting gaps.
Where {tenant} is a short identifier for the tenant (derive from config.json or ask the user).
Mode 3: Both
Generate the markdown file AND provide an inline summary in chat.
Always ask the user which mode before generating output.
Inline Report Template
Render the following sections in order. Omit sections only if explicitly noted as conditional.
🔴 URL Rule: All hyperlinks in the report MUST be copied verbatim from the URL Registry above. Do NOT generate, recall from memory, or paraphrase any URL. If a needed URL is not in the registry, use plain text (no hyperlink).
markdown
# 🔐 App Registration Security Posture Report
**Generated:** YYYY-MM-DD HH:MM UTC
**Data Sources:** Graph API + Advanced Hunting (AuditLogs, AADServicePrincipalSignInLogs, AADUserRiskEvents, MicrosoftGraphActivityLogs, AlertInfo, AlertEvidence)
**KQL Lookback:** <N> days (Q1–Q7); 90 days (Q8)
**Tenant:** <tenant name> (<tenant ID>)
---
## Executive Summary
<2-3 sentences: total apps with Graph permissions, apps with dangerous permissions, key chain detection findings, overall score>
**Overall Risk Rating:** 🔴/🟠/🟡/✅ <RATING> (<Score>/100)
---
## Key Metrics
| Metric | Value |
|--------|-------|
| Apps with Graph API Permissions | <N> |
| Apps with Dangerous Permissions | <N> |
| Critical Permission Grants (🔴) | <N> |
| High Permission Grants (🟠) | <N> |
| Medium Permission Grants (🟡) | <N> |
| Ownerless Apps with Dangerous Perms | <N> |
| Apps with No Local Application Object | <N> |
| Cross-Tenant SPNs | <N> |
| Active Abuse Chain Detections (Q1–Q8) | <N total hits> |
---
## 🔐 Permission Inventory (Graph API)
### Apps with Dangerous Permissions
| App Name | Dangerous Permissions | Risk Level | Grant Dates |
|----------|----------------------|------------|-------------|
| <app> | <perm1>, <perm2>, ... | 🔴/🟠/🟡 | <dates> |
### Permission Concentration
| Permission | Apps Granted | Risk |
|------------|-------------|------|
| <perm> | <N> (<app names>) | 🔴/🟠/🟡 |
**Assessment:**
- <emoji> <evidence-based finding about permission concentration>
- <emoji> <finding about golden ticket permissions (AppRoleAssignment.ReadWrite.All)>
---
## 👤 Owner Risk Assessment
### Flagged App Owners
> **Non-optional columns:** The `Identity Protection Risk` column MUST always be present. For each owner, check Q1 results or query AADUserRiskEvents for active risk state. If no risk events exist, show "✅ None". Never drop this column.
| App Name | Owner | Owner Roles | Identity Protection Risk | Owner Risk |
|----------|-------|-------------|--------------------------|------------|
| <app> | <upn> | <roles or "None (standard user)"> | <risk state + risk types, or "✅ None"> | 🔴/🟠/🟡/🟢 |
### Ownerless Apps with Dangerous Permissions
| App Name | Dangerous Permissions | Creator (from AuditLogs) |
|----------|----------------------|--------------------------|
| <app> | <perms> | <creator UPN or "Unknown"> |
**Assessment:**
- <emoji> <finding about non-admin owners on critical-permission apps>
- <emoji> <finding about ownerless apps>
---
## 🔑 Credential Hygiene
| App Name | Active Secrets | Active Certs | Oldest Secret Age | Longest Expiry | Risk |
|----------|---------------|-------------|-------------------|----------------|------|
| <app> | <N> | <N> | <days> | <date> | 🔴/🟠/🟡/🟢 |
**Assessment:**
- <emoji> <finding about multi-credential apps>
- <emoji> <finding about long-lived secrets>
- 🟡 **Dormant privileged apps:** List any apps with dangerous permissions but NO active credentials (0 secrets, 0 valid certs). These are one `Add service principal credentials` operation away from active abuse — rate as 🟡 at assessment level (not 🟢). Example: "Contoso employee onboarding has `User.ReadWrite.All` but no credentials — dormant risk."
---
## 🌐 Cross-Tenant SPN Exposure (Q4)
<If Q4 returns results:>
| SPN Name | Owner Tenant | Sign-Ins (30d) | Distinct IPs | Resources Accessed | Auth Methods | Locations | First Seen | Last Seen |
|----------|-------------|----------------|-------------|-------------------|-------------|-----------|------------|-----------|
| <name> | <tenant ID> | <N> | <N> | <resources> | <methods> | <locations> | <date> | <date> |
> **Auth method note:** `clientAssertion` (certificate-based) indicates higher attacker sophistication than `clientSecret`. Both present on a single SPN may indicate migration or redundant credential paths.
<If Q4 enhancement returns new SPNs:>
⚠️ **New Cross-Tenant SPNs (first seen in last 7 days):**
| SPN Name | Owner Tenant |
|----------|-------------|
| <name> | <tenant ID> |
<If Q4 returns 0:>
✅ No cross-tenant SPN sign-ins detected in the last <N> days.
**Assessment:**
- <emoji> <finding about foreign-tenant SPNs with golden ticket or CA policy write permissions>
- <emoji> <finding about sign-in volume and resource breadth>
- 🔵 Filter out known [first-party Microsoft service SPNs](https://learn.microsoft.com/en-us/troubleshoot/entra/entra-id/governance/verify-first-party-apps-sign-in) — normal behavior.
---
## ⚡ Active Abuse Chain Detection (Q1–Q3, Q5–Q8)
> **Note:** Q4 (Cross-Tenant SPNs) is presented in its own section above since it doubles as both a chain detection and a posture finding.
> **Bulk-pattern collapse rule:** When any chain query (Q1–Q8) returns >10 chains where >80% share the same actor AND the same pattern (uniform resource, timing, app naming convention), collapse into a single **"Automated Pipeline"** summary row with the total count and a governance-review flag. Only table the outliers individually. This prevents automation noise from burying genuine attack chains.
### Q1: Risky User → App Operations
<If Q1 returns results, always start with a rollup summary table:>
**Summary:**
| Priority | Chains | Users | Key Finding |
|----------|--------|-------|-------------|
| 🔴 Critical | <N> | <users> | <top finding — e.g., adminConfirmedUserCompromised → app consent> |
| 🟠 High | <N> | <users> | <summary> |
| 🟡 Low | <N> | <users> | <summary or "consent-flagging-consent loops"> |
<Then detail tables for 🔴 Critical and 🟠 High chains only. Collapse 🟡 Low into the summary.>
| Risk Detected | App Operation | Hours Gap | User | Risk Types | Risk Level | Target App |
|--------------|---------------|-----------|------|------------|------------|------------|
| <date> | <date> | <N> | <upn> | <types> | <level> | <app> |
> ⚠️ **Self-referencing note:** If Q1 results are dominated by `suspiciousAuthAppApproval` risk types, these may be self-referencing — Identity Protection flags consent operations as risky, which then correlates back to the same consent. Report both the raw count and a filtered count (`| where RiskTypes !has "suspiciousAuthAppApproval"`) to distinguish genuine compromise signals from circular detections.
<If Q1 returns 0:>
✅ No risky-user → app-operations chains detected.
### Q2: Credential Add → SPN Activation
<If Q2 returns results:>
| Cred Added | First SPN Sign-In | Hours to Activation | Actor | App | Distinct IPs | Resources |
|------------|-------------------|---------------------|-------|-----|-------------|-----------|
| <date> | <date> | <N> | <upn> | <app> | <N> | <resources> |
<If Q2 returns 0:>
✅ No credential-add → SPN-activation chains detected.
### Q3: Ownership → Credential Chain
<If Q3 returns results:>
| Owner Added | Cred Operation | Hours Gap | New Owner | Same Actor? | App |
|-------------|---------------|-----------|-----------|-------------|-----|
| <date> | <date> | <N> | <upn> | <yes/no> | <app> |
<If Q3 returns 0:>
✅ No ownership → credential modification chains detected.
### Q5: Credential Add → Graph API Lateral Movement
<If Q5 returns results:>
| Cred Added | Actor | App | Endpoint Category | Graph Calls | Methods | Success Rate |
|------------|-------|-----|-------------------|-------------|---------|-------------|
| <date> | <upn> | <app> | <category> | <N> | <methods> | <pct>% |
<If Q5 returns 0:>
✅ No credential-add → Graph API lateral movement chains detected.
> **Note:** MicrosoftGraphActivityLogs requires Entra ID P1/P2 + diagnostic settings. If table not found, report as: `❓ MicrosoftGraphActivityLogs not available — cannot assess Graph API lateral movement.`
### Q6: Credential Add → Permission Escalation
<If Q6 returns results:>
| Cred Added | Perm Escalation | Hours Gap | Actor | App | Escalation Operation |
|------------|----------------|-----------|-------|-----|---------------------|
| <date> | <date> | <N> | <upn> | <app> | <operation> |
<If Q6 returns 0:>
✅ No credential-add → permission-escalation chains detected.
### Q7: Multi-App Ownership Spread
<If Q7 returns results:>
| User | Apps Owned | Spread Window (hrs) | App Names | Added By |
|------|-----------|---------------------|-----------|----------|
| <upn> | <N> | <N> | <names> | <actors> |
<If Q7 returns 0:>
✅ No multi-app ownership spread detected (threshold: ≥3 apps).
### Q8: App Governance & OAuth Incident Cross-Reference
> **Purpose:** Cross-reference Phase 1 flagged apps with existing Microsoft detections (App Governance alerts, Defender XDR OAuth alerts, Attack Disruption incidents). This validates skill findings against Microsoft's own detection coverage and surfaces apps with multi-source detections.
<If Q8 returns results:>
**Detection Summary:**
| App Name | App ID | Alert Count | Detection Sources | Detection Breadth | Highest Severity | Has Attack Disruption |
|----------|--------|-------------|-------------------|-------------------|------------------|-----------------------|
| <name> | <id> | <N> | <sources> | <N> | <severity> | ✅/❌ |
**Cross-Reference with Phase 1:**
- 🔴 **Both skill and Microsoft flagged:** <list apps found in BOTH Phase 1 dangerous-permission inventory AND Q8 detections — these are confirmed high-priority>
- 🟠 **Skill-only (no Microsoft detection):** <list apps from Phase 1 that Q8 did NOT detect — skill's unique value-add, may indicate detection gap in App Governance>
- 🔵 **Microsoft-only (not in skill scope):** <list apps from Q8 that are NOT in Phase 1 — may not have dangerous permissions but triggered behavioral alerts>
<If Q8 returns 0:>
✅ No App Governance, OAuth, or Attack Disruption alerts detected for any apps in the last 90 days.
---
## App Permission Risk Score Card
```
┌──────────────────────────────────────────────────────────────┐
│ APP PERMISSION RISK SCORE: <NN>/100 │
│ Rating: <EMOJI> <RATING> │
├──────────────────────────────────────────────────────────────┤
│ Perm Concentration [<bar>] <N>/20 (<detail>) │
│ Owner Risk [<bar>] <N>/20 (<detail>) │
│ Credential Hygiene [<bar>] <N>/20 (<detail>) │
│ Cross-Tenant Exp. [<bar>] <N>/20 (<detail>) │
│ Active Abuse Sigs [<bar>] <N>/20 (<detail>) │
└──────────────────────────────────────────────────────────────┘
```
### Dimension Details
| Dimension | Score | Evidence |
|-----------|-------|----------|
| **Permission Concentration** | 🔴/🟠/🟡 <N>/20 | <N> apps with dangerous perms; list golden ticket / critical perms found |
| **Owner Risk** | 🔴/🟠/🟡 <N>/20 | <N> ownerless apps; non-admin owners on critical apps; Identity Protection signals |
| **Credential Hygiene** | 🔴/🟠/🟡 <N>/20 | Multi-secret apps; stale credentials; dormant privileged apps |
| **Cross-Tenant Exposure** | 🔴/🟠/🟡 <N>/20 | Foreign SPNs with critical perms; unknown tenant IDs; resource breadth |
| **Active Abuse Signals** | 🔴/🟠/🟡 <N>/20 | Which chain queries (Q1–Q8) returned critical results; key actors; Q8 detection breadth |
---
## Security Assessment
| Factor | Finding |
|--------|---------|
| <emoji> **<Factor>** | <Evidence-based finding> |
---
## Recommendations
> **Key context:** This skill detects signals that [Microsoft App Governance](https://learn.microsoft.com/en-us/defender-cloud-apps/app-governance-manage-app-governance) does NOT — specifically the cross-table correlation between user compromise signals and app abuse chains. Recommendations should complement App Governance, not duplicate it.
**Minimum recommendation checklist** — include ALL applicable items (skip only if the finding doesn't exist in the data). Order by severity (🔴 first):
| # | Must-Include Topic | When Applicable |
|---|-------------------|------------------|
| a | **Golden ticket / critical cross-tenant SPN remediation** | Any foreign SPN with `AppRoleAssignment.ReadWrite.All` or `Directory.ReadWrite.All` |
| b | **Compromised-user consent investigation** | Q1 returns `adminConfirmedUserCompromised` or `confirmedCompromised` chains |
| c | **Owner assignment for ownerless dangerous apps** | Any ownerless app with dangerous perms |
| d | **Stale credential rotation** | Any secret >365 days old on an app with dangerous perms |
| e | **Multi-credential reduction** | Any app with ≥3 active secrets |
| f | **Non-admin owner risk mitigation** | Non-admin user owns app with 🔴-level perms |
| g | **Single-user blast radius reduction** | Any user owns ≥20 apps (pipeline or otherwise) |
| h | **Dormant privileged app disposition** | App with dangerous perms but no credentials |
| i | **Expired-credential permission cleanup** | App with expired creds that still retains dangerous permission grants |
| j | **App Governance enablement** | Always include if not already deployed (standard closing recommendation) |
1. <emoji> **<Priority action>** — <evidence and rationale>
2. ...
---
## Related Workspace Resources
| Resource | Relationship |
|----------|-------------|
| `queries/identity/app_credential_management.md` | Individual event queries — complements chain detections |
| `queries/identity/service_principal_scope_drift.md` | SPN behavioral baseline — use for post-detection deep dive |
| `.github/skills/scope-drift-detection/spn/SKILL.md` | Full SPN investigation workflow — run on SPNs flagged by Q2 |
| `queries/cloud/behavior_entities.md` Q6 | MCAS `UnusualAdditionOfCredentialsToAnOauthApp` detection |
---
## Appendix: Query Execution Summary
| Phase | Query | Description | Records |
|-------|-------|-------------|--------|
| 1 | P1 | Find Graph SP ID | 1 |
| 1 | P2 | List permission grants | <N> |
| 1 | P3 | Resolve permission names | <N> |
| 1 | P4 | Filter dangerous perms | <N> |
| 1 | P5 | Resolve owners | <N> apps |
| 1 | P6 | Assess owner risk | <N> owners |
| 1 | P7 | Credential hygiene | <N> apps |
| 2 | Q1 | Risky User → App Ops | <N> |
| 2 | Q2 | Cred → SPN Activation | <N> |
| 2 | Q3 | Ownership → Credential | <N> |
| 2 | Q4 | Cross-Tenant SPNs | <N> |
| 2 | Q5 | Cred → Graph API | <N> |
| 2 | Q6 | Cred → Permission Esc. | <N> |
| 2 | Q7 | Ownership Spread | <N> |
| 2 | Q8 | App Gov & OAuth Cross-Ref | <N> |
Markdown File Report Template
When outputting to markdown file, use the same structure as the Inline Report Template above, saved to:
Problem: The same app has different GUIDs in TargetResources[0].id depending on the AuditLog operation type. Credential operations reference the Application ObjectId; permission/consent operations reference the ServicePrincipal ObjectId.
Impact: Joining credential events to permission events on TargetResources[0].id returns zero results even when both operations target the same app.
Solution: Q6 joins on Actor + TargetAppName (display name match) instead of ObjectId. This works reliably for same-actor chains.
2. Ownership Operations — Target Name in modifiedProperties
Problem: For "Add owner to application", TargetResources[0] is the new owner (User type), not the app. The app name is buried in TargetResources[0].modifiedProperties[1].newValue.
Solution: Extract with tostring(parse_json(tostring(ModProps[1].newValue))). Field name is Application.DisplayName.
3. OperationName Trailing Spaces
Problem:"Update application – Certificates and secrets management " has a trailing space. String equality (==) fails without it.
Solution: Use in~() with the exact string (including trailing space) or use has for substring matching.
4. Cross-Tenant SPNs Have No Local Application Object
Problem: Graph API calls to /v1.0/applications?$filter=displayName eq 'X' return empty for SPNs owned by foreign tenants — they only have a ServicePrincipal object in your tenant, not an Application object.
Impact: Cannot retrieve ownership or credential details for cross-tenant SPNs via local Graph API.
Solution: Identify cross-tenant SPNs via Q4 (AppOwnerTenantId != AADTenantId). Report them separately with a note that ownership is managed by the foreign tenant.
5. Graph API requiredResourceAccess ≠ Granted Permissions
Problem: The Application object's requiredResourceAccess shows what the app requests (manifest), not what's been admin-consented/granted.
Solution: Always use appRoleAssignedTo on the resource service principal (Step P2) for the authoritative granted permissions list.
6. Red Team Apps May Have Owners Stripped
Problem: Attack simulation tools often remove app ownership post-creation to evade detection. Graph API returns no owners.
Solution: Fall back to AuditLogs "Add application" OperationName to find the original creator — AuditLogs retain the InitiatedBy actor forever.
7. MicrosoftGraphActivityLogs May Not Be Available
Problem: Q5 requires MicrosoftGraphActivityLogs, which needs Entra ID P1/P2 and diagnostic settings to be enabled. Not all tenants have this.
Impact: If the table doesn't exist, Q5 returns an error.
Solution: If Q5 fails with "table not found", report as ❓ MicrosoftGraphActivityLogs not available and skip — do not fail the entire assessment. The other 7 chain queries and Graph API posture still provide substantial coverage.
8. suspiciousAuthAppApproval Self-Referencing in Q1
Problem: When a consent grant occurs, Identity Protection may flag the same event as a suspiciousAuthAppApproval risk detection. Q1 then correlates the risk event WITH the consent operation, creating a circular detection.
Solution: If Q1 results are dominated by suspiciousAuthAppApproval risk types, note in the report that these may be self-referencing. The user can filter with | where RiskTypes !has "suspiciousAuthAppApproval" for higher-confidence chains.
9. Conflating Delegated AllPrincipals Consent with Application Permission Risk
Problem: When auditing tenant permissions (e.g., Get-MgOauth2PermissionGrant -Filter "consentType eq 'AllPrincipals'"), the returned delegated scopes can look alarming — 100+ scopes on a single app. It is tempting to rate these at the same severity as application permissions.
Why this is wrong: Delegated permissions operate as the intersection of the app's consented scopes and the signed-in user's Entra roles. A standard user cannot exploit broad delegated consent beyond their own role boundaries. The consent only removes the per-user prompt — it does not elevate privilege.
Solution: See Delegated vs Application Permissions — Risk Model. When this skill's analysis overlaps with a separate delegated consent audit, always clarify which permission type is being discussed. Application permissions (from P2/appRoleAssignedTo) are the primary risk. Delegated AllPrincipals consents are a secondary concern relevant mainly to privileged admin account compromise scenarios.
App Registration Posture next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
App Registration Posture compared with similar skills
Skill
Stars
Used in
Tokens
Auto-check
Licence
Repo updated
App Registration Posture this skillSCStelz/security-investigator
Detect Azure service principal abuse in Microsoft Entra ID using KQL detection queries (Sentinel/Splunk) against Azure AD Audit and Sign-in Logs, covering added credentials, privileged role…
Detect lateral movement in Azure AD/Entra ID environments using Microsoft Graph API audit logs, Azure Sentinel KQL hunting queries, and sign-in anomaly correlation to identify privilege escalation…
Configure SAML 2.0 identity federation between on-premises Active Directory (via AD FS or a third-party IdP) and Microsoft Entra ID, covering federation models (AD FS, password hash sync…
Hunt AADGraphActivityLogs and MicrosoftGraphActivityLogs in Microsoft Sentinel/Log Analytics using KQL to fingerprint offensive Entra ID enumeration tools such as ROADtools, AADInternals, and…
Detect Golden Ticket attacks in Active Directory using Splunk and KQL queries against domain controller event logs, looking for Kerberos TGT anomalies such as mismatched encryption types, impossible…
A skill your agent uses when asked to investigate Conditional Access policy changes, sign-in failures related to CA policies (error codes 53000, 50074, 530032), or suspected policy…
A skill your agent uses when asked to create heatmaps, visualize patterns over time, show activity grids, or display aggregated data in a matrix format.
A skill your agent uses when asked to trace authentication flows, analyze SessionId chains, investigate token reuse vs interactive MFA, or assess geographic anomalies in sign-ins.
Audit Entra ID app registration and service principal security posture. App Registration Posture is an agent skill from SCStelz/security-investigator. Audit Entra ID app registration and service principal security posture.
When should I use App Registration Posture?
App Registration Posture fits situations like: keywords like app registration posture; service principal permissions; dangerous app permissions; app credential abuse.
How do I install App Registration Posture in Claude Code?
Run `npx skills add SCStelz/security-investigator --skill app-registration-posture -a claude-code`. Or copy the skill folder (.github/skills/app-registration-posture in SCStelz/security-investigator) into .claude/skills/app-registration-posture in your project. Claude Code loads it when a task matches its description.
How do I install App Registration Posture in Codex?
Run `npx skills add SCStelz/security-investigator --skill app-registration-posture -a codex`. Or copy the skill folder (.github/skills/app-registration-posture in SCStelz/security-investigator) into .agents/skills/app-registration-posture in your project. Codex loads it when a task matches its description.
Can I use App Registration Posture in Cursor, Gemini CLI or GitHub Copilot?
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add SCStelz/security-investigator --skill app-registration-posture -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/app-registration-posture, .gemini/skills/app-registration-posture, .github/skills/app-registration-posture and .opencode/skills/app-registration-posture in your project.
What does App Registration Posture need to run?
SKILL.md names no scripts, command-line tools or credentials: App Registration Posture is instructions for the agent only.
Does App Registration Posture access the network?
SKILL.md names 4 domains. In commands or code: learn.microsoft.com, attack.mitre.org, guardz.com and microsoft.com; the agent is likely to contact these when it follows the instructions. This is read from the text; nothing was executed.
Is App Registration Posture safe to install?
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
What licence does App Registration Posture use?
App Registration Posture is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
How many tokens does App Registration Posture use?
About 21k tokens (SKILL.md is roughly 85k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
What are the alternatives to App Registration Posture?
Skills that share tags, products or a category with App Registration Posture: Detecting Azure Service Principal Abuse (mukul975/Anthropic-Cybersecurity-Skills, 34k stars), Detecting Azure Lateral Movement (mukul975/Anthropic-Cybersecurity-Skills, 34k stars), Building Identity Federation With Saml Azure Ad (mukul975/Anthropic-Cybersecurity-Skills, 34k stars) and Analyzing Azure Activity Logs For Threats (mukul975/Anthropic-Cybersecurity-Skills, 34k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
Who maintains App Registration Posture?
SCStelz (a GitHub user) maintains it in SCStelz/security-investigator, which has 249 GitHub stars. The repository holds 22 skills in this directory. The repository was last updated on October 8, 2026.
Source: SCStelz/security-investigator on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.