SOC2/Get-SOC2SecurityControls.ps1

<#
.SYNOPSIS
    Assesses SOC 2 Security trust principle controls against Microsoft 365 configuration.
.DESCRIPTION
    Evaluates Microsoft 365 tenant settings against SOC 2 Trust Service Criteria for
    the Security principle. Checks Conditional Access policies, MFA enforcement,
    admin role assignments, audit logging, and Defender alert configurations.
 
    All operations are strictly read-only (Get-* cmdlets and Graph GET requests only).
    Maps each check to an AICPA SOC 2 Trust Service Criterion reference.
 
    DISCLAIMER: This tool assists with SOC 2 readiness assessment. It does not
    constitute a SOC 2 audit or certification.
 
    Requires Microsoft Graph connection with the following scopes:
    Policy.Read.All, RoleManagement.Read.Directory, SecurityEvents.Read.All,
    AuditLog.Read.All, User.Read.All, Reports.Read.All
.PARAMETER OutputPath
    Optional path to export results as CSV. If not specified, results are returned
    to the pipeline.
.EXAMPLE
    PS> . .\Common\Connect-Service.ps1
    PS> Connect-Service -Service Graph -Scopes 'Policy.Read.All','RoleManagement.Read.Directory','SecurityEvents.Read.All'
    PS> .\SOC2\Get-SOC2SecurityControls.ps1
 
    Displays SOC 2 Security control assessment results.
.EXAMPLE
    PS> .\SOC2\Get-SOC2SecurityControls.ps1 -OutputPath '.\soc2-security.csv'
 
    Exports SOC 2 Security control results to CSV.
.NOTES
    Author: Daren9m
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$OutputPath
)

$ErrorActionPreference = 'Stop'

# Verify Graph connection
if (-not (Assert-GraphConnection)) { return }

$results = [System.Collections.Generic.List[PSCustomObject]]::new()

# Helper to add a control result
function Add-ControlResult {
    param(
        [string]$TrustPrinciple,
        [string]$TSCReference,
        [string]$ControlId,
        [string]$ControlName,
        [string]$CurrentValue,
        [string]$ExpectedValue,
        [string]$Status,
        [string]$Severity,
        [string]$Evidence = '',
        [string]$Remediation = ''
    )
    $results.Add([PSCustomObject]@{
        TrustPrinciple = $TrustPrinciple
        TSCReference   = $TSCReference
        ControlId      = $ControlId
        ControlName    = $ControlName
        CurrentValue   = $CurrentValue
        ExpectedValue  = $ExpectedValue
        Status         = $Status
        Severity       = $Severity
        Evidence       = $Evidence
        Remediation    = $Remediation
    })
}

# ------------------------------------------------------------------
# S-01: MFA Enforced for All Users (CC6.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "S-01: Checking MFA enforcement via Conditional Access..."
    $caPolicies = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/identity/conditionalAccess/policies' -ErrorAction Stop
    $policies = if ($caPolicies -and $caPolicies['value']) { @($caPolicies['value']) } else { @() }

    # Check for Security Defaults first
    $secDefaults = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/policies/identitySecurityDefaultsEnforcementPolicy' -ErrorAction Stop
    $secDefaultsEnabled = $secDefaults['isEnabled']

    # Check for CA policy requiring MFA for all users
    $mfaForAll = $false
    $mfaPolicyNames = @()
    foreach ($policy in $policies) {
        if ($policy['state'] -ne 'enabled') { continue }
        $grantControls = $policy['grantControls']
        if (-not $grantControls) { continue }
        $builtInControls = @($grantControls['builtInControls'])
        $authStrength = $grantControls['authenticationStrength']
        $hasMfa = $builtInControls -contains 'mfa' -or $null -ne $authStrength

        if (-not $hasMfa) { continue }

        # Check if targeting all users
        $includeUsers = @($policy['conditions']['users']['includeUsers'])
        if ($includeUsers -contains 'All') {
            $mfaForAll = $true
            $mfaPolicyNames += $policy['displayName']
        }
    }

    $currentValue = if ($secDefaultsEnabled) {
        'Security Defaults enabled (MFA enforced)'
    } elseif ($mfaForAll) {
        "CA policy: $($mfaPolicyNames -join '; ')"
    } else {
        'No MFA-for-all policy found'
    }

    $status = if ($secDefaultsEnabled -or $mfaForAll) { 'Pass' } else { 'Fail' }

    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.1' -ControlId 'S-01' `
        -ControlName 'MFA Enforced for All Users' `
        -CurrentValue $currentValue -ExpectedValue 'MFA required for all users via CA or Security Defaults' `
        -Status $status -Severity 'High' `
        -Evidence "CA policies evaluated: $(@($policies).Count); Security Defaults: $secDefaultsEnabled" `
        -Remediation 'Create a Conditional Access policy requiring MFA for all users, or enable Security Defaults.'
}
catch {
    Write-Warning "S-01: Failed to check MFA enforcement: $_"
    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.1' -ControlId 'S-01' `
        -ControlName 'MFA Enforced for All Users' `
        -CurrentValue "Error: $_" -ExpectedValue 'MFA required for all users' `
        -Status 'Error' -Severity 'High'
}

# ------------------------------------------------------------------
# S-02: Sign-in Risk Policy Configured (CC6.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "S-02: Checking for sign-in risk Conditional Access policies..."
    # Reuse $policies from S-01 if available
    if (-not $policies) {
        $caPolicies = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/identity/conditionalAccess/policies' -ErrorAction Stop
        $policies = if ($caPolicies -and $caPolicies['value']) { @($caPolicies['value']) } else { @() }
    }

    $signInRiskPolicies = @()
    foreach ($policy in $policies) {
        if ($policy['state'] -ne 'enabled') { continue }
        $riskLevels = @($policy['conditions']['signInRiskLevels'])
        if ($riskLevels.Count -gt 0) {
            $signInRiskPolicies += $policy['displayName']
        }
    }

    $currentValue = if ($signInRiskPolicies.Count -gt 0) {
        "Configured: $($signInRiskPolicies -join '; ')"
    } else {
        'No sign-in risk policy found'
    }

    $status = if ($signInRiskPolicies.Count -gt 0) { 'Pass' } else { 'Fail' }

    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.1' -ControlId 'S-02' `
        -ControlName 'Sign-in Risk Policy Configured' `
        -CurrentValue $currentValue -ExpectedValue 'At least one CA policy with sign-in risk conditions' `
        -Status $status -Severity 'High' `
        -Evidence "Sign-in risk policies found: $($signInRiskPolicies.Count)" `
        -Remediation 'Create a CA policy with sign-in risk condition (Medium and High) requiring MFA or blocking access.'
}
catch {
    Write-Warning "S-02: Failed to check sign-in risk policies: $_"
    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.1' -ControlId 'S-02' `
        -ControlName 'Sign-in Risk Policy Configured' `
        -CurrentValue "Error: $_" -ExpectedValue 'Sign-in risk CA policy configured' `
        -Status 'Error' -Severity 'High'
}

# ------------------------------------------------------------------
# S-03: User Risk Policy Configured (CC6.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "S-03: Checking for user risk Conditional Access policies..."
    if (-not $policies) {
        $caPolicies = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/identity/conditionalAccess/policies' -ErrorAction Stop
        $policies = if ($caPolicies -and $caPolicies['value']) { @($caPolicies['value']) } else { @() }
    }

    $userRiskPolicies = @()
    foreach ($policy in $policies) {
        if ($policy['state'] -ne 'enabled') { continue }
        $riskLevels = @($policy['conditions']['userRiskLevels'])
        if ($riskLevels.Count -gt 0) {
            $userRiskPolicies += $policy['displayName']
        }
    }

    $currentValue = if ($userRiskPolicies.Count -gt 0) {
        "Configured: $($userRiskPolicies -join '; ')"
    } else {
        'No user risk policy found'
    }

    $status = if ($userRiskPolicies.Count -gt 0) { 'Pass' } else { 'Fail' }

    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.1' -ControlId 'S-03' `
        -ControlName 'User Risk Policy Configured' `
        -CurrentValue $currentValue -ExpectedValue 'At least one CA policy with user risk conditions' `
        -Status $status -Severity 'High' `
        -Evidence "User risk policies found: $($userRiskPolicies.Count)" `
        -Remediation 'Create a CA policy with user risk condition (High) requiring password change.'
}
catch {
    Write-Warning "S-03: Failed to check user risk policies: $_"
    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.1' -ControlId 'S-03' `
        -ControlName 'User Risk Policy Configured' `
        -CurrentValue "Error: $_" -ExpectedValue 'User risk CA policy configured' `
        -Status 'Error' -Severity 'High'
}

# ------------------------------------------------------------------
# S-04: Admin Accounts Use Phishing-Resistant MFA (CC6.2)
# ------------------------------------------------------------------
try {
    Write-Verbose "S-04: Checking admin accounts for phishing-resistant MFA..."

    # Get Global Admin role members
    $globalAdminRole = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/directoryRoles' -ErrorAction Stop
    $gaRole = if ($globalAdminRole -and $globalAdminRole['value']) { $globalAdminRole['value'] | Where-Object { $_['displayName'] -eq 'Global Administrator' } } else { $null }

    $adminUserIds = @()
    if ($gaRole) {
        $members = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/directoryRoles/$($gaRole['id'])/members" -ErrorAction Stop
        $memberList = if ($members -and $members['value']) { @($members['value']) } else { @() }
        $adminUserIds = @($memberList | Where-Object { $_['@odata.type'] -eq '#microsoft.graph.user' } | ForEach-Object { $_['id'] })
    }

    # Check auth method registration for phishing-resistant methods
    $phishingResistantAdmins = 0
    $totalAdmins = $adminUserIds.Count
    foreach ($userId in $adminUserIds) {
        try {
            $regDetails = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/reports/authenticationMethods/userRegistrationDetails?`$filter=id eq '$userId'" -ErrorAction Stop
            $details = if ($regDetails -and $regDetails['value']) { @($regDetails['value']) } else { @() }
            if ($details.Count -gt 0) {
                $methods = @($details[0]['methodsRegistered'])
                if ($methods -contains 'fido2SecurityKey' -or $methods -contains 'windowsHelloForBusiness' -or $methods -contains 'passKeyDeviceBound') {
                    $phishingResistantAdmins++
                }
            }
        }
        catch {
            Write-Verbose "Could not check auth methods for user $userId : $_"
        }
    }

    $currentValue = "$phishingResistantAdmins of $totalAdmins Global Admins use phishing-resistant MFA"
    $status = if ($totalAdmins -eq 0) { 'Review' } elseif ($phishingResistantAdmins -eq $totalAdmins) { 'Pass' } else { 'Fail' }

    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.2' -ControlId 'S-04' `
        -ControlName 'Admin Accounts Use Phishing-Resistant MFA' `
        -CurrentValue $currentValue -ExpectedValue 'All Global Admins registered for FIDO2 or Windows Hello' `
        -Status $status -Severity 'High' `
        -Evidence "Global Admins: $totalAdmins; Phishing-resistant: $phishingResistantAdmins" `
        -Remediation 'Register admin accounts for FIDO2 security keys or Windows Hello for Business.'
}
catch {
    Write-Warning "S-04: Failed to check admin MFA methods: $_"
    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.2' -ControlId 'S-04' `
        -ControlName 'Admin Accounts Use Phishing-Resistant MFA' `
        -CurrentValue "Error: $_" -ExpectedValue 'Phishing-resistant MFA for admins' `
        -Status 'Error' -Severity 'High'
}

# ------------------------------------------------------------------
# S-05: Least Privilege Admin Roles (CC6.3)
# ------------------------------------------------------------------
try {
    Write-Verbose "S-05: Checking Global Administrator count..."

    if (-not $gaRole) {
        $globalAdminRole = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/directoryRoles' -ErrorAction Stop
        $gaRole = if ($globalAdminRole -and $globalAdminRole['value']) { $globalAdminRole['value'] | Where-Object { $_['displayName'] -eq 'Global Administrator' } } else { $null }
    }

    $gaCount = 0
    if ($gaRole) {
        $members = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/directoryRoles/$($gaRole['id'])/members" -ErrorAction Stop
        $memberList = if ($members -and $members['value']) { @($members['value']) } else { @() }
        $gaCount = @($memberList | Where-Object { $_['@odata.type'] -eq '#microsoft.graph.user' }).Count
    }

    $currentValue = "$gaCount Global Administrators"
    $status = if ($gaCount -ge 2 -and $gaCount -le 4) { 'Pass' } elseif ($gaCount -lt 2) { 'Fail' } else { 'Fail' }

    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.3' -ControlId 'S-05' `
        -ControlName 'Least Privilege Admin Roles' `
        -CurrentValue $currentValue -ExpectedValue 'Between 2 and 4 Global Administrators' `
        -Status $status -Severity 'High' `
        -Evidence "Global Admin count: $gaCount" `
        -Remediation 'Reduce Global Admin assignments to 2-4 accounts. Use scoped admin roles for day-to-day tasks.'
}
catch {
    Write-Warning "S-05: Failed to check admin role assignments: $_"
    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC6.3' -ControlId 'S-05' `
        -ControlName 'Least Privilege Admin Roles' `
        -CurrentValue "Error: $_" -ExpectedValue '2-4 Global Admins' `
        -Status 'Error' -Severity 'High'
}

# ------------------------------------------------------------------
# S-06: Unified Audit Log Enabled (CC7.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "S-06: Checking Unified Audit Log status..."
    # This requires EXO connection — attempt via Graph audit log query as fallback
    $ualEnabled = $null

    # Try EXO cmdlet first
    try {
        $null = Get-Command -Name Get-AdminAuditLogConfig -ErrorAction Stop
        $auditConfig = Get-AdminAuditLogConfig -ErrorAction Stop
        $ualEnabled = $auditConfig.UnifiedAuditLogIngestionEnabled
    }
    catch {
        Write-Verbose "EXO cmdlet not available, attempting Graph-based audit log check..."
        # If we can query audit logs via Graph, UAL is likely enabled
        try {
            $testAudit = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/auditLogs/directoryAudits?$top=1' -ErrorAction Stop
            if ($null -ne $testAudit['value']) {
                $ualEnabled = $true
            }
        }
        catch {
            Write-Verbose "Could not verify UAL via Graph: $_"
        }
    }

    $currentValue = if ($null -eq $ualEnabled) {
        'Unable to determine (requires EXO connection or AuditLog.Read.All scope)'
    } elseif ($ualEnabled) {
        'Enabled'
    } else {
        'Disabled'
    }

    $status = if ($null -eq $ualEnabled) { 'Review' } elseif ($ualEnabled) { 'Pass' } else { 'Fail' }

    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC7.1' -ControlId 'S-06' `
        -ControlName 'Unified Audit Log Enabled' `
        -CurrentValue $currentValue -ExpectedValue 'Enabled' `
        -Status $status -Severity 'Critical' `
        -Evidence "UAL ingestion enabled: $ualEnabled" `
        -Remediation 'Enable audit logging: Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true'
}
catch {
    Write-Warning "S-06: Failed to check audit log status: $_"
    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC7.1' -ControlId 'S-06' `
        -ControlName 'Unified Audit Log Enabled' `
        -CurrentValue "Error: $_" -ExpectedValue 'UAL enabled' `
        -Status 'Error' -Severity 'Critical'
}

# ------------------------------------------------------------------
# S-07: Defender Alert Policies Active (CC7.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "S-07: Checking Defender alert policies..."
    $alerts = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/security/alerts_v2?$top=10' -ErrorAction Stop
    $alertList = if ($alerts -and $alerts['value']) { @($alerts['value']) } else { @() }

    $currentValue = if ($alertList.Count -gt 0) {
        "$($alertList.Count)+ alerts found (threat detection active)"
    } else {
        'No alerts found (may indicate no threat detection or clean environment)'
    }

    # Having alerts available means the system is monitoring — even 0 alerts can be fine
    $status = 'Pass'

    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC7.1' -ControlId 'S-07' `
        -ControlName 'Defender Alert Policies Active' `
        -CurrentValue $currentValue -ExpectedValue 'Defender alerts accessible and monitoring active' `
        -Status $status -Severity 'High' `
        -Evidence "Alert API accessible; alerts returned: $($alertList.Count)" `
        -Remediation 'Review alert policies in Microsoft Defender portal > Policies & rules > Alert policy.'
}
catch {
    Write-Warning "S-07: Failed to check Defender alerts: $_"
    $errorMsg = "$_"
    # Distinguish between permission issues and actual failures
    $status = if ($errorMsg -match 'Forbidden|403|Authorization') { 'Review' } else { 'Error' }
    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC7.1' -ControlId 'S-07' `
        -ControlName 'Defender Alert Policies Active' `
        -CurrentValue "Error: $errorMsg" -ExpectedValue 'Defender alerts active' `
        -Status $status -Severity 'High' `
        -Remediation 'Ensure SecurityEvents.Read.All or SecurityAlert.Read.All scope is granted.'
}

# ------------------------------------------------------------------
# S-08: Alerts Are Triaged and Responded To (CC7.2)
# ------------------------------------------------------------------
try {
    Write-Verbose "S-08: Checking alert triage activity..."
    $resolvedAlerts = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/security/alerts_v2?`$filter=status ne 'new'&`$top=10" -ErrorAction Stop
    $null = $resolvedAlerts  # Response used only to confirm API access; counts derived from allAlerts below

    $allAlerts = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/security/alerts_v2?$top=50' -ErrorAction Stop
    $allList = if ($allAlerts -and $allAlerts['value']) { @($allAlerts['value']) } else { @() }
    $newCount = @($allList | Where-Object { $_['status'] -eq 'new' }).Count
    $triagedCount = $allList.Count - $newCount

    $currentValue = if ($allList.Count -eq 0) {
        'No alerts to triage (clean environment)'
    } elseif ($triagedCount -gt 0) {
        "$triagedCount of $($allList.Count) alerts triaged (resolved/inProgress)"
    } else {
        "0 of $($allList.Count) alerts triaged — all alerts in 'new' status"
    }

    $status = if ($allList.Count -eq 0) { 'Pass' } elseif ($triagedCount -gt 0) { 'Pass' } else { 'Fail' }

    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC7.2' -ControlId 'S-08' `
        -ControlName 'Alerts Are Triaged and Responded To' `
        -CurrentValue $currentValue -ExpectedValue 'Evidence of alert triage activity' `
        -Status $status -Severity 'Medium' `
        -Evidence "Total alerts sampled: $($allList.Count); Triaged: $triagedCount; New: $newCount" `
        -Remediation 'Regularly review and triage security alerts in Microsoft Defender portal.'
}
catch {
    Write-Warning "S-08: Failed to check alert triage: $_"
    Add-ControlResult -TrustPrinciple 'Security' -TSCReference 'CC7.2' -ControlId 'S-08' `
        -ControlName 'Alerts Are Triaged and Responded To' `
        -CurrentValue "Error: $_" -ExpectedValue 'Alert triage evidence' `
        -Status 'Error' -Severity 'Medium'
}

# ------------------------------------------------------------------
# Output results
# ------------------------------------------------------------------
if ($results.Count -eq 0) {
    Write-Warning "No SOC 2 Security control results were generated."
    return
}

Write-Verbose "SOC 2 Security controls assessed: $($results.Count)"

if ($OutputPath) {
    $results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Output "Exported $($results.Count) SOC 2 Security controls to $OutputPath"
}
else {
    Write-Output $results
}