<# .SYNOPSIS Client-side helpers for sourcing GE-Enforce manifests from shopdb and reporting results back. Site-neutral reference kit - deploy alongside GE-Enforce; do NOT hardcode any site here. This module does NOT replace Install-FromManifest.ps1 (the engine). It only changes where the manifest JSON comes from (shopdb HTTP instead of a share file) and adds a result report. The engine, detection, self-heal, and SMB payload resolution stay exactly as they are. Resilience mirrors GE-Enforce: any failure is non-fatal (fail-safe). If shopdb is unreachable the client enforces from the last-known-good cached manifest and a PC is never left unmanaged because the web app is down. Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB BaseUrl e.g. https://shopdb.example.geaerospace.net ApiToken a geenforce.fetch (+ geenforce.report) managed service token, provisioned the same way as SFLD creds (Azure DSC). #> Set-StrictMode -Version Latest function Set-ShopdbTls { <# Force TLS 1.2 for the process-wide ServicePointManager. Windows PowerShell 5.1 (what runs as SYSTEM on the display image) does not always negotiate TLS 1.2 by default, so every network helper calls this first. Mirrors the pattern in docs/COLLECTOR-INTEGRATION.md. Best-effort: never throws. #> try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} } function Get-ShopdbProperty { <# Read a property/key from either a hashtable or a PSCustomObject, matching any of the given names case-insensitively. Returns $null when absent instead of throwing under Set-StrictMode. The engine's per-entry outcomes and summary may arrive as either shape, so all normalization goes through this. #> param($InputObject, [Parameter(Mandatory)][string[]]$Names) if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IDictionary]) { foreach ($wanted in $Names) { foreach ($key in @($InputObject.Keys)) { if ($key -is [string] -and $key -ieq $wanted) { return $InputObject[$key] } } } return $null } $properties = $InputObject.PSObject.Properties foreach ($wanted in $Names) { foreach ($property in $properties) { if ($property.Name -ieq $wanted) { return $property.Value } } } return $null } function Get-ShopdbConfig { param([string]$BaseUrl, [string]$ApiToken) $regPath = 'HKLM:\SOFTWARE\GE\ShopDB' if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) { $props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue $regBase = Get-ShopdbProperty $props 'BaseUrl' $regTok = Get-ShopdbProperty $props 'ApiToken' if (-not $BaseUrl -and $regBase) { $BaseUrl = [string]$regBase } if (-not $ApiToken -and $regTok) { $ApiToken = [string]$regTok } } # ApiToken is OPTIONAL: on a vaulted network the server may authorize by # source-IP allowlist, so a BaseUrl alone is a valid config. When a token # is present it is still sent (and honored) for token-authorized sites. if (-not $BaseUrl) { return $null } return @{ BaseUrl = $BaseUrl.TrimEnd('/'); ApiToken = $ApiToken } } function New-ShopdbAuthHeaders { # Only send X-API-Key when a token is configured; a token-less client # relies on the server's IP allowlist. param($Config) if ($Config.ApiToken) { return @{ 'X-API-Key' = $Config.ApiToken } } return @{} } function Sync-ShopdbManifest { <# Fetch the current published manifest for a scope into a local cache, using an ETag so an unchanged manifest is a cheap 304. On any network error, fall back to the last-known-good cached copy. Returns: @{ Path; Version; Source } where Source is 'shopdb' | 'cache-304' | 'cache-lastgood' | $null (nothing available) #> param( [Parameter(Mandatory)] [string]$Scope, [Parameter(Mandatory)] [hashtable]$Config, [string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce' ) Set-ShopdbTls if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null } $manifestPath = Join-Path $CacheDir "$Scope.json" $etagPath = Join-Path $CacheDir "$Scope.etag" $headers = New-ShopdbAuthHeaders $Config if (Test-Path $etagPath) { $headers['If-None-Match'] = (Get-Content -LiteralPath $etagPath -Raw).Trim() } $uri = "$($Config.BaseUrl)/api/geenforce/manifest?pctype=$([uri]::EscapeDataString($Scope))" try { $response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing ` -TimeoutSec 30 -ErrorAction Stop if ($response.StatusCode -eq 200) { # Validate JSON before overwriting the last-known-good cache: a proxy # or IIS error page served as 200 must not clobber the fallback. try { $null = ($response.Content | ConvertFrom-Json) } catch { throw "manifest response for $Scope was not valid JSON" } [System.IO.File]::WriteAllText($manifestPath, $response.Content) # PowerShell 7 returns header values as string arrays; 5.1 as scalars. # @(...)[0] yields a clean scalar in both. $etag = @($response.Headers['ETag'])[0] if ($etag) { Set-Content -LiteralPath $etagPath -Value $etag -NoNewline } $version = @($response.Headers['X-Manifest-Version'])[0] if ($version) { Set-Content -LiteralPath (Join-Path $CacheDir "$Scope.version") -Value $version -NoNewline } return @{ Path = $manifestPath; Version = $version; Source = 'shopdb' } } } catch { $status = $null $exResponse = Get-ShopdbProperty $_.Exception 'Response' if ($exResponse) { $status = [int]$exResponse.StatusCode } if ($status -eq 304 -and (Test-Path $manifestPath)) { return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' } } # Network/other failure: fall back to last-known-good. if (Test-Path $manifestPath) { return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' } } # Nothing cached (e.g. a fresh display): report why so the failure is not # silent. Include the HTTP status (401 auth, TLS-trust surfaces as a # non-status transport error) and the exception message. $errText = $_.Exception.Message if ($status) { $errText = "HTTP $status - $errText" } return @{ Path = $null; Version = $null; Source = $null; Error = $errText } } # 304 without exception (some PS versions) -> use cache. if (Test-Path $manifestPath) { return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' } } return @{ Path = $null; Version = $null; Source = $null } } function Read-CachedVersion { param([string]$CacheDir, [string]$Scope) $verPath = Join-Path $CacheDir "$Scope.version" if (Test-Path $verPath) { return (Get-Content -LiteralPath $verPath -Raw).Trim() } return $null } function Compare-ShopdbShadow { <# Shadow-mode comparison: do the shopdb manifest and the on-share manifest select the same ordered entry names? Returns @{ Same; ShopdbOnly; ShareOnly; OrderDiff }. Behavioral, not byte, comparison. #> param([Parameter(Mandatory)][string]$ShopdbManifestPath, [Parameter(Mandatory)][string]$ShareManifestPath) $shopdb = (Get-Content -LiteralPath $ShopdbManifestPath -Raw | ConvertFrom-Json) $share = (Get-Content -LiteralPath $ShareManifestPath -Raw | ConvertFrom-Json) $shopdbNames = @($shopdb.Applications | ForEach-Object { $_.Name }) $shareNames = @($share.Applications | ForEach-Object { $_.Name }) return @{ Same = (($shopdbNames -join '|') -eq ($shareNames -join '|')) ShopdbOnly = @($shopdbNames | Where-Object { $_ -notin $shareNames }) ShareOnly = @($shareNames | Where-Object { $_ -notin $shopdbNames }) OrderDiff = (($shopdbNames -join '|') -ne ($shareNames -join '|')) } } function Send-ShopdbReport { <# POST an enforcement report to shopdb. Best-effort: never throws, returns $true on success. Report is a hashtable matching POST /api/geenforce/report. #> param([Parameter(Mandatory)][hashtable]$Config, [Parameter(Mandatory)][hashtable]$Report) Set-ShopdbTls try { $body = ($Report | ConvertTo-Json -Depth 6) Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" ` -Method Post -Headers (New-ShopdbAuthHeaders $Config) ` -ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop | Out-Null return $true } catch { return $false } } function New-ShopdbReport { <# Build a report payload from an engine summary. `Summary` is expected to carry Installed/Skipped/Failed/Filtered counts and a Results list whose per-entry outcomes carry name/action and optionally selfhealed/exitcode/ message. The engine emits these in PascalCase (Name/Action/SelfHealed/ ExitCode/Message); the shopdb report contract is entirely lowercase, so this function maps every per-entry key down to lowercase. Case is matched case-insensitively, so a caller that already lowercased still works. #> param([string]$Hostname = $env:COMPUTERNAME, [Parameter(Mandatory)][string]$Scope, [int]$AppliedVersion, [Parameter(Mandatory)][hashtable]$Summary) $results = @(foreach ($entry in @($Summary.Results)) { if ($null -eq $entry) { continue } $mapped = @{ name = [string](Get-ShopdbProperty -InputObject $entry -Names 'name') action = [string](Get-ShopdbProperty -InputObject $entry -Names 'action') } $selfHealed = Get-ShopdbProperty -InputObject $entry -Names 'selfhealed' if ($null -ne $selfHealed) { $mapped['selfhealed'] = [bool]$selfHealed } $exitCode = Get-ShopdbProperty -InputObject $entry -Names 'exitcode' if ($null -ne $exitCode) { $mapped['exitcode'] = [int]$exitCode } $message = Get-ShopdbProperty -InputObject $entry -Names 'message' if ($message) { $mapped['message'] = [string]$message } $mapped }) return @{ hostname = $Hostname scopename = $Scope appliedversion = $AppliedVersion enforcerversion = $Summary.EnforcerVersion counts = @{ installed = [int]$Summary.Installed skipped = [int]$Summary.Skipped failed = [int]$Summary.Failed filtered = [int]$Summary.Filtered } results = $results } } function Get-ShopdbPayload { <# Fetch a payload blob by content hash over HTTPS, verify the sha256, and cache it locally (content-addressed, last-known-good). This is how a share-less PC pulls an installer the manifest references. Returns the local path, or $null on failure / hash mismatch. #> param( [Parameter(Mandatory)][string]$Sha256, [Parameter(Mandatory)][hashtable]$Config, [string]$Filename, [string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce' ) Set-ShopdbTls $sha = $Sha256.Trim().ToLower() $payloadDir = Join-Path $CacheDir 'payloads' if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null } $ext = if ($Filename) { [System.IO.Path]::GetExtension($Filename) } else { '' } $dest = Join-Path $payloadDir "$sha$ext" # Cache hit only counts if the cached bytes still hash correctly. if (Test-Path $dest) { if ((Get-FileHash -LiteralPath $dest -Algorithm SHA256).Hash.ToLower() -eq $sha) { return $dest } Remove-Item -LiteralPath $dest -Force -ErrorAction SilentlyContinue } $uri = "$($Config.BaseUrl)/api/geenforce/payload/$sha" $tmp = "$dest.tmp" try { Invoke-WebRequest -Uri $uri -Headers (New-ShopdbAuthHeaders $Config) ` -UseBasicParsing -TimeoutSec 120 -OutFile $tmp -ErrorAction Stop } catch { if (Test-Path $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } return $null } $got = (Get-FileHash -LiteralPath $tmp -Algorithm SHA256).Hash.ToLower() if ($got -ne $sha) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue return $null } Move-Item -LiteralPath $tmp -Destination $dest -Force return $dest } function Resolve-ShopdbPayloads { <# Rewrite a manifest so http/inline payload entries install from a locally fetched file instead of a share path - keeping the engine (and its SMB handling) untouched. For each entry with PayloadSha256 (PayloadSource http/inline), fetches + verifies the payload and points the entry's installer path at the local copy (Installer for MSI/EXE/CMD/BAT/INF, Script for PS1, Source for File). Returns a rewritten sibling manifest path, or the original path when there is nothing to resolve. Throws if a referenced payload cannot be fetched/verified (caller decides fail-safe behavior). #> param( [Parameter(Mandatory)][string]$ManifestPath, [Parameter(Mandatory)][hashtable]$Config, [string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce' ) $json = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json $pathField = @{ MSI='Installer'; EXE='Installer'; CMD='Installer'; BAT='Installer'; INF='Installer'; PS1='Script'; File='Source' } $changed = $false foreach ($entry in @($json.Applications)) { $src = [string](Get-ShopdbProperty $entry 'PayloadSource') $sha = [string](Get-ShopdbProperty $entry 'PayloadSha256') if (-not $sha -or ($src -ne 'http' -and $src -ne 'inline')) { continue } $field = $pathField[[string](Get-ShopdbProperty $entry 'Type')] if (-not $field) { continue } $ref = Get-ShopdbProperty $entry 'PayloadRef' $local = Get-ShopdbPayload -Sha256 $sha -Config $Config -Filename $ref -CacheDir $CacheDir if (-not $local) { throw "payload $sha for '$($entry.Name)' could not be fetched/verified" } # Write the LEAF filename, not the absolute path: the engine resolves the # entry field as Join-Path $InstallerRoot , and the runner sets # InstallerRoot to this same payloads dir. Passing an absolute path made # the engine double it (InstallerRoot + C:\...\ -> C:\...\C:\...). $leaf = Split-Path -Leaf $local if ($entry.PSObject.Properties.Name -contains $field) { $entry.$field = $leaf } else { $entry | Add-Member -NotePropertyName $field -NotePropertyValue $leaf } $changed = $true } if (-not $changed) { return $ManifestPath } $out = [System.IO.Path]::ChangeExtension($ManifestPath, '.resolved.json') ($json | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8 return $out } function Merge-ShopdbManifests { <# Merge the fleet-wide 'common' scope manifest into a pctype (display) scope manifest and write the merged result to a sibling file. This mirrors how the real GE-Enforce.ps1 applies scopes in order (common first, then the pctype), except we produce a single merged manifest for the unchanged engine to run once. Merge rules: - Entries are keyed by Name (case-insensitive). - common's unique entries come first, then all pctype entries, so common's own apps enforce ahead of the pctype's, matching the real script's common-then-pctype ordering. - On a Name conflict the PCTYPE entry wins (the pctype override replaces common's version, and keeps common's slot out of the list). Returns the merged manifest path. If there is no common manifest, returns the primary path unchanged. The merged manifest keeps the pctype manifest's top-level Version (that is the version the display reports as applied). #> param( [Parameter(Mandatory)][string]$PrimaryManifestPath, [string]$CommonManifestPath ) if (-not $CommonManifestPath -or -not (Test-Path $CommonManifestPath)) { return $PrimaryManifestPath } $primary = Get-Content -LiteralPath $PrimaryManifestPath -Raw | ConvertFrom-Json $common = Get-Content -LiteralPath $CommonManifestPath -Raw | ConvertFrom-Json $primaryApps = @($primary.Applications) $commonApps = @($common.Applications) $primaryNames = @{} foreach ($app in $primaryApps) { $name = [string]$app.Name if ($name) { $primaryNames[$name.ToLower()] = $true } } # common entries the pctype does not override, then all pctype entries. $merged = @() foreach ($app in $commonApps) { $name = [string]$app.Name if ($name -and $primaryNames.ContainsKey($name.ToLower())) { continue } $merged += $app } foreach ($app in $primaryApps) { $merged += $app } $primary.Applications = $merged $out = [System.IO.Path]::ChangeExtension($PrimaryManifestPath, '.merged.json') ($primary | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8 return $out } function ConvertTo-ShopdbSummary { <# Normalize whatever the engine returns into a well-formed summary hashtable so New-ShopdbReport always has clean input, without assuming the engine was fixed. Expected engine contract (what a compliant engine returns): @{ Installed=; Skipped=; Failed=; Filtered=; EnforcerVersion=; Results=@( @{ Name; Action; SelfHealed; ExitCode; Message } ... ) } This adapter tolerates any of: - $null / empty -> a zeroed summary. - a hashtable or PSCustomObject with those keys (any casing). - an array / multiple emitted objects -> the last element that looks like a summary (has any count or a Results list) is used. - a bare return code (int) or unrecognized object -> a zeroed summary. Counts are coerced to int; a missing EnforcerVersion is left for the caller to default. Always returns a hashtable. #> param($EngineResult, [string]$DefaultEnforcerVersion = '2.6') $zero = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @(); EnforcerVersion = $DefaultEnforcerVersion } $candidate = $EngineResult if ($candidate -is [System.Array]) { $picked = $null foreach ($item in $candidate) { if ($null -eq $item) { continue } $looksLikeSummary = $false foreach ($key in @('Installed', 'Skipped', 'Failed', 'Filtered', 'Results')) { if ($null -ne (Get-ShopdbProperty -InputObject $item -Names $key)) { $looksLikeSummary = $true; break } } if ($looksLikeSummary) { $picked = $item } } $candidate = $picked } if ($null -eq $candidate) { return $zero } # A bare return code (int) or any object without the expected members reads # as all-null through Get-ShopdbProperty below, which yields the zeroed # summary - exactly the fail-open behavior we want for a non-compliant engine. $results = Get-ShopdbProperty -InputObject $candidate -Names 'Results' $enforcerVersion = [string](Get-ShopdbProperty -InputObject $candidate -Names 'EnforcerVersion') if (-not $enforcerVersion) { $enforcerVersion = $DefaultEnforcerVersion } $toInt = { param($value) $parsed = 0 if ($null -ne $value -and [int]::TryParse([string]$value, [ref]$parsed)) { return $parsed } return 0 } return @{ Installed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Installed')) Skipped = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Skipped')) Failed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Failed')) Filtered = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Filtered', 'PCFiltered')) Results = @($results) EnforcerVersion = $enforcerVersion } } Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, ` Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, ` Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, ` ConvertTo-ShopdbSummary