# Detect-TATERAgent.ps1 - Intune Proactive Remediation Detection (Go agent v2.x) # # DEPLOYMENT ROLES (per CB field experience): # PRIMARY install detection for AVD multi-session hosts (Win32 App # unsupported by Microsoft on those, so Proactive Remediation is the # install path; this script triggers Remediate to do the install). # ONGOING HEALTH MONITORING when layered on top of Win32 App # deployment: catches drift Win32 App can't (stale binary, dead # apiBase, broken config, stopped service). For workstations, the # RECOMMENDED initial deployment is Win32 App (see # Docs/Help/agent-deployment.html#intune-win32) with this script # layered on top for self-healing. # # Exit 0 -> COMPLIANT (no remediation runs) # Exit 1 -> NON-COMPLIANT (Intune triggers Remediate-TATERAgent.ps1) # # Output goes to Intune's per-device script result panel. Keep messages short # but explicit - they're the only field-side telemetry we get. # # Side effect: writes C:\ProgramData\TATER\detect-status.json on every run so # Endpoint Central / RDP-collected forensics can see the most recent decision # without waiting for Intune sync. # # 7-BIT ASCII ONLY, in code and comments, and no PowerShell 7-only syntax. # Intune runs this under Windows PowerShell 5.1, which reads a BOM-less file as # the ANSI codepage: a UTF-8 em-dash inside a double-quoted string then decodes # to a character PowerShell accepts as a closing quote, and the script stops # parsing. Installer/tests/Test-ParsePs1.ps1 checks for both. $ErrorActionPreference = 'Stop' $TATERDir = Join-Path $env:ProgramData 'TATER' $ConfigPath = Join-Path $TATERDir 'config.json' $SecretPath = Join-Path $TATERDir 'config.secret.json' # ADO #1828 sidecar $LogDir = Join-Path $TATERDir 'Logs' $ScansDir = Join-Path $TATERDir 'Scans' $SentinelPath = Join-Path $TATERDir 'last-upload.json' $DetectStatusPath = Join-Path $TATERDir 'detect-status.json' $MaxAgeHours = 48 $BrickProneFloor = [Version]"2.5.2" # ADO #1145: pre-v2.5.2 in-place updater bricks ~1-in-7 devices - never fast-path these, force full MSI reinstall $issues = @() $installedVersion = $null # --- ADO #1828 / #2735 - the split config ------------------------------------ # From the release that arms ADO #1828 the installer stops writing apiKey into # config.json (which BUILTIN\Users can read) and writes it to config.secret.json # beside it, readable only by SYSTEM and Administrators. On such a device # config.json carrying NO key is the CORRECT state, not a broken one. # # Presence is decided WITHOUT reading the file: a context that is not SYSTEM or # an Administrator cannot read it, and nothing here has any business holding # the key. The file's contents are never read, logged or written anywhere. # # Returns one of: # absent no sidecar (every device installed before the split) # empty zero-length file - carries no key # trusted present, owned by SYSTEM or BUILTIN\Administrators # present-unverified present, but this context could not read its owner # untrusted a reparse point, or owned by someone else. The agent # refuses such a file (verifySecretFile in # Agent/go/internal/config), so it does not count as a key. # %ProgramData%\TATER lets standard users create files, # so "a file with that name exists" proves nothing alone. # KEEP THIS FUNCTION IDENTICAL in Detect-TATERAgent.ps1, Remediate-TATERAgent.ps1, # Collect-TATERStatus.ps1 and Agent\Verify-TATERAgent.ps1 - each is uploaded or # fetched on its own, so they cannot share a module. # Installer/tests/Test-SplitConfigConsumers.ps1 asserts the four copies match. function Get-TATERSecretSidecarState { param([string]$Path) try { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return 'absent' } $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return 'absent' } if ($item.Length -le 0) { return 'empty' } if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { return 'untrusted' } $ownerSid = $null try { $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop $ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value } catch { return 'present-unverified' } if (-not $ownerSid) { return 'present-unverified' } # S-1-5-18 = LocalSystem, S-1-5-32-544 = BUILTIN\Administrators. SIDs, not # names: the names are localised and the SIDs are not. if ($ownerSid -eq 'S-1-5-18' -or $ownerSid -eq 'S-1-5-32-544') { return 'trusted' } return 'untrusted' } # --- end ADO #2735 shared helper --------------------------------------------- # Ensure data dir exists so we can always write detect-status.json if (-not (Test-Path $TATERDir)) { try { New-Item -ItemType Directory -Path $TATERDir -Force | Out-Null } catch { } } # 1 - Windows service installed and running $svc = Get-Service -Name 'TATERAgent' -ErrorAction SilentlyContinue if (-not $svc) { $issues += "service not installed" } elseif ($svc.Status -ne 'Running') { $issues += "service is $($svc.Status), expected Running" } elseif ($svc.StartType -eq 'Disabled') { $issues += "service start type is Disabled" } # 2 - config.json present with required keys (strip BOM defensively) $configValid = $false $sidecarState = Get-TATERSecretSidecarState $SecretPath $sidecarHoldsKey = ($sidecarState -eq 'trusted' -or $sidecarState -eq 'present-unverified') $keyLocation = 'none' if ($sidecarHoldsKey) { $keyLocation = 'sidecar' } if ($sidecarState -eq 'untrusted') { # Reported, not just ignored: the agent refuses this file, and an operator # looking at the directory would otherwise assume the device is split. $issues += "config.secret.json exists but is not owned by SYSTEM/Administrators (or is a link) - the agent refuses it" } if (-not (Test-Path $ConfigPath)) { $issues += "config.json missing" } else { try { $raw = Get-Content $ConfigPath -Raw -Encoding UTF8 $raw = $raw.TrimStart([char]0xFEFF) # strip UTF-8 BOM if present $cfg = $raw | ConvertFrom-Json foreach ($key in @('apiBase', 'tenantId', 'organizationId')) { if (-not $cfg.$key) { $issues += "config missing key: $key" } } # ADO #2735: the key may legitimately live ONLY in the sidecar. Flag a # missing key only when it is in NEITHER place. $inlineKeyPresent = [bool]($cfg.apiKey -or $cfg.encryptedApiKey) if ($inlineKeyPresent -and $sidecarHoldsKey) { $keyLocation = 'both' } elseif ($sidecarHoldsKey) { $keyLocation = 'sidecar' } elseif ($inlineKeyPresent) { $keyLocation = 'inline' } if (-not $inlineKeyPresent -and -not $sidecarHoldsKey) { $issues += "no API key: config.json has no apiKey/encryptedApiKey and there is no usable config.secret.json (sidecar: $sidecarState)" } # Reject obvious placeholders that have shipped in earlier remediate # scripts - unless a sidecar holds the key, because the agent prefers # the sidecar and never uses the inline value then. if (-not $sidecarHoldsKey -and ($cfg.apiKey -in @('your-api-key', 'REPLACE_ME', 'TODO', ''))) { $issues += "config has placeholder apiKey ('$($cfg.apiKey)')" } if ($cfg.tenantId -in @('your-azure-tenant-id', 'REPLACE_ME', 'TODO', '')) { $issues += "config has placeholder tenantId" } if ($issues.Count -eq 0) { $configValid = $true } } catch { $issues += "config.json invalid JSON: $($_.Exception.Message)" } } # 3 - Installed binary version (best-effort; helps Detect surface stale agents) $exePaths = @( 'C:\ProgramData\TATER\tater-agent.exe', "$env:ProgramFiles\TATER\tater-agent.exe" ) foreach ($p in $exePaths) { if (Test-Path $p) { try { $installedVersion = (Get-Item $p).VersionInfo.ProductVersion break } catch { } } } # 3b - Stale-version detection (ADO #569). # Pre-v2.4.6 agents have a hardcoded dead auto-update URL # (func-tater-api-prod.azurewebsites.net) and cannot self-heal. Flag them # as non-compliant so Intune triggers Remediate, which force-replaces the # binary via the verified binary-swap pattern (not MSI - see ADO #568). if ($installedVersion) { try { $iv = [Version]$installedVersion if ($iv -lt [Version]'2.4.6') { $issues += "agent v$installedVersion has broken auto-update (ADO #569) - force-upgrade required" } else { # Even for >= 2.4.6, fetch latest from the API and flag if behind. # Best-effort: silent failure if API unreachable (caught below). $latest = (Invoke-RestMethod -Uri 'https://api.tatersecurity.com/api/agent/version' -TimeoutSec 8 -UseBasicParsing).version if ($latest -and ([Version]$latest -gt $iv)) { $behindMsg = "agent v$installedVersion is behind latest v$latest" if ($iv -lt $BrickProneFloor) { $behindMsg += " (below v$BrickProneFloor brick-prone-updater floor - Remediate will force a full reinstall, not a fast binary swap)" } $issues += $behindMsg } } } catch { } # don't fail Detect on network blip } # 4 - Recent log or scan evidence (only if service+config look healthy) $lastEvidence = $null if ($svc -and $svc.Status -eq 'Running' -and $configValid) { if (Test-Path $LogDir) { $latest = Get-ChildItem -Path $LogDir -Filter 'TATER-Agent_*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($latest) { $lastEvidence = $latest.LastWriteTime } } if (Test-Path $ScansDir) { $latest = Get-ChildItem -Path $ScansDir -Filter '*.json' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($latest -and (-not $lastEvidence -or $latest.LastWriteTime -gt $lastEvidence)) { $lastEvidence = $latest.LastWriteTime } } if (-not $lastEvidence) { $issues += "no log/scan output yet" } else { $ageHours = ((Get-Date) - $lastEvidence).TotalHours if ($ageHours -gt $MaxAgeHours) { $issues += "last output $([math]::Round($ageHours,1))h ago (>${MaxAgeHours}h)" } } } # 5 - Last-upload sentinel - confirms server-side acceptance, not just local activity $lastUploadAgeHours = $null if (Test-Path $SentinelPath) { try { $raw = Get-Content $SentinelPath -Raw -Encoding UTF8 $raw = $raw.TrimStart([char]0xFEFF) $sentinel = $raw | ConvertFrom-Json $lastUpload = [DateTime]::Parse($sentinel.timestamp) $lastUploadAgeHours = ((Get-Date).ToUniversalTime() - $lastUpload).TotalHours if ($lastUploadAgeHours -gt $MaxAgeHours) { $issues += "last successful upload $([math]::Round($lastUploadAgeHours,1))h ago - agent scanning locally but cannot reach API" } } catch { $issues += "last-upload.json malformed" } } elseif ($svc -and $svc.Status -eq 'Running' -and $installedVersion -and $installedVersion -ge '2.4.0') { # v2.4.0+ writes the sentinel on every successful upload. Absent + running # = uploads have never succeeded since this install. Almost always API auth. $issues += "agent v$installedVersion has never had a successful upload (auth failure?)" } # Persist decision to local JSON so admins can pull via Endpoint Central $status = [ordered]@{ timestamp = (Get-Date).ToUniversalTime().ToString('o') hostname = $env:COMPUTERNAME detectVersion = '2026.10.11-detect-a' serviceStatus = if ($svc) { $svc.Status.ToString() } else { 'NotInstalled' } serviceStartType = if ($svc) { $svc.StartType.ToString() } else { 'NotInstalled' } configPresent = (Test-Path $ConfigPath) configValid = $configValid apiKeyLocation = $keyLocation # none | inline | sidecar | both (ADO #1828) secretSidecar = $sidecarState # absent | empty | trusted | present-unverified | untrusted installedVersion = $installedVersion lastEvidence = if ($lastEvidence) { $lastEvidence.ToUniversalTime().ToString('o') } else { $null } lastUploadAgeHrs = $lastUploadAgeHours issues = $issues compliant = ($issues.Count -eq 0) } try { $status | ConvertTo-Json -Depth 4 | Out-File -FilePath $DetectStatusPath -Encoding utf8 -Force } catch { } if ($issues.Count -gt 0) { # No "??" here: Intune runs this under Windows PowerShell 5.1, where the # null-coalescing operator is a parse error that stops the WHOLE script. $shownVersion = if ($installedVersion) { $installedVersion } else { '?' } Write-Output "NON-COMPLIANT (v$shownVersion): $($issues -join ' | ')" exit 1 } Write-Output "COMPLIANT: TATERAgent v$installedVersion service running, config valid (api key: $keyLocation), upload within ${MaxAgeHours}h" exit 0