# LogChecker 0.1.0 - single-file bundle (Xenit AB, internal use only). # Generated by build/Build-Bundle.ps1 - do not edit by hand. # Load on any device: irm https://xenit.download/logchecker.ps1 | iex # Then: Get-IntuneDeviceIssue or Get-IntuneAppFailure -Name '' # Minimum: Windows PowerShell 5.1. Read-only; never modifies the logs it reads. # --- ConvertFrom-ImeErrorCode.ps1 --- function ConvertTo-ImeHexCode { # Normalize any IME error code (signed int, decimal string, or 0x form) to 0xXXXXXXXX. [CmdletBinding()] param([Parameter(Mandatory)][string]$Code) $c = $Code.Trim() $val = $null if ($c -match '^0x[0-9A-Fa-f]+$') { $val = [Convert]::ToInt64($c.Substring(2), 16) } elseif ($c -match '^-?\d+$') { $val = [int64]$c } else { return $c } # Mask to the low 32 bits with an explicit long literal so a negative HRESULT # (e.g. -2147023274) reinterprets cleanly as 0x80070656 without an unsigned-cast overflow. '0x{0:X8}' -f ($val -band 0xFFFFFFFFL) } function ConvertFrom-ImeErrorCode { <# .SYNOPSIS Map an IME / Win32 app error or exit code to a plain-language reason. Decodes HRESULT_FROM_WIN32 codes (0x8007xxxx) via the system Win32 message table, with a curated overlay for common MSI exit codes and IME-specific HRESULTs. Returns $null for a genuinely unknown code (caller keeps the raw value). #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Code) $hex = ConvertTo-ImeHexCode -Code $Code # Curated overlay (MSI exit codes given as raw decimal also normalize to these HRESULTs). $overlay = @{ '0x00000643' = 'Fatal error during installation (MSI 1603).' '0x80070643' = 'Fatal error during installation (MSI 1603).' '0x80070652' = 'Another installation is already in progress (MSI 1618).' '0x80070656' = 'The installer could not open its log file - the log path likely does not exist or is not writable in the SYSTEM context (MSI 1622). Check the app install command line log switch.' '0x87D1041C' = 'The application was not detected after the installation completed.' '0x87D300C9' = 'The installation was cancelled or interrupted.' } if ($overlay.ContainsKey($hex)) { return $overlay[$hex] } # HRESULT_FROM_WIN32: 0x8007xxxx -> Win32 error in the low word. # HRESULT_FROM_WIN32 (0x8007xxxx) or a plain Win32 / MSI exit code (0x0000xxxx): # decode the Win32 error in the low word via the system message table. foreach ($pattern in '^0x8007([0-9A-Fa-f]{4})$', '^0x0000([0-9A-Fa-f]{4})$') { if ($hex -match $pattern) { $win32 = [Convert]::ToInt32($Matches[1], 16) $msg = [System.ComponentModel.Win32Exception]::new($win32).Message if ($msg -and $msg -notmatch '^Unknown error') { return $msg } } } return $null } # --- Format-LogCheckerOutput.ps1 --- function ConvertTo-Verdict { [CmdletBinding()] param([Parameter(Mandatory)][string]$Outcome) switch ($Outcome) { 'Succeeded' { 'Succeeded' } 'Failed' { 'Failed' } 'InProgress' { 'InProgress' } default { 'Inconclusive' } } } function Format-LogCheckerOutput { # Human-readable rendering for -AsText. Presentation only; data is unchanged. # Wording reviewed with the /writing-guide skill (constitution Principle III). [CmdletBinding()] param([Parameter(Mandatory, ValueFromPipeline)]$InputObject) process { $type = $InputObject.PSObject.TypeNames[0] if ($type -eq 'LogChecker.AppCandidate') { return ('{0,-12} {1} (id: {2}, last attempt {3})' -f $InputObject.Verdict, $InputObject.AppName, $InputObject.AppId, $InputObject.LastAttempt) } if ($type -eq 'LogChecker.DeviceOverview') { $out = New-Object System.Collections.Generic.List[string] $out.Add(('Device overview: {0} ({1} issue(s))' -f $InputObject.OverallState, $InputObject.IssueCount)) foreach ($c in @($InputObject.Categories)) { $out.Add((' {0,-14} {1}' -f $c.Name, $c.CheckState)) } foreach ($issue in @($InputObject.Issues)) { $out.Add((' [{0,-11}] {1}: {2} {3}' -f $issue.Severity, $issue.Category, $issue.AffectedItem, $issue.Reason)) } return ($out -join [Environment]::NewLine) } $lines = New-Object System.Collections.Generic.List[string] $lines.Add(('App: {0}' -f $InputObject.AppName)) $lines.Add(('Status: {0}' -f $InputObject.Verdict)) if ($InputObject.Reason) { $lines.Add(('Reason: {0}' -f $InputObject.Reason)) } if ($InputObject.ErrorCode) { $lines.Add(('Code: {0}' -f $InputObject.ErrorCode)) } if ($InputObject.LastAttempt) { $lines.Add(('Last try: {0}' -f $InputObject.LastAttempt)) } if ($InputObject.Evidence -and @($InputObject.Evidence).Count -gt 0) { $e = @($InputObject.Evidence)[0] $lines.Add(('Evidence: {0}:{1}' -f $e.SourceFile, $e.LineNumber)) } return ($lines -join [Environment]::NewLine) } } # --- Get-AppInstallAttempt.ps1 --- function Test-AppWorkloadRecognized { <# .SYNOPSIS Did these records actually look like a real IME AppWorkload log we understand? Used to distinguish "checked, healthy" from "read the file but comprehended nothing" (constitution Principle IV - never report a false all-clear). #> [CmdletBinding()] param([Parameter(Mandatory)][AllowNull()][AllowEmptyCollection()][object[]]$Record) if (-not $Record) { return $false } [bool](@($Record | Where-Object { $_.Message -match '\[Win32App\]\[ReportingManager\]' -or $_.Message -match '"EnforcementState"' }).Count -gt 0) } function Get-AppInstallAttempt { <# .SYNOPSIS Parse real IME AppWorkload.log records into per-app install attempts. .DESCRIPTION Real IME logs report app outcomes as EnforcementState transitions in [Win32App][ReportingManager] lines, e.g. Report delta: {"EnforcementState":{"OldValue":"InProgressDownloadCompleted", "NewValue":"Error"},"EnforcementErrorCode":{"NewValue":-2147023274}} App display names come from content/download lines that carry both id and name. Each transition becomes an attempt; downstream keeps the latest per app. #> [CmdletBinding()] param([Parameter(Mandatory)][AllowNull()][AllowEmptyCollection()][object[]]$Record) if (-not $Record) { return } # 1) Build an id -> display-name map from any line that carries both. $names = @{} $idNameRx = [regex]'\(id\s*=\s*(?[0-9a-fA-F-]{36})\s*,\s*name\s*=?\s*(?[^),]+?)\s*[),]' $appNameRx = [regex]'"ApplicationId":"(?[0-9a-fA-F-]{36})"[^}]*?"ApplicationName":"(?[^"]+)"' $appNameRx2 = [regex]'"ApplicationName":"(?[^"]+)"[^}]*?"ApplicationId":"(?[0-9a-fA-F-]{36})"' foreach ($r in $Record) { $msg = [string]$r.Message foreach ($m in $idNameRx.Matches($msg)) { $id = $m.Groups['id'].Value; $nm = $m.Groups['name'].Value.Trim() if ($nm -and $nm -notmatch '^[0-9a-fA-F-]{36}$' -and -not $names.ContainsKey($id)) { $names[$id] = $nm } } foreach ($rx in @($appNameRx, $appNameRx2)) { $m = $rx.Match($msg) if ($m.Success -and $m.Groups['name'].Value -and $m.Groups['name'].Value -ne 'null') { $names[$m.Groups['id'].Value] = $m.Groups['name'].Value } } } # 2) Emit one attempt per EnforcementState transition. $transRx = [regex]'for app with id:\s*(?[0-9a-fA-F-]{36})\b.*?Report delta:\s*\{"EnforcementState":\{"OldValue":[^,]*,"NewValue":"?(?[^",}]+)"?\}(?:,"EnforcementErrorCode":\{"OldValue":[^,]*,"NewValue":(?-?\d+|null)\})?' foreach ($r in $Record) { $msg = [string]$r.Message $m = $transRx.Match($msg) if (-not $m.Success) { continue } $id = $m.Groups['id'].Value $state = $m.Groups['state'].Value $outcome = switch -regex ($state) { '^Success' { 'Succeeded' } '^Error' { 'Failed' } '^InProgress' { 'InProgress' } default { 'InProgress' } } $code = $null if ($outcome -eq 'Failed') { $raw = $m.Groups['code'].Value if ($raw -and $raw -ne 'null') { $code = ConvertTo-ImeHexCode -Code $raw } } $name = if ($names.ContainsKey($id)) { $names[$id] } else { $id } $ev = New-LCEvidence -SourceFile $r.SourceFile -LineNumber $r.LineNumber -Timestamp $r.Timestamp -Excerpt $msg New-LCAttempt -AppName $name -AppId $id -Timestamp $r.Timestamp -Action 'Install' -Outcome $outcome -ErrorCode $code -Evidence $ev } } # --- Get-LCCheckState.ps1 --- function Get-LCCheckState { # Derive a category CheckState from its resolved sources. [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Sources) $present = @($Sources | Where-Object { $_.State -eq 'Present' -and $_.Parsed }) $unreadable = @($Sources | Where-Object { $_.State -eq 'Unreadable' }) if ($present.Count -gt 0) { 'FullyChecked' } elseif ($unreadable.Count -gt 0) { 'Unreadable' } else { 'Inconclusive' } } # --- New-LogCheckerObject.ps1 --- # Factory helpers for the typed output objects (see data-model.md). PSTypeName-tagged # so formatting and downstream consumers can recognise them. function New-LCEvidence { [CmdletBinding()] param([string]$SourceFile, [int]$LineNumber, [datetime]$Timestamp, [string]$Excerpt) [pscustomobject]@{ PSTypeName = 'LogChecker.EvidenceCitation' SourceFile = $SourceFile LineNumber = $LineNumber Timestamp = $Timestamp Excerpt = $Excerpt } } function New-LCSourceStatus { [CmdletBinding()] param([string]$Path, [string]$State, [bool]$Parsed = $false) [pscustomobject]@{ PSTypeName = 'LogChecker.LogSourceStatus' Path = $Path State = $State Parsed = $Parsed } } function New-LCAttempt { [CmdletBinding()] param( [string]$AppName, [string]$AppId, [datetime]$Timestamp, [string]$Action, [string]$Outcome, [string]$ErrorCode, $Evidence ) [pscustomobject]@{ PSTypeName = 'LogChecker.AppInstallAttempt' AppName = $AppName AppId = $AppId Timestamp = $Timestamp Action = $Action Outcome = $Outcome ErrorCode = $ErrorCode Evidence = @($Evidence) } } function New-LCResult { [CmdletBinding()] param( [string]$AppName, [string]$AppId, [string]$Verdict, [string]$Reason, [string]$ErrorCode, [string]$Severity, $LastAttempt, [string]$Action, $Evidence, $History, $Sources ) [pscustomobject]@{ PSTypeName = 'LogChecker.AppFailureResult' AppName = $AppName AppId = $AppId Verdict = $Verdict Reason = $Reason ErrorCode = $ErrorCode Severity = $Severity LastAttempt = $LastAttempt Action = $Action Evidence = @($Evidence | Where-Object { $null -ne $_ }) History = @($History | Where-Object { $null -ne $_ }) Sources = @($Sources | Where-Object { $null -ne $_ }) } } function New-LCCandidate { [CmdletBinding()] param([string]$AppName, [string]$AppId, [string]$Verdict, $LastAttempt) [pscustomobject]@{ PSTypeName = 'LogChecker.AppCandidate' AppName = $AppName AppId = $AppId Verdict = $Verdict LastAttempt = $LastAttempt } } # --- Feature 002: Device Issue Overview -------------------------------------- function New-LCIssue { [CmdletBinding()] param( [string]$Category, [string]$AffectedItem, [string]$Reason, [string]$Severity, $LastSeen, [string]$ErrorCode, $Evidence ) [pscustomobject]@{ PSTypeName = 'LogChecker.Issue' Category = $Category AffectedItem = $AffectedItem Reason = $Reason Severity = $Severity LastSeen = $LastSeen ErrorCode = $ErrorCode Evidence = @($Evidence | Where-Object { $null -ne $_ }) } } function New-LCCategoryStatus { [CmdletBinding()] param([string]$Name, [string]$CheckState, [int]$IssueCount = 0) [pscustomobject]@{ PSTypeName = 'LogChecker.IssueCategoryStatus' Name = $Name CheckState = $CheckState IssueCount = $IssueCount } } function New-LCOverview { [CmdletBinding()] param([string]$OverallState, [int]$IssueCount, $Issues, $Categories, $Sources) [pscustomobject]@{ PSTypeName = 'LogChecker.DeviceOverview' OverallState = $OverallState IssueCount = $IssueCount Issues = @($Issues | Where-Object { $null -ne $_ }) Categories = @($Categories | Where-Object { $null -ne $_ }) Sources = @($Sources | Where-Object { $null -ne $_ }) } } # --- Get-AppInstallIssue.ps1 --- function Get-AppInstallIssue { <# .SYNOPSIS Collect outstanding app-install issues from AppWorkload.log. Reuses feature 001's attempt grouping; a failed latest attempt becomes an Issue. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) $sources = @(Resolve-LogSource -Path $Path -FileName 'AppWorkload*.log') $records = New-Object System.Collections.Generic.List[object] foreach ($s in $sources) { if ($s.State -ne 'Present') { continue } try { foreach ($r in @(Read-ImeLogRecord -Path $s.Path)) { $records.Add($r) }; $s.Parsed = $true } catch { $s.State = 'Unreadable' } } $issues = New-Object System.Collections.Generic.List[object] if ($records.Count -gt 0) { $attempts = @(Get-AppInstallAttempt -Record $records.ToArray()) foreach ($g in @($attempts | Group-Object AppId)) { $latest = @($g.Group | Sort-Object Timestamp)[-1] if ($latest.Outcome -eq 'Failed') { $reason = if ($latest.ErrorCode) { ConvertFrom-ImeErrorCode -Code $latest.ErrorCode } else { $null } if (-not $reason -and $latest.ErrorCode) { $reason = "Unrecognized error code ($($latest.ErrorCode))." } $issues.Add((New-LCIssue -Category 'AppInstall' -AffectedItem $latest.AppName -Reason $reason ` -Severity 'Error' -LastSeen $latest.Timestamp -ErrorCode $latest.ErrorCode -Evidence $latest.Evidence)) } } } # Safeguard (constitution Principle IV): if we read a present source but did not # recognize the Intune app-install format, report Inconclusive - never a false # "FullyChecked, 0 issues". $present = @($sources | Where-Object { $_.State -eq 'Present' -and $_.Parsed }) $checkState = if ($present.Count -gt 0 -and -not (Test-AppWorkloadRecognized -Record $records.ToArray())) { 'Inconclusive' } else { Get-LCCheckState -Sources $sources } [pscustomobject]@{ Issues = $issues.ToArray() Status = (New-LCCategoryStatus -Name 'AppInstall' -CheckState $checkState -IssueCount $issues.Count) Sources = $sources } } # --- Get-CompanyPortalIssue.ps1 --- function Test-CompanyPortalRecognized { # Did we read a real Company Portal log we understand (tab-delimited, ISO timestamp + # level)? Principle IV safeguard. [CmdletBinding()] param([Parameter(Mandatory)][AllowNull()][AllowEmptyCollection()][string[]]$Line) if (-not $Line) { return $false } [bool](@($Line | Where-Object { $_ -match '^\d{4}-\d{2}-\d{2}T[\d:.]+Z?\t\w+\t' }).Count -gt 0) } function Get-CompanyPortalIssue { <# .SYNOPSIS Collect outstanding Company Portal issues from the documented per-user log (%LOCALAPPDATA%\Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState\Log_*.log). Real format is tab-delimited: ISO-timestamp LEVEL ... message. A failure is signalled in the message ("N Apps in 'Failed' status", "Encountered an error status: Failed") or by an ERROR-level line. Read but unrecognized -> the category is Inconclusive, never a false all-clear. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [string]$CompanyPortalPath ) if (-not $CompanyPortalPath) { $fixtureCp = Join-Path $Path 'CompanyPortal\LocalState' if (Test-Path -LiteralPath $fixtureCp -PathType Container) { $CompanyPortalPath = $fixtureCp } else { $CompanyPortalPath = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState' } } $sources = @(Resolve-LogSource -Path $CompanyPortalPath -FileName 'Log_*.log') $tsLevelRx = [regex]'^(?\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\t(?\w+)\t' $failCountRx = [regex]"'?(?\d+)'?\s+Apps?\s+in\s+'?Failed'?\s+status" $errStatusRx = [regex]'Encountered an error status:\s*Failed' # Consolidate failure signals across all CP log files into ONE issue (FR-012): keep the # most recent failure line as evidence, and the highest "N apps failed" count seen. $recognizedAny = $false $best = $null $maxCount = 0 foreach ($s in $sources) { if ($s.State -ne 'Present') { continue } $lines = @() try { $lines = @(Get-Content -LiteralPath $s.Path -ErrorAction Stop); $s.Parsed = $true } catch { $s.State = 'Unreadable'; continue } if (Test-CompanyPortalRecognized -Line $lines) { $recognizedAny = $true } for ($i = 0; $i -lt $lines.Count; $i++) { $line = $lines[$i] $m = $tsLevelRx.Match($line) if (-not $m.Success) { continue } $isFail = $line -match $failCountRx -or $errStatusRx.IsMatch($line) -or $m.Groups['lvl'].Value -eq 'ERROR' if (-not $isFail) { continue } if ($line -match $failCountRx -and [int]$Matches['n'] -gt $maxCount) { $maxCount = [int]$Matches['n'] } [datetime]$ts = [datetime]::MinValue [void][datetime]::TryParse($m.Groups['ts'].Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$ts) if (-not $best -or $ts -gt $best.Ts) { $best = [pscustomobject]@{ Ts = $ts; Line = $line; LineNumber = $i + 1; Path = $s.Path } } } } $issues = New-Object System.Collections.Generic.List[object] if ($best) { $reason = if ($maxCount -ge 1) { "Company Portal reports $maxCount app(s) in a Failed state." } else { 'Company Portal logged an error (status: Failed).' } $ev = New-LCEvidence -SourceFile $best.Path -LineNumber $best.LineNumber -Timestamp $best.Ts -Excerpt ($best.Line.Trim()) $issues.Add((New-LCIssue -Category 'CompanyPortal' -AffectedItem 'Company Portal' -Reason $reason ` -Severity 'Error' -LastSeen $best.Ts -ErrorCode $null -Evidence $ev)) } $present = @($sources | Where-Object { $_.State -eq 'Present' -and $_.Parsed }) $checkState = if ($present.Count -gt 0 -and -not $recognizedAny) { 'Inconclusive' } elseif ($present.Count -gt 0) { 'FullyChecked' } else { Get-LCCheckState -Sources $sources } [pscustomobject]@{ Issues = $issues.ToArray() Status = (New-LCCategoryStatus -Name 'CompanyPortal' -CheckState $checkState -IssueCount $issues.Count) Sources = $sources } } # --- Get-RemediationIssue.ps1 --- function Test-HealthScriptsRecognized { # Did we read a real HealthScripts log we understand? (Principle IV safeguard.) [CmdletBinding()] param([Parameter(Mandatory)][AllowNull()][AllowEmptyCollection()][object[]]$Record) if (-not $Record) { return $false } [bool](@($Record | Where-Object { $_.Message -match '\[HS\]' }).Count -gt 0) } function Get-RemediationIssue { <# .SYNOPSIS Collect outstanding detection/remediation issues from the real HealthScripts.log format. A remediation/detection script that exits non-zero is an issue. The policy is identified by its PolicyId GUID (HealthScripts.log carries no friendly name). #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) $sources = @() $sources += Resolve-LogSource -Path $Path -FileName 'HealthScripts*.log' $sources += Resolve-LogSource -Path $Path -FileName 'AgentExecutor*.log' $records = New-Object System.Collections.Generic.List[object] foreach ($s in $sources) { if ($s.State -ne 'Present') { continue } try { foreach ($r in @(Read-ImeLogRecord -Path $s.Path)) { $records.Add($r) }; $s.Parsed = $true } catch { $s.State = 'Unreadable' } } $policyRx = [regex]'\[HS\]\s*ProcessScript PolicyId:\s*(?[0-9a-fA-F-]{36})' $exitRx = [regex]'\[HS\]\s*exit code of the script is\s*(?-?\d+)' # Walk records in order, attributing each script exit code to the policy being processed. # Keep the most recent exit per policy. $byPolicy = @{} $current = $null foreach ($r in $records) { $msg = [string]$r.Message $pm = $policyRx.Match($msg) if ($pm.Success) { $current = $pm.Groups['id'].Value; continue } $em = $exitRx.Match($msg) if ($em.Success) { $id = if ($current) { $current } else { 'unknown-policy' } if (-not $byPolicy.ContainsKey($id) -or $r.Timestamp -gt $byPolicy[$id].Record.Timestamp) { $byPolicy[$id] = [pscustomobject]@{ Code = [int]$em.Groups['code'].Value; Record = $r } } } } $issues = New-Object System.Collections.Generic.List[object] foreach ($kv in $byPolicy.GetEnumerator()) { if ($kv.Value.Code -eq 0) { continue } $r = $kv.Value.Record $ev = New-LCEvidence -SourceFile $r.SourceFile -LineNumber $r.LineNumber -Timestamp $r.Timestamp -Excerpt $r.Message $reason = "A detection/remediation script for policy $($kv.Key) exited with code $($kv.Value.Code)." $issues.Add((New-LCIssue -Category 'Remediation' -AffectedItem $kv.Key -Reason $reason ` -Severity 'Error' -LastSeen $r.Timestamp -ErrorCode ([string]$kv.Value.Code) -Evidence $ev)) } # Safeguard: present but unrecognized HealthScripts format -> Inconclusive, not a false # all-clear (constitution Principle IV). $present = @($sources | Where-Object { $_.State -eq 'Present' -and $_.Parsed }) $checkState = if ($present.Count -gt 0 -and -not (Test-HealthScriptsRecognized -Record $records.ToArray())) { 'Inconclusive' } else { Get-LCCheckState -Sources $sources } [pscustomobject]@{ Issues = $issues.ToArray() Status = (New-LCCategoryStatus -Name 'Remediation' -CheckState $checkState -IssueCount $issues.Count) Sources = $sources } } # --- Read-ImeLogRecord.ps1 --- function ConvertTo-ImeTimestamp { [CmdletBinding()] param([string]$Date, [string]$Time) # Strip a trailing UTC offset such as +000 / -000 from the time component. $t = $Time foreach ($sep in '+', '-') { $idx = $t.IndexOf($sep) if ($idx -gt 0) { $t = $t.Substring(0, $idx); break } } $stamp = "$Date $t" # All parsing is culture-invariant: real IME dates are M-d-yyyy (US-style), which a # Swedish/European-locale machine would otherwise misread. Fractional seconds vary in # length (3 to 7 digits), so try the long forms first. $invariant = [System.Globalization.CultureInfo]::InvariantCulture $formats = @( 'M-d-yyyy HH:mm:ss.fffffff', 'MM-dd-yyyy HH:mm:ss.fffffff', 'M-d-yyyy HH:mm:ss.fff', 'MM-dd-yyyy HH:mm:ss.fff', 'M-d-yyyy H:mm:ss.fff', 'MM-dd-yyyy H:mm:ss.fff', 'M-d-yyyy HH:mm:ss', 'MM-dd-yyyy HH:mm:ss' ) $parsed = [datetime]::MinValue foreach ($f in $formats) { if ([datetime]::TryParseExact($stamp, $f, $invariant, [System.Globalization.DateTimeStyles]::None, [ref]$parsed)) { return $parsed } } if ([datetime]::TryParse($stamp, $invariant, [System.Globalization.DateTimeStyles]::None, [ref]$parsed)) { return $parsed } return $null } function Read-ImeLogRecord { <# .SYNOPSIS Parse a CMTrace-format Intune Management Extension log file into record objects. Read-only; never modifies the source file. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } $text = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop if ([string]::IsNullOrEmpty($text)) { return } $rx = [regex]'(?s).*?)\]LOG\]!>[^"]+)"\s+date="(?[^"]+)"\s+component="(?[^"]*)"[^>]*?type="(?\d+)"' # Track the line number incrementally over disjoint text segments so the whole # file is scanned only once (O(n)), not once per match. $newlineRx = [regex]"`n" $lastIndex = 0 $lineSoFar = 1 foreach ($m in $rx.Matches($text)) { if ($m.Index -gt $lastIndex) { $segment = $text.Substring($lastIndex, $m.Index - $lastIndex) $lineSoFar += $newlineRx.Matches($segment).Count $lastIndex = $m.Index } [pscustomobject]@{ PSTypeName = 'LogChecker.LogRecord' Message = $m.Groups['msg'].Value.Trim() Component = $m.Groups['comp'].Value Type = [int]$m.Groups['type'].Value Timestamp = ConvertTo-ImeTimestamp -Date $m.Groups['date'].Value -Time $m.Groups['time'].Value LineNumber = $lineSoFar SourceFile = $Path } } } # --- Resolve-LogSource.ps1 --- function Resolve-LogSource { <# .SYNOPSIS Locate and classify the IME app log source(s) under a path. Returns LogChecker.LogSourceStatus objects (Present / Missing). Unreadable is determined at read time by the caller. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [string]$FileName = 'AppWorkload*.log' ) if (Test-Path -LiteralPath $Path -PathType Leaf) { return New-LCSourceStatus -Path $Path -State 'Present' } if (Test-Path -LiteralPath $Path -PathType Container) { $logs = @(Get-ChildItem -LiteralPath $Path -Filter $FileName -File -ErrorAction SilentlyContinue) if ($logs.Count -gt 0) { return $logs | ForEach-Object { New-LCSourceStatus -Path $_.FullName -State 'Present' } } $missingName = ($FileName -replace '\*', '') return New-LCSourceStatus -Path (Join-Path $Path $missingName) -State 'Missing' } return New-LCSourceStatus -Path $Path -State 'Missing' } # --- Get-IntuneAppFailure.ps1 --- function Get-IntuneAppFailure { <# .SYNOPSIS Find out why a specific Intune-managed app failed (or report its current status). .DESCRIPTION Reads the local Intune Management Extension logs read-only and reports a verdict for the requested app: Failed, Succeeded, InProgress, or Inconclusive. For a failure it gives a plain-language reason, the underlying error or exit code, the time of the last attempt, and a citation to the supporting log line. If the name matches more than one app, the command lists the candidates so you can re-run against the exact app. If a required log source is missing or unreadable, the result is Inconclusive and names the source - it never reports a false "no failure". .PARAMETER Name Case-insensitive substring of the app display name. Multiple matches return a candidate list instead of a single verdict. .PARAMETER Id Exact app or policy ID to match. .PARAMETER History Include the chronological list of recorded attempts for the app. .PARAMETER Path Log source root to read. Defaults to the standard IME logs folder. Point this at a copied log set or a test fixture to analyze logs off-box. .PARAMETER AsText Return a human-readable rendering instead of the result object. .EXAMPLE Get-IntuneAppFailure -Name 'Contoso VPN' Reports why the Contoso VPN app failed, with the reason, error code, and evidence. .EXAMPLE Get-IntuneAppFailure -Name 'Contoso VPN' | ConvertTo-Json -Depth 5 Returns the result in machine-readable form for automation. #> [CmdletBinding(DefaultParameterSetName = 'Name')] [OutputType('LogChecker.AppFailureResult')] param( [Parameter(Mandatory, ParameterSetName = 'Name', Position = 0)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory, ParameterSetName = 'Id')] [ValidateNotNullOrEmpty()] [string]$Id, [switch]$History, [string]$Path = (Join-Path $env:ProgramData 'Microsoft\IntuneManagementExtension\Logs'), [switch]$AsText ) # --- Read sources (read-only) ------------------------------------------------ $sources = @(Resolve-LogSource -Path $Path) $records = New-Object System.Collections.Generic.List[object] foreach ($s in $sources) { if ($s.State -ne 'Present') { continue } try { foreach ($rec in @(Read-ImeLogRecord -Path $s.Path)) { $records.Add($rec) } $s.Parsed = $true } catch { $s.State = 'Unreadable' Write-Warning "Could not read log source: $($s.Path)" } } Write-Verbose ("Inspected {0} source(s):" -f $sources.Count) foreach ($s in $sources) { Write-Verbose (" [{0}] {1}" -f $s.State, $s.Path) } $attempts = @(Get-AppInstallAttempt -Record $records.ToArray()) $usable = @($sources | Where-Object { $_.State -eq 'Present' -and $_.Parsed }) $badSource = @($sources | Where-Object { $_.State -in 'Missing', 'Unreadable' }) # --- Match the requested app ------------------------------------------------- if ($PSCmdlet.ParameterSetName -eq 'Name') { $matched = @($attempts | Where-Object { $_.AppName -like "*$Name*" }) $term = $Name } else { $matched = @($attempts | Where-Object { $_.AppId -eq $Id }) $term = $Id } $groups = @($matched | Group-Object AppId) $emit = { param($Obj) if ($AsText) { Format-LogCheckerOutput -InputObject $Obj } else { $Obj } } # --- Safeguard: read the file but understood nothing -> Inconclusive, never "no failures" # (constitution Principle IV). Distinguishes a genuinely healthy device from an # unrecognized log format. if ($usable.Count -gt 0 -and -not (Test-AppWorkloadRecognized -Record $records.ToArray())) { $reason = "Could not determine status: read $($usable.Count) source(s) but found no recognized Intune app-install records (EnforcementState). Reporting inconclusive rather than 'no failures'." $res = New-LCResult -AppName $term -AppId $null -Verdict 'Inconclusive' -Reason $reason ` -ErrorCode $null -Severity 'Warning' -LastAttempt $null -Action 'Unknown' ` -Evidence @() -History @() -Sources $sources return (& $emit $res) } # --- No match ---------------------------------------------------------------- if ($groups.Count -eq 0) { if ($badSource.Count -gt 0 -and $usable.Count -eq 0) { $reason = "Could not determine status: required log source(s) missing or unreadable - " + (($badSource | ForEach-Object { $_.Path }) -join '; ') } else { $reason = "No app matching '$term' was found in the available logs. Re-run with a broader -Name, or review the logs, to see which apps are present." } $res = New-LCResult -AppName $term -AppId $null -Verdict 'Inconclusive' -Reason $reason ` -ErrorCode $null -Severity 'Warning' -LastAttempt $null -Action 'Unknown' ` -Evidence @() -History @() -Sources $sources return (& $emit $res) } # --- Ambiguous name: candidate list ----------------------------------------- if ($groups.Count -gt 1) { foreach ($g in $groups) { $latest = @($g.Group | Sort-Object Timestamp)[-1] $cand = New-LCCandidate -AppName $latest.AppName -AppId $latest.AppId ` -Verdict (ConvertTo-Verdict $latest.Outcome) -LastAttempt $latest.Timestamp & $emit $cand } return } # --- Single app: full verdict ------------------------------------------------ $appAttempts = @($groups[0].Group | Sort-Object Timestamp) $latest = $appAttempts[-1] $verdict = ConvertTo-Verdict $latest.Outcome $reason = $null $errorCode = $null if ($verdict -eq 'Failed') { $errorCode = $latest.ErrorCode if ($errorCode) { $mapped = ConvertFrom-ImeErrorCode -Code $errorCode $reason = if ($mapped) { $mapped } else { "Unrecognized error code ($errorCode)." } } else { $reason = 'The installation failed; no specific error code was recorded.' } } $severity = switch ($verdict) { 'Failed' { 'Error' } 'Inconclusive' { 'Warning' } default { 'Information' } } $hist = if ($History) { $appAttempts } else { @() } $res = New-LCResult -AppName $latest.AppName -AppId $latest.AppId -Verdict $verdict -Reason $reason ` -ErrorCode $errorCode -Severity $severity -LastAttempt $latest.Timestamp -Action $latest.Action ` -Evidence $latest.Evidence -History $hist -Sources $sources & $emit $res } # --- Get-IntuneDeviceIssue.ps1 --- function Get-IntuneDeviceIssue { <# .SYNOPSIS Show all outstanding issues on a device in one severity-ordered overview. .DESCRIPTION Reads the local Intune Management Extension logs read-only across categories (app installations, detection/remediation, Company Portal) and returns a single overview. Each issue carries a category, the affected item, a plain-language reason, a severity, a last-seen time, and a citation. The overview distinguishes "all clear" from "could not fully check": when a log source is missing or unreadable, the affected category is reported as Inconclusive and the overall state is never a false all-clear. .PARAMETER Category Limit the overview to one or more categories: AppInstall, Remediation, CompanyPortal. .PARAMETER MinSeverity Minimum severity to include: Information (default), Warning, or Error. .PARAMETER Path Log source root to read. Defaults to the standard IME logs folder. Point this at a copied log set or a test fixture to analyze logs off-box. .PARAMETER AsText Return a human-readable rendering instead of the overview object. .EXAMPLE Get-IntuneDeviceIssue Shows every outstanding issue on the device, most severe first. .EXAMPLE Get-IntuneDeviceIssue -Category AppInstall -MinSeverity Error #> [CmdletBinding()] [OutputType('LogChecker.DeviceOverview')] param( [ValidateSet('AppInstall', 'Remediation', 'CompanyPortal')] [string[]]$Category, [ValidateSet('Information', 'Warning', 'Error')] [string]$MinSeverity = 'Information', [string]$Path = (Join-Path $env:ProgramData 'Microsoft\IntuneManagementExtension\Logs'), [switch]$AsText ) $wanted = if ($Category) { $Category } else { @('AppInstall', 'Remediation', 'CompanyPortal') } # Company Portal logs live outside the IME folder. When -Path is explicit (a copied # log set or fixture), look only under \CompanyPortal\LocalState; otherwise use # the documented per-user location. $cpPath = if ($PSBoundParameters.ContainsKey('Path')) { Join-Path $Path 'CompanyPortal\LocalState' } else { Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState' } $providers = @( @{ Name = 'AppInstall'; Fn = { Get-AppInstallIssue -Path $Path } } @{ Name = 'Remediation'; Fn = { Get-RemediationIssue -Path $Path } } @{ Name = 'CompanyPortal'; Fn = { Get-CompanyPortalIssue -Path $Path -CompanyPortalPath $cpPath } } ) $allIssues = New-Object System.Collections.Generic.List[object] $categories = New-Object System.Collections.Generic.List[object] $allSources = New-Object System.Collections.Generic.List[object] foreach ($p in $providers) { if ($p.Name -notin $wanted) { continue } $res = & $p.Fn foreach ($i in @($res.Issues)) { $allIssues.Add($i) } $categories.Add($res.Status) foreach ($s in @($res.Sources)) { $allSources.Add($s) } } Write-Verbose ("Inspected {0} source(s) across {1} categor(y/ies):" -f $allSources.Count, $categories.Count) foreach ($s in $allSources) { Write-Verbose (" [{0}] {1}" -f $s.State, $s.Path) } foreach ($c in $categories) { Write-Verbose (" category {0}: {1}" -f $c.Name, $c.CheckState) } # Severity filter. $rank = @{ 'Information' = 0; 'Warning' = 1; 'Error' = 2 } $min = $rank[$MinSeverity] $filtered = @($allIssues | Where-Object { $rank[$_.Severity] -ge $min }) # Sort: severity descending, then most recent first. $sorted = @($filtered | Sort-Object ` @{ Expression = { $rank[$_.Severity] }; Descending = $true }, ` @{ Expression = { $_.LastSeen }; Descending = $true }) $issueCount = $sorted.Count $notFully = @($categories | Where-Object { $_.CheckState -ne 'FullyChecked' }) if ($issueCount -gt 0) { $overall = 'Issues' } elseif ($notFully.Count -eq 0) { $overall = 'AllClear' } else { $overall = 'Inconclusive' } $overview = New-LCOverview -OverallState $overall -IssueCount $issueCount ` -Issues $sorted -Categories $categories.ToArray() -Sources $allSources.ToArray() if ($AsText) { Format-LogCheckerOutput -InputObject $overview } else { $overview } } # --- entry point --- Write-Host 'LogChecker loaded - scanning this device for outstanding Intune issues...' -ForegroundColor Cyan Get-IntuneDeviceIssue -AsText Write-Host '' Write-Host "For objects: Get-IntuneDeviceIssue | For one app: Get-IntuneAppFailure -Name ''" -ForegroundColor DarkGray