geenforce: harden allowlist + fix share-less kiosk client and display scope

- allowlist auth uses remote_addr, not the spoofable first X-Forwarded-For hop
  (adds _trusted_client_ip + a regression test); rate-limit path unchanged
- client psm1: fix Set-StrictMode crashes reading absent keys in Get-ShopdbConfig
  (token-less mode) and Resolve-ShopdbPayloads (no-payload entries); validate
  the manifest response is JSON before overwriting the last-known-good cache
- runner: pass the engine its required -InstallerRoot/-LogFile; create the log
  directory so enforce logging is not silently lost on a fresh kiosk
- display scope: dispatcher writes an all-users Startup shortcut instead of
  Start-Process (SYSTEM cannot show a window in session 0), resolves the base
  URL from HKLM, and adds an always-on power/no-lock entry; tests updated for
  the 6-entry scope
This commit is contained in:
cproudlock
2026-07-28 17:09:21 -04:00
parent f533af82cd
commit 4c0cc672a2
6 changed files with 229 additions and 46 deletions

View File

@@ -46,18 +46,36 @@ REPORT_SCOPE = 'geenforce.report'
ALLOWED_CIDRS_SETTING = 'geenforce_allowed_cidrs' ALLOWED_CIDRS_SETTING = 'geenforce_allowed_cidrs'
def _trusted_client_ip():
"""The trustworthy caller IP for the AUTH allowlist.
Uses request.remote_addr, NOT the raw X-Forwarded-For header. Proxies APPEND
to X-Forwarded-For, so its first hop is attacker-controlled: parsing it (as
_client_ip does for rate-limiting) would let any caller send
'X-Forwarded-For: <allowlisted-ip>' and bypass the token. remote_addr cannot
be forged here - behind IIS the URL-Rewrite rule overwrites X-Forwarded-For
with the real TCP peer and waitress (--trusted-proxy=127.0.0.1
--trusted-proxy-headers=x-forwarded-for) derives remote_addr from it; a
client hitting waitress directly is not a trusted proxy, so its remote_addr
is its own real peer address. Either way remote_addr is the true client.
"""
return request.remote_addr or ''
def _ip_allowlisted(): def _ip_allowlisted():
"""True when the caller IP falls in the configured geenforce allowlist. """True when the caller IP falls in the configured geenforce allowlist.
Lets vaulted fleet PCs reach the client endpoints without a per-PC token - Lets vaulted fleet PCs reach the client endpoints without a per-PC token -
network trust replaces the shared secret. Fails closed: an unparseable network trust replaces the shared secret. Fails closed: an unparseable
caller IP or malformed allowlist entry never matches. Empty setting = off. caller IP or malformed allowlist entry never matches. Empty setting = off.
Uses the SPOOF-RESISTANT remote_addr (see _trusted_client_ip), never the raw
X-Forwarded-For header.
""" """
raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip() raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip()
if not raw: if not raw:
return False return False
try: try:
ip = ipaddress.ip_address(_client_ip()) ip = ipaddress.ip_address(_trusted_client_ip())
except ValueError: except ValueError:
return False return False
for part in raw.split(','): for part in raw.split(','):

View File

@@ -55,7 +55,13 @@ param(
function Write-Log { function Write-Log {
param([string]$Message, [string]$Level = 'INFO') param([string]$Message, [string]$Level = 'INFO')
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
try { Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue } catch {} try {
$logDir = Split-Path -Parent $LogFile
if ($logDir -and -not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue
} catch {}
Write-Host $line Write-Host $line
} }
@@ -164,8 +170,23 @@ try {
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it # code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
# returns into a well-formed summary hashtable so the report stage always # returns into a well-formed summary hashtable so the report stage always
# gets clean input (we do NOT assume the engine was fixed). # gets clean input (we do NOT assume the engine was fixed).
# The engine requires -InstallerRoot (base for any relative Source/Installer
# path) and -LogFile. Shadow runs off the share, so relative paths resolve
# against the share scope dir. Cutover rewrites payloads to ABSOLUTE local
# paths, so InstallerRoot is only a harmless fallback base (the payload cache).
if ($ShadowMode -and $ShareManifestPath) {
$installerRoot = Split-Path -Parent $ShareManifestPath
} else {
$installerRoot = Join-Path (Split-Path -Parent $manifestToRun) 'payloads'
}
if ($installerRoot -and -not (Test-Path $installerRoot)) {
New-Item -ItemType Directory -Path $installerRoot -Force -ErrorAction SilentlyContinue | Out-Null
}
$engineLog = $LogFile -replace '\.log$', '-engine.log'
Write-Log "Running engine against $manifestToRun" Write-Log "Running engine against $manifestToRun"
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope $engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope `
-InstallerRoot $installerRoot -LogFile $engineLog
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult $summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
# Report the result (best-effort). # Report the result (best-effort).

View File

@@ -63,8 +63,10 @@ function Get-ShopdbConfig {
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB' $regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) { if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) {
$props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue $props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue
if (-not $BaseUrl -and $props.BaseUrl) { $BaseUrl = $props.BaseUrl } $regBase = Get-ShopdbProperty $props 'BaseUrl'
if (-not $ApiToken -and $props.ApiToken) { $ApiToken = $props.ApiToken } $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 # 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 # source-IP allowlist, so a BaseUrl alone is a valid config. When a token
@@ -107,6 +109,10 @@ function Sync-ShopdbManifest {
$response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing ` $response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing `
-TimeoutSec 30 -ErrorAction Stop -TimeoutSec 30 -ErrorAction Stop
if ($response.StatusCode -eq 200) { 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) [System.IO.File]::WriteAllText($manifestPath, $response.Content)
# PowerShell 7 returns header values as string arrays; 5.1 as scalars. # PowerShell 7 returns header values as string arrays; 5.1 as scalars.
# @(...)[0] yields a clean scalar in both. # @(...)[0] yields a clean scalar in both.
@@ -122,7 +128,8 @@ function Sync-ShopdbManifest {
} }
} catch { } catch {
$status = $null $status = $null
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } $exResponse = Get-ShopdbProperty $_.Exception 'Response'
if ($exResponse) { $status = [int]$exResponse.StatusCode }
if ($status -eq 304 -and (Test-Path $manifestPath)) { if ($status -eq 304 -and (Test-Path $manifestPath)) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' } return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
} }
@@ -298,12 +305,13 @@ function Resolve-ShopdbPayloads {
INF='Installer'; PS1='Script'; File='Source' } INF='Installer'; PS1='Script'; File='Source' }
$changed = $false $changed = $false
foreach ($entry in @($json.Applications)) { foreach ($entry in @($json.Applications)) {
$src = [string]$entry.PayloadSource $src = [string](Get-ShopdbProperty $entry 'PayloadSource')
$sha = [string]$entry.PayloadSha256 $sha = [string](Get-ShopdbProperty $entry 'PayloadSha256')
if (-not $sha -or ($src -ne 'http' -and $src -ne 'inline')) { continue } if (-not $sha -or ($src -ne 'http' -and $src -ne 'inline')) { continue }
$field = $pathField[[string]$entry.Type] $field = $pathField[[string](Get-ShopdbProperty $entry 'Type')]
if (-not $field) { continue } if (-not $field) { continue }
$local = Get-ShopdbPayload -Sha256 $sha -Config $Config -Filename $entry.PayloadRef -CacheDir $CacheDir $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" } if (-not $local) { throw "payload $sha for '$($entry.Name)' could not be fetched/verified" }
if ($entry.PSObject.Properties.Name -contains $field) { $entry.$field = $local } if ($entry.PSObject.Properties.Name -contains $field) { $entry.$field = $local }
else { $entry | Add-Member -NotePropertyName $field -NotePropertyValue $local } else { $entry | Add-Member -NotePropertyName $field -NotePropertyValue $local }

View File

@@ -80,15 +80,20 @@ def build_dispatcher_script():
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher. return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
# #
# Reads C:\\Enrollment\\display-type.txt and launches the kiosk browser at the # Reads C:\\Enrollment\\display-type.txt and ensures an all-users Startup
# route mapped for that display subtype. The kiosk browser is baked into the # shortcut that launches Edge in kiosk fullscreen at the route mapped for that
# display image; this script only points it at the right target. # subtype. The kiosk browser is baked into the display image; this only points
# the launch at the right target and heals the shortcut on a URL/type change.
#
# WHY a Startup shortcut, not Start-Process: this runs as SYSTEM under the
# enforce task. SYSTEM has no interactive desktop, so Start-Process msedge.exe
# opens INVISIBLY in session 0. A shortcut in the all-users Startup folder is
# instead launched by the auto-login user at logon, in their own visible
# session. Idempotent: prior ShopDB kiosk shortcuts are cleared first, then the
# one for the resolved target is written, so a display-type/URL change self-heals.
# #
# The DisplayTypeTargets table below is the single source of truth for the # The DisplayTypeTargets table below is the single source of truth for the
# subtype -> route map. Edit a row to retarget a subtype. # 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' $ErrorActionPreference = 'Continue'
@@ -97,19 +102,24 @@ $DisplayTypeTargets = @{{
{table_body} {table_body}
}} }}
# Base URL the kiosk browser opens; the route from the table is appended. Edit # Base URL the kiosk browser opens; the route from the table is appended. Read
# to point at this site's shopdb host. Kept here so the map above stays pure. # from HKLM (the value the GE-Enforce client is configured with) so it stays
$KioskBaseUrl = 'https://localhost' # site-agnostic; falls back to the West Jefferson shopdb host.
$KioskBaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
try {{
$shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop
if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }}
}} catch {{}}
$displayTypeFile = 'C:\\Enrollment\\display-type.txt' $displayTypeFile = 'C:\\Enrollment\\display-type.txt'
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{ if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
Write-Host "display-type.txt not found at $displayTypeFile; nothing to launch." Write-Host "display-type.txt not found at $displayTypeFile; nothing to configure."
return return
}} }}
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim() $displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
if ([string]::IsNullOrWhiteSpace($displayType)) {{ if ([string]::IsNullOrWhiteSpace($displayType)) {{
Write-Host 'display-type.txt is empty; nothing to launch.' Write-Host 'display-type.txt is empty; nothing to configure.'
return return
}} }}
@@ -122,25 +132,91 @@ if (-not $matchedKey) {{
return return
}} }}
$targetRoute = $DisplayTypeTargets[$matchedKey] $kioskUrl = "$KioskBaseUrl$($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 # Resolve msedge.exe (baked into the display image).
# so an enforce cycle does not relaunch the kiosk every run. $edge = $null
$alreadyRunning = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" -ErrorAction SilentlyContinue | foreach ($p in @("${{env:ProgramFiles(x86)}}\\Microsoft\\Edge\\Application\\msedge.exe",
Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains($kioskUrl) }} "$env:ProgramFiles\\Microsoft\\Edge\\Application\\msedge.exe")) {{
if ($alreadyRunning) {{ if (Test-Path -LiteralPath $p) {{ $edge = $p; break }}
Write-Host 'Kiosk already running for this target; leaving it in place.'
return
}} }}
if (-not $edge) {{ Write-Host 'msedge.exe not found; cannot write kiosk startup shortcut.'; return }}
$edgeArguments = @("--kiosk", $kioskUrl, "--edge-kiosk-type=fullscreen", "--no-first-run") $startup = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\Startup'
Start-Process -FilePath 'msedge.exe' -ArgumentList $edgeArguments if (-not (Test-Path $startup)) {{ New-Item -ItemType Directory -Path $startup -Force | Out-Null }}
Write-Host 'Launched kiosk browser.' # Clear any prior ShopDB kiosk shortcut so a URL/type change self-heals.
Get-ChildItem -LiteralPath $startup -Filter 'ShopDB Kiosk*.lnk' -ErrorAction SilentlyContinue |
ForEach-Object {{ Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }}
$edgeArgs = "--kiosk `"$kioskUrl`" --edge-kiosk-type=fullscreen --no-first-run --no-default-browser-check"
$lnkPath = Join-Path $startup 'ShopDB Kiosk.lnk'
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut($lnkPath)
$lnk.TargetPath = $edge
$lnk.Arguments = $edgeArgs
$lnk.WorkingDirectory = Split-Path -Parent $edge
$lnk.WindowStyle = 3
$lnk.Description = "ShopDB kiosk display: $kioskUrl"
$lnk.Save()
Write-Host "display-type '$displayType' -> kiosk Startup shortcut for $kioskUrl"
""" """
ALWAYSON_FILENAME = 'Set-DisplayAlwaysOn.ps1'
def build_alwayson_script():
"""Return the inline always-on PowerShell as text.
Keeps a display awake 24/7 (kiosk page always visible): powercfg never-off on
AC+DC, screensaver + auto-lock disabled. Plain raw string - no table
interpolation, so no brace escaping.
"""
return r'''# Set-DisplayAlwaysOn.ps1 -- gea-shopfloor-display always-on.
# Keeps the display awake 24/7 so the kiosk page stays visible: powercfg
# never-off (AC+DC) + screensaver/auto-lock disabled. SYSTEM, every cycle.
$ErrorActionPreference = 'Continue'
foreach ($c in @(
'monitor-timeout-ac 0','monitor-timeout-dc 0',
'standby-timeout-ac 0','standby-timeout-dc 0',
'hibernate-timeout-ac 0','hibernate-timeout-dc 0',
'disk-timeout-ac 0','disk-timeout-dc 0')) {
try { Start-Process -FilePath powercfg -ArgumentList "/change $c" -NoNewWindow -Wait -ErrorAction Stop } catch {}
}
# disable screensaver + secure lock for future profiles (.DEFAULT) + loaded hives.
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
New-PSDrive -Name HKU -PSProvider Registry -Root HKEY_USERS -ErrorAction SilentlyContinue | Out-Null
}
$hives = @('HKU:\.DEFAULT')
try {
Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop |
Where-Object { $_.PSChildName -match '^S-1-5-21' -and $_.PSChildName -notmatch '_Classes$' } |
ForEach-Object { $hives += "HKU:\$($_.PSChildName)" }
} catch {}
foreach ($h in $hives) {
$cp = "$h\Control Panel\Desktop"
try {
if (-not (Test-Path $cp)) { New-Item -Path $cp -Force -ErrorAction Stop | Out-Null }
Set-ItemProperty -Path $cp -Name ScreenSaveActive -Value '0' -ErrorAction Stop
Set-ItemProperty -Path $cp -Name ScreenSaverIsSecure -Value '0' -ErrorAction SilentlyContinue
Set-ItemProperty -Path $cp -Name ScreenSaveTimeOut -Value '0' -ErrorAction SilentlyContinue
} catch {}
}
# never auto-lock the kiosk console.
try {
$pol = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization'
if (-not (Test-Path $pol)) { New-Item -Path $pol -Force | Out-Null }
Set-ItemProperty -Path $pol -Name NoLockScreen -Value 1 -Type DWord -ErrorAction SilentlyContinue
} catch {}
Write-Host 'kiosk always-on enforced (power never-off + screensaver/lock disabled).'
'''
def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment): def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment):
"""One Type=Registry entry that writes a value and detects drift via """One Type=Registry entry that writes a value and detects drift via
ValueMatches against that same path/name. ValueMatches against that same path/name.
@@ -198,13 +274,16 @@ def build_display_manifest():
{ {
'_comment': ( '_comment': (
'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and ' 'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and '
'launches the kiosk target for the subtype. Data-driven map: ' 'writes an all-users Startup shortcut that launches Edge kiosk '
'fullscreen at the subtype target. Data-driven map: '
+ _relaunch_window_targets_comment() + _relaunch_window_targets_comment()
+ '. 3DPrintRoom target is a PLACEHOLDER (/parts-kiosk); ' + '. Base URL from HKLM BaseUrl, else the WJ host. Runs as SYSTEM '
'TODO-confirm the real target. Delivered inline over HTTPS ' '(no interactive desktop), so it does NOT Start-Process Edge '
'(share-less displays); DetectionMethod Always so it re-asserts ' '(that opens invisibly in session 0) - the auto-login user runs '
'each cycle, but the script is idempotent (skips if the kiosk is ' 'the shortcut at logon in a visible session. Delivered inline over '
'already serving the target URL).'), 'HTTPS (share-less displays); Always re-asserts, idempotent '
'(rewrites only the resolved-target shortcut, self-heals a '
'type/URL change).'),
'Name': 'Display kiosk dispatcher (display-type.txt)', 'Name': 'Display kiosk dispatcher (display-type.txt)',
'Type': 'PS1', 'Type': 'PS1',
'Script': DISPATCHER_FILENAME, 'Script': DISPATCHER_FILENAME,
@@ -212,6 +291,19 @@ def build_display_manifest():
'PayloadRef': DISPATCHER_FILENAME, 'PayloadRef': DISPATCHER_FILENAME,
'DetectionMethod': 'Always', 'DetectionMethod': 'Always',
}, },
{
'_comment': (
'Keep the display awake 24/7 so the kiosk page stays visible: '
'powercfg never-off (AC+DC) + screensaver/auto-lock disabled. '
'Delivered inline over HTTPS; Always re-asserts + self-heals a '
'power-plan drift each cycle.'),
'Name': 'Display always-on (no sleep/blank/lock)',
'Type': 'PS1',
'Script': ALWAYSON_FILENAME,
'PayloadSource': 'inline',
'PayloadRef': ALWAYSON_FILENAME,
'DetectionMethod': 'Always',
},
] ]
return { return {
'Version': SCOPE_VERSION, 'Version': SCOPE_VERSION,
@@ -248,6 +340,13 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
dispatcher, DISPATCHER_FILENAME, dispatcher, DISPATCHER_FILENAME,
'text/plain; charset=utf-8', scriptbytes) 'text/plain; charset=utf-8', scriptbytes)
alwayson = next(entry for entry in scope.entries
if entry.name == 'Display always-on (no sleep/blank/lock)')
alwaysonbytes = build_alwayson_script().encode('utf-8')
alwaysonpayload = service.store_inline_payload(
alwayson, ALWAYSON_FILENAME,
'text/plain; charset=utf-8', alwaysonbytes)
publishedversion = None publishedversion = None
if publish: if publish:
publishedversion = service.publish_scope( publishedversion = service.publish_scope(
@@ -260,5 +359,6 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
'entrycount': len(scope.entries), 'entrycount': len(scope.entries),
'entrytypes': [entry.entrytype for entry in scope.entries], 'entrytypes': [entry.entrytype for entry in scope.entries],
'dispatchersha256': payload.payloadsha256, 'dispatchersha256': payload.payloadsha256,
'alwaysonsha256': alwaysonpayload.payloadsha256,
'publishedversion': publishedversion, 'publishedversion': publishedversion,
} }

View File

@@ -11,7 +11,7 @@ from plugins.geenforce.models import (
) )
from plugins.geenforce.seed_display_scope import ( from plugins.geenforce.seed_display_scope import (
seed_display_scope, build_display_manifest, build_dispatcher_script, seed_display_scope, build_display_manifest, build_dispatcher_script,
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, ALWAYSON_FILENAME,
) )
@@ -25,10 +25,10 @@ def test_seed_creates_display_scope(db):
# and do NOT inherit common. # and do NOT inherit common.
assert scope.iscommon is False assert scope.iscommon is False
# Four Registry drift-heal entries + one PS1 dispatcher, in order. # Four Registry drift-heal entries + two inline PS1 (dispatcher, always-on).
assert summary['entrycount'] == 5 assert summary['entrycount'] == 6
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry', assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
'Registry', 'PS1'] 'Registry', 'PS1', 'PS1']
def test_registry_entries_use_valuematches_detection(db): def test_registry_entries_use_valuematches_detection(db):
@@ -53,7 +53,8 @@ def test_registry_entries_use_valuematches_detection(db):
def test_dispatcher_is_inline_and_data_driven(db): def test_dispatcher_is_inline_and_data_driven(db):
summary = seed_display_scope() summary = seed_display_scope()
dispatcher = ManifestEntry.query.filter_by(entrytype='PS1').one() dispatcher = ManifestEntry.query.filter_by(
payloadref=DISPATCHER_FILENAME).one()
assert dispatcher.payloadsource == 'inline' assert dispatcher.payloadsource == 'inline'
assert dispatcher.payloadref == DISPATCHER_FILENAME assert dispatcher.payloadref == DISPATCHER_FILENAME
assert dispatcher.payloadsha256 == summary['dispatchersha256'] assert dispatcher.payloadsha256 == summary['dispatchersha256']
@@ -66,6 +67,29 @@ def test_dispatcher_is_inline_and_data_driven(db):
assert display_type in scripttext assert display_type in scripttext
assert route in scripttext assert route in scripttext
assert 'display-type.txt' in scripttext assert 'display-type.txt' in scripttext
# H3: SYSTEM writes an all-users Startup shortcut (CreateShortcut/.Save),
# rather than Start-Process-ing Edge (which would open invisibly in session 0).
assert 'CreateShortcut' in scripttext
assert '.Save()' in scripttext
assert 'Startup' in scripttext
# H4: real base URL, not the localhost placeholder.
assert 'localhost' not in scripttext
def test_alwayson_entry_is_inline(db):
summary = seed_display_scope()
alwayson = ManifestEntry.query.filter_by(
payloadref=ALWAYSON_FILENAME).one()
assert alwayson.entrytype == 'PS1'
assert alwayson.payloadsource == 'inline'
assert alwayson.detectionmethod == 'Always'
assert alwayson.payloadsha256 == summary['alwaysonsha256']
payload = ManifestPayload.query.filter_by(entryid=alwayson.entryid).one()
scripttext = payload.payloadbytes.decode('utf-8')
assert 'powercfg' in scripttext
assert 'monitor-timeout-ac 0' in scripttext
def test_seed_publish_freezes_a_version(db): def test_seed_publish_freezes_a_version(db):
@@ -87,9 +111,10 @@ def test_seed_draft_is_idempotent(db):
assert first['scopeid'] == second['scopeid'] assert first['scopeid'] == second['scopeid']
assert first['entrycount'] == second['entrycount'] assert first['entrycount'] == second['entrycount']
assert first['dispatchersha256'] == second['dispatchersha256'] assert first['dispatchersha256'] == second['dispatchersha256']
assert first['alwaysonsha256'] == second['alwaysonsha256']
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all() entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
assert len(entries) == 5 assert len(entries) == 6
def test_build_manifest_has_no_smb_exe_payloads(db): def test_build_manifest_has_no_smb_exe_payloads(db):

View File

@@ -147,6 +147,17 @@ def test_empty_allowlist_keeps_token_required(client, db, app):
assert resp.status_code == 401 assert resp.status_code == 401
def test_spoofed_forwarded_for_does_not_bypass_allowlist(client, db, app):
# SECURITY: the allowlist uses remote_addr, not X-Forwarded-For. A caller
# whose real IP (127.0.0.1) is NOT allowlisted must NOT gain token-less access
# by forging X-Forwarded-For to an allowlisted address.
_seed_and_publish(app)
_set_allowlist(app, '10.134.48.0/23') # test client 127.0.0.1 is NOT in it
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-Forwarded-For': '10.134.48.10'})
assert resp.status_code == 401, 'spoofed X-Forwarded-For bypassed the allowlist'
def test_wrong_scope_rejected(client, db, app, auth_headers): def test_wrong_scope_rejected(client, db, app, auth_headers):
_seed_and_publish(app) _seed_and_publish(app)
resp = client.post('/api/apitokens', resp = client.post('/api/apitokens',