Skip to main content
Who is this for? IT administrators, security teams, and change advisory board (CAB) reviewers who need to independently verify the permissions granted to ContraForce enterprise applications before or after onboarding.

Overview

ContraForce provides two audit scripts that enumerate the permissions granted to ContraForce enterprise applications in your Microsoft Entra ID tenant. Both scripts produce an identical JSON output that you can compare against the Enterprise Applications Reference to verify that only the documented permissions are in place. Choose the script that fits your environment:
PowerShellPython
Best forMicrosoft-native environmentsCross-platform / CLI-first teams
RuntimePowerShell 7.0+ (pwsh)Python 3.10+
Auth methodMicrosoft Graph PowerShell SDKAzure CLI (az login)
DependenciesMicrosoft.Graph modules (Microsoft-published)azure-identity (Microsoft-published) + httpx
Government cloud-Cloud AzureUSGovernment-c AzureUSGovernment
These scripts are read-only. They do not create, modify, or delete any objects in your tenant. All API calls are HTTP GET requests to Microsoft Graph.

What the Scripts Access

Both scripts make read-only queries to the Microsoft Graph REST API. The specific endpoint depends on your cloud environment:
EnvironmentGraph API BaseIdentity Platform
Commercial / GCChttps://graph.microsoft.com/v1.0https://login.microsoftonline.com
GCC High / DoDhttps://graph.microsoft.us/v1.0https://login.microsoftonline.us
No other endpoints are contacted beyond the Microsoft identity platform for token acquisition.
Graph API EndpointPurpose
GET /meResolve the authenticated operator’s identity (UPN and object ID)
GET /organizationResolve the tenant ID
GET /servicePrincipalsLook up ContraForce and Microsoft resource API service principals by name or app ID
GET /servicePrincipals/{id}/oauth2PermissionGrantsRead delegated permission grants for each application
GET /servicePrincipals/{id}/appRoleAssignmentsRead application permission assignments for each application

Data in the Output File

The output JSON contains:
  • Application names and app IDs for each ContraForce enterprise application
  • Permission names and descriptions resolved to human-readable values
  • The resource API each permission targets (Microsoft Graph, WindowsDefenderATP, etc.)
  • Metadata: timestamp, tenant ID, cloud environment, authenticated operator, and tool version
The output does not contain secrets, tokens, or user data beyond the operator identifier. In government cloud environments, the operator’s UPN is automatically redacted and replaced with their Entra object ID (an opaque GUID). See Output File Access Control for handling guidance.

Applications Audited

In commercial (AzureCloud) environments, both scripts use built-in app IDs that match the Quick Reference table:
ApplicationApp ID
ContraForce API24d97bc0-8f2b-45d5-8e0b-7fe286732ef2
ContraForce Portal8b7cb435-9526-47ee-b79a-34433f0daad2
ContraForce for MDE6efccc6a-f0d3-49e5-92d0-17d4afa9ba52
ContraForce Gamebooks for MDEad7b0e79-3c37-4408-bf8f-eb89522cc920
ContraForce Gamebooks for Identity36b0d51c-4c0f-4810-9cc4-bfbd40c7dd4a
ContraForce Gamebooks for Email44dbf6fe-45e3-48a3-bac3-f8d4cf1dba6d
ContraForce Sentinel Hunting6bf1c74d-7ade-4671-a507-166936f89a1f
ContraForce User Management460b65b7-3a5e-4a2c-98d0-e48fd35374a9
You can verify these app IDs in the Microsoft Entra Admin Center under Enterprise Applications before running the scripts.
Government cloud environments use different app IDs. Contact [email protected] to obtain the app IDs for your environment. Pass them to the script via -AppsFile (PowerShell) or --apps-file (Python) — a JSON file containing an array of objects with Name/AppId (PowerShell) or name/app_id (Python) fields.

Prerequisites

Runtime: PowerShell 7.0+ (pwsh, not Windows PowerShell 5.1)Microsoft Graph modules:
Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser
Required Graph scopes: Application.Read.All and Directory.Read.AllAuthenticate before running:
# Commercial / GCC (-Cloud AzureCloud, the default)
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All"

# GCC High (-Cloud AzureUSGovernment)
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -Environment USGov

# DoD (-Cloud AzureUSGovernment)
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -Environment USGovDoD
For environments without a browser, use device code flow:
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -UseDeviceCode

Minimum Permissions

The account running the script needs read access to service principals and their permission grants. The minimum Microsoft Entra ID role is Directory Reader, or you can grant the following Microsoft Graph API permissions directly:
Graph PermissionTypePurpose
Application.Read.AllDelegatedRead service principal properties and app roles
Directory.Read.AllDelegatedRead delegated permission grants and role assignments
These are read-only permissions. They do not grant the ability to modify applications, permissions, or any other tenant objects.

Running the Scripts

# Commercial (default)
pwsh -File Audit-EnterpriseApps.ps1

# Custom output path
pwsh -File Audit-EnterpriseApps.ps1 -OutputPath ./audit-2026-02-12.json

# Government (GCC High / DoD) — requires -AppsFile
pwsh -File Audit-EnterpriseApps.ps1 -Cloud AzureUSGovernment -AppsFile ./gov-apps.json
The -Cloud parameter value must match the cloud you authenticated to with Connect-MgGraph -Environment. The script validates this and exits with an error if there is a mismatch. Government environments require -AppsFile because app IDs differ from commercial.
Both scripts display progress as they resolve permissions and will report a summary when complete. A non-zero exit code means one or more applications were not found in the tenant — this is expected if you haven’t consented all ContraForce applications (for example, Sentinel Hunting is only required for XDR + SIEM deployments).

Government Cloud Environments (GCC High / DoD)

Both scripts support Microsoft Azure Government cloud environments used by public sector organizations subject to CMMC, FedRAMP, or ITAR requirements. Both scripts use the same -Cloud / -c parameter values, which match the az cloud set --name values:
CloudValuePowerShellPython
Commercial (default)AzureCloud-Cloud AzureCloud-c AzureCloud
Government (GCC High / DoD)AzureUSGovernment-Cloud AzureUSGovernment-c AzureUSGovernment
When running in a government cloud environment, the scripts automatically:
  1. Use the correct Graph endpointgraph.microsoft.us instead of graph.microsoft.com
  2. Redact the operator’s UPN — Records the Entra object ID (an opaque GUID) instead of the User Principal Name in the generatedBy metadata field
  3. Restrict output file permissions — Limits file access to the current user only (PowerShell: Windows ACL; Python: POSIX chmod 600)
You can also redact the UPN in commercial environments using the -RedactUPN switch (PowerShell) or --redact-upn flag (Python).
The script validates that the connected session matches the requested cloud environment. If you specify -Cloud AzureUSGovernment but are connected to a commercial Graph session, the script will exit with an error and instructions to reconnect.

Audit Scripts

#Requires -Version 7.0
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Applications

<#
.SYNOPSIS
    Audits ContraForce enterprise application permissions in the current Microsoft Entra ID tenant.

.DESCRIPTION
    Queries Microsoft Graph for all documented ContraForce enterprise applications,
    resolves their delegated and application permissions to human-readable names,
    and outputs a structured JSON file suitable for diffing against documentation.

    Supports Commercial, GCC High, and DoD cloud environments.

    Requires:
    - Microsoft.Graph PowerShell modules:
        Install-Module Microsoft.Graph.Applications -Scope CurrentUser
    - An authenticated Microsoft Graph session with Directory.Read.All:
        Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All"
        Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -Environment USGov
    - The target tenant must have the ContraForce enterprise applications consented

.PARAMETER OutputPath
    Path for the output JSON file. Defaults to enterprise-apps-audit.json in the current directory.

.PARAMETER Cloud
    The Microsoft cloud environment to audit. Uses the same values as
    'az cloud set --name': AzureCloud (Commercial) or AzureUSGovernment
    (GCC High / DoD). Defaults to AzureCloud.

.PARAMETER AppsFile
    Path to a JSON file listing the applications to audit. Each entry must have
    'Name' and 'AppId' fields. Required for government cloud environments where
    app IDs differ from commercial. Contact [email protected] for your
    environment's app IDs.

.PARAMETER RedactUPN
    When set, records the operator's Entra object ID instead of the User Principal Name
    in the generatedBy metadata field. Automatically enabled for AzureUSGovernment
    to avoid recording PII in audit artifacts.

.EXAMPLE
    pwsh -File Audit-EnterpriseApps.ps1
    pwsh -File Audit-EnterpriseApps.ps1 -OutputPath ./audit-2026-02-12.json
    pwsh -File Audit-EnterpriseApps.ps1 -Cloud AzureUSGovernment -AppsFile ./gov-apps.json -OutputPath ./audit-gov.json
#>

param(
    [string]$OutputPath = "enterprise-apps-audit.json",

    [ValidateSet("AzureCloud", "AzureUSGovernment")]
    [string]$Cloud = "AzureCloud",

    [string]$AppsFile,

    [switch]$RedactUPN
)

$ErrorActionPreference = "Stop"

# ── Cloud environment metadata ───────────────────────────────────────────────
$EnvironmentNames = @{
    "AzureCloud"        = "Commercial"
    "AzureUSGovernment" = "US Government (GCC High / DoD)"
}

# Maps our -Cloud parameter values to the Connect-MgGraph -Environment values
# that Microsoft Graph PowerShell SDK uses internally.  GCC High uses "USGov"
# and DoD uses "USGovDoD" — both map to our single "AzureUSGovernment" value.
$ValidMgEnvironments = @{
    "AzureCloud"        = @("Global")
    "AzureUSGovernment" = @("USGov", "USGovDoD")
}

# ── Pre-flight: verify Microsoft Graph authentication ─────────────────────────
$context = Get-MgContext
if (-not $context) {
    $suggestedEnv = $ValidMgEnvironments[$Cloud][0]
    $envFlag = if ($Cloud -ne "AzureCloud") {
        " -Environment $suggestedEnv"
    } else { "" }
    Write-Error ("No active Microsoft Graph session. Connect first:`n" +
        "  Connect-MgGraph -Scopes 'Application.Read.All','Directory.Read.All'$envFlag`n" +
        "  Connect-MgGraph -Scopes 'Application.Read.All','Directory.Read.All'$envFlag -UseDeviceCode")
    exit 1
}

# Validate the connected environment matches the requested one.
# Connect-MgGraph -Environment uses "Global", "USGov", "USGovDoD" internally,
# so we check against the valid set for the -Cloud value the operator chose.
if ($context.Environment -notin $ValidMgEnvironments[$Cloud]) {
    $validList = $ValidMgEnvironments[$Cloud] -join "' or '"
    Write-Error ("Environment mismatch: connected to '$($context.Environment)' " +
        "but -Cloud '$Cloud' expects '$validList'.`n" +
        "Disconnect and reconnect to the correct environment:`n" +
        "  Disconnect-MgGraph`n" +
        "  Connect-MgGraph -Scopes 'Application.Read.All','Directory.Read.All' -Environment $($ValidMgEnvironments[$Cloud][0])")
    exit 1
}

# Resolve operator identity via Graph API (avoids parsing access tokens)
$meResponse = Invoke-MgGraphRequest -Uri '/me?$select=id,userPrincipalName' -Method GET
$operatorOid = $meResponse.id
$operatorUpn = $meResponse.userPrincipalName

# Auto-enable UPN redaction for government environments
$effectiveRedact = $RedactUPN.IsPresent -or ($Cloud -ne "AzureCloud")
$generatedBy = if ($effectiveRedact) { $operatorOid } else { $operatorUpn }

$envDisplayName = $EnvironmentNames[$Cloud]
Write-Host "Environment: $envDisplayName ($Cloud)" -ForegroundColor Green
Write-Host "Authenticated as: $generatedBy (Tenant: $($context.TenantId))" -ForegroundColor Green

# ── ContraForce enterprise applications to audit ──────────────────────────────
# App IDs differ by cloud environment.  The commercial IDs are built-in;
# government cloud app IDs are provided by ContraForce upon request and
# passed via -AppsFile.
if ($AppsFile) {
    $AppsToAudit = Get-Content $AppsFile -Raw | ConvertFrom-Json
} elseif ($Cloud -eq "AzureCloud") {
    $AppsToAudit = @(
        @{ Name = "ContraForce API"; AppId = "24d97bc0-8f2b-45d5-8e0b-7fe286732ef2" }
        @{ Name = "ContraForce Portal"; AppId = "8b7cb435-9526-47ee-b79a-34433f0daad2" }
        @{ Name = "ContraForce for MDE"; AppId = "6efccc6a-f0d3-49e5-92d0-17d4afa9ba52" }
        @{ Name = "ContraForce Gamebooks for MDE"; AppId = "ad7b0e79-3c37-4408-bf8f-eb89522cc920" }
        @{ Name = "ContraForce Gamebooks for Identity"; AppId = "36b0d51c-4c0f-4810-9cc4-bfbd40c7dd4a" }
        @{ Name = "ContraForce Gamebooks for Email"; AppId = "44dbf6fe-45e3-48a3-bac3-f8d4cf1dba6d" }
        @{ Name = "ContraForce Sentinel Hunting"; AppId = "6bf1c74d-7ade-4671-a507-166936f89a1f" }
        @{ Name = "ContraForce User Management"; AppId = "460b65b7-3a5e-4a2c-98d0-e48fd35374a9" }
    )
} else {
    Write-Error ("Government cloud environments require -AppsFile.`n" +
        "App IDs differ by cloud environment. Contact [email protected]`n" +
        "to obtain the app IDs for your environment.")
    exit 1
}

# ── Well-known first-party resource APIs ──────────────────────────────────────
# Maps Entra object IDs → friendly names for the Microsoft APIs that ContraForce
# integrates with. Object IDs are tenant-local so we resolve them at runtime.
$ResourceAPIs = @(
    "Microsoft Graph"
    "Windows Azure Service Management API"
    "WindowsDefenderATP"
    "Microsoft Threat Protection"
    "Log Analytics API"
)

# ── Friendly display names for resource APIs ──────────────────────────────────
$FriendlyNames = @{
    "Windows Azure Service Management API" = "Azure Service Management"
}

# ── Permission lookup tables ─────────────────────────────────────────────────
$AppRoleLookup = @{}       # resourceSPId -> { roleId -> { name, description } }
$DescByNameLookup = @{}    # resourceSPId -> { scopeName -> description }
$ResourceNameById = @{}    # resourceSPId -> friendly name

function Register-ServicePrincipal {
    param(
        [Parameter(Mandatory)]$Sp,
        [string]$FriendlyName
    )
    $id = $Sp.Id
    $ResourceNameById[$id] = if ($FriendlyName) { $FriendlyName } else { $Sp.DisplayName }

    $roles = @{}
    foreach ($role in $Sp.AppRoles) {
        $roles[$role.Id] = @{ name = $role.Value; description = $role.Description }
    }
    $AppRoleLookup[$id] = $roles

    $descByName = @{}
    foreach ($scope in $Sp.Oauth2PermissionScopes) {
        $descByName[$scope.Value] = $scope.AdminConsentDescription
    }
    $DescByNameLookup[$id] = $descByName
}

# ── Resolve resource API service principals ───────────────────────────────────
Write-Host "Resolving resource API service principals..." -ForegroundColor Cyan

foreach ($apiName in $ResourceAPIs) {
    $sp = Get-MgServicePrincipal -Filter "displayName eq '$apiName'" `
        -Property Id, DisplayName, AppRoles, Oauth2PermissionScopes -Top 1
    if (-not $sp) {
        Write-Warning "Resource API not found in tenant: $apiName"
        continue
    }

    $friendly = if ($FriendlyNames.ContainsKey($sp.DisplayName)) {
        $FriendlyNames[$sp.DisplayName]
    } else { $sp.DisplayName }
    Register-ServicePrincipal -Sp $sp -FriendlyName $friendly

    Write-Host "  Resolved: $friendly ($($sp.Id)) — $($AppRoleLookup[$sp.Id].Count) app roles, $($DescByNameLookup[$sp.Id].Count) delegated scopes"
}

# ── Also index ContraForce apps themselves (they expose internal scopes) ──────
# Why startswith('ContraForce')?
# Some ContraForce apps delegate to each other via custom oauth2 scopes (e.g. the
# Portal app exposes scopes consumed by the API app). To resolve these internal
# cross-app scopes to human-readable names instead of raw GUIDs, we index ALL
# service principals whose display name starts with "ContraForce" — not just the
# 8 customer-facing apps listed in $AppsToAudit. Permissions that originate from
# these internal apps are tagged with "internal": true in the output so auditors
# can distinguish them from Microsoft first-party API permissions.
$AllCFServicePrincipals = Get-MgServicePrincipal `
    -Filter "startswith(displayName, 'ContraForce')" `
    -Property Id, DisplayName, AppRoles, Oauth2PermissionScopes -All
foreach ($cfSp in $AllCFServicePrincipals) {
    if (-not $ResourceNameById.ContainsKey($cfSp.Id)) {
        Register-ServicePrincipal -Sp $cfSp
    }
}

# ── Audit each application ───────────────────────────────────────────────────
$results = [System.Collections.Generic.List[object]]::new()

foreach ($app in $AppsToAudit) {
    Write-Host "`nAuditing: $($app.Name) ($($app.AppId))..." -ForegroundColor Cyan

    # Find the service principal in this tenant
    $sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" `
        -Property Id, DisplayName, AppId -Top 1
    if (-not $sp) {
        Write-Warning "  NOT FOUND in tenant — skipping"
        $results.Add([ordered]@{
                applicationName        = $app.Name
                appId                  = $app.AppId
                status                 = "NOT_FOUND"
                delegatedPermissions   = @()
                applicationPermissions = @()
            })
        continue
    }
    $spId = $sp.Id

    # ── Delegated permissions (oauth2PermissionGrants) ────────────────────────
    $grants = Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $spId -All
    $delegated = [System.Collections.Generic.List[object]]::new()

    foreach ($grant in $grants) {
        $resourceId = $grant.ResourceId
        $resourceName = if ($ResourceNameById.ContainsKey($resourceId)) {
            $ResourceNameById[$resourceId]
        } else { $resourceId }
        $isInternal = $resourceName -like "ContraForce *"
        $scopeNames = ($grant.Scope -split ' ') | Where-Object { $_ -ne '' } | Sort-Object

        foreach ($scope in $scopeNames) {
            $desc = if ($DescByNameLookup.ContainsKey($resourceId) -and
                        $DescByNameLookup[$resourceId].ContainsKey($scope)) {
                $DescByNameLookup[$resourceId][$scope]
            } else { $null }

            $delegated.Add([ordered]@{
                    permission  = $scope
                    api         = $resourceName
                    type        = "Delegated"
                    description = $desc
                    internal    = $isInternal
                })
        }
    }

    # ── Application permissions (appRoleAssignments) ──────────────────────────
    $assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $spId -All
    $appPerms = [System.Collections.Generic.List[object]]::new()

    foreach ($assignment in $assignments) {
        $resourceName = $assignment.ResourceDisplayName
        $resourceId = $assignment.ResourceId
        $roleId = $assignment.AppRoleId

        # Resolve the role ID to a permission name and description
        $permName = $roleId
        $desc = $null
        if ($AppRoleLookup.ContainsKey($resourceId)) {
            $lookup = $AppRoleLookup[$resourceId]
            if ($lookup.ContainsKey($roleId)) {
                $permName = $lookup[$roleId].name
                $desc = $lookup[$roleId].description
            }
        }

        # Use friendly resource name if available
        $friendlyResource = if ($ResourceNameById.ContainsKey($resourceId)) {
            $ResourceNameById[$resourceId]
        } else { $resourceName }

        $appPerms.Add([ordered]@{
                permission  = $permName
                api         = $friendlyResource
                type        = "Application"
                description = $desc
            })
    }

    # Sort for stable output
    $sortedDelegated = $delegated | Sort-Object { $_.api }, { $_.permission }
    $sortedAppPerms = $appPerms  | Sort-Object { $_.api }, { $_.permission }

    $results.Add([ordered]@{
            applicationName        = $sp.DisplayName
            appId                  = $sp.AppId
            status                 = "OK"
            delegatedPermissions   = @($sortedDelegated)
            applicationPermissions = @($sortedAppPerms)
        })

    Write-Host "  Delegated: $($delegated.Count) | Application: $($appPerms.Count)"
}

# ── Build output document ────────────────────────────────────────────────────
$output = [ordered]@{
    metadata     = [ordered]@{
        generatedAt  = (Get-Date -Format "o")
        tenantId     = $context.TenantId
        environment  = $Cloud
        generatedBy  = $generatedBy
        toolVersion  = "2.1.0"
        description  = "ContraForce enterprise application permissions snapshot for documentation auditing."
    }
    applications = @($results)
}

$fullPath = [System.IO.Path]::GetFullPath($OutputPath)
$json = $output | ConvertTo-Json -Depth 10
# Normalize to LF line endings
$json = $json -replace "`r`n", "`n"
[System.IO.File]::WriteAllText(
    $fullPath,
    "$json`n",
    [System.Text.UTF8Encoding]::new($false)
)

# ── Restrict file permissions for government environments ─────────────────────
# In GCC High and DoD environments, restrict the output file so only the current
# user can read/write it. This prevents other accounts on shared jump boxes from
# accessing tenant permission data.
if ($Cloud -ne "AzureCloud") {
    try {
        $acl = Get-Acl -Path $fullPath
        $acl.SetAccessRuleProtection($true, $false)  # disable inheritance, remove inherited rules
        $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
        $rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
            $currentUser,
            [System.Security.AccessControl.FileSystemRights]::FullControl,
            [System.Security.AccessControl.AccessControlType]::Allow
        )
        $acl.SetAccessRule($rule)
        Set-Acl -Path $fullPath -AclObject $acl
        Write-Host "  File permissions restricted to current user." -ForegroundColor Yellow
    } catch {
        Write-Warning "Could not restrict file permissions on '$fullPath': $_"
    }
}

Write-Host "`nAudit complete. Output written to: $OutputPath" -ForegroundColor Green
Write-Host "Environment: $envDisplayName"
Write-Host "Applications audited: $($results.Count)"
Write-Host "Tenant: $($context.TenantId)"

# ── Exit with non-zero code if any apps were not found ────────────────────────
$failures = @($results | Where-Object { $_.status -eq "NOT_FOUND" })
if ($failures.Count -gt 0) {
    Write-Warning "$($failures.Count) application(s) were not found in the tenant."
    exit 1
}

Understanding the Output

Both scripts produce a JSON file with the same structure. Here’s an abbreviated example:
Output Schema
{
  "metadata": {
    "generatedAt": "2026-02-12T18:30:00.000000+00:00",
    "tenantId": "your-tenant-id",
    "generatedBy": "[email protected]",
    "toolVersion": "2.1.0",
    "environment": "AzureCloud",
    "description": "ContraForce enterprise application permissions snapshot for documentation auditing."
  },
  "applications": [
    {
      "applicationName": "ContraForce API",
      "appId": "24d97bc0-8f2b-45d5-8e0b-7fe286732ef2",
      "status": "OK",
      "delegatedPermissions": [
        {
          "permission": "Application.Read.All",
          "api": "Microsoft Graph",
          "type": "Delegated",
          "description": "Allows the app to read applications and service principals on behalf of the signed-in user.",
          "internal": false
        }
      ],
      "applicationPermissions": []
    }
  ]
}

Key Fields

FieldDescription
statusOK if the application was found and audited. NOT_FOUND if the application is not consented in the tenant.
typeDelegated (on-behalf-of user) or Application (app-only, no user context). See Permission Types Explained.
internaltrue for permissions between ContraForce applications (e.g., Portal delegating to API). These are internal platform scopes and do not grant access to your tenant data. Only present on delegated permissions.
environmentThe cloud environment the audit was run against (AzureCloud or AzureUSGovernment). Matches the az cloud set --name value.
generatedByThe authenticated operator. In government environments, this is an Entra object ID (GUID) rather than a UPN.
toolVersionInclude this when sharing audit results with ContraForce support for traceability.

Internal Scope Resolution

You may notice the output includes delegated permissions where the api field shows a ContraForce application name (e.g., "ContraForce Portal") and "internal": true. These are cross-application delegations where one ContraForce app delegates to another via custom OAuth2 scopes. To resolve these internal scopes to human-readable names instead of raw GUIDs, the scripts query for all service principals whose display name starts with ContraForce — not just the eight applications listed in the audit table. This broader query:
  • Does not grant additional access — it reads public service principal metadata that any authenticated directory reader can see
  • Is clearly tagged — all permissions from internal apps are marked "internal": true in the output
  • Improves readability — without this, internal scopes would appear as opaque GUIDs that are difficult to review

Comparing Against Documentation

To verify your tenant’s permissions match the documented permissions:
  1. Run the audit script to produce enterprise-apps-audit.json
  2. Open the Enterprise Applications Reference
  3. For each application in the JSON output, compare its delegatedPermissions and applicationPermissions against the corresponding tables in the reference
  4. Permissions marked "internal": true are ContraForce-to-ContraForce delegations and are not listed in the reference tables

Comparing Across Audit Runs

To track permission changes over time, save each audit output with a date-stamped filename:
# Date-stamped output
python audit_enterprise_apps.py -o audit-2026-02-12.json

# Later, compare against a previous audit
diff audit-2026-01-15.json audit-2026-02-12.json
Because the scripts sort all permissions alphabetically and produce deterministic JSON output, standard text diff tools (diff, VS Code’s built-in compare, or Compare-Object in PowerShell) will surface only actual permission changes — not ordering noise. Store audit outputs alongside your change management records. The metadata.generatedAt and metadata.toolVersion fields provide traceability for each snapshot.

Output File Access Control

The audit output reveals your tenant’s permission surface for ContraForce applications. While it does not contain secrets or tokens, it should be treated as internal documentation. Government cloud environments: Both scripts automatically restrict the output file so only the current user can read or write it:
  • PowerShell (Windows): Removes inherited ACL entries and grants FullControl only to the current user via Set-Acl
  • Python (POSIX): Sets file mode to 600 (chmod u=rw,go=) via os.chmod
Commercial environments: File permissions are not restricted automatically. If you are running the audit on a shared workstation or jump box, consider restricting access manually:
icacls enterprise-apps-audit.json /inheritance:r /grant:r "%USERNAME%":F

Using This Output as Audit Evidence

The audit output is designed to serve as evidence in change advisory board (CAB) reviews, compliance audits, and periodic access reviews. For CAB / Change Management:
  • Run the audit before and after onboarding a new ContraForce module
  • Include both snapshots in your change record to show exactly which permissions were added
  • The toolVersion field ensures reviewers know which version of the script produced the output
For Periodic Access Reviews (CMMC, SOC 2, FedRAMP):
  • Schedule monthly or quarterly audit runs and archive the output alongside your review documentation
  • Use date-stamped filenames (e.g., audit-2026-Q1.json) for easy retrieval
  • Compare successive outputs using diff to identify any permission drift
For Incident Response:
  • If you suspect unauthorized permission changes, run an immediate audit and compare against your most recent baseline
  • The generatedAt timestamp provides a verifiable point-in-time snapshot
Store audit outputs in a version-controlled repository or a tamper-evident storage location (such as an Azure Storage account with immutable blob policies) to maintain an auditable chain of custody.

Troubleshooting

IssueCauseResolution
NOT_FOUND for some applicationsApplication not consented in tenantExpected if you haven’t deployed all modules. See Applications by Module.
PowerShell: “No active Microsoft Graph session”Connect-MgGraph not runRun Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" first. For government cloud, add -Environment USGov (GCC High) or -Environment USGovDoD (DoD).
PowerShell: “Environment mismatch”-Cloud value does not match the Connect-MgGraph -Environment sessionDisconnect with Disconnect-MgGraph and reconnect with the correct -Environment flag matching your -Cloud value.
”Government cloud environments require -AppsFile / —apps-file”No apps file provided for government cloudApp IDs differ by cloud environment. Contact [email protected] for your environment’s app IDs and provide them via -AppsFile (PowerShell) or --apps-file (Python).
Python: “Azure CLI is not authenticated”az login not run or session expiredRun az login to authenticate. For government cloud, run az cloud set --name AzureUSGovernment first.
Python: “Missing required packages”azure-identity or httpx not installedRun pip install azure-identity httpx
Python: “Python 3.10+ is required”Running on an older Python versionInstall Python 3.10 or newer
Script takes a long timeMicrosoft Graph API throttlingNormal in large tenants. The scripts handle 429 Retry-After responses automatically.
Permissions don’t match documentationPermissions changed since last documentation updateContact [email protected] with the audit JSON and toolVersion

Questions about the audit scripts or enterprise application permissions? Contact us at [email protected].