diff --git a/docs/GE-ENFORCE-CLIENT.md b/docs/GE-ENFORCE-CLIENT.md index b729d39..59874f2 100644 --- a/docs/GE-ENFORCE-CLIENT.md +++ b/docs/GE-ENFORCE-CLIENT.md @@ -95,6 +95,50 @@ at the end of its main loop) and knows each entry's action; shape them into the `results` list at the call site (`New-ShopdbReport` in the kit takes a summary with `Installed/Skipped/Failed/Filtered` + a `Results` list). +The engine emits per-entry outcomes in PascalCase (`Name/Action/SelfHealed/ +ExitCode/Message`); `New-ShopdbReport` maps every per-entry key down to the +lowercase names above (`name/action/selfhealed/exitcode/message`) before POST, +so the entire wire contract shopdb reads is lowercase. `ConvertTo-ShopdbSummary` +first normalizes whatever the engine returns (a well-formed summary, a bare +return code, `$null`, or several emitted objects) into the count/results shape +`New-ShopdbReport` expects, so a not-yet-compliant engine still produces a valid +report. + +## Common-scope inheritance + +Every PC inherits the fleet-wide `common` scope on top of its own pctype scope, +mirroring the real GE-Enforce.ps1 (which applies `common\manifest.json` first, +then the pctype's). `Invoke-ShopdbEnforce.ps1` fetches the `common` scope in +addition to `-Scope` and merges it in via `Merge-ShopdbManifests`: + +- entries are keyed by `Name` (case-insensitive); +- common's unique entries come first, then all pctype entries (common enforces + ahead of the pctype, as on the share); +- on a `Name` conflict the pctype entry wins (its override replaces common's). + +Common is fetched over the same fail-safe path (ETag + last-known-good cache). +Pass `-NoCommon` to enforce the pctype alone, or `-CommonScope ` to inherit +a different fleet scope. A run whose `-Scope` already is the common scope does +not merge itself. This is how the three display subtypes (Dashboard, Lobby, 3D +Print Room), selected by `C:\Enrollment\display-type.txt`, pick up shared policy +without duplicating it per subtype. + +## Fail-safe is observable, not silent + +Any error still exits 0 - a bad web app never blocks or breaks a PC. But a fresh +display with an EMPTY cache (first boot, shopdb unreachable or the token +rejected with 401 / a TLS-trust failure) would otherwise enforce nothing +*silently*. When no manifest and no cache are available, the kit: + +- writes a Windows Application event-log entry (source `ShopdbEnforce`, event id + 1001, type Error) naming the scope and the reason (HTTP status or transport + error), and +- fires a best-effort report ping (counts `failed: 1`, a single + `(manifest-fetch)` result carrying the reason) so the miss surfaces under + GE-Enforce > Enforcement Reports. + +The cycle still exits 0; the signal just makes the no-enforcement state visible. + ## Cutover (safe, staged) 1. **Configure** the registry values on a canary PC; mint the token. diff --git a/docs/GE-ENFORCE-DISPLAY.md b/docs/GE-ENFORCE-DISPLAY.md new file mode 100644 index 0000000..630f5af --- /dev/null +++ b/docs/GE-ENFORCE-DISPLAY.md @@ -0,0 +1,110 @@ +# GE-Enforce: the gea-shopfloor-display scope + +Displays are the share-less corner of the fleet. They are Intune/Entra-joined, +credential-less kiosk PCs that pull their manifest over HTTPS on port 443 and +authenticate with a read-only service PAT scoped `geenforce.fetch`, sent as +`X-API-Key`. They have no SMB share mount. The kiosk engine and the kiosk +browser are baked into the display image, not shipped over HTTPS, so the display +manifest heals POLICY / CONFIG drift and inherited common entries, never EXEs. + +There are three display subtypes, selected by `C:\Enrollment\display-type.txt`: +`Dashboard`, `Lobby`, and `3DPrintRoom`. + +## Authoring the scope + +The scope is authored programmatically by +`plugins/geenforce/seed_display_scope.py`, which builds a manifest dict and +hands it to `service.replace_scope_draft` (the same call the `import-share` CLI +uses), then attaches the inline dispatcher payload. From a Flask app context: + +```python +from plugins.geenforce.seed_display_scope import seed_display_scope +seed_display_scope(publish=True) # publish=False leaves it as a draft +``` + +`replace_scope_draft` is an idempotent draft rebuild. `publish=True` additionally +freezes an immutable published snapshot (that step is not idempotent: it always +creates a new version). + +### What the scope contains + +1. Four `Registry` drift-heal entries that re-assert the Microsoft Edge kiosk + relaunch policies set at imaging by `09-Setup-Display.ps1`. Each writes the + value and detects drift with `DetectionMethod = ValueMatches` against the + same path/name, so a display that loses a policy self-heals on the next + enforce cycle with no keyboard or mouse on site: + - `RelaunchNotification = 2` (DWord, Required auto-restart) + - `RelaunchNotificationPeriod = 3600000` (DWord, 1 hour) + - `RelaunchHeadsUpPeriod = 60000` (DWord, 1 minute) + - `RelaunchWindow` (String, JSON, 02:00 start, 120 minute duration) +2. One `PS1` dispatcher, delivered inline over HTTPS. It reads + `C:\Enrollment\display-type.txt` and launches the kiosk target for the + subtype. The subtype -> route map is a data-driven table + (`DISPLAY_TYPE_TARGETS`) at the top of both the seed module and the generated + script, so targets are easy to edit. `DetectionMethod = Always` so it + re-asserts each cycle, but the script is idempotent (it skips relaunch if a + kiosk process is already serving the target URL). + +### display-type -> target map + +| display-type.txt | kiosk route | notes | +| --- | --- | --- | +| `Dashboard` | `/shopfloor` | core ShopfloorDashboard, standalone full-screen | +| `Lobby` | `/tv` | slides plugin TV dashboard (surface `lobby`) | +| `3DPrintRoom` | `/parts-kiosk` | **PLACEHOLDER, TODO-confirm** printedparts parts kiosk route; confirm the real 3D-print-room target with the floor team before publishing to production displays | + +## Inheritance: the client merges common + +The manifest model has no inheritance column. The display scope is a plain +(non-common) runtime scope carrying only display-specific entries. The CLIENT +merges the fleet-wide `common` scope underneath the display scope at fetch time. +So `common` is where the fleet-wide policy/config/self-update entries live, and +`gea-shopfloor-display` adds the kiosk-only entries on top. + +## Blocker: common carries SMB payloads that break on share-less displays + +Before a share-less display can safely inherit `common`, every `common` entry +that pulls a payload file from the SMB share must first be given an `http` or +`inline` payload (with a `payloadsha256`). A display has no share mount, so any +inherited entry whose `Installer` / `Source` / `Script` resolves to a +share-relative path will fail its fetch. + +Registry entries in `common` carry no payload (they write inline reg values) and +are safe to inherit as-is. The entries below reference a share file and must be +converted first. This audit is the authoritative to-convert list; the payload +bytes themselves are not converted here (that needs the real payload files). + +Common entries that reference an SMB/share payload (as of the on-share +`common/manifest.json`, 22 entries, Version 2.0): + +| # | Common entry | Type | Share payload | Field | +| --- | --- | --- | --- | --- | +| 1 | Adobe Acrobat Reader DC | CMD | `scripts/Install-AcroReader.cmd` | Installer | +| 2 | Migrate pc-type.txt to gea-shopfloor-* taxonomy | PS1 | `scripts/Migrate-PCType.ps1` | Script | +| 3 | WJF Defect Tracker | MSI | `apps/WJF_Defect_Tracker.msi` | Installer | +| 4 | 3OF9 barcode font | File | `configs/3OF9.ttf` | Source | +| 5 | Edge IE-Mode site list | File | `configs/enterprise-mode-site-list.xml` | Source | +| 6 | Ensure VNC firewall rule | PS1 | `scripts/ensure-vnc-firewall.ps1` | Script | +| 7 | FMS hosts pin (WJFMS3.AE.GE.COM) | PS1 | `scripts/Set-FmsHostsEntry.ps1` | Script | +| 8 | Oracle Client 11.2 | CMD | `scripts/Install-Oracle11r2.cmd` | Installer | +| 9 | PrinterInstallerMap (site-map printer installer) | File | `apps/PrinterInstallerMap.exe` | Source | +| 10 | OpenText HostExplorer ShopFloor | CMD | `scripts/Setup-OpenText.cmd` | Installer | +| 11 | GE-Enforce dispatcher (self-update) | File | `GE-Enforce.ps1` | Source | +| 12 | Install-FromManifest lib (self-update) | File | `lib/Install-FromManifest.ps1` | Source | +| 13 | Report asset (host + IP + machine number) to ShopDB | PS1 | `apps/Report-AssetToShopDB.ps1` | Script | +| 14 | EventSaver screensaver (binary) | File | `apps/EventSaver.scr` | Source | +| 15 | EventSaver screensaver (config) | File | `configs/EventSaver.ini` | Source | +| 16 | EventSaver enable (per-user screensaver) | PS1 | `scripts/Set-EventSaverScreensaver.ps1` | Script | +| 17 | EventSaver power (keep monitor awake) | PS1 | `scripts/Set-EventSaverPower.ps1` | Script | +| 18 | EventSaver disable (measuring-tool bays) | PS1 | `scripts/Set-EventSaverDisable.ps1` | Script | +| 19 | EventSaver disable (specific hostnames) | PS1 | `scripts/Set-EventSaverDisable.ps1` | Script | + +Safe to inherit as-is (Registry entries, no share payload): `3OF9 barcode font +registry entry`, `Edge IE-Mode policy level`, `Edge IE-Mode policy site list +pointer`. + +Not every entry above is relevant to a display (a display needs no Oracle +client, OpenText, or Defect Tracker), so a follow-up decision is which common +entries a display should actually run (via `PCTypes` targeting) versus which +must be repackaged as `http`/`inline`. But any that survive targeting must have +a non-SMB payload before displays inherit common. diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index e2a773f..5bfcbf4 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -9,14 +9,15 @@ Two audiences: """ import os +import time from functools import wraps -from flask import Blueprint, request, Response, send_file +from flask import Blueprint, request, Response, send_file, current_app from flask_jwt_extended import jwt_required from sqlalchemy.exc import IntegrityError from shopdb.api import ( - db, success_response, error_response, ErrorCodes, require_permission, + db, cache, success_response, error_response, ErrorCodes, require_permission, service_token_authorized, Setting, Application, ) @@ -101,6 +102,59 @@ def get_manifest(): # -- client payload download (share-less installer delivery) ------------------ +# GET /payload hardening. This endpoint is reachable with only a read-only +# geenforce.fetch token, so a leaked display token must not be able to pull +# unbounded bytes or hammer it. Two bounds cap the blast radius: +# - a per-IP fixed-window rate limit (same shape and cache extension as the +# login limiter in shopdb.core.api.auth, so no new dependency), and +# - a served-size ceiling: refuse to stream a blob larger than the cap. +# Both are overridable via app config for a site that ships bigger installers. +PAYLOAD_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024 +PAYLOAD_DOWNLOAD_RATELIMIT_MAX = 120 +PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60 + + +def _client_ip(): + """Caller IP for rate limiting, honoring the first X-Forwarded-For hop + (mirrors shopdb.core.api.auth._login_ip).""" + forwarded = request.headers.get('X-Forwarded-For') + if forwarded: + return forwarded.split(',')[0].strip() + return request.remote_addr or 'unknown' + + +def _payload_max_bytes(): + return current_app.config.get('GEENFORCE_PAYLOAD_MAX_BYTES', + PAYLOAD_DOWNLOAD_MAX_BYTES) + + +def _payload_download_ratelimited(): + """Fixed-window per-IP limiter for the payload download endpoint. + + Backed by the existing cache extension (no new dependency), same shape as + the login limiter. Under the default SimpleCache the counter is per-process, + so with N gunicorn workers the effective budget is N x the configured max; a + shared cache backend (Redis/memcached) tightens it to a true global budget. + Returns True when the caller is over budget for the current window. + """ + if not current_app.config.get('GEENFORCE_PAYLOAD_RATELIMIT_ENABLED', True): + return False + window = current_app.config.get( + 'GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS', + PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS) + maxhits = current_app.config.get( + 'GEENFORCE_PAYLOAD_RATELIMIT_MAX', PAYLOAD_DOWNLOAD_RATELIMIT_MAX) + # Time bucket makes this a fixed window: the key rolls over at each window + # boundary, so a per-hit set() cannot turn it into a sliding window. + bucket = int(time.time() // window) if window > 0 else 0 + key = f'geenforcepayloadratelimit:{_client_ip()}:{bucket}' + count = cache.get(key) or 0 + if count >= maxhits: + return True + cache.set(key, count + 1, timeout=window) + return False + + @geenforce_bp.route('/payload/', methods=['GET']) @require_fetch_token def get_payload(sha256): @@ -111,7 +165,15 @@ def get_payload(sha256): http payloads) first, then an inline DB payload with this hash. The client re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the hash (content is immutable). + + Hardened: per-IP rate limited, and a blob over the served-size ceiling is + refused (413) rather than streamed. """ + if _payload_download_ratelimited(): + return error_response('RATE_LIMITED', + 'Too many payload downloads. Try again later.', + http_code=429) + sha = (sha256 or '').strip().lower() if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha): return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256', @@ -120,8 +182,14 @@ def get_payload(sha256): if request.headers.get('If-None-Match') == etag: return Response(status=304, headers={'ETag': etag}) + maxbytes = _payload_max_bytes() + blob = db.session.get(ManifestBlob, sha) if blob and os.path.isfile(service.blob_path(sha)): + if blob.sizebytes is not None and blob.sizebytes > maxbytes: + return error_response('PAYLOAD_TOO_LARGE', + 'payload exceeds the download size limit', + http_code=413) response = send_file( service.blob_path(sha), mimetype=blob.contenttype or 'application/octet-stream', @@ -131,6 +199,10 @@ def get_payload(sha256): inline = ManifestPayload.query.filter_by(payloadsha256=sha).first() if inline: + if inline.payloadbytes is not None and len(inline.payloadbytes) > maxbytes: + return error_response('PAYLOAD_TOO_LARGE', + 'payload exceeds the download size limit', + http_code=413) return Response( inline.payloadbytes, mimetype=inline.contenttype or 'application/octet-stream', diff --git a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 index e67adef..eb112e8 100644 --- a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 +++ b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 @@ -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 diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 index c9d26f0..b5df9b9 100644 --- a/plugins/geenforce/client/ShopdbEnforceClient.psm1 +++ b/plugins/geenforce/client/ShopdbEnforceClient.psm1 @@ -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=; 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 + Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, ` + ConvertTo-ShopdbSummary diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py new file mode 100644 index 0000000..b2cb5f7 --- /dev/null +++ b/plugins/geenforce/seed_display_scope.py @@ -0,0 +1,267 @@ +"""Author the gea-shopfloor-display runtime scope programmatically. + +Displays are Intune/Entra-joined, credential-less kiosk PCs that pull their +manifest over HTTPS (no SMB share). The kiosk engine and the kiosk browser are +BAKED INTO THE DISPLAY IMAGE, so this scope does not ship any EXE payloads; it +heals POLICY / CONFIG drift only, plus one dispatcher that points the kiosk at +the right target for the display subtype. + +What this scope contains: +- Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk + relaunch policies from the imaging script 09-Setup-Display.ps1 (so a display + that loses those policies self-heals on the next enforce cycle without a + keyboard or mouse on site). +- One inline PS1 dispatcher that reads C:\\Enrollment\\display-type.txt and + launches the kiosk target for the subtype. The display-type -> target map is + a data-driven table (DISPLAY_TYPE_TARGETS) so the targets are easy to edit. + +Inheritance: the manifest model has no inheritance column. The display scope is +a plain (non-common) runtime scope; the CLIENT merges the fleet-wide 'common' +scope with the display scope at fetch time. So this scope carries display-only +entries and relies on the client to layer 'common' underneath. See the common +SMB-payload audit in docs/GE-ENFORCE-DISPLAY.md before letting a share-less +display inherit common. + +Authoring path mirrors how every other scope is created: build a manifest dict +and hand it to service.replace_scope_draft (the same call the import-share CLI +uses), then attach the inline dispatcher payload and optionally publish. Re- +running replace_scope_draft is an idempotent draft rebuild. +""" + +from shopdb.api import db + +from . import service + + +SCOPE_NAME = 'gea-shopfloor-display' +SCOPE_PHASE = 'runtime' +# Match the fleet-wide 'common' manifest version so a merged display + common +# document stays internally consistent. +SCOPE_VERSION = '2.0' + +# Edge kiosk relaunch policy key. Values below mirror 09-Setup-Display.ps1 +# exactly so the imaging-time state and the enforced state never disagree. +EDGE_POLICY_PATH = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge' +RELAUNCH_WINDOW_JSON = ( + '{"entries":[{"start":{"hour":2,"minute":0},"duration_mins":120}]}') + +# Data-driven display-type -> kiosk target map. The value of +# C:\Enrollment\display-type.txt selects the row; the target is a route the +# kiosk browser opens against the local kiosk base URL. Edit here to retarget a +# subtype. Keys are matched case-insensitively by the dispatcher. +# +# TODO-confirm: 3DPrintRoom points at the printedparts /parts-kiosk route as a +# PLACEHOLDER. Confirm the real 3D-print-room kiosk target with the floor team +# before this scope is published to production displays. +DISPLAY_TYPE_TARGETS = { + 'Dashboard': '/shopfloor', + 'Lobby': '/tv', + '3DPrintRoom': '/parts-kiosk', +} + +DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1' + + +def _relaunch_window_targets_comment(): + """Human note that lists the data-driven targets, for the manifest comment.""" + pairs = ', '.join(f'{name}={route}' + for name, route in DISPLAY_TYPE_TARGETS.items()) + return pairs + + +def build_dispatcher_script(): + """Return the inline dispatcher PowerShell as text. + + The display-type -> target map is emitted as a hashtable at the top of the + script (generated from DISPLAY_TYPE_TARGETS) so the on-PC script and the + manifest metadata agree and both stay easy to edit. + """ + table_lines = [] + for display_type, route in DISPLAY_TYPE_TARGETS.items(): + table_lines.append(f" '{display_type}' = '{route}'") + table_body = ';\n'.join(table_lines) + + return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher. +# +# Reads C:\\Enrollment\\display-type.txt and launches the kiosk browser at the +# route mapped for that display subtype. The kiosk browser is baked into the +# display image; this script only points it at the right target. +# +# The DisplayTypeTargets table below is the single source of truth for the +# subtype -> route map. Edit a row to retarget a subtype. +# +# TODO-confirm: 3DPrintRoom uses the printedparts /parts-kiosk route as a +# PLACEHOLDER. Confirm the real 3D-print-room target before production use. + +$ErrorActionPreference = 'Continue' + +# --- Data-driven subtype -> kiosk route map --- +$DisplayTypeTargets = @{{ +{table_body} +}} + +# Base URL the kiosk browser opens; the route from the table is appended. Edit +# to point at this site's shopdb host. Kept here so the map above stays pure. +$KioskBaseUrl = 'https://localhost' + +$displayTypeFile = 'C:\\Enrollment\\display-type.txt' +if (-not (Test-Path -LiteralPath $displayTypeFile)) {{ + Write-Host "display-type.txt not found at $displayTypeFile; nothing to launch." + return +}} + +$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim() +if ([string]::IsNullOrWhiteSpace($displayType)) {{ + Write-Host 'display-type.txt is empty; nothing to launch.' + return +}} + +# Case-insensitive lookup so 'lobby' and 'Lobby' both resolve. +$matchedKey = $DisplayTypeTargets.Keys | + Where-Object {{ $_ -ieq $displayType }} | + Select-Object -First 1 +if (-not $matchedKey) {{ + Write-Host "Unknown display-type '$displayType'; known types: $($DisplayTypeTargets.Keys -join ', ')." + return +}} + +$targetRoute = $DisplayTypeTargets[$matchedKey] +$kioskUrl = "$KioskBaseUrl$targetRoute" +Write-Host "display-type '$displayType' -> kiosk target $kioskUrl" + +# Idempotent: if an Edge kiosk process is already serving this URL, leave it be +# so an enforce cycle does not relaunch the kiosk every run. +$alreadyRunning = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" -ErrorAction SilentlyContinue | + Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains($kioskUrl) }} +if ($alreadyRunning) {{ + Write-Host 'Kiosk already running for this target; leaving it in place.' + return +}} + +$edgeArguments = @("--kiosk", $kioskUrl, "--edge-kiosk-type=fullscreen", "--no-first-run") +Start-Process -FilePath 'msedge.exe' -ArgumentList $edgeArguments +Write-Host 'Launched kiosk browser.' +""" + + +def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment): + """One Type=Registry entry that writes a value and detects drift via + ValueMatches against that same path/name. + + DetectionValue is stored as a string; the engine string-coerces for + ValueMatches, so a DWord value of 2 detects against '2'. + """ + return { + '_comment': comment, + 'Name': name, + 'Type': 'Registry', + 'RegPath': EDGE_POLICY_PATH, + 'RegName': regname, + 'RegValue': regvalue, + 'RegType': regtype, + 'DetectionMethod': 'ValueMatches', + 'DetectionPath': EDGE_POLICY_PATH, + 'DetectionName': regname, + 'DetectionValue': str(regvalue), + } + + +def build_display_manifest(): + """Return the gea-shopfloor-display manifest dict (Applications in order). + + Four Edge kiosk relaunch-policy drift-heal entries, then the one dispatcher + entry. The dispatcher is declared PayloadSource=inline with no hash yet; the + seed attaches the real payload bytes (and its sha256) after the draft rows + exist. Kept payload-free otherwise: the kiosk engine and browser are baked + into the display image, not shipped over HTTPS. + """ + applications = [ + _registry_drift_heal_entry( + 'Edge kiosk RelaunchNotification (Required auto-restart)', + 'RelaunchNotification', 2, 'DWord', + 'RelaunchNotification=2 (Required): Edge auto-restarts after the ' + 'notification period. Displays have no operator to dismiss the ' + 'update dialog, so this is the only mode that recovers unattended. ' + 'Heals drift of the policy set at imaging by 09-Setup-Display.ps1.'), + _registry_drift_heal_entry( + 'Edge kiosk RelaunchNotificationPeriod (1 hour)', + 'RelaunchNotificationPeriod', 3600000, 'DWord', + 'Milliseconds before the forced auto-restart. 3600000 ms = 1 hour.'), + _registry_drift_heal_entry( + 'Edge kiosk RelaunchHeadsUpPeriod (1 minute)', + 'RelaunchHeadsUpPeriod', 60000, 'DWord', + 'Milliseconds of final warning before auto-restart. 60000 ms = 1 ' + 'minute.'), + _registry_drift_heal_entry( + 'Edge kiosk RelaunchWindow (02:00-04:00)', + 'RelaunchWindow', RELAUNCH_WINDOW_JSON, 'String', + 'Overnight forced-restart window (02:00 start, 120 minute ' + 'duration) so business-hour updates wait until off-hours and the ' + 'dialog stays invisible during the day.'), + { + '_comment': ( + 'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and ' + 'launches the kiosk target for the subtype. Data-driven map: ' + + _relaunch_window_targets_comment() + + '. 3DPrintRoom target is a PLACEHOLDER (/parts-kiosk); ' + 'TODO-confirm the real target. Delivered inline over HTTPS ' + '(share-less displays); DetectionMethod Always so it re-asserts ' + 'each cycle, but the script is idempotent (skips if the kiosk is ' + 'already serving the target URL).'), + 'Name': 'Display kiosk dispatcher (display-type.txt)', + 'Type': 'PS1', + 'Script': DISPATCHER_FILENAME, + 'PayloadSource': 'inline', + 'PayloadRef': DISPATCHER_FILENAME, + 'DetectionMethod': 'Always', + }, + ] + return { + 'Version': SCOPE_VERSION, + '_comment': ( + 'gea-shopfloor-display runtime scope. Heals Edge kiosk relaunch ' + 'policy drift and dispatches the kiosk to the subtype target. No ' + 'EXE payloads: kiosk engine and browser are baked into the display ' + 'image. The client merges the fleet-wide common scope underneath ' + 'this one at fetch time.'), + 'Applications': applications, + } + + +def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'): + """Create/refresh the gea-shopfloor-display draft scope and its entries. + + Idempotent for the draft: replace_scope_draft rebuilds the draft rows, and + the inline dispatcher payload is content-addressed (a re-run stores the same + bytes to the same sha256). Set publish=True to also freeze a published + snapshot (that step is NOT idempotent: it always creates a new version). + + Commits the session. Returns a summary dict: + {scopeid, entrycount, entrytypes, dispatchersha256, publishedversion}. + """ + manifest = build_display_manifest() + scope = service.replace_scope_draft(SCOPE_NAME, SCOPE_PHASE, manifest) + # Flush so the new entries get entryids before the inline payload attaches. + db.session.flush() + + dispatcher = next(entry for entry in scope.entries + if entry.name == 'Display kiosk dispatcher (display-type.txt)') + scriptbytes = build_dispatcher_script().encode('utf-8') + payload = service.store_inline_payload( + dispatcher, DISPATCHER_FILENAME, + 'text/plain; charset=utf-8', scriptbytes) + + publishedversion = None + if publish: + publishedversion = service.publish_scope( + SCOPE_NAME, SCOPE_PHASE, notes=notes) + + db.session.commit() + + return { + 'scopeid': scope.scopeid, + 'entrycount': len(scope.entries), + 'entrytypes': [entry.entrytype for entry in scope.entries], + 'dispatchersha256': payload.payloadsha256, + 'publishedversion': publishedversion, + } diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 076f72e..2ae8698 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -46,6 +46,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'manifestentryhostnames', 'manifestentrymachinenumbers', 'manifestinusechecks', 'manifestinusecheckprocesses', 'manifestpublishedversions', 'manifestpayloads', + 'manifestblobs', 'manifestenforcementreports', 'manifestenforcementresults', 'pctypealiases'), 'knowledgebase': ('knowledgebase',), @@ -55,7 +56,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'notifications': ('notificationtypes', 'notifications'), 'printedparts': ('printeditems', 'printeditemtransactions', 'printeditemfiles'), - 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'), + 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers', + 'printersupplyalerts'), 'slides': ('tvslides',), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), 'warranty': ('warranties', 'warrantyassets'), diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 8113a21..e9719d8 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -46,7 +46,11 @@ CUTOVER_PLUGINS = ( # stamp '0001anchor'; measuringtools stamps its real baseline id. EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS} EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' -EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline' +# geenforce adds the content-addressed blob store (manifestblobs) on top of its +# baseline. +EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs' +# printers adds the printersupplyalerts crossing-state table on top of its anchor. +EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts' # machines (renamed from equipment) keeps its original anchor id and adds the # rename revision on top, so its head is not the f-string default. EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' @@ -54,8 +58,9 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo' # usb drops the dead usbcheckouts.machineid column on top of its anchor. EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid' -# printedparts is post-cutover: its 0001 really creates its tables. -EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0003gagetag' +# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds +# the per-transaction revision column. +EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev' # notifications indexes businessunitid on top of its anchor. EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx' diff --git a/tests/test_plugins/test_geenforce_blob_parity.py b/tests/test_plugins/test_geenforce_blob_parity.py new file mode 100644 index 0000000..ba7e0bd --- /dev/null +++ b/tests/test_plugins/test_geenforce_blob_parity.py @@ -0,0 +1,108 @@ +"""GE-Enforce manifestblobs model-vs-migration parity gate. + +manifestblobs is created by the SECOND geenforce migration +(0002_manifest_blobs.py), and the existing DDL-parity test only diffs the +baseline (0001) tables - it does not even list ManifestBlob. So the http-blob +store table has had NO parity coverage: a column added to the ManifestBlob model +but not the migration (or vice versa) would ship a broken schema silently. + +This gate builds manifestblobs two ways and diffs it column-by-column: + 1. the ManifestBlob model via metadata.create_all (desired shape), and + 2. the 0002 migration's upgrade() run against a fresh engine (shipped shape). +Both go through the same SQLite dialect so reflected types render identically. +""" + +import importlib.util +from pathlib import Path + +from sqlalchemy import create_engine, inspect +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +_BLOBS_MIGRATION = (Path(__file__).resolve().parent.parent.parent / 'plugins' / + 'geenforce' / 'migrations' / 'versions' / + '0002_manifest_blobs.py') + +_EXPECTED_COLUMNS = ('sha256', 'filename', 'contenttype', 'sizebytes', + 'createdat') + + +def _migration_columns(): + """Run the 0002 migration upgrade() on a fresh engine; return the reflected + manifestblobs columns as {name: column-dict}.""" + spec = importlib.util.spec_from_file_location('geenforce_blobs', + str(_BLOBS_MIGRATION)) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + + engine = create_engine('sqlite://') + with engine.connect() as connection: + operations = Operations(MigrationContext.configure(connection)) + operations._install_proxy() + try: + migration.upgrade() + finally: + operations._remove_proxy() + connection.commit() + inspector = inspect(connection) + assert 'manifestblobs' in inspector.get_table_names(), \ + 'migration did not create manifestblobs' + return {column['name']: column + for column in inspector.get_columns('manifestblobs')} + + +def _model_columns(db): + """Build manifestblobs from the ManifestBlob model; return {name: column}.""" + from plugins.geenforce.models.manifest import ManifestBlob + + engine = create_engine('sqlite://') + db.metadata.create_all(engine, tables=[ManifestBlob.__table__]) + return {column['name']: column + for column in inspect(engine).get_columns('manifestblobs')} + + +def _colkey(column): + """Normalize a reflected column to the facets we gate on (type, nullable).""" + return (str(column['type']), bool(column['nullable'])) + + +def test_manifestblob_model_matches_migration(app, db): + """Every ManifestBlob column matches what the 0002 migration builds.""" + with app.app_context(): + modelcols = _model_columns(db) + migrationcols = _migration_columns() + + drift = [] + for name in sorted(set(modelcols) - set(migrationcols)): + drift.append(f'{name}: in model, not in migration') + for name in sorted(set(migrationcols) - set(modelcols)): + drift.append(f'{name}: in migration, not in model') + for name in sorted(set(modelcols) & set(migrationcols)): + if _colkey(modelcols[name]) != _colkey(migrationcols[name]): + drift.append( + f'{name}: model {_colkey(modelcols[name])} != ' + f'migration {_colkey(migrationcols[name])}') + + assert not drift, ('manifestblobs model/migration DDL drift:\n' + + '\n'.join(drift)) + + +def test_manifestblob_expected_columns_present_both_sides(app, db): + """Lock the load-bearing columns (sha256 primary key + registry fields) + exist in BOTH the model and the migration.""" + with app.app_context(): + modelcols = _model_columns(db) + migrationcols = _migration_columns() + for name in _EXPECTED_COLUMNS: + assert name in modelcols, f'{name} missing from ManifestBlob model' + assert name in migrationcols, f'{name} missing from migration' + + +def test_manifestblob_sha256_is_not_nullable(app, db): + """The content hash is the primary key: it must be NOT NULL on both sides.""" + with app.app_context(): + modelcols = _model_columns(db) + migrationcols = _migration_columns() + assert modelcols['sha256']['nullable'] is False + assert migrationcols['sha256']['nullable'] is False diff --git a/tests/test_plugins/test_geenforce_display_seed.py b/tests/test_plugins/test_geenforce_display_seed.py new file mode 100644 index 0000000..698ba47 --- /dev/null +++ b/tests/test_plugins/test_geenforce_display_seed.py @@ -0,0 +1,108 @@ +"""Cover the gea-shopfloor-display authoring seed. + +Running seed_display_scope must produce the display scope with the expected +entry count and types: four Registry drift-heal entries (Edge kiosk relaunch +policies) plus one inline PS1 dispatcher. Also checks the dispatcher payload is +stored inline and the display-type map drives the dispatcher script. +""" + +from plugins.geenforce.models import ( + ManifestScope, ManifestEntry, ManifestPayload, +) +from plugins.geenforce.seed_display_scope import ( + seed_display_scope, build_display_manifest, build_dispatcher_script, + DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, +) + + +def test_seed_creates_display_scope(db): + summary = seed_display_scope() + + scope = ManifestScope.query.filter_by( + scopename=SCOPE_NAME, phase='runtime').first() + assert scope is not None + # Not the common scope: the client merges common underneath at fetch time. + assert scope.iscommon is False + + # Four Registry drift-heal entries + one PS1 dispatcher, in order. + assert summary['entrycount'] == 5 + assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry', + 'Registry', 'PS1'] + + +def test_registry_entries_use_valuematches_detection(db): + seed_display_scope() + + registry_entries = ManifestEntry.query.filter_by( + entrytype='Registry').order_by(ManifestEntry.sortorder).all() + assert len(registry_entries) == 4 + regnames = {entry.regname for entry in registry_entries} + assert regnames == { + 'RelaunchNotification', 'RelaunchNotificationPeriod', + 'RelaunchHeadsUpPeriod', 'RelaunchWindow', + } + for entry in registry_entries: + assert entry.detectionmethod == 'ValueMatches' + assert entry.regpath == 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge' + # Detection targets the same value it writes, coerced to string. + assert entry.detectionname == entry.regname + assert entry.detectionvalue is not None + + +def test_dispatcher_is_inline_and_data_driven(db): + summary = seed_display_scope() + + dispatcher = ManifestEntry.query.filter_by(entrytype='PS1').one() + assert dispatcher.payloadsource == 'inline' + assert dispatcher.payloadref == DISPATCHER_FILENAME + assert dispatcher.payloadsha256 == summary['dispatchersha256'] + + payload = ManifestPayload.query.filter_by( + entryid=dispatcher.entryid).one() + scripttext = payload.payloadbytes.decode('utf-8') + # The data-driven map surfaces every display-type and its target route. + for display_type, route in DISPLAY_TYPE_TARGETS.items(): + assert display_type in scripttext + assert route in scripttext + assert 'display-type.txt' in scripttext + + +def test_seed_publish_freezes_a_version(db): + summary = seed_display_scope(publish=True) + assert summary['publishedversion'] == 1 + + scope = ManifestScope.query.filter_by( + scopename=SCOPE_NAME, phase='runtime').first() + published = scope.publishedversions.filter_by(iscurrent=True).first() + assert published is not None + assert published.versionnumber == 1 + assert '"Version": "2.0"' in published.manifestjson + + +def test_seed_draft_is_idempotent(db): + first = seed_display_scope() + second = seed_display_scope() + # Re-running rebuilds the draft to the same shape (same scope, same count). + assert first['scopeid'] == second['scopeid'] + assert first['entrycount'] == second['entrycount'] + assert first['dispatchersha256'] == second['dispatchersha256'] + + entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all() + assert len(entries) == 5 + + +def test_build_manifest_has_no_smb_exe_payloads(db): + manifest = build_display_manifest() + # No entry ships an EXE/MSI/File payload from a share: the only payload is + # the inline dispatcher; everything else is a Registry policy heal. + for entry in manifest['Applications']: + assert entry.get('Installer') is None + assert entry.get('Source') is None + if entry['Type'] == 'PS1': + assert entry.get('PayloadSource') == 'inline' + + +def test_dispatcher_script_is_ascii(): + # Plain ASCII only (no smart quotes / em-dashes) so the naming gate stays + # green and the on-PC script parses cleanly. + build_dispatcher_script().encode('ascii') diff --git a/tests/test_plugins/test_geenforce_payload_hardening.py b/tests/test_plugins/test_geenforce_payload_hardening.py new file mode 100644 index 0000000..1d7bc50 --- /dev/null +++ b/tests/test_plugins/test_geenforce_payload_hardening.py @@ -0,0 +1,135 @@ +"""GE-Enforce payload-download hardening: rate limit + served-size ceiling. + +GET /api/geenforce/payload/ is reachable with only a read-only +geenforce.fetch token, so a leaked display token must not be able to hammer it +or pull unbounded bytes. These tests lock the two bounds added to the route: +a per-IP fixed-window rate limit (same cache extension as the login limiter) +and a size cap that refuses (413) a blob/inline payload over the configured max. +""" + +import hashlib +import io +from contextlib import contextmanager + +from plugins.geenforce import service +from plugins.geenforce.models import ManifestBlob + + +def _fetch_key(client, auth_headers): + resp = client.post('/api/apitokens', + json={'name': 'svc', 'scopes': ['geenforce.fetch']}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return {'X-API-Key': resp.get_json()['data']['secret']} + + +def _store(app, raw, filename='setup.exe'): + with app.app_context(): + sha = service.store_blob(raw, filename, 'application/octet-stream') + service.db.session.commit() + return sha + + +@contextmanager +def _config(app, **overrides): + """Temporarily override app.config keys (the app fixture is session-scoped, + so every key is restored to its prior value/absence on exit).""" + missing = object() + saved = {key: app.config.get(key, missing) for key in overrides} + app.config.update(overrides) + try: + yield + finally: + for key, value in saved.items(): + if value is missing: + app.config.pop(key, None) + else: + app.config[key] = value + + +def test_blob_within_cap_is_served(client, app, auth_headers): + raw = b'x' * 64 + sha = _store(app, raw, 'ok.exe') + headers = _fetch_key(client, auth_headers) + with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=1024): + resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers) + assert resp.status_code == 200 + assert resp.data == raw + + +def test_oversized_blob_refused_413(client, app, auth_headers): + raw = b'x' * 64 + sha = _store(app, raw, 'big.exe') + headers = _fetch_key(client, auth_headers) + with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=16): + resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers) + assert resp.status_code == 413 + # The blob registry row still exists; the cap gates delivery, not storage. + with app.app_context(): + assert service.db.session.get(ManifestBlob, sha) is not None + + +def test_oversized_inline_payload_refused_413(client, app, db, auth_headers): + scopeid = client.post( + '/api/geenforce/scopes', + json={'scopename': 'gea-shopfloor-cmm', 'phase': 'runtime'}, + headers=auth_headers).get_json()['data']['scopeid'] + entryid = client.post( + f'/api/geenforce/scopes/{scopeid}/entries', + json={'Name': 'eDNC config', 'Type': 'PS1', 'Script': 's.ps1'}, + headers=auth_headers).get_json()['data']['entryid'] + content = b'inline config bytes here' + up = client.post(f'/api/geenforce/entries/{entryid}/payload', + data={'file': (io.BytesIO(content), 'config.reg')}, + content_type='multipart/form-data', headers=auth_headers) + assert up.status_code == 201, up.get_json() + sha = hashlib.sha256(content).hexdigest() + + headers = _fetch_key(client, auth_headers) + with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=4): + resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers) + assert resp.status_code == 413 + + +def test_payload_download_is_rate_limited(client, app, auth_headers): + sha = _store(app, b'small blob') + key = _fetch_key(client, auth_headers) + # A unique caller IP isolates this bucket from other tests' shared counter. + headers = {**key, 'X-Forwarded-For': '203.0.113.201'} + with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=3, + GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300): + for _ in range(3): + ok = client.get(f'/api/geenforce/payload/{sha}', headers=headers) + assert ok.status_code == 200, ok.get_json() + # The 4th request in the same window is over budget. + over = client.get(f'/api/geenforce/payload/{sha}', headers=headers) + assert over.status_code == 429 + + +def test_rate_limit_is_per_ip(client, app, auth_headers): + """A second caller (different X-Forwarded-For) has its own budget: one IP + being throttled must not throttle everyone.""" + sha = _store(app, b'per-ip blob') + key = _fetch_key(client, auth_headers) + hot = {**key, 'X-Forwarded-For': '203.0.113.202'} + cool = {**key, 'X-Forwarded-For': '203.0.113.203'} + with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1, + GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300): + assert client.get(f'/api/geenforce/payload/{sha}', + headers=hot).status_code == 200 + assert client.get(f'/api/geenforce/payload/{sha}', + headers=hot).status_code == 429 + # Fresh IP still gets its first request through. + assert client.get(f'/api/geenforce/payload/{sha}', + headers=cool).status_code == 200 + + +def test_rate_limit_can_be_disabled(client, app, auth_headers): + sha = _store(app, b'unthrottled blob') + key = _fetch_key(client, auth_headers) + headers = {**key, 'X-Forwarded-For': '203.0.113.204'} + with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_ENABLED=False, + GEENFORCE_PAYLOAD_RATELIMIT_MAX=1): + for _ in range(5): + assert client.get(f'/api/geenforce/payload/{sha}', + headers=headers).status_code == 200 diff --git a/tests/test_plugins/test_geenforce_report_contract.py b/tests/test_plugins/test_geenforce_report_contract.py new file mode 100644 index 0000000..ed6fff3 --- /dev/null +++ b/tests/test_plugins/test_geenforce_report_contract.py @@ -0,0 +1,137 @@ +"""GE-Enforce enforcement-report per-entry key contract. + +The server reads LOWERCASE keys from each results[] item - +name / action / selfhealed / exitcode / message (see +service.record_enforcement_report) - and maps them onto the +ManifestEnforcementResult columns entryname / action / selfhealed / exitcode / +message. This is the exact contract the PowerShell client fix targets: the +client emits lowercase keys, so if the server ever silently switched to reading +uppercase keys the client's entryname/selfhealed would drop to empty/false. + +These tests POST a realistic multi-entry cycle with the lowercase keys and lock +that the result columns populate, plus the negative case (uppercase keys do NOT +populate) so a future contract flip fails loudly here. +""" + +from plugins.geenforce import service + + +SCOPE = { + 'Version': '2.6', + 'Applications': [ + {'Name': 'VNC firewall rule', 'Type': 'PS1', 'Script': 'scripts/vnc.ps1'}, + {'Name': 'PC-DMIS 2023.1', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi'}, + {'Name': 'eDNC config', 'Type': 'PS1', 'Script': 'scripts/ednc.ps1'}, + {'Name': 'Blancco agent', 'Type': 'MSI', 'Installer': 'apps/blancco.msi'}, + ], +} + + +def _seed_and_publish(app, scopename='gea-shopfloor-cmm'): + with app.app_context(): + service.replace_scope_draft(scopename, 'runtime', SCOPE) + service.publish_scope(scopename, 'runtime', notes='v') + service.db.session.commit() + + +def _report_token(client, auth_headers): + resp = client.post('/api/apitokens', + json={'name': 'svc', 'scopes': ['geenforce.report']}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['secret'] + + +def test_lowercase_keys_populate_result_columns(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _report_token(client, auth_headers) + + post = client.post('/api/geenforce/report', json={ + 'hostname': 'WJDISPLAY07', + 'scopename': 'gea-shopfloor-cmm', + 'phase': 'runtime', + 'appliedversion': 1, + 'enforcerversion': '2.6', + 'counts': {'installed': 2, 'skipped': 1, 'failed': 1, 'filtered': 0}, + 'results': [ + {'name': 'VNC firewall rule', 'action': 'installed', + 'selfhealed': True, 'exitcode': 0, 'message': 'rule re-added'}, + {'name': 'PC-DMIS 2023.1', 'action': 'skipped', + 'selfhealed': False, 'exitcode': 0, 'message': ''}, + {'name': 'eDNC config', 'action': 'installed', + 'selfhealed': True, 'exitcode': 0}, + {'name': 'Blancco agent', 'action': 'failed', + 'selfhealed': False, 'exitcode': 1603, 'message': 'MSI 1603'}, + ], + }, headers={'X-API-Key': secret}) + assert post.status_code == 200, post.get_json() + reportid = post.get_json()['data']['reportid'] + + detail = client.get(f'/api/geenforce/reports/{reportid}', + headers=auth_headers).get_json()['data'] + byname = {r['entryname']: r for r in detail['results']} + + # Every lowercase 'name' mapped onto entryname (nothing dropped). + assert set(byname) == {'VNC firewall rule', 'PC-DMIS 2023.1', + 'eDNC config', 'Blancco agent'} + + vnc = byname['VNC firewall rule'] + assert vnc['action'] == 'installed' + assert vnc['selfhealed'] is True + assert vnc['exitcode'] == 0 + assert vnc['message'] == 'rule re-added' + + assert byname['PC-DMIS 2023.1']['action'] == 'skipped' + assert byname['PC-DMIS 2023.1']['selfhealed'] is False + + ednc = byname['eDNC config'] + assert ednc['selfhealed'] is True + assert ednc['message'] is None # omitted key -> None + + blancco = byname['Blancco agent'] + assert blancco['action'] == 'failed' + assert blancco['exitcode'] == 1603 + assert blancco['message'] == 'MSI 1603' + + # A failure is present, so the cycle status is 'failed' (a self-heal without + # any failure would be 'selfhealed'). + assert detail['status'] == 'failed' + + +def test_selfhealed_defaults_false_when_key_omitted(client, db, app, + auth_headers): + _seed_and_publish(app) + secret = _report_token(client, auth_headers) + post = client.post('/api/geenforce/report', json={ + 'hostname': 'WJDISPLAY08', + 'scopename': 'gea-shopfloor-cmm', + 'results': [{'name': 'asset report', 'action': 'installed'}], + }, headers={'X-API-Key': secret}) + reportid = post.get_json()['data']['reportid'] + + detail = client.get(f'/api/geenforce/reports/{reportid}', + headers=auth_headers).get_json()['data'] + result = detail['results'][0] + assert result['entryname'] == 'asset report' + assert result['selfhealed'] is False + + +def test_uppercase_keys_do_not_populate(client, db, app, auth_headers): + """Negative lock: the server reads LOWERCASE keys, so an uppercase-keyed + item does NOT populate entryname/selfhealed. Guards against a silent flip to + uppercase that would break the client's lowercase payload.""" + _seed_and_publish(app) + secret = _report_token(client, auth_headers) + post = client.post('/api/geenforce/report', json={ + 'hostname': 'WJDISPLAY09', + 'scopename': 'gea-shopfloor-cmm', + 'results': [{'Name': 'wrong casing', 'Action': 'installed', + 'SelfHealed': True}], + }, headers={'X-API-Key': secret}) + reportid = post.get_json()['data']['reportid'] + + detail = client.get(f'/api/geenforce/reports/{reportid}', + headers=auth_headers).get_json()['data'] + result = detail['results'][0] + assert result['entryname'] == '' # 'Name' not read -> default '' + assert result['selfhealed'] is False # 'SelfHealed' not read -> default