The scanner has been reporting the same count for weeks, which is what a rule that only prints becomes. It now FAILS the build, and it looks where the leaks actually were: PowerShell, the installer, the seeds, generated JSON, the frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools. A line that is deliberate declares itself with an ADR-015-OK marker and a reason, so the claim is visible in review instead of tolerated in silence. What it found, fixed here: - The shadow client wrote one site's ShopDB URL into HKLM whenever the registry disagreed. At the site it was written for that reads as healing drift; anywhere else it overwrites the site's own address on every enforce cycle, and the site cannot win because the cycle repeats. The bay's value now wins, an explicit -BaseUrl seeds it, and with neither there is nothing honest to write, so it says so and skips. - The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a kiosk elsewhere quietly opened a server it has no business reaching. The fallback is now this site's site_base_url, baked in at seed time, and the dispatcher refuses rather than guessing when neither is set. Its legacy shortcut matcher derives the host from that URL instead of naming one. - The OpenAPI generator hardcoded a production hostname into every spec it generated, which then published to a public wiki. The relative mount is the only server it can honestly name; a site passes its own by environment. - Placeholders and examples in the UI and the client help offered real internal subnets and a real production URL. They now use documentation ranges. Both publication gates - the export scrub and the docs publishability test - carry the site patterns, which neither did. One plant's hostname, FQDN and internal networks are out of the documentation and the generated specs. Comments naming the reference site are reworded rather than deleted: the reasoning is worth keeping, the plant name is not what makes it true.
481 lines
22 KiB
PowerShell
481 lines
22 KiB
PowerShell
<#
|
|
.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.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,
|
|
[string]$SubType = (Get-ShopdbSubType))
|
|
$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
|
|
subtype = $SubType
|
|
counts = @{
|
|
installed = [int]$Summary.Installed
|
|
skipped = [int]$Summary.Skipped
|
|
failed = [int]$Summary.Failed
|
|
filtered = [int]$Summary.Filtered
|
|
}
|
|
results = $results
|
|
}
|
|
}
|
|
|
|
function Get-ShopdbSubType {
|
|
<#
|
|
.SYNOPSIS
|
|
What this PC is WITHIN its scope, read off the machine itself.
|
|
|
|
.DESCRIPTION
|
|
A display knows whether it is a Dashboard, a Lobby screen or the 3D print
|
|
room: the dispatcher reads C:\Enrollment\display-type.txt to choose which
|
|
page to open. It just never told shopdb, so the fleet table had to guess
|
|
from a DashboardDefault fqdn mapping that is empty unless somebody added a
|
|
row per kiosk. Reported by the device beats inferred from a lookup table,
|
|
the same way enforcerversion already works.
|
|
|
|
Returns '' when the file is absent - every non-display PC type - so the
|
|
report simply carries no subtype rather than a made-up one.
|
|
#>
|
|
param([string]$Path = 'C:\Enrollment\display-type.txt')
|
|
try {
|
|
if (-not (Test-Path $Path)) { return '' }
|
|
$value = (Get-Content -Path $Path -TotalCount 1 -ErrorAction Stop)
|
|
return ([string]$value).Trim()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
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 <field>, 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=<int>; Skipped=<int>; Failed=<int>; Filtered=<int>;
|
|
EnforcerVersion=<string>;
|
|
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, Get-ShopdbSubType
|