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.

Directory Role Assignments

The scripts on this page report Microsoft Graph permission grants — delegated (oauth2PermissionGrants) and application (appRoleAssignments). They do not enumerate Entra directory role memberships, which are a separate authorization mechanism that Graph permission audits do not surface. One ContraForce capability uses a directory role. If you enable the optional service-provider password reset add-on on the Identity module, the ContraForce Gamebooks for Identity service principal is assigned the Authentication Administrator role so the Reset Password Gamebook can run app-only (see the Enterprise Applications Reference). Review that assignment directly with the same read-only access the audit scripts already use (Directory.Read.All):
# Connect-MgGraph -Scopes "Directory.Read.All"
# Commercial app ID shown; government environments use a different ID (contact support).
$sp = Get-MgServicePrincipal -Filter "appId eq '36b0d51c-4c0f-4810-9cc4-bfbd40c7dd4a'"
Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$($sp.Id)'" |
  ForEach-Object {
    $def = Get-MgRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $_.RoleDefinitionId
    [pscustomobject]@{ ServicePrincipal = $sp.DisplayName; Role = $def.DisplayName }
  }
# Requires Directory.Read.All. Resolve the service principal object id, then its role assignments.
GET https://graph.microsoft.com/v1.0/servicePrincipals(appId='36b0d51c-4c0f-4810-9cc4-bfbd40c7dd4a')?$select=id
GET https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?$filter=principalId eq '<id-from-above>'&$expand=roleDefinition
An empty result means the add-on is not enabled (or has been turned off). When it is enabled, the expected assignment is Authentication Administrator — a role scoped to non-administrator users that cannot reset passwords for Global Administrators or other higher-privileged roles.

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
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 support@contraforce.com 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 support@contraforce.com 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" }
    )
} else {
    Write-Error ("Government cloud environments require -AppsFile.`n" +
        "App IDs differ by cloud environment. Contact support@contraforce.com`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
}
#!/usr/bin/env python3
"""Audit ContraForce enterprise application permissions in Microsoft Entra ID.

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.

Requires:
    - Python 3.10+ (older versions will produce a clear version error).
      See https://devguide.python.org/versions/ for supported Python versions.
    - ``azure-identity>=1.19`` and ``httpx>=0.28``::

        pip install azure-identity httpx

    - Azure CLI (``az login``) session — used by ``AzureCliCredential`` for token acquisition
    - The target tenant must have the ContraForce enterprise applications consented

Usage:
    python audit_enterprise_apps.py
    python audit_enterprise_apps.py -o audit-2026-02-12.json
    python audit_enterprise_apps.py -c AzureUSGovernment
    python audit_enterprise_apps.py -c AzureUSGovernment -o gov-audit.json
"""

import argparse
import dataclasses
import importlib.util
import json
import os
import stat
import sys
import time
from datetime import datetime, timezone
from typing import Any

# ── Requires: Python 3.10+, azure-identity>=1.19, httpx>=0.28 ────────────────
# Mirrors PowerShell's #Requires — validate before any third-party import.
if sys.version_info < (3, 10):  # noqa: UP036
    print(
        f"Python 3.10+ is required (running {sys.version.split()[0]})",
        file=sys.stderr,
    )
    raise SystemExit(1)

_REQUIRED_PACKAGES = [
    ("httpx", "httpx>=0.28"),
    ("azure.identity", "azure-identity>=1.19"),
]
_missing = [spec for mod, spec in _REQUIRED_PACKAGES if importlib.util.find_spec(mod) is None]
if _missing:
    print(
        f"Missing required packages: {', '.join(_missing)}\n"
        "Install via: pip install azure-identity httpx",
        file=sys.stderr,
    )
    raise SystemExit(1)
del _REQUIRED_PACKAGES, _missing

import httpx  # noqa: E402
from azure.core.exceptions import ClientAuthenticationError  # noqa: E402
from azure.identity import AzureCliCredential, CredentialUnavailableError  # noqa: E402

TOOL_VERSION = "2.1.0"

# ── Cloud environment endpoints ───────────────────────────────────────────────
# Microsoft Graph endpoints differ by cloud environment.  Commercial (including
# GCC) uses graph.microsoft.com; GCC High and DoD use graph.microsoft.us.
# See: https://learn.microsoft.com/en-us/graph/deployments
CLOUD_ENVIRONMENTS: dict[str, dict[str, str]] = {
    "AzureCloud": {
        "graph_base": "https://graph.microsoft.com/v1.0",
        "graph_scope": "https://graph.microsoft.com/.default",
        "name": "Commercial",
    },
    "AzureUSGovernment": {
        "graph_base": "https://graph.microsoft.us/v1.0",
        "graph_scope": "https://graph.microsoft.us/.default",
        "name": "US Government (GCC High / DoD)",
    },
}

# ContraForce enterprise applications to audit (Commercial / AzureCloud only).
# App IDs differ by cloud environment.  Government cloud app IDs are provided
# by ContraForce upon request and passed via --apps-file.
COMMERCIAL_APPS = [
    {"name": "ContraForce API", "app_id": "24d97bc0-8f2b-45d5-8e0b-7fe286732ef2"},
    {"name": "ContraForce Portal", "app_id": "8b7cb435-9526-47ee-b79a-34433f0daad2"},
    {"name": "ContraForce for MDE", "app_id": "6efccc6a-f0d3-49e5-92d0-17d4afa9ba52"},
    {"name": "ContraForce Gamebooks for MDE", "app_id": "ad7b0e79-3c37-4408-bf8f-eb89522cc920"},
    {
        "name": "ContraForce Gamebooks for Identity",
        "app_id": "36b0d51c-4c0f-4810-9cc4-bfbd40c7dd4a",
    },
    {
        "name": "ContraForce Gamebooks for Email",
        "app_id": "44dbf6fe-45e3-48a3-bac3-f8d4cf1dba6d",
    },
    {
        "name": "ContraForce Sentinel Hunting",
        "app_id": "6bf1c74d-7ade-4671-a507-166936f89a1f",
    },
]

# Well-known first-party resource APIs that ContraForce integrates with.
RESOURCE_APIS = [
    "Microsoft Graph",
    "Windows Azure Service Management API",
    "WindowsDefenderATP",
    "Microsoft Threat Protection",
    "Log Analytics API",
]

# Friendly display names for resource APIs.
FRIENDLY_NAMES: dict[str, str] = {
    "Windows Azure Service Management API": "Azure Service Management",
}


@dataclasses.dataclass
class PermissionRegistry:
    """Lookup tables for resolving permission IDs to human-readable names."""

    app_roles: dict[str, dict[str, dict[str, str | None]]] = dataclasses.field(
        default_factory=dict,
    )
    delegated_scopes: dict[str, dict[str, dict[str, str | None]]] = dataclasses.field(
        default_factory=dict,
    )
    resource_names: dict[str, str] = dataclasses.field(default_factory=dict)
    _scope_desc_by_name: dict[str, dict[str, str | None]] = dataclasses.field(
        default_factory=dict, repr=False,
    )

    def index_service_principal(self, sp: dict, *, friendly_name: str | None = None) -> None:
        """Index a service principal's roles and scopes for later resolution."""
        sp_id = sp["id"]
        self.resource_names[sp_id] = friendly_name or sp["displayName"]

        self.app_roles[sp_id] = {
            role["id"]: {"name": role["value"], "description": role.get("description")}
            for role in sp.get("appRoles") or []
        }

        scopes_by_id: dict[str, dict[str, str | None]] = {}
        desc_by_name: dict[str, str | None] = {}
        for scope in sp.get("oauth2PermissionScopes") or []:
            desc = scope.get("adminConsentDescription")
            scopes_by_id[scope["id"]] = {"name": scope["value"], "description": desc}
            desc_by_name[scope["value"]] = desc

        self.delegated_scopes[sp_id] = scopes_by_id
        self._scope_desc_by_name[sp_id] = desc_by_name

    def resolve_scope_description(self, resource_id: str, scope_name: str) -> str | None:
        """Look up a delegated scope description by name."""
        return self._scope_desc_by_name.get(resource_id, {}).get(scope_name)


class AuditAuthError(Exception):
    """Raised when Azure CLI authentication fails."""


class GraphClient:
    """Lightweight Microsoft Graph client backed by httpx and AzureCliCredential."""

    def __init__(self, *, cloud: str = "AzureCloud") -> None:
        env = CLOUD_ENVIRONMENTS[cloud]
        self._graph_base = env["graph_base"]
        self._graph_scope = env["graph_scope"]

        try:
            self._credential = AzureCliCredential()
            self._token = self._credential.get_token(self._graph_scope)
        except (CredentialUnavailableError, ClientAuthenticationError) as e:
            raise AuditAuthError(
                "Azure CLI is not authenticated. Run 'az login' first.\n"
                "For government cloud: az cloud set --name AzureUSGovernment && az login"
            ) from e

        self._http = httpx.Client(
            timeout=httpx.Timeout(30.0, connect=10.0),
        )
        self._refresh_auth()

        # Resolve identity from Microsoft Graph API rather than decoding the
        # access token JWT on the client side.  Access tokens are intended for
        # the resource server, not the client — decoding them without signature
        # verification is architecturally incorrect and raises red flags in
        # security reviews.  Using /me and /organization is both correct and
        # eliminates the need for a JWT verification library.
        self._resolve_identity()

    def _resolve_identity(self) -> None:
        """Resolve authenticated user and tenant identity from Microsoft Graph."""
        me_resp = self._request_with_retry(
            f"{self._graph_base}/me",
            params={"$select": "id,userPrincipalName"},
        )
        if me_resp is not None:
            me = me_resp.json()
            self.user_upn: str = me.get("userPrincipalName", "")
            self.user_oid: str = me.get("id", "")
        else:
            self.user_upn = ""
            self.user_oid = ""
            print(
                "  WARNING: Could not resolve user identity from /me.",
                file=sys.stderr,
            )

        org_resp = self._request_with_retry(
            f"{self._graph_base}/organization",
            params={"$select": "id"},
        )
        if org_resp is not None:
            orgs = org_resp.json().get("value", [])
            self.tenant_id: str = orgs[0]["id"] if orgs else ""
        else:
            self.tenant_id = ""
            print(
                "  WARNING: Could not resolve tenant from /organization.",
                file=sys.stderr,
            )

    # NOTE: Remove quotes when minimum version is Python 3.14+ (PEP 649).
    def __enter__(self) -> "GraphClient":  # quoted: class name isn't bound yet
        return self

    def __exit__(self, *exc: object) -> None:
        self._http.close()

    def _refresh_auth(self) -> None:
        """Refresh the bearer token if it is within 5 minutes of expiry."""
        if self._token.expires_on - time.time() < 300:
            self._token = self._credential.get_token(self._graph_scope)
        self._http.headers["Authorization"] = f"Bearer {self._token.token}"

    def _request_with_retry(
        self, url: str, params: dict[str, str] | None = None, *, max_retries: int = 3,
    ) -> httpx.Response | None:
        """GET with retry and 429/Retry-After handling."""
        for attempt in range(max_retries + 1):
            self._refresh_auth()
            try:
                resp = self._http.get(url, params=params)
                if resp.status_code == 429:
                    try:
                        retry_after = int(resp.headers.get("Retry-After", 2**attempt))
                    except ValueError:
                        retry_after = 2**attempt
                    print(f"  Throttled, retrying in {retry_after}s...", file=sys.stderr)
                    time.sleep(retry_after)
                    continue
                resp.raise_for_status()
                return resp
            except httpx.HTTPError as e:
                if attempt < max_retries:
                    time.sleep(2**attempt)
                    continue
                print(
                    f"  WARNING: Graph call failed after {max_retries + 1} attempts: {url}\n"
                    f"  {e}",
                    file=sys.stderr,
                )
                return None
        return None

    def paginated_get(self, url: str, params: dict[str, str] | None = None) -> list[dict]:
        """GET with automatic @odata.nextLink pagination."""
        all_values: list[dict] = []
        current_url: str | None = url
        current_params = params
        while current_url:
            resp = self._request_with_retry(current_url, current_params)
            if resp is None:
                print(
                    f"  WARNING: Pagination interrupted — returning {len(all_values)} "
                    f"partial result(s) for {url}",
                    file=sys.stderr,
                )
                break
            data = resp.json()
            all_values.extend(data.get("value", []))
            current_url = data.get("@odata.nextLink")
            current_params = None  # nextLink includes query params
        return all_values

    def list_service_principals(
        self, *, odata_filter: str, select: list[str],
    ) -> list[dict]:
        """Query /servicePrincipals with an OData filter."""
        return self.paginated_get(
            f"{self._graph_base}/servicePrincipals",
            params={"$filter": odata_filter, "$select": ",".join(select)},
        )

    def get_oauth2_permission_grants(self, sp_id: str) -> list[dict]:
        """Get delegated permission grants for a service principal."""
        return self.paginated_get(
            f"{self._graph_base}/servicePrincipals/{sp_id}/oauth2PermissionGrants",
        )

    def get_app_role_assignments(self, sp_id: str) -> list[dict]:
        """Get application permission assignments for a service principal."""
        return self.paginated_get(
            f"{self._graph_base}/servicePrincipals/{sp_id}/appRoleAssignments",
        )


def resolve_resource_apis(graph: GraphClient, registry: PermissionRegistry) -> None:
    """Resolve resource API service principals and populate the registry."""
    print("\033[36mResolving resource API service principals...\033[0m")

    select = ["id", "displayName", "appRoles", "oauth2PermissionScopes"]

    for api_name in RESOURCE_APIS:
        results = graph.list_service_principals(
            odata_filter=f"displayName eq '{api_name}'",
            select=select,
        )
        if not results:
            print(f"  WARNING: Resource API not found in tenant: {api_name}", file=sys.stderr)
            continue

        sp = results[0]
        sp_id = sp["id"]
        friendly_name = FRIENDLY_NAMES.get(sp["displayName"], sp["displayName"])
        registry.index_service_principal(sp, friendly_name=friendly_name)

        print(
            f"  Resolved: {friendly_name} ({sp_id})"
            f" — {len(registry.app_roles[sp_id])} app roles,"
            f" {len(registry.delegated_scopes[sp_id])} delegated scopes"
        )


def index_contraforce_apps(graph: GraphClient, registry: PermissionRegistry) -> None:
    """Index ContraForce apps for internal cross-app scope resolution.

    Some ContraForce applications delegate to each other via custom OAuth2
    scopes (e.g., the Portal delegates to the API).  This queries for ALL
    service principals whose displayName starts with ``ContraForce`` — not
    just the seven audited apps — so that cross-app scopes resolve to
    human-readable names instead of raw GUIDs in the output.

    This broader query does NOT grant any additional access; it only reads
    public service principal metadata visible to any authenticated directory
    reader.  Permissions returned from this query are tagged with
    ``"internal": true`` in the output to distinguish them from permissions
    that grant access to tenant data.
    """
    cf_sps = graph.list_service_principals(
        odata_filter="startswith(displayName, 'ContraForce')",
        select=["id", "displayName", "oauth2PermissionScopes"],
    )

    for cf_sp in cf_sps:
        if cf_sp["id"] not in registry.resource_names:
            registry.index_service_principal(cf_sp)


def audit_delegated_permissions(
    graph: GraphClient,
    sp_id: str,
    registry: PermissionRegistry,
) -> list[dict[str, Any]]:
    """Query and resolve delegated permissions (oauth2PermissionGrants)."""
    grants = graph.get_oauth2_permission_grants(sp_id)
    delegated: list[dict[str, Any]] = []

    for grant in grants:
        resource_id = grant["resourceId"]
        resource_name = registry.resource_names.get(resource_id, resource_id)
        is_internal = resource_name.startswith("ContraForce ")
        scope_names = sorted(s for s in grant.get("scope", "").split() if s)

        for scope in scope_names:
            delegated.append({
                "permission": scope,
                "api": resource_name,
                "type": "Delegated",
                "description": registry.resolve_scope_description(resource_id, scope),
                "internal": is_internal,
            })

    return sorted(delegated, key=lambda p: (p["api"], p["permission"]))


def audit_application_permissions(
    graph: GraphClient,
    sp_id: str,
    registry: PermissionRegistry,
) -> list[dict[str, Any]]:
    """Query and resolve application permissions (appRoleAssignments)."""
    assignments = graph.get_app_role_assignments(sp_id)
    app_perms: list[dict[str, Any]] = []

    for assignment in assignments:
        resource_name = assignment.get("resourceDisplayName", "")
        resource_id = assignment["resourceId"]
        role_id = assignment["appRoleId"]

        # Resolve the role ID to a permission name and description
        role = registry.app_roles.get(resource_id, {}).get(role_id)
        perm_name = role["name"] if role else role_id
        desc = role["description"] if role else None

        app_perms.append({
            "permission": perm_name,
            "api": registry.resource_names.get(resource_id, resource_name),
            "type": "Application",
            "description": desc,
        })

    return sorted(app_perms, key=lambda p: (p["api"], p["permission"]))


def audit_app(
    graph: GraphClient,
    app: dict[str, str],
    registry: PermissionRegistry,
) -> dict[str, Any]:
    """Audit a single enterprise application."""
    print(f"\n\033[36mAuditing: {app['name']} ({app['app_id']})...\033[0m")

    results = graph.list_service_principals(
        odata_filter=f"appId eq '{app['app_id']}'",
        select=["id", "displayName", "appId"],
    )
    if not results:
        print("  WARNING: NOT FOUND in tenant — skipping", file=sys.stderr)
        return {
            "applicationName": app["name"],
            "appId": app["app_id"],
            "status": "NOT_FOUND",
            "delegatedPermissions": [],
            "applicationPermissions": [],
        }

    sp = results[0]
    sp_id = sp["id"]

    delegated = audit_delegated_permissions(graph, sp_id, registry)
    app_perms = audit_application_permissions(graph, sp_id, registry)

    print(f"  Delegated: {len(delegated)} | Application: {len(app_perms)}")

    return {
        "applicationName": sp["displayName"],
        "appId": sp["appId"],
        "status": "OK",
        "delegatedPermissions": delegated,
        "applicationPermissions": app_perms,
    }


def parse_args() -> argparse.Namespace:
    """Parse command-line arguments."""
    parser = argparse.ArgumentParser(
        description="Audit ContraForce enterprise application permissions in Microsoft Entra ID.",
    )
    parser.add_argument(
        "-o", "--output",
        default="enterprise-apps-audit.json",
        help="Path for the output JSON file (default: enterprise-apps-audit.json)",
    )
    parser.add_argument(
        "-c", "--cloud",
        choices=list(CLOUD_ENVIRONMENTS),
        default="AzureCloud",
        help=(
            "Cloud environment to audit: AzureCloud (default) or"
            " AzureUSGovernment (GCC High / DoD). Must match the environment"
            " set with 'az cloud set --name <value>'."
        ),
    )
    parser.add_argument(
        "-a", "--apps-file",
        help=(
            "Path to a JSON file listing the applications to audit. Each entry"
            " must have 'name' and 'app_id' fields. Required for government"
            " cloud environments where app IDs differ from commercial."
            " Contact support@contraforce.com for your environment's app IDs."
        ),
    )
    parser.add_argument(
        "--redact-upn",
        action="store_true",
        help=(
            "Record the operator's Entra object ID instead of UPN in output"
            " metadata. Automatically enabled for government environments."
        ),
    )
    return parser.parse_args()


def main() -> int:
    """Run the enterprise application audit and return exit code."""
    args = parse_args()
    cloud = args.cloud

    # Resolve the application list: built-in for commercial, file-based for gov
    if args.apps_file:
        with open(args.apps_file, encoding="utf-8") as f:
            apps_to_audit = json.load(f)
    elif cloud == "AzureCloud":
        apps_to_audit = COMMERCIAL_APPS
    else:
        print(
            "ERROR: Government cloud environments require --apps-file.\n"
            "App IDs differ by cloud environment. Contact support@contraforce.com\n"
            "to obtain the app IDs for your environment.",
            file=sys.stderr,
        )
        return 1

    try:
        graph = GraphClient(cloud=cloud)
    except AuditAuthError as e:
        print(f"ERROR: {e}", file=sys.stderr)
        return 1

    with graph:
        # Auto-enable UPN redaction for government cloud environments
        redact_upn = args.redact_upn or cloud != "AzureCloud"
        generated_by = graph.user_oid if redact_upn else graph.user_upn

        tenant_id = graph.tenant_id
        env_name = CLOUD_ENVIRONMENTS[cloud]["name"]
        print(
            f"\033[32mAuthenticated as: {generated_by}"
            f" (Tenant: {tenant_id}, Environment: {env_name})\033[0m"
        )

        registry = PermissionRegistry()
        resolve_resource_apis(graph, registry)
        index_contraforce_apps(graph, registry)

        results = [audit_app(graph, app, registry) for app in apps_to_audit]

    # Build output document
    output = {
        "metadata": {
            "generatedAt": datetime.now(timezone.utc).isoformat(),  # noqa: UP017
            "tenantId": tenant_id,
            "generatedBy": generated_by,
            "toolVersion": TOOL_VERSION,
            "environment": cloud,
            "description": (
                "ContraForce enterprise application permissions snapshot"
                " for documentation auditing."
            ),
        },
        "applications": results,
    }

    output_json = json.dumps(output, indent=2, ensure_ascii=False) + "\n"
    with open(args.output, "w", encoding="utf-8", newline="\n") as f:
        f.write(output_json)

    # Restrict file permissions for government cloud environments
    if cloud != "AzureCloud":
        try:
            os.chmod(args.output, stat.S_IRUSR | stat.S_IWUSR)
        except OSError:
            print(
                f"  WARNING: Could not restrict file permissions on {args.output}.\n"
                '  On Windows, run: icacls <file> /inheritance:r /grant:r "%USERNAME%":F',
                file=sys.stderr,
            )

    print(f"\n\033[32mAudit complete. Output written to: {args.output}\033[0m")
    print(f"Applications audited: {len(results)}")
    print(f"Tenant: {tenant_id}")
    print(f"Environment: {env_name}")
    if redact_upn:
        print("Operator identity: redacted (object ID used)")

    # Exit with non-zero code if any apps were not found
    failures = [r for r in results if r["status"] == "NOT_FOUND"]
    if failures:
        print(
            f"\nWARNING: {len(failures)} application(s) were not found in the tenant.",
            file=sys.stderr,
        )
        return 1

    return 0


if __name__ == "__main__":
    sys.exit(main())

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": "admin@company.example",
    "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 seven 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 support@contraforce.com 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 support@contraforce.com with the audit JSON and toolVersion

Enterprise Applications Reference

Complete reference for all ContraForce enterprise applications and their permissions

Azure Resources Deployed

All Azure resources provisioned during ContraForce onboarding

Roles & Permissions

ContraForce platform roles and what each can do

Platform Onboarding

Step-by-step guide to onboarding your parent workspace
Questions about the audit scripts or enterprise application permissions? Contact us at support@contraforce.com.