#
.SYNOPSIS
Collects a complete diagnostics package from an Intune-managed Windows device.
.DESCRIPTION
Mimics the Intune "Collect diagnostics" action and extends it with:
- MDM logs via mdmdiagnosticstool.exe (all registered areas)
- Relevant Event Logs (MDM, Entra/AAD, Device Registration, ESP/Shell-Core)
- Registry exports (Enrollments, PolicyManager, IME, Autopilot, GPO policies)
- Identity status (dsregcmd, machine AND interactive-user context), certificates, network info
- Intune Management Extension (IME) logs
- Defender support files, Windows Update logs, system reports
- Co-management, Defender for Endpoint onboarding, Delivery Optimization state
- Disk space, time sync, TPM status
- Status of relevant services and scheduled tasks
Result: a single zip file in C:\Temp (or a custom path), plus a
_MANIFEST.json describing the run (version, profile, per-step outcome).
.PARAMETER OutputPath
Folder where the zip file will be created. Default: C:\Temp
.PARAMETER Remote
Slim profile for unattended/Intune use: skips the slow and large sections
(msinfo32, Get-WindowsUpdateLog, Defender -GetFiles cab, full-range event
log export, full mdmdiagnosticstool area zip) so the run stays well under
the Intune script timeout and the Sherlog upload size limit, while keeping
the IME logs, event logs (last 14 days), registry, identity and network data.
.PARAMETER UploadUrl
When set, the resulting zip is uploaded to this Sherlog drop-off endpoint,
e.g. https://sherlog.nl/api/diagnostics . Requires -UploadToken. Must be
https:// - a plain http:// URL is refused so the token is never sent in
cleartext.
.PARAMETER UploadToken
The self-chosen secret the admin generated on the Sherlog /inbox page. It
authorizes the upload and is the key to view the uploads at /inbox. It is
always redacted from every collected text file (including the transcript,
which PowerShell stamps with the full command line it was invoked with),
regardless of -Anonymize.
With -UploadUrl and -UploadToken set, the script also pings
/api/collect-status at the start of the run and again if it knows the
run failed, so the inbox can show the device as "collecting" instead of
staying empty for the minutes the collection takes. The ping carries only
the phase, the profile, the collector version and the same (optionally
anonymized) device label as the upload - never log content.
.PARAMETER MaxUploadMB
Client-side size guard matched against the server's MAX_UPLOAD_MB (default
100). A package over this size is not uploaded (it would be rejected with
413 anyway); the local zip is kept.
.PARAMETER Proxy
Explicit proxy URL for the upload (e.g. http://proxy.contoso.com:8080). If
omitted, the script tries to auto-detect one from `netsh winhttp show
proxy`, since a SYSTEM-context run has no per-user WinINET proxy settings
and would otherwise fail on any proxy-only network.
.PARAMETER Anonymize
Best-effort redaction of tenant and company data from the package: tenant id,
tenant/company name, domain(s), UPN/e-mail, device name and user name are
replaced with placeholders in all TEXT files, and the zip name + upload device
name are anonymized. Well-known system principals (SYSTEM, NT AUTHORITY, ...)
are never redacted, since doing so would corrupt registry paths like
HKEY_LOCAL_MACHINE\SYSTEM\... and break Sherlog's SYSTEM-context detection.
This is best-effort, NOT a guarantee: binary files (event logs .evtx, Defender
.cab, the nested mdmdiag .zip) are NOT scrubbed and may still contain
identifiers - review the package before sharing.
.EXAMPLE
.\Collect-IntuneDiagnostics.ps1
.\Collect-IntuneDiagnostics.ps1 -OutputPath D:\Diag
.EXAMPLE
# Share-safe, best-effort anonymized package:
.\Collect-IntuneDiagnostics.ps1 -Remote -Anonymize
.EXAMPLE
# Unattended drop-off (e.g. from an Intune remediation script):
.\Collect-IntuneDiagnostics.ps1 -Remote `
-UploadUrl 'https://sherlog.nl/api/diagnostics' -UploadToken ''
.NOTES
Run as Administrator (elevated PowerShell), or as SYSTEM via Intune.
#>
[CmdletBinding()]
param(
[string]$OutputPath = 'C:\Temp',
[switch]$Remote,
[string]$UploadUrl,
[string]$UploadToken,
[int]$MaxUploadMB = 100,
[string]$Proxy,
[switch]$Anonymize
)
$ScriptVersion = '1.3.1'
# ============================================================
# 0. Preparation
# ============================================================
# Admin check
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Error 'This script must be run as Administrator. Start an elevated PowerShell session and try again.'
exit 1
}
# Best-effort environment warnings; none of these are fatal, since a degraded
# collection is still more useful than none.
if (-not [Environment]::Is64BitProcess -and [Environment]::Is64BitOperatingSystem) {
Write-Warning 'Running in 32-bit PowerShell on a 64-bit OS; some paths (e.g. mdmdiagnosticstool.exe) may resolve incorrectly. Re-run in 64-bit PowerShell.'
}
if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') {
Write-Warning "PowerShell is running in $($ExecutionContext.SessionState.LanguageMode) mode; some collection steps (JSON export, .NET types) may fail under WDAC/CLM restrictions."
}
try {
$driveLetter = $OutputPath.Substring(0, 1)
$vol = Get-Volume -DriveLetter $driveLetter -ErrorAction Stop
if ($vol.SizeRemaining -lt 500MB) {
Write-Warning "Less than 500 MB free on ${driveLetter}: - collection may fail."
}
} catch {}
# .NET-formatted output (event level names, error messages) follows this
# thread culture; native console tools (netsh, certutil) still follow the OS
# display language regardless and are NOT made English by this.
try {
[Threading.Thread]::CurrentThread.CurrentUICulture = [Globalization.CultureInfo]::GetCultureInfo('en-US')
[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::GetCultureInfo('en-US')
} catch {}
$ProgressPreference = 'SilentlyContinue' # large -InFile uploads/copies stay fast on PS 5.1
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
$startedUtc = [DateTime]::UtcNow
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
# Device label for the zip name and upload header. Anonymized to a stable,
# non-identifying hash of the computer name when -Anonymize is set, so the
# filename and inbox don't leak the hostname.
$deviceLabel = $env:COMPUTERNAME
if ($Anonymize) {
$h = [System.Security.Cryptography.SHA256]::Create().ComputeHash(
[Text.Encoding]::UTF8.GetBytes("$env:COMPUTERNAME"))
$deviceLabel = 'anon-' + (-join ($h[0..3] | ForEach-Object { $_.ToString('x2') }))
}
$work = Join-Path $OutputPath "IntuneDiag-$deviceLabel-$timestamp"
$zipFile = "$work.zip"
# TLS 1.2 for every outbound call (the status ping below runs long before the
# upload does; PS 5.1 still defaults to TLS 1.0 on older builds).
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
function Get-SherlogProxy {
# Auto-detect a configured WinHTTP proxy (SYSTEM has no per-user WinINET
# settings, so Invoke-RestMethod would otherwise go direct and fail on any
# proxy-only network) unless one was passed explicitly.
if ($Proxy) { return $Proxy }
try {
$proxyShow = netsh winhttp show proxy 2>$null
$m = $proxyShow | Select-String 'Proxy Server\(s\)\s*:\s*(\S+)'
if ($m -and $m.Matches.Count -gt 0) { return 'http://' + $m.Matches[0].Groups[1].Value }
} catch {}
return $null
}
function Send-SherlogPing {
# Collection takes minutes; without this the inbox stays empty until the
# zip lands and the admin cannot tell "still running" from "never started".
# Best-effort by design: short timeout, no retries, every failure swallowed
# - a status ping must never break or slow down the collection itself.
param([ValidateSet('start','failed')][string]$Phase, [string]$Reason)
if (-not $UploadUrl -or -not $UploadToken) { return }
if ($UploadToken -eq '') { return }
if ($UploadUrl -notmatch '^https://') { return }
try {
$base = ($UploadUrl -replace '/api/diagnostics/?$', '')
$profileName = if ($Remote) { 'remote' } else { 'full' }
# The reason is an error string, so treat it as untrusted: never let
# the token ride along, keep it ASCII (PS 5.1 mangles non-ASCII in
# -Body) and short - the server caps it again anyway.
$r = "$Reason" -replace [regex]::Escape($UploadToken), ''
$r = $r -replace '[^\x20-\x7E]', ' '
if ($r.Length -gt 200) { $r = $r.Substring(0, 200) }
$body = @{ phase = $Phase; reason = $r; profile = $profileName } |
ConvertTo-Json -Compress
$pingArgs = @{
Uri = "$base/api/collect-status"; Method = 'Post'; Body = $body
ContentType = 'application/json'; TimeoutSec = 10
Headers = @{
'X-Upload-Token' = $UploadToken
'X-Device-Name' = $deviceLabel
'X-Collector-Version' = $ScriptVersion
}
}
$pingProxy = Get-SherlogProxy
if ($pingProxy) { $pingArgs['Proxy'] = $pingProxy }
Invoke-RestMethod @pingArgs | Out-Null
} catch {}
}
# Any unhandled terminating error still tells Sherlog the run died, so the
# device shows as failed instead of silently expiring. Lives here and not in
# the Intune remediation wrapper: the wrapper does not know the anonymized
# device label and would leak the real hostname in -Anonymize mode.
trap { try { Send-SherlogPing -Phase failed -Reason "collection aborted: $($_.Exception.Message)" } catch {}; break }
Send-SherlogPing -Phase start
$folders = @('MDM','EventLogs','Registry','Identity','Network','Apps-IME','System','Defender','WindowsUpdate','Autopilot','Management')
foreach ($f in $folders) {
New-Item -ItemType Directory -Path (Join-Path $work $f) -Force | Out-Null
}
$transcript = Join-Path $work 'CollectionTranscript.log'
Start-Transcript -Path $transcript -Force | Out-Null
$StepLog = [System.Collections.Generic.List[object]]::new()
function Write-Step { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] $Msg" -ForegroundColor Cyan }
function Invoke-Safe {
param([string]$Name, [scriptblock]$Action)
Write-Step $Name
$sw = [Diagnostics.Stopwatch]::StartNew()
$prevEAP = $ErrorActionPreference
try {
# Non-terminating cmdlet errors (a bad path, a missing log) otherwise
# print a message but leave the step looking like it succeeded; force
# them to be caught here so a failed step is recorded as failed.
$ErrorActionPreference = 'Stop'
& $Action
$StepLog.Add([pscustomobject]@{ Name = $Name; Ok = $true; Error = $null
Seconds = [math]::Round($sw.Elapsed.TotalSeconds, 1) })
} catch {
Write-Warning " Failed: $($_.Exception.Message)"
$StepLog.Add([pscustomobject]@{ Name = $Name; Ok = $false; Error = $_.Exception.Message
Seconds = [math]::Round($sw.Elapsed.TotalSeconds, 1) })
} finally {
$ErrorActionPreference = $prevEAP
}
}
# ============================================================
# 1. MDM logs (mdmdiagnosticstool)
# ============================================================
Invoke-Safe 'MDM diagnostics report...' {
# The all-areas zip duplicates the event logs and registry exports below
# and is the single largest item in the package; skip it in the slim
# remote profile and keep only the small default report.
if (-not $Remote) {
$areaKey = 'HKLM:\SOFTWARE\Microsoft\MdmDiagnostics\Area'
if (Test-Path $areaKey) {
$areas = (Get-ChildItem $areaKey).PSChildName -join ';'
Write-Host " Areas found: $areas"
& "$env:windir\system32\mdmdiagnosticstool.exe" -area $areas -zip (Join-Path $work 'MDM\MDMDiag-AllAreas.zip') | Out-Null
if ($LASTEXITCODE -ne 0) { throw "mdmdiagnosticstool -area exited with code $LASTEXITCODE" }
}
}
& "$env:windir\system32\mdmdiagnosticstool.exe" -out (Join-Path $work 'MDM\DefaultReport') | Out-Null
if ($LASTEXITCODE -ne 0) { throw "mdmdiagnosticstool -out exited with code $LASTEXITCODE" }
}
# ============================================================
# 2. Event Logs
# ============================================================
$eventLogs = @{
'DeviceManagement-Admin' = 'Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin'
'DeviceManagement-Operational'= 'Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Operational'
'AAD-Operational' = 'Microsoft-Windows-AAD/Operational'
'UserDeviceRegistration' = 'Microsoft-Windows-User Device Registration/Admin'
'Shell-Core' = 'Microsoft-Windows-Shell-Core/Operational'
'ModernDeployment-Autopilot' = 'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot'
'ModernDeployment-Diagnostics'= 'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService'
'Provisioning-Diagnostics' = 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin'
'CodeIntegrity' = 'Microsoft-Windows-CodeIntegrity/Operational'
'TaskScheduler' = 'Microsoft-Windows-TaskScheduler/Operational'
'PushNotification-Platform' = 'Microsoft-Windows-PushNotification-Platform/Operational'
# SCEP/PKCS certificate enrollment + renewal failures surface here long
# before the expiring MDM cert itself becomes visible.
'CertificateServicesClient' = 'Microsoft-Windows-CertificateServicesClient-Lifecycle-System/Operational'
'LAPS' = 'Microsoft-Windows-LAPS/Operational'
'Application' = 'Application'
'System' = 'System'
}
$eventLogWindowDays = 14
foreach ($entry in $eventLogs.GetEnumerator()) {
Invoke-Safe "Event log: $($entry.Key)..." {
$dest = Join-Path $work "EventLogs\$($entry.Key).evtx"
if ($Remote) {
# Slim profile: only the last N days in the raw export too - the
# biggest single size contributor on a chatty Application/System log.
$q = "*[System[TimeCreated[timediff(@SystemTime) <= $($eventLogWindowDays * 86400000)]]]"
wevtutil epl $entry.Value $dest "/q:$q" /ow:true 2>$null
} else {
wevtutil epl $entry.Value $dest /ow:true 2>$null
}
if ($LASTEXITCODE -ne 0) {
throw "wevtutil exited with code $LASTEXITCODE (log may not be registered on this device)"
}
# Errors/warnings/criticals from the last $eventLogWindowDays days,
# filtered server-side by Get-WinEvent so -MaxEvents caps the matching
# events, not the newest raw entries - a busy Application/System log
# would otherwise summarize to zero (the newest 200 raw entries are
# almost always informational).
$records = Get-WinEvent -FilterHashtable @{
LogName = $entry.Value
Level = 1, 2, 3
StartTime = (Get-Date).AddDays(-$eventLogWindowDays)
} -MaxEvents 200 -ErrorAction SilentlyContinue
if ($records) {
$records | Select-Object TimeCreated, Id, LevelDisplayName, Message |
Format-List | Out-File (Join-Path $work "EventLogs\$($entry.Key)-ErrorsWarnings.txt") -Width 250
# Locale-invariant sidecar: numeric Level survives non-English
# Windows, where LevelDisplayName ("Fout"/"Fehler"/...) breaks the
# text-based error/warning count.
$records | Select-Object TimeCreated, Id, Level, LevelDisplayName, Message |
ConvertTo-Json -Depth 3 | Out-File (Join-Path $work "EventLogs\$($entry.Key)-ErrorsWarnings.json")
}
}
}
# ============================================================
# 3. Registry exports
# ============================================================
$regKeys = @{
'Enrollments' = 'HKLM\SOFTWARE\Microsoft\Enrollments'
'PolicyManager-Current' = 'HKLM\SOFTWARE\Microsoft\PolicyManager\current'
'PolicyManager-Providers' = 'HKLM\SOFTWARE\Microsoft\PolicyManager\Providers'
'IntuneManagementExtension'= 'HKLM\SOFTWARE\Microsoft\IntuneManagementExtension'
'Win32Apps' = 'HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\Win32Apps'
'Autopilot' = 'HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\AutoPilot'
'Autopilot-EstablishedCorr'= 'HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings'
'EnrollmentStatusTracking' = 'HKLM\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking'
'FirstSync' = 'HKLM\SOFTWARE\Microsoft\Windows\Autopilot'
'CloudDomainJoin' = 'HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin'
'OMADM-Accounts' = 'HKLM\SOFTWARE\Microsoft\Provisioning\OMADM\Accounts'
'MDM-Uninstall' = 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
'InternetSettings' = 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings'
# GPO-vs-Intune conflicts (e.g. MDMWinsOverGP) are a common real-world
# cause of policy drift; correlate against the PolicyManager RSOP above.
'Policies' = 'HKLM\SOFTWARE\Policies'
'CoManagement' = 'HKLM\SOFTWARE\Microsoft\CCM'
'DefenderATP-Onboarding' = 'HKLM\SOFTWARE\Microsoft\Windows Advanced Threat Protection\Status'
# Per-CSP applied-value cache: what the DM client actually set, next to
# the PolicyManager intent above.
'NodeCache' = 'HKLM\SOFTWARE\Microsoft\Provisioning\NodeCache\CSP\Device\MS DM Server\Nodes'
# Policy only, never password material; key is absent on most devices
# (the step then records a clean failure in the manifest).
'LAPS-Policy' = 'HKLM\SOFTWARE\Microsoft\Policies\LAPS'
}
foreach ($entry in $regKeys.GetEnumerator()) {
Invoke-Safe "Registry: $($entry.Key)..." {
reg export $entry.Value (Join-Path $work "Registry\$($entry.Key).reg") /y 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "reg export exited with code $LASTEXITCODE (key may not exist on this device)" }
}
}
# ============================================================
# 4. Identity & certificates
# ============================================================
Invoke-Safe 'dsregcmd /status (machine/SYSTEM context)...' {
dsregcmd /status | Out-File (Join-Path $work 'Identity\dsregcmd-status.txt')
}
# The Primary Refresh Token is per-user: dsregcmd run as SYSTEM (the usual
# Intune remediation context) can never see it. Best-effort: run dsregcmd as
# the interactively logged-on user via a one-shot scheduled task, so the PRT
# check has a real signal instead of always reading "unknown".
Invoke-Safe 'dsregcmd /status (interactive user context)...' {
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
$userName = $cs.UserName
if (-not $userName) {
Write-Host ' No interactive user session found; skipping.'
return
}
$outFile = Join-Path $work 'Identity\dsregcmd-status-user.txt'
$taskName = 'SherlogDsregcmd-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$cmd = "dsregcmd /status > `"$outFile`" 2>&1"
schtasks /Create /TN $taskName /TR "cmd.exe /c $cmd" /SC ONCE /ST 00:00 /RU $userName /RL LIMITED /F | Out-Null
if ($LASTEXITCODE -ne 0) { throw "schtasks /Create exited with code $LASTEXITCODE" }
try {
schtasks /Run /TN $taskName | Out-Null
$deadline = (Get-Date).AddSeconds(20)
do {
Start-Sleep -Milliseconds 500
$info = schtasks /Query /TN $taskName /FO LIST /V 2>$null
$running = $info -match 'Status:\s*Running'
} while ($running -and (Get-Date) -lt $deadline)
} finally {
schtasks /Delete /TN $taskName /F 2>$null | Out-Null
}
if (-not (Test-Path $outFile)) {
Write-Host ' User-context dsregcmd produced no output (session may be locked/disconnected).'
}
}
Invoke-Safe 'Certificates (machine + user)...' {
certutil -store MY | Out-File (Join-Path $work 'Identity\certs-machine-MY.txt')
certutil -store -user MY | Out-File (Join-Path $work 'Identity\certs-user-MY.txt')
# Highlight the machine certificates, including the Intune MDM device cert
Get-ChildItem Cert:\LocalMachine\My |
Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, @{n='Expired';e={$_.NotAfter -lt (Get-Date)}} |
Format-List | Out-File (Join-Path $work 'Identity\certs-machine-overview.txt')
}
# ============================================================
# 5. Network
# ============================================================
Invoke-Safe 'Network configuration...' {
ipconfig /all | Out-File (Join-Path $work 'Network\ipconfig.txt')
netsh advfirewall show allprofiles | Out-File (Join-Path $work 'Network\firewall-profiles.txt')
netsh advfirewall show global | Out-File (Join-Path $work 'Network\firewall-global.txt')
netsh winhttp show proxy | Out-File (Join-Path $work 'Network\winhttp-proxy.txt')
netsh wlan show profiles | Out-File (Join-Path $work 'Network\wlan-profiles.txt')
route print | Out-File (Join-Path $work 'Network\routes.txt')
Get-DnsClientServerAddress | Format-Table -AutoSize | Out-File (Join-Path $work 'Network\dns-servers.txt')
# Locale-invariant twin of the firewall state: netsh's ON/OFF text is
# localized, Get-NetFirewallProfile's Enabled is a plain boolean.
Get-NetFirewallProfile -ErrorAction SilentlyContinue |
Select-Object Name, Enabled |
ConvertTo-Json | Out-File (Join-Path $work 'Network\firewall-profiles.json')
}
Invoke-Safe 'Connectivity test to Intune/Entra endpoints...' {
$endpoints = @(
'login.microsoftonline.com',
'enterpriseregistration.windows.net',
'enrollment.manage.microsoft.com',
'portal.manage.microsoft.com',
'graph.microsoft.com',
'nps.notify.windows.com',
'client.wns.windows.com',
'ztd.dds.microsoft.com',
'cs.dds.microsoft.com',
'manage.microsoft.com',
'dl.delivery.mp.microsoft.com',
'emdl.ws.microsoft.com',
'autologon.microsoftazuread-sso.com'
)
$results = foreach ($ep in $endpoints) {
$t = Test-NetConnection -ComputerName $ep -Port 443 -WarningAction SilentlyContinue
[pscustomobject]@{
Endpoint = $ep
Reachable = $t.TcpTestSucceeded
RemoteIP = "$($t.RemoteAddress)"
}
}
$results | Format-Table -AutoSize | Out-File (Join-Path $work 'Network\endpoint-connectivity.txt')
# Locale-invariant twin (same pattern as firewall-profiles.json).
$results | ConvertTo-Json | Out-File (Join-Path $work 'Network\endpoint-connectivity.json')
# TLS-inspection detection: a plain port-443 handshake only proves *a* TLS
# server answered. A real request's certificate issuer should be a
# Microsoft/DigiCert CA; a locally-installed inspection proxy substitutes
# its own issuer, which explains a lot of otherwise-mysterious app/sync
# failures on managed networks.
try {
$req = [Net.HttpWebRequest]::Create('https://login.microsoftonline.com/')
$req.Timeout = 5000
$resp = $req.GetResponse()
$cert = $req.ServicePoint.Certificate
$issuer = if ($cert) { $cert.Issuer } else { 'unknown' }
$resp.Close()
"TLS certificate issuer for login.microsoftonline.com: $issuer" |
Out-File (Join-Path $work 'Network\tls-issuer-check.txt')
} catch {
"TLS probe failed: $($_.Exception.Message)" |
Out-File (Join-Path $work 'Network\tls-issuer-check.txt')
}
}
# ============================================================
# 6. Apps / Intune Management Extension
# ============================================================
Invoke-Safe 'Copying IME logs...' {
$imeLogs = "$env:ProgramData\Microsoft\IntuneManagementExtension\Logs"
if (Test-Path $imeLogs) {
if ($Remote) {
# Slim profile: recent logs only (rotated archives go back months)
# and a running size cap so the package stays uploadable.
$dest = Join-Path $work 'Apps-IME\Logs'
New-Item -ItemType Directory -Path $dest -Force | Out-Null
$budget = 40MB
Get-ChildItem $imeLogs -File -Recurse |
Where-Object { $_.LastWriteTime -gt (Get-Date).AddDays(-14) } |
Sort-Object LastWriteTime -Descending | ForEach-Object {
if ($budget -ge $_.Length) {
Copy-Item $_.FullName (Join-Path $dest $_.Name) -Force
$budget -= $_.Length
}
}
} else {
Copy-Item $imeLogs (Join-Path $work 'Apps-IME\Logs') -Recurse -Force
}
}
}
Invoke-Safe 'IME service status...' {
Get-Service -Name 'IntuneManagementExtension','Microsoft Intune Management Extension' -ErrorAction SilentlyContinue |
Select-Object Name, Status, StartType |
Format-Table -AutoSize | Out-File (Join-Path $work 'Apps-IME\service-status.txt')
}
Invoke-Safe 'Inventorying installed apps...' {
$paths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
Get-ItemProperty $paths -ErrorAction SilentlyContinue |
Where-Object DisplayName |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate |
Sort-Object DisplayName |
Format-Table -AutoSize | Out-File (Join-Path $work 'Apps-IME\installed-apps.txt') -Width 250
}
# ============================================================
# 6b. Co-management, Defender for Endpoint, Delivery Optimization
# ============================================================
Invoke-Safe 'Co-management state...' {
$flags = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\CCM' -ErrorAction SilentlyContinue
$svc = Get-Service -Name 'CcmExec' -ErrorAction SilentlyContinue
[pscustomobject]@{
CcmExecService = if ($svc) { $svc.Status.ToString() } else { 'not installed' }
CoManagementFlags = $flags.CoManagementFlags
} | Format-List | Out-File (Join-Path $work 'Management\co-management.txt')
}
Invoke-Safe 'Defender for Endpoint onboarding...' {
$atp = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows Advanced Threat Protection\Status' -ErrorAction SilentlyContinue
$sense = Get-Service -Name 'Sense' -ErrorAction SilentlyContinue
[pscustomobject]@{
SenseService = if ($sense) { $sense.Status.ToString() } else { 'not installed' }
OnboardingState = $atp.OnboardingState
OrgId = $atp.OrgId
} | Format-List | Out-File (Join-Path $work 'Management\defender-atp-onboarding.txt')
}
Invoke-Safe 'Delivery Optimization status...' {
Get-DeliveryOptimizationStatus -ErrorAction SilentlyContinue |
Out-File (Join-Path $work 'Management\delivery-optimization-status.txt')
Get-DeliveryOptimizationPerfSnap -ErrorAction SilentlyContinue |
Out-File (Join-Path $work 'Management\delivery-optimization-perf.txt')
}
# ============================================================
# 7. System
# ============================================================
if (-not $Remote) {
Invoke-Safe 'msinfo32 report (this may take a while)...' {
Start-Process msinfo32 -ArgumentList "/report `"$(Join-Path $work 'System\msinfo32.log')`"" -Wait
}
}
Invoke-Safe 'Drivers, battery, OS info...' {
pnputil /enum-drivers | Out-File (Join-Path $work 'System\drivers.txt')
powercfg /batteryreport /output (Join-Path $work 'System\battery-report.html') 2>$null
Get-ComputerInfo | Out-File (Join-Path $work 'System\computerinfo.txt')
Get-HotFix | Sort-Object InstalledOn -Descending |
Format-Table -AutoSize | Out-File (Join-Path $work 'System\hotfixes.txt')
}
Invoke-Safe 'Relevant scheduled tasks...' {
$emTasks = Get-ScheduledTask -TaskPath '\Microsoft\Windows\EnterpriseMgmt\*' -ErrorAction SilentlyContinue
$emTasks | Select-Object TaskPath, TaskName, State |
Format-Table -AutoSize | Out-File (Join-Path $work 'System\enterprisemgmt-tasks.txt') -Width 250
# JSON twin with run results: LastTaskResult answers "does the sync
# schedule actually fire and succeed", which the State column cannot.
$emTasks | ForEach-Object {
$i = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
[pscustomobject]@{
TaskName = $_.TaskName
TaskPath = $_.TaskPath
State = $_.State.ToString()
LastRunTime = if ($i -and $i.LastRunTime) { $i.LastRunTime.ToString('o') } else { $null }
LastTaskResult = if ($i) { $i.LastTaskResult } else { $null }
NextRunTime = if ($i -and $i.NextRunTime) { $i.NextRunTime.ToString('o') } else { $null }
}
} | ConvertTo-Json | Out-File (Join-Path $work 'System\enterprisemgmt-tasks.json')
}
Invoke-Safe 'Key service states...' {
Get-Service -Name 'IntuneManagementExtension','dmwappushservice','WpnService',
'wuauserv','DoSvc','W32Time','CcmExec','Sense','WinDefend','Schedule' -ErrorAction SilentlyContinue |
Select-Object Name,
@{n='Status';e={$_.Status.ToString()}},
@{n='StartType';e={$_.StartType.ToString()}} |
ConvertTo-Json | Out-File (Join-Path $work 'System\services.json')
}
Invoke-Safe 'BitLocker / Secure Boot state...' {
Get-BitLockerVolume -ErrorAction SilentlyContinue |
Select-Object MountPoint,
@{n='VolumeStatus';e={$_.VolumeStatus.ToString()}},
@{n='ProtectionStatus';e={$_.ProtectionStatus.ToString()}},
EncryptionPercentage,
@{n='KeyProtectors';e={@($_.KeyProtector | ForEach-Object { $_.KeyProtectorType.ToString() })}} |
ConvertTo-Json -Depth 3 | Out-File (Join-Path $work 'System\bitlocker.json')
# null = legacy BIOS / not queryable (distinct from $false = disabled).
$sb = try { Confirm-SecureBootUEFI } catch { $null }
@{ SecureBoot = $sb } | ConvertTo-Json | Out-File (Join-Path $work 'System\secureboot.json')
}
Invoke-Safe 'Pending reboot state...' {
@{
CbsRebootPending = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'
WuRebootRequired = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
PendingFileRename = [bool](Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
} | ConvertTo-Json | Out-File (Join-Path $work 'System\pending-reboot.json')
}
Invoke-Safe 'Device info (build, boot, locale)...' {
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
$cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop
@{
LastBootUtc = $os.LastBootUpTime.ToUniversalTime().ToString('o')
OSBuild = "$($cv.CurrentBuildNumber).$($cv.UBR)"
DisplayVersion = "$($cv.DisplayVersion)"
Edition = "$($cv.EditionID)"
Locale = (Get-Culture).Name
TimeZone = (Get-TimeZone).Id
} | ConvertTo-Json | Out-File (Join-Path $work 'System\device-info.json')
}
Invoke-Safe 'Devices in error state...' {
Get-PnpDevice -Status Error -ErrorAction SilentlyContinue |
Select-Object FriendlyName, Class,
@{n='Status';e={$_.Status.ToString()}} |
ConvertTo-Json | Out-File (Join-Path $work 'System\pnp-errors.json')
}
Invoke-Safe 'Time sync status...' {
w32tm /query /status | Out-File (Join-Path $work 'System\time-sync-status.txt')
if ($LASTEXITCODE -ne 0) { throw "w32tm exited with code $LASTEXITCODE" }
}
Invoke-Safe 'Disk space...' {
Get-Volume -ErrorAction SilentlyContinue |
Where-Object { $_.DriveLetter } |
Select-Object DriveLetter, FileSystemLabel,
@{n='SizeGB';e={[math]::Round($_.Size / 1GB, 1)}},
@{n='FreeGB';e={[math]::Round($_.SizeRemaining / 1GB, 1)}} |
Format-Table -AutoSize | Out-File (Join-Path $work 'System\disk-space.txt')
}
Invoke-Safe 'TPM status...' {
Get-Tpm -ErrorAction SilentlyContinue | Format-List | Out-File (Join-Path $work 'System\tpm-status.txt')
}
# ============================================================
# 8. Defender
# ============================================================
Invoke-Safe 'Defender support files...' {
# -GetFiles produces a large cab; skip it in the slim remote profile.
if (-not $Remote) {
$mpcmd = "$env:ProgramFiles\Windows Defender\mpcmdrun.exe"
if (Test-Path $mpcmd) {
& $mpcmd -GetFiles | Out-Null
Copy-Item "$env:ProgramData\Microsoft\Windows Defender\Support\MpSupportFiles.cab" `
(Join-Path $work 'Defender') -Force -ErrorAction SilentlyContinue
}
}
Get-MpComputerStatus -ErrorAction SilentlyContinue |
Out-File (Join-Path $work 'Defender\mp-status.txt')
}
# ============================================================
# 9. Windows Update
# ============================================================
# Cheap registry-only Windows Update for Business state, collected in both
# profiles; the slow parts (Get-WindowsUpdateLog, the raw USO *.etl traces
# Sherlog cannot read anyway) stay full-profile only.
Invoke-Safe 'Windows Update for Business state...' {
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' -ErrorAction SilentlyContinue |
Format-List | Out-File (Join-Path $work 'WindowsUpdate\wufb-ux-settings.txt')
Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' -ErrorAction SilentlyContinue |
Format-List | Out-File (Join-Path $work 'WindowsUpdate\wufb-policy.txt')
}
Invoke-Safe 'Windows Update history...' {
# COM history is cheap and structured, unlike the ETL-based
# Get-WindowsUpdateLog. ResultCode: 2=Succeeded, 3=SucceededWithErrors,
# 4=Failed, 5=Aborted.
$session = New-Object -ComObject Microsoft.Update.Session
$searcher = $session.CreateUpdateSearcher()
$n = [Math]::Min($searcher.GetTotalHistoryCount(), 30)
if ($n -gt 0) {
$searcher.QueryHistory(0, $n) | ForEach-Object {
[pscustomobject]@{
Date = $_.Date.ToString('o')
Title = $_.Title
ResultCode = $_.ResultCode
HResult = $_.HResult
}
} | ConvertTo-Json | Out-File (Join-Path $work 'WindowsUpdate\wu-history.json')
}
}
if (-not $Remote) {
Invoke-Safe 'Windows Update log (this may take a while)...' {
Get-WindowsUpdateLog -LogPath (Join-Path $work 'WindowsUpdate\WindowsUpdate.log') -ErrorAction SilentlyContinue | Out-Null
}
}
# ============================================================
# 10. Autopilot / ESP extras
# ============================================================
Invoke-Safe 'Autopilot/ESP files...' {
Copy-Item "$env:windir\Logs\Panther\unattendgc\setupact.log" (Join-Path $work 'Autopilot') -Force -ErrorAction SilentlyContinue
Copy-Item "$env:ProgramData\Microsoft\Provisioning\*.log" (Join-Path $work 'Autopilot') -Force -ErrorAction SilentlyContinue
}
# ============================================================
# 11. Generate summary
# ============================================================
Invoke-Safe 'Generating summary...' {
$dsreg = dsregcmd /status
function Get-DsregField($name) {
$m = $dsreg | Select-String ('^\s*' + [regex]::Escape($name) + '\s*:\s*(.+?)\s*$') | Select-Object -First 1
if ($m -and $m.Matches.Count -gt 0) { $m.Matches[0].Groups[1].Value } else { '' }
}
$aadJoined = Get-DsregField 'AzureAdJoined'
$prt = Get-DsregField 'AzureAdPrt'
$mdmUrl = Get-DsregField 'MdmUrl'
$imeService = (Get-Service -Name 'IntuneManagementExtension' -ErrorAction SilentlyContinue).Status
$recentErrors = Get-WinEvent -LogName 'Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin' -MaxEvents 500 -ErrorAction SilentlyContinue |
Where-Object Level -eq 2 |
Select-Object -First 10 TimeCreated, Id, Message
$anonLine = if ($Anonymize) {
"`n [Anonymized] Best-effort redaction of tenant/company/device data in" +
" TEXT files. Binaries (evtx/cab/mdmdiag-zip) are NOT scrubbed -" +
" review before sharing.`n"
} else { '' }
$summary = @"
==========================================================
INTUNE DIAGNOSTICS SUMMARY
Device : $env:COMPUTERNAME
Date : $(Get-Date)
User : $env:USERNAME
Collector: v$ScriptVersion ($(if ($Remote) { 'Remote' } else { 'Full' }) profile)
==========================================================
$anonLine
[Identity]
AzureAdJoined : $aadJoined
AzureAdPrt : $prt
MDM URL : $mdmUrl
[Services]
IntuneManagementExtension : $imeService
[Last 10 MDM errors (DeviceManagement Admin log)]
$($recentErrors | Format-List | Out-String)
See the subfolders for all details:
MDM\ - mdmdiagnosticstool output (HTML report, registry dump, evtx)
EventLogs\ - evtx exports + errors/warnings as text and JSON (incl. push, cert enrollment, LAPS)
Registry\ - Enrollments, PolicyManager, NodeCache, IME, Autopilot, OMADM accounts, GPO policies
Identity\ - dsregcmd (machine + interactive user), certificates
Network\ - ipconfig, proxy, firewall, endpoint connectivity (txt+json), TLS-issuer check
Apps-IME\ - IME logs, app inventory
Management\ - co-management, Defender for Endpoint onboarding, Delivery Optimization
System\ - msinfo32, drivers, hotfixes, scheduled tasks (+run results), services,
BitLocker/Secure Boot, pending reboot, device info, PnP errors,
disk space, time sync, TPM
Defender\ - MpSupportFiles.cab, status
WindowsUpdate\ - WindowsUpdate.log, WUfB settings, update history (json)
Autopilot\ - setupact.log, provisioning logs
_MANIFEST.json - collector version, profile and per-step outcome
==========================================================
"@
$summary | Out-File (Join-Path $work '_SUMMARY.txt')
Write-Host $summary
}
# ============================================================
# 11b. Collection manifest
# ============================================================
Invoke-Safe 'Writing manifest...' {
$manifest = [pscustomobject]@{
CollectorVersion = $ScriptVersion
Profile = if ($Remote) { 'Remote' } else { 'Full' }
RunAsSystem = ($env:USERNAME -eq 'SYSTEM' -or $env:USERDOMAIN -eq 'NT AUTHORITY')
RunAsUser = "$env:USERDOMAIN\$env:USERNAME"
Anonymized = [bool]$Anonymize
StartedUtc = $startedUtc.ToString('o')
FinishedUtc = ([DateTime]::UtcNow).ToString('o')
OSBuild = [Environment]::OSVersion.VersionString
PSVersion = $PSVersionTable.PSVersion.ToString()
Steps = $StepLog
}
$manifest | ConvertTo-Json -Depth 4 | Out-File (Join-Path $work '_MANIFEST.json')
}
# ============================================================
# 11c. Secret redaction (always) + best-effort anonymization (-Anonymize)
# Stop the transcript first so CollectionTranscript.log can be scrubbed too.
# ============================================================
Stop-Transcript | Out-Null
function Invoke-TextRedaction {
param(
[Parameter(Mandatory)] [string]$Root,
[System.Collections.Generic.List[object]]$Map = [System.Collections.Generic.List[object]]::new(),
[switch]$AlsoRedactEmails
)
if ($Map.Count -eq 0 -and -not $AlsoRedactEmails) { return 0 }
$seen = @{}
$final = foreach ($r in ($Map | Sort-Object { $_.Value.Length } -Descending)) {
$k = $r.Value.ToLowerInvariant()
if (-not $seen.ContainsKey($k)) { $seen[$k] = $true; $r }
}
# Compile every literal token once instead of parsing the pattern again for
# every file: the map is small but the file set is not.
$rxOpts = [Text.RegularExpressions.RegexOptions]::IgnoreCase -bor
[Text.RegularExpressions.RegexOptions]::Compiled
$rules = foreach ($r in $final) {
[pscustomobject]@{ Re = [regex]::new([regex]::Escape($r.Value), $rxOpts); Tag = $r.Tag }
}
# E-mail catch-all, anchored on the literal '@' so the engine can skip
# through a multi-MB IME log at native speed. A pattern that starts with
# the local part instead ([A-Z0-9._%+-]+@...) tries - and backtracks out
# of - every GUID, hash and base64 run in the file; that is what made
# -Anonymize run for minutes on log-heavy devices. The local part is
# walked backwards from the match, bounded by its own length.
$emailRe = [regex]::new('@[A-Z0-9.-]+\.[A-Z]{2,}', $rxOpts)
$isLocal = New-Object 'bool[]' 128
foreach ($c in [char[]]'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._%+-') {
$isLocal[[int]$c] = $true
}
function Remove-EmailAddresses([string]$t) {
if ($t.IndexOf('@') -lt 0) { return $t }
$sb = [Text.StringBuilder]::new($t.Length)
$pos = 0
foreach ($m in $emailRe.Matches($t)) {
if ($m.Index -lt $pos) { continue }
$s = $m.Index
while ($s -gt $pos -and [int]$t[$s - 1] -lt 128 -and $isLocal[[int]$t[$s - 1]]) { $s-- }
if ($s -eq $m.Index) { continue } # bare '@domain' is not an address
[void]$sb.Append($t, $pos, $s - $pos).Append('')
$pos = $m.Index + $m.Length
}
if ($pos -eq 0) { return $t }
[void]$sb.Append($t, $pos, $t.Length - $pos)
return $sb.ToString()
}
$textExt = '.txt', '.log', '.reg', '.xml', '.html', '.htm', '.json', '.csv', '.ini', '.config'
$count = 0
Get-ChildItem $Root -Recurse -File |
Where-Object { $textExt -contains $_.Extension.ToLower() } | ForEach-Object {
try {
$bytes = [IO.File]::ReadAllBytes($_.FullName)
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
$enc = [Text.Encoding]::Unicode
} elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
$enc = [Text.Encoding]::BigEndianUnicode
} elseif ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
$enc = New-Object Text.UTF8Encoding($true)
} else {
$enc = New-Object Text.UTF8Encoding($false)
}
$text = $enc.GetString($bytes)
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
$orig = $text
foreach ($r in $rules) {
# IsMatch first: Replace copies the whole (multi-MB) string
# even when nothing matches, and most tokens appear in only
# a handful of files. Same engine, so same semantics.
if ($r.Re.IsMatch($text)) { $text = $r.Re.Replace($text, $r.Tag) }
}
if ($AlsoRedactEmails) { $text = Remove-EmailAddresses $text }
if ($text -ne $orig) {
[IO.File]::WriteAllBytes($_.FullName, $enc.GetPreamble() + $enc.GetBytes($text))
$count++
}
} catch { Write-Warning " Could not redact $($_.Name): $($_.Exception.Message)" }
}
return $count
}
# The upload secret is always redacted from every text file (chiefly the
# transcript, which PowerShell stamps with the full command line it was
# invoked with, including -UploadToken) - independent of -Anonymize.
# Both jobs share one walk over the package: a separate pass per job reads,
# decodes, re-encodes and rewrites every text file a second time, and on a
# log-heavy device that walk is the slow part of -Anonymize. The token entry
# is added first and outside any step, so a failure while collecting the
# anonymization tokens can never cost us the secret redaction.
$redactMap = [System.Collections.Generic.List[object]]::new()
if ($UploadToken) {
$redactMap.Add([pscustomobject]@{ Value = $UploadToken; Tag = '' })
}
if ($Anonymize) {
Invoke-Safe 'Collecting anonymization tokens...' {
# Principals that must never be redacted: they are not identifying and
# (for SYSTEM/NT AUTHORITY) redacting them corrupts registry paths
# like HKEY_LOCAL_MACHINE\SYSTEM\... and breaks the server's
# SYSTEM-context detection for the Entra PRT check.
$wellKnown = '(?i)^(NT AUTHORITY\\SYSTEM|SYSTEM|NT AUTHORITY|LOCAL SERVICE|NETWORK SERVICE|' +
'NT AUTHORITY\\LOCAL SERVICE|NT AUTHORITY\\NETWORK SERVICE|WORKGROUP|Unknown|N/A|None)$'
function Add-Redact($val, $tag) {
if ($null -eq $val) { return }
$v = "$val".Trim()
if ($v.Length -ge 4 -and $v -notmatch $wellKnown) {
$redactMap.Add([pscustomobject]@{ Value = $v; Tag = $tag })
}
}
$dsreg = dsregcmd /status
function Get-Dsreg($name) {
$line = $dsreg | Select-String ('^\s*' + [regex]::Escape($name) + '\s*:\s*(.+?)\s*$') | Select-Object -First 1
if ($line -and $line.Matches.Count -gt 0) { $line.Matches[0].Groups[1].Value } else { '' }
}
Add-Redact (Get-Dsreg 'TenantId') ''
Add-Redact (Get-Dsreg 'TenantName') ''
Add-Redact (Get-Dsreg 'TenantDisplayName') ''
Add-Redact (Get-Dsreg 'Executing Account Name') ''
Get-ChildItem 'HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo' `
-ErrorAction SilentlyContinue | ForEach-Object {
$p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
Add-Redact $p.TenantId ''
Add-Redact $p.TenantName ''
Add-Redact $p.UserEmail ''
Add-Redact $p.DisplayName ''
}
$cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' `
-ErrorAction SilentlyContinue
Add-Redact $cv.RegisteredOrganization ''
Add-Redact $cv.RegisteredOwner ''
Add-Redact $env:COMPUTERNAME ''
Add-Redact $env:USERNAME ''
Add-Redact $env:USERDNSDOMAIN ''
Add-Redact $env:USERDOMAIN ''
# Domain part of any UPN we found.
foreach ($u in @($redactMap | Where-Object { $_.Tag -eq '' })) {
if ($u.Value -match '@(.+)$') { Add-Redact $matches[1] '' }
}
Write-Host " Collected $($redactMap.Count) token(s) to redact."
}
}
if ($redactMap.Count -gt 0 -or $Anonymize) {
Invoke-Safe 'Redacting text files...' {
$n = Invoke-TextRedaction -Root $work -Map $redactMap -AlsoRedactEmails:$Anonymize
Write-Host " Redacted $n file(s) using $($redactMap.Count) token(s)."
}
}
if ($Anonymize) {
Write-Warning ('ANONYMIZE is best-effort and NOT a guarantee. Only TEXT files were ' +
'redacted; binary files (event logs .evtx, Defender .cab, the nested ' +
'mdmdiag .zip) are NOT scrubbed and may still contain tenant/company ' +
'identifiers. Review the package before sharing.')
}
# ============================================================
# 12. Package everything
# ============================================================
Write-Step 'Packaging everything...'
try {
if (Test-Path $zipFile) { Remove-Item $zipFile -Force -ErrorAction SilentlyContinue }
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::CreateFromDirectory($work, $zipFile, [IO.Compression.CompressionLevel]::Optimal, $false)
} finally {
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host ''
Write-Host "Done! Diagnostics package: $zipFile" -ForegroundColor Green
# ============================================================
# 13. Optional upload to Sherlog (drop-off API)
# ============================================================
# Best-effort cleanup of zips left behind by earlier failed runs in the same
# output folder, so a device that repeatedly fails to upload (offline, no
# token yet) doesn't fill the disk one package at a time.
Invoke-Safe 'Pruning old local packages...' {
Get-ChildItem $OutputPath -Filter 'IntuneDiag-*.zip' -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -ne $zipFile -and $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
Remove-Item -Force -ErrorAction SilentlyContinue
}
if ($UploadUrl) {
if (-not $UploadToken -or $UploadToken -eq '') {
Write-Warning 'UploadUrl set without a real UploadToken; skipping upload. Local zip kept.'
} elseif ($UploadUrl -notmatch '^https://') {
Write-Warning 'UploadUrl is not https:// - refusing to upload (would send the token in cleartext). Local zip kept.'
} else {
$sizeMB = [math]::Round((Get-Item $zipFile).Length / 1MB, 1)
if ($sizeMB -gt $MaxUploadMB) {
Write-Warning "Package is $sizeMB MB, over the $MaxUploadMB MB limit; skipping upload (would be rejected). Local zip kept: $zipFile"
Write-Output "SHERLOG_ERROR=package too large ($sizeMB MB > $MaxUploadMB MB), not uploaded"
Send-SherlogPing -Phase failed -Reason "package too large ($sizeMB MB > $MaxUploadMB MB), not uploaded"
} else {
Write-Step "Uploading to $UploadUrl ($sizeMB MB)..."
# Re-detected rather than cached: a VPN or proxy change during a
# long collection must not leave the upload with a stale proxy.
$uploadProxy = Get-SherlogProxy
$headers = @{
'X-Upload-Token' = $UploadToken
'X-Device-Name' = $deviceLabel
'X-Collector-Version' = $ScriptVersion
}
$maxAttempts = 3
$uploaded = $false
for ($attempt = 1; $attempt -le $maxAttempts -and -not $uploaded; $attempt++) {
try {
$irmArgs = @{
Uri = $UploadUrl; Method = 'Post'; InFile = $zipFile
ContentType = 'application/zip'; Headers = $headers; TimeoutSec = 180
}
if ($uploadProxy) { $irmArgs['Proxy'] = $uploadProxy }
$resp = Invoke-RestMethod @irmArgs
$base = ($UploadUrl -replace '/api/diagnostics/?$', '')
$resultUrl = "$base$($resp.url)"
Write-Host "Uploaded. Review at: $resultUrl" -ForegroundColor Green
Remove-Item $zipFile -Force -ErrorAction SilentlyContinue
$uploaded = $true
# Single deterministic line for automation (e.g. the Intune
# remediation wrapper) - independent of -ForegroundColor
# and of Write-Host/Write-Warning, neither of which flows
# through a normal PowerShell pipe.
Write-Output "SHERLOG_RESULT=$resultUrl"
} catch {
$status = $null
try { $status = [int]$_.Exception.Response.StatusCode } catch {}
$serverMsg = $_.ErrorDetails.Message
$reason = if ($serverMsg) { $serverMsg } else { $_.Exception.Message }
$permanent = $status -in 400, 401, 403, 404, 413
if ($permanent -or $attempt -eq $maxAttempts) {
Write-Warning "Upload failed ($status): $reason. Local zip kept: $zipFile"
Write-Output "SHERLOG_ERROR=upload failed ($status): $reason"
Send-SherlogPing -Phase failed -Reason "upload failed ($status): $reason"
} else {
Write-Host " Attempt $attempt/$maxAttempts failed ($status): $reason - retrying..."
Start-Sleep -Seconds (5 * $attempt)
}
}
}
}
}
}