All insights
ComplianceSOC 2 Type II

SOC 2 Type II Evidence: Why Screenshot-Based Compliance Fails and What Auditors Actually Require

SOC 2 Type II demands evidence that controls operated continuously throughout the audit period - not point-in-time screenshots that prove nothing about the intervening weeks. This guide replaces manual evidence gathering with automated Graph API collection mapped to Trust Services Criteria, giving management and audit committees continuous compliance proof that auditors accept without qualification.

INSIGHTS OF 2026
5 min read
Practitioner Insight

SOC 2 Evidence Automation: Using Graph API for Continuous Proof

Every SOC 2 Type II audit has the same bottleneck: evidence gathering. The audit period is typically 6-12 months, and auditors want proof that controls operated continuously - not just at a single point in time. If your evidence collection strategy involves someone taking screenshots of the Entra admin portal once a quarter, there is a better way. This guide shows how to automate the entire evidence pipeline using Microsoft Graph API and PowerShell.

The Problem with Screenshot-Based Evidence

Here is what most organisations preparing for SOC 2 do:

  1. An analyst opens the Entra ID portal, navigates to Conditional Access, takes a screenshot
  2. They paste it into a Word document with a timestamp
  3. They repeat this across 30-40 controls, every quarter
  4. The auditor receives a pile of Word documents and spends days reconciling them
  5. Something changed between screenshots and nobody noticed

This approach fails SOC 2 Type II because Type II requires evidence of consistent operation over time. A screenshot from March doesn't prove the control was active in April. You need continuous proof.

Graph API Endpoints for SOC 2 Evidence

Microsoft Graph provides programmatic access to virtually every M365 configuration relevant to SOC 2. Here are the most frequently used endpoints, mapped to Trust Services Criteria:

CC6 - Logical and Physical Access Controls:

# CC6.1: Conditional Access Policies (Access Control)
Connect-MgGraph -Scopes "Policy.Read.All"
$caPolicies = Get-MgIdentityConditionalAccessPolicy
$caPolicies | Select-Object DisplayName, State, CreatedDateTime, ModifiedDateTime,
    @{N="Conditions";E={$_.Conditions | ConvertTo-Json -Compress -Depth 5}},
    @{N="GrantControls";E={$_.GrantControls | ConvertTo-Json -Compress -Depth 3}} |
    Export-Csv -Path "./evidence/CC6.1-conditional-access-$(Get-Date -Format yyyy-MM-dd).csv" -NoTypeInformation

# CC6.2: Device Compliance Policies (Endpoint Security)
$compliancePolicies = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/deviceCompliancePolicies"
$compliancePolicies.value | ConvertTo-Json -Depth 10 | Out-File "./evidence/CC6.2-device-compliance-$(Get-Date -Format yyyy-MM-dd).json"

# CC6.3: Role Assignments (Privileged Access)
$roleAssignments = Get-MgRoleManagementDirectoryRoleAssignment -ExpandProperty "principal,roleDefinition"
$roleAssignments | Select-Object @{N="Role";E={$_.RoleDefinition.DisplayName}},
    @{N="Principal";E={$_.Principal.AdditionalProperties.userPrincipalName}},
    @{N="AssignmentType";E={$_.ScheduleInfo.Type}} |
    Export-Csv -Path "./evidence/CC6.3-role-assignments-$(Get-Date -Format yyyy-MM-dd).csv" -NoTypeInformation

CC7 - System Operations:

# CC7.2: Security Alerts and Incidents (Monitoring)
$alerts = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/security/alerts_v2?$top=500&$orderby=createdDateTime desc"
$alerts.value | Select-Object title, severity, status, createdDateTime, lastUpdateDateTime,
    @{N="DetectionSource";E={$_.detectionSource}} |
    Export-Csv -Path "./evidence/CC7.2-security-alerts-$(Get-Date -Format yyyy-MM-dd).csv" -NoTypeInformation

# CC7.3: Change Management (Audit Logs)
$auditLogs = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?$filter=activityDateTime ge $($(Get-Date).AddDays(-30).ToString('yyyy-MM-dd'))&$top=999"
$auditLogs.value | Where-Object { $_.category -in @("Policy", "RoleManagement", "ApplicationManagement", "DeviceManagement") } |
    Select-Object activityDisplayName, activityDateTime, result,
    @{N="Actor";E={$_.initiatedBy.user.userPrincipalName}},
    @{N="Target";E={$_.targetResources[0].displayName}} |
    Export-Csv -Path "./evidence/CC7.3-audit-logs-$(Get-Date -Format yyyy-MM-dd).csv" -NoTypeInformation

CC8 - Change Management:

# CC8.1: Configuration Change Tracking
# Pull all policy changes from the directory audit log
$policyChanges = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?$filter=category eq 'Policy'&$top=500"
$policyChanges.value | ForEach-Object {
    [PSCustomObject]@{
        Activity   = $_.activityDisplayName
        DateTime   = $_.activityDateTime
        Actor      = $_.initiatedBy.user.userPrincipalName
        Result     = $_.result
        TargetName = $_.targetResources[0].displayName
        TargetType = $_.targetResources[0].type
        OldValues  = ($_.targetResources[0].modifiedProperties | Where-Object { $_.oldValue } | ConvertTo-Json -Compress)
        NewValues  = ($_.targetResources[0].modifiedProperties | Where-Object { $_.newValue } | ConvertTo-Json -Compress)
    }
} | Export-Csv -Path "./evidence/CC8.1-policy-changes-$(Get-Date -Format yyyy-MM-dd).csv" -NoTypeInformation

Building the Automated Evidence Pack

The full evidence collection runs as an Azure Automation runbook on a weekly schedule. Here is the orchestration script:

# SOC 2 Evidence Collection Runbook
# Runs weekly via Azure Automation
# Stores evidence in a dedicated Azure Blob container with immutability

param(
    [string]$StorageAccountName = "soc2evidence",
    [string]$ContainerName = "evidence-$(Get-Date -Format yyyy-MM)"
)

# Authenticate with managed identity
Connect-MgGraph -Identity
Connect-AzAccount -Identity

# Create monthly container if it doesn't exist
$ctx = New-AzStorageContext -StorageAccountName $StorageAccountName
New-AzStorageContainer -Name $ContainerName -Context $ctx -ErrorAction SilentlyContinue

$timestamp = Get-Date -Format "yyyy-MM-dd"
$evidenceFiles = @()

# --- CC6: Access Controls ---
$caPolicies = Get-MgIdentityConditionalAccessPolicy | ConvertTo-Json -Depth 10
$caFile = "CC6.1-conditional-access-$timestamp.json"
$caPolicies | Out-File "./$caFile"
$evidenceFiles += $caFile

$mfaConfig = Get-MgPolicyAuthenticationMethodPolicy | ConvertTo-Json -Depth 10
$mfaFile = "CC6.1-auth-methods-$timestamp.json"
$mfaConfig | Out-File "./$mfaFile"
$evidenceFiles += $mfaFile

$compPolicies = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/deviceCompliancePolicies"
$compFile = "CC6.2-device-compliance-$timestamp.json"
$compPolicies | ConvertTo-Json -Depth 10 | Out-File "./$compFile"
$evidenceFiles += $compFile

$roles = Get-MgRoleManagementDirectoryRoleAssignment -All | ConvertTo-Json -Depth 5
$roleFile = "CC6.3-role-assignments-$timestamp.json"
$roles | Out-File "./$roleFile"
$evidenceFiles += $roleFile

# --- CC7: System Operations ---
$alerts = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/security/alerts_v2?$top=1000"
$alertFile = "CC7.2-security-alerts-$timestamp.json"
$alerts | ConvertTo-Json -Depth 10 | Out-File "./$alertFile"
$evidenceFiles += $alertFile

$signIns = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/signIns?$top=1000&$orderby=createdDateTime desc"
$signInFile = "CC7.2-signin-logs-$timestamp.json"
$signIns | ConvertTo-Json -Depth 10 | Out-File "./$signInFile"
$evidenceFiles += $signInFile

# --- CC8: Change Management ---
$audits = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?$top=999&$filter=activityDateTime ge $((Get-Date).AddDays(-7).ToString('yyyy-MM-ddTHH:mm:ssZ'))"
$auditFile = "CC8.1-directory-audits-$timestamp.json"
$audits | ConvertTo-Json -Depth 10 | Out-File "./$auditFile"
$evidenceFiles += $auditFile

# Upload all evidence files to immutable blob storage
foreach ($file in $evidenceFiles) {
    Set-AzStorageBlobContent -File "./$file" -Container $ContainerName -Context $ctx -Blob $file -Force
    Write-Output "Uploaded: $file"
}

# Generate manifest
$manifest = $evidenceFiles | ForEach-Object {
    [PSCustomObject]@{
        FileName      = $_
        CollectedDate = $timestamp
        SHA256        = (Get-FileHash -Path "./$_" -Algorithm SHA256).Hash
    }
}
$manifest | ConvertTo-Json | Out-File "./manifest-$timestamp.json"
Set-AzStorageBlobContent -File "./manifest-$timestamp.json" -Container $ContainerName -Context $ctx -Blob "manifest-$timestamp.json" -Force

Write-Output "SOC 2 evidence collection complete. $($evidenceFiles.Count) files uploaded."

Trust Services Criteria Mapping

Here is the complete mapping for M365-sourced evidence:

CC6 - Logical and Physical Access Controls: | Criteria | M365 Control | Graph Endpoint | Evidence Type | |---|---|---|---| | CC6.1 | Conditional Access | /identity/conditionalAccess/policies | Policy config + sign-in logs | | CC6.2 | Intune Compliance | /deviceManagement/deviceCompliancePolicies | Policy config + device status | | CC6.3 | PIM Role Assignments | /roleManagement/directory/roleAssignments | Active + eligible assignments | | CC6.6 | Named Locations | /identity/conditionalAccess/namedLocations | Trusted IP ranges | | CC6.8 | Access Reviews | /identityGovernance/accessReviews | Review decisions + history |

CC7 - System Operations: | Criteria | M365 Control | Graph Endpoint | Evidence Type | |---|---|---|---| | CC7.2 | Defender XDR Alerts | /security/alerts_v2 | Alert inventory + severity | | CC7.3 | Directory Audit Log | /auditLogs/directoryAudits | Policy and config changes | | CC7.4 | Incident Response | /security/incidents | Incident metrics + resolution |

CC8 - Change Management: | Criteria | M365 Control | Graph Endpoint | Evidence Type | |---|---|---|---| | CC8.1 | Audit Logs | /auditLogs/directoryAudits (filtered) | Before/after values |

Continuous Monitoring vs Point-in-Time Testing

SOC 2 Type II auditors evaluate whether controls operated effectively throughout the audit period. Continuous monitoring provides dramatically stronger evidence than periodic testing:

Point-in-time (weak): "On 15 March 2026, MFA was enabled for all users." - Proves nothing about 16 March.

Continuous (strong): "Automated weekly collection shows MFA policy was active every week from January to December 2026, with zero configuration changes except a documented enhancement on 22 April (ticket #CHG-4421)."

Continuous monitoring should use two layers:

  1. Weekly evidence collection (the runbook above) - provides the audit trail
  2. Real-time drift detection - Sentinel analytics rules that fire when critical configurations change:
// Detect Conditional Access policy modification or deletion
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName in ("Update conditional access policy", "Delete conditional access policy")
| extend Actor = tostring(InitiatedBy.user.userPrincipalName)
| extend PolicyName = tostring(TargetResources[0].displayName)
| project TimeGenerated, OperationName, Actor, PolicyName

When this fires, a Logic App creates an incident in your ITSM tool and notifies the compliance team. If the change was unauthorised, you have an immediate record for the auditor.

Integrating with Auditor Portals

Most audit firms now use evidence portals (Vanta, Drata, AuditBoard, Laika). Instead of uploading manually, evidence can be pushed via their APIs:

# Example: Push evidence to audit portal API
$evidencePayload = @{
    control_id  = "CC6.1"
    evidence_type = "automated"
    title       = "Conditional Access Policy Configuration - $timestamp"
    file_url    = "https://$StorageAccountName.blob.core.windows.net/$ContainerName/CC6.1-conditional-access-$timestamp.json"
    collected_at = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
    hash        = (Get-FileHash -Path "./CC6.1-conditional-access-$timestamp.json" -Algorithm SHA256).Hash
}

Invoke-RestMethod -Uri "https://api.auditportal.example/v1/evidence" `
    -Method POST `
    -Headers @{ "Authorization" = "Bearer $apiToken" } `
    -Body ($evidencePayload | ConvertTo-Json) `
    -ContentType "application/json"

What Auditors Actually Want

Based on experience across multiple SOC 2 cycles, here is what auditors value most:

  1. Consistency: Evidence collected the same way, at the same cadence, with the same format. The runbook approach guarantees this.

  2. Immutability: Evidence stored in immutable storage with SHA-256 hashes. This proves the evidence wasn't modified after collection.

  3. Traceability: A manifest file linking each evidence artefact to its Trust Services Criteria, collection date, and hash.

  4. Exceptions documented: When a control deviated (someone disabled a CA policy temporarily), auditors want to see that it was detected, documented, justified, and remediated. The drift detection query above provides this automatically.

  5. Coverage completeness: Evidence for every week of the audit period, not just the weeks when someone remembered to collect it.

Stop taking screenshots. Automate evidence collection from day one of your audit period, and the SOC 2 Type II engagement becomes a formality rather than a fire drill.