Invoke-IntuneDiagExtender.ps1
|
<#PSScriptInfo
.VERSION 1.1.1 .GUID 9b5b3ee1-3b09-47f7-bb3e-4710c43b89f2 .AUTHOR Wolfgang Bäck (CARECON GmbH) .COMPANYNAME CARECON GmbH .TAGS Intune Diagnostics Remediation OOBE Registry Windows PSEdition_Desktop .RELEASENOTES 1.1.1: Refactoring. 1.1.0: Added recursive registry key export (RegistryLocations) in .reg format, with wildcards and missing keys logged as warnings. Errors during file or registry collection, including invalid entries, are logged and collection continues. 1.0.0: Initial version: recursive log collection with wildcards, general and per-location file extensions, full-directory ZIP collection, and warnings for missing locations. #> #Requires -Version 2.0 <# .SYNOPSIS Stages log files for the Intune device action "Collect diagnostics". .DESCRIPTION Extends Microsoft's Intune device action "Collect diagnostics" by staging administrator-selected files, complete directory archives and registry exports in the Windows Temp location that Intune collects. Intended for Intune Remediations and Windows troubleshooting from Autopilot/OOBE when script execution is available. It only stages data; it neither starts a diagnostics request nor uploads data. Then run Intune Collect diagnostics and download the diagnostic package. KEY FEATURES - Recursively searches configured directories for selected file extensions. Search paths support PowerShell wildcards (*, ? and bracket expressions) and %ENVIRONMENT_VARIABLES%. Hidden files are included; junctions and symbolic links are not followed. General extensions apply to all search paths, with optional per-location additions such as '%windir%\Logs\CBS|.cab,.etl'. Use '*' to collect all extensions, including extensionless files. Overlapping matches are deduplicated. - Collects all files recursively from FullDirectoryLocations without an extension filter. Each matching directory is staged as a ZIP archive when supported by the available .NET runtime; otherwise files are copied individually (empty directories are not preserved). Matching directories are archived separately. Directory collection is enabled for %windir%\Panther by default; use @() to disable it. - Exports configured registry keys recursively, including values and subkeys, to Windows Registry Editor Version 5.00 (.reg) format. Supports HKLM, HKCU, HKCR, HKU and HKCC paths, PowerShell registry-drive notation and wildcards. Keys are read-only and the registry is never changed. Exports use the registry view of the process bitness. HKCU means the executing account (usually SYSTEM); use HKU with a SID pattern to target loaded user hives. Registry exports can contain sensitive data, so use narrowly scoped paths. - Writes staged files directly to %windir%\Temp (normally C:\Windows\Temp), where Intune collects %windir%\Temp\%computername%*.log. Output names include the computer name, a hash of the source path and the original file name. A .log suffix is appended without converting file contents; rename downloaded files to their original extension to open them, and rename .zip.log archives to .zip. A collector log records mappings, warnings and errors. - Continues after file, directory or registry collection errors, logs the problem and tries remaining items. Missing paths, drives, environment variables, keys or matching files are warnings and do not fail the run. Exit code 0 means collection completed without errors; exit code 1 means one or more errors occurred, even if other items were collected. An unusable destination or collector log is fatal. A failed archive is not staged; accessible files are copied individually where possible. COMPATIBILITY AND OPERATION Compatible with Windows PowerShell 2.0 and newer, including Windows PowerShell 5.1, and requires no extra PowerShell modules or external archive utilities. Run elevated or as SYSTEM; for Intune Remediations, set "Run this script using the logged-on credentials" to No and enable 64-bit PowerShell to collect the 64-bit registry view. Intune Remediations require the Intune Management Extension and may not execute at the earliest OOBE stage; use the same script from your provisioning workflow if collection must occur before the extension is ready. Repeated runs overwrite copies of the same source. Older staged copies whose sources have disappeared remain until removed; the collector log is overwritten each run. Source files are not changed or deleted. There are no size or age limits, so scope locations and extensions carefully to control disk use, runtime, diagnostic package size and sensitive data. Active files are copied up to their length when opened and may not be atomic snapshots. See Get-Help -Full for parameters and examples. .PARAMETER SearchLocations Directory paths/patterns searched recursively. Edit the defaults below for Intune deployment. %TEMP% resolves for the executing account (usually SYSTEM). Optionally append "|" and a comma-separated list of additional extensions that apply only to that location, in addition to FileExtensions: '%windir%\Logs\CBS|.cab,.etl' The additional extensions use the same format as FileExtensions. .PARAMETER FileExtensions General case-insensitive extensions applied to every search location: log, .log and *.log are equivalent. Use '*' to collect all extensions, including extensionless files. .PARAMETER FullDirectoryLocations Directory paths/patterns whose complete contents are collected recursively, irrespective of FileExtensions. Defaults to %windir%\Panther; use @() to disable full-directory collection. .PARAMETER RegistryLocations Registry key paths/patterns exported recursively, for example 'HKLM\SOFTWARE\Vendor\Product', 'HKLM:\SOFTWARE\Vendor\*' or 'HKU\S-1-5-21-*\Software\Vendor'. HKCU is the profile of the executing account (usually SYSTEM); use HKU with a SID pattern for user hives that are loaded. Defaults to none; use narrowly scoped keys because exports may contain sensitive data. .PARAMETER DisableDirectoryArchive Stage full-directory files individually instead of creating ZIP archives. .PARAMETER DestinationDirectory Defaults to %windir%\Temp. Override only for local testing; arbitrary destinations are not automatically included by Intune Collect diagnostics. .EXAMPLE .\Invoke-IntuneDiagExtender.ps1 Uses the configured lists and stages files for Intune diagnostics. .EXAMPLE .\Invoke-IntuneDiagExtender.ps1 -SearchLocations 'C:\ProgramData\Vendor\*\Logs','C:\Windows\Panther' -FileExtensions '.log','.txt','.etl' Recursively collects matching extensions beneath matching directories. .EXAMPLE .\Invoke-IntuneDiagExtender.ps1 -SearchLocations 'C:\Lab\Logs' -FileExtensions '*' -DestinationDirectory 'C:\Lab\Collected' Tests collection without writing to Windows Temp. .EXAMPLE .\Invoke-IntuneDiagExtender.ps1 -SearchLocations '%ProgramData%\Microsoft\IntuneManagementExtension\Logs','%windir%\Logs\CBS|.cab,.etl' -FileExtensions '.log' Collects .log files from both locations and additionally .cab and .etl files from the CBS location only. .EXAMPLE .\Invoke-IntuneDiagExtender.ps1 -FullDirectoryLocations 'C:\Windows\Panther','C:\ProgramData\Vendor\*\Diagnostics' Collects all files in the matching directories, using ZIP when available. .EXAMPLE .\Invoke-IntuneDiagExtender.ps1 -RegistryLocations 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE','HKLM:\SOFTWARE\Vendor\*\Settings' Additionally exports the matching registry keys and all their subkeys. .NOTES Author: Wolfgang Bäck (CARECON GmbH) History: Version Date Author Description 1.0.0 2026-11-13 Wolfgang Bäck (CARECON GmbH) Initial version. 1.1.0 2026-11-13 Wolfgang Bäck (CARECON GmbH) Recursive registry key export; errors are logged and collection continues. 1.1.1 2026-11-13 Wolfgang Bäck (CARECON GmbH) Refactoring. Intune collection paths: https://learn.microsoft.com/intune/device-management/actions/collect-diagnostics #> [CmdletBinding()] param( # "path|ext1,ext2" adds extensions for that location only, e.g. # '%windir%\Logs\CBS|.cab,.etl' [string[]]$SearchLocations = @( '%ProgramData%', '%windir%\Panther', '%windir%\Logs\DISM', '%windir%\Temp' ), [string[]]$FileExtensions = @('.log', '.txt', '.lo_'), [string[]]$FullDirectoryLocations = @('%windir%\Panther'), # Registry keys exported recursively (key, values and all subkeys). # Hives: HKLM, HKCU, HKCR, HKU, HKCC (short, long or PowerShell drive # notation); wildcards * ? [ ] are supported. Uncomment entries to use them. [string[]]$RegistryLocations = @( # 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE', # 'HKLM\SOFTWARE\Microsoft\Enrollments', # 'HKLM\SOFTWARE\Microsoft\IntuneManagementExtension', # 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot', # One specific key by its exact path (no wildcard); its subkeys are included: # 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU', # 'HKLM:\SOFTWARE\Vendor\*\Settings', # 'HKU\S-1-5-21-*\Software\Vendor' ), [switch]$DisableDirectoryArchive, [string]$DestinationDirectory = '%windir%\Temp' ) # Keep this version synchronized with .VERSION in the PSScriptInfo header. $scriptVersion = '1.1.1' $ErrorActionPreference = 'Stop' $script:CollectorLog = $null $script:CollectionErrors = 0 # Keep progress and failures in one per-run log so Intune diagnostics include # both the collected files and an explanation of what the collector attempted. function Write-CollectorLog { param([string]$Message, [string]$Level = 'INFO') $script:CollectorLog.WriteLine(('{0} [{1}] {2}' -f [DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss.fff'), $Level, $Message)) $script:CollectorLog.Flush() } # Record a non-fatal error: it is logged and counted for the exit code, and # the caller continues with the next file, key, extension or location. function Write-CollectionError { param([string]$Message) $script:CollectionErrors++ Write-CollectorLog $Message 'ERROR' } # Classify "not found" conditions (missing file, directory, drive or item), # including .NET exceptions wrapped by PowerShell method invocation. These are # expected while provisioning and must be logged as warnings, not failures. function Test-MissingPathError { param([Exception]$Exception) while ($null -ne $Exception) { if ($Exception -is [System.IO.FileNotFoundException] -or $Exception -is [System.IO.DirectoryNotFoundException] -or $Exception -is [System.IO.DriveNotFoundException] -or $Exception -is [System.Management.Automation.DriveNotFoundException] -or $Exception -is [System.Management.Automation.ItemNotFoundException]) { return $true } $Exception = $Exception.InnerException } return $false } # Check the directory and all of its parents so a configured path cannot enter # a junction or symbolic link while resolving a collection root. function Test-ReparsePath { param([System.IO.DirectoryInfo]$Directory) while ($null -ne $Directory) { if ($Directory.Exists -and ($Directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { return $true } $Directory = $Directory.Parent } return $false } # Copy only the bytes present when the source stream is opened. Sharing for # reads and deletes allows ordinary log writers to continue while we collect. # With -Archive, the ZIP entry is created only after the source was opened, so # a file that vanished in the meantime does not leave an empty archive entry. function Copy-LogSnapshot { param([System.IO.FileInfo]$Source, [string]$Target, $Archive, [string]$EntryName) $inputStream = $null $outputStream = $null try { $sharing = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete $inputStream = New-Object System.IO.FileStream($Source.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, $sharing) $remaining = $inputStream.Length if ($null -ne $Archive) { $outputStream = $Archive.CreateEntry($EntryName).Open() } else { $outputStream = New-Object System.IO.FileStream($Target, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) } $buffer = New-Object byte[] 65536 while ($remaining -gt 0) { $read = $inputStream.Read($buffer, 0, [int][Math]::Min($buffer.Length, $remaining)) if ($read -eq 0) { throw 'Source was truncated during collection.' } $outputStream.Write($buffer, 0, $read) $remaining -= $read } } finally { if ($null -ne $outputStream) { $outputStream.Dispose() } if ($null -ne $inputStream) { $inputStream.Dispose() } } } # Expand environment variables and wildcard directory patterns into existing # local directory roots; absent paths are normal during OOBE. function Get-DirectoryRoots { param([string]$Location, [string]$Label) if ([string]::IsNullOrEmpty($Location) -or $Location.Trim().Length -eq 0) { Write-CollectionError ('Empty {0} location skipped.' -f $Label) return } $expanded = [Environment]::ExpandEnvironmentVariables($Location) # An undefined variable (e.g. a user-only variable under SYSTEM) means the # location does not exist in this context; skip it without failing. if ($expanded -match '%[^%\\]+%') { Write-CollectorLog ('Location skipped, environment variable not defined: {0}' -f $Location) 'WARN' return } if ($expanded -notmatch '^[A-Za-z]:\\') { Write-CollectionError ('{0} location skipped, it must be an absolute local directory: {1}' -f $Label, $Location) return } Write-CollectorLog ('Searching {0} directory pattern: {1}' -f $Label, $expanded) try { $roots = @(Get-Item -Path $expanded -Force -ErrorAction Stop) } catch { if (Test-MissingPathError $_.Exception) { Write-CollectorLog ('Location not present: {0}' -f $expanded) 'WARN' return } $script:CollectionErrors++ Write-CollectorLog ('Cannot resolve {0}: {1}' -f $expanded, $_.Exception.Message) 'ERROR' return } if ($roots.Count -eq 0) { Write-CollectorLog ('No directories match: {0}' -f $expanded) 'WARN' } foreach ($root in $roots) { if ($root -isnot [System.IO.DirectoryInfo]) { Write-CollectionError ('Location matched a file and is skipped; supply directory patterns: {0}' -f $root.FullName) continue } if (Test-ReparsePath $root) { Write-CollectorLog ('Skipping reparse-point location: {0}' -f $root.FullName) 'WARN' continue } Write-Output $root } } # Exclude prior collector snapshots and the active collector log when a search # location overlaps the destination directory. function Test-CollectorOutput { param([System.IO.FileInfo]$File) return ($File.DirectoryName.TrimEnd('\') -ieq $destination.TrimEnd('\') -and $File.Name.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) } # Derive a stable staging name from the full source path. This makes repeated # runs refresh the same source while hashes keep equal file names distinct. # $SourceKey identifies the source (a file path, or "REGISTRY::" + key name). function Get-StagedPath { param([string]$SourceKey, [string]$Name, [string]$Suffix) $hashBytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($SourceKey.ToUpperInvariant())) $hash = [BitConverter]::ToString($hashBytes).Replace('-', '') $name = $Name if ([string]::IsNullOrEmpty($name) -or $name -match '[\\/:*?"<>|]') { $name = 'root' } if ($name.Length -gt 80) { $name = $name.Substring(0, 80) } return [System.IO.Path]::Combine($destination, $prefix + $hash + '-' + $name + $Suffix) } # Normalize one extension so matching is case-insensitive and accepts values # such as "log", ".log" or "*.log". '*' means all extensions. function ConvertTo-ExtensionKey { param([string]$Extension) if ([string]::IsNullOrEmpty($Extension) -or $Extension.Trim().Length -eq 0) { throw 'Empty file extension is not allowed.' } $normalized = $Extension.Trim().ToLowerInvariant() if ($normalized -eq '*') { return $normalized } $normalized = $normalized -replace '^\*?\.', '' if ($normalized -notmatch '^[a-z0-9_-]+$') { throw ('Invalid extension: {0}' -f $Extension) } return '.' + $normalized } # Walk directories with an explicit stack (compatible with older PowerShell), # de-duplicate overlapping roots, and optionally filter by normalized extension. # A file matches the general FileExtensions or the location's own additional # extensions ($LocationExtensions); matches are counted per extension in the # general or the location counter so unmatched extensions can be reported. # Directories are de-duplicated per extension set ($VisitKey), so a location # with additional extensions still walks a directory already searched by a # location with fewer extensions; $visitedFiles prevents duplicate copies. function Copy-DirectorySnapshots { param([System.IO.DirectoryInfo[]]$Roots, [switch]$AllFiles, [hashtable]$LocationExtensions = @{}, [hashtable]$LocationMatches = @{}, [string]$VisitKey = '') $pending = New-Object System.Collections.Stack foreach ($root in $Roots) { $pending.Push($root) } if ($AllFiles) { $VisitKey = '*' } while ($pending.Count -gt 0) { $directory = $pending.Pop() $directoryKey = $VisitKey + '|' + $directory.FullName if ($visitedDirectories.ContainsKey($directoryKey)) { continue } $visitedDirectories[$directoryKey] = $true try { $entries = $directory.GetFileSystemInfos() } catch { # A directory removed after it was discovered is not an error. if (Test-MissingPathError $_.Exception) { Write-CollectorLog ('Directory disappeared before enumeration: {0}' -f $directory.FullName) 'WARN' continue } $script:CollectionErrors++ Write-CollectorLog ('Cannot enumerate {0}: {1}' -f $directory.FullName, $_.Exception.Message) 'ERROR' continue } foreach ($entry in $entries) { if (($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { Write-CollectorLog ('Skipping reparse point: {0}' -f $entry.FullName) 'WARN' continue } if ($entry -is [System.IO.DirectoryInfo]) { # Do not recurse through links; this avoids loops and prevents # collection from escaping the configured directory tree. $pending.Push($entry) continue } if (Test-CollectorOutput $entry) { continue } if (-not $AllFiles -and -not $extensions.ContainsKey('*')) { $entryExtension = $entry.Extension.ToLowerInvariant() if ($extensions.ContainsKey($entryExtension)) { $extensionMatches[$entryExtension]++ } elseif ($LocationExtensions.ContainsKey('*')) { } elseif ($LocationExtensions.ContainsKey($entryExtension)) { $LocationMatches[$entryExtension]++ } else { continue } } if ($visitedFiles.ContainsKey($entry.FullName)) { continue } $visitedFiles[$entry.FullName] = $true $target = Get-StagedPath $entry.FullName $entry.Name '.log' try { $targetInfo = New-Object System.IO.FileInfo($target) if ($targetInfo.Exists -and ($targetInfo.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw 'Target must not be a reparse point.' } Copy-LogSnapshot $entry $target $script:copied++ Write-CollectorLog ('COPIED "{0}" -> "{1}"' -f $entry.FullName, $target) } catch { # A source deleted between enumeration and copy (e.g. rotated # logs) is skipped. The source is opened first, so no target exists. if (Test-MissingPathError $_.Exception) { Write-CollectorLog ('Source disappeared before copy: {0}' -f $entry.FullName) 'WARN' continue } $script:CollectionErrors++ Write-CollectorLog ('FAILED "{0}" -> "{1}": {2}. A partial target may remain; do not treat it as a complete log.' -f $entry.FullName, $target, $_.Exception.Message) 'ERROR' } } } } # Build a ZIP at a temporary path and publish it only after every entry was # read successfully. If archiving fails, the caller can fall back to file copies. function Compress-DirectorySnapshot { param([System.IO.DirectoryInfo]$Root) $target = Get-StagedPath $Root.FullName $Root.Name '.zip.log' $temporary = $target + '.' + [Guid]::NewGuid().ToString('N') + '.tmp' $backup = $temporary + '.bak' $stream = $null $archive = $null $created = $false $backupCreated = $false $fileCount = 0 $initialErrors = $script:CollectionErrors try { # Keep the temporary archive beside the final archive so the final move # or replace remains on the same volume. $stream = New-Object System.IO.FileStream($temporary, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) $created = $true $archive = New-Object System.IO.Compression.ZipArchive($stream, [System.IO.Compression.ZipArchiveMode]::Create, $true) $pending = New-Object System.Collections.Stack $pending.Push($Root) $rootPath = $Root.FullName.TrimEnd('\') while ($pending.Count -gt 0) { $directory = $pending.Pop() try { $entries = $directory.GetFileSystemInfos() } catch { if (Test-MissingPathError $_.Exception) { Write-CollectorLog ('Directory disappeared before enumeration: {0}' -f $directory.FullName) 'WARN' continue } $script:CollectionErrors++ Write-CollectorLog ('Cannot enumerate {0}: {1}' -f $directory.FullName, $_.Exception.Message) 'ERROR' continue } foreach ($entry in $entries) { if (($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { Write-CollectorLog ('Skipping reparse point: {0}' -f $entry.FullName) 'WARN' continue } $relative = $entry.FullName.Substring($rootPath.Length).TrimStart('\').Replace('\', '/') if ($entry -is [System.IO.DirectoryInfo]) { # Explicit entries preserve empty directories in the ZIP. [void]$archive.CreateEntry($relative + '/') $pending.Push($entry) continue } if (Test-CollectorOutput $entry) { continue } try { Copy-LogSnapshot -Source $entry -Archive $archive -EntryName $relative $fileCount++ } catch { if (Test-MissingPathError $_.Exception) { Write-CollectorLog ('Source disappeared before archiving: {0}' -f $entry.FullName) 'WARN' continue } $script:CollectionErrors++ Write-CollectorLog ('FAILED archive entry "{0}": {1}' -f $entry.FullName, $_.Exception.Message) 'ERROR' } } } $archive.Dispose() $archive = $null $stream.Dispose() $stream = $null if ($script:CollectionErrors -ne $initialErrors) { Write-CollectorLog ('Archive not staged for {0}; a previous archive may remain.' -f $Root.FullName) 'WARN' return $false } $targetInfo = New-Object System.IO.FileInfo($target) if ($targetInfo.Exists -and ($targetInfo.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw 'Archive target must not be a reparse point.' } if ($targetInfo.Exists) { if ([System.IO.File]::Exists($backup)) { throw 'Archive backup path already exists.' } # Preserve the last successful archive until its replacement is # complete; the backup is removed in finally after publication. [System.IO.File]::Replace($temporary, $target, $backup) $backupCreated = $true } else { [System.IO.File]::Move($temporary, $target) } $created = $false $script:archives++ $script:archivedFiles += $fileCount Write-CollectorLog ('ARCHIVED {0} files from "{1}" -> "{2}"' -f $fileCount, $Root.FullName, $target) return $true } catch { $script:CollectionErrors++ Write-CollectorLog ('FAILED archive for "{0}": {1}. A previous archive may remain.' -f $Root.FullName, $_.Exception.Message) 'ERROR' return $false } finally { if ($null -ne $archive) { try { $archive.Dispose() } catch { $script:CollectionErrors++; Write-CollectorLog ('Cannot close archive: {0}' -f $_.Exception.Message) 'ERROR' } } if ($null -ne $stream) { try { $stream.Dispose() } catch { $script:CollectionErrors++; Write-CollectorLog ('Cannot close archive file: {0}' -f $_.Exception.Message) 'ERROR' } } if ($created -and [System.IO.File]::Exists($temporary)) { try { [System.IO.File]::Delete($temporary) } catch { $script:CollectionErrors++; Write-CollectorLog ('Cannot remove incomplete archive {0}: {1}' -f $temporary, $_.Exception.Message) 'ERROR' } } if ($backupCreated -and [System.IO.File]::Exists($backup)) { try { [System.IO.File]::Delete($backup) } catch { $script:CollectionErrors++; Write-CollectorLog ('Cannot remove archive backup {0}: {1}' -f $backup, $_.Exception.Message) 'ERROR' } } } } # Supported registry hives. Aliases map short, long and drive notation to the # native hive name used in .reg files; @{} lookups are case-insensitive. $registryHives = @{ 'HKEY_LOCAL_MACHINE' = [Microsoft.Win32.Registry]::LocalMachine 'HKEY_CURRENT_USER' = [Microsoft.Win32.Registry]::CurrentUser 'HKEY_CLASSES_ROOT' = [Microsoft.Win32.Registry]::ClassesRoot 'HKEY_USERS' = [Microsoft.Win32.Registry]::Users 'HKEY_CURRENT_CONFIG' = [Microsoft.Win32.Registry]::CurrentConfig } $registryAliases = @{ 'HKLM' = 'HKEY_LOCAL_MACHINE'; 'HKEY_LOCAL_MACHINE' = 'HKEY_LOCAL_MACHINE' 'HKCU' = 'HKEY_CURRENT_USER'; 'HKEY_CURRENT_USER' = 'HKEY_CURRENT_USER' 'HKCR' = 'HKEY_CLASSES_ROOT'; 'HKEY_CLASSES_ROOT' = 'HKEY_CLASSES_ROOT' 'HKU' = 'HKEY_USERS'; 'HKEY_USERS' = 'HKEY_USERS' 'HKCC' = 'HKEY_CURRENT_CONFIG'; 'HKEY_CURRENT_CONFIG' = 'HKEY_CURRENT_CONFIG' } # Normalize "HKLM\X", "HKLM:\X", "HKEY_LOCAL_MACHINE\X" or "Registry::..." to # the native key name "HKEY_LOCAL_MACHINE\X". Invalid input is a config error. function ConvertTo-RegistryKeyName { param([string]$Location) if ([string]::IsNullOrEmpty($Location) -or $Location.Trim().Length -eq 0) { throw 'Empty registry location is not allowed.' } $match = [regex]::Match($Location.Trim(), '^(?:(?:Microsoft\.PowerShell\.Core\\)?Registry::)?([A-Za-z_]+):?(?:\\(.*))?$', 'IgnoreCase') if (-not $match.Success -or -not $registryAliases.ContainsKey($match.Groups[1].Value)) { throw ('Registry location must start with HKLM, HKCU, HKCR, HKU or HKCC: {0}' -f $Location) } $keyName = $registryAliases[$match.Groups[1].Value] $relative = $match.Groups[2].Value.Trim([char]'\') if ($relative.Length -gt 0) { $keyName += '\' + $relative } return $keyName } # Resolve one normalized key name (optionally with wildcards) into existing # key names. Missing keys are expected during OOBE and logged as warnings. function Get-RegistryRoots { param([string]$KeyName) Write-CollectorLog ('Searching registry key pattern: {0}' -f $KeyName) $separator = $KeyName.IndexOf('\') if ($separator -lt 0) { Write-Output $KeyName; return } $hive = $registryHives[$KeyName.Substring(0, $separator)] $relative = $KeyName.Substring($separator + 1) if ($relative -notmatch '[*?\[\]]') { $key = $null try { $key = $hive.OpenSubKey($relative, $false) if ($null -eq $key) { Write-CollectorLog ('Registry key not present: {0}' -f $KeyName) 'WARN' } else { Write-Output $key.Name } } catch { $script:CollectionErrors++ Write-CollectorLog ('Cannot open registry key {0}: {1}' -f $KeyName, $_.Exception.Message) 'ERROR' } finally { if ($null -ne $key) { $key.Close() } } return } # The registry provider expands wildcards; unreadable parents are reported # individually instead of stopping the remaining collection. $resolveErrors = $null $items = @(Get-Item -Path ('Registry::' + $KeyName) -Force -ErrorAction SilentlyContinue -ErrorVariable resolveErrors) foreach ($resolveError in @($resolveErrors)) { if (Test-MissingPathError $resolveError.Exception) { Write-CollectorLog ('Registry key not present: {0}' -f $resolveError.Exception.Message) 'WARN' } else { $script:CollectionErrors++ Write-CollectorLog ('Cannot resolve registry pattern {0}: {1}' -f $KeyName, $resolveError.Exception.Message) 'ERROR' } } $names = @() foreach ($item in $items) { if ($item -is [Microsoft.Win32.RegistryKey]) { $names += $item.Name $item.Close() } } if ($names.Count -eq 0) { Write-CollectorLog ('No registry keys match: {0}' -f $KeyName) 'WARN' } foreach ($name in $names) { Write-Output $name } } # Escape a string for a quoted .reg value or value name. function ConvertTo-RegFileString { param([string]$Text) return $Text.Replace('\', '\\').Replace('"', '\"') } # Format bytes as comma-separated lowercase hex, as written by regedit. function ConvertTo-RegFileHex { param([byte[]]$Bytes) if ($null -eq $Bytes -or $Bytes.Length -eq 0) { return '' } return [BitConverter]::ToString($Bytes).Replace('-', ',').ToLowerInvariant() } # Return the .reg line for one value, or $null if the value disappeared. # REG_EXPAND_SZ is read unexpanded so %VARIABLES% are preserved. function ConvertTo-RegFileValue { param([Microsoft.Win32.RegistryKey]$Key, [string]$ValueName) $value = $Key.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($null -eq $value) { return $null } $kind = $Key.GetValueKind($ValueName) if ($ValueName.Length -eq 0) { $name = '@' } else { $name = '"' + (ConvertTo-RegFileString $ValueName) + '"' } $unicode = [System.Text.Encoding]::Unicode if ($kind -eq [Microsoft.Win32.RegistryValueKind]::String) { return $name + '="' + (ConvertTo-RegFileString ([string]$value)) + '"' } if ($kind -eq [Microsoft.Win32.RegistryValueKind]::DWord) { return $name + '=dword:' + ([int]$value).ToString('x8') } if ($kind -eq [Microsoft.Win32.RegistryValueKind]::QWord) { return $name + '=hex(b):' + (ConvertTo-RegFileHex ([BitConverter]::GetBytes([long]$value))) } if ($kind -eq [Microsoft.Win32.RegistryValueKind]::ExpandString) { return $name + '=hex(2):' + (ConvertTo-RegFileHex $unicode.GetBytes([string]$value + [char]0)) } if ($kind -eq [Microsoft.Win32.RegistryValueKind]::MultiString) { $strings = [string[]]$value $text = [string]::Join([string][char]0, $strings) + [char]0 if ($strings.Length -gt 0) { $text += [char]0 } return $name + '=hex(7):' + (ConvertTo-RegFileHex $unicode.GetBytes($text)) } if ($kind -eq [Microsoft.Win32.RegistryValueKind]::Binary) { return $name + '=hex:' + (ConvertTo-RegFileHex ([byte[]]$value)) } if ($kind -eq [Microsoft.Win32.RegistryValueKind]::None) { return $name + '=hex(0):' + (ConvertTo-RegFileHex ([byte[]]$value)) } # .NET does not expose the numeric type of other kinds (e.g. REG_RESOURCE_LIST). return '; Original value type unknown, exported as binary' + [Environment]::NewLine + $name + '=hex:' + (ConvertTo-RegFileHex ([byte[]]$value)) } # Export one key, its values and all subkeys (depth-first, parents first, # sorted) to a staged .reg.log file. Keys are only opened read-only. function Export-RegistrySnapshot { param([string]$KeyName) $separator = $KeyName.IndexOf('\') if ($separator -lt 0) { $hiveName = $KeyName; $rootPath = '' } else { $hiveName = $KeyName.Substring(0, $separator); $rootPath = $KeyName.Substring($separator + 1) } $hive = $registryHives[$hiveName] $leaf = $KeyName.Substring($KeyName.LastIndexOf('\') + 1) -replace '[\\/:*?"<>|]', '_' $target = Get-StagedPath ('REGISTRY::' + $KeyName) $leaf '.reg.log' $writer = $null $keyCount = 0 $initialErrors = $script:CollectionErrors try { $targetInfo = New-Object System.IO.FileInfo($target) if ($targetInfo.Exists -and ($targetInfo.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw 'Target must not be a reparse point.' } # regedit writes version 5 files as UTF-16 LE with BOM. $writer = New-Object System.IO.StreamWriter($target, $false, [System.Text.Encoding]::Unicode) $writer.WriteLine('Windows Registry Editor Version 5.00') $writer.WriteLine() $pending = New-Object System.Collections.Stack $pending.Push($rootPath) while ($pending.Count -gt 0) { $relative = [string]$pending.Pop() $fullName = $hiveName if ($relative.Length -gt 0) { $fullName += '\' + $relative } $key = $null try { if ($relative.Length -eq 0) { $key = $hive } else { $key = $hive.OpenSubKey($relative, $false) } if ($null -eq $key) { Write-CollectorLog ('Registry key disappeared before export: {0}' -f $fullName) 'WARN' continue } $writer.WriteLine('[' + $fullName + ']') foreach ($valueName in @($key.GetValueNames() | Sort-Object)) { try { $line = ConvertTo-RegFileValue $key $valueName if ($null -eq $line) { Write-CollectorLog ('Registry value disappeared before export: {0}\{1}' -f $fullName, $valueName) 'WARN' } else { $writer.WriteLine($line) } } catch { $script:CollectionErrors++ Write-CollectorLog ('Cannot read registry value {0}\{1}: {2}' -f $fullName, $valueName, $_.Exception.Message) 'ERROR' $writer.WriteLine('; FAILED to read value "' + $valueName + '": ' + $_.Exception.Message) } } $writer.WriteLine() $keyCount++ # Push children in reverse order so they are exported sorted. $children = @($key.GetSubKeyNames() | Sort-Object) for ($i = $children.Count - 1; $i -ge 0; $i--) { if ($relative.Length -eq 0) { $pending.Push($children[$i]) } else { $pending.Push($relative + '\' + $children[$i]) } } } catch { $script:CollectionErrors++ Write-CollectorLog ('Cannot export registry key {0}: {1}' -f $fullName, $_.Exception.Message) 'ERROR' $writer.WriteLine('; FAILED to export [' + $fullName + ']: ' + $_.Exception.Message) $writer.WriteLine() } finally { if ($null -ne $key -and $relative.Length -gt 0) { $key.Close() } } } $script:registryExports++ $script:registryKeys += $keyCount if ($script:CollectionErrors -ne $initialErrors) { Write-CollectorLog ('Registry export of {0} is incomplete; see ";" comments in "{1}".' -f $KeyName, $target) 'WARN' } Write-CollectorLog ('EXPORTED {0} registry keys from "{1}" -> "{2}"' -f $keyCount, $KeyName, $target) } catch { $script:CollectionErrors++ Write-CollectorLog ('FAILED registry export "{0}" -> "{1}": {2}' -f $KeyName, $target, $_.Exception.Message) 'ERROR' } finally { if ($null -ne $writer) { $writer.Dispose() } } } $exitCode = 1 $copied = 0 $archives = 0 $archivedFiles = 0 $registryExports = 0 $registryKeys = 0 $visitedDirectories = @{} $visitedFiles = @{} $extensionMatches = @{} $sha = $null try { # Validate the local staging destination before creating any output. Intune # collects Windows Temp automatically; other paths are useful only for tests. $destination = [Environment]::ExpandEnvironmentVariables($DestinationDirectory) if ($destination -notmatch '^[A-Za-z]:\\' -or $destination -match '[*?\[\]]') { throw 'DestinationDirectory must be an absolute local directory without wildcards.' } $destination = [System.IO.Path]::GetFullPath($destination) $destinationInfo = New-Object System.IO.DirectoryInfo($destination) if (Test-ReparsePath $destinationInfo) { throw 'Destination must not traverse a reparse point.' } [void][System.IO.Directory]::CreateDirectory($destination) if ([string]::IsNullOrEmpty($env:COMPUTERNAME)) { throw 'COMPUTERNAME is not set.' } $prefix = $env:COMPUTERNAME + '-IntuneDiag-' $logPath = [System.IO.Path]::Combine($destination, $prefix + 'Collector.log') $logInfo = New-Object System.IO.FileInfo($logPath) if ($logInfo.Exists -and ($logInfo.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw 'Collector log must not be a reparse point.' } $utf8 = New-Object System.Text.UTF8Encoding($true) $script:CollectorLog = New-Object System.IO.StreamWriter($logPath, $false, $utf8) Write-CollectorLog ('Starting v{0}; PowerShell={1}; user={2}; destination={3}' -f $scriptVersion, $PSVersionTable.PSVersion, [Environment]::UserName, $destination) # Normalize the general extensions once; they apply to every search location. # An invalid entry is logged and skipped; the remaining entries still apply. $extensions = @{} if ($null -eq $FileExtensions -or $FileExtensions.Count -eq 0) { Write-CollectorLog 'FileExtensions is empty; only location-specific extensions are searched.' 'WARN' } foreach ($extension in @($FileExtensions)) { try { $normalized = ConvertTo-ExtensionKey $extension $extensions[$normalized] = $true $extensionMatches[$normalized] = 0 } catch { Write-CollectionError ('File extension skipped: {0}' -f $_.Exception.Message) } } if ($null -eq $SearchLocations -or $SearchLocations.Count -eq 0) { Write-CollectorLog 'SearchLocations is empty; no extension-filtered search runs.' 'WARN' } # Split "path|ext1,ext2" entries. Invalid entries or additional extensions # are logged and skipped, so one typo does not stop the other locations. $searchEntries = @() foreach ($location in @($SearchLocations)) { if ([string]::IsNullOrEmpty($location) -or $location.Trim().Length -eq 0) { Write-CollectionError 'Empty search location skipped.' continue } $parts = $location.Split([char]'|') if ($parts.Length -gt 2) { Write-CollectionError ('Search location skipped, it may contain only one "|" separator: {0}' -f $location) continue } $locationExtensions = @{} if ($parts.Length -eq 2) { foreach ($extension in $parts[1].Split([char]',')) { try { $normalized = ConvertTo-ExtensionKey $extension # Extensions already covered by FileExtensions need no extra handling. if (-not $extensions.ContainsKey($normalized)) { $locationExtensions[$normalized] = $true } } catch { Write-CollectionError ('Additional extension for {0} skipped: {1}' -f $parts[0].Trim(), $_.Exception.Message) } } } $searchEntries += , @($parts[0].Trim(), $locationExtensions) } # Normalize registry locations; an invalid hive name skips only that entry. $registryKeyNames = @() foreach ($location in @($RegistryLocations)) { try { $registryKeyNames += ConvertTo-RegistryKeyName $location } catch { Write-CollectionError ('Registry location skipped: {0}' -f $_.Exception.Message) } } $sha = [System.Security.Cryptography.SHA256]::Create() $archiveAvailable = $false # Windows PowerShell may not have loaded the compression assembly yet. # If ZIP support cannot be initialized, collect the same files individually. if ($null -ne $FullDirectoryLocations -and $FullDirectoryLocations.Count -gt 0 -and -not $DisableDirectoryArchive) { try { if (-not ('System.IO.Compression.ZipArchive' -as [type])) { Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop } if (-not ('System.IO.Compression.ZipArchive' -as [type])) { throw 'ZipArchive type is unavailable.' } $archiveAvailable = $true } catch { Write-CollectorLog ('ZIP support is unavailable; full-directory files will be staged individually: {0}' -f $_.Exception.Message) 'WARN' } } # Full-directory requests are handled first and deduplicated by resolved # root; filtered searches may additionally stage individual matching files. $fullRoots = @{} foreach ($location in @($FullDirectoryLocations)) { # Unexpected failures are contained per location so the rest continues. try { foreach ($root in @(Get-DirectoryRoots $location 'full-directory')) { if ($fullRoots.ContainsKey($root.FullName)) { continue } $fullRoots[$root.FullName] = $true $archived = $false if ($archiveAvailable) { $archived = Compress-DirectorySnapshot $root } if (-not $archived) { Write-CollectorLog ('Staging full-directory files individually: {0}' -f $root.FullName) Copy-DirectorySnapshots -Roots @($root) -AllFiles } } } catch { Write-CollectionError ('Full-directory location {0} failed, continuing: {1}' -f $location, $_.Exception.Message) } } # Run extension-filtered searches after full-directory collection. Shared # visited-file tracking prevents copies repeated across overlapping roots. foreach ($searchEntry in $searchEntries) { $path = $searchEntry[0] try { $locationExtensions = $searchEntry[1] $locationMatches = @{} foreach ($extension in $locationExtensions.Keys) { $locationMatches[$extension] = 0 } $visitKey = (@($locationExtensions.Keys | Sort-Object) -join ',') if ($locationExtensions.Count -gt 0 -and -not $extensions.ContainsKey('*')) { Write-CollectorLog ('Additional extensions for {0}: {1}' -f $path, $visitKey) } $roots = @(Get-DirectoryRoots $path 'search') Copy-DirectorySnapshots -Roots $roots -LocationExtensions $locationExtensions -LocationMatches $locationMatches -VisitKey $visitKey # Report location-specific extensions that matched nothing; warning only. if ($roots.Count -gt 0 -and -not $extensions.ContainsKey('*') -and -not $locationExtensions.ContainsKey('*')) { foreach ($extension in @($locationMatches.Keys | Sort-Object)) { if ($locationMatches[$extension] -eq 0) { Write-CollectorLog ('No files found with additional extension {0} in {1}.' -f $extension, $path) 'WARN' } } } } catch { Write-CollectionError ('Search location {0} failed, continuing: {1}' -f $path, $_.Exception.Message) } } # Export registry keys after the file collection. Duplicate matches from # overlapping patterns are exported once. $registryRootNames = @{} foreach ($keyName in $registryKeyNames) { try { foreach ($rootName in @(Get-RegistryRoots $keyName)) { if ($registryRootNames.ContainsKey($rootName)) { continue } $registryRootNames[$rootName] = $true Export-RegistrySnapshot $rootName } } catch { Write-CollectionError ('Registry location {0} failed, continuing: {1}' -f $keyName, $_.Exception.Message) } } # Report each configured extension that matched no file in any search # location. This is informational only and does not affect the exit code. if (-not $extensions.ContainsKey('*')) { foreach ($extension in @($extensionMatches.Keys | Sort-Object)) { if ($extensionMatches[$extension] -eq 0) { Write-CollectorLog ('No files found with extension {0} in the search locations.' -f $extension) 'WARN' } } } # No matches is a successful, explicitly logged outcome; only collection # errors should make an Intune remediation report failure. if ($copied -eq 0 -and $archivedFiles -eq 0 -and $registryExports -eq 0) { Write-CollectorLog 'No matching files were copied.' 'WARN' } Write-CollectorLog ('Finished; copied={0}; archives={1}; archivedFiles={2}; registryExports={3}; registryKeys={4}; errors={5}. Older snapshots of missing sources are retained.' -f $copied, $archives, $archivedFiles, $registryExports, $registryKeys, $script:CollectionErrors) $exitCode = 0 if ($script:CollectionErrors -gt 0) { $exitCode = 1 } } catch { $script:CollectionErrors++ if ($null -ne $script:CollectorLog) { Write-CollectorLog $_.Exception.Message 'ERROR' } [Console]::Error.WriteLine(('Collection failed: {0}' -f $_.Exception.Message)) } finally { if ($null -ne $sha) { $sha.Dispose() } if ($null -ne $script:CollectorLog) { $script:CollectorLog.Dispose() } } Write-Output ('Intune log collection: copied={0}; archives={1}; archivedFiles={2}; registryExports={3}; registryKeys={4}; errors={5}; exit={6}; collector log={7}' -f $copied, $archives, $archivedFiles, $registryExports, $registryKeys, $script:CollectionErrors, $exitCode, $logPath) exit $exitCode |