geenforce: display-readiness batch (server hardening, PS client wiring, display scope)

Get GE-Enforce closer to running on credential-less Intune/Entra display PCs
that pull manifest + payloads over HTTPS instead of SMB.

Server (plugins/geenforce/api/routes.py):
- Rate-limit + 512MB served-size ceiling on GET /payload/<sha256> (reuses the
  login limiter's cache pattern, config-overridable via GEENFORCE_PAYLOAD_*).
- New tests: payload hardening, manifestblobs model-vs-migration parity, and a
  report-contract test locking the lowercase per-entry report keys.

PS client (plugins/geenforce/client/):
- Fix New-ShopdbReport per-entry key casing to lowercase (name/action/selfhealed/
  exitcode/message) to match what the server reads; the engine emits PascalCase.
- Enforce TLS 1.2 in the network functions.
- Fetch + merge the fleet-wide common scope alongside the pctype scope
  (pctype wins on conflict; -NoCommon opt-out).
- Normalize whatever the engine returns into a well-formed summary.
- Make the empty-cache fail-safe observable: event-log entry + report ping
  instead of a silent exit 0.

Manifest (plugins/geenforce/seed_display_scope.py + docs/GE-ENFORCE-DISPLAY.md):
- Seed a gea-shopfloor-display scope: 4 Edge kiosk drift-heal registry entries
  + 1 data-driven dispatcher (Dashboard/Lobby/3DPrintRoom via display-type.txt).
  Kiosk EXEs stay image-baked; the manifest heals policy/config drift only.
- Documents the common SMB-payload audit (entries needing http/inline before a
  share-less display can inherit common).

Migration registry (shopdb/plugins/alembic_template.py + test):
- Register the pre-existing manifestblobs and the new printersupplyalerts tables
  in PLUGIN_TABLE_OWNERS; update EXPECTED_HEAD_REVISION for geenforce (0002blobs),
  printers (0002supplyalerts), and printedparts (0004txnrev) which had drifted.
This commit is contained in:
cproudlock
2026-07-23 08:16:38 -04:00
parent b211e817d5
commit 9d65ef103d
12 changed files with 1261 additions and 25 deletions

View File

@@ -24,6 +24,16 @@
.PARAMETER ShadowMode
Fetch + compare + report, but install from the share (no behavior change).
.PARAMETER CommonScope
The fleet-wide scope every PC inherits (default 'common'). Its manifest is
fetched in addition to -Scope and merged in, so a display enforces its own
scope entries PLUS common's. On a Name conflict the -Scope (pctype) entry
wins. Set -NoCommon to disable, or point at a different common scope name.
.PARAMETER NoCommon
Do not fetch or merge the common scope; enforce -Scope alone (the original
single-scope behavior).
.NOTES
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
@@ -34,6 +44,8 @@ param(
[Parameter(Mandatory)] [string]$EnginePath,
[string]$ShareManifestPath,
[switch]$ShadowMode,
[string]$CommonScope = 'common',
[switch]$NoCommon,
[string]$BaseUrl,
[string]$ApiToken,
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
@@ -46,6 +58,27 @@ function Write-Log {
Write-Host $line
}
function Write-ShopdbEventLog {
<#
Write a Windows Application event-log entry under source 'ShopdbEnforce'.
Used to make an otherwise silent fail-safe (no manifest and an empty cache)
observable to whoever watches the display. Best-effort: registering the
source needs admin, which the SYSTEM scheduled task has; any failure is
swallowed so it can never break the fail-safe.
#>
param([string]$Message,
[string]$EntryType = 'Warning',
[int]$EventId = 1001)
$source = 'ShopdbEnforce'
try {
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
New-EventLog -LogName Application -Source $source -ErrorAction Stop
}
Write-EventLog -LogName Application -Source $source -EntryType $EntryType `
-EventId $EventId -Message $Message -ErrorAction Stop
} catch {}
}
try {
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
@@ -57,7 +90,21 @@ try {
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
if (-not $sync.Path) {
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
# Fail-safe stays (exit 0), but a fresh display with an empty cache would
# otherwise enforce nothing SILENTLY. Surface it: a Windows event-log
# entry plus a best-effort report ping so it shows in Enforcement Reports.
$reason = if ($sync.Error) { $sync.Error } else { 'shopdb unreachable and no cached manifest' }
Write-Log "No manifest available for $Scope ($reason)." 'WARN'
Write-ShopdbEventLog -Message ("GE-Enforce could not fetch a manifest for scope '$Scope' and has no cached copy; nothing was enforced this cycle. Reason: $reason") -EntryType 'Error' -EventId 1001
try {
$failReport = New-ShopdbReport -Scope $Scope -AppliedVersion 0 -Summary @{
Installed = 0; Skipped = 0; Failed = 1; Filtered = 0; EnforcerVersion = '2.6'
Results = @(@{ Name = '(manifest-fetch)'; Action = 'failed'; Message = $reason })
}
if (Send-ShopdbReport -Config $config -Report $failReport) {
Write-Log 'Reported empty-cache fetch failure to shopdb.'
}
} catch {}
exit 0
}
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
@@ -75,29 +122,49 @@ try {
# Which manifest the engine actually runs against.
if ($ShadowMode -and $ShareManifestPath) {
# Shadow: install from the share exactly as today (no payload resolve).
# Shadow: install from the share exactly as today (no payload resolve,
# no common merge - the share already carries its own common scope).
$manifestToRun = $ShareManifestPath
} else {
# Common-scope inheritance: a display enforces its own scope PLUS the
# fleet-wide common scope. Fetch common too (best-effort, same fail-safe
# cache) and merge it in with the pctype winning on conflict. Skipped
# when -NoCommon, or when this run IS the common scope.
$manifestToMerge = $sync.Path
if (-not $NoCommon -and $CommonScope -and ($CommonScope -ine $Scope)) {
$commonSync = Sync-ShopdbManifest -Scope $CommonScope -Config $config
if ($commonSync.Path) {
$manifestToMerge = Merge-ShopdbManifests -PrimaryManifestPath $sync.Path -CommonManifestPath $commonSync.Path
if ($manifestToMerge -ne $sync.Path) {
Write-Log "Merged common scope '$CommonScope' (from $($commonSync.Source), v$($commonSync.Version)) into $Scope."
}
} else {
Write-Log "Common scope '$CommonScope' unavailable (no fetch, no cache) - enforcing $Scope alone." 'WARN'
}
}
# Cutover: stage any http/inline payloads to local files and rewrite the
# manifest to point at them, so the UNCHANGED engine installs from local
# (no SMB needed for share-less PCs).
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $sync.Path -Config $config
if ($manifestToRun -ne $sync.Path) {
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $manifestToMerge -Config $config
if ($manifestToRun -ne $manifestToMerge) {
Write-Log "Resolved http/inline payloads to local files: $manifestToRun"
}
}
# --- INTEGRATION POINT ---------------------------------------------------
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
# to your engine's actual return/parse; the shape below is the contract.
# Run the engine. EXPECTED ENGINE CONTRACT: Install-FromManifest.ps1 returns
# a summary object (hashtable or PSCustomObject) carrying integer counts
# Installed / Skipped / Failed / Filtered
# a string EnforcerVersion, and a Results list of per-entry outcomes
# @{ Name; Action; SelfHealed; ExitCode; Message }.
# The engine may not honor that yet: it might return $null, a bare return
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
# returns into a well-formed summary hashtable so the report stage always
# gets clean input (we do NOT assume the engine was fixed).
Write-Log "Running engine against $manifestToRun"
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
if (-not $summary) {
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
}
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
# Report the result (best-effort).
$appliedVersion = 0

View File

@@ -21,6 +21,43 @@
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'
@@ -46,6 +83,7 @@ function Sync-ShopdbManifest {
[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"
@@ -80,7 +118,12 @@ function Sync-ShopdbManifest {
if (Test-Path $manifestPath) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
}
return @{ Path = $null; Version = $null; Source = $null }
# 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) {
@@ -123,6 +166,7 @@ function Send-ShopdbReport {
#>
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" `
@@ -137,14 +181,31 @@ function Send-ShopdbReport {
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 of
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
per-entry outcomes into this at the call site.
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
@@ -156,7 +217,7 @@ function New-ShopdbReport {
failed = [int]$Summary.Failed
filtered = [int]$Summary.Filtered
}
results = @($Summary.Results)
results = $results
}
}
@@ -173,6 +234,7 @@ function Get-ShopdbPayload {
[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 }
@@ -241,6 +303,125 @@ function Resolve-ShopdbPayloads {
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
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
ConvertTo-ShopdbSummary