Public/Test-IntuneDeployedScript.ps1
|
function Test-IntuneDeployedScript { <# .EXTERNALHELP IntuneScriptLab-Help.xml .SYNOPSIS Analyzes the scripts a tenant has deployed, with the settings each policy actually carries. #> [CmdletBinding()] [OutputType('IntuneScriptLab.DeploymentFinding')] param( [ValidateSet('Remediation', 'PlatformScript', 'Win32App')] [string[]]$Kind = @('Remediation', 'PlatformScript', 'Win32App'), [SupportsWildcards()] [string[]]$Name = @('*'), [string[]]$Id, [string[]]$IncludeRule, [string[]]$ExcludeRule, [ValidateSet('Information', 'Warning', 'Error')] [string]$MinimumSeverity = 'Information', [switch]$SkipGroupLookup, # A settings file path or hashtable for the analysis (the tenant's scripts have no folder # of their own to carry one) $Settings ) Write-Verbose "Starting $($MyInvocation.MyCommand.Name) for $($PSBoundParameters.Keys -join ', ')" # Captured here: inside the nested functions $PSBoundParameters is their own, not this command's $nameGiven = $PSBoundParameters.ContainsKey('Name') $settingsGiven = $PSBoundParameters.ContainsKey('Settings') $severityRank = @{ Information = 0; Warning = 1; Error = 2 } $workName = "IntuneScriptLab\preflight-$([guid]::NewGuid().ToString('N'))" $workFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $workName $null = New-Item -ItemType Directory -Path $workFolder -Force # Mutable state the nested functions share (an assignment inside them would make a local copy) $groupState = @{ Cache = @{}; Blocked = $false } $filterState = @{ Cache = @{}; Blocked = $false } $filterEvidence = 'validateFilter accepted (device.cpuArchitecture -eq "x64") and (device.deviceTrustType ' + '-eq "Microsoft Entra joined") and the filter evaluator matched no device with either: Windows ' + 'reports amd64 and "Azure AD joined" (FLT-V25, FLT-E07, FLT-V27, FLT-F01)' function Test-Selected { param([string]$Rule) if ($IncludeRule -and -not ($IncludeRule | Where-Object { $Rule -like $_ })) { return $false } if ($ExcludeRule -and ($ExcludeRule | Where-Object { $Rule -like $_ })) { return $false } $true } function Test-Wanted { param($Policy) $displayName = "$($Policy.displayName)" # -Id alone selects by id: -Name's default of '*' only counts when -Name was given or -Id was not $byName = ($nameGiven -or -not $Id) -and @($Name | Where-Object { $displayName -like $_ }).Count -gt 0 $byId = $Id -and "$($Policy.id)" -in $Id $byName -or $byId } function ConvertTo-DeploymentFinding { param([string]$PolicyKind, $Policy, [string]$Role, $Finding) [pscustomobject]@{ PSTypeName = 'IntuneScriptLab.DeploymentFinding' Kind = $PolicyKind PolicyName = "$($Policy.displayName)" PolicyId = "$($Policy.id)" Role = $Role RuleName = $Finding.RuleName Severity = $Finding.Severity Message = $Finding.Message ScriptType = $Finding.ScriptType Line = $Finding.Line Column = $Finding.Column Text = $Finding.Text Evidence = $Finding.Evidence } } function ConvertTo-PolicyFinding { param([string]$PolicyKind, $Policy, [string]$Rule, [string]$Severity, [string]$Message, [string]$Evidence) if (-not (Test-Selected -Rule $Rule) -or $severityRank[$Severity] -lt $severityRank[$MinimumSeverity]) { return } $finding = [pscustomobject]@{ RuleName = $Rule; Severity = $Severity; Message = $Message; ScriptType = '' Line = 0; Column = 0; Text = ''; Evidence = $Evidence } ConvertTo-DeploymentFinding -PolicyKind $PolicyKind -Policy $Policy -Role 'policy' -Finding $finding } # Runs the script rules on one base64 script with the policy's own settings function Test-PolicyScript { param( [string]$PolicyKind, $Policy, [string]$Role, [string]$Content, [string]$ScriptType, $RunAsAccount, $RunAs32Bit, $EnforceSignatureCheck ) if (-not $Content) { return } $safeName = [regex]::Replace("$($Policy.displayName)", '[^\w.-]', '_') $folder = Join-Path -Path $workFolder -ChildPath "$PolicyKind\$safeName-$($Policy.id)" $null = New-Item -ItemType Directory -Path $folder -Force $file = Join-Path -Path $folder -ChildPath "$Role.ps1" # Byte for byte: the encoding rule needs to see the BOM, or its absence, as the tenant stores it [System.IO.File]::WriteAllBytes($file, [System.Convert]::FromBase64String($Content)) $context = if ("$RunAsAccount" -eq 'user') { 'User' } else { 'System' } $architecture = if ([bool]$RunAs32Bit) { 'x86' } else { 'x64' } $testSplat = @{ Path = $file ScriptType = $ScriptType Context = $context Architecture = $architecture EnforceSignatureCheck = [bool]$EnforceSignatureCheck MinimumSeverity = $MinimumSeverity } if ($settingsGiven) { $testSplat.Settings = $Settings } if ($IncludeRule) { $testSplat.IncludeRule = $IncludeRule } if ($ExcludeRule) { $testSplat.ExcludeRule = $ExcludeRule } Write-Verbose "$PolicyKind '$($Policy.displayName)' $Role as $ScriptType, $context, $architecture" foreach ($finding in (Test-IntuneScript @testSplat)) { ConvertTo-DeploymentFinding -PolicyKind $PolicyKind -Policy $Policy -Role $Role -Finding $finding } } function Test-DeviceGroup { param([string]$GroupId) if ($groupState.Cache.ContainsKey($GroupId)) { return $groupState.Cache[$GroupId] } $isDeviceGroup = $false if (-not $SkipGroupLookup -and -not $groupState.Blocked) { try { $members = Invoke-IslGraphRequest -Uri "/v1.0/groups/$GroupId/members?`$select=id&`$top=20" $types = @($members.value | ForEach-Object { "$($_.'@odata.type')" }) $others = @($types | Where-Object { $_ -ne '#microsoft.graph.device' }) $isDeviceGroup = $types.Count -gt 0 -and $others.Count -eq 0 } catch { $groupState.Blocked = $true Write-Warning ("Group members could not be read ($($_.Exception.Message)); the assignment " + 'check is skipped. GroupMember.Read.All allows it, -SkipGroupLookup silences this') } } $groupState.Cache[$GroupId] = $isDeviceGroup $isDeviceGroup } function Get-AssignmentFilter { param([string]$FilterId) if ($filterState.Cache.ContainsKey($FilterId)) { return $filterState.Cache[$FilterId] } $filter = $null if (-not $filterState.Blocked) { try { $filter = Invoke-IslGraphRequest -Uri "/beta/deviceManagement/assignmentFilters/$FilterId" } catch { $filterState.Blocked = $true Write-Warning ("Assignment filters could not be read ($($_.Exception.Message)); the filter " + 'check is skipped. DeviceManagementConfiguration.Read.All allows it') } } $filterState.Cache[$FilterId] = $filter $filter } # The filters on a policy's assignments: a rule the parser refuses is noted, a clause no Windows # device can match is a warning (the assignment silently reaches nobody, or everybody) function Test-AssignmentFilter { param([string]$PolicyKind, $Policy) foreach ($assignment in @($Policy.assignments)) { $target = $assignment.target $filterId = "$($target.deviceAndAppManagementAssignmentFilterId)" $filterType = "$($target.deviceAndAppManagementAssignmentFilterType)" if (-not $filterId -or $filterType -eq 'none') { continue } $filter = Get-AssignmentFilter -FilterId $filterId if (-not $filter) { continue } $label = "Filter '$($filter.displayName)' ($filterType)" $parsed = ConvertFrom-IslFilterRule -Rule "$($filter.rule)" if ($parsed.Error) { $unreadSplat = @{ PolicyKind = $PolicyKind; Policy = $Policy; Rule = 'IslFilterIssue'; Severity = 'Information' Message = "$label uses syntax this evaluator does not read ($($parsed.Error)): " + "$($filter.rule)" Evidence = $filterEvidence } ConvertTo-PolicyFinding @unreadSplat continue } foreach ($warning in $parsed.Warnings) { $severity = if ($warning.Kind -eq 'NeverMatches') { 'Warning' } else { 'Information' } $issueSplat = @{ PolicyKind = $PolicyKind; Policy = $Policy; Rule = 'IslFilterIssue'; Severity = $severity Message = "${label}: $($warning.Message). Rule: $($filter.rule)" Evidence = $filterEvidence } ConvertTo-PolicyFinding @issueSplat } } } # The assignments themselves (round 9, Findings "Assignment sanity"): none or exclusions only # mean the policy is never resolved by any device; a run-once schedule whose time has passed # runs once at the fetch on a device that has not run it; a user-context script assigned to a # device group is skipped on Entra registered devices function Test-Assignment { param([string]$PolicyKind, $Policy, $RunAsAccount) $assignments = @($Policy.assignments | Where-Object { $_ }) $includes = @($assignments | Where-Object { "$($_.target.'@odata.type')" -ne '#microsoft.graph.exclusionGroupAssignmentTarget' }) if ($includes.Count -eq 0) { $what = if ($assignments.Count -eq 0) { 'No assignment' } else { 'Only exclusion assignments' } $noneSplat = @{ PolicyKind = $PolicyKind; Policy = $Policy; Rule = 'IslAssignmentIssue'; Severity = 'Warning' Message = "${what}: no device resolves the policy, so it never runs anywhere" Evidence = 'A remediation with no assignment and one with only an exclusion were absent ' + 'from every device''s resolved policies after an agent restart, while the same scripts ' + 'with an include assignment ran (ASSIGN-NONE, ASSIGN-EXCLONLY, ASSIGN-INEX)' } ConvertTo-PolicyFinding @noneSplat } foreach ($assignment in $includes) { $schedule = $assignment.runSchedule if ("$($schedule.'@odata.type')" -ne '#microsoft.graph.deviceHealthScriptRunOnceSchedule') { continue } $stamp = "$($schedule.date) $($schedule.time)" $at = [datetime]::MinValue if (-not [datetime]::TryParse($stamp, [cultureinfo]::InvariantCulture, 'None', [ref]$at)) { continue } $now = if ([bool]$schedule.useUtc) { [datetime]::UtcNow } else { [datetime]::Now } if ($at -ge $now) { continue } $zone = if ([bool]$schedule.useUtc) { 'UTC' } else { 'device local time' } $when = $at.ToString('yyyy-MM-dd HH:mm:ss') $pastSplat = @{ PolicyKind = $PolicyKind; Policy = $Policy; Rule = 'IslScheduleIssue'; Severity = 'Information' Message = "Run-once schedule at $when ($zone) has passed: a device that already ran it " + 'will not again, and one that fetches the policy now runs it once at the fetch' Evidence = 'A run-once remediation dated two hours earlier ran once about six minutes ' + 'after a device fetched it, then never again; a device whose clock had not reached ' + 'the time waited for it (ASSIGN-PAST2, ASSIGN-PAST, REM-RUNONCE)' } ConvertTo-PolicyFinding @pastSplat } if ("$RunAsAccount" -ne 'user') { return } $deviceGroups = foreach ($assignment in $includes) { $target = $assignment.target $type = "$($target.'@odata.type')" if ($type -eq '#microsoft.graph.allDevicesAssignmentTarget') { 'all devices'; continue } if ($type -ne '#microsoft.graph.groupAssignmentTarget') { continue } if (Test-DeviceGroup -GroupId "$($target.groupId)") { "$($target.groupId)" } } if ($deviceGroups) { $userSplat = @{ PolicyKind = $PolicyKind; Policy = $Policy; Rule = 'IslAssignmentIssue'; Severity = 'Information' Message = "User context, assigned to devices ($($deviceGroups -join ', ')): runs as the " + 'signed-in user on Entra joined and hybrid joined devices only; an Entra registered ' + 'device downloads the policy and skips it' Evidence = 'On the Entra registered device the agent logged "This is not AADJ/HAADJ device, ' + 'skip user context" for every user-context remediation and platform script and never ran ' + 'them; the joined device ran them as the signed-in user (rounds 1-3, "Join type")' } ConvertTo-PolicyFinding @userSplat } } function Get-WantedPolicy { param([string]$Uri) @(Invoke-IslGraphRequest -Uri $Uri -All | Where-Object { Test-Wanted -Policy $_ }) } try { if ('Remediation' -in $Kind) { $remediations = '/beta/deviceManagement/deviceHealthScripts' foreach ($summary in (Get-WantedPolicy -Uri "$remediations`?`$select=id,displayName")) { $policy = Invoke-IslGraphRequest -Uri "$remediations/$($summary.id)?`$expand=assignments" $policySettings = @{ RunAsAccount = $policy.runAsAccount; RunAs32Bit = $policy.runAs32Bit EnforceSignatureCheck = $policy.enforceSignatureCheck } $detectSplat = @{ PolicyKind = 'Remediation'; Policy = $policy; Role = 'detection'; ScriptType = 'Detection' Content = $policy.detectionScriptContent } Test-PolicyScript @detectSplat @policySettings if ($policy.remediationScriptContent) { $remediateSplat = @{ PolicyKind = 'Remediation'; Policy = $policy; Role = 'remediation' ScriptType = 'Remediation'; Content = $policy.remediationScriptContent } Test-PolicyScript @remediateSplat @policySettings } else { $noteSplat = @{ PolicyKind = 'Remediation'; Policy = $policy; Rule = 'IslDetectOnly' Severity = 'Information' Message = 'No remediation script: the detection runs alone on its schedule and the ' + 'portal shows the issue without fixing it' Evidence = 'A remediation created without remediationScriptContent ran its detection ' + 'alone every cycle; Graph reports remediationState skipped (REM-DETECTONLY)' } ConvertTo-PolicyFinding @noteSplat } Test-Assignment -PolicyKind 'Remediation' -Policy $policy -RunAsAccount $policy.runAsAccount Test-AssignmentFilter -PolicyKind 'Remediation' -Policy $policy } } if ('PlatformScript' -in $Kind) { $scripts = '/beta/deviceManagement/deviceManagementScripts' foreach ($summary in (Get-WantedPolicy -Uri "$scripts`?`$select=id,displayName")) { $policy = Invoke-IslGraphRequest -Uri "$scripts/$($summary.id)?`$expand=assignments" $scriptSplat = @{ PolicyKind = 'PlatformScript'; Policy = $policy; Role = 'script'; ScriptType = 'PlatformScript' Content = $policy.scriptContent; RunAsAccount = $policy.runAsAccount RunAs32Bit = $policy.runAs32Bit; EnforceSignatureCheck = $policy.enforceSignatureCheck } Test-PolicyScript @scriptSplat Test-Assignment -PolicyKind 'PlatformScript' -Policy $policy -RunAsAccount $policy.runAsAccount Test-AssignmentFilter -PolicyKind 'PlatformScript' -Policy $policy } } if ('Win32App' -in $Kind) { $apps = '/beta/deviceAppManagement/mobileApps' $listUri = "$apps`?`$filter=isof('microsoft.graph.win32LobApp')&`$select=id,displayName" foreach ($summary in (Get-WantedPolicy -Uri $listUri)) { $app = Invoke-IslGraphRequest -Uri "$apps/$($summary.id)?`$expand=assignments" $ruleIndex = 0 foreach ($rule in @($app.detectionRules)) { $ruleIndex++ $ruleType = "$($rule.'@odata.type')" if ($ruleType -eq '#microsoft.graph.win32LobAppPowerShellScriptDetection') { $detectSplat = @{ PolicyKind = 'Win32App'; Policy = $app; Role = 'detection' ScriptType = 'Win32Detection'; Content = $rule.scriptContent RunAsAccount = 'system'; RunAs32Bit = $rule.runAs32Bit EnforceSignatureCheck = $rule.enforceSignatureCheck } Test-PolicyScript @detectSplat } elseif ($ruleType -eq '#microsoft.graph.win32LobAppFileSystemDetection' -and "$($rule.detectionType)" -eq 'doesNotExist') { $ruleSplat = @{ PolicyKind = 'Win32App'; Policy = $app; Rule = 'IslDetectionRuleIssue' Severity = 'Error' Message = "Detection rule $ruleIndex is a file rule with detectionType " + "doesNotExist ($($rule.path)\$($rule.fileOrFolderName)): the agent does " + 'not evaluate it. Use a registry doesNotExist rule or a script' Evidence = 'File doesNotExist: on a missing file the app was not detected ' + 'and the install ran; on a present file the detection ended "Invalid ' + 'detection rule or unable to parse detection rule" 0x87D30004 ' + '(W32-FILE-NOTEXIST, W32-FILE-NOTEXIST-PRESENT)' } ConvertTo-PolicyFinding @ruleSplat } } foreach ($rule in @($app.requirementRules)) { if ("$($rule.'@odata.type')" -ne '#microsoft.graph.win32LobAppPowerShellScriptRequirement') { continue } $requirementSplat = @{ PolicyKind = 'Win32App'; Policy = $app; Role = 'requirement' ScriptType = 'Win32Requirement'; Content = $rule.scriptContent RunAsAccount = $rule.runAsAccount; RunAs32Bit = $rule.runAs32Bit EnforceSignatureCheck = $rule.enforceSignatureCheck } Test-PolicyScript @requirementSplat } if ("$($app.installExperience.runAsAccount)" -eq 'user') { $deviceGroups = foreach ($assignment in @($app.assignments)) { $target = $assignment.target $type = "$($target.'@odata.type')" if ($type -eq '#microsoft.graph.allDevicesAssignmentTarget') { 'all devices'; continue } if ($type -ne '#microsoft.graph.groupAssignmentTarget') { continue } if (Test-DeviceGroup -GroupId "$($target.groupId)") { "$($target.groupId)" } } if ($deviceGroups) { $assignmentSplat = @{ PolicyKind = 'Win32App'; Policy = $app; Rule = 'IslAssignmentIssue' Severity = 'Warning' Message = 'Install behavior User, assigned to devices ' + "($($deviceGroups -join ', ')): the app is never installed there. Assign " + 'it to users' Evidence = 'A user-context app assigned to a device group was never installed ' + 'on either join type: "user install context and this is a userless ' + 'check-in", Not applicable, Applicability 1011 (W32-USER-INSTALL)' } ConvertTo-PolicyFinding @assignmentSplat } } # Install context is not a script context: the user-context note does not apply to apps Test-Assignment -PolicyKind 'Win32App' -Policy $app -RunAsAccount 'system' Test-AssignmentFilter -PolicyKind 'Win32App' -Policy $app } } } finally { Remove-Item -LiteralPath $workFolder -Recurse -Force -ErrorAction SilentlyContinue } Write-Verbose "Completed $($MyInvocation.MyCommand.Name)" } |