Assets/RemediationLibrary/investigation-common/Review-Evidence.ps1
|
#requires -Version 5.1 <# .SYNOPSIS Explains supported local evidence and produces proposals, never device changes. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$EvidencePath) $ErrorActionPreference='Stop' $file=Get-Item -LiteralPath $EvidencePath if($file.Length -gt 10485760){throw 'Evidence exceeds 10 MiB.'} $data=Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json if($data.Kind -ne 'IntuneAccess.EndpointEvidence' -or $data.SchemaVersion -ne '1.0'){throw 'Unsupported evidence schema.'} $index=@{} if(@($data.Facts).Count -gt 1000){throw 'Too many evidence facts.'} foreach($fact in $data.Facts){if([string]::IsNullOrWhiteSpace($fact.Key) -or $index.ContainsKey([string]$fact.Key)){throw 'Invalid or duplicate fact key.'};if($fact.State -notin @('Observed','Absent','Unavailable')){throw 'Unsupported fact state.'};$index[[string]$fact.Key]=$fact} $findings=[Collections.Generic.List[object]]::new() function Add-Review { param($Id,$State,$Explanation,$Proposal,$Evidence) $findings.Add([pscustomobject]@{Id=$Id;State=$State;Explanation=$Explanation;Proposal=$Proposal;Evidence=$Evidence;Applied=$false}) } function Compare-TypedDetection { param($Observed,$Expected,$ComparisonType,$Operator) if($null -eq $Observed -or $null -eq $Expected -or $Observed -is [array]){return 'NotEvaluated'} if($ComparisonType -eq 'integer'){ $left=0L;$right=0L if([string]$Observed -notmatch '^[+-]?\d+$' -or [string]$Expected -notmatch '^[+-]?\d+$' -or -not [long]::TryParse([string]$Observed,[ref]$left) -or -not [long]::TryParse([string]$Expected,[ref]$right)){return 'NotEvaluated'} } elseif($ComparisonType -eq 'version'){ $left=$null;$right=$null if([string]$Observed -notmatch '^\d+\.\d+(\.\d+){0,2}$' -or [string]$Expected -notmatch '^\d+\.\d+(\.\d+){0,2}$' -or -not [version]::TryParse([string]$Observed,[ref]$left) -or -not [version]::TryParse([string]$Expected,[ref]$right)){return 'NotEvaluated'} } else {return 'NotEvaluated'} $order=$left.CompareTo($right) $match=switch($Operator){ 'equal' {$order -eq 0} 'notEqual' {$order -ne 0} 'greaterThan' {$order -gt 0} 'greaterThanOrEqual' {$order -ge 0} 'lessThan' {$order -lt 0} 'lessThanOrEqual' {$order -le 0} default {return 'NotEvaluated'} } if($match){'Matches'}else{'Mismatch'} } switch($data.Investigation){ 'ApplicationDetection' { foreach($rule in $data.Configuration.Rules){ if($rule.Type -in @('Registry','File') -and $rule.PSObject.Properties['ComparisonType']){ $view=if($rule.Type -eq 'File'){'File'}else{[string]$rule.View} $fact=$index["rule/$($rule.Id)/$view"] $result='NotEvaluated' if($view -in @('Registry32','Registry64','File') -and $null -ne $fact){ if($fact.State -eq 'Absent'){$result='Mismatch'} elseif($fact.State -eq 'Observed'){ $observed=if($rule.Type -eq 'File'){$fact.Value.Version}else{$fact.Value} $result=Compare-TypedDetection -Observed $observed -Expected $rule.ExpectedValue -ComparisonType $rule.ComparisonType -Operator $rule.Operator } } Add-Review -Id $rule.Id -State $result -Explanation "Comparison: $($rule.ComparisonType), operator $($rule.Operator), expected $($rule.ExpectedValue), view $view." -Proposal 'Review the observed value and original rule. Unsupported formats and Intune version string fallback are not emulated; no threshold is weakened automatically.' -Evidence $fact continue } if($rule.PSObject.Properties['Operator'] -and $rule.Operator -ne 'equal'){ Add-Review -Id $rule.Id -State 'NotEvaluated' -Explanation 'This comparison operator is not supported by this reviewer.' -Proposal 'Preserve the original Intune operator; do not substitute an equality check.' -Evidence $rule;continue } if($rule.Type -eq 'Registry'){ $view=[string]$rule.View if($view -notin @('Registry32','Registry64')){Add-Review -Id $rule.Id -State 'NotEvaluated' -Explanation 'The intended registry view is not configured.' -Proposal 'Specify the view configured in Intune.' -Evidence @();continue} $fact=$index["rule/$($rule.Id)/$view"] $otherView=if($view -eq 'Registry64'){'Registry32'}else{'Registry64'} $other=$index["rule/$($rule.Id)/$otherView"] $expectedProperty=$rule.PSObject.Properties['ExpectedValue'] if($null -eq $expectedProperty -or $null -eq $fact -or $fact.State -eq 'Unavailable'){ Add-Review -Id $rule.Id -State 'NotEvaluated' -Explanation 'Expected value or evidence is unavailable.' -Proposal 'Review configuration and collection errors.' -Evidence $fact } elseif($fact.State -eq 'Observed' -and [string]$fact.Value -ceq [string]$rule.ExpectedValue){ Add-Review -Id $rule.Id -State 'Matches' -Explanation 'The value matches in the configured registry view.' -Proposal 'No correction proposed. This is not proof of application health.' -Evidence $fact } elseif($null -ne $other -and $other.State -eq 'Observed' -and [string]$other.Value -ceq [string]$rule.ExpectedValue){ Add-Review -Id $rule.Id -State 'ArchitectureMismatchCandidate' -Explanation 'The expected value exists only in the alternative registry view or differs in the configured view.' -Proposal ([pscustomobject]@{Action='ReviewDetectionRule';Path=$rule.Path;Name=$rule.Name;ProposedView=$otherView;ExpectedValue=$rule.ExpectedValue;Reason='Confirm this identifies the intended application before editing Intune.'}) -Evidence @($fact,$other) } else {Add-Review -Id $rule.Id -State 'Mismatch' -Explanation 'The configured exact value is not observed.' -Proposal 'Check installation, version and execution context. Do not weaken detection to make the error disappear.' -Evidence @($fact,$other)} } elseif($rule.Type -eq 'File'){ $fact=$index["rule/$($rule.Id)/File"] if($null -eq $fact -or $fact.State -eq 'Unavailable'){Add-Review -Id $rule.Id -State 'NotEvaluated' -Explanation 'File evidence is unavailable.' -Proposal 'Review the path and execution context.' -Evidence $fact} elseif($fact.State -eq 'Absent'){Add-Review -Id $rule.Id -State 'Mismatch' -Explanation 'The expected file was absent in the executing context.' -Proposal 'Check install context and actual path; do not fabricate a detection marker.' -Evidence $fact} elseif($rule.PSObject.Properties['ExpectedVersion'] -and [string]$fact.Value.Version -cne [string]$rule.ExpectedVersion){Add-Review -Id $rule.Id -State 'Mismatch' -Explanation 'File version differs from the configured exact version.' -Proposal 'Review whether exact matching is appropriate for a self-updating application.' -Evidence $fact} else {Add-Review -Id $rule.Id -State 'Matches' -Explanation 'The configured file observation matches.' -Proposal 'No correction proposed. File presence is not proof of a healthy application.' -Evidence $fact} } else {Add-Review -Id $rule.Id -State 'NotEvaluated' -Explanation 'Unsupported detection rule type.' -Proposal 'Review this condition in Intune; it has not been treated as a passing rule.' -Evidence $rule} } } 'UpdateSources' { foreach($category in @('Quality','Feature','Driver','Other')){ $name="SetPolicyDrivenUpdateSourceFor${category}Updates" $gp=$index["update/$name"]; $mdm=$index["mdm-update/$name"] $observed=@(@($gp,$mdm)|Where-Object {$null -ne $_ -and $_.State -eq 'Observed'}) $expected=$null if($null -ne $data.Configuration -and $null -ne $data.Configuration.ExpectedSources){$expected=$data.Configuration.ExpectedSources.$category} if($observed.Count -eq 0){Add-Review -Id $category -State 'NotEvaluated' -Explanation 'No explicit per-category source was observed; defaults and other policy conditions require investigation.' -Proposal 'Do not infer the effective source from UseWUServer alone.' -Evidence @($gp,$mdm);continue} $values=@($observed|ForEach-Object {[string]$_.Value}|Select-Object -Unique) if($values.Count -gt 1){Add-Review -Id $category -State 'ConflictingEvidence' -Explanation 'Policy stores contain different source values; effective ownership is not proven.' -Proposal 'Review Group Policy results and Configuration Manager workload ownership before changing values.' -Evidence $observed} elseif($values[0] -notin @('0','1')){Add-Review -Id $category -State 'NotEvaluated' -Explanation 'Unrecognised source value.' -Proposal 'Check the supported Windows policy contract.' -Evidence $observed} else { $source=if($values[0] -eq '1'){'WSUS'}else{'WindowsUpdate'} $state=if($expected -in @('WSUS','WindowsUpdate')){if($expected -eq $source){'Matches'}else{'MigrationMismatch'}}else{'ReviewRequired'} Add-Review -Id $category -State $state -Explanation "The observed explicit policy value selects $source. This is not a live scan trace." -Proposal 'Correct the owning policy first. Only consider local cleanup after confirming the previous owner is retired.' -Evidence $observed } } } 'PolicyResidue' { foreach($rule in $data.Configuration.Rules){ $fact=$index["rule/$($rule.Id)/$($rule.View)"] if($null -eq $fact -or $fact.State -eq 'Unavailable'){Add-Review -Id $rule.Id -State 'NotEvaluated' -Explanation 'The configured value could not be evaluated.' -Proposal 'Review the exact registry view and collection errors.' -Evidence $fact} elseif($fact.State -eq 'Absent'){Add-Review -Id $rule.Id -State 'ValueAbsent' -Explanation 'The selected value was not observed.' -Proposal 'Absence alone does not establish the effective policy default or resolved behaviour.' -Evidence $fact} else {Add-Review -Id $rule.Id -State 'OwnershipReviewRequired' -Explanation 'The selected value remains present. This does not prove it is a policy remnant.' -Proposal 'Compare current assignments, prior snapshot, Group Policy results and setting-specific removal behaviour before any cleanup.' -Evidence $fact} } } 'UserApplication' { Add-Review -Id 'coverage' -State 'ReviewRequired' -Explanation 'Inventory covers machine installations and only the executing user, not every profile.' -Proposal 'Collect under the affected user. Choose a vendor-specific migration adapter before producing an uninstall package.' -Evidence $data.Context $userApps=@($data.Facts|Where-Object {$_.Key -like 'applications/HKCU:*' -and $_.State -eq 'Observed'}|ForEach-Object Value) $machineApps=@($data.Facts|Where-Object {$_.Key -like 'applications/HKLM:*' -and $_.State -eq 'Observed'}|ForEach-Object Value) foreach($app in $userApps){ $applicationMatches=@($machineApps|Where-Object {$_.Name -eq $app.Name -and $_.Publisher -eq $app.Publisher}) if($applicationMatches.Count){Add-Review -Id $app.ProductKey -State 'ParallelInstallationCandidate' -Explanation 'The same display name and publisher appear in user and machine inventory. Product identity and replacement health are not proven.' -Proposal 'Validate the vendor, supported versions, replacement launch and settings backup before migration. No uninstall command is executed.' -Evidence (@($app)+@($applicationMatches))} } } 'DeviceComparison' {Add-Review -Id 'comparison' -State 'ReferenceRequired' -Explanation 'This file is one endpoint observation.' -Proposal 'Collect the same investigation on a known-working reference and use Compare-Evidence.ps1. Differences are not automatically causes.' -Evidence $data.Context} default {throw 'Unsupported investigation type.'} } $combined=$null $contextAssessment='NotSpecified' if($data.Investigation -eq 'ApplicationDetection' -and -not [string]::IsNullOrWhiteSpace($data.Configuration.ApplicationId)){ $contextAssessment='NotEvaluated' $context=$data.Context $expectedContext=[string]$data.Configuration.InstallContext $expectedSid=[string]$data.Configuration.ExpectedUserSid if($null -ne $context -and $context.IsSystem -is [bool]){ if($expectedContext -eq 'system' -and $context.IsSystem -eq $true -and $context.UserSid -eq 'S-1-5-18'){$contextAssessment='SystemContextMatched'} elseif($expectedContext -eq 'user' -and $context.IsSystem -eq $false -and $context.UserSid -ne 'S-1-5-18' -and $expectedSid -match '^S-1-\d+(-\d+)+$' -and $context.UserSid -ceq $expectedSid){$contextAssessment='SelectedUserContextMatched'} } if($contextAssessment -eq 'NotEvaluated'){ Add-Review -Id 'execution-context' -State 'NotEvaluated' -Explanation 'The collected account context has not been matched to the imported application installation context. Individual observations cannot establish the combined detection result.' -Proposal 'Collect SYSTEM applications as SYSTEM. For user applications, explicitly set ExpectedUserSid to the affected user SID and collect as that user. Do not change installation context merely to make a check pass.' -Evidence ([pscustomobject]@{InstallContext=$expectedContext;ExpectedUserSid=$expectedSid;CollectedContext=$context}) } } if($data.Investigation -eq 'UpdateSources'){ foreach($finding in $findings){ $category=$finding.Id $setting="SetPolicyDrivenUpdateSourceFor${category}Updates" $owners=@() $rsop=$index['update/GroupPolicyResults'] if($null -ne $rsop -and $rsop.State -eq 'Observed'){ $owners=@($rsop.Value | Where-Object {$_.valueName -eq $setting -and $_.deleted -eq $false} | Select-Object GPOID,precedence,registryKey,valueName) } $expected=$null if($null -ne $data.Configuration -and $null -ne $data.Configuration.ExpectedSources){$expected=$data.Configuration.ExpectedSources.$category} $target=if($expected -in @('WSUS','WindowsUpdate')){$expected}else{'NotSpecified'} $finding.Proposal=[pscustomobject]@{ Action='ReviewOwningUpdatePolicy';ExpectedSource=$target ExpectedSourceProvenance='Administrator-supplied collection configuration, not inferred from registry observations.' Setting=$setting CspPath="./Device/Vendor/MSFT/Policy/Config/Update/$setting" GroupPolicyPath='Computer Configuration > Administrative Templates > Windows Components > Windows Update > Manage updates offered from Windows Server Update Service > Specify source service for specific classes of Windows Updates' RecordedGroupPolicies=$owners OwnershipState=if($owners.Count){'RecordedGroupPolicyCandidate'}else{'NotEstablished'} CollectionEvidence=@($rsop,$index['management/ConfigMgrService'],$index['update/UseUpdateClassPolicySource']) NextStep=if($target -eq 'NotSpecified'){'Declare the intended source for this update class before proposing a value change.'} elseif($owners.Count){'Review the recorded GPO IDs and their current settings in Group Policy Management. Check co-management ownership and MDM policy targeting before changing the owning policy.'} else {'Identify the assigned Intune Update CSP policy and check Group Policy results and co-management ownership. Missing RSoP evidence is not proof that Group Policy is absent.'} ProposedValue=if($target -eq 'WSUS'){1}elseif($target -eq 'WindowsUpdate'){0}else{$null} CleanupAllowed=$false;Applied=$false Verification='After an externally approved policy correction, collect fresh evidence and check a subsequent Windows Update scan. Registry agreement alone does not prove the scan source.' Recovery='Record the owning policy and its original values before changing it. Restore through the same management authority if the pilot fails.' Reference='https://learn.microsoft.com/en-us/windows/deployment/update/wufb-wsus' } } } if($data.Investigation -eq 'ApplicationDetection'){ $combined=if($findings.Count -eq 0 -or @($findings|Where-Object State -eq 'NotEvaluated').Count){'NotEvaluated'} elseif(@($findings|Where-Object State -ne 'Matches').Count){'Mismatch'}else{'Matches'} if($data.Configuration.PSObject.Properties['RuleCombination'] -and $data.Configuration.RuleCombination -ne 'All'){$combined='NotEvaluated'} } [pscustomobject]@{SchemaVersion='1.0';Kind='IntuneAccess.EndpointReview';Investigation=$data.Investigation;ComputerName=$data.ComputerName;CollectedAt=$data.CollectedAt;ContextAssessment=$contextAssessment;DetectionConditions=$combined;Findings=$findings.ToArray();DeviceConfigurationChanged=$false} | ConvertTo-Json -Depth 18 |