From 4ef775cde4870fd331de3fca1256dc19f60993ec Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Fri, 16 Oct 2026 00:12:49 +0330 Subject: [PATCH 001/135] perf(r235): reject irrelevant Start windows before expensive process lookups --- src/TaskbarMonitorEnhanced.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/TaskbarMonitorEnhanced.cs b/src/TaskbarMonitorEnhanced.cs index 352c580f..d33cf2f5 100644 --- a/src/TaskbarMonitorEnhanced.cs +++ b/src/TaskbarMonitorEnhanced.cs @@ -3477,6 +3477,11 @@ public static bool IsStartSurfacePresent(out string processName,out string class Native.RECT wr; if(!Native.GetWindowRect(h,out wr))return true; Rectangle r=wr.ToRectangle(); + // R190: reject irrelevant window geometry before expensive + // per-HWND .NET process table/name queries. + if(r.Width<300 || r.Height<250)return true; + if(r.Width>=screen.Width-20 && r.Height>=screen.Height-20)return true; + if(r.Bottom < screen.Top + screen.Height/2)return true; uint pid=0; Native.GetWindowThreadProcessId(h,out pid); @@ -3491,9 +3496,6 @@ public static bool IsStartSurfacePresent(out string processName,out string class String.Equals(pn,"SearchApp",StringComparison.OrdinalIgnoreCase); if(!realStartHost)return true; - if(r.Width<300 || r.Height<250)return true; - if(r.Width>=screen.Width-20 && r.Height>=screen.Height-20)return true; - if(r.Bottom < screen.Top + screen.Height/2)return true; StringBuilder sb=new StringBuilder(256); Native.GetClassName(h,sb,sb.Capacity); From 3d67d355f1db4e98cd32e2211f76457896d2bae5 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Fri, 16 Oct 2026 01:27:50 +0330 Subject: [PATCH 002/135] release(candidate): v1.7.3 CPU fix and overlap-free Glass/Settings (R243-R244) --- .github/workflows/ci.yml | 16 +- ...ptance.yml => release-v173-acceptance.yml} | 61 ++-- build/Build.ps1 | 10 +- build/Package-Signed-Setup.ps1 | 4 +- build/TaskbarMonitorEnhanced_Setup.csproj | 4 +- build/Test-Native-Presentation-R243.ps1 | 300 ++++++++++++++++++ build/Test-NoPawnIO-OssRelease.ps1 | 6 +- build/Test-Release-Gates.ps1 | 14 +- build/Test-SettingsUi-R244.ps1 | 87 +++++ build/Test-Signed-Payload-Contract.ps1 | 6 +- build/Test-Signed-Staging.ps1 | 6 +- build/Test-Upgrade172-R243.ps1 | 218 +++++++++++++ build/Test-UpgradeContract-R243.ps1 | 64 ++++ build/Verify-Embedded-Main.ps1 | 4 +- build/Verify-Release-Metadata-R243.ps1 | 105 ++++++ build/official-v172-managed.json | 128 ++++++++ docs/releases/v1.7.3/INSTALLER_README.txt | 7 + docs/releases/v1.7.3/RELEASE_NOTES.md | 13 + installer/TaskbarMonitorEnhanced_Setup.cs | 83 +++-- src/TaskbarGlass.cs | 20 +- src/TaskbarMonitorEnhanced.cs | 31 +- src/WorkspaceSelfTest.cs | 2 +- 22 files changed, 1095 insertions(+), 94 deletions(-) rename .github/workflows/{release-v172-acceptance.yml => release-v173-acceptance.yml} (59%) create mode 100644 build/Test-Native-Presentation-R243.ps1 create mode 100644 build/Test-SettingsUi-R244.ps1 create mode 100644 build/Test-Upgrade172-R243.ps1 create mode 100644 build/Test-UpgradeContract-R243.ps1 create mode 100644 build/Verify-Release-Metadata-R243.ps1 create mode 100644 build/official-v172-managed.json create mode 100644 docs/releases/v1.7.3/INSTALLER_README.txt create mode 100644 docs/releases/v1.7.3/RELEASE_NOTES.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69d55ffb..54985b6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,18 @@ jobs: $glass = Get-Content ./artifacts/ci-visual/glassproof/GLASS_PROOF.json -Raw | ConvertFrom-Json if ($glass.Status -ne 'PASS' -or $glass.Tests -ne 196) { throw '49-theme alpha contrast foreground proof did not PASS.' } + - name: R244 nine-page Settings UI overlap and Glass mode renderer guard + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-SettingsUi-R244.ps1 -MainPath ./build/_out/App/TaskbarMonitorEnhanced.exe -OutDir ./artifacts/ci-settings-r244 + $s = Get-Content ./artifacts/ci-settings-r244/R244_SETTINGS_UI.json -Raw | ConvertFrom-Json + if($s.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or + $s.SettingsPages -ne 9 -or $s.SiblingOverlapCount -ne 0 -or + $s.GlassStatusSeparate -ne $true -or $s.GlassTrackBackAlpha -ne 255) { + throw 'R244.Settings UI glass switch overlap/layout regression' + } + - name: Portable runtime contract probes (5 suites; installed host checks separate) shell: pwsh run: | @@ -123,7 +135,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference='Stop' - ./build/Verify-Release-Metadata-R155.ps1 -SelfTest + ./build/Verify-Release-Metadata-R243.ps1 -SelfTest if ($LASTEXITCODE -ne 0) {throw 'R136 release version, 5 output SHA and SBOM source gates FAILED'} - name: Attest binary build provenance @@ -156,6 +168,8 @@ jobs: artifacts/ci-visual/SUMMARY.json artifacts/ci-visual/chartglassproof/CHART_GLASS_PROOF.json artifacts/ci-visual/glassproof/GLASS_PROOF.json + artifacts/ci-settings-r244/R244_SETTINGS_UI.json + artifacts/ci-settings-r244/R244_GLASS_SWITCH_ON.png artifacts/ci-runtime/SUMMARY.json artifacts/BUILD_MANIFEST.json artifacts/SBOM.spdx.json diff --git a/.github/workflows/release-v172-acceptance.yml b/.github/workflows/release-v173-acceptance.yml similarity index 59% rename from .github/workflows/release-v172-acceptance.yml rename to .github/workflows/release-v173-acceptance.yml index e741e923..9a5130ad 100644 --- a/.github/workflows/release-v172-acceptance.yml +++ b/.github/workflows/release-v173-acceptance.yml @@ -1,7 +1,7 @@ -name: v1.7.2 - Exact Upgrade and Native Acceptance +name: v1.7.3 - Protected official v1.7.2 upgrade and native acceptance on: push: - branches: [release/r155-v172-upgrade, main] + branches: [release/r243-v173-cpu-safe-upgrade, main] workflow_dispatch: permissions: contents: read @@ -18,54 +18,57 @@ jobs: shell: pwsh run: | $ErrorActionPreference='Stop' - if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R155 source drift'} - foreach($p in @('build/Test-Upgrade171-R155.ps1','build/Test-UpgradeContract-R155.ps1','build/Test-Native-Presentation-R155.ps1')){ + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R243 source drift'} + foreach($p in @('build/Test-Upgrade172-R243.ps1','build/Test-UpgradeContract-R243.ps1','build/Test-Native-Presentation-R243.ps1')){ $t=$null;$e=$null [void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p).Path,[ref]$t,[ref]$e) - if(@($e).Count){throw ('R155 parser '+$p)} + if(@($e).Count){throw ('R243 parser '+$p)} } ./build/Build.ps1 ./build/Generate-Sbom.ps1 - ./build/Verify-Release-Metadata-R155.ps1 -SelfTest - if($LASTEXITCODE -ne 0){throw 'R155 version/manifest/SPDX gate'} + ./build/Verify-Release-Metadata-R243.ps1 -SelfTest + if($LASTEXITCODE -ne 0){throw 'R243 version/manifest/SPDX gate'} $main='./build/_out/App/TaskbarMonitorEnhanced.exe' - $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.2.exe' - $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.2';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} + $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.3.exe' + $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.3';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} [IO.File]::WriteAllText((Join-Path $pwd 'build/_out/CANDIDATE.json'),($c|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) - name: Actual compiled control, geometry, visibility and upgrade contracts shell: powershell run: | $ErrorActionPreference='Stop' $main='./build/_out/App/TaskbarMonitorEnhanced.exe' - ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R155_MODELS/APPEARANCE') - ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R155_MODELS/GEOMETRY') - ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R155_MODELS/VISIBILITY') - ./build/Test-UpgradeContract-R155.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.2.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R155_MODELS/UPGRADE') + ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/APPEARANCE') + ./build/Test-SettingsUi-R244.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/SETTINGS') + $ui=Get-Content (Join-Path $env:RUNNER_TEMP 'R243_MODELS/SETTINGS/R244_SETTINGS_UI.json') -Raw|ConvertFrom-Json + if($ui.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or $ui.SettingsPages -ne 9 -or $ui.SiblingOverlapCount -ne 0){throw 'R244_NATIVE_SETTINGS_UI_LAYOUT_COLLISION'} + ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/GEOMETRY') + ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/VISIBILITY') + ./build/Test-UpgradeContract-R243.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.3.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/UPGRADE') - name: Preserve one exact candidate installer, not a release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: R155-candidate + name: R243-candidate if-no-files-found: error retention-days: 14 path: | build/_out/CANDIDATE.json - build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.2.exe + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.3.exe - name: Preserve model receipts and binary manifest if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: R155-compiled-contracts + name: R243-compiled-contracts if-no-files-found: warn retention-days: 21 path: | - ${{ runner.temp }}/R155_MODELS/** + ${{ runner.temp }}/R243_MODELS/** artifacts/BUILD_MANIFEST.json artifacts/SBOM.spdx.json upgrade-and-native: needs: build-candidate runs-on: windows-latest timeout-minutes: 24 - name: Real v1.7.1 upgrade and four native modes (${{ matrix.host }}) + name: Real official 1.7.2 to 1.7.3 upgrade and native modes (${{ matrix.host }}) strategy: fail-fast: false matrix: @@ -78,31 +81,31 @@ jobs: - name: Transfer exact previously compiled Setup bytes uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: R155-candidate + name: R243-candidate path: build/_out - - name: Genuine direct and inherited v1.7.1 protected upgrade + - name: Genuine official v1.7.2 upgrade preserving user data shell: powershell env: GH_TOKEN: ${{ github.token }} run: | $ErrorActionPreference='Stop' - ./build/Test-Upgrade171-R155.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' - $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R155_UPGRADE_${{ matrix.host }}/R155_UPGRADE.json') -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_GENUINE_171_TO_172_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R155 actual updater acceptance failed'} + ./build/Test-Upgrade172-R243.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R243_UPGRADE_${{ matrix.host }}/R243_UPGRADE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_GENUINE_172_TO_173_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R243 actual updater acceptance failed'} - name: Native menu, Settings, Start, fullscreen and 1-percent regression on upgraded bytes shell: powershell run: | $ErrorActionPreference='Stop' - ./build/Test-Native-Presentation-R155.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate - $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R155_${{ matrix.host }}/R155_NATIVE.json') -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R155 native four-mode evidence not accepted'} + ./build/Test-Native-Presentation-R243.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R243_${{ matrix.host }}/R243_NATIVE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R243 native four-mode evidence not accepted'} - name: Preserve raw upgrade receipts and physical native pixels on success or failure if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: R155-native-upgrade-${{ matrix.host }} + name: R243-native-upgrade-${{ matrix.host }} if-no-files-found: warn retention-days: 21 path: | - ${{ runner.temp }}/TBME_R155_UPGRADE_${{ matrix.host }}/R155_UPGRADE.json - ${{ runner.temp }}/TBME_R155_${{ matrix.host }}/** + ${{ runner.temp }}/TBME_R243_UPGRADE_${{ matrix.host }}/R243_UPGRADE.json + ${{ runner.temp }}/TBME_R243_${{ matrix.host }}/** diff --git a/build/Build.ps1 b/build/Build.ps1 index b3ecde8f..867a51df 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -92,12 +92,12 @@ $CanonicalTextResources=[ordered]@{ 'TaskbarMonitorSensorSupervisor.cs'=(Join-Path $Root 'src\sensors\TaskbarMonitorSensorSupervisor.cs') 'TBME_Setup_Elevated_Helper.ps1'=(Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1') 'LICENSE'=(Join-Path $Root 'LICENSE') - 'README.md'=(Join-Path $Root 'docs\releases\v1.7.2\INSTALLER_README.txt') + 'README.md'=(Join-Path $Root 'docs\releases\v1.7.3\INSTALLER_README.txt') 'AUTHORS.md'=(Join-Path $Root 'AUTHORS.md') 'COPYRIGHT_AND_ATTRIBUTION.md'=(Join-Path $Root 'COPYRIGHT_AND_ATTRIBUTION.md') 'AI_ASSISTED_DEVELOPMENT.md'=(Join-Path $Root 'AI_ASSISTED_DEVELOPMENT.md') 'THIRD_PARTY_NOTICES.md'=(Join-Path $Root 'THIRD_PARTY_NOTICES.md') - 'RELEASE_NOTES_v1.7.2.md'=(Join-Path $Root 'docs\releases\v1.7.2\RELEASE_NOTES.md') + 'RELEASE_NOTES_v1.7.3.md'=(Join-Path $Root 'docs\releases\v1.7.3\RELEASE_NOTES.md') 'UPSTREAM_REFERENCE_GPL_NOTICE.md'=(Join-Path $Root 'UPSTREAM_REFERENCE_GPL_NOTICE.md') 'TaskbarMonitorEnhanced_Setup.cs'=(Join-Path $Root 'installer\TaskbarMonitorEnhanced_Setup.cs') } @@ -107,7 +107,7 @@ foreach($entry in $CanonicalTextResources.GetEnumerator()){ Write-Host 'TBME_CANONICAL_TEXT_PAYLOAD=PASS' Build (Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj') -$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' if(!(Test-Path -LiteralPath $Setup)){throw 'Setup output missing.'} # SignPath Foundation requires uniform signed-artifact project and ProductVersion metadata. @@ -152,8 +152,8 @@ if([string]$v.Status -ne 'PASS' -or [int]$v.Resources -ne 25 -or $v.PawnIOBundle if($LASTEXITCODE -ne 0){throw 'Application contract suite failed.'} $manifest=[ordered]@{ - Version='1.7.2' - Build='V1_7_2_R155_TASKBAR_FIX' + Version='1.7.3' + Build='V1_7_3_R243_CPU_PREFILTER' GeneratedUtc=[datetime]::UtcNow.ToString('o') Dependencies=[ordered]@{ LibreHardwareMonitor=[ordered]@{Version=[string]$Lock.dependencies.LibreHardwareMonitor.version;Url=$LhmUrl;SHA256=$LhmSha} diff --git a/build/Package-Signed-Setup.ps1 b/build/Package-Signed-Setup.ps1 index 266d31bd..0034a73a 100644 --- a/build/Package-Signed-Setup.ps1 +++ b/build/Package-Signed-Setup.ps1 @@ -10,10 +10,10 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $UnsignedMain=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' $StageBase=if($ContractFixtureOnly){Join-Path $Root 'artifacts\_r80_contract_only'}else{Join-Path $Root 'artifacts\signed\Setup\TO_SIGN'} -$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' $Proof=Join-Path $StageBase 'EMBEDDED_MAIN_PROOF.json' $Verification=Join-Path $StageBase 'SETUP_VERIFY.json' $CreatedStage=$false diff --git a/build/TaskbarMonitorEnhanced_Setup.csproj b/build/TaskbarMonitorEnhanced_Setup.csproj index 8aa58816..332ce868 100644 --- a/build/TaskbarMonitorEnhanced_Setup.csproj +++ b/build/TaskbarMonitorEnhanced_Setup.csproj @@ -1,5 +1,5 @@ - net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.2_out\Setup\false + net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.3_out\Setup\false _package\TaskbarMonitorEnhanced.exefalse @@ -23,7 +23,7 @@ - + \ No newline at end of file diff --git a/build/Test-Native-Presentation-R243.ps1 b/build/Test-Native-Presentation-R243.ps1 new file mode 100644 index 00000000..7ff2d271 --- /dev/null +++ b/build/Test-Native-Presentation-R243.ps1 @@ -0,0 +1,300 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel,[switch]$UseInstalledCandidate) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R146_REAL_TEST_ONLY_FRESH_HOSTED_WINDOWS_FRAMEWORK'} +Add-Type -AssemblyName System.Drawing,System.Windows.Forms,UIAutomationClient,UIAutomationTypes +$native=@' +using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices; +public static class R146Native { + [StructLayout(LayoutKind.Sequential)] public struct Rect {public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential)] public struct Point {public int X,Y;} + [StructLayout(LayoutKind.Sequential)] public struct Mouse {public int x,y;public uint data,flags,time;public UIntPtr extra;} + [StructLayout(LayoutKind.Explicit)] public struct Union {[FieldOffset(0)] public Mouse m;} + [StructLayout(LayoutKind.Sequential)] public struct Input {public uint type;public Union u;} + public delegate bool EnumFn(IntPtr h,IntPtr data); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p,EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out Rect r); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetWindowLongPtr(IntPtr h,int n); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint n); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h,StringBuilder s,int n); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c,string t); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int s); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l); + [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr extra); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out Point p); + [DllImport("user32.dll",SetLastError=true)] public static extern uint SendInput(uint n,Input[] p,int size); + public static void Key(byte code){keybd_event(code,0,0,UIntPtr.Zero);keybd_event(code,0,2,UIntPtr.Zero);} + public static void Click(int x,int y,bool right){Point old;GetCursorPos(out old);SetCursorPos(x,y);try{ + Input down=new Input{u=new Union{m=new Mouse{flags=right?8u:2u}}}; + Input up=new Input{u=new Union{m=new Mouse{flags=right?16u:4u}}}; + if(Marshal.SizeOf(typeof(Input))!=40||SendInput(2,new[]{down,up},40)!=2)throw new Exception("Input not delivered"); + }finally{SetCursorPos(old.X,old.Y);}} + public static IntPtr[] Owned(uint pid,IntPtr tray){var list=new List();EnumFn f=(h,d)=>{uint p;GetWindowThreadProcessId(h,out p);if(p==pid&&!list.Contains(h))list.Add(h);return true;};EnumWindows(f,IntPtr.Zero);EnumChildWindows(tray,f,IntPtr.Zero);return list.ToArray();} + public static bool Above(IntPtr a,IntPtr b){for(int i=0;i<512;i++){b=GetWindow(b,3);if(b==IntPtr.Zero)return false;if(a==b)return true;}return false;} + public static string Title(IntPtr h){var s=new StringBuilder(256);GetWindowText(h,s,s.Capacity);return s.ToString();} +} +'@ +Add-Type -TypeDefinition $native -Language CSharp +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Private -ne $true){throw 'R146_NOT_EXACT_PRIVATE_BUILD_ARTIFACT'} +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +if((Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R146_SETUP_SHA'} +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R243_'+$HostLabel) +if((Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R243_NATIVE_OUTPUT_OR_PROCESS_PRESTATE'} +if($UseInstalledCandidate){ + if(!(Test-Path $app) -or !(Test-Path $reg) -or (Test-Path (Join-Path $app '.tbme-upgrade'))){throw 'R243_VERIFIED_EXISTING_CANDIDATE_REQUIRED'} + $s=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($s.Version -cne '1.7.3' -or (Get-FileHash (Join-Path $app 'TaskbarMonitorEnhanced.exe') -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R243_NATIVE_INSTALLED_SHA_OR_VERSION'} +}else{if((Test-Path $app) -or (Test-Path $reg)){throw 'R243_NATIVE_FRESH_VM_REQUIRED'}} +New-Item -ItemType Directory -Path $out|Out-Null +$visualFailures=New-Object 'Collections.Generic.List[string]' +$phase='INSTALL';$status='FAIL';$failure='';$process=$null;$full=$null;$taskbar=[IntPtr]::Zero;$hidTaskbar=$false;$evidence=@() +function Box([IntPtr]$h){$r=New-Object R146Native+Rect;if(![R146Native]::GetWindowRect($h,[ref]$r)){throw 'R146_RECT_FAILED'};return New-Object Drawing.Rectangle($r.L,$r.T,($r.R-$r.L),($r.B-$r.T))} +function Pump([int]$ms){$stop=[Diagnostics.Stopwatch]::StartNew();while($stop.ElapsedMilliseconds -lt $ms){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 25}} +function Capture([IntPtr]$h,[string]$label){ + $r=Box $h;$file=$label+'.png';$b=New-Object Drawing.Bitmap($r.Width,$r.Height) + try{ + $g=[Drawing.Graphics]::FromImage($b);try{$g.CopyFromScreen($r.Location,[Drawing.Point]::Empty,$r.Size)}finally{$g.Dispose()} + $edges=0;for($y=0;$y -lt $b.Height;$y++){for($x=1;$x -lt $b.Width;$x++){ + $a=$b.GetPixel($x,$y);$z=$b.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$edges++} + }} + $b.Save((Join-Path $out $file),[Drawing.Imaging.ImageFormat]::Png) + if($edges -lt 60){$visualFailures.Add(('R146_PHYSICAL_GLYPHS_MISSING '+$label+' EDGES='+$edges))} + return @{State=$label;File=$file;SHA256=(Get-FileHash (Join-Path $out $file) -Algorithm SHA256).Hash;Edges=$edges;Rect=@($r.X,$r.Y,$r.Width,$r.Height);Parent=[R146Native]::GetParent($h).ToInt64();Owner=[R146Native]::GetWindow($h,4).ToInt64();OwnerMemory=[R146Native]::GetWindowLongPtr($h,-8).ToInt64();PreviousSibling=[R146Native]::GetWindow($h,3).ToInt64();ExStyle=[R146Native]::GetWindowLongPtr($h,-20).ToInt64();Style=[R146Native]::GetWindowLongPtr($h,-16).ToInt64();Foreground=[R146Native]::GetForegroundWindow().ToInt64()} + }finally{$b.Dispose()} +} +# R163 fork-only paint readiness. WS_VISIBLE is not proof of actual text ink. +# Same RGB edge definition as Capture(): adjacent channel-sum delta >=120. +# ZERO edges must NEVER satisfy 60-edge native acceptance. No product code change. +function Probe-R163PhysicalInk([IntPtr]$h,[string]$label,[bool]$saveFrame){ + $rect=Box $h + if($rect.Width -lt 80 -or $rect.Height -lt 16 -or $rect.Height -ge 100){ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error='R163_INVALID_WINDOW_GEOMETRY'} + } + $bitmap=New-Object Drawing.Bitmap($rect.Width,$rect.Height) + try{ + try{ + $g=[Drawing.Graphics]::FromImage($bitmap) + try{$g.CopyFromScreen($rect.Location,[Drawing.Point]::Empty,$rect.Size)} + finally{$g.Dispose()} + }catch{ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error=('R163_COPY_FROM_SCREEN_FAILED '+$_.Exception.Message)} + } + $ink=0 + $colors=New-Object 'System.Collections.Generic.HashSet[int]' + for($y=0;$y -lt $bitmap.Height;$y++){ + for($x=0;$x -lt $bitmap.Width;$x++){ + $a=$bitmap.GetPixel($x,$y) + $rgb=([int]$a.R -shl 16) -bor ([int]$a.G -shl 8) -bor [int]$a.B + [void]$colors.Add($rgb) + if($x -ge 1){ + $z=$bitmap.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$ink++} + } + } + } + $file='';$sha='';$bytes=0 + if($saveFrame){ + $file=$label+'.png' + $full=Join-Path $out $file + if(Test-Path $full){throw ('R163_PROBE_IMAGE_OVERWRITE_REFUSED '+$file)} + $bitmap.Save($full,[Drawing.Imaging.ImageFormat]::Png) + $sha=(Get-FileHash $full -Algorithm SHA256).Hash + $bytes=(Get-Item $full).Length + } + return @{Edges=$ink;Colors=$colors.Count;Bytes=$bytes;File=$file;SHA256=$sha;Error='';Rect=@($rect.X,$rect.Y,$rect.Width,$rect.Height)} + }finally{$bitmap.Dispose()} +} + +function Assert-Visible([IntPtr]$h,[Drawing.Rectangle]$original,[string]$label,[bool]$top){ + if(![R146Native]::IsWindowVisible($h)){throw ('R146_MONITOR_HIDDEN '+$label)} + $r=Box $h + if($r.X -ne $original.X -or $r.Y -ne $original.Y -or $r.Height -ne $original.Height -or [Math]::Abs($r.Width-$original.Width) -gt 96){throw ('R146_UNEXPECTED_MOVEMENT '+$label)} + # R153: retain both direct owner-memory and WS_POPUP GetParent checks. + # R152 real Start evidence showed GW_OWNER=0 but GetParent=the exact + # taskbar and 877 physical glyph edges. Never let a relationship-walk + # API substitute for pixels; never waive actual owner-memory or geometry. + $ownerMemory=[R146Native]::GetWindowLongPtr($h,-8) + $popupOwner=[R146Native]::GetParent($h) + if($ownerMemory -ne $taskbar -or $popupOwner -ne $taskbar){ + $nativeOwner=[R146Native]::GetWindow($h,4) + $directTray=[R146Native]::FindWindow('Shell_TrayWnd',$null) + $frame=Capture $h ($label+'_OWNER_FAILURE') + [IO.File]::WriteAllText((Join-Path $out ($label+'_NATIVE_FAILURE.json')),(@{ExpectedOwner=$taskbar.ToInt64();ActualOwner=$nativeOwner.ToInt64();DirectOwnerMemory=$ownerMemory.ToInt64();PopupOwner=$popupOwner.ToInt64();DirectTaskbar=$directTray.ToInt64();Frame=$frame}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + throw ('R152_TASKBAR_OWNER_DRIFT '+$label+' EXPECTED='+$taskbar.ToInt64()+' ACTUAL='+$nativeOwner.ToInt64()+' DIRECT='+$directTray.ToInt64()) + } + if(([R146Native]::GetWindowLongPtr($h,-16).ToInt64() -band 0x40000000) -ne 0){throw ('R151_MONITOR_BECAME_CHILD '+$label)} + if($top -and [R146Native]::Above($taskbar,$h)){throw ('R146_MONITOR_UNDER_TASKBAR '+$label)} +} +try{ + if(!$UseInstalledCandidate){ + $install=Start-Process -FilePath $setup -ArgumentList @('/install','/quiet','/desktop=0','/startup=0') -PassThru + if(!$install.WaitForExit(190000) -or $install.ExitCode -ne 0){throw 'R243_SETUP_DID_NOT_FINISH'} + } + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe';$config=Join-Path $app 'config.json' + if((Get-FileHash $main -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R146_INSTALLED_MAIN_SHA'} + $taskbar=[R146Native]::FindWindow('Shell_TrayWnd',$null) + if($taskbar -eq [IntPtr]::Zero -or ![R146Native]::IsWindowVisible($taskbar)){throw 'R146_REAL_TASKBAR_MISSING'} + $assembly=[Reflection.Assembly]::LoadFrom($main) + $shell=$assembly.GetType('TaskbarMonitorEnhanced.ShellUi',$true) + $shellMethod=$shell.GetMethod('IsStartShellForeground',[Reflection.BindingFlags]'Static,Public,NonPublic') + foreach($mode in @('plain1','plain10','glass0','opaque100')){ + $phase=$mode + $pct=if($mode -ceq 'plain1'){0.01}elseif($mode -ceq 'plain10'){0.10}elseif($mode -ceq 'glass0'){0.0}else{1.0} + $fixture=@{Theme='Retro Terminal';Opacity=$pct;GlassMode=($mode -ceq 'glass0');OpacityWhenGlassOff=0.10;ConfigSchemaVersion=8;StartWithWindows=$false;ShowSparklines=$true;UpdateIntervalMs=1000} + [IO.File]::WriteAllText($config,($fixture|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $process=Start-Process -FilePath $main -WorkingDirectory $app -PassThru + $readyClock=[Diagnostics.Stopwatch]::StartNew();$readyObservations=@() + Pump 5000 # Keep original warm-up and strict 20s total readiness deadline. + $monitor=[IntPtr]::Zero;$paintFirst=$false;$paintLast=$null + $lastEligible=[IntPtr]::Zero + do{ + $process.Refresh() + if($process.HasExited){throw ('R158_MAIN_EXITED_BEFORE_READY '+$mode+' CODE='+$process.ExitCode)} + $observed=@() + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){ + $visible=[R146Native]::IsWindowVisible($h);$r=Box $h + $one=@{Handle=$h.ToInt64();Visible=$visible;Rect=@($r.X,$r.Y,$r.Width,$r.Height)} + if($visible -and $r.Width -ge 80 -and $r.Height -ge 16 -and $r.Height -lt 100 -and (Box $taskbar).Contains($r)){ + $lastEligible=$h + # Persist FIRST physical frame, whether ink is present or missing. + $paint=Probe-R163PhysicalInk $h ($mode+'_R163_FIRST_PAINT') (!$paintFirst) + $paintFirst=$true + $paintLast=$paint + $one.PaintEdges=$paint.Edges;$one.PaintColors=$paint.Colors;$one.PaintError=$paint.Error + if($paint.Edges -ge 60 -and [string]$paint.Error -ceq ''){ + $monitor=$h;$observed+=@($one);break + } + } + $observed+=@($one) + } + if($readyObservations.Count -lt 24){ + $readyObservations+=@{ElapsedMs=$readyClock.ElapsedMilliseconds;Windows=$observed} + } + if($monitor -eq [IntPtr]::Zero){Pump 200} + }while($monitor -eq [IntPtr]::Zero -and $readyClock.ElapsedMilliseconds -lt 20000) + $readyClock.Stop() + $timeoutProbe=$null + if($monitor -eq [IntPtr]::Zero -and $lastEligible -ne [IntPtr]::Zero){ + $timeoutProbe=Probe-R163PhysicalInk $lastEligible ($mode+'_R163_TIMEOUT_PAINT') $true + } + $ready=[ordered]@{Mode=$mode;Pid=$process.Id;Ready=($monitor -ne [IntPtr]::Zero); + PaintReady=($monitor -ne [IntPtr]::Zero -and $null -ne $paintLast -and $paintLast.Edges -ge 60); + PaintThresholdEdges=60;OriginalRgbDelta=120; + LastMeasuredEdges=if($null -eq $paintLast){-1}else{$paintLast.Edges}; + LastMeasuredColors=if($null -eq $paintLast){0}else{$paintLast.Colors}; + TimeoutFrame=$timeoutProbe; + ElapsedMilliseconds=$readyClock.ElapsedMilliseconds;HardDeadlineMs=20000; + Observations=$readyObservations;ProductSourceChanged=$false; + NoThresholdWeakening=$true;SeparateTaskbarHideInvariantRequired=$true} + [IO.File]::WriteAllText((Join-Path $out ('R158_READY_'+$mode+'.json')),($ready|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if($monitor -eq [IntPtr]::Zero){ + if($lastEligible -ne [IntPtr]::Zero){ + throw ('R163_PHYSICAL_PAINT_NOT_READY_WITHIN_20_SECONDS '+$mode+ + ' EDGES='+$ready.LastMeasuredEdges+' COLORS='+$ready.LastMeasuredColors) + } + throw ('R158_NATIVE_WINDOW_NOT_READY_WITHIN_20_SECONDS '+$mode) + } + if(!$ready.PaintReady -or $ready.LastMeasuredEdges -lt 60){ + throw ('R163_UNPROVEN_NATIVE_PAINT '+$mode) + } + Write-Output ('R163_NATIVE_AND_ACTUAL_INK_READY MODE='+$mode+' PID='+$process.Id+ + ' ELAPSED_MS='+$readyClock.ElapsedMilliseconds+' EDGES='+$ready.LastMeasuredEdges) + $original=Box $monitor;$top=$true + # Original Capture() remains the separate final pixel threshold acceptance. + $evidence+=Capture $monitor ($mode+'_NORMAL') + # Real right-click and invoke the actual installed product's Settings menu item. + [R146Native]::Click(($original.X+40),($original.Y+($original.Height/2)),$true);Pump 650 + Assert-Visible $monitor $original ($mode+'_MENU') $top + $evidence+=Capture $monitor ($mode+'_MENU') + $menuItem=$null + $condition=New-Object Windows.Automation.AndCondition([Windows.Automation.Condition[]]@( + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::ProcessIdProperty,[int]$process.Id)), + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::NameProperty,'Settings...')))) + $menuItem=[Windows.Automation.AutomationElement]::RootElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$condition) + if($null -eq $menuItem){throw 'R146_REAL_SETTINGS_MENU_ITEM_NOT_FOUND'} + $invoke=$menuItem.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern);$invoke.Invoke();Pump 1500 + $settings=[IntPtr]::Zero + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){if([R146Native]::Title($h) -like '* - Settings' -and [R146Native]::IsWindowVisible($h)){$settings=$h;break}} + if($settings -eq [IntPtr]::Zero -or [R146Native]::GetForegroundWindow() -ne $settings){throw 'R146_SETTINGS_NOT_REAL_FOREGROUND'} + $work=[Windows.Forms.Screen]::FromHandle($settings).WorkingArea + if(!$work.Contains((Box $settings))){throw 'R146_SETTINGS_EXTENDS_BEHIND_TASKBAR'} + Assert-Visible $monitor $original ($mode+'_SETTINGS') $top + $evidence+=Capture $monitor ($mode+'_SETTINGS') + [void][R146Native]::SendMessage($settings,0x0010,[IntPtr]::Zero,[IntPtr]::Zero);Pump 500 + [R146Native]::Key(0x5B);Pump 1200 + $args=[object[]]@('','',[IntPtr]::Zero) + if(!$shellMethod.Invoke($null,$args)){throw 'R146_GENUINE_START_FOREGROUND_NOT_VERIFIED'} + Assert-Visible $monitor $original ($mode+'_START') $top + $evidence+=Capture $monitor ($mode+'_START') + [R146Native]::Key(0x1B);Pump 900 + Assert-Visible $monitor $original ($mode+'_AFTER_START') $top + # A real foreground fullscreen window covering this monitor and its taskbar. + $full=New-Object Windows.Forms.Form + $full.FormBorderStyle='None';$full.StartPosition='Manual';$full.Bounds=[Windows.Forms.Screen]::FromHandle($taskbar).Bounds + $full.TopMost=$true;$full.BackColor=[Drawing.Color]::FromArgb(24,40,56);$full.Text='R146 disposable fullscreen witness';$full.Show();Pump 300 + [R146Native]::Click(($full.Left+100),($full.Top+100),$false);Pump 1500 + if([R146Native]::GetForegroundWindow() -ne $full.Handle){throw 'R146_FULLSCREEN_NOT_FOREGROUND'} + if([R146Native]::IsWindowVisible($monitor)){throw 'R146_MONITOR_VISIBLE_WITHOUT_TASKBAR_IN_FULLSCREEN'} + $evidence+=@{State=$mode+'_FULLSCREEN';Hidden=$true;Foreground=$full.Handle.ToInt64()} + $full.Close();$full.Dispose();$full=$null;Pump 1500 + Assert-Visible $monitor $original ($mode+'_RETURN_FULLSCREEN') $top + $evidence+=Capture $monitor ($mode+'_RETURN_FULLSCREEN') + # Simulate taskbar visibility notification, only on disposable runner; always restore. + [void][R146Native]::ShowWindow($taskbar,0);$hidTaskbar=$true;Pump 1100 + if([R146Native]::IsWindowVisible($monitor)){ + # Do not suppress this independent FAIL gate. Collect the real HWND/shell + # visibility and optional screen-pixel evidence before the existing throw. + $forkHide=[ordered]@{ + Mode=$mode;MainPid=$process.Id; + TaskbarVisible=[R146Native]::IsWindowVisible($taskbar) + MonitorVisible=[R146Native]::IsWindowVisible($monitor) + TaskbarRect=@( (Box $taskbar).X,(Box $taskbar).Y,(Box $taskbar).Width,(Box $taskbar).Height ) + MonitorRect=@( (Box $monitor).X,(Box $monitor).Y,(Box $monitor).Width,(Box $monitor).Height ) + PopupOwner=[R146Native]::GetParent($monitor).ToInt64() + MemoryOwner=[R146Native]::GetWindowLongPtr($monitor,-8).ToInt64() + ProductSourceChanged=$false + OriginalHideInvariantRetained=$true + } + [IO.File]::WriteAllText((Join-Path $out ($mode+'_R163_TASKBAR_HIDE_FAILURE.json')), + ($forkHide|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + throw 'R146_MONITOR_IGNORES_TASKBAR_HIDE' + } + [void][R146Native]::ShowWindow($taskbar,4);$hidTaskbar=$false;Pump 1100 + Assert-Visible $monitor $original ($mode+'_TASKBAR_REAPPEARS') $top + $process.Refresh();$cpu0=$process.CPU;$clock=[Diagnostics.Stopwatch]::StartNew();Pump 5000;$clock.Stop();$process.Refresh() + $evidence+=@{State=$mode+'_CPU';Seconds=$clock.Elapsed.TotalSeconds;CpuSeconds=$process.CPU-$cpu0;PercentOfOneCore=100*($process.CPU-$cpu0)/$clock.Elapsed.TotalSeconds;Status='MEASURED_NOT_USER_PC_ACCEPTANCE'} + [void][R146Native]::SendMessage($monitor,0x0010,[IntPtr]::Zero,[IntPtr]::Zero) + if(!$process.WaitForExit(10000)){throw 'R146_OWN_TEST_PROCESS_DID_NOT_EXIT'};$process.Dispose();$process=$null + } + if($visualFailures.Count){throw ($visualFailures -join ';')} + $status='PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' +}catch{$failure=$_.Exception.ToString().Substring(0,[Math]::Min(3500,$_.Exception.ToString().Length))} +finally{ + if($hidTaskbar -and $taskbar -ne [IntPtr]::Zero){[void][R146Native]::ShowWindow($taskbar,4)} + [R146Native]::Key(0x1B) + if($null -ne $full){$full.Close();$full.Dispose()} + if($null -ne $process){try{if(!$process.HasExited){$process.Kill()}}catch{}} + try{ + $logs=@(Get-ChildItem (Join-Path $app 'Logs') -Filter 'tbme_csharp_*.log' -File -ErrorAction Stop) + foreach($log in $logs){ + Get-Content -LiteralPath $log.FullName -Tail 1600 | Select-String -Pattern 'PRESENTATION|GLASS_NATIVE|GLASS_COMPOSITION|TASKBAR_|VISIBILITY_WATCHDOG|LOW_PRESSURE|CONTEXT_MENU|SETTINGS|START_|RECOVERY' | ForEach-Object Line | Set-Content -LiteralPath (Join-Path $out ('DIAG_'+$log.Name)) -Encoding UTF8 + } + }catch{Write-Output ('R148_LOG_COLLECTION_WARNING '+$_.Exception.Message)} + $r=[ordered]@{Revision='R243';Status=$status;Phase=$phase;Failure=$failure;Host=$HostLabel;EventCommit=$env:GITHUB_SHA;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;Evidence=$evidence;TestedModes=@('plain1','plain10','glass0','opaque100');ExpectedTaskbarOwner=$taskbar.ToInt64();PersonalComputerTouched=$false;Proof='Installed candidate + native HWND + real screenshots + real input; not synthetic taskbar paint'} + [IO.File]::WriteAllText((Join-Path $out 'R243_NATIVE.json'),($r|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 12 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R146_NATIVE_FAIL '+$failure)} diff --git a/build/Test-NoPawnIO-OssRelease.ps1 b/build/Test-NoPawnIO-OssRelease.ps1 index 3c54d8c9..71853a27 100644 --- a/build/Test-NoPawnIO-OssRelease.ps1 +++ b/build/Test-NoPawnIO-OssRelease.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $LegacyDir=Join-Path $Root 'artifacts\_r85_forbidden_pawnio_fixture' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' -$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' $Main=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' $LegacyVerify=Join-Path $LegacyDir 'legacy_verify.json' $Reader=Join-Path $PSScriptRoot 'Verify-Embedded-Main.ps1' if(Test-Path -LiteralPath $LegacyDir){throw 'R85 UNSAFE RERUN: FORBIDDEN FIXTURE ALREADY EXISTS'} @@ -56,7 +56,7 @@ try { Write-Output ('R85_OSS_NO_PROPRIETARY_BUNDLE_PASS productionEmbedded=False negativeLegacyResources='+$v.Resources+' negativeRejected=True') } finally { if($created -and (Test-Path -LiteralPath $LegacyDir)){ - $allow=@('TaskbarMonitorEnhanced_Setup_1.7.2.exe','TaskbarMonitorEnhanced_Setup_1.7.2.exe.config','legacy_verify.json') + $allow=@('TaskbarMonitorEnhanced_Setup_1.7.3.exe','TaskbarMonitorEnhanced_Setup_1.7.3.exe.config','legacy_verify.json') $objects=@(Get-ChildItem -LiteralPath $LegacyDir -Force) if(@($objects | Where-Object {$_.PSIsContainer -or $_.Name -notin $allow}).Count -ne 0){ throw ('R85 DEGRADED: UNKNOWN FILE IN OWNED STAGING; PRESERVED '+$LegacyDir) diff --git a/build/Test-Release-Gates.ps1 b/build/Test-Release-Gates.ps1 index 0f771072..dc1d1c59 100644 --- a/build/Test-Release-Gates.ps1 +++ b/build/Test-Release-Gates.ps1 @@ -44,7 +44,7 @@ if($null -ne $manifest) { 'TaskbarMonitorEnhanced.exe.config'='build\_out\App\TaskbarMonitorEnhanced.exe.config'; 'TaskbarMonitorSensorBroker.exe'='build\_out\Broker\TaskbarMonitorSensorBroker.exe'; 'TaskbarMonitorSensorSupervisor.exe'='build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe'; - 'TaskbarMonitorEnhanced_Setup_1.7.2.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' + 'TaskbarMonitorEnhanced_Setup_1.7.3.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' } $realHashesPass=$true foreach($item in $outputs){ @@ -168,7 +168,7 @@ Add-Gate 'G12_START_SEARCH_24_REAL_NATIVE_INK_AND_EXACT_RETURN' $startFloatPass # R75: G02 hashes deterministic UNSIGNED builder outputs. G14 and G15 must use # separate SIGNED release staging, since Authenticode modifies executable SHA256. $signedMainExe=Join-Path $root 'artifacts\signed\Main\TaskbarMonitorEnhanced.exe' -$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' $expectedSignedMainDigest='' if((Test-Path -LiteralPath $signedMainExe -PathType Leaf) -and (Test-Path -LiteralPath $signedSetupExe -PathType Leaf)){ @@ -233,7 +233,7 @@ Add-Gate 'G14_INSTALLED_TASKBAR_EXPLORER_SENSORS_ROLLBACK' $installedPass $insta $setupExe=$signedSetupExe $mainExe=$signedMainExe # Keep original _out builder outputs immutable so G02 exact source checks remain reproducible. -$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' # R73: G15 must prove actual SignPath Foundation publisher, valid timestamps, matched # exact file/product versions AND live provider-origin/payload evidence. Never trust # self-declared "approved" JSON, a test certificate or token-less synthetic output. @@ -253,10 +253,10 @@ if((Test-Path -LiteralPath $setupExe -PathType Leaf) -and (Test-Path -LiteralPat $sameCert=$null -ne $setupCert -and $null -ne $mainCert -and $setupCert.Thumbprint -ceq $mainCert.Thumbprint $exactMetadata=$setupInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and $mainInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and - $setupInfo.ProductVersion -ceq '1.7.2+r126' -and - $mainInfo.ProductVersion -ceq '1.7.2+r126' -and - $setupInfo.FileVersion -ceq '1.7.2.0' -and - $mainInfo.FileVersion -ceq '1.7.2.0' + $setupInfo.ProductVersion -ceq '1.7.3+r243' -and + $mainInfo.ProductVersion -ceq '1.7.3+r243' -and + $setupInfo.FileVersion -ceq '1.7.3.0' -and + $mainInfo.FileVersion -ceq '1.7.3.0' $cryptoPass=([string]$mainSig.Status -ceq 'Valid') -and ([string]$setupSig.Status -ceq 'Valid') -and $mainName -ceq 'SignPath Foundation' -and diff --git a/build/Test-SettingsUi-R244.ps1 b/build/Test-SettingsUi-R244.ps1 new file mode 100644 index 00000000..652eeca7 --- /dev/null +++ b/build/Test-SettingsUi-R244.ps1 @@ -0,0 +1,87 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$MainPath, + [Parameter(Mandatory=$true)][string]$OutDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Inspect the exact candidate WinForms SettingsForm in Windows PowerShell 5.1. +# Never launch the running product, access user's configuration or send GUI input. +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R244_WINDOWS_POWERSHELL_51_REQUIRED'} +if(Test-Path -LiteralPath $OutDir){throw 'R244_OUTPUT_PREEXISTS'} +Add-Type -AssemblyName System.Windows.Forms,System.Drawing +$path=[IO.Path]::GetFullPath($MainPath) +$a=[Reflection.Assembly]::LoadFrom($path) +$cfgType=$a.GetType('TaskbarMonitorEnhanced.AppConfig',$true) +$formType=$a.GetType('TaskbarMonitorEnhanced.SettingsForm',$true) +$flags=[Reflection.BindingFlags]'Instance,Public,NonPublic' +$config=[Activator]::CreateInstance($cfgType,$true) +$config.GlassMode=$true +$config.Opacity=0 +$config.OpacityWhenGlassOff=0.1 +$ctor=@($formType.GetConstructors($flags)|Where-Object {$_.GetParameters().Count -eq 2})[0] +$form=$ctor.Invoke([object[]]@($config,$null)) +$results=[Collections.Generic.List[object]]::new() +$severe=[Collections.Generic.List[string]]::new() +try{ + $toggle=$formType.GetField('glassModeToggle',$flags).GetValue($form) + $stateField=$formType.GetField('glassModeState',$flags) + $state=if($stateField){$stateField.GetValue($form)}else{$null} + $tabs=$formType.GetField('tabsControl',$flags).GetValue($form) + if($null -eq $toggle -or $null -eq $tabs){throw 'R244_MISSING_SETTINGS_GLASS_OR_TABS'} + if($tabs.TabPages.Count -ne 9){$severe.Add(('TAB_COUNT_'+$tabs.TabPages.Count))} + if($toggle.BackColor.A -ne 255){$severe.Add('GLASS_TOGGLE_TRANSPARENT_BACKGROUND')} + if($toggle.Text -ne ''){$severe.Add('GLASS_SWITCH_NATIVE_TEXT_NOT_EMPTY')} + if($toggle.Width -gt 80){$severe.Add('GLASS_CONTROL_TOO_WIDE_WITH_EMBEDDED_STATUS')} + if($null -eq $state){$severe.Add('GLASS_STATE_SEPARATE_LABEL_MISSING')} + else{ + if($state.Parent -ne $toggle.Parent){$severe.Add('GLASS_LABEL_NOT_IN_SAME_PARENT')} + if($state.BackColor.A -ne 255){$severe.Add('GLASS_STATE_LABEL_BACKGROUND_NOT_OPAQUE')} + if($state.Text -cne 'On'){$severe.Add('GLASS_STATUS_NOT_ON')} + if([Drawing.Rectangle]::Intersect($state.Bounds,$toggle.Bounds).Width -gt 0){$severe.Add('GLASS_TRACK_STATUS_OVERLAP')} + $toggle.Checked=$false + if($state.Text -cne 'Off'){$severe.Add('GLASS_STATUS_NOT_OFF_AFTER_TOGGLE')} + $toggle.Checked=$true + if($state.Text -cne 'On'){$severe.Add('GLASS_STATUS_NOT_ON_AFTER_TOGGLE')} + } + foreach($page in $tabs.TabPages){ + $controls=@($page.Controls|Where-Object { + $_ -isnot [Windows.Forms.VScrollBar] -and $_ -isnot [Windows.Forms.HScrollBar]}) + $overlapCount=0 + for($i=0;$i -lt $controls.Count;$i++){ + for($j=$i+1;$j -lt $controls.Count;$j++){ + $left=$controls[$i];$right=$controls[$j] + $int=[Drawing.Rectangle]::Intersect($left.Bounds,$right.Bounds) + if($int.Width -ge 4 -and $int.Height -ge 4){ + $overlapCount++ + $severe.Add(('PAGE_'+$page.Text+'_SIBLING_OVERLAP_'+$left.GetType().Name+'_AND_'+$right.GetType().Name+'_XYWH_'+$int.ToString())) + } + } + } + $results.Add([ordered]@{Page=$page.Text;Controls=$controls.Count;SiblingOverlaps=$overlapCount + AutoScrollWidth=$page.AutoScrollMinSize.Width;AutoScrollHeight=$page.AutoScrollMinSize.Height}) + } + $toggleBitmap=New-Object Drawing.Bitmap($toggle.Width,$toggle.Height) + New-Item -ItemType Directory -Path $OutDir -ErrorAction Stop|Out-Null + try{ + $toggle.CreateControl() + $toggle.DrawToBitmap($toggleBitmap,$toggle.ClientRectangle) + $toggleBitmap.Save((Join-Path $OutDir 'R244_GLASS_SWITCH_ON.png'),[Drawing.Imaging.ImageFormat]::Png) + }finally{$toggleBitmap.Dispose()} + $overlapTotal=0;foreach($entry in $results){$overlapTotal+=[int]$entry['SiblingOverlaps']} + $result=[ordered]@{ + Revision='R244';Status=if($severe.Count -eq 0){'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP'}else{'FAIL_UI_OVERLAP_OR_GLASS_SWITCH'} + MainSHA256=(Get-FileHash -LiteralPath $path).Hash + SettingsPages=$tabs.TabPages.Count;CanvasBounds=@($form.Width,$form.Height) + GlassTrackBounds=$toggle.Bounds.ToString() + GlassTrackBackAlpha=$toggle.BackColor.A + GlassStatusSeparate=$null -ne $state + GlassStatusBounds=if($state){$state.Bounds.ToString()}else{'MISSING'} + SiblingOverlapCount=$overlapTotal + Violations=@($severe.ToArray());PageMetrics=@($results.ToArray()) + ReadOnlyCandidateUI=$true;PersonalInstallationTouched=$false + } + [IO.File]::WriteAllText((Join-Path $OutDir 'R244_SETTINGS_UI.json'),($result|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($result|ConvertTo-Json -Compress -Depth 8) + if($severe.Count){throw ('R244_SETTINGS_UI_FAIL_CLOSED '+($severe.ToArray() -join '; '))} +}finally{$form.Dispose()} diff --git a/build/Test-Signed-Payload-Contract.ps1 b/build/Test-Signed-Payload-Contract.ps1 index a5f1ddcb..d453e118 100644 --- a/build/Test-Signed-Payload-Contract.ps1 +++ b/build/Test-Signed-Payload-Contract.ps1 @@ -4,11 +4,11 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $original=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' $inputDir=Join-Path $Root 'artifacts\_r80_contract_input' $inputFile=Join-Path $inputDir 'TaskbarMonitorEnhanced.exe' $stageDir=Join-Path $Root 'artifacts\_r80_contract_only' -$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' $originalMainSHA=(Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash $originalSetupSHA=(Get-FileHash -LiteralPath $setupOrig -Algorithm SHA256).Hash if((Test-Path -LiteralPath $inputDir) -or (Test-Path -LiteralPath $stageDir)){ @@ -70,7 +70,7 @@ try{ # Cleanup exact owned files only. Unknown files survive; never delete personal state. foreach($d in @($stageDir,$inputDir)){ if(Test-Path -LiteralPath $d){ - $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.2.exe','TaskbarMonitorEnhanced_Setup_1.7.2.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} + $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.3.exe','TaskbarMonitorEnhanced_Setup_1.7.3.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} else{@('TaskbarMonitorEnhanced.exe')} $found=@(Get-ChildItem -LiteralPath $d -Force) if(@($found|Where-Object {$_.PSIsContainer -or $_.Name -notin $allowed}).Count -gt 0){ diff --git a/build/Test-Signed-Staging.ps1 b/build/Test-Signed-Staging.ps1 index c051d937..458cd656 100644 --- a/build/Test-Signed-Staging.ps1 +++ b/build/Test-Signed-Staging.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $stage=Join-Path $root 'artifacts\signed' $origMain=Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' $targetMain=Join-Path $stage 'Main\TaskbarMonitorEnhanced.exe' -$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.2.exe' +$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' $created=@() if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $origMain -PathType Leaf) -or @@ -26,7 +26,7 @@ if(!(Test-Path -LiteralPath $manifest -PathType Leaf)){throw 'R75 MANIFEST REQUI $m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json $mapping=@{ 'TaskbarMonitorEnhanced.exe'=$hashMain - 'TaskbarMonitorEnhanced_Setup_1.7.2.exe'=$hashSetup + 'TaskbarMonitorEnhanced_Setup_1.7.3.exe'=$hashSetup } foreach($entry in $m.Outputs){ if($mapping.ContainsKey([string]$entry.Name)){ diff --git a/build/Test-Upgrade172-R243.ps1 b/build/Test-Upgrade172-R243.ps1 new file mode 100644 index 00000000..3e56d37b --- /dev/null +++ b/build/Test-Upgrade172-R243.ps1 @@ -0,0 +1,218 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Genuine product install/update is permitted ONLY on a fresh disposable hosted VM. +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R243_REFUSE_PERSONAL_OR_NONINTERACTIVE_HOST'} +Add-Type -AssemblyName System.Windows.Forms +# R156: keep genuine CLR null; PS5.1 string binder converts $null to empty string. +Add-Type -TypeDefinition 'using System.IO; public static class R156FileBridge { public static void ReplaceWithoutBackup(string source,string destination){File.Replace(source,destination,null);} }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R243_UPGRADE_'+$HostLabel) +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $reg) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R243_NOT_FRESH_DISPOSABLE_HOST'} +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.3' -or $manifest.Private -ne $true -or (Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R243_NOT_EXACT_VERSIONED_CANDIDATE'} +New-Item -ItemType Directory -Path $out|Out-Null +$phase='SOURCE_DOWNLOAD';$status='FAIL';$errorText='';$negative=0;$rollback=$false;$preserved=$false;$idempotent=$false;$downgrade=$false;$chain=@();$beforeProtected=@{};$evidence=@();$oldCpuR170=$null;$newCpuR170=$null +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Run-Exact([string]$path,[string]$Arguments,[int]$timeout=190000){ + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$path;$si.Arguments=$Arguments;$si.UseShellExecute=$false;$si.WorkingDirectory=(Split-Path $path -Parent) + $p=[Diagnostics.Process]::Start($si) + if($null -eq $p){throw 'R243_SETUP_PROCESS_NOT_CREATED'} + try{if(!$p.WaitForExit($timeout)){throw ('R243_SETUP_PROCESS_TIMEOUT PID='+$p.Id)};return [int]$p.ExitCode}finally{$p.Dispose()} +} +function StartupValue(){ + $k=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $k){return 'MISSING'};$p=$k.PSObject.Properties['TaskbarMonitorEnhanced'];if($null -eq $p){return 'MISSING'};return [string]$p.Value +} +function Snapshot(){ + $map=@{} + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v172-managed.json') -Raw|ConvertFrom-Json + $paths=@($inventory.Rows|ForEach-Object Path)+@('Docs/RELEASE_NOTES_v1.7.3.md','install_state.json','config.json','config.json.bak','R243_USER_OWNED.txt','SensorBackend/LibreHardwareMonitor-0.9.6/LibreHardwareMonitorLib.dll') + # Exclude volatile sensor telemetry/log files, not owned by installer and free to change independently. + foreach($rel in @($paths|Select-Object -Unique)){$map[$rel]=Hash (Join-Path $app $rel)} + $map['@RegistryVersion']=[string](Get-ItemProperty $reg).DisplayVersion + $map['@Startup']=StartupValue + return $map +} +function Equal-Snapshot($a,$b,[string]$reason){ + if($a.Count -ne $b.Count){throw ('R243_SNAPSHOT_COUNT_CHANGED '+$reason)} + foreach($k in $a.Keys){if(!$b.ContainsKey($k) -or $a[$k] -cne $b[$k]){throw ('R243_SNAPSHOT_CHANGED '+$reason+' '+$k)}} +} +# R170: identical owner-independent CPU sampling on BOTH versions on the SAME disposable +# GitHub-hosted Windows worker. Never invoked on personal/unknown hosts. +function Measure-InstalledCpuR170([string]$version,[string]$expectedSha){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or ![Environment]::UserInteractive){ + throw 'R170_CPU_PROBE_REFUSES_NON_DISPOSABLE_MACHINE' + } + $bin=Join-Path $app 'TaskbarMonitorEnhanced.exe' + $configFile=Join-Path $app 'config.json' + $backupFile=Join-Path $app 'config.json.bak' + if((Hash $bin) -cne $expectedSha){throw 'R170_CANDIDATE_EXE_SHA_MISMATCH'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_BEFORE_SAMPLE_PRODUCT_PROCESS_ALREADY_RUNNING' + } + $cfgBytes=[IO.File]::ReadAllBytes($configFile) + $bakBytes=[IO.File]::ReadAllBytes($backupFile) + $cfgSha=Hash $configFile;$bakSha=Hash $backupFile + $probe=$null;$sample=$null;$forcedStop=$false + try{ + $probe=Start-Process -FilePath $bin -WorkingDirectory $app -PassThru -ErrorAction Stop + Start-Sleep -Seconds 8 + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_WARMUP_'+$version)} + $cpu0=$probe.TotalProcessorTime.TotalSeconds + $timer=[Diagnostics.Stopwatch]::StartNew() + Start-Sleep -Seconds 8 + $timer.Stop() + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_SAMPLE_'+$version)} + $cpuSeconds=$probe.TotalProcessorTime.TotalSeconds-$cpu0 + $elapsed=$timer.Elapsed.TotalSeconds + $pct=100.0*$cpuSeconds/$elapsed + if($elapsed -lt 7.5 -or $elapsed -gt 12.5 -or $cpuSeconds -lt 0 -or + $pct -gt 500 -or [Double]::IsNaN($pct)){ + throw ('R170_INVALID_CPU_SAMPLE_'+$version) + } + $sample=[ordered]@{Version=$version;Host=$HostLabel;MainSHA256=$expectedSha; + CpuSeconds=$cpuSeconds;ElapsedSeconds=$elapsed;PercentOfOneCore=$pct; + ProcessId=$probe.Id;WarmupSeconds=8;Source='HOSTED_PROCESS_CPU_NOT_PERSONAL'; + ConfigurationBeforeSHA=$cfgSha;BackupBeforeSHA=$bakSha} + } + finally{ + if($null -ne $probe){ + try{ + $probe.Refresh() + if(!$probe.HasExited){ + try{[void]$probe.CloseMainWindow()}catch{} + if(!$probe.WaitForExit(3500)){ + $forcedStop=$true # CI-only process launched above, on disposable runner. + $probe.Kill() + if(!$probe.WaitForExit(12000)){throw 'R170_DISPOSABLE_PROCESS_NOT_STOPPED'} + } + } + }finally{$probe.Dispose()} + } + # Restore the exact isolated test fixture if application startup normalizes it. + if((Hash $configFile) -cne $cfgSha){ + [IO.File]::WriteAllBytes($configFile,$cfgBytes) + if((Hash $configFile) -cne $cfgSha){throw 'R170_TEST_CONFIG_RESTORE_FAILED'} + } + if((Hash $backupFile) -cne $bakSha){ + [IO.File]::WriteAllBytes($backupFile,$bakBytes) + if((Hash $backupFile) -cne $bakSha){throw 'R170_TEST_BACKUP_RESTORE_FAILED'} + } + } + if($null -eq $sample){throw 'R170_NO_HOSTED_CPU_SAMPLE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_LEFT_TEST_PROCESS_RUNNING' + } + $sample['DisposableOnlyForcedStop']=$forcedStop + $sample['ConfigRestoredToOriginalSHA']=((Hash $configFile) -ceq $cfgSha) + $sample['BackupRestoredToOriginalSHA']=((Hash $backupFile) -ceq $bakSha) + return $sample +} +try{ + & gh release download v1.7.2 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.2.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R243_OFFICIAL172_DOWNLOAD_FAILED'} + $priorSetup=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.2.exe' + if((Hash $priorSetup) -cne '225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4'){throw 'R243_PREVIOUS_SETUP_NOT_IMMUTABLE_OFFICIAL'} + $phase='CREATE_GENUINE_V172_PREDECESSOR' + if($HostLabel -ceq 'B'){ + & gh release download v1.7.1 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.1.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R243_OFFICIAL171_DOWNLOAD_FAILED'} + $older=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.1.exe' + if((Hash $older) -cne 'ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89'){throw 'R243_PREVIOUS_V171_SETUP_CHANGED'} + if((Run-Exact $older '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R243_GENUINE_V171_INSTALL_FAILED'} + if((Run-Exact $priorSetup '/update /quiet') -ne 0){throw 'R243_GENUINE_V171_TO_V172_FAILED'} + $chain=@('1.7.1','1.7.2') + }else{ + if((Run-Exact $priorSetup '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R243_GENUINE_V172_INSTALL_FAILED'} + $chain=@('1.7.2') + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne '84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F' -or (Get-ItemProperty $reg).DisplayVersion -cne '1.7.2'){throw 'R243_OFFICIAL_V172_INSTALLED_IDENTITY_NOT_PROVEN'} + $phase='KNOWN_USER_DATA_FIXTURE' + $config=Join-Path $app 'config.json';$backup=Join-Path $app 'config.json.bak';$state=Join-Path $app 'install_state.json' + $fixture='{"Theme":"Retro Terminal","Opacity":0.00,"GlassMode":true,"OpacityWhenGlassOff":0.10,"ConfigSchemaVersion":8,"StartWithWindows":false,"UpdateIntervalMs":1000,"ShowSparklines":true,"AutoCheckUpdates":false,"R243OwnerSentinel":"preserve user literal 1.7.2"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"independent":"prior backup must not be overwritten"}',[Text.UTF8Encoding]::new($false)) + $userFile=Join-Path $app 'R243_USER_OWNED.txt';[IO.File]::WriteAllText($userFile,'User data, not installer-managed.',[Text.UTF8Encoding]::new($false)) + $sourceText=[IO.File]::ReadAllText($state) + $expectedState=$sourceText.Replace('"Version": "1.7.2"','"Version": "1.7.3"').Replace('"PublicVersion": "1.7.2"','"PublicVersion": "1.7.3"') + if($expectedState -ceq $sourceText){throw 'R243_ACTUAL_STATE_FORMAT_NOT_RECOGNIZED'} + foreach($path in @($config,$backup,$userFile,(Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'))){if((Hash $path) -ceq 'MISSING'){throw ('R243_PROTECTED_INPUT_MISSING '+$path)};$beforeProtected[$path]=Hash $path} + $startupBefore=StartupValue + $phase='SAME_HOST_171_CPU_BASELINE' + $oldCpuR170=Measure-InstalledCpuR170 '1.7.2' '84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F' + if($null -eq $oldCpuR170){throw 'R170_MISSING_OLD_BASELINE'} + $phase='REJECT_EDITED_MANAGED_SOURCE' + $managed=Join-Path $app 'Source\TaskbarGlass.cs';$original=[IO.File]::ReadAllBytes($managed) + $changed=[byte[]]($original+[Text.Encoding]::UTF8.GetBytes("`n// deliberate owner edit: updater must refuse`n")) + [IO.File]::WriteAllBytes($managed,$changed);$editedHash=Hash $managed + $snap=Snapshot + $code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R243_EDITED_SOURCE_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'EDITED_SOURCE_REFUSAL' + if(Test-Path (Join-Path $app '.tbme-upgrade')){throw 'R243_REFUSAL_LEFT_STAGE'} + if((Hash $managed) -cne $editedHash){throw 'R243_CONCURRENT_EDIT_CANNOT_RESTORE_FIXTURE'} + [IO.File]::WriteAllBytes($managed,$original);$negative++ + $phase='REJECT_UNKNOWN_INSTALLED_VERSION' + [IO.File]::WriteAllText($state,$sourceText.Replace('1.7.2','9.9.9'),[Text.UTF8Encoding]::new($false)) + $snap=Snapshot;$code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R243_UNKNOWN_VERSION_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'UNKNOWN_VERSION_REFUSAL' + [IO.File]::WriteAllText($state,$sourceText,[Text.UTF8Encoding]::new($false));$negative++ + $phase='REAL_ROLLBACK_ENGINE_AFTER_SIMULATED_PARTIAL_COMMIT' + # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. + # This is explicit simulated interruption, not a physical outage or real process-kill witness. + $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.2','OFFICIAL_STABLE_V172')) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) + # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) + $beforeRollback=Snapshot + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.2','OFFICIAL_STABLE_V172')) + $stage=Join-Path $app '.tbme-upgrade' + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $stage 'new') $rel;$live=Join-Path $app $rel + [R156FileBridge]::ReplaceWithoutBackup($staged,$live) + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256){throw 'R243_PARTIAL_COMMIT_NOT_REAL'} + [void]$t.GetMethod('RecoverUpgradeStageIfAny',$f).Invoke($null,[object[]]@()) + if(Test-Path $stage){throw 'R243_ROLLBACK_STAGE_NOT_CLEARED'} + Equal-Snapshot $beforeRollback (Snapshot) 'REAL_ROLLBACK_RESTORATION';$rollback=$true + $phase='REAL_CLI_171_TO_172' + $code=Run-Exact $setup '/update /quiet' + if($code -ne 0){throw ('R243_REAL_UPGRADE_FAILED EXIT='+$code)} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256 -or (Hash (Join-Path $app 'Uninstall.exe')) -cne $manifest.SetupSHA256){throw 'R243_FINAL_BINARY_HASH_MISMATCH'} + if((Get-ItemProperty $reg).DisplayVersion -cne '1.7.3' -or [IO.File]::ReadAllText($state) -cne $expectedState){throw 'R243_STATE_OR_REGISTRY_NOT_EXACT_172'} + foreach($path in $beforeProtected.Keys){if((Hash $path) -cne $beforeProtected[$path]){throw ('R243_USER_DATA_CHANGED '+$path)}} + if((StartupValue) -cne $startupBefore){throw 'R243_STARTUP_PREFERENCE_CHANGED'} + if(Test-Path $stage){throw 'R243_STAGE_LEFT_AFTER_SUCCESS'} + if(!(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.3.md')) -or (Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.2.md'))){throw 'R243_RELEASE_NOTES_NOT_MIGRATED'} + $registrations=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($registrations.Count -ne 1 -or (Get-ItemProperty $reg).InstallLocation -cne $app){throw 'R243_DUPLICATE_OR_WRONG_ROOT'} + $preserved=$true;$chain+=@('1.7.3') + $phase='SAME_HOST_172_CPU_COMPARATOR' + $newCpuR170=Measure-InstalledCpuR170 '1.7.3' $manifest.MainSHA256 + if($null -eq $newCpuR170 -or $oldCpuR170.Host -cne $newCpuR170.Host){throw 'R170_SAME_HOST_CPU_NOT_VERIFIED'} + $phase='IDEMPOTENCE_AND_DOWNGRADE_REFUSAL' + $after=Snapshot + if((Run-Exact $setup '/update /quiet') -ne 0){throw 'R243_REPEATED_IDENTICAL_UPGRADE_FAILED'} + Equal-Snapshot $after (Snapshot) 'IDENTICAL_REINSTALL';$idempotent=$true + if((Run-Exact $priorSetup '/update /quiet') -eq 0){throw 'R243_OLD_INSTALLER_DOWNGRADE_ACCEPTED'} + Equal-Snapshot $after (Snapshot) 'DOWNGRADE_REFUSAL';$downgrade=$true + $status='PASS_GENUINE_172_TO_173_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' +}catch{$errorText=$_.Exception.ToString()} +finally{ + $r=[ordered]@{Revision='R243';Status=$status;Phase=$phase;Failure=$errorText;Host=$HostLabel;Commit=$env:GITHUB_SHA;SourceChain=$chain;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;RejectedUnsafeUpdates=$negative;RealProductionRollbackAfterSimulatedPartialCommit=$rollback;PhysicalPowerCutClaimed=$false;RealProcessKillClaimed=$false;ProtectedUserFilesPreserved=$preserved;IdenticalSetupIdempotent=$idempotent;DowngradeRefused=$downgrade;PersonalComputerTouched=$false;ProtectedSHA256=$beforeProtected;R170SameHostedWindowsOldCpu=$oldCpuR170;R170SameHostedWindowsNewCpu=$newCpuR170;R170SameHostCpuMeasured=($null -ne $oldCpuR170 -and $null -ne $newCpuR170);R170PersonalCpuAcceptance=$false} + [IO.File]::WriteAllText((Join-Path $out 'R243_UPGRADE.json'),($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 8 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R243_UPGRADE_ACCEPTANCE_FAILED '+$phase+' '+$errorText)} diff --git a/build/Test-UpgradeContract-R243.ps1 b/build/Test-UpgradeContract-R243.ps1 new file mode 100644 index 00000000..a9bea598 --- /dev/null +++ b/build/Test-UpgradeContract-R243.ps1 @@ -0,0 +1,64 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$SetupPath,[Parameter(Mandatory=$true)][string]$OutDir) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R243_FRAMEWORK_REFLECTION_REQUIRED'} +if(Test-Path $OutDir){throw 'R243_CONTRACT_OUTPUT_EXISTS'} +$full=[IO.Path]::GetFullPath($SetupPath) +if([IO.Path]::GetFileName($full) -cne 'TaskbarMonitorEnhanced_Setup_1.7.3.exe'){throw 'R243_WRONG_SETUP_NAME'} +$a=[Reflection.Assembly]::LoadFile($full) +$t=$a.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,Public,NonPublic' +$newState=$t.GetMethod('NewInstallState',$f) +$legacy=$t.GetMethod('LegacyExpected',$f) +$optional=$t.GetMethod('IsOptionalStableLegacyRoot',$f) +if($t.GetField('Version',$f).GetRawConstantValue() -cne '1.7.3'){throw 'R243_COMPILED_VERSION_NOT_172'} +$cases=0;$negative=0 +foreach($v in @('1.6.0','1.7.0','1.7.1','1.7.2')){ + $before='{ "ProductIdentity":"LOCKED", "Version":"'+$v+'", "PublicVersion":"'+$v+'", "Unrelated":"preserve literal 1.7.1", "Custom":{"x":17}, "ProtocolVersion":"1.1.2+r21" }' + $want=$before.Replace(('"Version":"'+$v+'"'),'"Version":"1.7.3"').Replace(('"PublicVersion":"'+$v+'"'),'"PublicVersion":"1.7.3"') + $got=[string]$newState.Invoke($null,[object[]]@($before)) + if($got -cne $want){throw ('R243_STATE_NOT_EXACTLY_TWO_VERSION_FIELDS '+$v)} + $cases++ +} +$bad=@( + '{"Version":"1.7.1","PublicVersion":"1.7.0"}', + '{"Version":"1.7.1","Version":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"1.7.1","PublicVersion":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"0.9.0","PublicVersion":"0.9.0"}', + '{"Version":"1.7.4","PublicVersion":"1.7.4"}', + '{"Version":"1.7.1"}', + '{"PublicVersion":"1.7.1"}' +) +foreach($s in $bad){ + $refused=$false + try{[void]$newState.Invoke($null,[object[]]@($s))}catch{$refused=$true} + if(!$refused){throw ('R243_INVALID_STATE_ACCEPTED '+$s)};$negative++ +} +$inventory=Get-Content (Join-Path $PSScriptRoot 'official-v172-managed.json') -Raw|ConvertFrom-Json +$dict=$legacy.Invoke($null,[object[]]@('1.7.2','OFFICIAL_STABLE_V172')) +if($dict.Count -ne 23 -or $inventory.ManagedCount -ne 23){throw 'R243_171_EXACT_MANAGED_COUNT'} +foreach($row in $inventory.Rows){if(!$dict.ContainsKey($row.Path) -or $dict[$row.Path] -cne $row.SHA256){throw ('R243_COMPILED_MANAGED_SHA_DRIFT '+$row.Path)};$cases++} +foreach($pair in @(@('1.7.1','OFFICIAL_STABLE_V170'),@('1.7.1','PREVIEW1'),@('1.7.3','OFFICIAL_STABLE_V171'),@('1.7.1','UNKNOWN'))){ + $refused=$false;try{[void]$legacy.Invoke($null,[object[]]@($pair[0],$pair[1]))}catch{$refused=$true} + if(!$refused){throw 'R243_WRONG_ANCESTOR_CHANNEL_ACCEPTED'};$negative++ +} +foreach($channel in @('OFFICIAL_STABLE_V170','OFFICIAL_STABLE_V171')){ + if(!$optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs',$channel))){throw 'R243_INHERITED_ROOT_DUPLICATE_REQUIRED'};$cases++ + foreach($path in @('Source/TaskbarMonitorEnhanced.cs','TaskbarMonitorEnhanced.exe','config.json')){ + if($optional.Invoke($null,[object[]]@($path,$channel))){throw ('R243_CRITICAL_SOURCE_MADE_OPTIONAL '+$path)};$negative++ + } +} +$targets=$t.GetField('UpgradeTargets',$f).GetValue($null) +$removed=0;$names=@();$if=[Reflection.BindingFlags]'Instance,NonPublic,Public' +foreach($target in $targets){ + $path=[string]$target.GetType().GetField('Rel',$if).GetValue($target) + $resource=$target.GetType().GetField('ResourceName',$if).GetValue($target) + if($names -ccontains $path -or $path -match '^(config\.json|SensorBackend/|Backups/|Data/)'){throw ('R243_UPGRADE_TOUCHES_PROTECTED_OR_DUPLICATE '+$path)} + if($null -eq $resource){$removed++};$names+=@($path);$cases++ +} +if($removed -ne 5 -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.3.md' -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.1.md'){throw 'R243_VERSIONED_NOTES_TRANSACTION_MAP'} +New-Item -ItemType Directory -Path $OutDir|Out-Null +$r=[ordered]@{Revision='R243';Status='PASS_COMPILED_173_UPGRADE_CONTRACT';SetupSHA256=(Get-FileHash $full -Algorithm SHA256).Hash;PositiveCases=$cases;RejectedCases=$negative;Official172ManagedFiles=23;PreviousVersions=@('1.6.0','1.7.0','1.7.1','1.7.2');CurrentVersion='1.7.3';NoAppEntryPointInvoked=$true;NoPersonalFilesOrRegistryMutated=$true;ActualInstallAcceptance='SEPARATE_HOSTED_VM_GATE'} +[IO.File]::WriteAllText((Join-Path $OutDir 'R243_CONTRACT.json'),($r|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +Write-Output ($r|ConvertTo-Json -Depth 6 -Compress) diff --git a/build/Verify-Embedded-Main.ps1 b/build/Verify-Embedded-Main.ps1 index 9b23eb88..97af3362 100644 --- a/build/Verify-Embedded-Main.ps1 +++ b/build/Verify-Embedded-Main.ps1 @@ -1,4 +1,4 @@ -# R127: retain R80 independent read-only verification and accept versioned 1.7.2 patch filename. +# R127: retain R80 independent read-only verification and accept versioned 1.7.3 patch filename. # R80: independent read-only verification that a Setup's exact embedded Main PE bytes # equal the SHA256 of an independently pinned, already-signed Main distribution file. [CmdletBinding()] @@ -11,7 +11,7 @@ $ErrorActionPreference='Stop' if($ExpectedMainSHA256 -notmatch '^[0-9a-fA-F]{64}$'){throw 'R80 EXPECTED MAIN SHA NOT 64 HEX'} $path=[IO.Path]::GetFullPath($SetupExe) if(!(Test-Path -LiteralPath $path -PathType Leaf) -or - ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.2.exe'))){ + ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.3.exe'))){ throw 'R80 SETUP EXE MISSING OR WRONG FILE NAME' } $setupHash=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash diff --git a/build/Verify-Release-Metadata-R243.ps1 b/build/Verify-Release-Metadata-R243.ps1 new file mode 100644 index 00000000..5c3a3ab7 --- /dev/null +++ b/build/Verify-Release-Metadata-R243.ps1 @@ -0,0 +1,105 @@ +# R243 independent, read-only authority check for the versioned 1.7.3 +# release Build Manifest -> deterministic binary files -> SBOM 2.3. +[CmdletBinding()] +param( + [string]$Root=(Join-Path $PSScriptRoot '..'), + [switch]$SelfTest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($Root) +$manifestPath=Join-Path $root 'artifacts\BUILD_MANIFEST.json' +$sbomPath=Join-Path $root 'artifacts\SBOM.spdx.json' +$setupVerifyPath=Join-Path $root 'build\_out\setup_verify.json' +$source=(Join-Path $root 'src\TaskbarMonitorEnhanced.cs') +foreach($f in @($manifestPath,$sbomPath,$setupVerifyPath,$source)){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){throw ('R243_REQUIRED_EVIDENCE_MISSING '+$f)} +} +$m=Get-Content $manifestPath -Raw|ConvertFrom-Json +$b=Get-Content $sbomPath -Raw|ConvertFrom-Json +$v=Get-Content $setupVerifyPath -Raw|ConvertFrom-Json +$commit=(& git -C $root rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R243_CURRENT_HEAD_NOT_EXACT'} +function Issues($manifest,$sbom,$setup,$sha){ + $fail=[Collections.Generic.List[string]]::new() + if($manifest.Version -cne '1.7.3' -or $manifest.Build -cne 'V1_7_3_R243_CPU_PREFILTER'){ + $fail.Add('WRONG_PUBLIC_BUILD_VERSION') + } + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.3' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.3/'+$sha)){ + $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') + } + if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or + $sbom.packages[0].versionInfo -cne '1.7.3'){ + $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') + } + $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', + 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', + 'TaskbarMonitorEnhanced_Setup_1.7.3.exe') + $found=@($manifest.Outputs|ForEach-Object {$_.Name}) + if(@($manifest.Outputs).Count -ne 5 -or + @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ + $fail.Add('MANIFEST_EXACT_FIVE_OUTPUTS_MISMATCH') + }else{ + foreach($row in $manifest.Outputs){ + $name=[string]$row.Name + $rel=switch($name){ + 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} + 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} + default {'build\_out\App\'+$name} + } + $path=Join-Path $root $rel + if(!(Test-Path $path -PathType Leaf) -or + (Get-Item $path).Length -ne [int64]$row.Bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash -cne [string]$row.SHA256){ + $fail.Add('ACTUAL_OUTPUT_SHA_OR_SIZE_MISMATCH_'+$name) + } + $frow=@($sbom.files|Where-Object fileName -ceq $name) + if($frow.Count -ne 1 -or @($frow[0].checksums).Count -ne 1 -or + $frow[0].checksums[0].algorithm -cne 'SHA256' -or + $frow[0].checksums[0].checksumValue -cne ([string]$row.SHA256).ToLowerInvariant()){ + $fail.Add('SBOM_OUTPUT_SHA_MISMATCH_'+$name) + } + } + } + if($setup.Status -cne 'PASS' -or [int]$setup.Resources -ne 25 -or + $setup.PawnIOBundled -ne $false){ + $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') + } + $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe') + if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.3')){ + $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') + } + return @($fail.ToArray()) +} +$errors=@(Issues $m $b $v $commit) +if($errors.Count){throw ('R243_RELEASE_METADATA_FAIL_CLOSED '+($errors -join ';'))} +if($SelfTest){ + $mutants=@( + @{Type='Manifest';Field='Version';Bad='1.7.0'}, + @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, + @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.3/'+('0'*40))}, + @{Type='Setup';Field='PawnIOBundled';Bad=$true} + ) + foreach($mutation in $mutants){ + $am=$m|ConvertTo-Json -Depth 14|ConvertFrom-Json + $ab=$b|ConvertTo-Json -Depth 14|ConvertFrom-Json + $av=$v|ConvertTo-Json -Depth 14|ConvertFrom-Json + switch($mutation.Type){ + 'Manifest' {$am.($mutation.Field)=$mutation.Bad} + 'Sbom' {$ab.($mutation.Field)=$mutation.Bad} + 'Setup' {$av.($mutation.Field)=$mutation.Bad} + } + if(@(Issues $am $ab $av $commit).Count -eq 0){ + throw ('R243_HOSTILE_RELEASE_METADATA_ACCEPTED '+$mutation.Type+' '+$mutation.Field) + } + } + Write-Output 'R243_FIVE_HOSTILE_RELEASE_METADATA_MUTANTS_REJECTED' +} +Write-Output ('R243_MANIFEST_SBOM_BINARY_VERSION=PASS VERSION='+$m.Version+ + ' OUTPUTS='+@($m.Outputs).Count+' SHA256='+(Get-FileHash $manifestPath -Algorithm SHA256).Hash) +exit 0 diff --git a/build/official-v172-managed.json b/build/official-v172-managed.json new file mode 100644 index 00000000..83fa6ab3 --- /dev/null +++ b/build/official-v172-managed.json @@ -0,0 +1,128 @@ +{ + "Schema": 1, + "Source": "IMMUTABLE_PUBLIC_V172_SETUP_EXACT_VERIFIED", + "OfficialVersion": "1.7.2", + "OfficialSetupSHA256": "225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4", + "OfficialMainSHA256": "84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F", + "ManagedCount": 23, + "ComparedExistingFiles": 22, + "OwnerPrivateDataIncluded": false, + "OptionalRootDuplicate": "TaskbarMonitorEnhanced.cs", + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.cs", + "SHA256": "E43DE02A5DB89F8159408F1E1284B9311884736084D3565A5EE1B5035341B362", + "Optional": true + }, + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F", + "Optional": false + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Optional": false + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "E43DE02A5DB89F8159408F1E1284B9311884736084D3565A5EE1B5035341B362", + "Optional": false + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Optional": false + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Optional": false + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "B71B4F1CD41A12A82DC470534563D2EA24FAAF38233D0158AC13085EF7F75ED0", + "Optional": false + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Optional": false + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Optional": false + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "76C65D5A16F384B7600EB4BDEDE7551283235CA32A662A248AF6A6DE03CD5A35", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "A3AD433D281894BB514F225ADEBA007B5F2E8641A1337BF57DE5248D2F96246A", + "Optional": false + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Optional": false + }, + { + "Path": "Docs/README.md", + "SHA256": "FE7F6354C295B71607D14AB5D51843C2C951B2E984399FA5AA00BBF8AA4F8F0E", + "Optional": false + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Optional": false + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Optional": false + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Optional": false + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Optional": false + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.2.md", + "SHA256": "088EEAF5EE5200CE9686134CBD15F5C6C9436E8027E9DF1613190762911D01FA", + "Optional": false + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Optional": false + }, + { + "Path": "Uninstall.exe", + "SHA256": "225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4", + "Optional": false + } + ] +} \ No newline at end of file diff --git a/docs/releases/v1.7.3/INSTALLER_README.txt b/docs/releases/v1.7.3/INSTALLER_README.txt new file mode 100644 index 00000000..a41dc35d --- /dev/null +++ b/docs/releases/v1.7.3/INSTALLER_README.txt @@ -0,0 +1,7 @@ +Taskbar Monitor Enhanced 1.7.3 +Single per-user installation under LocalAppData/TaskbarMonitorEnhanced. +One versioned Setup performs in-place upgrade from verified official v1.7.2 without uninstall or data loss. +Close the running monitor normally before launching the updater. +Your Glass ON background is fully transparent; OFF restores its separately saved opacity. +This release is intentionally unsigned. An exact SHA-256 confirms identity, not malware clearance or public signing. +Review Release Notes for test limitations and external security reports. diff --git a/docs/releases/v1.7.3/RELEASE_NOTES.md b/docs/releases/v1.7.3/RELEASE_NOTES.md new file mode 100644 index 00000000..b08c811a --- /dev/null +++ b/docs/releases/v1.7.3/RELEASE_NOTES.md @@ -0,0 +1,13 @@ +# Taskbar Monitor Enhanced v1.7.3 — CPU efficiency and verified in-place update + +## Changes +- Reordered existing Start/Search native-window geometry filters ahead of expensive Windows process-name enumeration; no layout behavior change intended. +- The separate user-controlled **Glass Mode** ON uses zero-opacity background while metric ink remains visible. OFF restores the saved 1–100% background opacity; Start is pinned to the actual taskbar and never intentionally floated. +- One SHA-pinned Windows Setup updates an existing official v1.7.2 installation **in place**, without uninstalling or creating a second per-user root. Existing official v1.6.0/1.7.0/1.7.1 compatible paths remain guarded. + +## Verification and scope +- On one owner Windows PC, actual 4 × 60-second BASE–TRIAL–TRIAL–BASE whole-program CPU observation measured 23.37809% versus 6.65184% of one logical core (71.547% lower in those tested conditions), exact prior v1.7.2 Main restored. +- The owner's quiescent 442-file data-root ZIP passed SHA checks and byte-exact isolated restore; this does not establish physical AC power-loss resilience or restore a deliberately damaged owner HKCU registry. +- Public release promotion requires this version's own exact-head build, clean-clone reproducibility, real disposable Windows protected v1.7.2 → v1.7.3 upgrade, Glass/native presentation, provenance/SBOM, and uploaded asset digest checks. +- The installer is **unsigned** by maintainer choice. Unknown publisher/SmartScreen messages are not proof of malware; actual antivirus detections must not be bypassed or dismissed. External vulnerable-driver issue #6 attribution remains unresolved, as do formal signed labs, physical power loss, and machine-wide multiuser/HKLM collision proof. +- Public immutable v1.7.2 assets are not altered by this new release. diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 3d8c44b8..c9f65cf8 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -20,14 +20,14 @@ [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.2.0")] -[assembly: AssemblyFileVersion("1.7.2.0")] -[assembly: AssemblyInformationalVersion("1.7.2+r155")] +[assembly: AssemblyVersion("1.7.3.0")] +[assembly: AssemblyFileVersion("1.7.3.0")] +[assembly: AssemblyInformationalVersion("1.7.3+r243")] internal static partial class SetupProgram { const string Product="Taskbar Monitor Enhanced"; - const string Version="1.7.2"; + const string Version="1.7.3"; const string SensorProtocolVersion="1.1.2+r21"; const string SensorSupervisorVersion="1.3.0+r33"; const string Publisher="Dr. Ali-Akbar Emadeddin"; @@ -58,7 +58,7 @@ internal static partial class SetupProgram "Payload.COPYRIGHT_AND_ATTRIBUTION.md", "Payload.AI_ASSISTED_DEVELOPMENT.md", "Payload.THIRD_PARTY_NOTICES.md", - "Payload.RELEASE_NOTES_v1.7.2.md", + "Payload.RELEASE_NOTES_v1.7.3.md", "Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md", "Payload.TaskbarMonitorEnhanced_Setup.cs" }; @@ -228,8 +228,8 @@ static void WriteInstallState(SensorOutcome sensorOutcome) string sensorMode=sensorOutcome==null?"":(sensorOutcome.LayerMode??""); string json="{\r\n"+ " \"App\": \"Taskbar Monitor Enhanced\",\r\n"+ - " \"Version\": \"1.7.2\",\r\n"+ - " \"PublicVersion\": \"1.7.2\",\r\n"+ + " \"Version\": \"1.7.3\",\r\n"+ + " \"PublicVersion\": \"1.7.3\",\r\n"+ " \"InternalRuntimeBaseline\": \"V1_1_2_R21_PRODUCTION_HARDENING\",\r\n"+ " \"SensorSupervisor\": \"V1_3_0_R33_STORAGE_COMPLETION_HARDENING\",\r\n"+ " \"SensorLayerStatus\": \""+sensorStatus.Replace("\\","\\\\").Replace("\"","\\\"")+"\",\r\n"+ @@ -445,7 +445,7 @@ static bool IsCurrentProcessElevated() static SensorOutcome Install(bool desktop,bool startup) { if(!Environment.Is64BitOperatingSystem) - throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.2 requires 64-bit Windows."); + throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.3 requires 64-bit Windows."); // R114: prevent fresh-install code from ever overwriting an installed // product or user-owned files. Existing roots must use /update. @@ -475,7 +475,7 @@ static SensorOutcome Install(bool desktop,bool startup) string[] docs=new string[]{ "LICENSE","README.md","AUTHORS.md","COPYRIGHT_AND_ATTRIBUTION.md", "AI_ASSISTED_DEVELOPMENT.md","THIRD_PARTY_NOTICES.md", - "RELEASE_NOTES_v1.7.2.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" + "RELEASE_NOTES_v1.7.3.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" }; foreach(string doc in docs) Extract("Payload."+doc,Path.Combine(AppRoot,"Docs",doc)); @@ -619,7 +619,7 @@ static int Verify(string path) } if(!String.IsNullOrEmpty(path)){ string json="{\"Status\":\"PASS\",\"Resources\":"+(RequiredResources.Length+(pawnBundled?1:0))+ - ",\"Version\":\"1.7.2\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ + ",\"Version\":\"1.7.3\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ ",\"SensorArchitecture\":\"R21_PROCESS_ISOLATED\",\"SensorUpgradePolicy\":\"EXACT_R33_SUPERVISOR_R21_PROTOCOL\""+ ",\"PawnIOBundled\":"+(pawnBundled?"true":"false")+",\"EmbeddedMainSHA256\":\""+embeddedMainSHA256+"\",\"EmbeddedMainBytes\":"+embeddedMainBytes+"}"; File.WriteAllText(path,json,Encoding.UTF8); @@ -658,7 +658,7 @@ public SetupForm() Icon=Icon.ExtractAssociatedIcon(Application.ExecutablePath); Label title=new Label(); - title.Text=Product+" 1.7.2"; + title.Text=Product+" 1.7.3"; title.Font=new Font(Font.FontFamily,18,FontStyle.Bold); title.Left=28;title.Top=22;title.AutoSize=true;Controls.Add(title); @@ -693,12 +693,12 @@ public SetupForm() progress.Visible=false; status.Text="Installation completed."; if(outcome.IsHealthy){ - MessageBox.Show(Product+" 1.7.2 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", + MessageBox.Show(Product+" 1.7.3 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", "Setup complete",MessageBoxButtons.OK,MessageBoxIcon.Information); }else{ string extra=outcome.RebootRequired ? "\r\n\r\nRestart Windows, then use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors if needed." : "\r\n\r\nThe application is installed and usable. The sensor supervisor continues in the background. If protected CPU/GPU/storage telemetry is still unavailable after a short wait or restart, use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors."; - MessageBox.Show(Product+" 1.7.2 was installed successfully.\r\n\r\n"+outcome.Message+extra, + MessageBox.Show(Product+" 1.7.3 was installed successfully.\r\n\r\n"+outcome.Message+extra, "Setup complete - sensor warning",MessageBoxButtons.OK,MessageBoxIcon.Warning); } Close(); @@ -832,6 +832,9 @@ internal static partial class SetupProgram const string InstalledV16="1.6.0"; const string InstalledV170="1.7.0"; const string InstalledV171="1.7.1"; + const string InstalledV172="1.7.2"; + const string OfficialV172SetupSHA="225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4"; + const string OfficialV172MainSHA="84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F"; const string OfficialV171SetupSHA="ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89"; const string OfficialV171MainSHA="D50A9D03190719B8385AB2F7F7DBB3129EC7EC1C77ECBD8EF7F29832AEC26420"; const string OfficialV170SetupSHA="148827212B716238DDB270309F393140EBE3CB935CD0E80D36233220A4BE611E"; @@ -952,6 +955,34 @@ internal static partial class SetupProgram "Uninstall.exe|ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89", }; + // R243: exact immutable official v1.7.2 embedded Setup payload SHA-256, no owner data. + // Root-level .cs is optional for this version; any present copy must match. + static readonly string[] OfficialStableV172Rows=new string[] { + "TaskbarMonitorEnhanced.cs|E43DE02A5DB89F8159408F1E1284B9311884736084D3565A5EE1B5035341B362", + "TaskbarMonitorEnhanced.exe|84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|E43DE02A5DB89F8159408F1E1284B9311884736084D3565A5EE1B5035341B362", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|B71B4F1CD41A12A82DC470534563D2EA24FAAF38233D0158AC13085EF7F75ED0", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|76C65D5A16F384B7600EB4BDEDE7551283235CA32A662A248AF6A6DE03CD5A35", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|A3AD433D281894BB514F225ADEBA007B5F2E8641A1337BF57DE5248D2F96246A", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|FE7F6354C295B71607D14AB5D51843C2C951B2E984399FA5AA00BBF8AA4F8F0E", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.2.md|088EEAF5EE5200CE9686134CBD15F5C6C9436E8027E9DF1613190762911D01FA", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4", + }; + sealed class UpgradeTarget { internal readonly string Rel; @@ -979,14 +1010,15 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/COPYRIGHT_AND_ATTRIBUTION.md","Payload.COPYRIGHT_AND_ATTRIBUTION.md"), new UpgradeTarget("Docs/AI_ASSISTED_DEVELOPMENT.md","Payload.AI_ASSISTED_DEVELOPMENT.md"), new UpgradeTarget("Docs/THIRD_PARTY_NOTICES.md","Payload.THIRD_PARTY_NOTICES.md"), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md","Payload.RELEASE_NOTES_v1.7.2.md"), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md","Payload.RELEASE_NOTES_v1.7.3.md"), new UpgradeTarget("Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md","Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md"), new UpgradeTarget("Uninstall.exe","@SELF"), new UpgradeTarget("install_state.json","@INSTALLSTATE"), new UpgradeTarget("TaskbarMonitorEnhanced.cs",null), // redundant v1.6 root duplicate new UpgradeTarget("Docs/RELEASE_NOTES_v1.6.0.md",null), // superseded v1.6 official file new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.0.md",null), // exact historical managed note only - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.1.md",null) // previous version; refuse any user-edited bytes + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.1.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md",null) // previous version, guarded byte-for-byte }; static string UpgradeHash(string path) @@ -1012,6 +1044,8 @@ static Dictionary LegacyExpected(string version,string channel) { rows=OfficialStableV170Rows; expectedSetup=OfficialV170SetupSHA; count=23; } else if(version==InstalledV171 && channel=="OFFICIAL_STABLE_V171") { rows=OfficialStableV171Rows; expectedSetup=OfficialV171SetupSHA; count=23; } + else if(version==InstalledV172 && channel=="OFFICIAL_STABLE_V172") + { rows=OfficialStableV172Rows; expectedSetup=OfficialV172SetupSHA; count=23; } else if(version==InstalledV170 && channel=="PREVIEW1") { rows=LegacyPreview1Rows; expectedSetup=LegacyPreview1InstallerSha; count=23; } else throw new InvalidDataException("Unsupported exact prior source identity; no downgrade/second root."); @@ -1112,7 +1146,7 @@ static string NewInstallState(string source) throw new InvalidDataException("Old install state Version/PublicVersion mismatch."); string prior=installed.Groups["v"].Value; if(prior==Version)return source; - if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171) + if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172) throw new InvalidDataException("Prior install state version not a pinned officially allowed ancestor."); string next=ReplaceStateVersion(source,"Version",prior); next=ReplaceStateVersion(next,"PublicVersion",prior); @@ -1138,7 +1172,7 @@ static Dictionary PrepareNewBytes(string previousState) if(bytes.Length<1 || fresh.ContainsKey(t.Rel))throw new InvalidDataException("Duplicate/empty payload "+t.Rel); fresh.Add(t.Rel,bytes); } - if(fresh.Count!=UpgradeTargets.Length-4) + if(fresh.Count!=UpgradeTargets.Length-5) throw new InvalidDataException("Upgrade static target map is invalid."); return fresh; } @@ -1147,7 +1181,7 @@ static bool IsOptionalStableLegacyRoot(string rel,string channel) { // Only one SHA-pinned historical Stable file may be absent. Any present // occurrence still requires the exact immutable published source hash. - return (channel=="OFFICIAL_STABLE_V170" || channel=="OFFICIAL_STABLE_V171") && + return (channel=="OFFICIAL_STABLE_V170" || channel=="OFFICIAL_STABLE_V171" || channel=="OFFICIAL_STABLE_V172") && String.Equals(rel,"TaskbarMonitorEnhanced.cs",StringComparison.Ordinal); } @@ -1390,8 +1424,8 @@ static SensorOutcome UpgradeExisting() CheckSingleCanonicalRoot(); string installed=CurrentInstalledVersion(); string newSha=Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"); - if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=Version) - throw new InvalidOperationException("Only SHA-pinned official v1.6, v1.7.0 stable/Preview1 or v1.7.1 may upgrade in place."); + if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=Version) + throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2 may upgrade in place."); string channel="OFFICIAL_V16"; string currentMainSHA=UpgradeHash(AppExe); string installedSetup=UPath("Uninstall.exe"); @@ -1405,7 +1439,8 @@ static SensorOutcome UpgradeExisting() !File.Exists(UPath("TaskbarMonitorEnhanced.cs")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.6.0.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.0.md")) && - !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.1.md"))) + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.1.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.2.md"))) return ExistingSensorOutcome(); throw new InvalidOperationException("Unknown current-version maintenance revision; no files changed."); } @@ -1425,6 +1460,12 @@ static SensorOutcome UpgradeExisting() throw new InvalidOperationException("Unknown v1.7.1 source/Setup identity; refuse before any mutation."); channel="OFFICIAL_STABLE_V171"; } + if(installed==InstalledV172) + { + if(currentMainSHA!=OfficialV172MainSHA || oldSetupSHA!=OfficialV172SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.2 source/Setup identity; refuse before any mutation."); + channel="OFFICIAL_STABLE_V172"; + } // No force-kill; the owner must close their currently running app normally. EnsureAppNotRunning(); diff --git a/src/TaskbarGlass.cs b/src/TaskbarGlass.cs index c920ce3d..b8f847e3 100644 --- a/src/TaskbarGlass.cs +++ b/src/TaskbarGlass.cs @@ -21,12 +21,12 @@ internal sealed class GlassToggleSwitch : CheckBox { internal GlassToggleSwitch() { - AutoSize=false;Size=new Size(112,34);TabStop=true;Text=""; + AutoSize=false;Size=new Size(68,34);TabStop=true;Text=""; AccessibleRole=AccessibleRole.CheckButton; SetStyle(ControlStyles.UserPaint|ControlStyles.AllPaintingInWmPaint| - ControlStyles.OptimizedDoubleBuffer|ControlStyles.ResizeRedraw| - ControlStyles.SupportsTransparentBackColor,true); - BackColor=Color.Transparent;Cursor=Cursors.Hand; + ControlStyles.OptimizedDoubleBuffer|ControlStyles.ResizeRedraw,true); + // Never composite other WinForms/native text behind this owner-painted control. + BackColor=Color.FromArgb(20,28,38);Cursor=Cursors.Hand; } protected override void OnCheckedChanged(EventArgs e) {base.OnCheckedChanged(e);Invalidate();} @@ -36,11 +36,16 @@ protected override void OnKeyDown(KeyEventArgs e) {Checked=e.KeyCode==Keys.Right;e.Handled=true;e.SuppressKeyPress=true;return;} base.OnKeyDown(e); } + protected override void OnPaintBackground(PaintEventArgs e) + { + e.Graphics.Clear(BackColor); + } protected override void OnPaint(PaintEventArgs e) { + e.Graphics.Clear(BackColor); e.Graphics.SmoothingMode=System.Drawing.Drawing2D.SmoothingMode.AntiAlias; float h=Math.Max(12,Math.Min(ClientSize.Height-6,26f*DeviceDpi/96f)); - float w=h*1.9f,x=3,y=(ClientSize.Height-h)/2f; + float w=Math.Min(ClientSize.Width-8,h*1.9f),x=4,y=(ClientSize.Height-h)/2f; Color track=SystemInformation.HighContrast? (Checked?SystemColors.Highlight:SystemColors.ControlDark): (Checked?Color.FromArgb(41,128,227):Color.FromArgb(96,106,121)); @@ -54,9 +59,8 @@ protected override void OnPaint(PaintEventArgs e) float inset=Math.Max(2,h/9),d=h-2*inset; using(var thumb=new SolidBrush(SystemInformation.HighContrast?SystemColors.HighlightText:Color.White)) e.Graphics.FillEllipse(thumb,right?x+w-inset-d:x+inset,y+inset,d,d); - TextRenderer.DrawText(e.Graphics,Checked?"On":"Off",Font, - new Rectangle((int)(x+w+8),0,Math.Max(1,ClientSize.Width-(int)(x+w+8)),ClientSize.Height), - Enabled?ForeColor:SystemColors.GrayText,TextFormatFlags.Left|TextFormatFlags.VerticalCenter); + // The ON/OFF word lives in an independent opaque Settings label, not + // on top of the switch's thumb or in an insufficient width at high DPI. if(Focused&&ShowFocusCues)ControlPaint.DrawFocusRectangle(e.Graphics,ClientRectangle); } } diff --git a/src/TaskbarMonitorEnhanced.cs b/src/TaskbarMonitorEnhanced.cs index d33cf2f5..b1888e39 100644 --- a/src/TaskbarMonitorEnhanced.cs +++ b/src/TaskbarMonitorEnhanced.cs @@ -33,18 +33,18 @@ [assembly: AssemblyDescription("Live system monitor integrated into the Windows taskbar")] [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyInformationalVersion("1.7.2+r155")] +[assembly: AssemblyInformationalVersion("1.7.3+r243")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.2.0")] -[assembly: AssemblyFileVersion("1.7.2.0")] +[assembly: AssemblyVersion("1.7.3.0")] +[assembly: AssemblyFileVersion("1.7.3.0")] namespace TaskbarMonitorEnhanced { internal static class BuildInfo { - public const string Version = "V1_7_2_R155_TASKBAR_FIX"; + public const string Version = "V1_7_3_R243_CPU_PREFILTER"; public const string Product = "Taskbar Monitor Enhanced"; - public const string PublicVersion = "1.7.2"; + public const string PublicVersion = "1.7.3"; public const string ShortcutName = "Taskbar Monitor Enhanced"; public const string ProductDescription = "Live system monitor integrated into the Windows taskbar"; public const string Author = "Dr. Ali-Akbar Emadeddin"; @@ -7167,6 +7167,7 @@ private sealed class HardwareChoice private ComboBox theme,windowsLightTheme,windowsDarkTheme,position,taskbarDisplay,cpuMode,gpuMode,diskMode,netMode,multiLayout,memoryUnit,storageUnit,networkUnit,diskRateUnit; private NumericUpDown opacity,interval,width,cpuWarn,gpuWarn,diskWarn,batteryInterval,historyLength; private GlassToggleSwitch glassModeToggle; + private Label glassModeState; private int lastOpacityPercent; private CheckBox cpu,ram,disk,gpu,vram,net,temp,spark,startup,safe,floatAboveStart,ramNumeric,diskNumeric,flyout,hoverAll,autoCheckUpdates,tempAlerts,tempNotifications,adaptiveBattery,followWindowsTheme; private CheckedListBox cpuList,gpuList,diskList,netList; @@ -7258,9 +7259,16 @@ internal SettingsForm(AppConfig config,MetricsSnapshot current,string initialTab Label glassLabel=new Label{Text="Glass mode",Location=new Point(24,164),Size=new Size(145,26)}; display.Controls.Add(glassLabel); glassModeToggle=new GlassToggleSwitch{Location=new Point(180,158), - Size=new Size(112,34),Checked=c.GlassMode}; + Size=new Size(68,34),Checked=c.GlassMode}; SetA11y(glassModeToggle,"Glass mode switch","Click or press Space to switch Glass on or off. On: zero background; Off: your saved 1-100% opacity."); display.Controls.Add(glassModeToggle); + // A separate, opaque status label eliminates mixed native CheckBox text + // painting and keeps track, thumb and ON/OFF text spatially independent. + glassModeState=new Label{Text=c.GlassMode?"On":"Off",Location=new Point(260,160), + Size=new Size(72,30),AutoSize=false,TextAlign=ContentAlignment.MiddleLeft, + ForeColor=SettingsText,BackColor=SettingsSurface}; + SetA11y(glassModeState,"Glass mode status","Current Glass mode state."); + display.Controls.Add(glassModeState); lastOpacityPercent=Math.Max(1,Math.Min(100,(int)Math.Round(c.OpacityWhenGlassOff*100))); opacity=Number(display,"Background opacity %",c.GlassMode?0:(decimal)(c.Opacity*100),c.GlassMode?0:1,100,216); opacity.Enabled=!c.GlassMode; @@ -7335,7 +7343,9 @@ internal SettingsForm(AppConfig config,MetricsSnapshot current,string initialTab ModernizeSettingsPage(diagnostics,"Diagnostics","Inspect live health, copy reports and export support evidence."); ModernizeSettingsPage(advanced,"Advanced","Maintenance, logs and safe configuration reset."); - themePreview=new ThemePreviewControl(themePreviewRenderer);themePreview.Location=new Point(24,500);themePreview.Size=new Size(650,132);themePreview.Anchor=AnchorStyles.Top|AnchorStyles.Left|AnchorStyles.Right;display.AutoScrollMinSize=new Size(760,680);display.Controls.Add(themePreview);themePreview.BringToFront(); + // The note extends to y=508 after the page modernization shift. + // The former preview at y=500 overlapped it by 8px; leave a 28px gap. + themePreview=new ThemePreviewControl(themePreviewRenderer);themePreview.Location=new Point(24,536);themePreview.Size=new Size(650,132);themePreview.Anchor=AnchorStyles.Top|AnchorStyles.Left|AnchorStyles.Right;display.AutoScrollMinSize=new Size(760,720);display.Controls.Add(themePreview);themePreview.BringToFront(); theme.SelectedIndexChanged+=delegate{UpdateThemeModeUi();};windowsLightTheme.SelectedIndexChanged+=delegate{UpdateThemeModeUi();};windowsDarkTheme.SelectedIndexChanged+=delegate{UpdateThemeModeUi();};followWindowsTheme.CheckedChanged+=delegate{UpdateThemeModeUi();};UpdateThemeModeUi(); EnsureAccessibilityMetadata(this); @@ -7413,6 +7423,12 @@ private void StyleSettingsControl(Control control) { NumericUpDown n=(NumericUpDown)control;n.BackColor=SettingsSurface2;n.ForeColor=SettingsText;n.BorderStyle=BorderStyle.FixedSingle;foreach(Control child in n.Controls){child.BackColor=SettingsSurface2;child.ForeColor=SettingsText;} } + else if(control is GlassToggleSwitch) + { + // Native Flat CheckBox paint is never allowed over custom track ink. + GlassToggleSwitch x=(GlassToggleSwitch)control; + x.FlatStyle=FlatStyle.Standard;x.ForeColor=SettingsText;x.BackColor=SettingsSurface; + } else if(control is CheckBox) { CheckBox x=(CheckBox)control;x.ForeColor=SettingsText;x.FlatStyle=FlatStyle.Flat;x.FlatAppearance.BorderColor=SettingsAccent; @@ -7524,6 +7540,7 @@ private string ResolvedPreviewThemeName() private void SyncGlassOpacityUi() { if(glassModeToggle==null || opacity==null)return; + if(glassModeState!=null)glassModeState.Text=glassModeToggle.Checked?"On":"Off"; if(glassModeToggle.Checked) { if(opacity.Enabled && opacity.Value>0)lastOpacityPercent=(int)opacity.Value; diff --git a/src/WorkspaceSelfTest.cs b/src/WorkspaceSelfTest.cs index 45714d11..6b8e887f 100644 --- a/src/WorkspaceSelfTest.cs +++ b/src/WorkspaceSelfTest.cs @@ -126,7 +126,7 @@ internal static int Run() { try { - Check(BuildInfo.PublicVersion=="1.7.2","version"); + Check(BuildInfo.PublicVersion=="1.7.3","version"); for(int schema=0;schema<7;schema++){ var legacy=new AppConfig{ConfigSchemaVersion=schema,EnableTemperatureNotifications=true,CpuTempWarningC=0,FontSize=Double.NaN,Opacity=Double.NaN};legacy.Normalize(); Check(legacy.ConfigSchemaVersion==8,"all legacy schemas migrate "+schema); From 690cef8d6883e7811913a394343fa0e304ccb87a Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Fri, 16 Oct 2026 01:32:38 +0330 Subject: [PATCH 003/135] fix(updater): include official v1.7.2 in atomic journal and rollback guards (R245) --- installer/TaskbarMonitorEnhanced_Setup.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index c9f65cf8..0c47dec9 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1284,7 +1284,7 @@ static void StageNewManaged(Dictionary fresh, Dictionary rows) string priorVersionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); if(!File.Exists(priorVersionPath))throw new IOException("Previous upgrade version missing; retain stage."); string priorVersion=File.ReadAllText(priorVersionPath,Encoding.ASCII); - if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); // This works after a mid-commit process kill: exact old snapshots are persisted // on the same volume, and new-only files are removed only if their SHA is known. for(int i=rows.Count-1;i>=0;i--){ From 7d272515df6f0d3a05ef4f03e5bb8472393d34eb Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Fri, 16 Oct 2026 01:40:14 +0330 Subject: [PATCH 004/135] docs(release): record R244 switch overlap fix and accepted A/B evidence --- docs/releases/v1.7.3/RELEASE_NOTES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/releases/v1.7.3/RELEASE_NOTES.md b/docs/releases/v1.7.3/RELEASE_NOTES.md index b08c811a..87ca6bf6 100644 --- a/docs/releases/v1.7.3/RELEASE_NOTES.md +++ b/docs/releases/v1.7.3/RELEASE_NOTES.md @@ -3,11 +3,12 @@ ## Changes - Reordered existing Start/Search native-window geometry filters ahead of expensive Windows process-name enumeration; no layout behavior change intended. - The separate user-controlled **Glass Mode** ON uses zero-opacity background while metric ink remains visible. OFF restores the saved 1–100% background opacity; Start is pinned to the actual taskbar and never intentionally floated. +- **Glass Mode switch visual repair:** the switch and its On/Off label are separate opaque controls, with no double-painted native text on the thumb. The Display preview no longer overlaps its explanation. All nine Settings pages have a layout-collision regression guard. - One SHA-pinned Windows Setup updates an existing official v1.7.2 installation **in place**, without uninstalling or creating a second per-user root. Existing official v1.6.0/1.7.0/1.7.1 compatible paths remain guarded. ## Verification and scope - On one owner Windows PC, actual 4 × 60-second BASE–TRIAL–TRIAL–BASE whole-program CPU observation measured 23.37809% versus 6.65184% of one logical core (71.547% lower in those tested conditions), exact prior v1.7.2 Main restored. - The owner's quiescent 442-file data-root ZIP passed SHA checks and byte-exact isolated restore; this does not establish physical AC power-loss resilience or restore a deliberately damaged owner HKCU registry. -- Public release promotion requires this version's own exact-head build, clean-clone reproducibility, real disposable Windows protected v1.7.2 → v1.7.3 upgrade, Glass/native presentation, provenance/SBOM, and uploaded asset digest checks. +- Candidate verification (Commit `690cef8d6883e7811913a394343fa0e304ccb87a`): independent Windows A/B protected v1.7.2 → v1.7.3 in-place upgrades, journal rollback, idempotence, downgrade refusal and four native Start/Settings/fullscreen/presentation modes passed; standard Build and Verification, exact binary/SBOM, and nine-page no-overlap UI suite passed. Public promotion requires equivalent merged-main exact-head CI, asset provenance and uploaded SHA-256 acceptance. - The installer is **unsigned** by maintainer choice. Unknown publisher/SmartScreen messages are not proof of malware; actual antivirus detections must not be bypassed or dismissed. External vulnerable-driver issue #6 attribution remains unresolved, as do formal signed labs, physical power loss, and machine-wide multiuser/HKLM collision proof. - Public immutable v1.7.2 assets are not altered by this new release. From e7904d09273314d7ff280f5abafafb23f9752ad9 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Fri, 16 Oct 2026 03:37:38 +0330 Subject: [PATCH 005/135] release(candidate): versioned 1.7.4 repairs fresh maintenance and pinned v1.7.3 upgrades (R256) --- .github/workflows/ci.yml | 2 +- .github/workflows/release-v173-acceptance.yml | 111 ------- .github/workflows/release-v174-acceptance.yml | 169 ++++++++++ build/Build.ps1 | 10 +- build/Package-Signed-Setup.ps1 | 4 +- build/TaskbarMonitorEnhanced_Setup.csproj | 4 +- build/Test-Native-Presentation-R256.ps1 | 300 ++++++++++++++++++ build/Test-NoPawnIO-OssRelease.ps1 | 6 +- .../Test-R256-FreshSameVersion-Disposable.ps1 | 177 +++++++++++ build/Test-Release-Gates.ps1 | 14 +- build/Test-Signed-Payload-Contract.ps1 | 6 +- build/Test-Signed-Staging.ps1 | 6 +- build/Test-Upgrade173-R256.ps1 | 227 +++++++++++++ build/Test-UpgradeContract-R256.ps1 | 64 ++++ build/Verify-Embedded-Main.ps1 | 4 +- build/Verify-Release-Metadata-R256.ps1 | 105 ++++++ build/official-v173-managed.json | 129 ++++++++ docs/releases/v1.7.4/INSTALLER_README.txt | 6 + docs/releases/v1.7.4/RELEASE_NOTES.md | 11 + installer/TaskbarMonitorEnhanced_Setup.cs | 89 ++++-- src/TaskbarMonitorEnhanced.cs | 10 +- src/WorkspaceSelfTest.cs | 2 +- 22 files changed, 1287 insertions(+), 169 deletions(-) delete mode 100644 .github/workflows/release-v173-acceptance.yml create mode 100644 .github/workflows/release-v174-acceptance.yml create mode 100644 build/Test-Native-Presentation-R256.ps1 create mode 100644 build/Test-R256-FreshSameVersion-Disposable.ps1 create mode 100644 build/Test-Upgrade173-R256.ps1 create mode 100644 build/Test-UpgradeContract-R256.ps1 create mode 100644 build/Verify-Release-Metadata-R256.ps1 create mode 100644 build/official-v173-managed.json create mode 100644 docs/releases/v1.7.4/INSTALLER_README.txt create mode 100644 docs/releases/v1.7.4/RELEASE_NOTES.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54985b6c..076b0ec7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,7 +135,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference='Stop' - ./build/Verify-Release-Metadata-R243.ps1 -SelfTest + ./build/Verify-Release-Metadata-R256.ps1 -SelfTest if ($LASTEXITCODE -ne 0) {throw 'R136 release version, 5 output SHA and SBOM source gates FAILED'} - name: Attest binary build provenance diff --git a/.github/workflows/release-v173-acceptance.yml b/.github/workflows/release-v173-acceptance.yml deleted file mode 100644 index 9a5130ad..00000000 --- a/.github/workflows/release-v173-acceptance.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: v1.7.3 - Protected official v1.7.2 upgrade and native acceptance -on: - push: - branches: [release/r243-v173-cpu-safe-upgrade, main] - workflow_dispatch: -permissions: - contents: read -jobs: - build-candidate: - runs-on: windows-latest - timeout-minutes: 20 - steps: - - name: Exact source checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Parser and versioned reproducible build - shell: pwsh - run: | - $ErrorActionPreference='Stop' - if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R243 source drift'} - foreach($p in @('build/Test-Upgrade172-R243.ps1','build/Test-UpgradeContract-R243.ps1','build/Test-Native-Presentation-R243.ps1')){ - $t=$null;$e=$null - [void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p).Path,[ref]$t,[ref]$e) - if(@($e).Count){throw ('R243 parser '+$p)} - } - ./build/Build.ps1 - ./build/Generate-Sbom.ps1 - ./build/Verify-Release-Metadata-R243.ps1 -SelfTest - if($LASTEXITCODE -ne 0){throw 'R243 version/manifest/SPDX gate'} - $main='./build/_out/App/TaskbarMonitorEnhanced.exe' - $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.3.exe' - $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.3';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} - [IO.File]::WriteAllText((Join-Path $pwd 'build/_out/CANDIDATE.json'),($c|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) - - name: Actual compiled control, geometry, visibility and upgrade contracts - shell: powershell - run: | - $ErrorActionPreference='Stop' - $main='./build/_out/App/TaskbarMonitorEnhanced.exe' - ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/APPEARANCE') - ./build/Test-SettingsUi-R244.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/SETTINGS') - $ui=Get-Content (Join-Path $env:RUNNER_TEMP 'R243_MODELS/SETTINGS/R244_SETTINGS_UI.json') -Raw|ConvertFrom-Json - if($ui.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or $ui.SettingsPages -ne 9 -or $ui.SiblingOverlapCount -ne 0){throw 'R244_NATIVE_SETTINGS_UI_LAYOUT_COLLISION'} - ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/GEOMETRY') - ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/VISIBILITY') - ./build/Test-UpgradeContract-R243.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.3.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R243_MODELS/UPGRADE') - - name: Preserve one exact candidate installer, not a release - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: R243-candidate - if-no-files-found: error - retention-days: 14 - path: | - build/_out/CANDIDATE.json - build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.3.exe - - name: Preserve model receipts and binary manifest - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: R243-compiled-contracts - if-no-files-found: warn - retention-days: 21 - path: | - ${{ runner.temp }}/R243_MODELS/** - artifacts/BUILD_MANIFEST.json - artifacts/SBOM.spdx.json - upgrade-and-native: - needs: build-candidate - runs-on: windows-latest - timeout-minutes: 24 - name: Real official 1.7.2 to 1.7.3 upgrade and native modes (${{ matrix.host }}) - strategy: - fail-fast: false - matrix: - host: [A, B] - steps: - - name: Fresh independent Windows source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Transfer exact previously compiled Setup bytes - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: R243-candidate - path: build/_out - - name: Genuine official v1.7.2 upgrade preserving user data - shell: powershell - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference='Stop' - ./build/Test-Upgrade172-R243.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' - $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R243_UPGRADE_${{ matrix.host }}/R243_UPGRADE.json') -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_GENUINE_172_TO_173_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R243 actual updater acceptance failed'} - - name: Native menu, Settings, Start, fullscreen and 1-percent regression on upgraded bytes - shell: powershell - run: | - $ErrorActionPreference='Stop' - ./build/Test-Native-Presentation-R243.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate - $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R243_${{ matrix.host }}/R243_NATIVE.json') -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R243 native four-mode evidence not accepted'} - - name: Preserve raw upgrade receipts and physical native pixels on success or failure - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: R243-native-upgrade-${{ matrix.host }} - if-no-files-found: warn - retention-days: 21 - path: | - ${{ runner.temp }}/TBME_R243_UPGRADE_${{ matrix.host }}/R243_UPGRADE.json - ${{ runner.temp }}/TBME_R243_${{ matrix.host }}/** diff --git a/.github/workflows/release-v174-acceptance.yml b/.github/workflows/release-v174-acceptance.yml new file mode 100644 index 00000000..9f736e9a --- /dev/null +++ b/.github/workflows/release-v174-acceptance.yml @@ -0,0 +1,169 @@ +name: v1.7.4 - Protected official v1.7.3 upgrade and native acceptance +on: + push: + branches: [release/r256-v174-fresh-idempotence, main] + workflow_dispatch: +permissions: + contents: read +jobs: + build-candidate: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Exact source checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Parser and versioned reproducible build + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R256 source drift'} + foreach($p in @('build/Test-Upgrade173-R256.ps1','build/Test-UpgradeContract-R256.ps1','build/Test-Native-Presentation-R256.ps1')){ + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p).Path,[ref]$t,[ref]$e) + if(@($e).Count){throw ('R256 parser '+$p)} + } + ./build/Build.ps1 + ./build/Generate-Sbom.ps1 + ./build/Verify-Release-Metadata-R256.ps1 -SelfTest + if($LASTEXITCODE -ne 0){throw 'R256 version/manifest/SPDX gate'} + $main='./build/_out/App/TaskbarMonitorEnhanced.exe' + $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.4.exe' + $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.4';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} + [IO.File]::WriteAllText((Join-Path $pwd 'build/_out/CANDIDATE.json'),($c|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + - name: Actual compiled control, geometry, visibility and upgrade contracts + shell: powershell + run: | + $ErrorActionPreference='Stop' + $main='./build/_out/App/TaskbarMonitorEnhanced.exe' + ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/APPEARANCE') + ./build/Test-SettingsUi-R244.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/SETTINGS') + $ui=Get-Content (Join-Path $env:RUNNER_TEMP 'R256_MODELS/SETTINGS/R244_SETTINGS_UI.json') -Raw|ConvertFrom-Json + if($ui.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or $ui.SettingsPages -ne 9 -or $ui.SiblingOverlapCount -ne 0){throw 'R244_NATIVE_SETTINGS_UI_LAYOUT_COLLISION'} + ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/GEOMETRY') + ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/VISIBILITY') + ./build/Test-UpgradeContract-R256.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.4.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/UPGRADE') + - name: Preserve one exact candidate installer, not a release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R256-candidate + if-no-files-found: error + retention-days: 14 + path: | + build/_out/CANDIDATE.json + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.4.exe + - name: Preserve model receipts and binary manifest + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R256-compiled-contracts + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R256_MODELS/** + artifacts/BUILD_MANIFEST.json + artifacts/SBOM.spdx.json + upgrade-and-native: + needs: build-candidate + runs-on: windows-latest + timeout-minutes: 24 + name: Real official 1.7.3 to 1.7.4 upgrade and native modes (${{ matrix.host }}) + strategy: + fail-fast: false + matrix: + host: [A, B] + steps: + - name: Fresh independent Windows source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Transfer exact previously compiled Setup bytes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R256-candidate + path: build/_out + - name: Genuine official v1.7.3 upgrade preserving user data + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-Upgrade173-R256.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R256_UPGRADE_${{ matrix.host }}/R256_UPGRADE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_GENUINE_173_TO_174_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R256 actual updater acceptance failed'} + - name: Native menu, Settings, Start, fullscreen and 1-percent regression on upgraded bytes + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-Native-Presentation-R256.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R256_${{ matrix.host }}/R256_NATIVE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R256 native four-mode evidence not accepted'} + - name: Preserve raw upgrade receipts and physical native pixels on success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R256-native-upgrade-${{ matrix.host }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R256_UPGRADE_${{ matrix.host }}/R256_UPGRADE.json + ${{ runner.temp }}/TBME_R256_${{ matrix.host }}/** + + fresh-install-maintenance: + needs: build-candidate + name: "R256 truly fresh, repeated maintenance, legacy duplicate refusal ${{ matrix.host }}" + runs-on: ${{ matrix.system }} + timeout-minutes: 23 + strategy: + fail-fast: false + matrix: + include: + - host: A + system: windows-2025 + - host: B + system: windows-2022 + steps: + - name: Fresh disposable Windows source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Download compiled exact candidate, not public Setup + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R256-candidate + path: build/_out + - name: Prove no root-source duplication, two maintenance calls and conflict refusal + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-R256-FreshSameVersion-Disposable.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $receipt=Join-Path $env:RUNNER_TEMP 'TBME_R256_${{ matrix.host }}/R256_FRESH.json' + if(!(Test-Path $receipt)){throw 'R256_FRESH_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_GUEST_FRESH_R256_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.SingleManagedSourceWithoutRootDuplicate -ne $true -or + $r.FreshInstallExit -ne 0 -or + $r.FirstSameVersionMaintenanceExit -ne 0 -or + $r.NegativeProgramFilesCollisionExit -ne 34 -or + $r.SecondSameVersionMaintenanceExit -ne 0 -or + $r.ProtectedCanaryAndConfigPreserved -ne $true -or + $r.PublicSetupChanged -ne $false -or + $r.PersonalComputerTouched -ne $false -or + $r.RealHKLMRegistryOrCrossUserAcceptance -ne $false){ + throw 'R256_REAL_FRESH_INSTALL_OR_DATA_SCOPE_NOT_PASS' + } + 'R256_REAL_TWO_HOST_FRESH_AND_REPEATED_MAINTENANCE=PASS' + # The job invokes native commands in the child test; only after all + # receipt assertions passed reset stale native LASTEXITCODE. + $global:LASTEXITCODE=0 + - name: Save bounded disposable fresh-only receipts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R256-fresh-${{ matrix.host }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R256_${{ matrix.host }}/R256_FRESH.json diff --git a/build/Build.ps1 b/build/Build.ps1 index 867a51df..a2f7d873 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -92,12 +92,12 @@ $CanonicalTextResources=[ordered]@{ 'TaskbarMonitorSensorSupervisor.cs'=(Join-Path $Root 'src\sensors\TaskbarMonitorSensorSupervisor.cs') 'TBME_Setup_Elevated_Helper.ps1'=(Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1') 'LICENSE'=(Join-Path $Root 'LICENSE') - 'README.md'=(Join-Path $Root 'docs\releases\v1.7.3\INSTALLER_README.txt') + 'README.md'=(Join-Path $Root 'docs\releases\v1.7.4\INSTALLER_README.txt') 'AUTHORS.md'=(Join-Path $Root 'AUTHORS.md') 'COPYRIGHT_AND_ATTRIBUTION.md'=(Join-Path $Root 'COPYRIGHT_AND_ATTRIBUTION.md') 'AI_ASSISTED_DEVELOPMENT.md'=(Join-Path $Root 'AI_ASSISTED_DEVELOPMENT.md') 'THIRD_PARTY_NOTICES.md'=(Join-Path $Root 'THIRD_PARTY_NOTICES.md') - 'RELEASE_NOTES_v1.7.3.md'=(Join-Path $Root 'docs\releases\v1.7.3\RELEASE_NOTES.md') + 'RELEASE_NOTES_v1.7.4.md'=(Join-Path $Root 'docs\releases\v1.7.4\RELEASE_NOTES.md') 'UPSTREAM_REFERENCE_GPL_NOTICE.md'=(Join-Path $Root 'UPSTREAM_REFERENCE_GPL_NOTICE.md') 'TaskbarMonitorEnhanced_Setup.cs'=(Join-Path $Root 'installer\TaskbarMonitorEnhanced_Setup.cs') } @@ -107,7 +107,7 @@ foreach($entry in $CanonicalTextResources.GetEnumerator()){ Write-Host 'TBME_CANONICAL_TEXT_PAYLOAD=PASS' Build (Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj') -$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' if(!(Test-Path -LiteralPath $Setup)){throw 'Setup output missing.'} # SignPath Foundation requires uniform signed-artifact project and ProductVersion metadata. @@ -152,8 +152,8 @@ if([string]$v.Status -ne 'PASS' -or [int]$v.Resources -ne 25 -or $v.PawnIOBundle if($LASTEXITCODE -ne 0){throw 'Application contract suite failed.'} $manifest=[ordered]@{ - Version='1.7.3' - Build='V1_7_3_R243_CPU_PREFILTER' + Version='1.7.4' + Build='V1_7_4_R256_FRESH_MAINTENANCE' GeneratedUtc=[datetime]::UtcNow.ToString('o') Dependencies=[ordered]@{ LibreHardwareMonitor=[ordered]@{Version=[string]$Lock.dependencies.LibreHardwareMonitor.version;Url=$LhmUrl;SHA256=$LhmSha} diff --git a/build/Package-Signed-Setup.ps1 b/build/Package-Signed-Setup.ps1 index 0034a73a..49e2569f 100644 --- a/build/Package-Signed-Setup.ps1 +++ b/build/Package-Signed-Setup.ps1 @@ -10,10 +10,10 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $UnsignedMain=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' $StageBase=if($ContractFixtureOnly){Join-Path $Root 'artifacts\_r80_contract_only'}else{Join-Path $Root 'artifacts\signed\Setup\TO_SIGN'} -$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' $Proof=Join-Path $StageBase 'EMBEDDED_MAIN_PROOF.json' $Verification=Join-Path $StageBase 'SETUP_VERIFY.json' $CreatedStage=$false diff --git a/build/TaskbarMonitorEnhanced_Setup.csproj b/build/TaskbarMonitorEnhanced_Setup.csproj index 332ce868..b54da0c1 100644 --- a/build/TaskbarMonitorEnhanced_Setup.csproj +++ b/build/TaskbarMonitorEnhanced_Setup.csproj @@ -1,5 +1,5 @@ - net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.3_out\Setup\false + net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.4_out\Setup\false _package\TaskbarMonitorEnhanced.exefalse @@ -23,7 +23,7 @@ - + \ No newline at end of file diff --git a/build/Test-Native-Presentation-R256.ps1 b/build/Test-Native-Presentation-R256.ps1 new file mode 100644 index 00000000..c299840e --- /dev/null +++ b/build/Test-Native-Presentation-R256.ps1 @@ -0,0 +1,300 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel,[switch]$UseInstalledCandidate) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R146_REAL_TEST_ONLY_FRESH_HOSTED_WINDOWS_FRAMEWORK'} +Add-Type -AssemblyName System.Drawing,System.Windows.Forms,UIAutomationClient,UIAutomationTypes +$native=@' +using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices; +public static class R146Native { + [StructLayout(LayoutKind.Sequential)] public struct Rect {public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential)] public struct Point {public int X,Y;} + [StructLayout(LayoutKind.Sequential)] public struct Mouse {public int x,y;public uint data,flags,time;public UIntPtr extra;} + [StructLayout(LayoutKind.Explicit)] public struct Union {[FieldOffset(0)] public Mouse m;} + [StructLayout(LayoutKind.Sequential)] public struct Input {public uint type;public Union u;} + public delegate bool EnumFn(IntPtr h,IntPtr data); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p,EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out Rect r); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetWindowLongPtr(IntPtr h,int n); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint n); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h,StringBuilder s,int n); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c,string t); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int s); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l); + [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr extra); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out Point p); + [DllImport("user32.dll",SetLastError=true)] public static extern uint SendInput(uint n,Input[] p,int size); + public static void Key(byte code){keybd_event(code,0,0,UIntPtr.Zero);keybd_event(code,0,2,UIntPtr.Zero);} + public static void Click(int x,int y,bool right){Point old;GetCursorPos(out old);SetCursorPos(x,y);try{ + Input down=new Input{u=new Union{m=new Mouse{flags=right?8u:2u}}}; + Input up=new Input{u=new Union{m=new Mouse{flags=right?16u:4u}}}; + if(Marshal.SizeOf(typeof(Input))!=40||SendInput(2,new[]{down,up},40)!=2)throw new Exception("Input not delivered"); + }finally{SetCursorPos(old.X,old.Y);}} + public static IntPtr[] Owned(uint pid,IntPtr tray){var list=new List();EnumFn f=(h,d)=>{uint p;GetWindowThreadProcessId(h,out p);if(p==pid&&!list.Contains(h))list.Add(h);return true;};EnumWindows(f,IntPtr.Zero);EnumChildWindows(tray,f,IntPtr.Zero);return list.ToArray();} + public static bool Above(IntPtr a,IntPtr b){for(int i=0;i<512;i++){b=GetWindow(b,3);if(b==IntPtr.Zero)return false;if(a==b)return true;}return false;} + public static string Title(IntPtr h){var s=new StringBuilder(256);GetWindowText(h,s,s.Capacity);return s.ToString();} +} +'@ +Add-Type -TypeDefinition $native -Language CSharp +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Private -ne $true){throw 'R146_NOT_EXACT_PRIVATE_BUILD_ARTIFACT'} +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +if((Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R146_SETUP_SHA'} +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R256_'+$HostLabel) +if((Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R256_NATIVE_OUTPUT_OR_PROCESS_PRESTATE'} +if($UseInstalledCandidate){ + if(!(Test-Path $app) -or !(Test-Path $reg) -or (Test-Path (Join-Path $app '.tbme-upgrade'))){throw 'R256_VERIFIED_EXISTING_CANDIDATE_REQUIRED'} + $s=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($s.Version -cne '1.7.4' -or (Get-FileHash (Join-Path $app 'TaskbarMonitorEnhanced.exe') -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R256_NATIVE_INSTALLED_SHA_OR_VERSION'} +}else{if((Test-Path $app) -or (Test-Path $reg)){throw 'R256_NATIVE_FRESH_VM_REQUIRED'}} +New-Item -ItemType Directory -Path $out|Out-Null +$visualFailures=New-Object 'Collections.Generic.List[string]' +$phase='INSTALL';$status='FAIL';$failure='';$process=$null;$full=$null;$taskbar=[IntPtr]::Zero;$hidTaskbar=$false;$evidence=@() +function Box([IntPtr]$h){$r=New-Object R146Native+Rect;if(![R146Native]::GetWindowRect($h,[ref]$r)){throw 'R146_RECT_FAILED'};return New-Object Drawing.Rectangle($r.L,$r.T,($r.R-$r.L),($r.B-$r.T))} +function Pump([int]$ms){$stop=[Diagnostics.Stopwatch]::StartNew();while($stop.ElapsedMilliseconds -lt $ms){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 25}} +function Capture([IntPtr]$h,[string]$label){ + $r=Box $h;$file=$label+'.png';$b=New-Object Drawing.Bitmap($r.Width,$r.Height) + try{ + $g=[Drawing.Graphics]::FromImage($b);try{$g.CopyFromScreen($r.Location,[Drawing.Point]::Empty,$r.Size)}finally{$g.Dispose()} + $edges=0;for($y=0;$y -lt $b.Height;$y++){for($x=1;$x -lt $b.Width;$x++){ + $a=$b.GetPixel($x,$y);$z=$b.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$edges++} + }} + $b.Save((Join-Path $out $file),[Drawing.Imaging.ImageFormat]::Png) + if($edges -lt 60){$visualFailures.Add(('R146_PHYSICAL_GLYPHS_MISSING '+$label+' EDGES='+$edges))} + return @{State=$label;File=$file;SHA256=(Get-FileHash (Join-Path $out $file) -Algorithm SHA256).Hash;Edges=$edges;Rect=@($r.X,$r.Y,$r.Width,$r.Height);Parent=[R146Native]::GetParent($h).ToInt64();Owner=[R146Native]::GetWindow($h,4).ToInt64();OwnerMemory=[R146Native]::GetWindowLongPtr($h,-8).ToInt64();PreviousSibling=[R146Native]::GetWindow($h,3).ToInt64();ExStyle=[R146Native]::GetWindowLongPtr($h,-20).ToInt64();Style=[R146Native]::GetWindowLongPtr($h,-16).ToInt64();Foreground=[R146Native]::GetForegroundWindow().ToInt64()} + }finally{$b.Dispose()} +} +# R163 fork-only paint readiness. WS_VISIBLE is not proof of actual text ink. +# Same RGB edge definition as Capture(): adjacent channel-sum delta >=120. +# ZERO edges must NEVER satisfy 60-edge native acceptance. No product code change. +function Probe-R163PhysicalInk([IntPtr]$h,[string]$label,[bool]$saveFrame){ + $rect=Box $h + if($rect.Width -lt 80 -or $rect.Height -lt 16 -or $rect.Height -ge 100){ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error='R163_INVALID_WINDOW_GEOMETRY'} + } + $bitmap=New-Object Drawing.Bitmap($rect.Width,$rect.Height) + try{ + try{ + $g=[Drawing.Graphics]::FromImage($bitmap) + try{$g.CopyFromScreen($rect.Location,[Drawing.Point]::Empty,$rect.Size)} + finally{$g.Dispose()} + }catch{ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error=('R163_COPY_FROM_SCREEN_FAILED '+$_.Exception.Message)} + } + $ink=0 + $colors=New-Object 'System.Collections.Generic.HashSet[int]' + for($y=0;$y -lt $bitmap.Height;$y++){ + for($x=0;$x -lt $bitmap.Width;$x++){ + $a=$bitmap.GetPixel($x,$y) + $rgb=([int]$a.R -shl 16) -bor ([int]$a.G -shl 8) -bor [int]$a.B + [void]$colors.Add($rgb) + if($x -ge 1){ + $z=$bitmap.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$ink++} + } + } + } + $file='';$sha='';$bytes=0 + if($saveFrame){ + $file=$label+'.png' + $full=Join-Path $out $file + if(Test-Path $full){throw ('R163_PROBE_IMAGE_OVERWRITE_REFUSED '+$file)} + $bitmap.Save($full,[Drawing.Imaging.ImageFormat]::Png) + $sha=(Get-FileHash $full -Algorithm SHA256).Hash + $bytes=(Get-Item $full).Length + } + return @{Edges=$ink;Colors=$colors.Count;Bytes=$bytes;File=$file;SHA256=$sha;Error='';Rect=@($rect.X,$rect.Y,$rect.Width,$rect.Height)} + }finally{$bitmap.Dispose()} +} + +function Assert-Visible([IntPtr]$h,[Drawing.Rectangle]$original,[string]$label,[bool]$top){ + if(![R146Native]::IsWindowVisible($h)){throw ('R146_MONITOR_HIDDEN '+$label)} + $r=Box $h + if($r.X -ne $original.X -or $r.Y -ne $original.Y -or $r.Height -ne $original.Height -or [Math]::Abs($r.Width-$original.Width) -gt 96){throw ('R146_UNEXPECTED_MOVEMENT '+$label)} + # R153: retain both direct owner-memory and WS_POPUP GetParent checks. + # R152 real Start evidence showed GW_OWNER=0 but GetParent=the exact + # taskbar and 877 physical glyph edges. Never let a relationship-walk + # API substitute for pixels; never waive actual owner-memory or geometry. + $ownerMemory=[R146Native]::GetWindowLongPtr($h,-8) + $popupOwner=[R146Native]::GetParent($h) + if($ownerMemory -ne $taskbar -or $popupOwner -ne $taskbar){ + $nativeOwner=[R146Native]::GetWindow($h,4) + $directTray=[R146Native]::FindWindow('Shell_TrayWnd',$null) + $frame=Capture $h ($label+'_OWNER_FAILURE') + [IO.File]::WriteAllText((Join-Path $out ($label+'_NATIVE_FAILURE.json')),(@{ExpectedOwner=$taskbar.ToInt64();ActualOwner=$nativeOwner.ToInt64();DirectOwnerMemory=$ownerMemory.ToInt64();PopupOwner=$popupOwner.ToInt64();DirectTaskbar=$directTray.ToInt64();Frame=$frame}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + throw ('R152_TASKBAR_OWNER_DRIFT '+$label+' EXPECTED='+$taskbar.ToInt64()+' ACTUAL='+$nativeOwner.ToInt64()+' DIRECT='+$directTray.ToInt64()) + } + if(([R146Native]::GetWindowLongPtr($h,-16).ToInt64() -band 0x40000000) -ne 0){throw ('R151_MONITOR_BECAME_CHILD '+$label)} + if($top -and [R146Native]::Above($taskbar,$h)){throw ('R146_MONITOR_UNDER_TASKBAR '+$label)} +} +try{ + if(!$UseInstalledCandidate){ + $install=Start-Process -FilePath $setup -ArgumentList @('/install','/quiet','/desktop=0','/startup=0') -PassThru + if(!$install.WaitForExit(190000) -or $install.ExitCode -ne 0){throw 'R256_SETUP_DID_NOT_FINISH'} + } + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe';$config=Join-Path $app 'config.json' + if((Get-FileHash $main -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R146_INSTALLED_MAIN_SHA'} + $taskbar=[R146Native]::FindWindow('Shell_TrayWnd',$null) + if($taskbar -eq [IntPtr]::Zero -or ![R146Native]::IsWindowVisible($taskbar)){throw 'R146_REAL_TASKBAR_MISSING'} + $assembly=[Reflection.Assembly]::LoadFrom($main) + $shell=$assembly.GetType('TaskbarMonitorEnhanced.ShellUi',$true) + $shellMethod=$shell.GetMethod('IsStartShellForeground',[Reflection.BindingFlags]'Static,Public,NonPublic') + foreach($mode in @('plain1','plain10','glass0','opaque100')){ + $phase=$mode + $pct=if($mode -ceq 'plain1'){0.01}elseif($mode -ceq 'plain10'){0.10}elseif($mode -ceq 'glass0'){0.0}else{1.0} + $fixture=@{Theme='Retro Terminal';Opacity=$pct;GlassMode=($mode -ceq 'glass0');OpacityWhenGlassOff=0.10;ConfigSchemaVersion=8;StartWithWindows=$false;ShowSparklines=$true;UpdateIntervalMs=1000} + [IO.File]::WriteAllText($config,($fixture|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $process=Start-Process -FilePath $main -WorkingDirectory $app -PassThru + $readyClock=[Diagnostics.Stopwatch]::StartNew();$readyObservations=@() + Pump 5000 # Keep original warm-up and strict 20s total readiness deadline. + $monitor=[IntPtr]::Zero;$paintFirst=$false;$paintLast=$null + $lastEligible=[IntPtr]::Zero + do{ + $process.Refresh() + if($process.HasExited){throw ('R158_MAIN_EXITED_BEFORE_READY '+$mode+' CODE='+$process.ExitCode)} + $observed=@() + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){ + $visible=[R146Native]::IsWindowVisible($h);$r=Box $h + $one=@{Handle=$h.ToInt64();Visible=$visible;Rect=@($r.X,$r.Y,$r.Width,$r.Height)} + if($visible -and $r.Width -ge 80 -and $r.Height -ge 16 -and $r.Height -lt 100 -and (Box $taskbar).Contains($r)){ + $lastEligible=$h + # Persist FIRST physical frame, whether ink is present or missing. + $paint=Probe-R163PhysicalInk $h ($mode+'_R163_FIRST_PAINT') (!$paintFirst) + $paintFirst=$true + $paintLast=$paint + $one.PaintEdges=$paint.Edges;$one.PaintColors=$paint.Colors;$one.PaintError=$paint.Error + if($paint.Edges -ge 60 -and [string]$paint.Error -ceq ''){ + $monitor=$h;$observed+=@($one);break + } + } + $observed+=@($one) + } + if($readyObservations.Count -lt 24){ + $readyObservations+=@{ElapsedMs=$readyClock.ElapsedMilliseconds;Windows=$observed} + } + if($monitor -eq [IntPtr]::Zero){Pump 200} + }while($monitor -eq [IntPtr]::Zero -and $readyClock.ElapsedMilliseconds -lt 20000) + $readyClock.Stop() + $timeoutProbe=$null + if($monitor -eq [IntPtr]::Zero -and $lastEligible -ne [IntPtr]::Zero){ + $timeoutProbe=Probe-R163PhysicalInk $lastEligible ($mode+'_R163_TIMEOUT_PAINT') $true + } + $ready=[ordered]@{Mode=$mode;Pid=$process.Id;Ready=($monitor -ne [IntPtr]::Zero); + PaintReady=($monitor -ne [IntPtr]::Zero -and $null -ne $paintLast -and $paintLast.Edges -ge 60); + PaintThresholdEdges=60;OriginalRgbDelta=120; + LastMeasuredEdges=if($null -eq $paintLast){-1}else{$paintLast.Edges}; + LastMeasuredColors=if($null -eq $paintLast){0}else{$paintLast.Colors}; + TimeoutFrame=$timeoutProbe; + ElapsedMilliseconds=$readyClock.ElapsedMilliseconds;HardDeadlineMs=20000; + Observations=$readyObservations;ProductSourceChanged=$false; + NoThresholdWeakening=$true;SeparateTaskbarHideInvariantRequired=$true} + [IO.File]::WriteAllText((Join-Path $out ('R158_READY_'+$mode+'.json')),($ready|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if($monitor -eq [IntPtr]::Zero){ + if($lastEligible -ne [IntPtr]::Zero){ + throw ('R163_PHYSICAL_PAINT_NOT_READY_WITHIN_20_SECONDS '+$mode+ + ' EDGES='+$ready.LastMeasuredEdges+' COLORS='+$ready.LastMeasuredColors) + } + throw ('R158_NATIVE_WINDOW_NOT_READY_WITHIN_20_SECONDS '+$mode) + } + if(!$ready.PaintReady -or $ready.LastMeasuredEdges -lt 60){ + throw ('R163_UNPROVEN_NATIVE_PAINT '+$mode) + } + Write-Output ('R163_NATIVE_AND_ACTUAL_INK_READY MODE='+$mode+' PID='+$process.Id+ + ' ELAPSED_MS='+$readyClock.ElapsedMilliseconds+' EDGES='+$ready.LastMeasuredEdges) + $original=Box $monitor;$top=$true + # Original Capture() remains the separate final pixel threshold acceptance. + $evidence+=Capture $monitor ($mode+'_NORMAL') + # Real right-click and invoke the actual installed product's Settings menu item. + [R146Native]::Click(($original.X+40),($original.Y+($original.Height/2)),$true);Pump 650 + Assert-Visible $monitor $original ($mode+'_MENU') $top + $evidence+=Capture $monitor ($mode+'_MENU') + $menuItem=$null + $condition=New-Object Windows.Automation.AndCondition([Windows.Automation.Condition[]]@( + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::ProcessIdProperty,[int]$process.Id)), + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::NameProperty,'Settings...')))) + $menuItem=[Windows.Automation.AutomationElement]::RootElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$condition) + if($null -eq $menuItem){throw 'R146_REAL_SETTINGS_MENU_ITEM_NOT_FOUND'} + $invoke=$menuItem.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern);$invoke.Invoke();Pump 1500 + $settings=[IntPtr]::Zero + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){if([R146Native]::Title($h) -like '* - Settings' -and [R146Native]::IsWindowVisible($h)){$settings=$h;break}} + if($settings -eq [IntPtr]::Zero -or [R146Native]::GetForegroundWindow() -ne $settings){throw 'R146_SETTINGS_NOT_REAL_FOREGROUND'} + $work=[Windows.Forms.Screen]::FromHandle($settings).WorkingArea + if(!$work.Contains((Box $settings))){throw 'R146_SETTINGS_EXTENDS_BEHIND_TASKBAR'} + Assert-Visible $monitor $original ($mode+'_SETTINGS') $top + $evidence+=Capture $monitor ($mode+'_SETTINGS') + [void][R146Native]::SendMessage($settings,0x0010,[IntPtr]::Zero,[IntPtr]::Zero);Pump 500 + [R146Native]::Key(0x5B);Pump 1200 + $args=[object[]]@('','',[IntPtr]::Zero) + if(!$shellMethod.Invoke($null,$args)){throw 'R146_GENUINE_START_FOREGROUND_NOT_VERIFIED'} + Assert-Visible $monitor $original ($mode+'_START') $top + $evidence+=Capture $monitor ($mode+'_START') + [R146Native]::Key(0x1B);Pump 900 + Assert-Visible $monitor $original ($mode+'_AFTER_START') $top + # A real foreground fullscreen window covering this monitor and its taskbar. + $full=New-Object Windows.Forms.Form + $full.FormBorderStyle='None';$full.StartPosition='Manual';$full.Bounds=[Windows.Forms.Screen]::FromHandle($taskbar).Bounds + $full.TopMost=$true;$full.BackColor=[Drawing.Color]::FromArgb(24,40,56);$full.Text='R146 disposable fullscreen witness';$full.Show();Pump 300 + [R146Native]::Click(($full.Left+100),($full.Top+100),$false);Pump 1500 + if([R146Native]::GetForegroundWindow() -ne $full.Handle){throw 'R146_FULLSCREEN_NOT_FOREGROUND'} + if([R146Native]::IsWindowVisible($monitor)){throw 'R146_MONITOR_VISIBLE_WITHOUT_TASKBAR_IN_FULLSCREEN'} + $evidence+=@{State=$mode+'_FULLSCREEN';Hidden=$true;Foreground=$full.Handle.ToInt64()} + $full.Close();$full.Dispose();$full=$null;Pump 1500 + Assert-Visible $monitor $original ($mode+'_RETURN_FULLSCREEN') $top + $evidence+=Capture $monitor ($mode+'_RETURN_FULLSCREEN') + # Simulate taskbar visibility notification, only on disposable runner; always restore. + [void][R146Native]::ShowWindow($taskbar,0);$hidTaskbar=$true;Pump 1100 + if([R146Native]::IsWindowVisible($monitor)){ + # Do not suppress this independent FAIL gate. Collect the real HWND/shell + # visibility and optional screen-pixel evidence before the existing throw. + $forkHide=[ordered]@{ + Mode=$mode;MainPid=$process.Id; + TaskbarVisible=[R146Native]::IsWindowVisible($taskbar) + MonitorVisible=[R146Native]::IsWindowVisible($monitor) + TaskbarRect=@( (Box $taskbar).X,(Box $taskbar).Y,(Box $taskbar).Width,(Box $taskbar).Height ) + MonitorRect=@( (Box $monitor).X,(Box $monitor).Y,(Box $monitor).Width,(Box $monitor).Height ) + PopupOwner=[R146Native]::GetParent($monitor).ToInt64() + MemoryOwner=[R146Native]::GetWindowLongPtr($monitor,-8).ToInt64() + ProductSourceChanged=$false + OriginalHideInvariantRetained=$true + } + [IO.File]::WriteAllText((Join-Path $out ($mode+'_R163_TASKBAR_HIDE_FAILURE.json')), + ($forkHide|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + throw 'R146_MONITOR_IGNORES_TASKBAR_HIDE' + } + [void][R146Native]::ShowWindow($taskbar,4);$hidTaskbar=$false;Pump 1100 + Assert-Visible $monitor $original ($mode+'_TASKBAR_REAPPEARS') $top + $process.Refresh();$cpu0=$process.CPU;$clock=[Diagnostics.Stopwatch]::StartNew();Pump 5000;$clock.Stop();$process.Refresh() + $evidence+=@{State=$mode+'_CPU';Seconds=$clock.Elapsed.TotalSeconds;CpuSeconds=$process.CPU-$cpu0;PercentOfOneCore=100*($process.CPU-$cpu0)/$clock.Elapsed.TotalSeconds;Status='MEASURED_NOT_USER_PC_ACCEPTANCE'} + [void][R146Native]::SendMessage($monitor,0x0010,[IntPtr]::Zero,[IntPtr]::Zero) + if(!$process.WaitForExit(10000)){throw 'R146_OWN_TEST_PROCESS_DID_NOT_EXIT'};$process.Dispose();$process=$null + } + if($visualFailures.Count){throw ($visualFailures -join ';')} + $status='PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' +}catch{$failure=$_.Exception.ToString().Substring(0,[Math]::Min(3500,$_.Exception.ToString().Length))} +finally{ + if($hidTaskbar -and $taskbar -ne [IntPtr]::Zero){[void][R146Native]::ShowWindow($taskbar,4)} + [R146Native]::Key(0x1B) + if($null -ne $full){$full.Close();$full.Dispose()} + if($null -ne $process){try{if(!$process.HasExited){$process.Kill()}}catch{}} + try{ + $logs=@(Get-ChildItem (Join-Path $app 'Logs') -Filter 'tbme_csharp_*.log' -File -ErrorAction Stop) + foreach($log in $logs){ + Get-Content -LiteralPath $log.FullName -Tail 1600 | Select-String -Pattern 'PRESENTATION|GLASS_NATIVE|GLASS_COMPOSITION|TASKBAR_|VISIBILITY_WATCHDOG|LOW_PRESSURE|CONTEXT_MENU|SETTINGS|START_|RECOVERY' | ForEach-Object Line | Set-Content -LiteralPath (Join-Path $out ('DIAG_'+$log.Name)) -Encoding UTF8 + } + }catch{Write-Output ('R148_LOG_COLLECTION_WARNING '+$_.Exception.Message)} + $r=[ordered]@{Revision='R256';Status=$status;Phase=$phase;Failure=$failure;Host=$HostLabel;EventCommit=$env:GITHUB_SHA;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;Evidence=$evidence;TestedModes=@('plain1','plain10','glass0','opaque100');ExpectedTaskbarOwner=$taskbar.ToInt64();PersonalComputerTouched=$false;Proof='Installed candidate + native HWND + real screenshots + real input; not synthetic taskbar paint'} + [IO.File]::WriteAllText((Join-Path $out 'R256_NATIVE.json'),($r|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 12 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R146_NATIVE_FAIL '+$failure)} diff --git a/build/Test-NoPawnIO-OssRelease.ps1 b/build/Test-NoPawnIO-OssRelease.ps1 index 71853a27..ec582468 100644 --- a/build/Test-NoPawnIO-OssRelease.ps1 +++ b/build/Test-NoPawnIO-OssRelease.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $LegacyDir=Join-Path $Root 'artifacts\_r85_forbidden_pawnio_fixture' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' -$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' $Main=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' $LegacyVerify=Join-Path $LegacyDir 'legacy_verify.json' $Reader=Join-Path $PSScriptRoot 'Verify-Embedded-Main.ps1' if(Test-Path -LiteralPath $LegacyDir){throw 'R85 UNSAFE RERUN: FORBIDDEN FIXTURE ALREADY EXISTS'} @@ -56,7 +56,7 @@ try { Write-Output ('R85_OSS_NO_PROPRIETARY_BUNDLE_PASS productionEmbedded=False negativeLegacyResources='+$v.Resources+' negativeRejected=True') } finally { if($created -and (Test-Path -LiteralPath $LegacyDir)){ - $allow=@('TaskbarMonitorEnhanced_Setup_1.7.3.exe','TaskbarMonitorEnhanced_Setup_1.7.3.exe.config','legacy_verify.json') + $allow=@('TaskbarMonitorEnhanced_Setup_1.7.4.exe','TaskbarMonitorEnhanced_Setup_1.7.4.exe.config','legacy_verify.json') $objects=@(Get-ChildItem -LiteralPath $LegacyDir -Force) if(@($objects | Where-Object {$_.PSIsContainer -or $_.Name -notin $allow}).Count -ne 0){ throw ('R85 DEGRADED: UNKNOWN FILE IN OWNED STAGING; PRESERVED '+$LegacyDir) diff --git a/build/Test-R256-FreshSameVersion-Disposable.ps1 b/build/Test-R256-FreshSameVersion-Disposable.ps1 new file mode 100644 index 00000000..1671b268 --- /dev/null +++ b/build/Test-R256-FreshSameVersion-Disposable.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$CandidateDir, + [Parameter(Mandatory=$true)][ValidateSet("A","B")][string]$HostLabel +) +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" + +# Test-only, exact source, genuine compiled .NET 4.x Windows setup. +# No run on owner's PC, no driver install direct from this harness. +if($env:GITHUB_ACTIONS -cne "true" -or + $env:RUNNER_ENVIRONMENT -cne "github-hosted" -or + $env:RUNNER_OS -cne "Windows" -or + $env:GITHUB_REPOSITORY -cne "GOD13emad/TaskbarMonitorEnhanced" -or + ($env:GITHUB_REF -cne "refs/heads/release/r256-v174-fresh-idempotence" -and + $env:GITHUB_REF -cne "refs/heads/main") -or + $env:COMPUTERNAME -match "(?i)EMAD|ALI|LAPTOP" -or + -not [Environment]::UserInteractive) { + throw "R256_REFUSE_NON_DISPOSABLE_GITHUB_HOST" +} +$repo="GOD13emad/TaskbarMonitorEnhanced" +$app=Join-Path $env:LOCALAPPDATA "TaskbarMonitorEnhanced" +$reg="HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced" +$build=Get-Content -LiteralPath (Join-Path $CandidateDir "CANDIDATE.json") -Raw|ConvertFrom-Json -ErrorAction Stop +$setup=[IO.Path]::GetFullPath((Join-Path $CandidateDir "Setup/TaskbarMonitorEnhanced_Setup_1.7.4.exe")) +$out=Join-Path $env:RUNNER_TEMP ("TBME_R256_"+$HostLabel) +$receipt=Join-Path $out "R256_FRESH.json" +$folder=if($HostLabel -ceq "A"){[Environment]::GetFolderPath("ProgramFiles")}else{[Environment]::GetFolderPath("ProgramFilesX86")} +$legacy=Join-Path $folder "TaskbarMonitorEnhanced" +$marker=Join-Path $legacy "TaskbarMonitorEnhanced.exe" +$owned=$false;$expectedMarker="" +$status="FAIL";$phase="PREFLIGHT";$error="" +$installedExit=$null;$sameVersionExit=$null;$negativeExit=$null;$afterCollisionExit=$null +$original=@{};$originalRun="";$removed=false +function StopNow([string]$msg){throw ("R256_"+$msg)} +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return "MISSING"} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function RunCandidate([string]$arguments){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setup;$psi.Arguments=$arguments + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.WorkingDirectory=[IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($setup)) + $p=[Diagnostics.Process]::Start($psi) + if($null -eq $p){StopNow "CANDIDATE_SETUP_NOT_STARTED"} + try{ + if(!$p.WaitForExit(180000)){StopNow ("CANDIDATE_TIMEOUT_"+$p.Id)} + return [int]$p.ExitCode + } finally {$p.Dispose()} +} +function StartupValue{ + $key=Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -ErrorAction SilentlyContinue + if($null -eq $key){return "ABSENT"} + $v=$key.PSObject.Properties["TaskbarMonitorEnhanced"] + if($null -eq $v){return "ABSENT"} + return [string]$v.Value +} +function Snapshot{ + $paths=@("TaskbarMonitorEnhanced.exe","Uninstall.exe","config.json", + "config.json.bak","install_state.json","Source/TaskbarMonitorEnhanced.cs", + "Source/TaskbarGlass.cs","R256_PRESERVE_USER_CANARY.txt") + $m=@{} + foreach($path in $paths){$m[$path]=Hash (Join-Path $app $path)} + return $m +} +function AssertUnchanged($before,[string]$case){ + $now=Snapshot + if($now.Count -ne $before.Count){StopNow ("MAP_COUNT_"+$case)} + foreach($item in $before.Keys){ + if($before[$item] -cne $now[$item]){StopNow ("USER_OR_MANAGED_DRIFT_"+$case+"_"+$item)} + } + if((Get-ItemProperty $reg -ErrorAction Stop).DisplayVersion -cne "1.7.4"){ + StopNow ("REGISTERED_VERSION_DRIFT_"+$case) + } + if((StartupValue) -cne $originalRun){StopNow ("STARTUP_DRIFT_"+$case)} + if(Test-Path (Join-Path $app ".tbme-upgrade")){StopNow ("TRANSACTION_STAGE_LEFT_"+$case)} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow ("MAIN_PROCESS_RUNNING_"+$case)} +} +if($build.Commit -cne $env:GITHUB_SHA -or $build.Version -cne "1.7.4" -or + $build.Private -ne $true -or $build.NotForPublicInstallation -ne $true){ + StopNow "WRONG_PRIVATE_CANDIDATE_MANIFEST" +} +if((Hash $setup) -cne $build.SetupSHA256){StopNow "PRIVATE_COMPILED_SETUP_SHA_DRIFT"} +if((Test-Path $app) -or (Test-Path $reg) -or (Test-Path $legacy) -or + (Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + StopNow "GUEST_NOT_CLEAN" +} +[void](New-Item -Path $out -ItemType Directory -ErrorAction Stop) +try{ + $phase="FRESH_TEST_ONLY_SETUP" + $installedExit=RunCandidate "/install /quiet /desktop=0 /startup=0" + if($installedExit -ne 0){StopNow ("FRESH_INSTALL_EXIT_"+$installedExit)} + if((Hash (Join-Path $app "TaskbarMonitorEnhanced.exe")) -cne $build.MainSHA256 -or + (Hash (Join-Path $app "Uninstall.exe")) -cne $build.SetupSHA256){ + StopNow "FRESH_INSTALL_PRODUCT_SHA_CHANGED" + } + $source=Join-Path $app "Source/TaskbarMonitorEnhanced.cs" + $redundant=Join-Path $app "TaskbarMonitorEnhanced.cs" + if((Hash $source) -ceq "MISSING"){StopNow "SINGLE_MANAGED_SOURCE_NOT_INSTALLED"} + if(Test-Path $redundant){StopNow "REDUNDANT_ROOT_SOURCE_STILL_INSTALLED"} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow "FRESH_MAIN_ALREADY_RUNNING"} + $phase="CREATE_USER_OWNED_TEST_FIXTURE" + $config=Join-Path $app "config.json" + $backup=Join-Path $app "config.json.bak" + $canary=Join-Path $app "R256_PRESERVE_USER_CANARY.txt" + if(!(Test-Path $config)){ + [IO.File]::WriteAllText($config,'{"GlassMode":true,"Opacity":0,"OpacityWhenGlassOff":0.1,"R256UserFixture":"retain all bytes"}',[Text.UTF8Encoding]::new($false)) + } + if(!(Test-Path $backup)){ + [IO.File]::WriteAllText($backup,'{"R256Backup":"independent prior user config must remain SHA-exact"}',[Text.UTF8Encoding]::new($false)) + } + [IO.File]::WriteAllText($canary,"R256 synthetic guest user canary, no personal content",[Text.UTF8Encoding]::new($false)) + $originalRun=StartupValue + $original=Snapshot + if(@($original.Values|Where-Object {$_ -ceq "MISSING"}).Count){StopNow "EXPECTED_FRESH_FIXTURE_MISSING"} + + $phase="FIRST_SAME_VERSION_MAINTENANCE_IDEMPOTENCE" + $sameVersionExit=RunCandidate "/update /quiet" + if($sameVersionExit -ne 0){StopNow ("SAME_VERSION_MAINTENANCE_FAILED_"+$sameVersionExit)} + AssertUnchanged $original "FIRST_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "MAINTENANCE_RECREATED_REDUNDANT_SOURCE"} + + $phase="REJECT_DISPOSABLE_PROGRAMFILES_DUPLICATE" + if(Test-Path $legacy){StopNow "LEGACY_PATH_PREEXISTS"} + [void](New-Item $legacy -ItemType Directory -ErrorAction Stop) + $owned=$true + [IO.File]::WriteAllText($marker,("R256 inert duplicate executable marker "+$HostLabel),[Text.UTF8Encoding]::new($false)) + $expectedMarker=Hash $marker + if($expectedMarker -ceq "MISSING"){StopNow "LEGACY_INERT_MARKER_MISSING"} + $negativeExit=RunCandidate "/update /quiet" + if($negativeExit -ne 34){StopNow ("PROGRAMFILES_DUPLICATE_NOT_EXACTLY_REFUSED_"+$negativeExit)} + if((Hash $marker) -cne $expectedMarker){StopNow "INERT_MARKER_MODIFIED"} + AssertUnchanged $original "PROGRAMFILES_CONFLICT_REFUSAL" + if((Hash $marker) -cne $expectedMarker){StopNow "MARKER_CHANGED_BEFORE_REMOVAL"} + Remove-Item -LiteralPath $marker -Force -ErrorAction Stop + if(@(Get-ChildItem -LiteralPath $legacy -Force).Count){StopNow "LEGACY_DIR_CONTENT_NOT_OWNED"} + Remove-Item -LiteralPath $legacy -Force -ErrorAction Stop + $owned=$false + if(Test-Path $legacy){StopNow "LEGACY_DIR_NOT_REMOVED"} + $removed=$true + + $phase="SECOND_IDEMPOTENT_UPDATE_AFTER_CONFLICT_REMOVAL" + $afterCollisionExit=RunCandidate "/update /quiet" + if($afterCollisionExit -ne 0){StopNow ("AFTER_COLLISION_MAINTENANCE_FAILED_"+$afterCollisionExit)} + AssertUnchanged $original "SECOND_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "ROOT_REDUNDANCY_RETURNED"} + $status="PASS_REAL_GUEST_FRESH_R256_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT" +}catch{ + $error=$_.Exception.GetType().Name+": "+$_.Exception.Message +}finally{ + if($owned -and (Test-Path $marker) -and (Hash $marker) -ceq $expectedMarker){ + Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue + } + if($owned -and (Test-Path $legacy) -and @((Get-ChildItem -LiteralPath $legacy -Force -ErrorAction SilentlyContinue)).Count -eq 0){ + Remove-Item -LiteralPath $legacy -Force -ErrorAction SilentlyContinue + } + $e=[ordered]@{ + Revision="R256";Status=$status;Phase=$phase;Failure=$error;Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;TestOnlyCandidate=$true;PublicSetupChanged=$false + PersonalComputerTouched=$false;ReleasedV173Untouched=$true + CandidateMainSHA256=$build.MainSHA256;CandidateSetupSHA256=$build.SetupSHA256 + FreshInstallExit=$installedExit + SingleManagedSourceWithoutRootDuplicate=(!(Test-Path (Join-Path $app "TaskbarMonitorEnhanced.cs"))) + FirstSameVersionMaintenanceExit=$sameVersionExit + NegativeProgramFilesCollisionExit=$negativeExit + SecondSameVersionMaintenanceExit=$afterCollisionExit + GuestOwnedLegacyFixtureRemoved=(!(Test-Path $legacy)) + ProtectedCanaryAndConfigPreserved=($status -like "PASS_*") + RealHKLMRegistryOrCrossUserAcceptance=$false + NoPhysicalPowerCutOrExternalSecurityClaim=$true + UTC=[DateTime]::UtcNow.ToString("o") + } + [IO.File]::WriteAllText($receipt,($e|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($e|ConvertTo-Json -Compress -Depth 8) +} +if($status -notlike "PASS_*"){throw ("R256_REAL_GUEST_NOT_ACCEPTED "+$phase+" "+$error)} diff --git a/build/Test-Release-Gates.ps1 b/build/Test-Release-Gates.ps1 index dc1d1c59..5b0ac3c6 100644 --- a/build/Test-Release-Gates.ps1 +++ b/build/Test-Release-Gates.ps1 @@ -44,7 +44,7 @@ if($null -ne $manifest) { 'TaskbarMonitorEnhanced.exe.config'='build\_out\App\TaskbarMonitorEnhanced.exe.config'; 'TaskbarMonitorSensorBroker.exe'='build\_out\Broker\TaskbarMonitorSensorBroker.exe'; 'TaskbarMonitorSensorSupervisor.exe'='build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe'; - 'TaskbarMonitorEnhanced_Setup_1.7.3.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' + 'TaskbarMonitorEnhanced_Setup_1.7.4.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' } $realHashesPass=$true foreach($item in $outputs){ @@ -168,7 +168,7 @@ Add-Gate 'G12_START_SEARCH_24_REAL_NATIVE_INK_AND_EXACT_RETURN' $startFloatPass # R75: G02 hashes deterministic UNSIGNED builder outputs. G14 and G15 must use # separate SIGNED release staging, since Authenticode modifies executable SHA256. $signedMainExe=Join-Path $root 'artifacts\signed\Main\TaskbarMonitorEnhanced.exe' -$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' $expectedSignedMainDigest='' if((Test-Path -LiteralPath $signedMainExe -PathType Leaf) -and (Test-Path -LiteralPath $signedSetupExe -PathType Leaf)){ @@ -233,7 +233,7 @@ Add-Gate 'G14_INSTALLED_TASKBAR_EXPLORER_SENSORS_ROLLBACK' $installedPass $insta $setupExe=$signedSetupExe $mainExe=$signedMainExe # Keep original _out builder outputs immutable so G02 exact source checks remain reproducible. -$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' # R73: G15 must prove actual SignPath Foundation publisher, valid timestamps, matched # exact file/product versions AND live provider-origin/payload evidence. Never trust # self-declared "approved" JSON, a test certificate or token-less synthetic output. @@ -253,10 +253,10 @@ if((Test-Path -LiteralPath $setupExe -PathType Leaf) -and (Test-Path -LiteralPat $sameCert=$null -ne $setupCert -and $null -ne $mainCert -and $setupCert.Thumbprint -ceq $mainCert.Thumbprint $exactMetadata=$setupInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and $mainInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and - $setupInfo.ProductVersion -ceq '1.7.3+r243' -and - $mainInfo.ProductVersion -ceq '1.7.3+r243' -and - $setupInfo.FileVersion -ceq '1.7.3.0' -and - $mainInfo.FileVersion -ceq '1.7.3.0' + $setupInfo.ProductVersion -ceq '1.7.4+r256' -and + $mainInfo.ProductVersion -ceq '1.7.4+r256' -and + $setupInfo.FileVersion -ceq '1.7.4.0' -and + $mainInfo.FileVersion -ceq '1.7.4.0' $cryptoPass=([string]$mainSig.Status -ceq 'Valid') -and ([string]$setupSig.Status -ceq 'Valid') -and $mainName -ceq 'SignPath Foundation' -and diff --git a/build/Test-Signed-Payload-Contract.ps1 b/build/Test-Signed-Payload-Contract.ps1 index d453e118..058e5f41 100644 --- a/build/Test-Signed-Payload-Contract.ps1 +++ b/build/Test-Signed-Payload-Contract.ps1 @@ -4,11 +4,11 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $original=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' $inputDir=Join-Path $Root 'artifacts\_r80_contract_input' $inputFile=Join-Path $inputDir 'TaskbarMonitorEnhanced.exe' $stageDir=Join-Path $Root 'artifacts\_r80_contract_only' -$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' $originalMainSHA=(Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash $originalSetupSHA=(Get-FileHash -LiteralPath $setupOrig -Algorithm SHA256).Hash if((Test-Path -LiteralPath $inputDir) -or (Test-Path -LiteralPath $stageDir)){ @@ -70,7 +70,7 @@ try{ # Cleanup exact owned files only. Unknown files survive; never delete personal state. foreach($d in @($stageDir,$inputDir)){ if(Test-Path -LiteralPath $d){ - $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.3.exe','TaskbarMonitorEnhanced_Setup_1.7.3.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} + $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.4.exe','TaskbarMonitorEnhanced_Setup_1.7.4.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} else{@('TaskbarMonitorEnhanced.exe')} $found=@(Get-ChildItem -LiteralPath $d -Force) if(@($found|Where-Object {$_.PSIsContainer -or $_.Name -notin $allowed}).Count -gt 0){ diff --git a/build/Test-Signed-Staging.ps1 b/build/Test-Signed-Staging.ps1 index 458cd656..b01e91e6 100644 --- a/build/Test-Signed-Staging.ps1 +++ b/build/Test-Signed-Staging.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $stage=Join-Path $root 'artifacts\signed' $origMain=Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' $targetMain=Join-Path $stage 'Main\TaskbarMonitorEnhanced.exe' -$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.3.exe' +$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' $created=@() if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $origMain -PathType Leaf) -or @@ -26,7 +26,7 @@ if(!(Test-Path -LiteralPath $manifest -PathType Leaf)){throw 'R75 MANIFEST REQUI $m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json $mapping=@{ 'TaskbarMonitorEnhanced.exe'=$hashMain - 'TaskbarMonitorEnhanced_Setup_1.7.3.exe'=$hashSetup + 'TaskbarMonitorEnhanced_Setup_1.7.4.exe'=$hashSetup } foreach($entry in $m.Outputs){ if($mapping.ContainsKey([string]$entry.Name)){ diff --git a/build/Test-Upgrade173-R256.ps1 b/build/Test-Upgrade173-R256.ps1 new file mode 100644 index 00000000..aa261a59 --- /dev/null +++ b/build/Test-Upgrade173-R256.ps1 @@ -0,0 +1,227 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Genuine product install/update is permitted ONLY on a fresh disposable hosted VM. +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R256_REFUSE_PERSONAL_OR_NONINTERACTIVE_HOST'} +Add-Type -AssemblyName System.Windows.Forms +# R156: keep genuine CLR null; PS5.1 string binder converts $null to empty string. +Add-Type -TypeDefinition 'using System.IO; public static class R156FileBridge { public static void ReplaceWithoutBackup(string source,string destination){File.Replace(source,destination,null);} }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R256_UPGRADE_'+$HostLabel) +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $reg) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R256_NOT_FRESH_DISPOSABLE_HOST'} +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.4' -or $manifest.Private -ne $true -or (Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R256_NOT_EXACT_VERSIONED_CANDIDATE'} +New-Item -ItemType Directory -Path $out|Out-Null +$phase='SOURCE_DOWNLOAD';$status='FAIL';$errorText='';$negative=0;$rollback=$false;$preserved=$false;$idempotent=$false;$downgrade=$false;$chain=@();$beforeProtected=@{};$evidence=@();$oldCpuR170=$null;$newCpuR170=$null +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Run-Exact([string]$path,[string]$Arguments,[int]$timeout=190000){ + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$path;$si.Arguments=$Arguments;$si.UseShellExecute=$false;$si.WorkingDirectory=(Split-Path $path -Parent) + $p=[Diagnostics.Process]::Start($si) + if($null -eq $p){throw 'R256_SETUP_PROCESS_NOT_CREATED'} + try{if(!$p.WaitForExit($timeout)){throw ('R256_SETUP_PROCESS_TIMEOUT PID='+$p.Id)};return [int]$p.ExitCode}finally{$p.Dispose()} +} +function StartupValue(){ + $k=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $k){return 'MISSING'};$p=$k.PSObject.Properties['TaskbarMonitorEnhanced'];if($null -eq $p){return 'MISSING'};return [string]$p.Value +} +function Snapshot(){ + $map=@{} + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v173-managed.json') -Raw|ConvertFrom-Json + $paths=@($inventory.Rows|ForEach-Object Path)+@('Docs/RELEASE_NOTES_v1.7.4.md','install_state.json','config.json','config.json.bak','R256_USER_OWNED.txt','SensorBackend/LibreHardwareMonitor-0.9.6/LibreHardwareMonitorLib.dll') + # Exclude volatile sensor telemetry/log files, not owned by installer and free to change independently. + foreach($rel in @($paths|Select-Object -Unique)){$map[$rel]=Hash (Join-Path $app $rel)} + $map['@RegistryVersion']=[string](Get-ItemProperty $reg).DisplayVersion + $map['@Startup']=StartupValue + return $map +} +function Equal-Snapshot($a,$b,[string]$reason){ + if($a.Count -ne $b.Count){throw ('R256_SNAPSHOT_COUNT_CHANGED '+$reason)} + foreach($k in $a.Keys){if(!$b.ContainsKey($k) -or $a[$k] -cne $b[$k]){throw ('R256_SNAPSHOT_CHANGED '+$reason+' '+$k)}} +} +# R170: identical owner-independent CPU sampling on BOTH versions on the SAME disposable +# GitHub-hosted Windows worker. Never invoked on personal/unknown hosts. +function Measure-InstalledCpuR170([string]$version,[string]$expectedSha){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or ![Environment]::UserInteractive){ + throw 'R170_CPU_PROBE_REFUSES_NON_DISPOSABLE_MACHINE' + } + $bin=Join-Path $app 'TaskbarMonitorEnhanced.exe' + $configFile=Join-Path $app 'config.json' + $backupFile=Join-Path $app 'config.json.bak' + if((Hash $bin) -cne $expectedSha){throw 'R170_CANDIDATE_EXE_SHA_MISMATCH'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_BEFORE_SAMPLE_PRODUCT_PROCESS_ALREADY_RUNNING' + } + $cfgBytes=[IO.File]::ReadAllBytes($configFile) + $bakBytes=[IO.File]::ReadAllBytes($backupFile) + $cfgSha=Hash $configFile;$bakSha=Hash $backupFile + $probe=$null;$sample=$null;$forcedStop=$false + try{ + $probe=Start-Process -FilePath $bin -WorkingDirectory $app -PassThru -ErrorAction Stop + Start-Sleep -Seconds 8 + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_WARMUP_'+$version)} + $cpu0=$probe.TotalProcessorTime.TotalSeconds + $timer=[Diagnostics.Stopwatch]::StartNew() + Start-Sleep -Seconds 8 + $timer.Stop() + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_SAMPLE_'+$version)} + $cpuSeconds=$probe.TotalProcessorTime.TotalSeconds-$cpu0 + $elapsed=$timer.Elapsed.TotalSeconds + $pct=100.0*$cpuSeconds/$elapsed + if($elapsed -lt 7.5 -or $elapsed -gt 12.5 -or $cpuSeconds -lt 0 -or + $pct -gt 500 -or [Double]::IsNaN($pct)){ + throw ('R170_INVALID_CPU_SAMPLE_'+$version) + } + $sample=[ordered]@{Version=$version;Host=$HostLabel;MainSHA256=$expectedSha; + CpuSeconds=$cpuSeconds;ElapsedSeconds=$elapsed;PercentOfOneCore=$pct; + ProcessId=$probe.Id;WarmupSeconds=8;Source='HOSTED_PROCESS_CPU_NOT_PERSONAL'; + ConfigurationBeforeSHA=$cfgSha;BackupBeforeSHA=$bakSha} + } + finally{ + if($null -ne $probe){ + try{ + $probe.Refresh() + if(!$probe.HasExited){ + try{[void]$probe.CloseMainWindow()}catch{} + if(!$probe.WaitForExit(3500)){ + $forcedStop=$true # CI-only process launched above, on disposable runner. + $probe.Kill() + if(!$probe.WaitForExit(12000)){throw 'R170_DISPOSABLE_PROCESS_NOT_STOPPED'} + } + } + }finally{$probe.Dispose()} + } + # Restore the exact isolated test fixture if application startup normalizes it. + if((Hash $configFile) -cne $cfgSha){ + [IO.File]::WriteAllBytes($configFile,$cfgBytes) + if((Hash $configFile) -cne $cfgSha){throw 'R170_TEST_CONFIG_RESTORE_FAILED'} + } + if((Hash $backupFile) -cne $bakSha){ + [IO.File]::WriteAllBytes($backupFile,$bakBytes) + if((Hash $backupFile) -cne $bakSha){throw 'R170_TEST_BACKUP_RESTORE_FAILED'} + } + } + if($null -eq $sample){throw 'R170_NO_HOSTED_CPU_SAMPLE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_LEFT_TEST_PROCESS_RUNNING' + } + $sample['DisposableOnlyForcedStop']=$forcedStop + $sample['ConfigRestoredToOriginalSHA']=((Hash $configFile) -ceq $cfgSha) + $sample['BackupRestoredToOriginalSHA']=((Hash $backupFile) -ceq $bakSha) + return $sample +} +try{ + & gh release download v1.7.3 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.3.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R256_OFFICIAL172_DOWNLOAD_FAILED'} + $priorSetup=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' + if((Hash $priorSetup) -cne '9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B'){throw 'R256_PREVIOUS_SETUP_NOT_IMMUTABLE_OFFICIAL'} + $phase='CREATE_GENUINE_V172_PREDECESSOR' + if($HostLabel -ceq 'B'){ + & gh release download v1.7.1 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.1.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R256_OFFICIAL171_DOWNLOAD_FAILED'} + $older=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.1.exe' + if((Hash $older) -cne 'ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89'){throw 'R256_PREVIOUS_V171_SETUP_CHANGED'} + if((Run-Exact $older '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R256_GENUINE_V171_INSTALL_FAILED'} + if((Run-Exact $priorSetup '/update /quiet') -ne 0){throw 'R256_GENUINE_V171_TO_V173_FAILED'} + $chain=@('1.7.1','1.7.3') + }else{ + if((Run-Exact $priorSetup '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R256_GENUINE_V173_INSTALL_FAILED'} + $chain=@('1.7.3') + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne '6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C' -or (Get-ItemProperty $reg).DisplayVersion -cne '1.7.3'){throw 'R256_OFFICIAL_V173_INSTALLED_IDENTITY_NOT_PROVEN'} + # Distinguish genuine published v1.7.3 fresh versus upgraded old installs. + $extra=Join-Path $app 'TaskbarMonitorEnhanced.cs' + $source=Join-Path $app 'Source/TaskbarMonitorEnhanced.cs' + if($HostLabel -ceq 'A'){ + if((Hash $extra) -cne '5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32' -or + (Hash $source) -cne (Hash $extra)){throw 'R256_OFFICIAL_V173_FRESH_DUPLICATE_NOT_EXACT'} + }else{ + if(Test-Path $extra){throw 'R256_OFFICIAL_V173_UPGRADED_ROOT_SHOULD_BE_ABSENT'} + } + $phase='KNOWN_USER_DATA_FIXTURE' + $config=Join-Path $app 'config.json';$backup=Join-Path $app 'config.json.bak';$state=Join-Path $app 'install_state.json' + $fixture='{"Theme":"Retro Terminal","Opacity":0.00,"GlassMode":true,"OpacityWhenGlassOff":0.10,"ConfigSchemaVersion":8,"StartWithWindows":false,"UpdateIntervalMs":1000,"ShowSparklines":true,"AutoCheckUpdates":false,"R256OwnerSentinel":"preserve user literal 1.7.3"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"independent":"prior backup must not be overwritten"}',[Text.UTF8Encoding]::new($false)) + $userFile=Join-Path $app 'R256_USER_OWNED.txt';[IO.File]::WriteAllText($userFile,'User data, not installer-managed.',[Text.UTF8Encoding]::new($false)) + $sourceText=[IO.File]::ReadAllText($state) + $expectedState=$sourceText.Replace('"Version": "1.7.3"','"Version": "1.7.4"').Replace('"PublicVersion": "1.7.3"','"PublicVersion": "1.7.4"') + if($expectedState -ceq $sourceText){throw 'R256_ACTUAL_STATE_FORMAT_NOT_RECOGNIZED'} + foreach($path in @($config,$backup,$userFile,(Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'))){if((Hash $path) -ceq 'MISSING'){throw ('R256_PROTECTED_INPUT_MISSING '+$path)};$beforeProtected[$path]=Hash $path} + $startupBefore=StartupValue + $phase='SAME_HOST_171_CPU_BASELINE' + $oldCpuR170=Measure-InstalledCpuR170 '1.7.3' '6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C' + if($null -eq $oldCpuR170){throw 'R170_MISSING_OLD_BASELINE'} + $phase='REJECT_EDITED_MANAGED_SOURCE' + $managed=Join-Path $app 'Source\TaskbarGlass.cs';$original=[IO.File]::ReadAllBytes($managed) + $changed=[byte[]]($original+[Text.Encoding]::UTF8.GetBytes("`n// deliberate owner edit: updater must refuse`n")) + [IO.File]::WriteAllBytes($managed,$changed);$editedHash=Hash $managed + $snap=Snapshot + $code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R256_EDITED_SOURCE_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'EDITED_SOURCE_REFUSAL' + if(Test-Path (Join-Path $app '.tbme-upgrade')){throw 'R256_REFUSAL_LEFT_STAGE'} + if((Hash $managed) -cne $editedHash){throw 'R256_CONCURRENT_EDIT_CANNOT_RESTORE_FIXTURE'} + [IO.File]::WriteAllBytes($managed,$original);$negative++ + $phase='REJECT_UNKNOWN_INSTALLED_VERSION' + [IO.File]::WriteAllText($state,$sourceText.Replace('1.7.3','9.9.9'),[Text.UTF8Encoding]::new($false)) + $snap=Snapshot;$code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R256_UNKNOWN_VERSION_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'UNKNOWN_VERSION_REFUSAL' + [IO.File]::WriteAllText($state,$sourceText,[Text.UTF8Encoding]::new($false));$negative++ + $phase='REAL_ROLLBACK_ENGINE_AFTER_SIMULATED_PARTIAL_COMMIT' + # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. + # This is explicit simulated interruption, not a physical outage or real process-kill witness. + $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.3','OFFICIAL_STABLE_V173')) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) + # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) + $beforeRollback=Snapshot + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.3','OFFICIAL_STABLE_V173')) + $stage=Join-Path $app '.tbme-upgrade' + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $stage 'new') $rel;$live=Join-Path $app $rel + [R156FileBridge]::ReplaceWithoutBackup($staged,$live) + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256){throw 'R256_PARTIAL_COMMIT_NOT_REAL'} + [void]$t.GetMethod('RecoverUpgradeStageIfAny',$f).Invoke($null,[object[]]@()) + if(Test-Path $stage){throw 'R256_ROLLBACK_STAGE_NOT_CLEARED'} + Equal-Snapshot $beforeRollback (Snapshot) 'REAL_ROLLBACK_RESTORATION';$rollback=$true + $phase='REAL_CLI_171_TO_172' + $code=Run-Exact $setup '/update /quiet' + if($code -ne 0){throw ('R256_REAL_UPGRADE_FAILED EXIT='+$code)} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256 -or (Hash (Join-Path $app 'Uninstall.exe')) -cne $manifest.SetupSHA256){throw 'R256_FINAL_BINARY_HASH_MISMATCH'} + if((Get-ItemProperty $reg).DisplayVersion -cne '1.7.4' -or [IO.File]::ReadAllText($state) -cne $expectedState){throw 'R256_STATE_OR_REGISTRY_NOT_EXACT_174'} + foreach($path in $beforeProtected.Keys){if((Hash $path) -cne $beforeProtected[$path]){throw ('R256_USER_DATA_CHANGED '+$path)}} + if((StartupValue) -cne $startupBefore){throw 'R256_STARTUP_PREFERENCE_CHANGED'} + if(Test-Path $stage){throw 'R256_STAGE_LEFT_AFTER_SUCCESS'} + if(!(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.4.md')) -or (Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.3.md'))){throw 'R256_RELEASE_NOTES_NOT_MIGRATED'} + $registrations=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($registrations.Count -ne 1 -or (Get-ItemProperty $reg).InstallLocation -cne $app){throw 'R256_DUPLICATE_OR_WRONG_ROOT'} + $preserved=$true;$chain+=@('1.7.4') + $phase='SAME_HOST_172_CPU_COMPARATOR' + $newCpuR170=Measure-InstalledCpuR170 '1.7.4' $manifest.MainSHA256 + if($null -eq $newCpuR170 -or $oldCpuR170.Host -cne $newCpuR170.Host){throw 'R170_SAME_HOST_CPU_NOT_VERIFIED'} + $phase='IDEMPOTENCE_AND_DOWNGRADE_REFUSAL' + $after=Snapshot + if((Run-Exact $setup '/update /quiet') -ne 0){throw 'R256_REPEATED_IDENTICAL_UPGRADE_FAILED'} + Equal-Snapshot $after (Snapshot) 'IDENTICAL_REINSTALL';$idempotent=$true + if((Run-Exact $priorSetup '/update /quiet') -eq 0){throw 'R256_OLD_INSTALLER_DOWNGRADE_ACCEPTED'} + Equal-Snapshot $after (Snapshot) 'DOWNGRADE_REFUSAL';$downgrade=$true + $status='PASS_GENUINE_173_TO_174_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' +}catch{$errorText=$_.Exception.ToString()} +finally{ + $r=[ordered]@{Revision='R256';Status=$status;Phase=$phase;Failure=$errorText;Host=$HostLabel;Commit=$env:GITHUB_SHA;SourceChain=$chain;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;RejectedUnsafeUpdates=$negative;RealProductionRollbackAfterSimulatedPartialCommit=$rollback;PhysicalPowerCutClaimed=$false;RealProcessKillClaimed=$false;ProtectedUserFilesPreserved=$preserved;IdenticalSetupIdempotent=$idempotent;DowngradeRefused=$downgrade;PersonalComputerTouched=$false;ProtectedSHA256=$beforeProtected;R170SameHostedWindowsOldCpu=$oldCpuR170;R170SameHostedWindowsNewCpu=$newCpuR170;R170SameHostCpuMeasured=($null -ne $oldCpuR170 -and $null -ne $newCpuR170);R170PersonalCpuAcceptance=$false} + [IO.File]::WriteAllText((Join-Path $out 'R256_UPGRADE.json'),($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 8 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R256_UPGRADE_ACCEPTANCE_FAILED '+$phase+' '+$errorText)} diff --git a/build/Test-UpgradeContract-R256.ps1 b/build/Test-UpgradeContract-R256.ps1 new file mode 100644 index 00000000..5423c400 --- /dev/null +++ b/build/Test-UpgradeContract-R256.ps1 @@ -0,0 +1,64 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$SetupPath,[Parameter(Mandatory=$true)][string]$OutDir) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R256_FRAMEWORK_REFLECTION_REQUIRED'} +if(Test-Path $OutDir){throw 'R256_CONTRACT_OUTPUT_EXISTS'} +$full=[IO.Path]::GetFullPath($SetupPath) +if([IO.Path]::GetFileName($full) -cne 'TaskbarMonitorEnhanced_Setup_1.7.4.exe'){throw 'R256_WRONG_SETUP_NAME'} +$a=[Reflection.Assembly]::LoadFile($full) +$t=$a.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,Public,NonPublic' +$newState=$t.GetMethod('NewInstallState',$f) +$legacy=$t.GetMethod('LegacyExpected',$f) +$optional=$t.GetMethod('IsOptionalStableLegacyRoot',$f) +if($t.GetField('Version',$f).GetRawConstantValue() -cne '1.7.4'){throw 'R256_COMPILED_VERSION_NOT_172'} +$cases=0;$negative=0 +foreach($v in @('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3')){ + $before='{ "ProductIdentity":"LOCKED", "Version":"'+$v+'", "PublicVersion":"'+$v+'", "Unrelated":"preserve literal 1.7.1", "Custom":{"x":17}, "ProtocolVersion":"1.1.2+r21" }' + $want=$before.Replace(('"Version":"'+$v+'"'),'"Version":"1.7.4"').Replace(('"PublicVersion":"'+$v+'"'),'"PublicVersion":"1.7.4"') + $got=[string]$newState.Invoke($null,[object[]]@($before)) + if($got -cne $want){throw ('R256_STATE_NOT_EXACTLY_TWO_VERSION_FIELDS '+$v)} + $cases++ +} +$bad=@( + '{"Version":"1.7.1","PublicVersion":"1.7.0"}', + '{"Version":"1.7.1","Version":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"1.7.1","PublicVersion":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"0.9.0","PublicVersion":"0.9.0"}', + '{"Version":"1.7.5","PublicVersion":"1.7.5"}', + '{"Version":"1.7.1"}', + '{"PublicVersion":"1.7.1"}' +) +foreach($s in $bad){ + $refused=$false + try{[void]$newState.Invoke($null,[object[]]@($s))}catch{$refused=$true} + if(!$refused){throw ('R256_INVALID_STATE_ACCEPTED '+$s)};$negative++ +} +$inventory=Get-Content (Join-Path $PSScriptRoot 'official-v173-managed.json') -Raw|ConvertFrom-Json +$dict=$legacy.Invoke($null,[object[]]@('1.7.3','OFFICIAL_STABLE_V173')) +if($dict.Count -ne 23 -or $inventory.ManagedCount -ne 23){throw 'R256_171_EXACT_MANAGED_COUNT'} +foreach($row in $inventory.Rows){if(!$dict.ContainsKey($row.Path) -or $dict[$row.Path] -cne $row.SHA256){throw ('R256_COMPILED_MANAGED_SHA_DRIFT '+$row.Path)};$cases++} +foreach($pair in @(@('1.7.1','OFFICIAL_STABLE_V170'),@('1.7.1','PREVIEW1'),@('1.7.4','OFFICIAL_STABLE_V171'),@('1.7.1','UNKNOWN'))){ + $refused=$false;try{[void]$legacy.Invoke($null,[object[]]@($pair[0],$pair[1]))}catch{$refused=$true} + if(!$refused){throw 'R256_WRONG_ANCESTOR_CHANNEL_ACCEPTED'};$negative++ +} +foreach($channel in @('OFFICIAL_STABLE_V170','OFFICIAL_STABLE_V171','OFFICIAL_STABLE_V172','OFFICIAL_STABLE_V173')){ + if(!$optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs',$channel))){throw 'R256_INHERITED_ROOT_DUPLICATE_REQUIRED'};$cases++ + foreach($path in @('Source/TaskbarMonitorEnhanced.cs','TaskbarMonitorEnhanced.exe','config.json')){ + if($optional.Invoke($null,[object[]]@($path,$channel))){throw ('R256_CRITICAL_SOURCE_MADE_OPTIONAL '+$path)};$negative++ + } +} +$targets=$t.GetField('UpgradeTargets',$f).GetValue($null) +$removed=0;$names=@();$if=[Reflection.BindingFlags]'Instance,NonPublic,Public' +foreach($target in $targets){ + $path=[string]$target.GetType().GetField('Rel',$if).GetValue($target) + $resource=$target.GetType().GetField('ResourceName',$if).GetValue($target) + if($names -ccontains $path -or $path -match '^(config\.json|SensorBackend/|Backups/|Data/)'){throw ('R256_UPGRADE_TOUCHES_PROTECTED_OR_DUPLICATE '+$path)} + if($null -eq $resource){$removed++};$names+=@($path);$cases++ +} +if($removed -ne 6 -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.4.md' -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.1.md'){throw 'R256_VERSIONED_NOTES_TRANSACTION_MAP'} +New-Item -ItemType Directory -Path $OutDir|Out-Null +$r=[ordered]@{Revision='R256';Status='PASS_COMPILED_174_UPGRADE_CONTRACT';SetupSHA256=(Get-FileHash $full -Algorithm SHA256).Hash;PositiveCases=$cases;RejectedCases=$negative;Official173ManagedFiles=23;PreviousVersions=@('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3');CurrentVersion='1.7.4';NoAppEntryPointInvoked=$true;NoPersonalFilesOrRegistryMutated=$true;ActualInstallAcceptance='SEPARATE_HOSTED_VM_GATE'} +[IO.File]::WriteAllText((Join-Path $OutDir 'R256_CONTRACT.json'),($r|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +Write-Output ($r|ConvertTo-Json -Depth 6 -Compress) diff --git a/build/Verify-Embedded-Main.ps1 b/build/Verify-Embedded-Main.ps1 index 97af3362..9709346c 100644 --- a/build/Verify-Embedded-Main.ps1 +++ b/build/Verify-Embedded-Main.ps1 @@ -1,4 +1,4 @@ -# R127: retain R80 independent read-only verification and accept versioned 1.7.3 patch filename. +# R127: retain R80 independent read-only verification and accept versioned 1.7.4 patch filename. # R80: independent read-only verification that a Setup's exact embedded Main PE bytes # equal the SHA256 of an independently pinned, already-signed Main distribution file. [CmdletBinding()] @@ -11,7 +11,7 @@ $ErrorActionPreference='Stop' if($ExpectedMainSHA256 -notmatch '^[0-9a-fA-F]{64}$'){throw 'R80 EXPECTED MAIN SHA NOT 64 HEX'} $path=[IO.Path]::GetFullPath($SetupExe) if(!(Test-Path -LiteralPath $path -PathType Leaf) -or - ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.3.exe'))){ + ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.4.exe'))){ throw 'R80 SETUP EXE MISSING OR WRONG FILE NAME' } $setupHash=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash diff --git a/build/Verify-Release-Metadata-R256.ps1 b/build/Verify-Release-Metadata-R256.ps1 new file mode 100644 index 00000000..d01e8789 --- /dev/null +++ b/build/Verify-Release-Metadata-R256.ps1 @@ -0,0 +1,105 @@ +# R256 independent, read-only authority check for the versioned 1.7.4 +# release Build Manifest -> deterministic binary files -> SBOM 2.3. +[CmdletBinding()] +param( + [string]$Root=(Join-Path $PSScriptRoot '..'), + [switch]$SelfTest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($Root) +$manifestPath=Join-Path $root 'artifacts\BUILD_MANIFEST.json' +$sbomPath=Join-Path $root 'artifacts\SBOM.spdx.json' +$setupVerifyPath=Join-Path $root 'build\_out\setup_verify.json' +$source=(Join-Path $root 'src\TaskbarMonitorEnhanced.cs') +foreach($f in @($manifestPath,$sbomPath,$setupVerifyPath,$source)){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){throw ('R256_REQUIRED_EVIDENCE_MISSING '+$f)} +} +$m=Get-Content $manifestPath -Raw|ConvertFrom-Json +$b=Get-Content $sbomPath -Raw|ConvertFrom-Json +$v=Get-Content $setupVerifyPath -Raw|ConvertFrom-Json +$commit=(& git -C $root rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R256_CURRENT_HEAD_NOT_EXACT'} +function Issues($manifest,$sbom,$setup,$sha){ + $fail=[Collections.Generic.List[string]]::new() + if($manifest.Version -cne '1.7.4' -or $manifest.Build -cne 'V1_7_4_R256_FRESH_MAINTENANCE'){ + $fail.Add('WRONG_PUBLIC_BUILD_VERSION') + } + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.4' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.4/'+$sha)){ + $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') + } + if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or + $sbom.packages[0].versionInfo -cne '1.7.4'){ + $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') + } + $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', + 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', + 'TaskbarMonitorEnhanced_Setup_1.7.4.exe') + $found=@($manifest.Outputs|ForEach-Object {$_.Name}) + if(@($manifest.Outputs).Count -ne 5 -or + @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ + $fail.Add('MANIFEST_EXACT_FIVE_OUTPUTS_MISMATCH') + }else{ + foreach($row in $manifest.Outputs){ + $name=[string]$row.Name + $rel=switch($name){ + 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} + 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} + default {'build\_out\App\'+$name} + } + $path=Join-Path $root $rel + if(!(Test-Path $path -PathType Leaf) -or + (Get-Item $path).Length -ne [int64]$row.Bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash -cne [string]$row.SHA256){ + $fail.Add('ACTUAL_OUTPUT_SHA_OR_SIZE_MISMATCH_'+$name) + } + $frow=@($sbom.files|Where-Object fileName -ceq $name) + if($frow.Count -ne 1 -or @($frow[0].checksums).Count -ne 1 -or + $frow[0].checksums[0].algorithm -cne 'SHA256' -or + $frow[0].checksums[0].checksumValue -cne ([string]$row.SHA256).ToLowerInvariant()){ + $fail.Add('SBOM_OUTPUT_SHA_MISMATCH_'+$name) + } + } + } + if($setup.Status -cne 'PASS' -or [int]$setup.Resources -ne 25 -or + $setup.PawnIOBundled -ne $false){ + $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') + } + $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe') + if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.4')){ + $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') + } + return @($fail.ToArray()) +} +$errors=@(Issues $m $b $v $commit) +if($errors.Count){throw ('R256_RELEASE_METADATA_FAIL_CLOSED '+($errors -join ';'))} +if($SelfTest){ + $mutants=@( + @{Type='Manifest';Field='Version';Bad='1.7.0'}, + @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, + @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.4/'+('0'*40))}, + @{Type='Setup';Field='PawnIOBundled';Bad=$true} + ) + foreach($mutation in $mutants){ + $am=$m|ConvertTo-Json -Depth 14|ConvertFrom-Json + $ab=$b|ConvertTo-Json -Depth 14|ConvertFrom-Json + $av=$v|ConvertTo-Json -Depth 14|ConvertFrom-Json + switch($mutation.Type){ + 'Manifest' {$am.($mutation.Field)=$mutation.Bad} + 'Sbom' {$ab.($mutation.Field)=$mutation.Bad} + 'Setup' {$av.($mutation.Field)=$mutation.Bad} + } + if(@(Issues $am $ab $av $commit).Count -eq 0){ + throw ('R256_HOSTILE_RELEASE_METADATA_ACCEPTED '+$mutation.Type+' '+$mutation.Field) + } + } + Write-Output 'R256_FIVE_HOSTILE_RELEASE_METADATA_MUTANTS_REJECTED' +} +Write-Output ('R256_MANIFEST_SBOM_BINARY_VERSION=PASS VERSION='+$m.Version+ + ' OUTPUTS='+@($m.Outputs).Count+' SHA256='+(Get-FileHash $manifestPath -Algorithm SHA256).Hash) +exit 0 diff --git a/build/official-v173-managed.json b/build/official-v173-managed.json new file mode 100644 index 00000000..9653a0b6 --- /dev/null +++ b/build/official-v173-managed.json @@ -0,0 +1,129 @@ +{ + "Status": "PASS_PINNED_PUBLIC_V173_MANAGED_RESOURCE_FOR_FUTURE_UPGRADE", + "Schema": 1, + "Version": "1.7.3", + "SourceCommit": "6001fce0e7900f2a1ec5172224f4c75224f3ac69", + "PublicSetupSHA256": "9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B", + "PublicMainSHA256": "6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C", + "ManagedCount": 23, + "OwnerUpgradedManagedMatched": 22, + "RootSourceOptionalSHA256": "5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "PrivateConfigIncluded": false, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.cs", + "SHA256": "5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "Optional": true + }, + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C", + "Optional": false + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Optional": false + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "Optional": false + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Optional": false + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Optional": false + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Optional": false + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Optional": false + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Optional": false + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "F11D20C67056AC054C690C630044F717657DAB6FE26E5997EEBFF25947CF69B9", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Optional": false + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "25C2E5BD69BC4210757695501606942ED3ADD0C250848A2CC0B6AB4D8070578F", + "Optional": false + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Optional": false + }, + { + "Path": "Docs/README.md", + "SHA256": "48026FC6A842A6BAEC519E04555A7291CD3DE339CBE59B9AAE635AAA1C1E8D37", + "Optional": false + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Optional": false + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Optional": false + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Optional": false + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Optional": false + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.3.md", + "SHA256": "CA1A8889BA56AA802BD9A2E2C0A45F885004DB5868702EBAE1076BF52C0F7683", + "Optional": false + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Optional": false + }, + { + "Path": "Uninstall.exe", + "SHA256": "9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B", + "Optional": false + } + ] +} \ No newline at end of file diff --git a/docs/releases/v1.7.4/INSTALLER_README.txt b/docs/releases/v1.7.4/INSTALLER_README.txt new file mode 100644 index 00000000..f23f4a49 --- /dev/null +++ b/docs/releases/v1.7.4/INSTALLER_README.txt @@ -0,0 +1,6 @@ +Taskbar Monitor Enhanced 1.7.4 +One per-user installer; guarded in-place update of official 1.7.3 sources, with or without its legacy redundant root source. +Fresh installs store canonical program source only under Source/. +Existing Glass settings and arbitrary user data are preserved; no uninstall-before-upgrade. +Unsigned by existing owner policy; user may see SmartScreen publisher reputation notice. Do not bypass actual antivirus detections. +See Release Notes for verified test boundaries. diff --git a/docs/releases/v1.7.4/RELEASE_NOTES.md b/docs/releases/v1.7.4/RELEASE_NOTES.md new file mode 100644 index 00000000..24962b19 --- /dev/null +++ b/docs/releases/v1.7.4/RELEASE_NOTES.md @@ -0,0 +1,11 @@ +# Taskbar Monitor Enhanced v1.7.4 — fresh-install maintenance and protected upgrades + +## Fix +- Prevent the redundant root-level TaskbarMonitorEnhanced.cs file during fresh install while retaining the canonical Source/TaskbarMonitorEnhanced.cs file. This resolves the genuine fresh v1.7.3 same-version maintenance defect, independently reproduced on Windows 2025 and 2022. +- Include SHA-pinned official v1.7.3 as a supported source for same-root v1.7.4 in-place upgrade, regardless of whether it was installed fresh (redundant root source present) or upgraded from v1.7.2 (root source absent). Any present legacy root source must match the official SHA before managed removal. +- Glass transparency, separate On/Off switch status label, protected user config and backup, native taskbar geometry and CPU optimizations remain as accepted in v1.7.3. + +## Release gates and scope +- Do not release until exact code head builds reproducibly with its own verification tests and two independent fresh Windows instances cover actual fresh 1.7.4 install/maintenance, official v1.7.3 fresh and upgraded to 1.7.4, native Start/Settings/Glass/fullscreen and protected user canaries with rollback. +- The public v1.7.3 release remains immutable; no silent binary replacement and no duplicate per-user installed root. +- Intentionally unsigned; SmartScreen reputation is distinct from antivirus vulnerability detection. Microsoft vulnerable WinRing0 Issue #6 attribution is unresolved. Never disable Defender, bypass quarantines or claim malware-free status. Physical power cut, HKLM multiple-user and external signing/lab are not accepted merely from these CI tests. diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 0c47dec9..8aa167c3 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -20,14 +20,14 @@ [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.3.0")] -[assembly: AssemblyFileVersion("1.7.3.0")] -[assembly: AssemblyInformationalVersion("1.7.3+r243")] +[assembly: AssemblyVersion("1.7.4.0")] +[assembly: AssemblyFileVersion("1.7.4.0")] +[assembly: AssemblyInformationalVersion("1.7.4+r256")] internal static partial class SetupProgram { const string Product="Taskbar Monitor Enhanced"; - const string Version="1.7.3"; + const string Version="1.7.4"; const string SensorProtocolVersion="1.1.2+r21"; const string SensorSupervisorVersion="1.3.0+r33"; const string Publisher="Dr. Ali-Akbar Emadeddin"; @@ -58,7 +58,7 @@ internal static partial class SetupProgram "Payload.COPYRIGHT_AND_ATTRIBUTION.md", "Payload.AI_ASSISTED_DEVELOPMENT.md", "Payload.THIRD_PARTY_NOTICES.md", - "Payload.RELEASE_NOTES_v1.7.3.md", + "Payload.RELEASE_NOTES_v1.7.4.md", "Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md", "Payload.TaskbarMonitorEnhanced_Setup.cs" }; @@ -228,8 +228,8 @@ static void WriteInstallState(SensorOutcome sensorOutcome) string sensorMode=sensorOutcome==null?"":(sensorOutcome.LayerMode??""); string json="{\r\n"+ " \"App\": \"Taskbar Monitor Enhanced\",\r\n"+ - " \"Version\": \"1.7.3\",\r\n"+ - " \"PublicVersion\": \"1.7.3\",\r\n"+ + " \"Version\": \"1.7.4\",\r\n"+ + " \"PublicVersion\": \"1.7.4\",\r\n"+ " \"InternalRuntimeBaseline\": \"V1_1_2_R21_PRODUCTION_HARDENING\",\r\n"+ " \"SensorSupervisor\": \"V1_3_0_R33_STORAGE_COMPLETION_HARDENING\",\r\n"+ " \"SensorLayerStatus\": \""+sensorStatus.Replace("\\","\\\\").Replace("\"","\\\"")+"\",\r\n"+ @@ -445,7 +445,7 @@ static bool IsCurrentProcessElevated() static SensorOutcome Install(bool desktop,bool startup) { if(!Environment.Is64BitOperatingSystem) - throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.3 requires 64-bit Windows."); + throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.4 requires 64-bit Windows."); // R114: prevent fresh-install code from ever overwriting an installed // product or user-owned files. Existing roots must use /update. @@ -460,7 +460,7 @@ static SensorOutcome Install(bool desktop,bool startup) Extract("Payload.TaskbarMonitorEnhanced.exe",AppExe); Extract("Payload.TaskbarMonitorEnhanced.exe.config",AppExe+".config"); Extract("Payload.TaskbarMonitorEnhanced.ico",Path.Combine(AppRoot,"TaskbarMonitorEnhanced.ico")); - Extract("Payload.TaskbarMonitorEnhanced.cs",Path.Combine(AppRoot,"TaskbarMonitorEnhanced.cs")); + // R255: avoid a redundant root source that breaks same-version maintenance. Extract("Payload.TaskbarMonitorEnhanced.cs",Path.Combine(AppRoot,"Source","TaskbarMonitorEnhanced.cs")); Extract("Payload.StudioThemes.cs",Path.Combine(AppRoot,"Source","StudioThemes.cs")); Extract("Payload.WallpaperStudio.cs",Path.Combine(AppRoot,"Source","WallpaperStudio.cs")); @@ -475,7 +475,7 @@ static SensorOutcome Install(bool desktop,bool startup) string[] docs=new string[]{ "LICENSE","README.md","AUTHORS.md","COPYRIGHT_AND_ATTRIBUTION.md", "AI_ASSISTED_DEVELOPMENT.md","THIRD_PARTY_NOTICES.md", - "RELEASE_NOTES_v1.7.3.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" + "RELEASE_NOTES_v1.7.4.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" }; foreach(string doc in docs) Extract("Payload."+doc,Path.Combine(AppRoot,"Docs",doc)); @@ -619,7 +619,7 @@ static int Verify(string path) } if(!String.IsNullOrEmpty(path)){ string json="{\"Status\":\"PASS\",\"Resources\":"+(RequiredResources.Length+(pawnBundled?1:0))+ - ",\"Version\":\"1.7.3\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ + ",\"Version\":\"1.7.4\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ ",\"SensorArchitecture\":\"R21_PROCESS_ISOLATED\",\"SensorUpgradePolicy\":\"EXACT_R33_SUPERVISOR_R21_PROTOCOL\""+ ",\"PawnIOBundled\":"+(pawnBundled?"true":"false")+",\"EmbeddedMainSHA256\":\""+embeddedMainSHA256+"\",\"EmbeddedMainBytes\":"+embeddedMainBytes+"}"; File.WriteAllText(path,json,Encoding.UTF8); @@ -658,7 +658,7 @@ public SetupForm() Icon=Icon.ExtractAssociatedIcon(Application.ExecutablePath); Label title=new Label(); - title.Text=Product+" 1.7.3"; + title.Text=Product+" 1.7.4"; title.Font=new Font(Font.FontFamily,18,FontStyle.Bold); title.Left=28;title.Top=22;title.AutoSize=true;Controls.Add(title); @@ -693,12 +693,12 @@ public SetupForm() progress.Visible=false; status.Text="Installation completed."; if(outcome.IsHealthy){ - MessageBox.Show(Product+" 1.7.3 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", + MessageBox.Show(Product+" 1.7.4 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", "Setup complete",MessageBoxButtons.OK,MessageBoxIcon.Information); }else{ string extra=outcome.RebootRequired ? "\r\n\r\nRestart Windows, then use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors if needed." : "\r\n\r\nThe application is installed and usable. The sensor supervisor continues in the background. If protected CPU/GPU/storage telemetry is still unavailable after a short wait or restart, use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors."; - MessageBox.Show(Product+" 1.7.3 was installed successfully.\r\n\r\n"+outcome.Message+extra, + MessageBox.Show(Product+" 1.7.4 was installed successfully.\r\n\r\n"+outcome.Message+extra, "Setup complete - sensor warning",MessageBoxButtons.OK,MessageBoxIcon.Warning); } Close(); @@ -833,6 +833,9 @@ internal static partial class SetupProgram const string InstalledV170="1.7.0"; const string InstalledV171="1.7.1"; const string InstalledV172="1.7.2"; + const string InstalledV173="1.7.3"; + const string OfficialV173SetupSHA="9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B"; + const string OfficialV173MainSHA="6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C"; const string OfficialV172SetupSHA="225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4"; const string OfficialV172MainSHA="84D5DBF6F2E6D1D2E2B3E6D9C9DA523E564A7F5240C08D34A456D934026B829F"; const string OfficialV171SetupSHA="ABD106630F7BAB3471CAF3F5BD4313DFCF2D61DDE1DF79E72EFE91933C8F2F89"; @@ -983,6 +986,34 @@ internal static partial class SetupProgram "Uninstall.exe|225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4", }; + // R256: pinned immutable public v1.7.3 fresh/upgraded source; never owner data. + // Root-level .cs may be present only if exact SHA matched. + static readonly string[] OfficialStableV173Rows=new string[] { + "TaskbarMonitorEnhanced.cs|5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "TaskbarMonitorEnhanced.exe|6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|5BB6F7D089251474E29ECFBCBB59653D18FA8664FD783BAB311B61D947851D32", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|F11D20C67056AC054C690C630044F717657DAB6FE26E5997EEBFF25947CF69B9", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|25C2E5BD69BC4210757695501606942ED3ADD0C250848A2CC0B6AB4D8070578F", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|48026FC6A842A6BAEC519E04555A7291CD3DE339CBE59B9AAE635AAA1C1E8D37", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.3.md|CA1A8889BA56AA802BD9A2E2C0A45F885004DB5868702EBAE1076BF52C0F7683", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B", + }; + sealed class UpgradeTarget { internal readonly string Rel; @@ -1010,7 +1041,7 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/COPYRIGHT_AND_ATTRIBUTION.md","Payload.COPYRIGHT_AND_ATTRIBUTION.md"), new UpgradeTarget("Docs/AI_ASSISTED_DEVELOPMENT.md","Payload.AI_ASSISTED_DEVELOPMENT.md"), new UpgradeTarget("Docs/THIRD_PARTY_NOTICES.md","Payload.THIRD_PARTY_NOTICES.md"), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md","Payload.RELEASE_NOTES_v1.7.3.md"), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.4.md","Payload.RELEASE_NOTES_v1.7.4.md"), new UpgradeTarget("Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md","Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md"), new UpgradeTarget("Uninstall.exe","@SELF"), new UpgradeTarget("install_state.json","@INSTALLSTATE"), @@ -1018,7 +1049,8 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/RELEASE_NOTES_v1.6.0.md",null), // superseded v1.6 official file new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.0.md",null), // exact historical managed note only new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.1.md",null), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md",null) // previous version, guarded byte-for-byte + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md",null) // previous version, guarded byte-for-byte }; static string UpgradeHash(string path) @@ -1046,6 +1078,8 @@ static Dictionary LegacyExpected(string version,string channel) { rows=OfficialStableV171Rows; expectedSetup=OfficialV171SetupSHA; count=23; } else if(version==InstalledV172 && channel=="OFFICIAL_STABLE_V172") { rows=OfficialStableV172Rows; expectedSetup=OfficialV172SetupSHA; count=23; } + else if(version==InstalledV173 && channel=="OFFICIAL_STABLE_V173") + { rows=OfficialStableV173Rows; expectedSetup=OfficialV173SetupSHA; count=23; } else if(version==InstalledV170 && channel=="PREVIEW1") { rows=LegacyPreview1Rows; expectedSetup=LegacyPreview1InstallerSha; count=23; } else throw new InvalidDataException("Unsupported exact prior source identity; no downgrade/second root."); @@ -1146,7 +1180,7 @@ static string NewInstallState(string source) throw new InvalidDataException("Old install state Version/PublicVersion mismatch."); string prior=installed.Groups["v"].Value; if(prior==Version)return source; - if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172) + if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173) throw new InvalidDataException("Prior install state version not a pinned officially allowed ancestor."); string next=ReplaceStateVersion(source,"Version",prior); next=ReplaceStateVersion(next,"PublicVersion",prior); @@ -1172,7 +1206,7 @@ static Dictionary PrepareNewBytes(string previousState) if(bytes.Length<1 || fresh.ContainsKey(t.Rel))throw new InvalidDataException("Duplicate/empty payload "+t.Rel); fresh.Add(t.Rel,bytes); } - if(fresh.Count!=UpgradeTargets.Length-5) + if(fresh.Count!=UpgradeTargets.Length-6) throw new InvalidDataException("Upgrade static target map is invalid."); return fresh; } @@ -1181,7 +1215,7 @@ static bool IsOptionalStableLegacyRoot(string rel,string channel) { // Only one SHA-pinned historical Stable file may be absent. Any present // occurrence still requires the exact immutable published source hash. - return (channel=="OFFICIAL_STABLE_V170" || channel=="OFFICIAL_STABLE_V171" || channel=="OFFICIAL_STABLE_V172") && + return (channel=="OFFICIAL_STABLE_V170" || channel=="OFFICIAL_STABLE_V171" || channel=="OFFICIAL_STABLE_V172" || channel=="OFFICIAL_STABLE_V173") && String.Equals(rel,"TaskbarMonitorEnhanced.cs",StringComparison.Ordinal); } @@ -1284,7 +1318,7 @@ static void StageNewManaged(Dictionary fresh, Dictionary rows) string priorVersionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); if(!File.Exists(priorVersionPath))throw new IOException("Previous upgrade version missing; retain stage."); string priorVersion=File.ReadAllText(priorVersionPath,Encoding.ASCII); - if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); // This works after a mid-commit process kill: exact old snapshots are persisted // on the same volume, and new-only files are removed only if their SHA is known. for(int i=rows.Count-1;i>=0;i--){ @@ -1424,8 +1458,8 @@ static SensorOutcome UpgradeExisting() CheckSingleCanonicalRoot(); string installed=CurrentInstalledVersion(); string newSha=Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"); - if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=Version) - throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2 may upgrade in place."); + if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=Version) + throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3 may upgrade in place."); string channel="OFFICIAL_V16"; string currentMainSHA=UpgradeHash(AppExe); string installedSetup=UPath("Uninstall.exe"); @@ -1440,7 +1474,8 @@ static SensorOutcome UpgradeExisting() !File.Exists(UPath("Docs/RELEASE_NOTES_v1.6.0.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.0.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.1.md")) && - !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.2.md"))) + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.2.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.3.md"))) return ExistingSensorOutcome(); throw new InvalidOperationException("Unknown current-version maintenance revision; no files changed."); } @@ -1466,6 +1501,12 @@ static SensorOutcome UpgradeExisting() throw new InvalidOperationException("Unknown official v1.7.2 source/Setup identity; refuse before any mutation."); channel="OFFICIAL_STABLE_V172"; } + if(installed==InstalledV173) + { + if(currentMainSHA!=OfficialV173MainSHA || oldSetupSHA!=OfficialV173SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.3 source/Setup identity; refuse before any mutation."); + channel="OFFICIAL_STABLE_V173"; + } // No force-kill; the owner must close their currently running app normally. EnsureAppNotRunning(); diff --git a/src/TaskbarMonitorEnhanced.cs b/src/TaskbarMonitorEnhanced.cs index b1888e39..47c3ee98 100644 --- a/src/TaskbarMonitorEnhanced.cs +++ b/src/TaskbarMonitorEnhanced.cs @@ -33,18 +33,18 @@ [assembly: AssemblyDescription("Live system monitor integrated into the Windows taskbar")] [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyInformationalVersion("1.7.3+r243")] +[assembly: AssemblyInformationalVersion("1.7.4+r256")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.3.0")] -[assembly: AssemblyFileVersion("1.7.3.0")] +[assembly: AssemblyVersion("1.7.4.0")] +[assembly: AssemblyFileVersion("1.7.4.0")] namespace TaskbarMonitorEnhanced { internal static class BuildInfo { - public const string Version = "V1_7_3_R243_CPU_PREFILTER"; + public const string Version = "V1_7_4_R256_FRESH_MAINTENANCE"; public const string Product = "Taskbar Monitor Enhanced"; - public const string PublicVersion = "1.7.3"; + public const string PublicVersion = "1.7.4"; public const string ShortcutName = "Taskbar Monitor Enhanced"; public const string ProductDescription = "Live system monitor integrated into the Windows taskbar"; public const string Author = "Dr. Ali-Akbar Emadeddin"; diff --git a/src/WorkspaceSelfTest.cs b/src/WorkspaceSelfTest.cs index 6b8e887f..1078b276 100644 --- a/src/WorkspaceSelfTest.cs +++ b/src/WorkspaceSelfTest.cs @@ -126,7 +126,7 @@ internal static int Run() { try { - Check(BuildInfo.PublicVersion=="1.7.3","version"); + Check(BuildInfo.PublicVersion=="1.7.4","version"); for(int schema=0;schema<7;schema++){ var legacy=new AppConfig{ConfigSchemaVersion=schema,EnableTemperatureNotifications=true,CpuTempWarningC=0,FontSize=Double.NaN,Opacity=Double.NaN};legacy.Normalize(); Check(legacy.ConfigSchemaVersion==8,"all legacy schemas migrate "+schema); From 8633c606c6209483e9d8ef09581bd6c4246ad017 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Fri, 16 Oct 2026 15:14:54 +0330 Subject: [PATCH 006/135] release(candidate): v1.7.5 per-user protected sensor task guard and official v1.7.4 upgrade R265 --- .../workflows/audit-r265-sensor-safety.yml | 109 +++++++ .github/workflows/ci.yml | 2 +- ...ptance.yml => release-v175-acceptance.yml} | 82 ++--- build/Build.ps1 | 10 +- build/Package-Signed-Setup.ps1 | 4 +- build/TaskbarMonitorEnhanced_Setup.csproj | 4 +- build/Test-Native-Presentation-R265.ps1 | 300 ++++++++++++++++++ build/Test-NoPawnIO-OssRelease.ps1 | 6 +- build/Test-R265-CrossUserGuard.ps1 | 195 ++++++++++++ .../Test-R265-FreshSameVersion-Disposable.ps1 | 177 +++++++++++ build/Test-Release-Gates.ps1 | 14 +- build/Test-Signed-Payload-Contract.ps1 | 6 +- build/Test-Signed-Staging.ps1 | 6 +- build/Test-Upgrade174-R265.ps1 | 224 +++++++++++++ build/Test-UpgradeContract-R265.ps1 | 73 +++++ build/Verify-Embedded-Main.ps1 | 4 +- build/Verify-Release-Metadata-R265.ps1 | 105 ++++++ build/official-v174-managed.json | 102 ++++++ docs/releases/v1.7.5/INSTALLER_README.txt | 6 + docs/releases/v1.7.5/RELEASE_NOTES.md | 12 + installer/TBME_Setup_Elevated_Helper.ps1 | 49 +++ installer/TaskbarMonitorEnhanced_Setup.cs | 83 +++-- src/TaskbarMonitorEnhanced.cs | 10 +- src/WorkspaceSelfTest.cs | 2 +- 24 files changed, 1488 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/audit-r265-sensor-safety.yml rename .github/workflows/{release-v174-acceptance.yml => release-v175-acceptance.yml} (68%) create mode 100644 build/Test-Native-Presentation-R265.ps1 create mode 100644 build/Test-R265-CrossUserGuard.ps1 create mode 100644 build/Test-R265-FreshSameVersion-Disposable.ps1 create mode 100644 build/Test-Upgrade174-R265.ps1 create mode 100644 build/Test-UpgradeContract-R265.ps1 create mode 100644 build/Verify-Release-Metadata-R265.ps1 create mode 100644 build/official-v174-managed.json create mode 100644 docs/releases/v1.7.5/INSTALLER_README.txt create mode 100644 docs/releases/v1.7.5/RELEASE_NOTES.md diff --git a/.github/workflows/audit-r265-sensor-safety.yml b/.github/workflows/audit-r265-sensor-safety.yml new file mode 100644 index 00000000..45a2dbda --- /dev/null +++ b/.github/workflows/audit-r265-sensor-safety.yml @@ -0,0 +1,109 @@ +name: R265 - Scoped cross-account sensor helper guard on disposable Windows +on: + push: + branches: + - release/r265-v175-sensor-crossuser-safety + - main +permissions: + contents: read +jobs: + guest-sensor-guard: + name: "R265 no-driver guard-only cross-user test ${{ matrix.guest }}" + runs-on: ${{ matrix.system }} + timeout-minutes: 17 + strategy: + fail-fast: false + matrix: + include: + - guest: A + system: windows-2025 + - guest: B + system: windows-2022 + steps: + - name: Checkout exact unmodified public v1.7.4 ancestor + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + fetch-depth: 0 + - name: Ensure only helper/test/workflow changed; disposable admin host + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + ($env:GITHUB_REF -cne 'refs/heads/release/r265-v175-sensor-crossuser-safety' -and $env:GITHUB_REF -cne 'refs/heads/main') -or + $env:COMPUTERNAME -match '(?i)EMAD|ALI|LAPTOP'){ + throw 'R265_REJECT_NON_DISPOSABLE_HOST' + } + $base='1375d719989aa93966bae445ac63b9a415f637ad' + git merge-base --is-ancestor $base HEAD + if($LASTEXITCODE -ne 0){throw 'R265_EXACT_RELEASE_SOURCE_NOT_BASE'} + $actual=@(git diff --name-only $base HEAD) + $allowed=@( + '.github/workflows/audit-r265-sensor-safety.yml', + 'build/Test-R265-CrossUserGuard.ps1', + 'installer/TBME_Setup_Elevated_Helper.ps1' + ) + if($actual.Count -lt 14 -or $actual -cnotcontains 'installer/TBME_Setup_Elevated_Helper.ps1' -or $actual -cnotcontains 'installer/TaskbarMonitorEnhanced_Setup.cs'){ + throw 'R265_VERSIONED_PRODUCT_SOURCE_GUARD_NOT_STAGED' + } + $files=@( + @{File='installer/TBME_Setup_Elevated_Helper.ps1';SHA='84A7DC21012A63C5959447D7234B365CF926028016A81B742F891951B191E7FB'}, + @{File='build/Test-R265-CrossUserGuard.ps1';SHA='935BF48132112F2C01AD3D0E29129C06D400D444974F3121E38A279C9A458A8B'} + ) + foreach($item in $files){ + $path=(Resolve-Path $item.File).Path + $str=[IO.File]::ReadAllText($path,[Text.Encoding]::UTF8) + $lf=$str.Replace(([string][char]13+[string][char]10),([string][char]10)).Replace(([string][char]13),([string][char]10)) + $sha=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($lf))) + if($sha -cne $item.SHA){throw 'R265_SOURCE_DIGEST_CHANGED'} + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($path,[ref]$tok,[ref]$err) + if(@($err).Count){throw 'R265_WINDOWS_POWERSHELL51_SYNTAX_FAILED'} + } + $isAdmin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$isAdmin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R265_NO_ADMIN_ON_GUEST_NO_ESCALATION' + } + if(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue){ + throw 'R265_EXISTING_REAL_TASK_MUST_NOT_BE_TOUCHED' + } + if(Test-Path (Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker')){ + throw 'R265_EXISTING_REAL_BROKER_MUST_NOT_BE_TOUCHED' + } + 'R265_FRESH_VM_TEST_ONLY_SCOPE_PIN=PASS' + - name: Test actual owner SID guard on both helper modes without executing later side effects + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-R265-CrossUserGuard.ps1 -HostLabel '${{ matrix.guest }}' + $p=Join-Path $env:RUNNER_TEMP 'TBME_R265_${{ matrix.guest }}/R265_CROSSUSER.json' + if(!(Test-Path $p)){throw 'R265_TEST_RECEIPT_NOT_FOUND'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Revision -cne 'R265' -or + $r.Status -cne 'PASS_OTHER_USER_SENSOR_TASK_PRESERVED_AND_SAME_OWNER_GUARD_ALLOWED' -or + $r.Guest -cne '${{ matrix.guest }}' -or + @($r.OtherUserModesRefused).Count -ne 4 -or + @($r.SameUserModesPermitted).Count -ne 4 -or + $r.VerifiedRealFullHelperEarlyRefusal -ne $true -or + $r.TestOwnedTaskDeleted -ne $true -or + $r.GuestUserAccountDeleted -ne $true -or + $r.ExactProductionHelperAfterGuardWasNotExecuted -ne $true -or + $r.NoSensorTaskTriggered -ne $true -or + $r.NoPawnIODriverOrSetupExecuted -ne $true -or + $r.OwnerPCAndPublicV174Unchanged -ne $true){ + throw 'R265_GENUINE_VM_GUARD_SCOPE_NOT_ACCEPTED' + } + 'R265_OTHER_USER_TASK_PRESERVED_BOTH_MODES_AND_SAME_OWNER_PERMITTED=PASS' + $global:LASTEXITCODE=0 + - name: Preserve disposable bounded acceptance evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R265-guard-${{ matrix.guest }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R265_${{ matrix.guest }}/R265_CROSSUSER.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 076b0ec7..48ae6876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,7 +135,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference='Stop' - ./build/Verify-Release-Metadata-R256.ps1 -SelfTest + ./build/Verify-Release-Metadata-R265.ps1 -SelfTest if ($LASTEXITCODE -ne 0) {throw 'R136 release version, 5 output SHA and SBOM source gates FAILED'} - name: Attest binary build provenance diff --git a/.github/workflows/release-v174-acceptance.yml b/.github/workflows/release-v175-acceptance.yml similarity index 68% rename from .github/workflows/release-v174-acceptance.yml rename to .github/workflows/release-v175-acceptance.yml index 9f736e9a..aa993080 100644 --- a/.github/workflows/release-v174-acceptance.yml +++ b/.github/workflows/release-v175-acceptance.yml @@ -1,7 +1,7 @@ -name: v1.7.4 - Protected official v1.7.3 upgrade and native acceptance +name: v1.7.5 - Full protected v1.7.4 upgrade and native acceptance on: push: - branches: [release/r256-v174-fresh-idempotence, main] + branches: [release/r265-v175-sensor-crossuser-safety, main] workflow_dispatch: permissions: contents: read @@ -18,57 +18,57 @@ jobs: shell: pwsh run: | $ErrorActionPreference='Stop' - if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R256 source drift'} - foreach($p in @('build/Test-Upgrade173-R256.ps1','build/Test-UpgradeContract-R256.ps1','build/Test-Native-Presentation-R256.ps1')){ + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R265 source drift'} + foreach($p in @('build/Test-Upgrade174-R265.ps1','build/Test-UpgradeContract-R265.ps1','build/Test-Native-Presentation-R265.ps1')){ $t=$null;$e=$null [void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p).Path,[ref]$t,[ref]$e) - if(@($e).Count){throw ('R256 parser '+$p)} + if(@($e).Count){throw ('R265 parser '+$p)} } ./build/Build.ps1 ./build/Generate-Sbom.ps1 - ./build/Verify-Release-Metadata-R256.ps1 -SelfTest - if($LASTEXITCODE -ne 0){throw 'R256 version/manifest/SPDX gate'} + ./build/Verify-Release-Metadata-R265.ps1 -SelfTest + if($LASTEXITCODE -ne 0){throw 'R265 version/manifest/SPDX gate'} $main='./build/_out/App/TaskbarMonitorEnhanced.exe' - $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.4.exe' - $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.4';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} + $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.5.exe' + $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.5';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} [IO.File]::WriteAllText((Join-Path $pwd 'build/_out/CANDIDATE.json'),($c|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) - name: Actual compiled control, geometry, visibility and upgrade contracts shell: powershell run: | $ErrorActionPreference='Stop' $main='./build/_out/App/TaskbarMonitorEnhanced.exe' - ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/APPEARANCE') - ./build/Test-SettingsUi-R244.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/SETTINGS') - $ui=Get-Content (Join-Path $env:RUNNER_TEMP 'R256_MODELS/SETTINGS/R244_SETTINGS_UI.json') -Raw|ConvertFrom-Json + ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/APPEARANCE') + ./build/Test-SettingsUi-R244.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/SETTINGS') + $ui=Get-Content (Join-Path $env:RUNNER_TEMP 'R265_MODELS/SETTINGS/R244_SETTINGS_UI.json') -Raw|ConvertFrom-Json if($ui.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or $ui.SettingsPages -ne 9 -or $ui.SiblingOverlapCount -ne 0){throw 'R244_NATIVE_SETTINGS_UI_LAYOUT_COLLISION'} - ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/GEOMETRY') - ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/VISIBILITY') - ./build/Test-UpgradeContract-R256.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.4.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R256_MODELS/UPGRADE') + ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/GEOMETRY') + ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/VISIBILITY') + ./build/Test-UpgradeContract-R265.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.5.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R265_MODELS/UPGRADE') - name: Preserve one exact candidate installer, not a release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: R256-candidate + name: R265-candidate if-no-files-found: error retention-days: 14 path: | build/_out/CANDIDATE.json - build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.4.exe + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.5.exe - name: Preserve model receipts and binary manifest if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: R256-compiled-contracts + name: R265-compiled-contracts if-no-files-found: warn retention-days: 21 path: | - ${{ runner.temp }}/R256_MODELS/** + ${{ runner.temp }}/R265_MODELS/** artifacts/BUILD_MANIFEST.json artifacts/SBOM.spdx.json upgrade-and-native: needs: build-candidate runs-on: windows-latest timeout-minutes: 24 - name: Real official 1.7.3 to 1.7.4 upgrade and native modes (${{ matrix.host }}) + name: Real official 1.7.4 to 1.7.5 upgrade and native modes (${{ matrix.host }}) strategy: fail-fast: false matrix: @@ -81,38 +81,38 @@ jobs: - name: Transfer exact previously compiled Setup bytes uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: R256-candidate + name: R265-candidate path: build/_out - - name: Genuine official v1.7.3 upgrade preserving user data + - name: Genuine official v1.7.4 upgrade preserving user data shell: powershell env: GH_TOKEN: ${{ github.token }} run: | $ErrorActionPreference='Stop' - ./build/Test-Upgrade173-R256.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' - $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R256_UPGRADE_${{ matrix.host }}/R256_UPGRADE.json') -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_GENUINE_173_TO_174_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R256 actual updater acceptance failed'} + ./build/Test-Upgrade174-R265.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R265_UPGRADE_${{ matrix.host }}/R265_UPGRADE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_GENUINE_174_TO_175_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R265 actual updater acceptance failed'} - name: Native menu, Settings, Start, fullscreen and 1-percent regression on upgraded bytes shell: powershell run: | $ErrorActionPreference='Stop' - ./build/Test-Native-Presentation-R256.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate - $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R256_${{ matrix.host }}/R256_NATIVE.json') -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R256 native four-mode evidence not accepted'} + ./build/Test-Native-Presentation-R265.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R265_${{ matrix.host }}/R265_NATIVE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R265 native four-mode evidence not accepted'} - name: Preserve raw upgrade receipts and physical native pixels on success or failure if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: R256-native-upgrade-${{ matrix.host }} + name: R265-native-upgrade-${{ matrix.host }} if-no-files-found: warn retention-days: 21 path: | - ${{ runner.temp }}/TBME_R256_UPGRADE_${{ matrix.host }}/R256_UPGRADE.json - ${{ runner.temp }}/TBME_R256_${{ matrix.host }}/** + ${{ runner.temp }}/TBME_R265_UPGRADE_${{ matrix.host }}/R265_UPGRADE.json + ${{ runner.temp }}/TBME_R265_${{ matrix.host }}/** fresh-install-maintenance: needs: build-candidate - name: "R256 truly fresh, repeated maintenance, legacy duplicate refusal ${{ matrix.host }}" + name: "R265 truly fresh, repeated maintenance, legacy duplicate refusal ${{ matrix.host }}" runs-on: ${{ matrix.system }} timeout-minutes: 23 strategy: @@ -131,17 +131,17 @@ jobs: - name: Download compiled exact candidate, not public Setup uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: R256-candidate + name: R265-candidate path: build/_out - name: Prove no root-source duplication, two maintenance calls and conflict refusal shell: powershell run: | $ErrorActionPreference='Stop' - ./build/Test-R256-FreshSameVersion-Disposable.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' - $receipt=Join-Path $env:RUNNER_TEMP 'TBME_R256_${{ matrix.host }}/R256_FRESH.json' - if(!(Test-Path $receipt)){throw 'R256_FRESH_RECEIPT_MISSING'} + ./build/Test-R265-FreshSameVersion-Disposable.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $receipt=Join-Path $env:RUNNER_TEMP 'TBME_R265_${{ matrix.host }}/R265_FRESH.json' + if(!(Test-Path $receipt)){throw 'R265_FRESH_RECEIPT_MISSING'} $r=Get-Content $receipt -Raw|ConvertFrom-Json - if($r.Status -cne 'PASS_REAL_GUEST_FRESH_R256_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT' -or + if($r.Status -cne 'PASS_REAL_GUEST_FRESH_R265_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT' -or $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SingleManagedSourceWithoutRootDuplicate -ne $true -or $r.FreshInstallExit -ne 0 -or @@ -152,9 +152,9 @@ jobs: $r.PublicSetupChanged -ne $false -or $r.PersonalComputerTouched -ne $false -or $r.RealHKLMRegistryOrCrossUserAcceptance -ne $false){ - throw 'R256_REAL_FRESH_INSTALL_OR_DATA_SCOPE_NOT_PASS' + throw 'R265_REAL_FRESH_INSTALL_OR_DATA_SCOPE_NOT_PASS' } - 'R256_REAL_TWO_HOST_FRESH_AND_REPEATED_MAINTENANCE=PASS' + 'R265_REAL_TWO_HOST_FRESH_AND_REPEATED_MAINTENANCE=PASS' # The job invokes native commands in the child test; only after all # receipt assertions passed reset stale native LASTEXITCODE. $global:LASTEXITCODE=0 @@ -162,8 +162,8 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: R256-fresh-${{ matrix.host }} + name: R265-fresh-${{ matrix.host }} if-no-files-found: warn retention-days: 21 path: | - ${{ runner.temp }}/TBME_R256_${{ matrix.host }}/R256_FRESH.json + ${{ runner.temp }}/TBME_R265_${{ matrix.host }}/R265_FRESH.json diff --git a/build/Build.ps1 b/build/Build.ps1 index a2f7d873..00e608dc 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -92,12 +92,12 @@ $CanonicalTextResources=[ordered]@{ 'TaskbarMonitorSensorSupervisor.cs'=(Join-Path $Root 'src\sensors\TaskbarMonitorSensorSupervisor.cs') 'TBME_Setup_Elevated_Helper.ps1'=(Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1') 'LICENSE'=(Join-Path $Root 'LICENSE') - 'README.md'=(Join-Path $Root 'docs\releases\v1.7.4\INSTALLER_README.txt') + 'README.md'=(Join-Path $Root 'docs\releases\v1.7.5\INSTALLER_README.txt') 'AUTHORS.md'=(Join-Path $Root 'AUTHORS.md') 'COPYRIGHT_AND_ATTRIBUTION.md'=(Join-Path $Root 'COPYRIGHT_AND_ATTRIBUTION.md') 'AI_ASSISTED_DEVELOPMENT.md'=(Join-Path $Root 'AI_ASSISTED_DEVELOPMENT.md') 'THIRD_PARTY_NOTICES.md'=(Join-Path $Root 'THIRD_PARTY_NOTICES.md') - 'RELEASE_NOTES_v1.7.4.md'=(Join-Path $Root 'docs\releases\v1.7.4\RELEASE_NOTES.md') + 'RELEASE_NOTES_v1.7.5.md'=(Join-Path $Root 'docs\releases\v1.7.5\RELEASE_NOTES.md') 'UPSTREAM_REFERENCE_GPL_NOTICE.md'=(Join-Path $Root 'UPSTREAM_REFERENCE_GPL_NOTICE.md') 'TaskbarMonitorEnhanced_Setup.cs'=(Join-Path $Root 'installer\TaskbarMonitorEnhanced_Setup.cs') } @@ -107,7 +107,7 @@ foreach($entry in $CanonicalTextResources.GetEnumerator()){ Write-Host 'TBME_CANONICAL_TEXT_PAYLOAD=PASS' Build (Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj') -$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' if(!(Test-Path -LiteralPath $Setup)){throw 'Setup output missing.'} # SignPath Foundation requires uniform signed-artifact project and ProductVersion metadata. @@ -152,8 +152,8 @@ if([string]$v.Status -ne 'PASS' -or [int]$v.Resources -ne 25 -or $v.PawnIOBundle if($LASTEXITCODE -ne 0){throw 'Application contract suite failed.'} $manifest=[ordered]@{ - Version='1.7.4' - Build='V1_7_4_R256_FRESH_MAINTENANCE' + Version='1.7.5' + Build='V1_7_5_R265_SENSOR_CROSSUSER_GUARD' GeneratedUtc=[datetime]::UtcNow.ToString('o') Dependencies=[ordered]@{ LibreHardwareMonitor=[ordered]@{Version=[string]$Lock.dependencies.LibreHardwareMonitor.version;Url=$LhmUrl;SHA256=$LhmSha} diff --git a/build/Package-Signed-Setup.ps1 b/build/Package-Signed-Setup.ps1 index 49e2569f..76297234 100644 --- a/build/Package-Signed-Setup.ps1 +++ b/build/Package-Signed-Setup.ps1 @@ -10,10 +10,10 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $UnsignedMain=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' $StageBase=if($ContractFixtureOnly){Join-Path $Root 'artifacts\_r80_contract_only'}else{Join-Path $Root 'artifacts\signed\Setup\TO_SIGN'} -$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' $Proof=Join-Path $StageBase 'EMBEDDED_MAIN_PROOF.json' $Verification=Join-Path $StageBase 'SETUP_VERIFY.json' $CreatedStage=$false diff --git a/build/TaskbarMonitorEnhanced_Setup.csproj b/build/TaskbarMonitorEnhanced_Setup.csproj index b54da0c1..6504e943 100644 --- a/build/TaskbarMonitorEnhanced_Setup.csproj +++ b/build/TaskbarMonitorEnhanced_Setup.csproj @@ -1,5 +1,5 @@ - net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.4_out\Setup\false + net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.5_out\Setup\false _package\TaskbarMonitorEnhanced.exefalse @@ -23,7 +23,7 @@ - + \ No newline at end of file diff --git a/build/Test-Native-Presentation-R265.ps1 b/build/Test-Native-Presentation-R265.ps1 new file mode 100644 index 00000000..f5a5f226 --- /dev/null +++ b/build/Test-Native-Presentation-R265.ps1 @@ -0,0 +1,300 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel,[switch]$UseInstalledCandidate) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R146_REAL_TEST_ONLY_FRESH_HOSTED_WINDOWS_FRAMEWORK'} +Add-Type -AssemblyName System.Drawing,System.Windows.Forms,UIAutomationClient,UIAutomationTypes +$native=@' +using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices; +public static class R146Native { + [StructLayout(LayoutKind.Sequential)] public struct Rect {public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential)] public struct Point {public int X,Y;} + [StructLayout(LayoutKind.Sequential)] public struct Mouse {public int x,y;public uint data,flags,time;public UIntPtr extra;} + [StructLayout(LayoutKind.Explicit)] public struct Union {[FieldOffset(0)] public Mouse m;} + [StructLayout(LayoutKind.Sequential)] public struct Input {public uint type;public Union u;} + public delegate bool EnumFn(IntPtr h,IntPtr data); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p,EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out Rect r); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetWindowLongPtr(IntPtr h,int n); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint n); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h,StringBuilder s,int n); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c,string t); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int s); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l); + [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr extra); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out Point p); + [DllImport("user32.dll",SetLastError=true)] public static extern uint SendInput(uint n,Input[] p,int size); + public static void Key(byte code){keybd_event(code,0,0,UIntPtr.Zero);keybd_event(code,0,2,UIntPtr.Zero);} + public static void Click(int x,int y,bool right){Point old;GetCursorPos(out old);SetCursorPos(x,y);try{ + Input down=new Input{u=new Union{m=new Mouse{flags=right?8u:2u}}}; + Input up=new Input{u=new Union{m=new Mouse{flags=right?16u:4u}}}; + if(Marshal.SizeOf(typeof(Input))!=40||SendInput(2,new[]{down,up},40)!=2)throw new Exception("Input not delivered"); + }finally{SetCursorPos(old.X,old.Y);}} + public static IntPtr[] Owned(uint pid,IntPtr tray){var list=new List();EnumFn f=(h,d)=>{uint p;GetWindowThreadProcessId(h,out p);if(p==pid&&!list.Contains(h))list.Add(h);return true;};EnumWindows(f,IntPtr.Zero);EnumChildWindows(tray,f,IntPtr.Zero);return list.ToArray();} + public static bool Above(IntPtr a,IntPtr b){for(int i=0;i<512;i++){b=GetWindow(b,3);if(b==IntPtr.Zero)return false;if(a==b)return true;}return false;} + public static string Title(IntPtr h){var s=new StringBuilder(256);GetWindowText(h,s,s.Capacity);return s.ToString();} +} +'@ +Add-Type -TypeDefinition $native -Language CSharp +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Private -ne $true){throw 'R146_NOT_EXACT_PRIVATE_BUILD_ARTIFACT'} +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +if((Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R146_SETUP_SHA'} +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R265_'+$HostLabel) +if((Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R265_NATIVE_OUTPUT_OR_PROCESS_PRESTATE'} +if($UseInstalledCandidate){ + if(!(Test-Path $app) -or !(Test-Path $reg) -or (Test-Path (Join-Path $app '.tbme-upgrade'))){throw 'R265_VERIFIED_EXISTING_CANDIDATE_REQUIRED'} + $s=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($s.Version -cne '1.7.5' -or (Get-FileHash (Join-Path $app 'TaskbarMonitorEnhanced.exe') -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R265_NATIVE_INSTALLED_SHA_OR_VERSION'} +}else{if((Test-Path $app) -or (Test-Path $reg)){throw 'R265_NATIVE_FRESH_VM_REQUIRED'}} +New-Item -ItemType Directory -Path $out|Out-Null +$visualFailures=New-Object 'Collections.Generic.List[string]' +$phase='INSTALL';$status='FAIL';$failure='';$process=$null;$full=$null;$taskbar=[IntPtr]::Zero;$hidTaskbar=$false;$evidence=@() +function Box([IntPtr]$h){$r=New-Object R146Native+Rect;if(![R146Native]::GetWindowRect($h,[ref]$r)){throw 'R146_RECT_FAILED'};return New-Object Drawing.Rectangle($r.L,$r.T,($r.R-$r.L),($r.B-$r.T))} +function Pump([int]$ms){$stop=[Diagnostics.Stopwatch]::StartNew();while($stop.ElapsedMilliseconds -lt $ms){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 25}} +function Capture([IntPtr]$h,[string]$label){ + $r=Box $h;$file=$label+'.png';$b=New-Object Drawing.Bitmap($r.Width,$r.Height) + try{ + $g=[Drawing.Graphics]::FromImage($b);try{$g.CopyFromScreen($r.Location,[Drawing.Point]::Empty,$r.Size)}finally{$g.Dispose()} + $edges=0;for($y=0;$y -lt $b.Height;$y++){for($x=1;$x -lt $b.Width;$x++){ + $a=$b.GetPixel($x,$y);$z=$b.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$edges++} + }} + $b.Save((Join-Path $out $file),[Drawing.Imaging.ImageFormat]::Png) + if($edges -lt 60){$visualFailures.Add(('R146_PHYSICAL_GLYPHS_MISSING '+$label+' EDGES='+$edges))} + return @{State=$label;File=$file;SHA256=(Get-FileHash (Join-Path $out $file) -Algorithm SHA256).Hash;Edges=$edges;Rect=@($r.X,$r.Y,$r.Width,$r.Height);Parent=[R146Native]::GetParent($h).ToInt64();Owner=[R146Native]::GetWindow($h,4).ToInt64();OwnerMemory=[R146Native]::GetWindowLongPtr($h,-8).ToInt64();PreviousSibling=[R146Native]::GetWindow($h,3).ToInt64();ExStyle=[R146Native]::GetWindowLongPtr($h,-20).ToInt64();Style=[R146Native]::GetWindowLongPtr($h,-16).ToInt64();Foreground=[R146Native]::GetForegroundWindow().ToInt64()} + }finally{$b.Dispose()} +} +# R163 fork-only paint readiness. WS_VISIBLE is not proof of actual text ink. +# Same RGB edge definition as Capture(): adjacent channel-sum delta >=120. +# ZERO edges must NEVER satisfy 60-edge native acceptance. No product code change. +function Probe-R163PhysicalInk([IntPtr]$h,[string]$label,[bool]$saveFrame){ + $rect=Box $h + if($rect.Width -lt 80 -or $rect.Height -lt 16 -or $rect.Height -ge 100){ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error='R163_INVALID_WINDOW_GEOMETRY'} + } + $bitmap=New-Object Drawing.Bitmap($rect.Width,$rect.Height) + try{ + try{ + $g=[Drawing.Graphics]::FromImage($bitmap) + try{$g.CopyFromScreen($rect.Location,[Drawing.Point]::Empty,$rect.Size)} + finally{$g.Dispose()} + }catch{ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error=('R163_COPY_FROM_SCREEN_FAILED '+$_.Exception.Message)} + } + $ink=0 + $colors=New-Object 'System.Collections.Generic.HashSet[int]' + for($y=0;$y -lt $bitmap.Height;$y++){ + for($x=0;$x -lt $bitmap.Width;$x++){ + $a=$bitmap.GetPixel($x,$y) + $rgb=([int]$a.R -shl 16) -bor ([int]$a.G -shl 8) -bor [int]$a.B + [void]$colors.Add($rgb) + if($x -ge 1){ + $z=$bitmap.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$ink++} + } + } + } + $file='';$sha='';$bytes=0 + if($saveFrame){ + $file=$label+'.png' + $full=Join-Path $out $file + if(Test-Path $full){throw ('R163_PROBE_IMAGE_OVERWRITE_REFUSED '+$file)} + $bitmap.Save($full,[Drawing.Imaging.ImageFormat]::Png) + $sha=(Get-FileHash $full -Algorithm SHA256).Hash + $bytes=(Get-Item $full).Length + } + return @{Edges=$ink;Colors=$colors.Count;Bytes=$bytes;File=$file;SHA256=$sha;Error='';Rect=@($rect.X,$rect.Y,$rect.Width,$rect.Height)} + }finally{$bitmap.Dispose()} +} + +function Assert-Visible([IntPtr]$h,[Drawing.Rectangle]$original,[string]$label,[bool]$top){ + if(![R146Native]::IsWindowVisible($h)){throw ('R146_MONITOR_HIDDEN '+$label)} + $r=Box $h + if($r.X -ne $original.X -or $r.Y -ne $original.Y -or $r.Height -ne $original.Height -or [Math]::Abs($r.Width-$original.Width) -gt 96){throw ('R146_UNEXPECTED_MOVEMENT '+$label)} + # R153: retain both direct owner-memory and WS_POPUP GetParent checks. + # R152 real Start evidence showed GW_OWNER=0 but GetParent=the exact + # taskbar and 877 physical glyph edges. Never let a relationship-walk + # API substitute for pixels; never waive actual owner-memory or geometry. + $ownerMemory=[R146Native]::GetWindowLongPtr($h,-8) + $popupOwner=[R146Native]::GetParent($h) + if($ownerMemory -ne $taskbar -or $popupOwner -ne $taskbar){ + $nativeOwner=[R146Native]::GetWindow($h,4) + $directTray=[R146Native]::FindWindow('Shell_TrayWnd',$null) + $frame=Capture $h ($label+'_OWNER_FAILURE') + [IO.File]::WriteAllText((Join-Path $out ($label+'_NATIVE_FAILURE.json')),(@{ExpectedOwner=$taskbar.ToInt64();ActualOwner=$nativeOwner.ToInt64();DirectOwnerMemory=$ownerMemory.ToInt64();PopupOwner=$popupOwner.ToInt64();DirectTaskbar=$directTray.ToInt64();Frame=$frame}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + throw ('R152_TASKBAR_OWNER_DRIFT '+$label+' EXPECTED='+$taskbar.ToInt64()+' ACTUAL='+$nativeOwner.ToInt64()+' DIRECT='+$directTray.ToInt64()) + } + if(([R146Native]::GetWindowLongPtr($h,-16).ToInt64() -band 0x40000000) -ne 0){throw ('R151_MONITOR_BECAME_CHILD '+$label)} + if($top -and [R146Native]::Above($taskbar,$h)){throw ('R146_MONITOR_UNDER_TASKBAR '+$label)} +} +try{ + if(!$UseInstalledCandidate){ + $install=Start-Process -FilePath $setup -ArgumentList @('/install','/quiet','/desktop=0','/startup=0') -PassThru + if(!$install.WaitForExit(190000) -or $install.ExitCode -ne 0){throw 'R265_SETUP_DID_NOT_FINISH'} + } + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe';$config=Join-Path $app 'config.json' + if((Get-FileHash $main -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R146_INSTALLED_MAIN_SHA'} + $taskbar=[R146Native]::FindWindow('Shell_TrayWnd',$null) + if($taskbar -eq [IntPtr]::Zero -or ![R146Native]::IsWindowVisible($taskbar)){throw 'R146_REAL_TASKBAR_MISSING'} + $assembly=[Reflection.Assembly]::LoadFrom($main) + $shell=$assembly.GetType('TaskbarMonitorEnhanced.ShellUi',$true) + $shellMethod=$shell.GetMethod('IsStartShellForeground',[Reflection.BindingFlags]'Static,Public,NonPublic') + foreach($mode in @('plain1','plain10','glass0','opaque100')){ + $phase=$mode + $pct=if($mode -ceq 'plain1'){0.01}elseif($mode -ceq 'plain10'){0.10}elseif($mode -ceq 'glass0'){0.0}else{1.0} + $fixture=@{Theme='Retro Terminal';Opacity=$pct;GlassMode=($mode -ceq 'glass0');OpacityWhenGlassOff=0.10;ConfigSchemaVersion=8;StartWithWindows=$false;ShowSparklines=$true;UpdateIntervalMs=1000} + [IO.File]::WriteAllText($config,($fixture|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $process=Start-Process -FilePath $main -WorkingDirectory $app -PassThru + $readyClock=[Diagnostics.Stopwatch]::StartNew();$readyObservations=@() + Pump 5000 # Keep original warm-up and strict 20s total readiness deadline. + $monitor=[IntPtr]::Zero;$paintFirst=$false;$paintLast=$null + $lastEligible=[IntPtr]::Zero + do{ + $process.Refresh() + if($process.HasExited){throw ('R158_MAIN_EXITED_BEFORE_READY '+$mode+' CODE='+$process.ExitCode)} + $observed=@() + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){ + $visible=[R146Native]::IsWindowVisible($h);$r=Box $h + $one=@{Handle=$h.ToInt64();Visible=$visible;Rect=@($r.X,$r.Y,$r.Width,$r.Height)} + if($visible -and $r.Width -ge 80 -and $r.Height -ge 16 -and $r.Height -lt 100 -and (Box $taskbar).Contains($r)){ + $lastEligible=$h + # Persist FIRST physical frame, whether ink is present or missing. + $paint=Probe-R163PhysicalInk $h ($mode+'_R163_FIRST_PAINT') (!$paintFirst) + $paintFirst=$true + $paintLast=$paint + $one.PaintEdges=$paint.Edges;$one.PaintColors=$paint.Colors;$one.PaintError=$paint.Error + if($paint.Edges -ge 60 -and [string]$paint.Error -ceq ''){ + $monitor=$h;$observed+=@($one);break + } + } + $observed+=@($one) + } + if($readyObservations.Count -lt 24){ + $readyObservations+=@{ElapsedMs=$readyClock.ElapsedMilliseconds;Windows=$observed} + } + if($monitor -eq [IntPtr]::Zero){Pump 200} + }while($monitor -eq [IntPtr]::Zero -and $readyClock.ElapsedMilliseconds -lt 20000) + $readyClock.Stop() + $timeoutProbe=$null + if($monitor -eq [IntPtr]::Zero -and $lastEligible -ne [IntPtr]::Zero){ + $timeoutProbe=Probe-R163PhysicalInk $lastEligible ($mode+'_R163_TIMEOUT_PAINT') $true + } + $ready=[ordered]@{Mode=$mode;Pid=$process.Id;Ready=($monitor -ne [IntPtr]::Zero); + PaintReady=($monitor -ne [IntPtr]::Zero -and $null -ne $paintLast -and $paintLast.Edges -ge 60); + PaintThresholdEdges=60;OriginalRgbDelta=120; + LastMeasuredEdges=if($null -eq $paintLast){-1}else{$paintLast.Edges}; + LastMeasuredColors=if($null -eq $paintLast){0}else{$paintLast.Colors}; + TimeoutFrame=$timeoutProbe; + ElapsedMilliseconds=$readyClock.ElapsedMilliseconds;HardDeadlineMs=20000; + Observations=$readyObservations;ProductSourceChanged=$false; + NoThresholdWeakening=$true;SeparateTaskbarHideInvariantRequired=$true} + [IO.File]::WriteAllText((Join-Path $out ('R158_READY_'+$mode+'.json')),($ready|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if($monitor -eq [IntPtr]::Zero){ + if($lastEligible -ne [IntPtr]::Zero){ + throw ('R163_PHYSICAL_PAINT_NOT_READY_WITHIN_20_SECONDS '+$mode+ + ' EDGES='+$ready.LastMeasuredEdges+' COLORS='+$ready.LastMeasuredColors) + } + throw ('R158_NATIVE_WINDOW_NOT_READY_WITHIN_20_SECONDS '+$mode) + } + if(!$ready.PaintReady -or $ready.LastMeasuredEdges -lt 60){ + throw ('R163_UNPROVEN_NATIVE_PAINT '+$mode) + } + Write-Output ('R163_NATIVE_AND_ACTUAL_INK_READY MODE='+$mode+' PID='+$process.Id+ + ' ELAPSED_MS='+$readyClock.ElapsedMilliseconds+' EDGES='+$ready.LastMeasuredEdges) + $original=Box $monitor;$top=$true + # Original Capture() remains the separate final pixel threshold acceptance. + $evidence+=Capture $monitor ($mode+'_NORMAL') + # Real right-click and invoke the actual installed product's Settings menu item. + [R146Native]::Click(($original.X+40),($original.Y+($original.Height/2)),$true);Pump 650 + Assert-Visible $monitor $original ($mode+'_MENU') $top + $evidence+=Capture $monitor ($mode+'_MENU') + $menuItem=$null + $condition=New-Object Windows.Automation.AndCondition([Windows.Automation.Condition[]]@( + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::ProcessIdProperty,[int]$process.Id)), + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::NameProperty,'Settings...')))) + $menuItem=[Windows.Automation.AutomationElement]::RootElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$condition) + if($null -eq $menuItem){throw 'R146_REAL_SETTINGS_MENU_ITEM_NOT_FOUND'} + $invoke=$menuItem.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern);$invoke.Invoke();Pump 1500 + $settings=[IntPtr]::Zero + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){if([R146Native]::Title($h) -like '* - Settings' -and [R146Native]::IsWindowVisible($h)){$settings=$h;break}} + if($settings -eq [IntPtr]::Zero -or [R146Native]::GetForegroundWindow() -ne $settings){throw 'R146_SETTINGS_NOT_REAL_FOREGROUND'} + $work=[Windows.Forms.Screen]::FromHandle($settings).WorkingArea + if(!$work.Contains((Box $settings))){throw 'R146_SETTINGS_EXTENDS_BEHIND_TASKBAR'} + Assert-Visible $monitor $original ($mode+'_SETTINGS') $top + $evidence+=Capture $monitor ($mode+'_SETTINGS') + [void][R146Native]::SendMessage($settings,0x0010,[IntPtr]::Zero,[IntPtr]::Zero);Pump 500 + [R146Native]::Key(0x5B);Pump 1200 + $args=[object[]]@('','',[IntPtr]::Zero) + if(!$shellMethod.Invoke($null,$args)){throw 'R146_GENUINE_START_FOREGROUND_NOT_VERIFIED'} + Assert-Visible $monitor $original ($mode+'_START') $top + $evidence+=Capture $monitor ($mode+'_START') + [R146Native]::Key(0x1B);Pump 900 + Assert-Visible $monitor $original ($mode+'_AFTER_START') $top + # A real foreground fullscreen window covering this monitor and its taskbar. + $full=New-Object Windows.Forms.Form + $full.FormBorderStyle='None';$full.StartPosition='Manual';$full.Bounds=[Windows.Forms.Screen]::FromHandle($taskbar).Bounds + $full.TopMost=$true;$full.BackColor=[Drawing.Color]::FromArgb(24,40,56);$full.Text='R146 disposable fullscreen witness';$full.Show();Pump 300 + [R146Native]::Click(($full.Left+100),($full.Top+100),$false);Pump 1500 + if([R146Native]::GetForegroundWindow() -ne $full.Handle){throw 'R146_FULLSCREEN_NOT_FOREGROUND'} + if([R146Native]::IsWindowVisible($monitor)){throw 'R146_MONITOR_VISIBLE_WITHOUT_TASKBAR_IN_FULLSCREEN'} + $evidence+=@{State=$mode+'_FULLSCREEN';Hidden=$true;Foreground=$full.Handle.ToInt64()} + $full.Close();$full.Dispose();$full=$null;Pump 1500 + Assert-Visible $monitor $original ($mode+'_RETURN_FULLSCREEN') $top + $evidence+=Capture $monitor ($mode+'_RETURN_FULLSCREEN') + # Simulate taskbar visibility notification, only on disposable runner; always restore. + [void][R146Native]::ShowWindow($taskbar,0);$hidTaskbar=$true;Pump 1100 + if([R146Native]::IsWindowVisible($monitor)){ + # Do not suppress this independent FAIL gate. Collect the real HWND/shell + # visibility and optional screen-pixel evidence before the existing throw. + $forkHide=[ordered]@{ + Mode=$mode;MainPid=$process.Id; + TaskbarVisible=[R146Native]::IsWindowVisible($taskbar) + MonitorVisible=[R146Native]::IsWindowVisible($monitor) + TaskbarRect=@( (Box $taskbar).X,(Box $taskbar).Y,(Box $taskbar).Width,(Box $taskbar).Height ) + MonitorRect=@( (Box $monitor).X,(Box $monitor).Y,(Box $monitor).Width,(Box $monitor).Height ) + PopupOwner=[R146Native]::GetParent($monitor).ToInt64() + MemoryOwner=[R146Native]::GetWindowLongPtr($monitor,-8).ToInt64() + ProductSourceChanged=$false + OriginalHideInvariantRetained=$true + } + [IO.File]::WriteAllText((Join-Path $out ($mode+'_R163_TASKBAR_HIDE_FAILURE.json')), + ($forkHide|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + throw 'R146_MONITOR_IGNORES_TASKBAR_HIDE' + } + [void][R146Native]::ShowWindow($taskbar,4);$hidTaskbar=$false;Pump 1100 + Assert-Visible $monitor $original ($mode+'_TASKBAR_REAPPEARS') $top + $process.Refresh();$cpu0=$process.CPU;$clock=[Diagnostics.Stopwatch]::StartNew();Pump 5000;$clock.Stop();$process.Refresh() + $evidence+=@{State=$mode+'_CPU';Seconds=$clock.Elapsed.TotalSeconds;CpuSeconds=$process.CPU-$cpu0;PercentOfOneCore=100*($process.CPU-$cpu0)/$clock.Elapsed.TotalSeconds;Status='MEASURED_NOT_USER_PC_ACCEPTANCE'} + [void][R146Native]::SendMessage($monitor,0x0010,[IntPtr]::Zero,[IntPtr]::Zero) + if(!$process.WaitForExit(10000)){throw 'R146_OWN_TEST_PROCESS_DID_NOT_EXIT'};$process.Dispose();$process=$null + } + if($visualFailures.Count){throw ($visualFailures -join ';')} + $status='PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' +}catch{$failure=$_.Exception.ToString().Substring(0,[Math]::Min(3500,$_.Exception.ToString().Length))} +finally{ + if($hidTaskbar -and $taskbar -ne [IntPtr]::Zero){[void][R146Native]::ShowWindow($taskbar,4)} + [R146Native]::Key(0x1B) + if($null -ne $full){$full.Close();$full.Dispose()} + if($null -ne $process){try{if(!$process.HasExited){$process.Kill()}}catch{}} + try{ + $logs=@(Get-ChildItem (Join-Path $app 'Logs') -Filter 'tbme_csharp_*.log' -File -ErrorAction Stop) + foreach($log in $logs){ + Get-Content -LiteralPath $log.FullName -Tail 1600 | Select-String -Pattern 'PRESENTATION|GLASS_NATIVE|GLASS_COMPOSITION|TASKBAR_|VISIBILITY_WATCHDOG|LOW_PRESSURE|CONTEXT_MENU|SETTINGS|START_|RECOVERY' | ForEach-Object Line | Set-Content -LiteralPath (Join-Path $out ('DIAG_'+$log.Name)) -Encoding UTF8 + } + }catch{Write-Output ('R148_LOG_COLLECTION_WARNING '+$_.Exception.Message)} + $r=[ordered]@{Revision='R265';Status=$status;Phase=$phase;Failure=$failure;Host=$HostLabel;EventCommit=$env:GITHUB_SHA;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;Evidence=$evidence;TestedModes=@('plain1','plain10','glass0','opaque100');ExpectedTaskbarOwner=$taskbar.ToInt64();PersonalComputerTouched=$false;Proof='Installed candidate + native HWND + real screenshots + real input; not synthetic taskbar paint'} + [IO.File]::WriteAllText((Join-Path $out 'R265_NATIVE.json'),($r|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 12 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R146_NATIVE_FAIL '+$failure)} diff --git a/build/Test-NoPawnIO-OssRelease.ps1 b/build/Test-NoPawnIO-OssRelease.ps1 index ec582468..26d5e1e7 100644 --- a/build/Test-NoPawnIO-OssRelease.ps1 +++ b/build/Test-NoPawnIO-OssRelease.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $LegacyDir=Join-Path $Root 'artifacts\_r85_forbidden_pawnio_fixture' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' -$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' $Main=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' $LegacyVerify=Join-Path $LegacyDir 'legacy_verify.json' $Reader=Join-Path $PSScriptRoot 'Verify-Embedded-Main.ps1' if(Test-Path -LiteralPath $LegacyDir){throw 'R85 UNSAFE RERUN: FORBIDDEN FIXTURE ALREADY EXISTS'} @@ -56,7 +56,7 @@ try { Write-Output ('R85_OSS_NO_PROPRIETARY_BUNDLE_PASS productionEmbedded=False negativeLegacyResources='+$v.Resources+' negativeRejected=True') } finally { if($created -and (Test-Path -LiteralPath $LegacyDir)){ - $allow=@('TaskbarMonitorEnhanced_Setup_1.7.4.exe','TaskbarMonitorEnhanced_Setup_1.7.4.exe.config','legacy_verify.json') + $allow=@('TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe.config','legacy_verify.json') $objects=@(Get-ChildItem -LiteralPath $LegacyDir -Force) if(@($objects | Where-Object {$_.PSIsContainer -or $_.Name -notin $allow}).Count -ne 0){ throw ('R85 DEGRADED: UNKNOWN FILE IN OWNED STAGING; PRESERVED '+$LegacyDir) diff --git a/build/Test-R265-CrossUserGuard.ps1 b/build/Test-R265-CrossUserGuard.ps1 new file mode 100644 index 00000000..d604afd1 --- /dev/null +++ b/build/Test-R265-CrossUserGuard.ps1 @@ -0,0 +1,195 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + ($env:GITHUB_REF -cne 'refs/heads/release/r265-v175-sensor-crossuser-safety' -and $env:GITHUB_REF -cne 'refs/heads/main') -or + $env:COMPUTERNAME -match '(?i)EMAD|ALI|LAPTOP'){ + throw 'R265_DISPOSABLE_GUEST_ONLY' +} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($identity) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R265_EXISTING_GITHUB_VM_ADMIN_REQUIRED' +} +$helper=Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1' +$expected='84A7DC21012A63C5959447D7234B365CF926028016A81B742F891951B191E7FB' +$task='TaskbarMonitorEnhanced Sensor Broker' +$user='TBME265'+$HostLabel +$description='R265 no-trigger owned task test' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R265_'+$HostLabel) +$receipt=Join-Path $out 'R265_CROSSUSER.json' +$copy=Join-Path $out 'R265_OnlyOriginalPreMutationGuard.ps1' +$payload=Join-Path $out 'EmptyPayload' +$fixtureCreated=$false;$taskOwned=$false;$accountRemoved=$false;$taskRemoved=$false +$otherSID='';$ownerSID=[string]$identity.User.Value +$otherPrincipal='';$currentPrincipal='' +$blocked=@();$allowed=@();$status='FAIL';$failure='';$phase='PREFLIGHT' +function Fail([string]$reason){throw ('R265_'+$reason)} +function VerifyTask{ + $x=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop + $xml=[xml](Export-ScheduledTask -TaskName $task) + if([string]$xml.Task.RegistrationInfo.Description -cne $description -or + @($x.Triggers|Where-Object {$null -ne $_}).Count){Fail 'TEST_TASK_UNEXPECTED_OWNERSHIP_OR_TRIGGER'} + return $x +} +function InvokePrefix([string]$mode,[string]$root,[bool]$fullHelper=$false){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName='powershell.exe' + $selected=if($fullHelper){$helper}else{$copy} + $psi.Arguments='-NoProfile -NonInteractive -File "'+$selected+'" -Mode '+$mode+' -PayloadDir "'+$payload+'" -AppRoot "'+$root+'" -UserId "'+$identity.Name+'"' + $psi.WorkingDirectory=$out;$psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){Fail 'PREFIX_NOT_STARTED'} + try{ + if(!$proc.WaitForExit(25000)){Fail ('GUARD_PREFIX_TIMEOUT_'+$mode)} + return [ordered]@{Exit=[int]$proc.ExitCode;Out=$proc.StandardOutput.ReadToEnd().Trim();Err=$proc.StandardError.ReadToEnd().Trim()} + }finally{$proc.Dispose()} +} +if((Test-Path $out) -or (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) -or + (Get-LocalUser -Name $user -ErrorAction SilentlyContinue) -or + (Test-Path (Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker'))){ + Fail 'HOST_NOT_FRESH_NO_SHARED_SENSOR_TASK' +} +if((Get-FileHash $helper).Hash -cne $expected){Fail 'VERSIONED_TEST_HELPER_SHA_MISMATCH'} +New-Item -ItemType Directory -Path $out|Out-Null +try{ + $phase='COPY_ONLY_EXACT_BEFORE_MUTATION_HELPER_PREFIX' + $full=[IO.File]::ReadAllText($helper,[Text.Encoding]::UTF8) + $marker='# R264-SENSOR-GUARD-BOUNDARY-END' + if($full.Split(@($marker),[StringSplitOptions]::None).Count -ne 2){Fail 'GUARD_BOUNDARY_NOT_UNIQUE'} + $end=$full.IndexOf($marker)+$marker.Length + $uninstall=$full.IndexOf(('if('+'$Mode'+" -eq 'Uninstall'){")) + if($end -lt 3000 -or $end -ge $uninstall){Fail 'GUARD_IS_NOT_PRE_UNINSTALL'} + $only=$full.Substring(0,$end)+[Environment]::NewLine+"Write-Output 'R265_GUARD_PASSED_SAME_USER'"+[Environment]::NewLine + [IO.File]::WriteAllText($copy,$only,[Text.UTF8Encoding]::new($false)) + $tk=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($copy,[ref]$tk,[ref]$errors) + if(@($errors).Count){Fail 'GUARD_ONLY_PREFIX_PARSE_FAILED'} + New-Item -ItemType Directory -Path $payload|Out-Null + $phase='CREATE_ONLY_NEW_DISPOSABLE_LOCAL_USER' + $bytes=[byte[]]::new(32) + $rng=[Security.Cryptography.RandomNumberGenerator]::Create() + try{$rng.GetBytes($bytes)}finally{$rng.Dispose()} + $secure=ConvertTo-SecureString ([Convert]::ToBase64String($bytes)+'R9!') -AsPlainText -Force + $u=New-LocalUser -Name $user -Password $secure -AccountNeverExpires -PasswordNeverExpires -Description 'R265 test-only temporary local user' -ErrorAction Stop + $otherSID=[string]$u.SID.Value + $fixtureCreated=$true + if($otherSID -notmatch '^S-1-5-21-' -or $otherSID -ceq $ownerSID){Fail 'SID_NOT_OTHER_USER'} + $phase='REGISTER_OTHER_USER_FIXED_LEGACY_TASK_WITHOUT_TRIGGER' + $otherName=[Security.Principal.SecurityIdentifier]::new($otherSID).Translate([Security.Principal.NTAccount]).Value + $action=New-ScheduledTaskAction -Execute (Join-Path $env:SystemRoot 'System32\whoami.exe') + $settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::FromMinutes(1)) + $other=New-ScheduledTaskPrincipal -UserId $otherName -LogonType Interactive -RunLevel Limited + Register-ScheduledTask -TaskName $task -Principal $other -Action $action -Settings $settings -Description $description -ErrorAction Stop|Out-Null + $taskOwned=$true + $otherPrincipal=[string](VerifyTask).Principal.UserId + if(!$otherPrincipal.EndsWith($user,[StringComparison]::OrdinalIgnoreCase)){Fail 'OTHER_TASK_NOT_OTHER_USER'} + $phase='INSTALL_AND_UNINSTALL_MUST_REFUSE_OTHER_USER_TASK_WITHOUT_MODIFICATION' + foreach($mode in @('Install','Uninstall')){ + $app=Join-Path $out ('OtherRoot_'+$mode) + New-Item -ItemType Directory -Path $app|Out-Null + $run=InvokePrefix $mode $app + $result=Join-Path $app 'Logs\sensor_install_result.json' + if($run.Exit -ne 0 -or !(Test-Path $result)){Fail ('OTHER_USER_NOT_REFUSED_'+$mode)} + $j=Get-Content $result -Raw|ConvertFrom-Json + if($j.Status -cne 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' -or + $j.Architecture -cne 'SHARED_PROTECTED_SENSOR_LAYER_LEFT_UNTOUCHED' -or + $run.Out -match 'R265_GUARD_PASSED_SAME_USER'){Fail ('REFUSAL_STATUS_WRONG_'+$mode)} + if([string](VerifyTask).Principal.UserId -cne $otherPrincipal){Fail ('OTHER_TASK_CHANGED_'+$mode)} + $blocked+=$mode + } + $phase='RUN_REAL_FULL_ELEVATED_HELPER_AGAINST_OTHER_ACCOUNT_OWNED_TASK' + # Unlike prefix tests above, run all released helper source. It MUST exit + # before real shared broker drivers/files or any global process mutation. + foreach($mode in @('Install','Uninstall')){ + $app=Join-Path $out ('WholeHelperOtherRoot_'+$mode) + New-Item -ItemType Directory -Path $app|Out-Null + $run=InvokePrefix $mode $app $true + $result=Join-Path $app 'Logssensor_install_result.json' + if($run.Exit -ne 0 -or !(Test-Path $result)){Fail ('REAL_FULL_HELPER_OTHER_USER_NOT_REFUSED_'+$mode+'_'+$run.Err)} + $j=Get-Content $result -Raw|ConvertFrom-Json + if($j.Status -cne 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' -or + $j.Architecture -cne 'SHARED_PROTECTED_SENSOR_LAYER_LEFT_UNTOUCHED'){ + Fail ('REAL_FULL_HELPER_ENTERED_UNSAFE_CODE_'+$mode) + } + if([string](VerifyTask).Principal.UserId -cne $otherPrincipal){Fail ('OTHER_GLOBAL_TASK_MODIFIED_BY_FULL_HELPER_'+$mode)} + $blocked+=('WholeHelper_'+$mode) + } + $phase='SAME_USER_TASK_SHOULD_PASS_GUARD_ONLY_NOT_HELPER_MUTATIONS' + $own=New-ScheduledTaskPrincipal -UserId $identity.Name -LogonType Interactive -RunLevel Limited + Register-ScheduledTask -TaskName $task -Principal $own -Action $action -Settings $settings -Description $description -Force -ErrorAction Stop|Out-Null + $currentPrincipal=[string](VerifyTask).Principal.UserId + if(!$currentPrincipal.EndsWith($env:USERNAME,[StringComparison]::OrdinalIgnoreCase)){Fail 'CURRENT_USER_TASK_NOT_OWNER'} + foreach($mode in @('Install','Uninstall')){ + $app=Join-Path $out ('OwnRoot_'+$mode) + New-Item -ItemType Directory -Path $app|Out-Null + $run=InvokePrefix $mode $app + if($run.Exit -ne 0 -or $run.Out -notmatch 'R265_GUARD_PASSED_SAME_USER'){ + Fail ('OWN_ACCOUNT_FALSE_REFUSAL_'+$mode+'_'+$run.Err) + } + if(!(VerifyTask)){Fail 'CURRENT_ACCOUNT_TASK_CHANGED'} + $allowed+=$mode + } + $phase='CLEANUP_ONLY_OWNED_GUEST_TASK_AND_USER' + if(!(VerifyTask)){Fail 'TASK_NOT_OWNED_AT_CLEANUP'} + Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction Stop + $taskOwned=$false + $taskRemoved=($null -eq (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue)) + if(!$taskRemoved){Fail 'OWNED_TASK_NOT_REMOVED_BEFORE_FRESH_GUARD'} + $phase='FRESH_SINGLE_USER_NO_GLOBAL_TASK_AND_NO_SHARED_BROKER_MUST_NOT_FALSE_REFUSE' + foreach($mode in @('Install','Uninstall')){ + $app=Join-Path $out ('FreshNoTaskRoot_'+$mode) + New-Item -ItemType Directory -Path $app|Out-Null + $run=InvokePrefix $mode $app + if($run.Exit -ne 0 -or $run.Out -notmatch 'R265_GUARD_PASSED_SAME_USER'){ + Fail ('EMPTY_GLOBAL_TASK_GUARD_FALSE_REFUSED_'+$mode+'_'+$run.Err) + } + $allowed+=('FreshNoTask_'+$mode) + } + $found=Get-LocalUser -Name $user -ErrorAction Stop + if([string]$found.SID.Value -cne $otherSID){Fail 'LOCAL_USER_IDENTITY_CHANGED'} + Disable-LocalUser -Name $user -ErrorAction Stop + Remove-LocalUser -Name $user -ErrorAction Stop + $fixtureCreated=$false + $accountRemoved=($null -eq (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) + if(!$taskRemoved -or !$accountRemoved){Fail 'FIXTURES_NOT_REMOVED'} + $status='PASS_OTHER_USER_SENSOR_TASK_PRESERVED_AND_SAME_OWNER_GUARD_ALLOWED' +}catch{$failure=$_.Exception.GetType().Name+': '+$_.Exception.Message} +finally{ + try{ + if($taskOwned -and (VerifyTask)){ + Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction Stop + $taskRemoved=($null -eq (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue)) + } + }catch{$status='FAIL_TASK_CLEANUP_UNCERTAIN';$failure+=' | '+$_.Exception.Message} + try{ + if($fixtureCreated){ + $x=Get-LocalUser -Name $user -ErrorAction SilentlyContinue + if($x -and [string]$x.SID.Value -ceq $otherSID){ + Disable-LocalUser -Name $user -ErrorAction Stop + Remove-LocalUser -Name $user -ErrorAction Stop + $accountRemoved=($null -eq (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) + } + } + }catch{$status='FAIL_ACCOUNT_CLEANUP_UNCERTAIN';$failure+=' | '+$_.Exception.Message} + $r=[ordered]@{ + Revision='R264';Status=$status;Guest=$HostLabel;Phase=$phase;Failure=$failure + SourceGuardSHA256=$expected;ExactProductionHelperAfterGuardWasNotExecuted=$true + OtherUserSid=$otherSID;CurrentUserSid=$ownerSID + OtherTaskPrincipal=$otherPrincipal;CurrentTaskPrincipal=$currentPrincipal + OtherUserModesRefused=$blocked;SameUserModesPermitted=$allowed + TestOwnedTaskDeleted=$taskRemoved;GuestUserAccountDeleted=$accountRemoved + VerifiedRealFullHelperEarlyRefusal=($blocked.Count -eq 4) + NoSensorTaskTriggered=$true;NoPawnIODriverOrSetupExecuted=$true + OwnerPCAndPublicV174Unchanged=$true;NeedsNewVersionForAnyDeployment=$true + Utc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Compress -Depth 8) +} +if($status -notlike 'PASS_*' -or !$taskRemoved -or !$accountRemoved){ + throw ('R265_GUEST_SENSOR_OWNER_GUARD_NOT_PASS_'+$phase+'_'+$failure) +} diff --git a/build/Test-R265-FreshSameVersion-Disposable.ps1 b/build/Test-R265-FreshSameVersion-Disposable.ps1 new file mode 100644 index 00000000..e59d2a0b --- /dev/null +++ b/build/Test-R265-FreshSameVersion-Disposable.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$CandidateDir, + [Parameter(Mandatory=$true)][ValidateSet("A","B")][string]$HostLabel +) +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" + +# Test-only, exact source, genuine compiled .NET 4.x Windows setup. +# No run on owner's PC, no driver install direct from this harness. +if($env:GITHUB_ACTIONS -cne "true" -or + $env:RUNNER_ENVIRONMENT -cne "github-hosted" -or + $env:RUNNER_OS -cne "Windows" -or + $env:GITHUB_REPOSITORY -cne "GOD13emad/TaskbarMonitorEnhanced" -or + ($env:GITHUB_REF -cne "refs/heads/release/r265-v175-sensor-crossuser-safety" -and + $env:GITHUB_REF -cne "refs/heads/main") -or + $env:COMPUTERNAME -match "(?i)EMAD|ALI|LAPTOP" -or + -not [Environment]::UserInteractive) { + throw "R265_REFUSE_NON_DISPOSABLE_GITHUB_HOST" +} +$repo="GOD13emad/TaskbarMonitorEnhanced" +$app=Join-Path $env:LOCALAPPDATA "TaskbarMonitorEnhanced" +$reg="HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced" +$build=Get-Content -LiteralPath (Join-Path $CandidateDir "CANDIDATE.json") -Raw|ConvertFrom-Json -ErrorAction Stop +$setup=[IO.Path]::GetFullPath((Join-Path $CandidateDir "Setup/TaskbarMonitorEnhanced_Setup_1.7.5.exe")) +$out=Join-Path $env:RUNNER_TEMP ("TBME_R265_"+$HostLabel) +$receipt=Join-Path $out "R265_FRESH.json" +$folder=if($HostLabel -ceq "A"){[Environment]::GetFolderPath("ProgramFiles")}else{[Environment]::GetFolderPath("ProgramFilesX86")} +$legacy=Join-Path $folder "TaskbarMonitorEnhanced" +$marker=Join-Path $legacy "TaskbarMonitorEnhanced.exe" +$owned=$false;$expectedMarker="" +$status="FAIL";$phase="PREFLIGHT";$error="" +$installedExit=$null;$sameVersionExit=$null;$negativeExit=$null;$afterCollisionExit=$null +$original=@{};$originalRun="";$removed=false +function StopNow([string]$msg){throw ("R265_"+$msg)} +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return "MISSING"} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function RunCandidate([string]$arguments){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setup;$psi.Arguments=$arguments + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.WorkingDirectory=[IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($setup)) + $p=[Diagnostics.Process]::Start($psi) + if($null -eq $p){StopNow "CANDIDATE_SETUP_NOT_STARTED"} + try{ + if(!$p.WaitForExit(180000)){StopNow ("CANDIDATE_TIMEOUT_"+$p.Id)} + return [int]$p.ExitCode + } finally {$p.Dispose()} +} +function StartupValue{ + $key=Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -ErrorAction SilentlyContinue + if($null -eq $key){return "ABSENT"} + $v=$key.PSObject.Properties["TaskbarMonitorEnhanced"] + if($null -eq $v){return "ABSENT"} + return [string]$v.Value +} +function Snapshot{ + $paths=@("TaskbarMonitorEnhanced.exe","Uninstall.exe","config.json", + "config.json.bak","install_state.json","Source/TaskbarMonitorEnhanced.cs", + "Source/TaskbarGlass.cs","R265_PRESERVE_USER_CANARY.txt") + $m=@{} + foreach($path in $paths){$m[$path]=Hash (Join-Path $app $path)} + return $m +} +function AssertUnchanged($before,[string]$case){ + $now=Snapshot + if($now.Count -ne $before.Count){StopNow ("MAP_COUNT_"+$case)} + foreach($item in $before.Keys){ + if($before[$item] -cne $now[$item]){StopNow ("USER_OR_MANAGED_DRIFT_"+$case+"_"+$item)} + } + if((Get-ItemProperty $reg -ErrorAction Stop).DisplayVersion -cne "1.7.5"){ + StopNow ("REGISTERED_VERSION_DRIFT_"+$case) + } + if((StartupValue) -cne $originalRun){StopNow ("STARTUP_DRIFT_"+$case)} + if(Test-Path (Join-Path $app ".tbme-upgrade")){StopNow ("TRANSACTION_STAGE_LEFT_"+$case)} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow ("MAIN_PROCESS_RUNNING_"+$case)} +} +if($build.Commit -cne $env:GITHUB_SHA -or $build.Version -cne "1.7.5" -or + $build.Private -ne $true -or $build.NotForPublicInstallation -ne $true){ + StopNow "WRONG_PRIVATE_CANDIDATE_MANIFEST" +} +if((Hash $setup) -cne $build.SetupSHA256){StopNow "PRIVATE_COMPILED_SETUP_SHA_DRIFT"} +if((Test-Path $app) -or (Test-Path $reg) -or (Test-Path $legacy) -or + (Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + StopNow "GUEST_NOT_CLEAN" +} +[void](New-Item -Path $out -ItemType Directory -ErrorAction Stop) +try{ + $phase="FRESH_TEST_ONLY_SETUP" + $installedExit=RunCandidate "/install /quiet /desktop=0 /startup=0" + if($installedExit -ne 0){StopNow ("FRESH_INSTALL_EXIT_"+$installedExit)} + if((Hash (Join-Path $app "TaskbarMonitorEnhanced.exe")) -cne $build.MainSHA256 -or + (Hash (Join-Path $app "Uninstall.exe")) -cne $build.SetupSHA256){ + StopNow "FRESH_INSTALL_PRODUCT_SHA_CHANGED" + } + $source=Join-Path $app "Source/TaskbarMonitorEnhanced.cs" + $redundant=Join-Path $app "TaskbarMonitorEnhanced.cs" + if((Hash $source) -ceq "MISSING"){StopNow "SINGLE_MANAGED_SOURCE_NOT_INSTALLED"} + if(Test-Path $redundant){StopNow "REDUNDANT_ROOT_SOURCE_STILL_INSTALLED"} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow "FRESH_MAIN_ALREADY_RUNNING"} + $phase="CREATE_USER_OWNED_TEST_FIXTURE" + $config=Join-Path $app "config.json" + $backup=Join-Path $app "config.json.bak" + $canary=Join-Path $app "R265_PRESERVE_USER_CANARY.txt" + if(!(Test-Path $config)){ + [IO.File]::WriteAllText($config,'{"GlassMode":true,"Opacity":0,"OpacityWhenGlassOff":0.1,"R265UserFixture":"retain all bytes"}',[Text.UTF8Encoding]::new($false)) + } + if(!(Test-Path $backup)){ + [IO.File]::WriteAllText($backup,'{"R265Backup":"independent prior user config must remain SHA-exact"}',[Text.UTF8Encoding]::new($false)) + } + [IO.File]::WriteAllText($canary,"R265 synthetic guest user canary, no personal content",[Text.UTF8Encoding]::new($false)) + $originalRun=StartupValue + $original=Snapshot + if(@($original.Values|Where-Object {$_ -ceq "MISSING"}).Count){StopNow "EXPECTED_FRESH_FIXTURE_MISSING"} + + $phase="FIRST_SAME_VERSION_MAINTENANCE_IDEMPOTENCE" + $sameVersionExit=RunCandidate "/update /quiet" + if($sameVersionExit -ne 0){StopNow ("SAME_VERSION_MAINTENANCE_FAILED_"+$sameVersionExit)} + AssertUnchanged $original "FIRST_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "MAINTENANCE_RECREATED_REDUNDANT_SOURCE"} + + $phase="REJECT_DISPOSABLE_PROGRAMFILES_DUPLICATE" + if(Test-Path $legacy){StopNow "LEGACY_PATH_PREEXISTS"} + [void](New-Item $legacy -ItemType Directory -ErrorAction Stop) + $owned=$true + [IO.File]::WriteAllText($marker,("R265 inert duplicate executable marker "+$HostLabel),[Text.UTF8Encoding]::new($false)) + $expectedMarker=Hash $marker + if($expectedMarker -ceq "MISSING"){StopNow "LEGACY_INERT_MARKER_MISSING"} + $negativeExit=RunCandidate "/update /quiet" + if($negativeExit -ne 34){StopNow ("PROGRAMFILES_DUPLICATE_NOT_EXACTLY_REFUSED_"+$negativeExit)} + if((Hash $marker) -cne $expectedMarker){StopNow "INERT_MARKER_MODIFIED"} + AssertUnchanged $original "PROGRAMFILES_CONFLICT_REFUSAL" + if((Hash $marker) -cne $expectedMarker){StopNow "MARKER_CHANGED_BEFORE_REMOVAL"} + Remove-Item -LiteralPath $marker -Force -ErrorAction Stop + if(@(Get-ChildItem -LiteralPath $legacy -Force).Count){StopNow "LEGACY_DIR_CONTENT_NOT_OWNED"} + Remove-Item -LiteralPath $legacy -Force -ErrorAction Stop + $owned=$false + if(Test-Path $legacy){StopNow "LEGACY_DIR_NOT_REMOVED"} + $removed=$true + + $phase="SECOND_IDEMPOTENT_UPDATE_AFTER_CONFLICT_REMOVAL" + $afterCollisionExit=RunCandidate "/update /quiet" + if($afterCollisionExit -ne 0){StopNow ("AFTER_COLLISION_MAINTENANCE_FAILED_"+$afterCollisionExit)} + AssertUnchanged $original "SECOND_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "ROOT_REDUNDANCY_RETURNED"} + $status="PASS_REAL_GUEST_FRESH_R265_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT" +}catch{ + $error=$_.Exception.GetType().Name+": "+$_.Exception.Message +}finally{ + if($owned -and (Test-Path $marker) -and (Hash $marker) -ceq $expectedMarker){ + Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue + } + if($owned -and (Test-Path $legacy) -and @((Get-ChildItem -LiteralPath $legacy -Force -ErrorAction SilentlyContinue)).Count -eq 0){ + Remove-Item -LiteralPath $legacy -Force -ErrorAction SilentlyContinue + } + $e=[ordered]@{ + Revision="R265";Status=$status;Phase=$phase;Failure=$error;Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;TestOnlyCandidate=$true;PublicSetupChanged=$false + PersonalComputerTouched=$false;ReleasedV173Untouched=$true + CandidateMainSHA256=$build.MainSHA256;CandidateSetupSHA256=$build.SetupSHA256 + FreshInstallExit=$installedExit + SingleManagedSourceWithoutRootDuplicate=(!(Test-Path (Join-Path $app "TaskbarMonitorEnhanced.cs"))) + FirstSameVersionMaintenanceExit=$sameVersionExit + NegativeProgramFilesCollisionExit=$negativeExit + SecondSameVersionMaintenanceExit=$afterCollisionExit + GuestOwnedLegacyFixtureRemoved=(!(Test-Path $legacy)) + ProtectedCanaryAndConfigPreserved=($status -like "PASS_*") + RealHKLMRegistryOrCrossUserAcceptance=$false + NoPhysicalPowerCutOrExternalSecurityClaim=$true + UTC=[DateTime]::UtcNow.ToString("o") + } + [IO.File]::WriteAllText($receipt,($e|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($e|ConvertTo-Json -Compress -Depth 8) +} +if($status -notlike "PASS_*"){throw ("R265_REAL_GUEST_NOT_ACCEPTED "+$phase+" "+$error)} diff --git a/build/Test-Release-Gates.ps1 b/build/Test-Release-Gates.ps1 index 5b0ac3c6..19038043 100644 --- a/build/Test-Release-Gates.ps1 +++ b/build/Test-Release-Gates.ps1 @@ -44,7 +44,7 @@ if($null -ne $manifest) { 'TaskbarMonitorEnhanced.exe.config'='build\_out\App\TaskbarMonitorEnhanced.exe.config'; 'TaskbarMonitorSensorBroker.exe'='build\_out\Broker\TaskbarMonitorSensorBroker.exe'; 'TaskbarMonitorSensorSupervisor.exe'='build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe'; - 'TaskbarMonitorEnhanced_Setup_1.7.4.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' + 'TaskbarMonitorEnhanced_Setup_1.7.5.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' } $realHashesPass=$true foreach($item in $outputs){ @@ -168,7 +168,7 @@ Add-Gate 'G12_START_SEARCH_24_REAL_NATIVE_INK_AND_EXACT_RETURN' $startFloatPass # R75: G02 hashes deterministic UNSIGNED builder outputs. G14 and G15 must use # separate SIGNED release staging, since Authenticode modifies executable SHA256. $signedMainExe=Join-Path $root 'artifacts\signed\Main\TaskbarMonitorEnhanced.exe' -$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' $expectedSignedMainDigest='' if((Test-Path -LiteralPath $signedMainExe -PathType Leaf) -and (Test-Path -LiteralPath $signedSetupExe -PathType Leaf)){ @@ -233,7 +233,7 @@ Add-Gate 'G14_INSTALLED_TASKBAR_EXPLORER_SENSORS_ROLLBACK' $installedPass $insta $setupExe=$signedSetupExe $mainExe=$signedMainExe # Keep original _out builder outputs immutable so G02 exact source checks remain reproducible. -$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' # R73: G15 must prove actual SignPath Foundation publisher, valid timestamps, matched # exact file/product versions AND live provider-origin/payload evidence. Never trust # self-declared "approved" JSON, a test certificate or token-less synthetic output. @@ -253,10 +253,10 @@ if((Test-Path -LiteralPath $setupExe -PathType Leaf) -and (Test-Path -LiteralPat $sameCert=$null -ne $setupCert -and $null -ne $mainCert -and $setupCert.Thumbprint -ceq $mainCert.Thumbprint $exactMetadata=$setupInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and $mainInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and - $setupInfo.ProductVersion -ceq '1.7.4+r256' -and - $mainInfo.ProductVersion -ceq '1.7.4+r256' -and - $setupInfo.FileVersion -ceq '1.7.4.0' -and - $mainInfo.FileVersion -ceq '1.7.4.0' + $setupInfo.ProductVersion -ceq '1.7.5+r256' -and + $mainInfo.ProductVersion -ceq '1.7.5+r256' -and + $setupInfo.FileVersion -ceq '1.7.5.0' -and + $mainInfo.FileVersion -ceq '1.7.5.0' $cryptoPass=([string]$mainSig.Status -ceq 'Valid') -and ([string]$setupSig.Status -ceq 'Valid') -and $mainName -ceq 'SignPath Foundation' -and diff --git a/build/Test-Signed-Payload-Contract.ps1 b/build/Test-Signed-Payload-Contract.ps1 index 058e5f41..0142b50c 100644 --- a/build/Test-Signed-Payload-Contract.ps1 +++ b/build/Test-Signed-Payload-Contract.ps1 @@ -4,11 +4,11 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $original=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' $inputDir=Join-Path $Root 'artifacts\_r80_contract_input' $inputFile=Join-Path $inputDir 'TaskbarMonitorEnhanced.exe' $stageDir=Join-Path $Root 'artifacts\_r80_contract_only' -$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' $originalMainSHA=(Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash $originalSetupSHA=(Get-FileHash -LiteralPath $setupOrig -Algorithm SHA256).Hash if((Test-Path -LiteralPath $inputDir) -or (Test-Path -LiteralPath $stageDir)){ @@ -70,7 +70,7 @@ try{ # Cleanup exact owned files only. Unknown files survive; never delete personal state. foreach($d in @($stageDir,$inputDir)){ if(Test-Path -LiteralPath $d){ - $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.4.exe','TaskbarMonitorEnhanced_Setup_1.7.4.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} + $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} else{@('TaskbarMonitorEnhanced.exe')} $found=@(Get-ChildItem -LiteralPath $d -Force) if(@($found|Where-Object {$_.PSIsContainer -or $_.Name -notin $allowed}).Count -gt 0){ diff --git a/build/Test-Signed-Staging.ps1 b/build/Test-Signed-Staging.ps1 index b01e91e6..393e6ce8 100644 --- a/build/Test-Signed-Staging.ps1 +++ b/build/Test-Signed-Staging.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $stage=Join-Path $root 'artifacts\signed' $origMain=Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' $targetMain=Join-Path $stage 'Main\TaskbarMonitorEnhanced.exe' -$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.4.exe' +$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' $created=@() if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $origMain -PathType Leaf) -or @@ -26,7 +26,7 @@ if(!(Test-Path -LiteralPath $manifest -PathType Leaf)){throw 'R75 MANIFEST REQUI $m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json $mapping=@{ 'TaskbarMonitorEnhanced.exe'=$hashMain - 'TaskbarMonitorEnhanced_Setup_1.7.4.exe'=$hashSetup + 'TaskbarMonitorEnhanced_Setup_1.7.5.exe'=$hashSetup } foreach($entry in $m.Outputs){ if($mapping.ContainsKey([string]$entry.Name)){ diff --git a/build/Test-Upgrade174-R265.ps1 b/build/Test-Upgrade174-R265.ps1 new file mode 100644 index 00000000..1e5e167d --- /dev/null +++ b/build/Test-Upgrade174-R265.ps1 @@ -0,0 +1,224 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Genuine product install/update is permitted ONLY on a fresh disposable hosted VM. +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R265_REFUSE_PERSONAL_OR_NONINTERACTIVE_HOST'} +Add-Type -AssemblyName System.Windows.Forms +# R156: keep genuine CLR null; PS5.1 string binder converts $null to empty string. +Add-Type -TypeDefinition 'using System.IO; public static class R156FileBridge { public static void ReplaceWithoutBackup(string source,string destination){File.Replace(source,destination,null);} }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R265_UPGRADE_'+$HostLabel) +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $reg) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R265_NOT_FRESH_DISPOSABLE_HOST'} +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.5' -or $manifest.Private -ne $true -or (Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R265_NOT_EXACT_VERSIONED_CANDIDATE'} +New-Item -ItemType Directory -Path $out|Out-Null +$phase='SOURCE_DOWNLOAD';$status='FAIL';$errorText='';$negative=0;$rollback=$false;$preserved=$false;$idempotent=$false;$downgrade=$false;$chain=@();$beforeProtected=@{};$evidence=@();$oldCpuR170=$null;$newCpuR170=$null +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Run-Exact([string]$path,[string]$Arguments,[int]$timeout=190000){ + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$path;$si.Arguments=$Arguments;$si.UseShellExecute=$false;$si.WorkingDirectory=(Split-Path $path -Parent) + $p=[Diagnostics.Process]::Start($si) + if($null -eq $p){throw 'R265_SETUP_PROCESS_NOT_CREATED'} + try{if(!$p.WaitForExit($timeout)){throw ('R265_SETUP_PROCESS_TIMEOUT PID='+$p.Id)};return [int]$p.ExitCode}finally{$p.Dispose()} +} +function StartupValue(){ + $k=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $k){return 'MISSING'};$p=$k.PSObject.Properties['TaskbarMonitorEnhanced'];if($null -eq $p){return 'MISSING'};return [string]$p.Value +} +function Snapshot(){ + $map=@{} + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v174-managed.json') -Raw|ConvertFrom-Json + $paths=@($inventory.Rows|ForEach-Object Path)+@('Docs/RELEASE_NOTES_v1.7.5.md','install_state.json','config.json','config.json.bak','R265_USER_OWNED.txt','SensorBackend/LibreHardwareMonitor-0.9.6/LibreHardwareMonitorLib.dll') + # Exclude volatile sensor telemetry/log files, not owned by installer and free to change independently. + foreach($rel in @($paths|Select-Object -Unique)){$map[$rel]=Hash (Join-Path $app $rel)} + $map['@RegistryVersion']=[string](Get-ItemProperty $reg).DisplayVersion + $map['@Startup']=StartupValue + return $map +} +function Equal-Snapshot($a,$b,[string]$reason){ + if($a.Count -ne $b.Count){throw ('R265_SNAPSHOT_COUNT_CHANGED '+$reason)} + foreach($k in $a.Keys){if(!$b.ContainsKey($k) -or $a[$k] -cne $b[$k]){throw ('R265_SNAPSHOT_CHANGED '+$reason+' '+$k)}} +} +# R170: identical owner-independent CPU sampling on BOTH versions on the SAME disposable +# GitHub-hosted Windows worker. Never invoked on personal/unknown hosts. +function Measure-InstalledCpuR170([string]$version,[string]$expectedSha){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or ![Environment]::UserInteractive){ + throw 'R170_CPU_PROBE_REFUSES_NON_DISPOSABLE_MACHINE' + } + $bin=Join-Path $app 'TaskbarMonitorEnhanced.exe' + $configFile=Join-Path $app 'config.json' + $backupFile=Join-Path $app 'config.json.bak' + if((Hash $bin) -cne $expectedSha){throw 'R170_CANDIDATE_EXE_SHA_MISMATCH'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_BEFORE_SAMPLE_PRODUCT_PROCESS_ALREADY_RUNNING' + } + $cfgBytes=[IO.File]::ReadAllBytes($configFile) + $bakBytes=[IO.File]::ReadAllBytes($backupFile) + $cfgSha=Hash $configFile;$bakSha=Hash $backupFile + $probe=$null;$sample=$null;$forcedStop=$false + try{ + $probe=Start-Process -FilePath $bin -WorkingDirectory $app -PassThru -ErrorAction Stop + Start-Sleep -Seconds 8 + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_WARMUP_'+$version)} + $cpu0=$probe.TotalProcessorTime.TotalSeconds + $timer=[Diagnostics.Stopwatch]::StartNew() + Start-Sleep -Seconds 8 + $timer.Stop() + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_SAMPLE_'+$version)} + $cpuSeconds=$probe.TotalProcessorTime.TotalSeconds-$cpu0 + $elapsed=$timer.Elapsed.TotalSeconds + $pct=100.0*$cpuSeconds/$elapsed + if($elapsed -lt 7.5 -or $elapsed -gt 12.5 -or $cpuSeconds -lt 0 -or + $pct -gt 500 -or [Double]::IsNaN($pct)){ + throw ('R170_INVALID_CPU_SAMPLE_'+$version) + } + $sample=[ordered]@{Version=$version;Host=$HostLabel;MainSHA256=$expectedSha; + CpuSeconds=$cpuSeconds;ElapsedSeconds=$elapsed;PercentOfOneCore=$pct; + ProcessId=$probe.Id;WarmupSeconds=8;Source='HOSTED_PROCESS_CPU_NOT_PERSONAL'; + ConfigurationBeforeSHA=$cfgSha;BackupBeforeSHA=$bakSha} + } + finally{ + if($null -ne $probe){ + try{ + $probe.Refresh() + if(!$probe.HasExited){ + try{[void]$probe.CloseMainWindow()}catch{} + if(!$probe.WaitForExit(3500)){ + $forcedStop=$true # CI-only process launched above, on disposable runner. + $probe.Kill() + if(!$probe.WaitForExit(12000)){throw 'R170_DISPOSABLE_PROCESS_NOT_STOPPED'} + } + } + }finally{$probe.Dispose()} + } + # Restore the exact isolated test fixture if application startup normalizes it. + if((Hash $configFile) -cne $cfgSha){ + [IO.File]::WriteAllBytes($configFile,$cfgBytes) + if((Hash $configFile) -cne $cfgSha){throw 'R170_TEST_CONFIG_RESTORE_FAILED'} + } + if((Hash $backupFile) -cne $bakSha){ + [IO.File]::WriteAllBytes($backupFile,$bakBytes) + if((Hash $backupFile) -cne $bakSha){throw 'R170_TEST_BACKUP_RESTORE_FAILED'} + } + } + if($null -eq $sample){throw 'R170_NO_HOSTED_CPU_SAMPLE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_LEFT_TEST_PROCESS_RUNNING' + } + $sample['DisposableOnlyForcedStop']=$forcedStop + $sample['ConfigRestoredToOriginalSHA']=((Hash $configFile) -ceq $cfgSha) + $sample['BackupRestoredToOriginalSHA']=((Hash $backupFile) -ceq $bakSha) + return $sample +} +try{ + & gh release download v1.7.4 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.4.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R265_OFFICIAL172_DOWNLOAD_FAILED'} + $priorSetup=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' + if((Hash $priorSetup) -cne 'C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1'){throw 'R265_PREVIOUS_SETUP_NOT_IMMUTABLE_OFFICIAL'} + $phase='CREATE_GENUINE_V172_PREDECESSOR' + if($HostLabel -ceq 'B'){ + & gh release download v1.7.3 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.3.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R265_OFFICIAL171_DOWNLOAD_FAILED'} + $older=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.3.exe' + if((Hash $older) -cne '9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B'){throw 'R265_PREVIOUS_V171_SETUP_CHANGED'} + if((Run-Exact $older '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R265_GENUINE_V171_INSTALL_FAILED'} + if((Run-Exact $priorSetup '/update /quiet') -ne 0){throw 'R265_GENUINE_V171_TO_V173_FAILED'} + $chain=@('1.7.3','1.7.4') + }else{ + if((Run-Exact $priorSetup '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R265_GENUINE_V173_INSTALL_FAILED'} + $chain=@('1.7.4') + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne '279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C' -or (Get-ItemProperty $reg).DisplayVersion -cne '1.7.4'){throw 'R265_OFFICIAL_V173_INSTALLED_IDENTITY_NOT_PROVEN'} + # Neither fresh official v1.7.4 nor the v1.7.3→v1.7.4 upgrade includes + # a redundant root C# source; original R254 defect was already fixed. + $extra=Join-Path $app 'TaskbarMonitorEnhanced.cs' + $source=Join-Path $app 'Source/TaskbarMonitorEnhanced.cs' + if((Test-Path $extra) -or (Hash $source) -ceq 'MISSING'){ + throw 'R265_PUBLIC_V174_SOURCE_LAYOUT_NOT_CANONICAL' + } + $config=Join-Path $app 'config.json';$backup=Join-Path $app 'config.json.bak';$state=Join-Path $app 'install_state.json' + $fixture='{"Theme":"Retro Terminal","Opacity":0.00,"GlassMode":true,"OpacityWhenGlassOff":0.10,"ConfigSchemaVersion":8,"StartWithWindows":false,"UpdateIntervalMs":1000,"ShowSparklines":true,"AutoCheckUpdates":false,"R265OwnerSentinel":"preserve user literal 1.7.4"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"independent":"prior backup must not be overwritten"}',[Text.UTF8Encoding]::new($false)) + $userFile=Join-Path $app 'R265_USER_OWNED.txt';[IO.File]::WriteAllText($userFile,'User data, not installer-managed.',[Text.UTF8Encoding]::new($false)) + $sourceText=[IO.File]::ReadAllText($state) + $expectedState=$sourceText.Replace('"Version": "1.7.4"','"Version": "1.7.5"').Replace('"PublicVersion": "1.7.4"','"PublicVersion": "1.7.5"') + if($expectedState -ceq $sourceText){throw 'R265_ACTUAL_STATE_FORMAT_NOT_RECOGNIZED'} + foreach($path in @($config,$backup,$userFile,(Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'))){if((Hash $path) -ceq 'MISSING'){throw ('R265_PROTECTED_INPUT_MISSING '+$path)};$beforeProtected[$path]=Hash $path} + $startupBefore=StartupValue + $phase='SAME_HOST_171_CPU_BASELINE' + $oldCpuR170=Measure-InstalledCpuR170 '1.7.4' '279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C' + if($null -eq $oldCpuR170){throw 'R170_MISSING_OLD_BASELINE'} + $phase='REJECT_EDITED_MANAGED_SOURCE' + $managed=Join-Path $app 'Source\TaskbarGlass.cs';$original=[IO.File]::ReadAllBytes($managed) + $changed=[byte[]]($original+[Text.Encoding]::UTF8.GetBytes("`n// deliberate owner edit: updater must refuse`n")) + [IO.File]::WriteAllBytes($managed,$changed);$editedHash=Hash $managed + $snap=Snapshot + $code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R265_EDITED_SOURCE_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'EDITED_SOURCE_REFUSAL' + if(Test-Path (Join-Path $app '.tbme-upgrade')){throw 'R265_REFUSAL_LEFT_STAGE'} + if((Hash $managed) -cne $editedHash){throw 'R265_CONCURRENT_EDIT_CANNOT_RESTORE_FIXTURE'} + [IO.File]::WriteAllBytes($managed,$original);$negative++ + $phase='REJECT_UNKNOWN_INSTALLED_VERSION' + [IO.File]::WriteAllText($state,$sourceText.Replace('1.7.4','9.9.9'),[Text.UTF8Encoding]::new($false)) + $snap=Snapshot;$code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R265_UNKNOWN_VERSION_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'UNKNOWN_VERSION_REFUSAL' + [IO.File]::WriteAllText($state,$sourceText,[Text.UTF8Encoding]::new($false));$negative++ + $phase='REAL_ROLLBACK_ENGINE_AFTER_SIMULATED_PARTIAL_COMMIT' + # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. + # This is explicit simulated interruption, not a physical outage or real process-kill witness. + $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.4','OFFICIAL_STABLE_V173')) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) + # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) + $beforeRollback=Snapshot + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.4','OFFICIAL_STABLE_V173')) + $stage=Join-Path $app '.tbme-upgrade' + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $stage 'new') $rel;$live=Join-Path $app $rel + [R156FileBridge]::ReplaceWithoutBackup($staged,$live) + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256){throw 'R265_PARTIAL_COMMIT_NOT_REAL'} + [void]$t.GetMethod('RecoverUpgradeStageIfAny',$f).Invoke($null,[object[]]@()) + if(Test-Path $stage){throw 'R265_ROLLBACK_STAGE_NOT_CLEARED'} + Equal-Snapshot $beforeRollback (Snapshot) 'REAL_ROLLBACK_RESTORATION';$rollback=$true + $phase='REAL_CLI_171_TO_172' + $code=Run-Exact $setup '/update /quiet' + if($code -ne 0){throw ('R265_REAL_UPGRADE_FAILED EXIT='+$code)} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256 -or (Hash (Join-Path $app 'Uninstall.exe')) -cne $manifest.SetupSHA256){throw 'R265_FINAL_BINARY_HASH_MISMATCH'} + if((Get-ItemProperty $reg).DisplayVersion -cne '1.7.5' -or [IO.File]::ReadAllText($state) -cne $expectedState){throw 'R265_STATE_OR_REGISTRY_NOT_EXACT_174'} + foreach($path in $beforeProtected.Keys){if((Hash $path) -cne $beforeProtected[$path]){throw ('R265_USER_DATA_CHANGED '+$path)}} + if((StartupValue) -cne $startupBefore){throw 'R265_STARTUP_PREFERENCE_CHANGED'} + if(Test-Path $stage){throw 'R265_STAGE_LEFT_AFTER_SUCCESS'} + if(!(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.5.md')) -or (Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.4.md'))){throw 'R265_RELEASE_NOTES_NOT_MIGRATED'} + $registrations=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($registrations.Count -ne 1 -or (Get-ItemProperty $reg).InstallLocation -cne $app){throw 'R265_DUPLICATE_OR_WRONG_ROOT'} + $preserved=$true;$chain+=@('1.7.5') + $phase='SAME_HOST_172_CPU_COMPARATOR' + $newCpuR170=Measure-InstalledCpuR170 '1.7.5' $manifest.MainSHA256 + if($null -eq $newCpuR170 -or $oldCpuR170.Host -cne $newCpuR170.Host){throw 'R170_SAME_HOST_CPU_NOT_VERIFIED'} + $phase='IDEMPOTENCE_AND_DOWNGRADE_REFUSAL' + $after=Snapshot + if((Run-Exact $setup '/update /quiet') -ne 0){throw 'R265_REPEATED_IDENTICAL_UPGRADE_FAILED'} + Equal-Snapshot $after (Snapshot) 'IDENTICAL_REINSTALL';$idempotent=$true + if((Run-Exact $priorSetup '/update /quiet') -eq 0){throw 'R265_OLD_INSTALLER_DOWNGRADE_ACCEPTED'} + Equal-Snapshot $after (Snapshot) 'DOWNGRADE_REFUSAL';$downgrade=$true + $status='PASS_GENUINE_174_TO_175_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' +}catch{$errorText=$_.Exception.ToString()} +finally{ + $r=[ordered]@{Revision='R265';Status=$status;Phase=$phase;Failure=$errorText;Host=$HostLabel;Commit=$env:GITHUB_SHA;SourceChain=$chain;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;RejectedUnsafeUpdates=$negative;RealProductionRollbackAfterSimulatedPartialCommit=$rollback;PhysicalPowerCutClaimed=$false;RealProcessKillClaimed=$false;ProtectedUserFilesPreserved=$preserved;IdenticalSetupIdempotent=$idempotent;DowngradeRefused=$downgrade;PersonalComputerTouched=$false;ProtectedSHA256=$beforeProtected;R170SameHostedWindowsOldCpu=$oldCpuR170;R170SameHostedWindowsNewCpu=$newCpuR170;R170SameHostCpuMeasured=($null -ne $oldCpuR170 -and $null -ne $newCpuR170);R170PersonalCpuAcceptance=$false} + [IO.File]::WriteAllText((Join-Path $out 'R265_UPGRADE.json'),($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 8 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R265_UPGRADE_ACCEPTANCE_FAILED '+$phase+' '+$errorText)} diff --git a/build/Test-UpgradeContract-R265.ps1 b/build/Test-UpgradeContract-R265.ps1 new file mode 100644 index 00000000..76d8c068 --- /dev/null +++ b/build/Test-UpgradeContract-R265.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$SetupPath,[Parameter(Mandatory=$true)][string]$OutDir) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R265_FRAMEWORK_REFLECTION_REQUIRED'} +if(Test-Path $OutDir){throw 'R265_CONTRACT_OUTPUT_EXISTS'} +$full=[IO.Path]::GetFullPath($SetupPath) +if([IO.Path]::GetFileName($full) -cne 'TaskbarMonitorEnhanced_Setup_1.7.5.exe'){throw 'R265_WRONG_SETUP_NAME'} +$a=[Reflection.Assembly]::LoadFile($full) +$t=$a.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,Public,NonPublic' +$newState=$t.GetMethod('NewInstallState',$f) +$legacy=$t.GetMethod('LegacyExpected',$f) +$optional=$t.GetMethod('IsOptionalStableLegacyRoot',$f) +if($t.GetField('Version',$f).GetRawConstantValue() -cne '1.7.5'){throw 'R265_COMPILED_VERSION_NOT_172'} +$cases=0;$negative=0 +foreach($v in @('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3','1.7.4')){ + $before='{ "ProductIdentity":"LOCKED", "Version":"'+$v+'", "PublicVersion":"'+$v+'", "Unrelated":"preserve literal 1.7.1", "Custom":{"x":17}, "ProtocolVersion":"1.1.2+r21" }' + $want=$before.Replace(('"Version":"'+$v+'"'),'"Version":"1.7.5"').Replace(('"PublicVersion":"'+$v+'"'),'"PublicVersion":"1.7.5"') + $got=[string]$newState.Invoke($null,[object[]]@($before)) + if($got -cne $want){throw ('R265_STATE_NOT_EXACTLY_TWO_VERSION_FIELDS '+$v)} + $cases++ +} +$bad=@( + '{"Version":"1.7.1","PublicVersion":"1.7.0"}', + '{"Version":"1.7.1","Version":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"1.7.1","PublicVersion":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"0.9.0","PublicVersion":"0.9.0"}', + '{"Version":"1.7.6","PublicVersion":"1.7.6"}', + '{"Version":"1.7.1"}', + '{"PublicVersion":"1.7.1"}' +) +foreach($s in $bad){ + $refused=$false + try{[void]$newState.Invoke($null,[object[]]@($s))}catch{$refused=$true} + if(!$refused){throw ('R265_INVALID_STATE_ACCEPTED '+$s)};$negative++ +} +$inventory=Get-Content (Join-Path $PSScriptRoot 'official-v174-managed.json') -Raw|ConvertFrom-Json +$dict=$legacy.Invoke($null,[object[]]@('1.7.4','OFFICIAL_STABLE_V174')) +if($dict.Count -ne 22 -or $inventory.ManagedCount -ne 22){throw 'R265_OFFICIAL_174_EXACT_22_MANAGED_COUNT'} +foreach($row in $inventory.Rows){if(!$dict.ContainsKey($row.Path) -or $dict[$row.Path] -cne $row.SHA256){throw ('R265_OFFICIAL_V174_COMPILED_MANAGED_SHA_DRIFT '+$row.Path)};$cases++} +# Retain exact previously accepted immutable v1.7.3 path as a separate ancestor. +$oldInventory=Get-Content (Join-Path $PSScriptRoot 'official-v173-managed.json') -Raw|ConvertFrom-Json +$oldDictionary=$legacy.Invoke($null,[object[]]@('1.7.3','OFFICIAL_STABLE_V173')) +if($oldInventory.ManagedCount -ne 23 -or $oldDictionary.Count -ne 23){throw 'R265_HISTORICAL_V173_23_ROWS_DRIFT'} +foreach($row in $oldInventory.Rows){if(!$oldDictionary.ContainsKey($row.Path) -or $oldDictionary[$row.Path] -cne $row.SHA256){throw ('R265_V173_HISTORIC_DRIFT '+$row.Path)};$cases++} +foreach($pair in @(@('1.7.1','OFFICIAL_STABLE_V170'),@('1.7.1','PREVIEW1'),@('1.7.5','OFFICIAL_STABLE_V171'),@('1.7.1','UNKNOWN'))){ + $refused=$false;try{[void]$legacy.Invoke($null,[object[]]@($pair[0],$pair[1]))}catch{$refused=$true} + if(!$refused){throw 'R265_WRONG_ANCESTOR_CHANNEL_ACCEPTED'};$negative++ +} +foreach($channel in @('OFFICIAL_STABLE_V170','OFFICIAL_STABLE_V171','OFFICIAL_STABLE_V172','OFFICIAL_STABLE_V173')){ + if(!$optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs',$channel))){throw 'R265_INHERITED_ROOT_DUPLICATE_REQUIRED'};$cases++ + foreach($path in @('Source/TaskbarMonitorEnhanced.cs','TaskbarMonitorEnhanced.exe','config.json')){ + if($optional.Invoke($null,[object[]]@($path,$channel))){throw ('R265_CRITICAL_SOURCE_MADE_OPTIONAL '+$path)};$negative++ + } +} +if($optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs','OFFICIAL_STABLE_V174'))){ + throw 'R265_OFFICIAL_V174_MUST_NOT_HAVE_OPTIONAL_ROOT_SOURCE' +} +$cases++ +$targets=$t.GetField('UpgradeTargets',$f).GetValue($null) +$removed=0;$names=@();$if=[Reflection.BindingFlags]'Instance,NonPublic,Public' +foreach($target in $targets){ + $path=[string]$target.GetType().GetField('Rel',$if).GetValue($target) + $resource=$target.GetType().GetField('ResourceName',$if).GetValue($target) + if($names -ccontains $path -or $path -match '^(config\.json|SensorBackend/|Backups/|Data/)'){throw ('R265_UPGRADE_TOUCHES_PROTECTED_OR_DUPLICATE '+$path)} + if($null -eq $resource){$removed++};$names+=@($path);$cases++ +} +if($removed -ne 7 -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.5.md' -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.1.md'){throw 'R265_VERSIONED_NOTES_TRANSACTION_MAP'} +New-Item -ItemType Directory -Path $OutDir|Out-Null +$r=[ordered]@{Revision='R265';Status='PASS_COMPILED_174_UPGRADE_CONTRACT';SetupSHA256=(Get-FileHash $full -Algorithm SHA256).Hash;PositiveCases=$cases;RejectedCases=$negative;Official173ManagedFiles=23;Official174ManagedFiles=22;PreviousVersions=@('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3','1.7.4');CurrentVersion='1.7.5';NoAppEntryPointInvoked=$true;NoPersonalFilesOrRegistryMutated=$true;ActualInstallAcceptance='SEPARATE_HOSTED_VM_GATE'} +[IO.File]::WriteAllText((Join-Path $OutDir 'R265_CONTRACT.json'),($r|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +Write-Output ($r|ConvertTo-Json -Depth 6 -Compress) diff --git a/build/Verify-Embedded-Main.ps1 b/build/Verify-Embedded-Main.ps1 index 9709346c..8301ae1b 100644 --- a/build/Verify-Embedded-Main.ps1 +++ b/build/Verify-Embedded-Main.ps1 @@ -1,4 +1,4 @@ -# R127: retain R80 independent read-only verification and accept versioned 1.7.4 patch filename. +# R127: retain R80 independent read-only verification and accept versioned 1.7.5 patch filename. # R80: independent read-only verification that a Setup's exact embedded Main PE bytes # equal the SHA256 of an independently pinned, already-signed Main distribution file. [CmdletBinding()] @@ -11,7 +11,7 @@ $ErrorActionPreference='Stop' if($ExpectedMainSHA256 -notmatch '^[0-9a-fA-F]{64}$'){throw 'R80 EXPECTED MAIN SHA NOT 64 HEX'} $path=[IO.Path]::GetFullPath($SetupExe) if(!(Test-Path -LiteralPath $path -PathType Leaf) -or - ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.4.exe'))){ + ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe'))){ throw 'R80 SETUP EXE MISSING OR WRONG FILE NAME' } $setupHash=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash diff --git a/build/Verify-Release-Metadata-R265.ps1 b/build/Verify-Release-Metadata-R265.ps1 new file mode 100644 index 00000000..24555833 --- /dev/null +++ b/build/Verify-Release-Metadata-R265.ps1 @@ -0,0 +1,105 @@ +# R265 independent, read-only authority check for the versioned 1.7.5 +# release Build Manifest -> deterministic binary files -> SBOM 2.3. +[CmdletBinding()] +param( + [string]$Root=(Join-Path $PSScriptRoot '..'), + [switch]$SelfTest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($Root) +$manifestPath=Join-Path $root 'artifacts\BUILD_MANIFEST.json' +$sbomPath=Join-Path $root 'artifacts\SBOM.spdx.json' +$setupVerifyPath=Join-Path $root 'build\_out\setup_verify.json' +$source=(Join-Path $root 'src\TaskbarMonitorEnhanced.cs') +foreach($f in @($manifestPath,$sbomPath,$setupVerifyPath,$source)){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){throw ('R265_REQUIRED_EVIDENCE_MISSING '+$f)} +} +$m=Get-Content $manifestPath -Raw|ConvertFrom-Json +$b=Get-Content $sbomPath -Raw|ConvertFrom-Json +$v=Get-Content $setupVerifyPath -Raw|ConvertFrom-Json +$commit=(& git -C $root rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R265_CURRENT_HEAD_NOT_EXACT'} +function Issues($manifest,$sbom,$setup,$sha){ + $fail=[Collections.Generic.List[string]]::new() + if($manifest.Version -cne '1.7.5' -or $manifest.Build -cne 'V1_7_5_R265_SENSOR_CROSSUSER_GUARD'){ + $fail.Add('WRONG_PUBLIC_BUILD_VERSION') + } + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.5' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.5/'+$sha)){ + $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') + } + if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or + $sbom.packages[0].versionInfo -cne '1.7.5'){ + $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') + } + $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', + 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', + 'TaskbarMonitorEnhanced_Setup_1.7.5.exe') + $found=@($manifest.Outputs|ForEach-Object {$_.Name}) + if(@($manifest.Outputs).Count -ne 5 -or + @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ + $fail.Add('MANIFEST_EXACT_FIVE_OUTPUTS_MISMATCH') + }else{ + foreach($row in $manifest.Outputs){ + $name=[string]$row.Name + $rel=switch($name){ + 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} + 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} + default {'build\_out\App\'+$name} + } + $path=Join-Path $root $rel + if(!(Test-Path $path -PathType Leaf) -or + (Get-Item $path).Length -ne [int64]$row.Bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash -cne [string]$row.SHA256){ + $fail.Add('ACTUAL_OUTPUT_SHA_OR_SIZE_MISMATCH_'+$name) + } + $frow=@($sbom.files|Where-Object fileName -ceq $name) + if($frow.Count -ne 1 -or @($frow[0].checksums).Count -ne 1 -or + $frow[0].checksums[0].algorithm -cne 'SHA256' -or + $frow[0].checksums[0].checksumValue -cne ([string]$row.SHA256).ToLowerInvariant()){ + $fail.Add('SBOM_OUTPUT_SHA_MISMATCH_'+$name) + } + } + } + if($setup.Status -cne 'PASS' -or [int]$setup.Resources -ne 25 -or + $setup.PawnIOBundled -ne $false){ + $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') + } + $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe') + if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.5')){ + $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') + } + return @($fail.ToArray()) +} +$errors=@(Issues $m $b $v $commit) +if($errors.Count){throw ('R265_RELEASE_METADATA_FAIL_CLOSED '+($errors -join ';'))} +if($SelfTest){ + $mutants=@( + @{Type='Manifest';Field='Version';Bad='1.7.0'}, + @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, + @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.5/'+('0'*40))}, + @{Type='Setup';Field='PawnIOBundled';Bad=$true} + ) + foreach($mutation in $mutants){ + $am=$m|ConvertTo-Json -Depth 14|ConvertFrom-Json + $ab=$b|ConvertTo-Json -Depth 14|ConvertFrom-Json + $av=$v|ConvertTo-Json -Depth 14|ConvertFrom-Json + switch($mutation.Type){ + 'Manifest' {$am.($mutation.Field)=$mutation.Bad} + 'Sbom' {$ab.($mutation.Field)=$mutation.Bad} + 'Setup' {$av.($mutation.Field)=$mutation.Bad} + } + if(@(Issues $am $ab $av $commit).Count -eq 0){ + throw ('R265_HOSTILE_RELEASE_METADATA_ACCEPTED '+$mutation.Type+' '+$mutation.Field) + } + } + Write-Output 'R265_FIVE_HOSTILE_RELEASE_METADATA_MUTANTS_REJECTED' +} +Write-Output ('R265_MANIFEST_SBOM_BINARY_VERSION=PASS VERSION='+$m.Version+ + ' OUTPUTS='+@($m.Outputs).Count+' SHA256='+(Get-FileHash $manifestPath -Algorithm SHA256).Hash) +exit 0 diff --git a/build/official-v174-managed.json b/build/official-v174-managed.json new file mode 100644 index 00000000..8c611509 --- /dev/null +++ b/build/official-v174-managed.json @@ -0,0 +1,102 @@ +{ + "Status": "PASS_EXACT_PUBLIC_V174_MANAGED_FILES_PINNED_FROM_RELEASED_SETUP", + "Schema": 1, + "Version": "1.7.4", + "SourceCommit": "1375d719989aa93966bae445ac63b9a415f637ad", + "PublicSetupSHA256": "C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1", + "PublicMainSHA256": "279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C", + "ManagedCount": 22, + "OwnerInstalledAllManagedMatched": true, + "IncludesUserConfig": false, + "OptionalAbsentRootSourceSupported": false, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C" + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F" + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF" + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "24460E03A325FD3F085302EAED0DDD7511BAD71F9DE1C722EDD61408CA754CF9" + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD" + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D" + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF" + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19" + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65" + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "23B946D913810FB074F2C904CFF133A44948C57724818E3250B8E21B4D519E37" + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B" + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B" + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "957B655CC028F4D68EB88A430CA4330107D7623004585601233DABD67ABED6B2" + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986" + }, + { + "Path": "Docs/README.md", + "SHA256": "0D5CC4DAE08C3131DCCC095109EDEA997C757DE65E7A8BA296A2F399E5B67292" + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40" + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153" + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB" + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559" + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.4.md", + "SHA256": "FF604D0B69C2116CD1A72C08189FCB18ACA5435894E3FECD822FE972CA996E07" + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293" + }, + { + "Path": "Uninstall.exe", + "SHA256": "C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1" + } + ] +} \ No newline at end of file diff --git a/docs/releases/v1.7.5/INSTALLER_README.txt b/docs/releases/v1.7.5/INSTALLER_README.txt new file mode 100644 index 00000000..783ee897 --- /dev/null +++ b/docs/releases/v1.7.5/INSTALLER_README.txt @@ -0,0 +1,6 @@ +Taskbar Monitor Enhanced 1.7.5 +Single per-user installer, exact official 1.7.4 same-root update with protected rollback and no data erasure. +Cross-user sensor safety guard: never take over another user's protected Task Scheduler sensor task. +Fresh setup remains per-account; machinewide single-install is not enforced. +Glass ON transparency and user configuration are protected during update. +Unsigned as previously accepted; respect Microsoft Defender detections, do not disable security tools. diff --git a/docs/releases/v1.7.5/RELEASE_NOTES.md b/docs/releases/v1.7.5/RELEASE_NOTES.md new file mode 100644 index 00000000..9ca09061 --- /dev/null +++ b/docs/releases/v1.7.5/RELEASE_NOTES.md @@ -0,0 +1,12 @@ +# Taskbar Monitor Enhanced v1.7.5 — cross-user sensor-layer safety + +## Critical fix +- Prevent one Windows account's elevated sensor setup/uninstall from unregistering the machinewide sensor Scheduled Task, terminating sensors, or removing shared Program Files sensor payload owned by a different account. +- Before any shared mutation resolve the target SID, verify the existing task principal and all observable broker/supervisor process owners; on foreign or unknown ownership, fail closed and leave protected sensors unchanged, while allowing the current user's main app and independent settings to work with unavailable protected readings if necessary. +- Fresh no-task installs and same-account repairs remain permitted. This is a safety guard, **not** global single-install enforcement, task per-SID migration, or permission to disable antivirus. + +## Installer and delivery +- Single 64-bit Windows per-user Setup; exact SHA-pinned immutable official v1.7.4 22 managed rows supported for same-root v1.7.5 update with atomic rollback. Existing v1.6–v1.7.3 official upgrade sources retained. +- Preserves Glass alpha-zero mode, separate On/Off state label, Start/Settings/fullscreen, user's config/previous backup, startup registration and uninstaller state. +- Intentionally unsigned under prior publisher waiver. Microsoft genuine vulnerable-driver WinRing0 Issue #6 attribution awaits the reporter's exact driver SHA/install source and independent review. Physical AC power loss and cross-account actual full protected-sensor cohabitation are not claimed. +- Release only after exact merged source Build/metadata/security/rollback and independent two-host Windows A/B official v1.7.4→v1.7.5 and fresh install maintenance tests; verify and redownload all 7 public artifacts before owner in-place upgrade. diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 5d42e5b1..794dad06 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -138,6 +138,55 @@ function Restore-PreviousSensorLayer([string]$Reason){ Write-Log "BEGIN mode=$Mode user=$UserId" +# R264-SENSOR-GUARD-BOUNDARY-BEGIN +# Before any shared task/process/file mutation, fail closed if a different +# Windows account owns the fixed legacy machinewide sensor task or processes. +# This is defensive per-account isolation, NOT a machinewide singleton policy. +function Resolve-SensorPrincipalSid([string]$AccountName){ + if([string]::IsNullOrWhiteSpace($AccountName)){throw 'EMPTY_SENSOR_PRINCIPAL'} + if($AccountName -match '^S-1-5-(?:\d+-)+\d+$'){return $AccountName} + $account=[Security.Principal.NTAccount]::new($AccountName) + return [string]$account.Translate([Security.Principal.SecurityIdentifier]).Value +} +function Preserve-OtherUserSensorLayer([string]$Why){ + $script:ActiveArchitecture='SHARED_PROTECTED_SENSOR_LAYER_LEFT_UNTOUCHED' + Write-Log ('CROSS_USER_SENSOR_SAFETY_REFUSAL reason='+$Why+' mode='+$Mode) + Write-Result 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' ('Protected sensors were not changed: '+$Why+'. The per-user main app remains available with unavailable protected metrics as N/A.') 'NOT_STARTED' $null $false $false $false + exit 0 +} +try{ + $expectedUserSid=Resolve-SensorPrincipalSid $UserId + Import-Module ScheduledTasks -ErrorAction Stop + $lookupErrors=@() + $existing=@(Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue -ErrorVariable +lookupErrors | + Where-Object {$null -ne $_}) + if(@($lookupErrors|Where-Object {$_.CategoryInfo.Category -ne [Management.Automation.ErrorCategory]::ObjectNotFound}).Count){ + throw 'TASK_SCHEDULER_LOOKUP_NOT_TRUSTED' + } + if($existing.Count -gt 1){throw 'MULTIPLE_GLOBAL_TASK_MATCHES'} + if($existing.Count -eq 1){ + $oldSid=Resolve-SensorPrincipalSid ([string]$existing[0].Principal.UserId) + if($oldSid -cne $expectedUserSid){ + Preserve-OtherUserSensorLayer 'ANOTHER_ACCOUNT_OWNS_GLOBAL_SCHEDULED_TASK' + } + }elseif(Test-Path -LiteralPath $BrokerRoot){ + Preserve-OtherUserSensorLayer 'UNOWNED_SHARED_PROGRAMFILES_BROKER_WITHOUT_TASK' + } + $sensorProcesses=@(Get-CimInstance Win32_Process -Filter "Name='TaskbarMonitorSensorBroker.exe' OR Name='TaskbarMonitorSensorSupervisor.exe'" -ErrorAction Stop | + Where-Object {$null -ne $_}) + foreach($proc in $sensorProcesses){ + $owner=Invoke-CimMethod -InputObject $proc -MethodName GetOwnerSid -ErrorAction Stop + if([int]$owner.ReturnValue -ne 0 -or + [String]::IsNullOrWhiteSpace([string]$owner.Sid) -or + [string]$owner.Sid -cne $expectedUserSid){ + Preserve-OtherUserSensorLayer 'SENSOR_PROCESS_OWNER_IS_UNKNOWN_OR_OTHER_ACCOUNT' + } + } +}catch{ + Preserve-OtherUserSensorLayer ('UNVERIFIED_MACHINEWIDE_SENSOR_OWNERSHIP_'+$_.Exception.GetType().Name) +} +# R264-SENSOR-GUARD-BOUNDARY-END + if($Mode -eq 'Uninstall'){ try{ Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue | diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 8aa167c3..a65b74ef 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -20,14 +20,14 @@ [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.4.0")] -[assembly: AssemblyFileVersion("1.7.4.0")] -[assembly: AssemblyInformationalVersion("1.7.4+r256")] +[assembly: AssemblyVersion("1.7.5.0")] +[assembly: AssemblyFileVersion("1.7.5.0")] +[assembly: AssemblyInformationalVersion("1.7.5+r265")] internal static partial class SetupProgram { const string Product="Taskbar Monitor Enhanced"; - const string Version="1.7.4"; + const string Version="1.7.5"; const string SensorProtocolVersion="1.1.2+r21"; const string SensorSupervisorVersion="1.3.0+r33"; const string Publisher="Dr. Ali-Akbar Emadeddin"; @@ -58,7 +58,7 @@ internal static partial class SetupProgram "Payload.COPYRIGHT_AND_ATTRIBUTION.md", "Payload.AI_ASSISTED_DEVELOPMENT.md", "Payload.THIRD_PARTY_NOTICES.md", - "Payload.RELEASE_NOTES_v1.7.4.md", + "Payload.RELEASE_NOTES_v1.7.5.md", "Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md", "Payload.TaskbarMonitorEnhanced_Setup.cs" }; @@ -228,8 +228,8 @@ static void WriteInstallState(SensorOutcome sensorOutcome) string sensorMode=sensorOutcome==null?"":(sensorOutcome.LayerMode??""); string json="{\r\n"+ " \"App\": \"Taskbar Monitor Enhanced\",\r\n"+ - " \"Version\": \"1.7.4\",\r\n"+ - " \"PublicVersion\": \"1.7.4\",\r\n"+ + " \"Version\": \"1.7.5\",\r\n"+ + " \"PublicVersion\": \"1.7.5\",\r\n"+ " \"InternalRuntimeBaseline\": \"V1_1_2_R21_PRODUCTION_HARDENING\",\r\n"+ " \"SensorSupervisor\": \"V1_3_0_R33_STORAGE_COMPLETION_HARDENING\",\r\n"+ " \"SensorLayerStatus\": \""+sensorStatus.Replace("\\","\\\\").Replace("\"","\\\"")+"\",\r\n"+ @@ -445,7 +445,7 @@ static bool IsCurrentProcessElevated() static SensorOutcome Install(bool desktop,bool startup) { if(!Environment.Is64BitOperatingSystem) - throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.4 requires 64-bit Windows."); + throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.5 requires 64-bit Windows."); // R114: prevent fresh-install code from ever overwriting an installed // product or user-owned files. Existing roots must use /update. @@ -475,7 +475,7 @@ static SensorOutcome Install(bool desktop,bool startup) string[] docs=new string[]{ "LICENSE","README.md","AUTHORS.md","COPYRIGHT_AND_ATTRIBUTION.md", "AI_ASSISTED_DEVELOPMENT.md","THIRD_PARTY_NOTICES.md", - "RELEASE_NOTES_v1.7.4.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" + "RELEASE_NOTES_v1.7.5.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" }; foreach(string doc in docs) Extract("Payload."+doc,Path.Combine(AppRoot,"Docs",doc)); @@ -619,7 +619,7 @@ static int Verify(string path) } if(!String.IsNullOrEmpty(path)){ string json="{\"Status\":\"PASS\",\"Resources\":"+(RequiredResources.Length+(pawnBundled?1:0))+ - ",\"Version\":\"1.7.4\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ + ",\"Version\":\"1.7.5\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ ",\"SensorArchitecture\":\"R21_PROCESS_ISOLATED\",\"SensorUpgradePolicy\":\"EXACT_R33_SUPERVISOR_R21_PROTOCOL\""+ ",\"PawnIOBundled\":"+(pawnBundled?"true":"false")+",\"EmbeddedMainSHA256\":\""+embeddedMainSHA256+"\",\"EmbeddedMainBytes\":"+embeddedMainBytes+"}"; File.WriteAllText(path,json,Encoding.UTF8); @@ -658,7 +658,7 @@ public SetupForm() Icon=Icon.ExtractAssociatedIcon(Application.ExecutablePath); Label title=new Label(); - title.Text=Product+" 1.7.4"; + title.Text=Product+" 1.7.5"; title.Font=new Font(Font.FontFamily,18,FontStyle.Bold); title.Left=28;title.Top=22;title.AutoSize=true;Controls.Add(title); @@ -693,12 +693,12 @@ public SetupForm() progress.Visible=false; status.Text="Installation completed."; if(outcome.IsHealthy){ - MessageBox.Show(Product+" 1.7.4 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", + MessageBox.Show(Product+" 1.7.5 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", "Setup complete",MessageBoxButtons.OK,MessageBoxIcon.Information); }else{ string extra=outcome.RebootRequired ? "\r\n\r\nRestart Windows, then use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors if needed." : "\r\n\r\nThe application is installed and usable. The sensor supervisor continues in the background. If protected CPU/GPU/storage telemetry is still unavailable after a short wait or restart, use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors."; - MessageBox.Show(Product+" 1.7.4 was installed successfully.\r\n\r\n"+outcome.Message+extra, + MessageBox.Show(Product+" 1.7.5 was installed successfully.\r\n\r\n"+outcome.Message+extra, "Setup complete - sensor warning",MessageBoxButtons.OK,MessageBoxIcon.Warning); } Close(); @@ -834,6 +834,9 @@ internal static partial class SetupProgram const string InstalledV171="1.7.1"; const string InstalledV172="1.7.2"; const string InstalledV173="1.7.3"; + const string InstalledV174="1.7.4"; + const string OfficialV174SetupSHA="C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1"; + const string OfficialV174MainSHA="279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C"; const string OfficialV173SetupSHA="9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B"; const string OfficialV173MainSHA="6C8895C040D50C88AF6F6D018F9F70FB481C294B7DABCB083DB78EE35F5D8D1C"; const string OfficialV172SetupSHA="225BCC259C392DA04F3E0157CAF22F96C4052282AAF02555E6F6E777F4CB8AF4"; @@ -1014,6 +1017,32 @@ internal static partial class SetupProgram "Uninstall.exe|9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B", }; + // R265: exact 22-file immutable official 1.7.4 ancestor; no optional root source. + static readonly string[] OfficialStableV174Rows=new string[] { + "TaskbarMonitorEnhanced.exe|279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|24460E03A325FD3F085302EAED0DDD7511BAD71F9DE1C722EDD61408CA754CF9", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|23B946D913810FB074F2C904CFF133A44948C57724818E3250B8E21B4D519E37", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|957B655CC028F4D68EB88A430CA4330107D7623004585601233DABD67ABED6B2", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|0D5CC4DAE08C3131DCCC095109EDEA997C757DE65E7A8BA296A2F399E5B67292", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.4.md|FF604D0B69C2116CD1A72C08189FCB18ACA5435894E3FECD822FE972CA996E07", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1", + }; + sealed class UpgradeTarget { internal readonly string Rel; @@ -1041,7 +1070,7 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/COPYRIGHT_AND_ATTRIBUTION.md","Payload.COPYRIGHT_AND_ATTRIBUTION.md"), new UpgradeTarget("Docs/AI_ASSISTED_DEVELOPMENT.md","Payload.AI_ASSISTED_DEVELOPMENT.md"), new UpgradeTarget("Docs/THIRD_PARTY_NOTICES.md","Payload.THIRD_PARTY_NOTICES.md"), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.4.md","Payload.RELEASE_NOTES_v1.7.4.md"), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.5.md","Payload.RELEASE_NOTES_v1.7.5.md"), new UpgradeTarget("Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md","Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md"), new UpgradeTarget("Uninstall.exe","@SELF"), new UpgradeTarget("install_state.json","@INSTALLSTATE"), @@ -1050,7 +1079,8 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.0.md",null), // exact historical managed note only new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.1.md",null), new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md",null), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md",null) // previous version, guarded byte-for-byte + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.4.md",null) // previous version, guarded byte-for-byte }; static string UpgradeHash(string path) @@ -1080,6 +1110,8 @@ static Dictionary LegacyExpected(string version,string channel) { rows=OfficialStableV172Rows; expectedSetup=OfficialV172SetupSHA; count=23; } else if(version==InstalledV173 && channel=="OFFICIAL_STABLE_V173") { rows=OfficialStableV173Rows; expectedSetup=OfficialV173SetupSHA; count=23; } + else if(version==InstalledV174 && channel=="OFFICIAL_STABLE_V174") + { rows=OfficialStableV174Rows; expectedSetup=OfficialV174SetupSHA; count=22; } else if(version==InstalledV170 && channel=="PREVIEW1") { rows=LegacyPreview1Rows; expectedSetup=LegacyPreview1InstallerSha; count=23; } else throw new InvalidDataException("Unsupported exact prior source identity; no downgrade/second root."); @@ -1180,7 +1212,7 @@ static string NewInstallState(string source) throw new InvalidDataException("Old install state Version/PublicVersion mismatch."); string prior=installed.Groups["v"].Value; if(prior==Version)return source; - if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173) + if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173 && prior!=InstalledV174) throw new InvalidDataException("Prior install state version not a pinned officially allowed ancestor."); string next=ReplaceStateVersion(source,"Version",prior); next=ReplaceStateVersion(next,"PublicVersion",prior); @@ -1206,7 +1238,7 @@ static Dictionary PrepareNewBytes(string previousState) if(bytes.Length<1 || fresh.ContainsKey(t.Rel))throw new InvalidDataException("Duplicate/empty payload "+t.Rel); fresh.Add(t.Rel,bytes); } - if(fresh.Count!=UpgradeTargets.Length-6) + if(fresh.Count!=UpgradeTargets.Length-7) throw new InvalidDataException("Upgrade static target map is invalid."); return fresh; } @@ -1318,7 +1350,7 @@ static void StageNewManaged(Dictionary fresh, Dictionary rows) string priorVersionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); if(!File.Exists(priorVersionPath))throw new IOException("Previous upgrade version missing; retain stage."); string priorVersion=File.ReadAllText(priorVersionPath,Encoding.ASCII); - if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=InstalledV174 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); // This works after a mid-commit process kill: exact old snapshots are persisted // on the same volume, and new-only files are removed only if their SHA is known. for(int i=rows.Count-1;i>=0;i--){ @@ -1458,8 +1490,8 @@ static SensorOutcome UpgradeExisting() CheckSingleCanonicalRoot(); string installed=CurrentInstalledVersion(); string newSha=Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"); - if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=Version) - throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3 may upgrade in place."); + if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=InstalledV174 && installed!=Version) + throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3/v1.7.4 may upgrade in place."); string channel="OFFICIAL_V16"; string currentMainSHA=UpgradeHash(AppExe); string installedSetup=UPath("Uninstall.exe"); @@ -1475,7 +1507,8 @@ static SensorOutcome UpgradeExisting() !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.0.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.1.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.2.md")) && - !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.3.md"))) + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.3.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.4.md"))) return ExistingSensorOutcome(); throw new InvalidOperationException("Unknown current-version maintenance revision; no files changed."); } @@ -1507,6 +1540,12 @@ static SensorOutcome UpgradeExisting() throw new InvalidOperationException("Unknown official v1.7.3 source/Setup identity; refuse before any mutation."); channel="OFFICIAL_STABLE_V173"; } + if(installed==InstalledV174) + { + if(currentMainSHA!=OfficialV174MainSHA || oldSetupSHA!=OfficialV174SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.4 source/Setup identity; refuse before any mutation."); + channel="OFFICIAL_STABLE_V174"; + } // No force-kill; the owner must close their currently running app normally. EnsureAppNotRunning(); diff --git a/src/TaskbarMonitorEnhanced.cs b/src/TaskbarMonitorEnhanced.cs index 47c3ee98..49fcc421 100644 --- a/src/TaskbarMonitorEnhanced.cs +++ b/src/TaskbarMonitorEnhanced.cs @@ -33,18 +33,18 @@ [assembly: AssemblyDescription("Live system monitor integrated into the Windows taskbar")] [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyInformationalVersion("1.7.4+r256")] +[assembly: AssemblyInformationalVersion("1.7.5+r265")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.4.0")] -[assembly: AssemblyFileVersion("1.7.4.0")] +[assembly: AssemblyVersion("1.7.5.0")] +[assembly: AssemblyFileVersion("1.7.5.0")] namespace TaskbarMonitorEnhanced { internal static class BuildInfo { - public const string Version = "V1_7_4_R256_FRESH_MAINTENANCE"; + public const string Version = "V1_7_5_R265_SENSOR_CROSSUSER_GUARD"; public const string Product = "Taskbar Monitor Enhanced"; - public const string PublicVersion = "1.7.4"; + public const string PublicVersion = "1.7.5"; public const string ShortcutName = "Taskbar Monitor Enhanced"; public const string ProductDescription = "Live system monitor integrated into the Windows taskbar"; public const string Author = "Dr. Ali-Akbar Emadeddin"; diff --git a/src/WorkspaceSelfTest.cs b/src/WorkspaceSelfTest.cs index 1078b276..637093da 100644 --- a/src/WorkspaceSelfTest.cs +++ b/src/WorkspaceSelfTest.cs @@ -126,7 +126,7 @@ internal static int Run() { try { - Check(BuildInfo.PublicVersion=="1.7.4","version"); + Check(BuildInfo.PublicVersion=="1.7.5","version"); for(int schema=0;schema<7;schema++){ var legacy=new AppConfig{ConfigSchemaVersion=schema,EnableTemperatureNotifications=true,CpuTempWarningC=0,FontSize=Double.NaN,Opacity=Double.NaN};legacy.Normalize(); Check(legacy.ConfigSchemaVersion==8,"all legacy schemas migrate "+schema); From 2c12179fad50a6bda411493bbf0ac19721a84dd7 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Fri, 16 Oct 2026 15:19:57 +0330 Subject: [PATCH 007/135] test(r265): use real SID for PawnIO-negative and correct official v1.7.4 rollback channel --- .github/workflows/audit-r265-sensor-safety.yml | 2 +- build/Test-PawnIO-External-Degrade.ps1 | 2 +- build/Test-R265-CrossUserGuard.ps1 | 2 +- build/Test-Upgrade174-R265.ps1 | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/audit-r265-sensor-safety.yml b/.github/workflows/audit-r265-sensor-safety.yml index 45a2dbda..aa4f1c49 100644 --- a/.github/workflows/audit-r265-sensor-safety.yml +++ b/.github/workflows/audit-r265-sensor-safety.yml @@ -51,7 +51,7 @@ jobs: } $files=@( @{File='installer/TBME_Setup_Elevated_Helper.ps1';SHA='84A7DC21012A63C5959447D7234B365CF926028016A81B742F891951B191E7FB'}, - @{File='build/Test-R265-CrossUserGuard.ps1';SHA='935BF48132112F2C01AD3D0E29129C06D400D444974F3121E38A279C9A458A8B'} + @{File='build/Test-R265-CrossUserGuard.ps1';SHA='D51BE3D43B530F88E55CE3AC534CD4FE72681E0C74473ABECA236DBC37CAC114'} ) foreach($item in $files){ $path=(Resolve-Path $item.File).Path diff --git a/build/Test-PawnIO-External-Degrade.ps1 b/build/Test-PawnIO-External-Degrade.ps1 index 514933f3..6aadb248 100644 --- a/build/Test-PawnIO-External-Degrade.ps1 +++ b/build/Test-PawnIO-External-Degrade.ps1 @@ -32,7 +32,7 @@ try { [IO.File]::WriteAllBytes((Join-Path $Payload 'TaskbarMonitorSensorSupervisor.exe'),[byte[]](1,2,3)) [IO.File]::WriteAllBytes((Join-Path $App 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'),[byte[]](1,2,3)) [IO.File]::WriteAllText($Mock,$mocked,[Text.UTF8Encoding]::new($false)) - $exec=@(& pwsh -NoProfile -File $Mock -Mode Install -PayloadDir $Payload -AppRoot $App -UserId 'R85_TEST_FIXTURE_NO_DRIVER') + $exec=@(& pwsh -NoProfile -File $Mock -Mode Install -PayloadDir $Payload -AppRoot $App -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name)) if($LASTEXITCODE -ne 0 -or !(Test-Path -LiteralPath $Result)){throw 'R85 MOCK HOST SENSOR HELPER FAILED'} $receipt=Get-Content -LiteralPath $Result -Raw|ConvertFrom-Json if($receipt.Status -cne 'DEGRADED' -or diff --git a/build/Test-R265-CrossUserGuard.ps1 b/build/Test-R265-CrossUserGuard.ps1 index d604afd1..fbbac6b5 100644 --- a/build/Test-R265-CrossUserGuard.ps1 +++ b/build/Test-R265-CrossUserGuard.ps1 @@ -109,7 +109,7 @@ try{ New-Item -ItemType Directory -Path $app|Out-Null $run=InvokePrefix $mode $app $true $result=Join-Path $app 'Logssensor_install_result.json' - if($run.Exit -ne 0 -or !(Test-Path $result)){Fail ('REAL_FULL_HELPER_OTHER_USER_NOT_REFUSED_'+$mode+'_'+$run.Err)} + if($run.Exit -ne 0 -or !(Test-Path $result)){Fail ('REAL_FULL_HELPER_OTHER_USER_NOT_REFUSED_'+$mode+'_Exit_'+$run.Exit+'_Result_'+(Test-Path $result)+'_Out_'+$run.Out+'_Err_'+$run.Err)} $j=Get-Content $result -Raw|ConvertFrom-Json if($j.Status -cne 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' -or $j.Architecture -cne 'SHARED_PROTECTED_SENSOR_LAYER_LEFT_UNTOUCHED'){ diff --git a/build/Test-Upgrade174-R265.ps1 b/build/Test-Upgrade174-R265.ps1 index 1e5e167d..dcf49c75 100644 --- a/build/Test-Upgrade174-R265.ps1 +++ b/build/Test-Upgrade174-R265.ps1 @@ -177,12 +177,12 @@ try{ # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. # This is explicit simulated interruption, not a physical outage or real process-kill witness. $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' - $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.4','OFFICIAL_STABLE_V173')) + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.4','OFFICIAL_STABLE_V174')) $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) $beforeRollback=Snapshot - [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.4','OFFICIAL_STABLE_V173')) + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.4','OFFICIAL_STABLE_V174')) $stage=Join-Path $app '.tbme-upgrade' [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ From a9415ce3f6c77f008ab847c9104283520f535db9 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Fri, 16 Oct 2026 15:39:49 +0330 Subject: [PATCH 008/135] test(r265): correct full helper receipt path and R265 receipt version --- .github/workflows/audit-r265-sensor-safety.yml | 2 +- build/Test-R265-CrossUserGuard.ps1 | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/audit-r265-sensor-safety.yml b/.github/workflows/audit-r265-sensor-safety.yml index aa4f1c49..8265fdd0 100644 --- a/.github/workflows/audit-r265-sensor-safety.yml +++ b/.github/workflows/audit-r265-sensor-safety.yml @@ -51,7 +51,7 @@ jobs: } $files=@( @{File='installer/TBME_Setup_Elevated_Helper.ps1';SHA='84A7DC21012A63C5959447D7234B365CF926028016A81B742F891951B191E7FB'}, - @{File='build/Test-R265-CrossUserGuard.ps1';SHA='D51BE3D43B530F88E55CE3AC534CD4FE72681E0C74473ABECA236DBC37CAC114'} + @{File='build/Test-R265-CrossUserGuard.ps1';SHA='153268FBDC57F65B8AEC56ED80832F4C6EF5839A3640D0233DF2D7B4ED4606E2'} ) foreach($item in $files){ $path=(Resolve-Path $item.File).Path diff --git a/build/Test-R265-CrossUserGuard.ps1 b/build/Test-R265-CrossUserGuard.ps1 index fbbac6b5..a2fd860c 100644 --- a/build/Test-R265-CrossUserGuard.ps1 +++ b/build/Test-R265-CrossUserGuard.ps1 @@ -108,7 +108,7 @@ try{ $app=Join-Path $out ('WholeHelperOtherRoot_'+$mode) New-Item -ItemType Directory -Path $app|Out-Null $run=InvokePrefix $mode $app $true - $result=Join-Path $app 'Logssensor_install_result.json' + $result=Join-Path $app 'Logs\sensor_install_result.json' if($run.Exit -ne 0 -or !(Test-Path $result)){Fail ('REAL_FULL_HELPER_OTHER_USER_NOT_REFUSED_'+$mode+'_Exit_'+$run.Exit+'_Result_'+(Test-Path $result)+'_Out_'+$run.Out+'_Err_'+$run.Err)} $j=Get-Content $result -Raw|ConvertFrom-Json if($j.Status -cne 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' -or @@ -176,7 +176,7 @@ finally{ } }catch{$status='FAIL_ACCOUNT_CLEANUP_UNCERTAIN';$failure+=' | '+$_.Exception.Message} $r=[ordered]@{ - Revision='R264';Status=$status;Guest=$HostLabel;Phase=$phase;Failure=$failure + Revision='R265';Status=$status;Guest=$HostLabel;Phase=$phase;Failure=$failure SourceGuardSHA256=$expected;ExactProductionHelperAfterGuardWasNotExecuted=$true OtherUserSid=$otherSID;CurrentUserSid=$ownerSID OtherTaskPrincipal=$otherPrincipal;CurrentTaskPrincipal=$currentPrincipal From 936ad2053af4b31f18e6602f8b74d54641eeb069 Mon Sep 17 00:00:00 2001 From: Aliakbar Emadoddin <94546844+GOD13emad@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:27:34 +0330 Subject: [PATCH 009/135] docs: align public project control with v1.7.5 R270 Sanitized public status, preserve historical records, and keep security/physical/multiuser limitations explicit. CI build/verification run 38911429257 PASS. --- docs/project-control/CURRENT_PROJECT.json | 92 ++++++++++------------- docs/project-control/PROJECT_BRAIN.md | 17 +++++ docs/project-control/README.md | 9 ++- 3 files changed, 60 insertions(+), 58 deletions(-) diff --git a/docs/project-control/CURRENT_PROJECT.json b/docs/project-control/CURRENT_PROJECT.json index 95e4d549..5b83de79 100644 --- a/docs/project-control/CURRENT_PROJECT.json +++ b/docs/project-control/CURRENT_PROJECT.json @@ -1,63 +1,47 @@ { "Schema": 1, "Project": "TaskbarMonitorEnhanced", - "CanonicalRoot": "C:\\Users\\Aa.Emad\\source\\repos\\TaskbarMonitorEnhanced", - "InstalledRoot": "C:\\Users\\Aa.Emad\\AppData\\Local\\TaskbarMonitorEnhanced", - "CurrentEngineeringRevision": "R40", - "Status": "CURRENT_PACKAGE_CANDIDATE_ACCEPTED_NOT_INSTALLED", - "UpdatedUtc": "2026-10-05T17:03:48.6381459Z", - "FinalObjective": "Deliver v1.7.0 Wallpaper Studio with 21 Studio themes including Sekiro and exactly 10 wallpapers per Studio theme, preserving the accepted v1.6.0 runtime until promotion gates pass.", - "DefinitionOfDone": [ - "21 Studio themes including Sekiro", - "10 validated wallpapers per Studio theme (210 total)", - "preview/export/apply UX", - "reversible desktop apply pre-state/rollback", - "candidate source tests PASS", - "v1.7 package/installer integrated", - "clean-clone determinism and visual package acceptance PASS", - "single reversible live wallpaper canary PASS", - "installed v1.7 runtime regression PASS", - "release/CI promotion PASS if publication is authorized" - ], - "PreviousAcceptedState": { - "Version": "1.6.0", - "Revision": "R38", - "Status": "RELEASED_ACCEPTED_IMMUTABLE", - "SourceSHA": "1652989b80c5c6d301c6f05818c48c5207fd9b7a", - "InstalledMainSHA256": "05F9E069C4598FDAD327A41BF1CA34A1498D022119DB81E09F4B3B091C9BA377" - }, - "CurrentCandidate": { - "Branch": "feature/v1.7.0-wallpaper-studio", - "ProductSourceCommit": "ed46152eb9d9ff326687ba5038aa0990e03102d0", - "PackagingCommit": "2cb0baadb127c4b9e8090670d51905ffd2e29627", - "SourceAcceptance": "docs/acceptance/v1.7.0/R39_SOURCE_CANDIDATE_ACCEPTANCE.json", - "PackageAcceptance": "docs/acceptance/v1.7.0/R40_PACKAGE_CANDIDATE_ACCEPTANCE.json", - "ThemesTotal": 49, - "StudioThemes": 21, - "WallpapersPerStudioTheme": 10, - "StudioWallpapers": 210, - "SetupSHA256": "58E844297A0B33E52F05D5023408DB159275BAECDB5C1627DBAEA990CF1237AF", - "Installed": false, - "Released": false + "AuthorityIndex": "docs/project-control/PROJECT_BRAIN.md", + "Scope": "PUBLIC_REDACTED_SNAPSHOT; owner-local detailed Project Brain is the only active operational authority", + "CurrentEngineeringRevision": "R270", + "Status": "PUBLIC_IMMUTABLE_UNSIGNED_V175_OWNER_SCOPED_PASS", + "UpdatedUtc": "2026-10-16T13:05:40Z", + "FinalObjective": "Preserve v1.6 taskbar behavior with user-enabled zero-background Glass Mode, correct Start/Settings/fullscreen handling, safe in-place upgrades, a single per-user installation root, and reliable low-overhead sensors.", + "CurrentAcceptedBaseline": { + "Version": "1.7.5", + "ReleaseId": 414936595, + "ReleaseURL": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.5", + "SourceCommit": "9284b124168f3aa2c90f8637eeba6a20f2d0e1af", + "SetupSHA256": "8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", + "InstalledMainSHA256": "25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6", + "PublicAssetCount": 7, + "InstallerUnsigned": true, + "PerUserInstall": true, + "MachinewideSingletonProven": false }, "Completed": [ - "R39 source implementation and pixel validation", - "R40 v1.7 installer/build identity integration", - "WallpaperStudio canonical source embedded and extraction path verified", - "Setup /verify PASS with 25 resources", - "exact-commit full build PASS 0 warnings / 0 errors", - "clean-clone determinism PASS", - "visual candidate suite PASS 8/8", - "workspace proof PASS", - "wallpaper proof PASS 210/210 unique" + "Seven immutable public v1.7.5 assets independently SHA/Manifest verified (R267)", + "Exact-main Windows A/B build, fresh/upgrade/rollback, native UI and cross-user guard CI PASS (R265)", + "Owner official v1.7.4 to v1.7.5 in-place upgrade and 443-file cold backup/isolated restore PASS (R268)", + "Owner unmanaged data preserved, Glass native observations and unpaired 60.688-second CPU observation recorded (R269)", + "Owner current-user scheduled-task principal and all four live process SIDs matched; installed hashes retained (R270)" ], "Open": [ - "single live apply/verify/restore canary", - "installed v1.7 runtime acceptance", - "release/CI gates" + "ISSUE6_REPORTER_DRIVER_SHA_AND_INSTALL_ORIGIN_MISSING", + "CONTROLLED_PHYSICAL_MAINS_LOSS_NOT_TESTED", + "ONE_PER_MACHINE_POLICY_REQUIRES_EXPLICIT_OWNER_DECISION; one per Windows user verified", + "INDEPENDENT_SECURITY_SIGNING_LAB_NOT_PROVEN; intentionally unsigned release accepted" ], - "CriticalPath": "single reversible wallpaper canary -> installed v1.7 acceptance -> release/CI", - "MainBlocker": "Live desktop apply/restore behavior is not yet proven on the host; package promotion to installed v1.7 is blocked until one controlled canary returns the exact desktop pre-state.", - "BrainStatus": "CURRENT_R40_PACKAGE_CANDIDATE", - "ExactNextAction": "R41 only: capture wallpaper/GUI/AnyDesk pre-state, apply one Sekiro wallpaper from exact candidate 2cb0baa, verify it, restore the exact prior wallpaper/style/tile, and verify host stability." + "CriticalPath": "Keep immutable v1.7.5 and existing owner data unchanged; pursue only external reporter artifact, controlled physical mains-loss rig, and explicit machinewide install policy if required.", + "MainBlocker": "External security attribution and physical rig evidence; owner policy if one-per-machine is required. Existing per-user product release is scoped PASS, not unconditional FINAL.", + "BrainStatus": "PUBLIC_SUMMARY_R270; not the owner-local complete Brain", + "ExactNextAction": "Use public Issue #7 for current blockers; obtain reporter driver SHA/origin without restoring quarantined malware, test physical AC outage on a controlled disposable rig, and decide per-user versus machinewide installation only if a new policy is needed.", + "PublicTracker": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7", + "SecurityTracker": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6", + "HistoricalR40Record": { + "Commit": "9284b124168f3aa2c90f8637eeba6a20f2d0e1af", + "Path": "docs/project-control/CURRENT_PROJECT.json", + "GitBlob": "95e4d549d855de787a9274888bc686e3fda54d20", + "Role": "immutable historical evidence, superseded by R270" + } } diff --git a/docs/project-control/PROJECT_BRAIN.md b/docs/project-control/PROJECT_BRAIN.md index a1aea338..b26de132 100644 --- a/docs/project-control/PROJECT_BRAIN.md +++ b/docs/project-control/PROJECT_BRAIN.md @@ -1,3 +1,20 @@ + +# Current public release status — R270 / v1.7.5 (2026-10-16) + +The immutable **latest official v1.7.5** release ([assets](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.5), ID `414936595`) is accepted within a clearly limited scope. Its source is commit `9284b124168f3aa2c90f8637eeba6a20f2d0e1af`, the single **unsigned** Setup has SHA-256 `8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC`, and the accepted installed Main has SHA-256 `25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6`. Seven public release assets and exact-main hosted Windows tests have been verified. These checks establish release identity and tested behavior, **not an independent malware-security certification**. + +The owner's official v1.7.4 → v1.7.5 in-place update, preservation of preexisting user data and Glass ON/alpha-zero configuration, and current-owner Scheduled Task plus four process SID check have passed (R268–R270). The installation model is one root **per Windows user**, not one guaranteed root per computer. + +**Outstanding external evidence:** [Issue #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) needs the original reporter's WinRing0 driver SHA/install origin; real physical AC-loss recovery has not been tested; a global single-install policy needs an explicit design decision. Public signing/security laboratory attestation is absent, with unsigned distribution explicitly accepted. Do not dismiss a Defender vulnerable-driver warning or restore excluded driver binaries. + +The complete current owner-host Project Brain is maintained privately in the existing canonical project root and is **not replicated in this public repository**. This file and [CURRENT_PROJECT.json](CURRENT_PROJECT.json) are redacted public release/continuity summaries; [Issue #7](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7) is the rolling public tracker. Earlier “CURRENT” headings below are historical, superseded records. Their original blobs remain reachable from Git history (including source commit `9284b124`). + + + +--- + +## Historical engineering records — prior "CURRENT" headings are not current release claims + # CURRENT R56 — real Start/Search 24/24 PASS; active Native ShellBeacon contract corrected, exact CI/host retest pending R54 exact source de6d2bb3025b551dabfd0bd792fb368a1d6f519c, 12 candidate gates plus 4 Stable blockers. R55 actual installed-path Main-only guarded seven-minute watchdog test on Emad with real CPU/RAM/GPU/network showed 2895 actual text and 2695 graph pixels. Full resident eight-case suite got 7 PASS: startup, hover, hardware, temperature, protected sensor health, support and START. START met 24/24 SearchHost real shell, 24/24 exact 60px safe float and actual per-frame glyph+chart ink, minimum 48647 foreground signal, ≥1492 text and 1625 chart changed pixels, exact native HWND return to taskbar with legible ink. Eighth ShellState FAIL DIRECT_CHILD_NOT_FOUND: stale child-only proof, expected since true-alpha 0–99% uses a top-level popup. R55 original public installed v1.6 exe/config hashes restored exact, old process running, personal Slideshow and AnyDesk protected; genuine 7/8 FAIL retained. R56 source ShellState now distinguishes real PID-owned native top-level layered/noactivate popup vs 100% opaque taskbar child; native PArgb active beacon performs synchronous real ULW upload, requires magenta/green ≥100 physical pixels and BOTH reset below100, plus real signal≥300, style/PID/geometry/uncloak. Code builds locally, actual installed eight-case retest and exact source CI pending. All external Stable gates still OPEN: approved real Picture WallpaperStudio positive apply/restore, complete installed v1.7 Setup incl Explorer/sensors/8/8 and rollback, public-trusted Authenticode, main+immutable release. Public version v1.6.0 immutable. User Slideshow mode2 untouched, SAEID host declined, no security bypass. Authority docs/project-control/R56_SHELL_BEACON_NATIVE_ACCEPTANCE.md and R54/R51/R49/R46 history, cumulative Brain R38–R55. Project weighted completion UNPROVEN. diff --git a/docs/project-control/README.md b/docs/project-control/README.md index 1c42bd39..cb4894bf 100644 --- a/docs/project-control/README.md +++ b/docs/project-control/README.md @@ -1,8 +1,9 @@ # Project control -This directory contains cumulative project-governance records used to preserve technical decisions, evidence lineage, failure-prevention knowledge, and release closeout state. +This directory preserves release-engineering decisions and historical verification evidence. It is **not part of the installed application**. -- [Project Brain](PROJECT_BRAIN.md) -- [Project Knowledge & Evidence](PROJECT_KNOWLEDGE_EVIDENCE.md) +**Current public status (R270 / v1.7.5):** [public state](CURRENT_PROJECT.json), [Project Brain summary and history](PROJECT_BRAIN.md), [verified release](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.5), and [rolling public tracker / open gates](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7). [Security report #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) remains open. -These records are not runtime components and are not release downloads. +[Project Knowledge & Evidence](PROJECT_KNOWLEDGE_EVIDENCE.md) retains dated engineering evidence. Historical headings such as “CURRENT R56” were current **only at the time recorded**. The complete owner-local Project Brain is the single active private operational authority; these Git-tracked files are a redacted **public summary and historical record**, not a second live operational Brain. Original historical file versions remain retrievable from Git. + +No documentation update changes an immutable release, installed binary, or security verdict. From 2348791fbde3aff5a18b7df536dba8a66a65d191 Mon Sep 17 00:00:00 2001 From: Aliakbar Emadoddin <94546844+GOD13emad@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:28:15 +0330 Subject: [PATCH 010/135] release: v1.7.6 Glass thermal warning and safe 1.7.5 update R272 glass red warning fix. R273 exact versioned upgrade and dual hosted Windows native gates. R274 interrupted journal accepts official 1.7.5 rollback. Exact candidate A/B + Build CI pass. Preserve unsigned scope and Issue #6 open. --- .../workflows/audit-r265-sensor-safety.yml | 1 - .github/workflows/ci.yml | 12 +- .github/workflows/release-v175-acceptance.yml | 2 +- .github/workflows/release-v176-acceptance.yml | 171 ++++++++++ build/Build.ps1 | 10 +- build/Package-Signed-Setup.ps1 | 4 +- build/TaskbarMonitorEnhanced_Setup.csproj | 4 +- build/Test-Appearance-R145.ps1 | 16 +- build/Test-GlassThermal-R272.ps1 | 71 +++++ build/Test-Native-Presentation-R273.ps1 | 300 ++++++++++++++++++ build/Test-NoPawnIO-OssRelease.ps1 | 6 +- .../Test-R273-FreshSameVersion-Disposable.ps1 | 177 +++++++++++ build/Test-Release-Gates.ps1 | 14 +- build/Test-Signed-Payload-Contract.ps1 | 6 +- build/Test-Signed-Staging.ps1 | 6 +- build/Test-Upgrade175-R273.ps1 | 224 +++++++++++++ build/Test-UpgradeContract-R273.ps1 | 90 ++++++ build/Verify-Embedded-Main.ps1 | 2 +- build/Verify-Release-Metadata-R273.ps1 | 105 ++++++ build/official-v175-managed.json | 121 +++++++ docs/releases/v1.7.6/INSTALLER_README.txt | 5 + docs/releases/v1.7.6/RELEASE_NOTES.md | 19 ++ installer/TaskbarMonitorEnhanced_Setup.cs | 84 +++-- src/TaskbarGlass.cs | 139 +++++++- src/TaskbarMonitorEnhanced.cs | 24 +- src/WorkspaceSelfTest.cs | 2 +- 26 files changed, 1548 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/release-v176-acceptance.yml create mode 100644 build/Test-GlassThermal-R272.ps1 create mode 100644 build/Test-Native-Presentation-R273.ps1 create mode 100644 build/Test-R273-FreshSameVersion-Disposable.ps1 create mode 100644 build/Test-Upgrade175-R273.ps1 create mode 100644 build/Test-UpgradeContract-R273.ps1 create mode 100644 build/Verify-Release-Metadata-R273.ps1 create mode 100644 build/official-v175-managed.json create mode 100644 docs/releases/v1.7.6/INSTALLER_README.txt create mode 100644 docs/releases/v1.7.6/RELEASE_NOTES.md diff --git a/.github/workflows/audit-r265-sensor-safety.yml b/.github/workflows/audit-r265-sensor-safety.yml index 8265fdd0..ac4de103 100644 --- a/.github/workflows/audit-r265-sensor-safety.yml +++ b/.github/workflows/audit-r265-sensor-safety.yml @@ -3,7 +3,6 @@ on: push: branches: - release/r265-v175-sensor-crossuser-safety - - main permissions: contents: read jobs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48ae6876..0b516a6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,14 @@ jobs: $glass = Get-Content ./artifacts/ci-visual/glassproof/GLASS_PROOF.json -Raw | ConvertFrom-Json if ($glass.Status -ne 'PASS' -or $glass.Tests -ne 196) { throw '49-theme alpha contrast foreground proof did not PASS.' } + - name: R272 semantic thermal warnings visible as red ink in Glass on all 49 themes + shell: pwsh + run: | + ./build/Test-GlassThermal-R272.ps1 -MainPath ./build/_out/App/TaskbarMonitorEnhanced.exe -OutDir ./artifacts/ci-thermal-r272 + $r = Get-Content ./artifacts/ci-thermal-r272/R272_ACCEPTANCE.json -Raw | ConvertFrom-Json + if ($r.Status -cne 'PASS' -or $r.ThemePairs -ne 98 -or $r.AlertPixelMinimum -lt 40 -or $r.ColdPixelMaximum -ne 0) { + throw 'R272 threshold-to-red foreground Glass semantic contract failed.' + } - name: R244 nine-page Settings UI overlap and Glass mode renderer guard shell: powershell run: | @@ -135,7 +143,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference='Stop' - ./build/Verify-Release-Metadata-R265.ps1 -SelfTest + ./build/Verify-Release-Metadata-R273.ps1 -SelfTest if ($LASTEXITCODE -ne 0) {throw 'R136 release version, 5 output SHA and SBOM source gates FAILED'} - name: Attest binary build provenance @@ -168,6 +176,8 @@ jobs: artifacts/ci-visual/SUMMARY.json artifacts/ci-visual/chartglassproof/CHART_GLASS_PROOF.json artifacts/ci-visual/glassproof/GLASS_PROOF.json + artifacts/ci-thermal-r272/R272_ACCEPTANCE.json + artifacts/ci-thermal-r272/real-thermal-pixels/THERMAL_GLASS_PROOF.json artifacts/ci-settings-r244/R244_SETTINGS_UI.json artifacts/ci-settings-r244/R244_GLASS_SWITCH_ON.png artifacts/ci-runtime/SUMMARY.json diff --git a/.github/workflows/release-v175-acceptance.yml b/.github/workflows/release-v175-acceptance.yml index aa993080..0d9fa7ad 100644 --- a/.github/workflows/release-v175-acceptance.yml +++ b/.github/workflows/release-v175-acceptance.yml @@ -1,7 +1,7 @@ name: v1.7.5 - Full protected v1.7.4 upgrade and native acceptance on: push: - branches: [release/r265-v175-sensor-crossuser-safety, main] + branches: [release/r265-v175-sensor-crossuser-safety] workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/release-v176-acceptance.yml b/.github/workflows/release-v176-acceptance.yml new file mode 100644 index 00000000..d4e33b46 --- /dev/null +++ b/.github/workflows/release-v176-acceptance.yml @@ -0,0 +1,171 @@ +name: v1.7.6 - Full protected v1.7.5 upgrade and native acceptance +on: + push: + branches: [fix/v1.7.6-glass-thermal-alert-r272, main] + workflow_dispatch: +permissions: + contents: read +jobs: + build-candidate: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Exact source checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Parser and versioned reproducible build + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R273 source drift'} + foreach($p in @('build/Test-Upgrade175-R273.ps1','build/Test-UpgradeContract-R273.ps1','build/Test-Native-Presentation-R273.ps1')){ + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p).Path,[ref]$t,[ref]$e) + if(@($e).Count){throw ('R273 parser '+$p)} + } + ./build/Build.ps1 + ./build/Generate-Sbom.ps1 + ./build/Verify-Release-Metadata-R273.ps1 -SelfTest + ./build/Test-GlassThermal-R272.ps1 -MainPath ./build/_out/App/TaskbarMonitorEnhanced.exe -OutDir ./artifacts/r273-thermal-pixels + if($LASTEXITCODE -ne 0){throw 'R273 version/manifest/SPDX gate'} + $main='./build/_out/App/TaskbarMonitorEnhanced.exe' + $setup='./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.6.exe' + $c=[ordered]@{Commit=$env:GITHUB_SHA;Version='1.7.6';Private=$true;NotForPublicInstallation=$true;MainSHA256=(Get-FileHash $main -Algorithm SHA256).Hash;SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash} + [IO.File]::WriteAllText((Join-Path $pwd 'build/_out/CANDIDATE.json'),($c|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + - name: Actual compiled control, geometry, visibility and upgrade contracts + shell: powershell + run: | + $ErrorActionPreference='Stop' + $main='./build/_out/App/TaskbarMonitorEnhanced.exe' + ./build/Test-Appearance-R145.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/APPEARANCE') + ./build/Test-SettingsUi-R244.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/SETTINGS') + $ui=Get-Content (Join-Path $env:RUNNER_TEMP 'R273_MODELS/SETTINGS/R244_SETTINGS_UI.json') -Raw|ConvertFrom-Json + if($ui.Status -cne 'PASS_ALL_NINE_SETTINGS_PAGES_NO_CONTROL_OVERLAP' -or $ui.SettingsPages -ne 9 -or $ui.SiblingOverlapCount -ne 0){throw 'R244_NATIVE_SETTINGS_UI_LAYOUT_COLLISION'} + ./build/Test-Geometry-R150.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/GEOMETRY') + ./build/Test-Visibility-R146.ps1 -MainPath $main -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/VISIBILITY') + ./build/Test-UpgradeContract-R273.ps1 -SetupPath ./build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.6.exe -OutDir (Join-Path $env:RUNNER_TEMP 'R273_MODELS/UPGRADE') + - name: Preserve one exact candidate installer, not a release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R273-candidate + if-no-files-found: error + retention-days: 14 + path: | + build/_out/CANDIDATE.json + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.6.exe + - name: Preserve model receipts and binary manifest + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R273-compiled-contracts + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R273_MODELS/** + artifacts/BUILD_MANIFEST.json + artifacts/SBOM.spdx.json + artifacts/r273-thermal-pixels/R272_ACCEPTANCE.json + upgrade-and-native: + needs: build-candidate + runs-on: windows-latest + timeout-minutes: 24 + name: Real official 1.7.5 to 1.7.6 upgrade and native modes (${{ matrix.host }}) + strategy: + fail-fast: false + matrix: + host: [A, B] + steps: + - name: Fresh independent Windows source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Transfer exact previously compiled Setup bytes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R273-candidate + path: build/_out + - name: Genuine official v1.7.5 upgrade preserving user data + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-Upgrade175-R273.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R273_UPGRADE_${{ matrix.host }}/R273_UPGRADE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_GENUINE_175_TO_176_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' -or $r.Commit -cne $env:GITHUB_SHA){throw 'R273 actual updater acceptance failed'} + - name: Native menu, Settings, Start, fullscreen and 1-percent regression on upgraded bytes + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-Native-Presentation-R273.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' -UseInstalledCandidate + $r=Get-Content (Join-Path $env:RUNNER_TEMP 'TBME_R273_${{ matrix.host }}/R273_NATIVE.json') -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' -or @($r.TestedModes).Count -ne 4 -or $r.EventCommit -cne $env:GITHUB_SHA){throw 'R273 native four-mode evidence not accepted'} + - name: Preserve raw upgrade receipts and physical native pixels on success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R273-native-upgrade-${{ matrix.host }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R273_UPGRADE_${{ matrix.host }}/R273_UPGRADE.json + ${{ runner.temp }}/TBME_R273_${{ matrix.host }}/** + + fresh-install-maintenance: + needs: build-candidate + name: "R273 truly fresh, repeated maintenance, legacy duplicate refusal ${{ matrix.host }}" + runs-on: ${{ matrix.system }} + timeout-minutes: 23 + strategy: + fail-fast: false + matrix: + include: + - host: A + system: windows-2025 + - host: B + system: windows-2022 + steps: + - name: Fresh disposable Windows source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Download compiled exact candidate, not public Setup + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R273-candidate + path: build/_out + - name: Prove no root-source duplication, two maintenance calls and conflict refusal + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-R273-FreshSameVersion-Disposable.ps1 -CandidateDir ./build/_out -HostLabel '${{ matrix.host }}' + $receipt=Join-Path $env:RUNNER_TEMP 'TBME_R273_${{ matrix.host }}/R273_FRESH.json' + if(!(Test-Path $receipt)){throw 'R273_FRESH_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_REAL_GUEST_FRESH_R273_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.SingleManagedSourceWithoutRootDuplicate -ne $true -or + $r.FreshInstallExit -ne 0 -or + $r.FirstSameVersionMaintenanceExit -ne 0 -or + $r.NegativeProgramFilesCollisionExit -ne 34 -or + $r.SecondSameVersionMaintenanceExit -ne 0 -or + $r.ProtectedCanaryAndConfigPreserved -ne $true -or + $r.PublicSetupChanged -ne $false -or + $r.PersonalComputerTouched -ne $false -or + $r.RealHKLMRegistryOrCrossUserAcceptance -ne $false){ + throw 'R273_REAL_FRESH_INSTALL_OR_DATA_SCOPE_NOT_PASS' + } + 'R273_REAL_TWO_HOST_FRESH_AND_REPEATED_MAINTENANCE=PASS' + # The job invokes native commands in the child test; only after all + # receipt assertions passed reset stale native LASTEXITCODE. + $global:LASTEXITCODE=0 + - name: Save bounded disposable fresh-only receipts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R273-fresh-${{ matrix.host }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R273_${{ matrix.host }}/R273_FRESH.json diff --git a/build/Build.ps1 b/build/Build.ps1 index 00e608dc..2536cd8d 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -92,12 +92,12 @@ $CanonicalTextResources=[ordered]@{ 'TaskbarMonitorSensorSupervisor.cs'=(Join-Path $Root 'src\sensors\TaskbarMonitorSensorSupervisor.cs') 'TBME_Setup_Elevated_Helper.ps1'=(Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1') 'LICENSE'=(Join-Path $Root 'LICENSE') - 'README.md'=(Join-Path $Root 'docs\releases\v1.7.5\INSTALLER_README.txt') + 'README.md'=(Join-Path $Root 'docs\releases\v1.7.6\INSTALLER_README.txt') 'AUTHORS.md'=(Join-Path $Root 'AUTHORS.md') 'COPYRIGHT_AND_ATTRIBUTION.md'=(Join-Path $Root 'COPYRIGHT_AND_ATTRIBUTION.md') 'AI_ASSISTED_DEVELOPMENT.md'=(Join-Path $Root 'AI_ASSISTED_DEVELOPMENT.md') 'THIRD_PARTY_NOTICES.md'=(Join-Path $Root 'THIRD_PARTY_NOTICES.md') - 'RELEASE_NOTES_v1.7.5.md'=(Join-Path $Root 'docs\releases\v1.7.5\RELEASE_NOTES.md') + 'RELEASE_NOTES_v1.7.6.md'=(Join-Path $Root 'docs\releases\v1.7.6\RELEASE_NOTES.md') 'UPSTREAM_REFERENCE_GPL_NOTICE.md'=(Join-Path $Root 'UPSTREAM_REFERENCE_GPL_NOTICE.md') 'TaskbarMonitorEnhanced_Setup.cs'=(Join-Path $Root 'installer\TaskbarMonitorEnhanced_Setup.cs') } @@ -107,7 +107,7 @@ foreach($entry in $CanonicalTextResources.GetEnumerator()){ Write-Host 'TBME_CANONICAL_TEXT_PAYLOAD=PASS' Build (Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj') -$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' if(!(Test-Path -LiteralPath $Setup)){throw 'Setup output missing.'} # SignPath Foundation requires uniform signed-artifact project and ProductVersion metadata. @@ -152,8 +152,8 @@ if([string]$v.Status -ne 'PASS' -or [int]$v.Resources -ne 25 -or $v.PawnIOBundle if($LASTEXITCODE -ne 0){throw 'Application contract suite failed.'} $manifest=[ordered]@{ - Version='1.7.5' - Build='V1_7_5_R265_SENSOR_CROSSUSER_GUARD' + Version='1.7.6' + Build='V1_7_6_R273_GLASS_THERMAL_ALERT' GeneratedUtc=[datetime]::UtcNow.ToString('o') Dependencies=[ordered]@{ LibreHardwareMonitor=[ordered]@{Version=[string]$Lock.dependencies.LibreHardwareMonitor.version;Url=$LhmUrl;SHA256=$LhmSha} diff --git a/build/Package-Signed-Setup.ps1 b/build/Package-Signed-Setup.ps1 index 76297234..aa16aae6 100644 --- a/build/Package-Signed-Setup.ps1 +++ b/build/Package-Signed-Setup.ps1 @@ -10,10 +10,10 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $UnsignedMain=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' $StageBase=if($ContractFixtureOnly){Join-Path $Root 'artifacts\_r80_contract_only'}else{Join-Path $Root 'artifacts\signed\Setup\TO_SIGN'} -$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' $Proof=Join-Path $StageBase 'EMBEDDED_MAIN_PROOF.json' $Verification=Join-Path $StageBase 'SETUP_VERIFY.json' $CreatedStage=$false diff --git a/build/TaskbarMonitorEnhanced_Setup.csproj b/build/TaskbarMonitorEnhanced_Setup.csproj index 6504e943..4da2acdc 100644 --- a/build/TaskbarMonitorEnhanced_Setup.csproj +++ b/build/TaskbarMonitorEnhanced_Setup.csproj @@ -1,5 +1,5 @@ - net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.5_out\Setup\false + net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.6_out\Setup\false _package\TaskbarMonitorEnhanced.exefalse @@ -23,7 +23,7 @@ - + \ No newline at end of file diff --git a/build/Test-Appearance-R145.ps1 b/build/Test-Appearance-R145.ps1 index b4b59758..58f05afb 100644 --- a/build/Test-Appearance-R145.ps1 +++ b/build/Test-Appearance-R145.ps1 @@ -57,9 +57,19 @@ $bg=[Drawing.Color]::FromArgb(255,90,70,40) $full.SetPixel(0,0,$bg);$full.SetPixel(1,0,$bg) $ink.SetPixel(1,0,$fg);$halo.SetPixel(2,0,[Drawing.Color]::White) $compose=$pType.GetMethod('Compose',$stat) +# Preserve the original 100-case contract while the semantic warning mask +# extends the production compositor's internal signature from 7 to 8 args. +$composeCount=@($compose.GetParameters()).Count +if($composeCount -ne 7 -and $composeCount -ne 8){throw ('R145_UNEXPECTED_COMPOSITOR_SIGNATURE_'+$composeCount)} +function Invoke-Compose([int]$pct,[bool]$glassMode){ + $args=[object[]]@($full.PSObject.BaseObject,$ink.PSObject.BaseObject, + $halo.PSObject.BaseObject,$pct,[Drawing.Color]::White,$bg.PSObject.BaseObject,$glassMode) + if($composeCount -eq 8){$args+=@($null)} + return $compose.Invoke($null,$args) +} try{ foreach($pct in (1..100)){ - $b=$compose.Invoke($null,[object[]]@($full.PSObject.BaseObject,$ink.PSObject.BaseObject,$halo.PSObject.BaseObject,$pct,[Drawing.Color]::White,$bg.PSObject.BaseObject,$false)) + $b=Invoke-Compose $pct $false try{ $back=$b.GetPixel(0,0);$glyph=$b.GetPixel(1,0);$ring=$b.GetPixel(2,0) if($back.A -ne [Math]::Round(255*$pct/100.0) -or $glyph.ToArgb() -ne $fg.ToArgb() -or $ring.A -ne 0){ @@ -68,9 +78,9 @@ try{ if($pct -in @(1,10,29,30,31,100)){$readings+=@{Percent=$pct;BackgroundAlpha=$back.A;ForegroundArgb=$glyph.ToArgb();HaloAlpha=$ring.A}} }finally{$b.Dispose()} } - $b=$compose.Invoke($null,[object[]]@($full.PSObject.BaseObject,$ink.PSObject.BaseObject,$halo.PSObject.BaseObject,17,[Drawing.Color]::White,$bg.PSObject.BaseObject,$true)) + $b=Invoke-Compose 17 $true try{if($b.GetPixel(0,0).A -ne 0 -or $b.GetPixel(1,0).A -ne 255){throw 'R145_EXPLICIT_GLASS_ALPHA_NOT_ZERO'}}finally{$b.Dispose()} - $b=$compose.Invoke($null,[object[]]@($full.PSObject.BaseObject,$ink.PSObject.BaseObject,$halo.PSObject.BaseObject,0,[Drawing.Color]::White,$bg.PSObject.BaseObject,$false)) + $b=Invoke-Compose 0 $false try{if($b.GetPixel(0,0).A -le 0){throw 'R145_ZERO_NUMBER_IMPLICITLY_ENABLED_GLASS'}}finally{$b.Dispose()} }finally{$full.Dispose();$ink.Dispose();$halo.Dispose()} $result=[ordered]@{Revision='R145';Status='PASS_MODEL_REAL_CONTROL_AND_PIXEL_CONTRACT';MainSHA256=(Get-FileHash $MainPath -Algorithm SHA256).Hash;ModelOpacityCases=100;PixelOpacityCases=100;RealToggleClickCycles=8;ControlKind='CheckBox-derived pill with circular thumb';ZeroOnlyExplicitGlass=$true;OpacityOneMinimum=$true;PlainInkAndChartsKeepThemeColor=$true;Screenshots='Control-only rendering, NOT native taskbar acceptance';Readings=$readings;PersonalInstallationTouched=$false} diff --git a/build/Test-GlassThermal-R272.ps1 b/build/Test-GlassThermal-R272.ps1 new file mode 100644 index 00000000..2520edae --- /dev/null +++ b/build/Test-GlassThermal-R272.ps1 @@ -0,0 +1,71 @@ +[CmdletBinding()] +param( + [string]$MainPath='', + [string]$OutDir='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($MainPath)){$MainPath=Join-Path $PSScriptRoot '_out\App\TaskbarMonitorEnhanced.exe'} +if([string]::IsNullOrWhiteSpace($OutDir)){$OutDir=Join-Path $PSScriptRoot '..\artifacts\thermal-r272'} +$MainPath=(Resolve-Path -LiteralPath $MainPath).Path +$OutDir=[IO.Path]::GetFullPath($OutDir) +if(Test-Path $OutDir){throw 'R272 output already exists: fail closed against stale evidence'} +[void][IO.Directory]::CreateDirectory($OutDir) +$expectedSha=(Get-FileHash -LiteralPath $MainPath -Algorithm SHA256).Hash +$sourceHead=(& git -C (Join-Path $PSScriptRoot '..') rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0){throw 'R272 git source head unavailable'} +$dir=Join-Path $OutDir 'real-thermal-pixels' +$p=[Diagnostics.Process]::new() +$p.StartInfo.FileName=$MainPath +$p.StartInfo.Arguments='--glassthermalproof "'+$dir+'"' +$p.StartInfo.UseShellExecute=$false +$p.StartInfo.CreateNoWindow=$true +$p.StartInfo.RedirectStandardOutput=$true +$p.StartInfo.RedirectStandardError=$true +try{ + if(!$p.Start()){throw 'R272 child process did not start'} + $stdoutAsync=$p.StandardOutput.ReadToEndAsync() + $stderrAsync=$p.StandardError.ReadToEndAsync() + if(!$p.WaitForExit(180000)){ + try{$p.Kill();$p.WaitForExit()}catch{} + throw 'R272 child process timeout' + } + $p.WaitForExit() + $code=$p.ExitCode + $stdout=$stdoutAsync.GetAwaiter().GetResult() + $stderr=$stderrAsync.GetAwaiter().GetResult() +}finally{$p.Dispose()} +[IO.File]::WriteAllText((Join-Path $OutDir 'child-stdout.txt'),$stdout,[Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $OutDir 'child-stderr.txt'),$stderr,[Text.UTF8Encoding]::new($false)) +if($code -ne 0 -or !$stdout.Contains('TBME_THERMAL_GLASS_PROOF=PASS') -or $stderr.Contains('=FAIL')){ + throw ('R272 native compiled pixel proof failed childCode='+$code+' stdout='+$stdout+' stderr='+$stderr) +} +$receiptPath=Join-Path $dir 'THERMAL_GLASS_PROOF.json' +if(!(Test-Path $receiptPath)){throw 'R272 typed receipt absent'} +$v=Get-Content $receiptPath -Raw | ConvertFrom-Json +if($v.Revision -cne 'R272' -or $v.Status -cne 'PASS' -or + $v.Source -cne 'REAL_OVERLAY_THEME_AND_METRIC_PAINTER' -or + $v.HotC -ne 95 -or $v.ColdC -ne 60 -or $v.ThresholdC -ne 85 -or + $v.Themes -ne 49 -or $v.ThemeBackgroundPairs -ne 98 -or + $v.HotRedPixelsMinimum -lt 40 -or $v.ColdRedPixelsMaximum -ne 0 -or + $v.ClearBackgroundPixelsMinimum -le 7440 -or + $v.OptOutTested -ne $true -or $v.NoNativeHostInput -ne $true -or + $v.NoUserSettingsOrSensorMutation -ne $true){ + throw 'R272 typed 49-theme thermal pixel acceptance contract failed' +} +$actualSha=(Get-FileHash $MainPath -Algorithm SHA256).Hash +if($actualSha -cne $expectedSha){throw 'R272 compiled Main mutated after proof'} +$receipt=[ordered]@{ + Revision='R272' + Status='PASS' + GitHead=$sourceHead + MainSHA256=$expectedSha + PixelReceiptSHA256=(Get-FileHash $receiptPath).Hash + ThemePairs=$v.ThemeBackgroundPairs + AlertPixelMinimum=$v.HotRedPixelsMinimum + ColdPixelMaximum=$v.ColdRedPixelsMaximum + ClearPixelMinimum=$v.ClearBackgroundPixelsMinimum + VisualScope='Real production bitmap painter with synthetic CPU temperature; no live HWND or notification' +} +[IO.File]::WriteAllText((Join-Path $OutDir 'R272_ACCEPTANCE.json'),($receipt|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +Write-Output ('TBME_R272_THERMAL_GLASS=PASS 49_THEMES=TRUE PAIRS='+$v.ThemeBackgroundPairs+' MIN_RED='+$v.HotRedPixelsMinimum+' BINARY_SHA='+$expectedSha) diff --git a/build/Test-Native-Presentation-R273.ps1 b/build/Test-Native-Presentation-R273.ps1 new file mode 100644 index 00000000..e3ac4d40 --- /dev/null +++ b/build/Test-Native-Presentation-R273.ps1 @@ -0,0 +1,300 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel,[switch]$UseInstalledCandidate) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R146_REAL_TEST_ONLY_FRESH_HOSTED_WINDOWS_FRAMEWORK'} +Add-Type -AssemblyName System.Drawing,System.Windows.Forms,UIAutomationClient,UIAutomationTypes +$native=@' +using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices; +public static class R146Native { + [StructLayout(LayoutKind.Sequential)] public struct Rect {public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential)] public struct Point {public int X,Y;} + [StructLayout(LayoutKind.Sequential)] public struct Mouse {public int x,y;public uint data,flags,time;public UIntPtr extra;} + [StructLayout(LayoutKind.Explicit)] public struct Union {[FieldOffset(0)] public Mouse m;} + [StructLayout(LayoutKind.Sequential)] public struct Input {public uint type;public Union u;} + public delegate bool EnumFn(IntPtr h,IntPtr data); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr p,EnumFn f,IntPtr d); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out Rect r); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetWindowLongPtr(IntPtr h,int n); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h,uint n); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h,StringBuilder s,int n); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c,string t); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int s); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l); + [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr extra); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out Point p); + [DllImport("user32.dll",SetLastError=true)] public static extern uint SendInput(uint n,Input[] p,int size); + public static void Key(byte code){keybd_event(code,0,0,UIntPtr.Zero);keybd_event(code,0,2,UIntPtr.Zero);} + public static void Click(int x,int y,bool right){Point old;GetCursorPos(out old);SetCursorPos(x,y);try{ + Input down=new Input{u=new Union{m=new Mouse{flags=right?8u:2u}}}; + Input up=new Input{u=new Union{m=new Mouse{flags=right?16u:4u}}}; + if(Marshal.SizeOf(typeof(Input))!=40||SendInput(2,new[]{down,up},40)!=2)throw new Exception("Input not delivered"); + }finally{SetCursorPos(old.X,old.Y);}} + public static IntPtr[] Owned(uint pid,IntPtr tray){var list=new List();EnumFn f=(h,d)=>{uint p;GetWindowThreadProcessId(h,out p);if(p==pid&&!list.Contains(h))list.Add(h);return true;};EnumWindows(f,IntPtr.Zero);EnumChildWindows(tray,f,IntPtr.Zero);return list.ToArray();} + public static bool Above(IntPtr a,IntPtr b){for(int i=0;i<512;i++){b=GetWindow(b,3);if(b==IntPtr.Zero)return false;if(a==b)return true;}return false;} + public static string Title(IntPtr h){var s=new StringBuilder(256);GetWindowText(h,s,s.Capacity);return s.ToString();} +} +'@ +Add-Type -TypeDefinition $native -Language CSharp +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Private -ne $true){throw 'R146_NOT_EXACT_PRIVATE_BUILD_ARTIFACT'} +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +if((Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R146_SETUP_SHA'} +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R273_'+$HostLabel) +if((Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R273_NATIVE_OUTPUT_OR_PROCESS_PRESTATE'} +if($UseInstalledCandidate){ + if(!(Test-Path $app) -or !(Test-Path $reg) -or (Test-Path (Join-Path $app '.tbme-upgrade'))){throw 'R273_VERIFIED_EXISTING_CANDIDATE_REQUIRED'} + $s=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($s.Version -cne '1.7.6' -or (Get-FileHash (Join-Path $app 'TaskbarMonitorEnhanced.exe') -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R273_NATIVE_INSTALLED_SHA_OR_VERSION'} +}else{if((Test-Path $app) -or (Test-Path $reg)){throw 'R273_NATIVE_FRESH_VM_REQUIRED'}} +New-Item -ItemType Directory -Path $out|Out-Null +$visualFailures=New-Object 'Collections.Generic.List[string]' +$phase='INSTALL';$status='FAIL';$failure='';$process=$null;$full=$null;$taskbar=[IntPtr]::Zero;$hidTaskbar=$false;$evidence=@() +function Box([IntPtr]$h){$r=New-Object R146Native+Rect;if(![R146Native]::GetWindowRect($h,[ref]$r)){throw 'R146_RECT_FAILED'};return New-Object Drawing.Rectangle($r.L,$r.T,($r.R-$r.L),($r.B-$r.T))} +function Pump([int]$ms){$stop=[Diagnostics.Stopwatch]::StartNew();while($stop.ElapsedMilliseconds -lt $ms){[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 25}} +function Capture([IntPtr]$h,[string]$label){ + $r=Box $h;$file=$label+'.png';$b=New-Object Drawing.Bitmap($r.Width,$r.Height) + try{ + $g=[Drawing.Graphics]::FromImage($b);try{$g.CopyFromScreen($r.Location,[Drawing.Point]::Empty,$r.Size)}finally{$g.Dispose()} + $edges=0;for($y=0;$y -lt $b.Height;$y++){for($x=1;$x -lt $b.Width;$x++){ + $a=$b.GetPixel($x,$y);$z=$b.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$edges++} + }} + $b.Save((Join-Path $out $file),[Drawing.Imaging.ImageFormat]::Png) + if($edges -lt 60){$visualFailures.Add(('R146_PHYSICAL_GLYPHS_MISSING '+$label+' EDGES='+$edges))} + return @{State=$label;File=$file;SHA256=(Get-FileHash (Join-Path $out $file) -Algorithm SHA256).Hash;Edges=$edges;Rect=@($r.X,$r.Y,$r.Width,$r.Height);Parent=[R146Native]::GetParent($h).ToInt64();Owner=[R146Native]::GetWindow($h,4).ToInt64();OwnerMemory=[R146Native]::GetWindowLongPtr($h,-8).ToInt64();PreviousSibling=[R146Native]::GetWindow($h,3).ToInt64();ExStyle=[R146Native]::GetWindowLongPtr($h,-20).ToInt64();Style=[R146Native]::GetWindowLongPtr($h,-16).ToInt64();Foreground=[R146Native]::GetForegroundWindow().ToInt64()} + }finally{$b.Dispose()} +} +# R163 fork-only paint readiness. WS_VISIBLE is not proof of actual text ink. +# Same RGB edge definition as Capture(): adjacent channel-sum delta >=120. +# ZERO edges must NEVER satisfy 60-edge native acceptance. No product code change. +function Probe-R163PhysicalInk([IntPtr]$h,[string]$label,[bool]$saveFrame){ + $rect=Box $h + if($rect.Width -lt 80 -or $rect.Height -lt 16 -or $rect.Height -ge 100){ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error='R163_INVALID_WINDOW_GEOMETRY'} + } + $bitmap=New-Object Drawing.Bitmap($rect.Width,$rect.Height) + try{ + try{ + $g=[Drawing.Graphics]::FromImage($bitmap) + try{$g.CopyFromScreen($rect.Location,[Drawing.Point]::Empty,$rect.Size)} + finally{$g.Dispose()} + }catch{ + return @{Edges=0;Colors=0;Bytes=0;SHA256='';Error=('R163_COPY_FROM_SCREEN_FAILED '+$_.Exception.Message)} + } + $ink=0 + $colors=New-Object 'System.Collections.Generic.HashSet[int]' + for($y=0;$y -lt $bitmap.Height;$y++){ + for($x=0;$x -lt $bitmap.Width;$x++){ + $a=$bitmap.GetPixel($x,$y) + $rgb=([int]$a.R -shl 16) -bor ([int]$a.G -shl 8) -bor [int]$a.B + [void]$colors.Add($rgb) + if($x -ge 1){ + $z=$bitmap.GetPixel(($x-1),$y) + if([Math]::Abs(([int]$a.R+$a.G+$a.B)-([int]$z.R+$z.G+$z.B)) -ge 120){$ink++} + } + } + } + $file='';$sha='';$bytes=0 + if($saveFrame){ + $file=$label+'.png' + $full=Join-Path $out $file + if(Test-Path $full){throw ('R163_PROBE_IMAGE_OVERWRITE_REFUSED '+$file)} + $bitmap.Save($full,[Drawing.Imaging.ImageFormat]::Png) + $sha=(Get-FileHash $full -Algorithm SHA256).Hash + $bytes=(Get-Item $full).Length + } + return @{Edges=$ink;Colors=$colors.Count;Bytes=$bytes;File=$file;SHA256=$sha;Error='';Rect=@($rect.X,$rect.Y,$rect.Width,$rect.Height)} + }finally{$bitmap.Dispose()} +} + +function Assert-Visible([IntPtr]$h,[Drawing.Rectangle]$original,[string]$label,[bool]$top){ + if(![R146Native]::IsWindowVisible($h)){throw ('R146_MONITOR_HIDDEN '+$label)} + $r=Box $h + if($r.X -ne $original.X -or $r.Y -ne $original.Y -or $r.Height -ne $original.Height -or [Math]::Abs($r.Width-$original.Width) -gt 96){throw ('R146_UNEXPECTED_MOVEMENT '+$label)} + # R153: retain both direct owner-memory and WS_POPUP GetParent checks. + # R152 real Start evidence showed GW_OWNER=0 but GetParent=the exact + # taskbar and 877 physical glyph edges. Never let a relationship-walk + # API substitute for pixels; never waive actual owner-memory or geometry. + $ownerMemory=[R146Native]::GetWindowLongPtr($h,-8) + $popupOwner=[R146Native]::GetParent($h) + if($ownerMemory -ne $taskbar -or $popupOwner -ne $taskbar){ + $nativeOwner=[R146Native]::GetWindow($h,4) + $directTray=[R146Native]::FindWindow('Shell_TrayWnd',$null) + $frame=Capture $h ($label+'_OWNER_FAILURE') + [IO.File]::WriteAllText((Join-Path $out ($label+'_NATIVE_FAILURE.json')),(@{ExpectedOwner=$taskbar.ToInt64();ActualOwner=$nativeOwner.ToInt64();DirectOwnerMemory=$ownerMemory.ToInt64();PopupOwner=$popupOwner.ToInt64();DirectTaskbar=$directTray.ToInt64();Frame=$frame}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + throw ('R152_TASKBAR_OWNER_DRIFT '+$label+' EXPECTED='+$taskbar.ToInt64()+' ACTUAL='+$nativeOwner.ToInt64()+' DIRECT='+$directTray.ToInt64()) + } + if(([R146Native]::GetWindowLongPtr($h,-16).ToInt64() -band 0x40000000) -ne 0){throw ('R151_MONITOR_BECAME_CHILD '+$label)} + if($top -and [R146Native]::Above($taskbar,$h)){throw ('R146_MONITOR_UNDER_TASKBAR '+$label)} +} +try{ + if(!$UseInstalledCandidate){ + $install=Start-Process -FilePath $setup -ArgumentList @('/install','/quiet','/desktop=0','/startup=0') -PassThru + if(!$install.WaitForExit(190000) -or $install.ExitCode -ne 0){throw 'R273_SETUP_DID_NOT_FINISH'} + } + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe';$config=Join-Path $app 'config.json' + if((Get-FileHash $main -Algorithm SHA256).Hash -cne $manifest.MainSHA256){throw 'R146_INSTALLED_MAIN_SHA'} + $taskbar=[R146Native]::FindWindow('Shell_TrayWnd',$null) + if($taskbar -eq [IntPtr]::Zero -or ![R146Native]::IsWindowVisible($taskbar)){throw 'R146_REAL_TASKBAR_MISSING'} + $assembly=[Reflection.Assembly]::LoadFrom($main) + $shell=$assembly.GetType('TaskbarMonitorEnhanced.ShellUi',$true) + $shellMethod=$shell.GetMethod('IsStartShellForeground',[Reflection.BindingFlags]'Static,Public,NonPublic') + foreach($mode in @('plain1','plain10','glass0','opaque100')){ + $phase=$mode + $pct=if($mode -ceq 'plain1'){0.01}elseif($mode -ceq 'plain10'){0.10}elseif($mode -ceq 'glass0'){0.0}else{1.0} + $fixture=@{Theme='Retro Terminal';Opacity=$pct;GlassMode=($mode -ceq 'glass0');OpacityWhenGlassOff=0.10;ConfigSchemaVersion=8;StartWithWindows=$false;ShowSparklines=$true;UpdateIntervalMs=1000} + [IO.File]::WriteAllText($config,($fixture|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $process=Start-Process -FilePath $main -WorkingDirectory $app -PassThru + $readyClock=[Diagnostics.Stopwatch]::StartNew();$readyObservations=@() + Pump 5000 # Keep original warm-up and strict 20s total readiness deadline. + $monitor=[IntPtr]::Zero;$paintFirst=$false;$paintLast=$null + $lastEligible=[IntPtr]::Zero + do{ + $process.Refresh() + if($process.HasExited){throw ('R158_MAIN_EXITED_BEFORE_READY '+$mode+' CODE='+$process.ExitCode)} + $observed=@() + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){ + $visible=[R146Native]::IsWindowVisible($h);$r=Box $h + $one=@{Handle=$h.ToInt64();Visible=$visible;Rect=@($r.X,$r.Y,$r.Width,$r.Height)} + if($visible -and $r.Width -ge 80 -and $r.Height -ge 16 -and $r.Height -lt 100 -and (Box $taskbar).Contains($r)){ + $lastEligible=$h + # Persist FIRST physical frame, whether ink is present or missing. + $paint=Probe-R163PhysicalInk $h ($mode+'_R163_FIRST_PAINT') (!$paintFirst) + $paintFirst=$true + $paintLast=$paint + $one.PaintEdges=$paint.Edges;$one.PaintColors=$paint.Colors;$one.PaintError=$paint.Error + if($paint.Edges -ge 60 -and [string]$paint.Error -ceq ''){ + $monitor=$h;$observed+=@($one);break + } + } + $observed+=@($one) + } + if($readyObservations.Count -lt 24){ + $readyObservations+=@{ElapsedMs=$readyClock.ElapsedMilliseconds;Windows=$observed} + } + if($monitor -eq [IntPtr]::Zero){Pump 200} + }while($monitor -eq [IntPtr]::Zero -and $readyClock.ElapsedMilliseconds -lt 20000) + $readyClock.Stop() + $timeoutProbe=$null + if($monitor -eq [IntPtr]::Zero -and $lastEligible -ne [IntPtr]::Zero){ + $timeoutProbe=Probe-R163PhysicalInk $lastEligible ($mode+'_R163_TIMEOUT_PAINT') $true + } + $ready=[ordered]@{Mode=$mode;Pid=$process.Id;Ready=($monitor -ne [IntPtr]::Zero); + PaintReady=($monitor -ne [IntPtr]::Zero -and $null -ne $paintLast -and $paintLast.Edges -ge 60); + PaintThresholdEdges=60;OriginalRgbDelta=120; + LastMeasuredEdges=if($null -eq $paintLast){-1}else{$paintLast.Edges}; + LastMeasuredColors=if($null -eq $paintLast){0}else{$paintLast.Colors}; + TimeoutFrame=$timeoutProbe; + ElapsedMilliseconds=$readyClock.ElapsedMilliseconds;HardDeadlineMs=20000; + Observations=$readyObservations;ProductSourceChanged=$false; + NoThresholdWeakening=$true;SeparateTaskbarHideInvariantRequired=$true} + [IO.File]::WriteAllText((Join-Path $out ('R158_READY_'+$mode+'.json')),($ready|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if($monitor -eq [IntPtr]::Zero){ + if($lastEligible -ne [IntPtr]::Zero){ + throw ('R163_PHYSICAL_PAINT_NOT_READY_WITHIN_20_SECONDS '+$mode+ + ' EDGES='+$ready.LastMeasuredEdges+' COLORS='+$ready.LastMeasuredColors) + } + throw ('R158_NATIVE_WINDOW_NOT_READY_WITHIN_20_SECONDS '+$mode) + } + if(!$ready.PaintReady -or $ready.LastMeasuredEdges -lt 60){ + throw ('R163_UNPROVEN_NATIVE_PAINT '+$mode) + } + Write-Output ('R163_NATIVE_AND_ACTUAL_INK_READY MODE='+$mode+' PID='+$process.Id+ + ' ELAPSED_MS='+$readyClock.ElapsedMilliseconds+' EDGES='+$ready.LastMeasuredEdges) + $original=Box $monitor;$top=$true + # Original Capture() remains the separate final pixel threshold acceptance. + $evidence+=Capture $monitor ($mode+'_NORMAL') + # Real right-click and invoke the actual installed product's Settings menu item. + [R146Native]::Click(($original.X+40),($original.Y+($original.Height/2)),$true);Pump 650 + Assert-Visible $monitor $original ($mode+'_MENU') $top + $evidence+=Capture $monitor ($mode+'_MENU') + $menuItem=$null + $condition=New-Object Windows.Automation.AndCondition([Windows.Automation.Condition[]]@( + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::ProcessIdProperty,[int]$process.Id)), + (New-Object Windows.Automation.PropertyCondition([Windows.Automation.AutomationElement]::NameProperty,'Settings...')))) + $menuItem=[Windows.Automation.AutomationElement]::RootElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$condition) + if($null -eq $menuItem){throw 'R146_REAL_SETTINGS_MENU_ITEM_NOT_FOUND'} + $invoke=$menuItem.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern);$invoke.Invoke();Pump 1500 + $settings=[IntPtr]::Zero + foreach($h in [R146Native]::Owned([uint32]$process.Id,$taskbar)){if([R146Native]::Title($h) -like '* - Settings' -and [R146Native]::IsWindowVisible($h)){$settings=$h;break}} + if($settings -eq [IntPtr]::Zero -or [R146Native]::GetForegroundWindow() -ne $settings){throw 'R146_SETTINGS_NOT_REAL_FOREGROUND'} + $work=[Windows.Forms.Screen]::FromHandle($settings).WorkingArea + if(!$work.Contains((Box $settings))){throw 'R146_SETTINGS_EXTENDS_BEHIND_TASKBAR'} + Assert-Visible $monitor $original ($mode+'_SETTINGS') $top + $evidence+=Capture $monitor ($mode+'_SETTINGS') + [void][R146Native]::SendMessage($settings,0x0010,[IntPtr]::Zero,[IntPtr]::Zero);Pump 500 + [R146Native]::Key(0x5B);Pump 1200 + $args=[object[]]@('','',[IntPtr]::Zero) + if(!$shellMethod.Invoke($null,$args)){throw 'R146_GENUINE_START_FOREGROUND_NOT_VERIFIED'} + Assert-Visible $monitor $original ($mode+'_START') $top + $evidence+=Capture $monitor ($mode+'_START') + [R146Native]::Key(0x1B);Pump 900 + Assert-Visible $monitor $original ($mode+'_AFTER_START') $top + # A real foreground fullscreen window covering this monitor and its taskbar. + $full=New-Object Windows.Forms.Form + $full.FormBorderStyle='None';$full.StartPosition='Manual';$full.Bounds=[Windows.Forms.Screen]::FromHandle($taskbar).Bounds + $full.TopMost=$true;$full.BackColor=[Drawing.Color]::FromArgb(24,40,56);$full.Text='R146 disposable fullscreen witness';$full.Show();Pump 300 + [R146Native]::Click(($full.Left+100),($full.Top+100),$false);Pump 1500 + if([R146Native]::GetForegroundWindow() -ne $full.Handle){throw 'R146_FULLSCREEN_NOT_FOREGROUND'} + if([R146Native]::IsWindowVisible($monitor)){throw 'R146_MONITOR_VISIBLE_WITHOUT_TASKBAR_IN_FULLSCREEN'} + $evidence+=@{State=$mode+'_FULLSCREEN';Hidden=$true;Foreground=$full.Handle.ToInt64()} + $full.Close();$full.Dispose();$full=$null;Pump 1500 + Assert-Visible $monitor $original ($mode+'_RETURN_FULLSCREEN') $top + $evidence+=Capture $monitor ($mode+'_RETURN_FULLSCREEN') + # Simulate taskbar visibility notification, only on disposable runner; always restore. + [void][R146Native]::ShowWindow($taskbar,0);$hidTaskbar=$true;Pump 1100 + if([R146Native]::IsWindowVisible($monitor)){ + # Do not suppress this independent FAIL gate. Collect the real HWND/shell + # visibility and optional screen-pixel evidence before the existing throw. + $forkHide=[ordered]@{ + Mode=$mode;MainPid=$process.Id; + TaskbarVisible=[R146Native]::IsWindowVisible($taskbar) + MonitorVisible=[R146Native]::IsWindowVisible($monitor) + TaskbarRect=@( (Box $taskbar).X,(Box $taskbar).Y,(Box $taskbar).Width,(Box $taskbar).Height ) + MonitorRect=@( (Box $monitor).X,(Box $monitor).Y,(Box $monitor).Width,(Box $monitor).Height ) + PopupOwner=[R146Native]::GetParent($monitor).ToInt64() + MemoryOwner=[R146Native]::GetWindowLongPtr($monitor,-8).ToInt64() + ProductSourceChanged=$false + OriginalHideInvariantRetained=$true + } + [IO.File]::WriteAllText((Join-Path $out ($mode+'_R163_TASKBAR_HIDE_FAILURE.json')), + ($forkHide|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + throw 'R146_MONITOR_IGNORES_TASKBAR_HIDE' + } + [void][R146Native]::ShowWindow($taskbar,4);$hidTaskbar=$false;Pump 1100 + Assert-Visible $monitor $original ($mode+'_TASKBAR_REAPPEARS') $top + $process.Refresh();$cpu0=$process.CPU;$clock=[Diagnostics.Stopwatch]::StartNew();Pump 5000;$clock.Stop();$process.Refresh() + $evidence+=@{State=$mode+'_CPU';Seconds=$clock.Elapsed.TotalSeconds;CpuSeconds=$process.CPU-$cpu0;PercentOfOneCore=100*($process.CPU-$cpu0)/$clock.Elapsed.TotalSeconds;Status='MEASURED_NOT_USER_PC_ACCEPTANCE'} + [void][R146Native]::SendMessage($monitor,0x0010,[IntPtr]::Zero,[IntPtr]::Zero) + if(!$process.WaitForExit(10000)){throw 'R146_OWN_TEST_PROCESS_DID_NOT_EXIT'};$process.Dispose();$process=$null + } + if($visualFailures.Count){throw ($visualFailures -join ';')} + $status='PASS_REAL_MENU_SETTINGS_START_FULLSCREEN_TASKBAR_VISIBILITY_FOUR_MODES' +}catch{$failure=$_.Exception.ToString().Substring(0,[Math]::Min(3500,$_.Exception.ToString().Length))} +finally{ + if($hidTaskbar -and $taskbar -ne [IntPtr]::Zero){[void][R146Native]::ShowWindow($taskbar,4)} + [R146Native]::Key(0x1B) + if($null -ne $full){$full.Close();$full.Dispose()} + if($null -ne $process){try{if(!$process.HasExited){$process.Kill()}}catch{}} + try{ + $logs=@(Get-ChildItem (Join-Path $app 'Logs') -Filter 'tbme_csharp_*.log' -File -ErrorAction Stop) + foreach($log in $logs){ + Get-Content -LiteralPath $log.FullName -Tail 1600 | Select-String -Pattern 'PRESENTATION|GLASS_NATIVE|GLASS_COMPOSITION|TASKBAR_|VISIBILITY_WATCHDOG|LOW_PRESSURE|CONTEXT_MENU|SETTINGS|START_|RECOVERY' | ForEach-Object Line | Set-Content -LiteralPath (Join-Path $out ('DIAG_'+$log.Name)) -Encoding UTF8 + } + }catch{Write-Output ('R148_LOG_COLLECTION_WARNING '+$_.Exception.Message)} + $r=[ordered]@{Revision='R273';Status=$status;Phase=$phase;Failure=$failure;Host=$HostLabel;EventCommit=$env:GITHUB_SHA;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;Evidence=$evidence;TestedModes=@('plain1','plain10','glass0','opaque100');ExpectedTaskbarOwner=$taskbar.ToInt64();PersonalComputerTouched=$false;Proof='Installed candidate + native HWND + real screenshots + real input; not synthetic taskbar paint'} + [IO.File]::WriteAllText((Join-Path $out 'R273_NATIVE.json'),($r|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 12 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R146_NATIVE_FAIL '+$failure)} diff --git a/build/Test-NoPawnIO-OssRelease.ps1 b/build/Test-NoPawnIO-OssRelease.ps1 index 26d5e1e7..06c6ac62 100644 --- a/build/Test-NoPawnIO-OssRelease.ps1 +++ b/build/Test-NoPawnIO-OssRelease.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $LegacyDir=Join-Path $Root 'artifacts\_r85_forbidden_pawnio_fixture' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' -$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' $Main=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' $LegacyVerify=Join-Path $LegacyDir 'legacy_verify.json' $Reader=Join-Path $PSScriptRoot 'Verify-Embedded-Main.ps1' if(Test-Path -LiteralPath $LegacyDir){throw 'R85 UNSAFE RERUN: FORBIDDEN FIXTURE ALREADY EXISTS'} @@ -56,7 +56,7 @@ try { Write-Output ('R85_OSS_NO_PROPRIETARY_BUNDLE_PASS productionEmbedded=False negativeLegacyResources='+$v.Resources+' negativeRejected=True') } finally { if($created -and (Test-Path -LiteralPath $LegacyDir)){ - $allow=@('TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe.config','legacy_verify.json') + $allow=@('TaskbarMonitorEnhanced_Setup_1.7.6.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe.config','legacy_verify.json') $objects=@(Get-ChildItem -LiteralPath $LegacyDir -Force) if(@($objects | Where-Object {$_.PSIsContainer -or $_.Name -notin $allow}).Count -ne 0){ throw ('R85 DEGRADED: UNKNOWN FILE IN OWNED STAGING; PRESERVED '+$LegacyDir) diff --git a/build/Test-R273-FreshSameVersion-Disposable.ps1 b/build/Test-R273-FreshSameVersion-Disposable.ps1 new file mode 100644 index 00000000..2efc7737 --- /dev/null +++ b/build/Test-R273-FreshSameVersion-Disposable.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$CandidateDir, + [Parameter(Mandatory=$true)][ValidateSet("A","B")][string]$HostLabel +) +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" + +# Test-only, exact source, genuine compiled .NET 4.x Windows setup. +# No run on owner's PC, no driver install direct from this harness. +if($env:GITHUB_ACTIONS -cne "true" -or + $env:RUNNER_ENVIRONMENT -cne "github-hosted" -or + $env:RUNNER_OS -cne "Windows" -or + $env:GITHUB_REPOSITORY -cne "GOD13emad/TaskbarMonitorEnhanced" -or + ($env:GITHUB_REF -cne "refs/heads/fix/v1.7.6-glass-thermal-alert-r272" -and + $env:GITHUB_REF -cne "refs/heads/main") -or + $env:COMPUTERNAME -match "(?i)EMAD|ALI|LAPTOP" -or + -not [Environment]::UserInteractive) { + throw "R273_REFUSE_NON_DISPOSABLE_GITHUB_HOST" +} +$repo="GOD13emad/TaskbarMonitorEnhanced" +$app=Join-Path $env:LOCALAPPDATA "TaskbarMonitorEnhanced" +$reg="HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced" +$build=Get-Content -LiteralPath (Join-Path $CandidateDir "CANDIDATE.json") -Raw|ConvertFrom-Json -ErrorAction Stop +$setup=[IO.Path]::GetFullPath((Join-Path $CandidateDir "Setup/TaskbarMonitorEnhanced_Setup_1.7.6.exe")) +$out=Join-Path $env:RUNNER_TEMP ("TBME_R273_"+$HostLabel) +$receipt=Join-Path $out "R273_FRESH.json" +$folder=if($HostLabel -ceq "A"){[Environment]::GetFolderPath("ProgramFiles")}else{[Environment]::GetFolderPath("ProgramFilesX86")} +$legacy=Join-Path $folder "TaskbarMonitorEnhanced" +$marker=Join-Path $legacy "TaskbarMonitorEnhanced.exe" +$owned=$false;$expectedMarker="" +$status="FAIL";$phase="PREFLIGHT";$error="" +$installedExit=$null;$sameVersionExit=$null;$negativeExit=$null;$afterCollisionExit=$null +$original=@{};$originalRun="";$removed=false +function StopNow([string]$msg){throw ("R273_"+$msg)} +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return "MISSING"} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function RunCandidate([string]$arguments){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setup;$psi.Arguments=$arguments + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.WorkingDirectory=[IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($setup)) + $p=[Diagnostics.Process]::Start($psi) + if($null -eq $p){StopNow "CANDIDATE_SETUP_NOT_STARTED"} + try{ + if(!$p.WaitForExit(180000)){StopNow ("CANDIDATE_TIMEOUT_"+$p.Id)} + return [int]$p.ExitCode + } finally {$p.Dispose()} +} +function StartupValue{ + $key=Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -ErrorAction SilentlyContinue + if($null -eq $key){return "ABSENT"} + $v=$key.PSObject.Properties["TaskbarMonitorEnhanced"] + if($null -eq $v){return "ABSENT"} + return [string]$v.Value +} +function Snapshot{ + $paths=@("TaskbarMonitorEnhanced.exe","Uninstall.exe","config.json", + "config.json.bak","install_state.json","Source/TaskbarMonitorEnhanced.cs", + "Source/TaskbarGlass.cs","R273_PRESERVE_USER_CANARY.txt") + $m=@{} + foreach($path in $paths){$m[$path]=Hash (Join-Path $app $path)} + return $m +} +function AssertUnchanged($before,[string]$case){ + $now=Snapshot + if($now.Count -ne $before.Count){StopNow ("MAP_COUNT_"+$case)} + foreach($item in $before.Keys){ + if($before[$item] -cne $now[$item]){StopNow ("USER_OR_MANAGED_DRIFT_"+$case+"_"+$item)} + } + if((Get-ItemProperty $reg -ErrorAction Stop).DisplayVersion -cne "1.7.6"){ + StopNow ("REGISTERED_VERSION_DRIFT_"+$case) + } + if((StartupValue) -cne $originalRun){StopNow ("STARTUP_DRIFT_"+$case)} + if(Test-Path (Join-Path $app ".tbme-upgrade")){StopNow ("TRANSACTION_STAGE_LEFT_"+$case)} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow ("MAIN_PROCESS_RUNNING_"+$case)} +} +if($build.Commit -cne $env:GITHUB_SHA -or $build.Version -cne "1.7.6" -or + $build.Private -ne $true -or $build.NotForPublicInstallation -ne $true){ + StopNow "WRONG_PRIVATE_CANDIDATE_MANIFEST" +} +if((Hash $setup) -cne $build.SetupSHA256){StopNow "PRIVATE_COMPILED_SETUP_SHA_DRIFT"} +if((Test-Path $app) -or (Test-Path $reg) -or (Test-Path $legacy) -or + (Test-Path $out) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + StopNow "GUEST_NOT_CLEAN" +} +[void](New-Item -Path $out -ItemType Directory -ErrorAction Stop) +try{ + $phase="FRESH_TEST_ONLY_SETUP" + $installedExit=RunCandidate "/install /quiet /desktop=0 /startup=0" + if($installedExit -ne 0){StopNow ("FRESH_INSTALL_EXIT_"+$installedExit)} + if((Hash (Join-Path $app "TaskbarMonitorEnhanced.exe")) -cne $build.MainSHA256 -or + (Hash (Join-Path $app "Uninstall.exe")) -cne $build.SetupSHA256){ + StopNow "FRESH_INSTALL_PRODUCT_SHA_CHANGED" + } + $source=Join-Path $app "Source/TaskbarMonitorEnhanced.cs" + $redundant=Join-Path $app "TaskbarMonitorEnhanced.cs" + if((Hash $source) -ceq "MISSING"){StopNow "SINGLE_MANAGED_SOURCE_NOT_INSTALLED"} + if(Test-Path $redundant){StopNow "REDUNDANT_ROOT_SOURCE_STILL_INSTALLED"} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){StopNow "FRESH_MAIN_ALREADY_RUNNING"} + $phase="CREATE_USER_OWNED_TEST_FIXTURE" + $config=Join-Path $app "config.json" + $backup=Join-Path $app "config.json.bak" + $canary=Join-Path $app "R273_PRESERVE_USER_CANARY.txt" + if(!(Test-Path $config)){ + [IO.File]::WriteAllText($config,'{"GlassMode":true,"Opacity":0,"OpacityWhenGlassOff":0.1,"R273UserFixture":"retain all bytes"}',[Text.UTF8Encoding]::new($false)) + } + if(!(Test-Path $backup)){ + [IO.File]::WriteAllText($backup,'{"R273Backup":"independent prior user config must remain SHA-exact"}',[Text.UTF8Encoding]::new($false)) + } + [IO.File]::WriteAllText($canary,"R273 synthetic guest user canary, no personal content",[Text.UTF8Encoding]::new($false)) + $originalRun=StartupValue + $original=Snapshot + if(@($original.Values|Where-Object {$_ -ceq "MISSING"}).Count){StopNow "EXPECTED_FRESH_FIXTURE_MISSING"} + + $phase="FIRST_SAME_VERSION_MAINTENANCE_IDEMPOTENCE" + $sameVersionExit=RunCandidate "/update /quiet" + if($sameVersionExit -ne 0){StopNow ("SAME_VERSION_MAINTENANCE_FAILED_"+$sameVersionExit)} + AssertUnchanged $original "FIRST_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "MAINTENANCE_RECREATED_REDUNDANT_SOURCE"} + + $phase="REJECT_DISPOSABLE_PROGRAMFILES_DUPLICATE" + if(Test-Path $legacy){StopNow "LEGACY_PATH_PREEXISTS"} + [void](New-Item $legacy -ItemType Directory -ErrorAction Stop) + $owned=$true + [IO.File]::WriteAllText($marker,("R273 inert duplicate executable marker "+$HostLabel),[Text.UTF8Encoding]::new($false)) + $expectedMarker=Hash $marker + if($expectedMarker -ceq "MISSING"){StopNow "LEGACY_INERT_MARKER_MISSING"} + $negativeExit=RunCandidate "/update /quiet" + if($negativeExit -ne 34){StopNow ("PROGRAMFILES_DUPLICATE_NOT_EXACTLY_REFUSED_"+$negativeExit)} + if((Hash $marker) -cne $expectedMarker){StopNow "INERT_MARKER_MODIFIED"} + AssertUnchanged $original "PROGRAMFILES_CONFLICT_REFUSAL" + if((Hash $marker) -cne $expectedMarker){StopNow "MARKER_CHANGED_BEFORE_REMOVAL"} + Remove-Item -LiteralPath $marker -Force -ErrorAction Stop + if(@(Get-ChildItem -LiteralPath $legacy -Force).Count){StopNow "LEGACY_DIR_CONTENT_NOT_OWNED"} + Remove-Item -LiteralPath $legacy -Force -ErrorAction Stop + $owned=$false + if(Test-Path $legacy){StopNow "LEGACY_DIR_NOT_REMOVED"} + $removed=$true + + $phase="SECOND_IDEMPOTENT_UPDATE_AFTER_CONFLICT_REMOVAL" + $afterCollisionExit=RunCandidate "/update /quiet" + if($afterCollisionExit -ne 0){StopNow ("AFTER_COLLISION_MAINTENANCE_FAILED_"+$afterCollisionExit)} + AssertUnchanged $original "SECOND_IDEMPOTENCE" + if(Test-Path $redundant){StopNow "ROOT_REDUNDANCY_RETURNED"} + $status="PASS_REAL_GUEST_FRESH_R273_NO_REDUNDANT_SOURCE_AND_TWICE_IDEMPOTENT" +}catch{ + $error=$_.Exception.GetType().Name+": "+$_.Exception.Message +}finally{ + if($owned -and (Test-Path $marker) -and (Hash $marker) -ceq $expectedMarker){ + Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue + } + if($owned -and (Test-Path $legacy) -and @((Get-ChildItem -LiteralPath $legacy -Force -ErrorAction SilentlyContinue)).Count -eq 0){ + Remove-Item -LiteralPath $legacy -Force -ErrorAction SilentlyContinue + } + $e=[ordered]@{ + Revision="R273";Status=$status;Phase=$phase;Failure=$error;Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;TestOnlyCandidate=$true;PublicSetupChanged=$false + PersonalComputerTouched=$false;ReleasedV173Untouched=$true + CandidateMainSHA256=$build.MainSHA256;CandidateSetupSHA256=$build.SetupSHA256 + FreshInstallExit=$installedExit + SingleManagedSourceWithoutRootDuplicate=(!(Test-Path (Join-Path $app "TaskbarMonitorEnhanced.cs"))) + FirstSameVersionMaintenanceExit=$sameVersionExit + NegativeProgramFilesCollisionExit=$negativeExit + SecondSameVersionMaintenanceExit=$afterCollisionExit + GuestOwnedLegacyFixtureRemoved=(!(Test-Path $legacy)) + ProtectedCanaryAndConfigPreserved=($status -like "PASS_*") + RealHKLMRegistryOrCrossUserAcceptance=$false + NoPhysicalPowerCutOrExternalSecurityClaim=$true + UTC=[DateTime]::UtcNow.ToString("o") + } + [IO.File]::WriteAllText($receipt,($e|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($e|ConvertTo-Json -Compress -Depth 8) +} +if($status -notlike "PASS_*"){throw ("R273_REAL_GUEST_NOT_ACCEPTED "+$phase+" "+$error)} diff --git a/build/Test-Release-Gates.ps1 b/build/Test-Release-Gates.ps1 index 19038043..3882a221 100644 --- a/build/Test-Release-Gates.ps1 +++ b/build/Test-Release-Gates.ps1 @@ -44,7 +44,7 @@ if($null -ne $manifest) { 'TaskbarMonitorEnhanced.exe.config'='build\_out\App\TaskbarMonitorEnhanced.exe.config'; 'TaskbarMonitorSensorBroker.exe'='build\_out\Broker\TaskbarMonitorSensorBroker.exe'; 'TaskbarMonitorSensorSupervisor.exe'='build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe'; - 'TaskbarMonitorEnhanced_Setup_1.7.5.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' + 'TaskbarMonitorEnhanced_Setup_1.7.6.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' } $realHashesPass=$true foreach($item in $outputs){ @@ -168,7 +168,7 @@ Add-Gate 'G12_START_SEARCH_24_REAL_NATIVE_INK_AND_EXACT_RETURN' $startFloatPass # R75: G02 hashes deterministic UNSIGNED builder outputs. G14 and G15 must use # separate SIGNED release staging, since Authenticode modifies executable SHA256. $signedMainExe=Join-Path $root 'artifacts\signed\Main\TaskbarMonitorEnhanced.exe' -$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' $expectedSignedMainDigest='' if((Test-Path -LiteralPath $signedMainExe -PathType Leaf) -and (Test-Path -LiteralPath $signedSetupExe -PathType Leaf)){ @@ -233,7 +233,7 @@ Add-Gate 'G14_INSTALLED_TASKBAR_EXPLORER_SENSORS_ROLLBACK' $installedPass $insta $setupExe=$signedSetupExe $mainExe=$signedMainExe # Keep original _out builder outputs immutable so G02 exact source checks remain reproducible. -$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' # R73: G15 must prove actual SignPath Foundation publisher, valid timestamps, matched # exact file/product versions AND live provider-origin/payload evidence. Never trust # self-declared "approved" JSON, a test certificate or token-less synthetic output. @@ -253,10 +253,10 @@ if((Test-Path -LiteralPath $setupExe -PathType Leaf) -and (Test-Path -LiteralPat $sameCert=$null -ne $setupCert -and $null -ne $mainCert -and $setupCert.Thumbprint -ceq $mainCert.Thumbprint $exactMetadata=$setupInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and $mainInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and - $setupInfo.ProductVersion -ceq '1.7.5+r256' -and - $mainInfo.ProductVersion -ceq '1.7.5+r256' -and - $setupInfo.FileVersion -ceq '1.7.5.0' -and - $mainInfo.FileVersion -ceq '1.7.5.0' + $setupInfo.ProductVersion -ceq '1.7.6+r273' -and + $mainInfo.ProductVersion -ceq '1.7.6+r273' -and + $setupInfo.FileVersion -ceq '1.7.6.0' -and + $mainInfo.FileVersion -ceq '1.7.6.0' $cryptoPass=([string]$mainSig.Status -ceq 'Valid') -and ([string]$setupSig.Status -ceq 'Valid') -and $mainName -ceq 'SignPath Foundation' -and diff --git a/build/Test-Signed-Payload-Contract.ps1 b/build/Test-Signed-Payload-Contract.ps1 index 0142b50c..903e7af4 100644 --- a/build/Test-Signed-Payload-Contract.ps1 +++ b/build/Test-Signed-Payload-Contract.ps1 @@ -4,11 +4,11 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $original=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' $inputDir=Join-Path $Root 'artifacts\_r80_contract_input' $inputFile=Join-Path $inputDir 'TaskbarMonitorEnhanced.exe' $stageDir=Join-Path $Root 'artifacts\_r80_contract_only' -$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' $originalMainSHA=(Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash $originalSetupSHA=(Get-FileHash -LiteralPath $setupOrig -Algorithm SHA256).Hash if((Test-Path -LiteralPath $inputDir) -or (Test-Path -LiteralPath $stageDir)){ @@ -70,7 +70,7 @@ try{ # Cleanup exact owned files only. Unknown files survive; never delete personal state. foreach($d in @($stageDir,$inputDir)){ if(Test-Path -LiteralPath $d){ - $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} + $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.6.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} else{@('TaskbarMonitorEnhanced.exe')} $found=@(Get-ChildItem -LiteralPath $d -Force) if(@($found|Where-Object {$_.PSIsContainer -or $_.Name -notin $allowed}).Count -gt 0){ diff --git a/build/Test-Signed-Staging.ps1 b/build/Test-Signed-Staging.ps1 index 393e6ce8..5a8d9eeb 100644 --- a/build/Test-Signed-Staging.ps1 +++ b/build/Test-Signed-Staging.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $stage=Join-Path $root 'artifacts\signed' $origMain=Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' $targetMain=Join-Path $stage 'Main\TaskbarMonitorEnhanced.exe' -$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.5.exe' +$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' $created=@() if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $origMain -PathType Leaf) -or @@ -26,7 +26,7 @@ if(!(Test-Path -LiteralPath $manifest -PathType Leaf)){throw 'R75 MANIFEST REQUI $m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json $mapping=@{ 'TaskbarMonitorEnhanced.exe'=$hashMain - 'TaskbarMonitorEnhanced_Setup_1.7.5.exe'=$hashSetup + 'TaskbarMonitorEnhanced_Setup_1.7.6.exe'=$hashSetup } foreach($entry in $m.Outputs){ if($mapping.ContainsKey([string]$entry.Name)){ diff --git a/build/Test-Upgrade175-R273.ps1 b/build/Test-Upgrade175-R273.ps1 new file mode 100644 index 00000000..4f00af0b --- /dev/null +++ b/build/Test-Upgrade175-R273.ps1 @@ -0,0 +1,224 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$CandidateDir,[Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Genuine product install/update is permitted ONLY on a fresh disposable hosted VM. +if($PSVersionTable.PSEdition -eq 'Core' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or ![Environment]::UserInteractive){throw 'R273_REFUSE_PERSONAL_OR_NONINTERACTIVE_HOST'} +Add-Type -AssemblyName System.Windows.Forms +# R156: keep genuine CLR null; PS5.1 string binder converts $null to empty string. +Add-Type -TypeDefinition 'using System.IO; public static class R156FileBridge { public static void ReplaceWithoutBackup(string source,string destination){File.Replace(source,destination,null);} }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R273_UPGRADE_'+$HostLabel) +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $reg) -or @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'R273_NOT_FRESH_DISPOSABLE_HOST'} +$manifest=Get-Content (Join-Path $CandidateDir 'CANDIDATE.json') -Raw|ConvertFrom-Json +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.6' -or $manifest.Private -ne $true -or (Get-FileHash $setup -Algorithm SHA256).Hash -cne $manifest.SetupSHA256){throw 'R273_NOT_EXACT_VERSIONED_CANDIDATE'} +New-Item -ItemType Directory -Path $out|Out-Null +$phase='SOURCE_DOWNLOAD';$status='FAIL';$errorText='';$negative=0;$rollback=$false;$preserved=$false;$idempotent=$false;$downgrade=$false;$chain=@();$beforeProtected=@{};$evidence=@();$oldCpuR170=$null;$newCpuR170=$null +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Run-Exact([string]$path,[string]$Arguments,[int]$timeout=190000){ + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$path;$si.Arguments=$Arguments;$si.UseShellExecute=$false;$si.WorkingDirectory=(Split-Path $path -Parent) + $p=[Diagnostics.Process]::Start($si) + if($null -eq $p){throw 'R273_SETUP_PROCESS_NOT_CREATED'} + try{if(!$p.WaitForExit($timeout)){throw ('R273_SETUP_PROCESS_TIMEOUT PID='+$p.Id)};return [int]$p.ExitCode}finally{$p.Dispose()} +} +function StartupValue(){ + $k=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $k){return 'MISSING'};$p=$k.PSObject.Properties['TaskbarMonitorEnhanced'];if($null -eq $p){return 'MISSING'};return [string]$p.Value +} +function Snapshot(){ + $map=@{} + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v175-managed.json') -Raw|ConvertFrom-Json + $paths=@($inventory.Rows|ForEach-Object Path)+@('Docs/RELEASE_NOTES_v1.7.6.md','install_state.json','config.json','config.json.bak','R273_USER_OWNED.txt','SensorBackend/LibreHardwareMonitor-0.9.6/LibreHardwareMonitorLib.dll') + # Exclude volatile sensor telemetry/log files, not owned by installer and free to change independently. + foreach($rel in @($paths|Select-Object -Unique)){$map[$rel]=Hash (Join-Path $app $rel)} + $map['@RegistryVersion']=[string](Get-ItemProperty $reg).DisplayVersion + $map['@Startup']=StartupValue + return $map +} +function Equal-Snapshot($a,$b,[string]$reason){ + if($a.Count -ne $b.Count){throw ('R273_SNAPSHOT_COUNT_CHANGED '+$reason)} + foreach($k in $a.Keys){if(!$b.ContainsKey($k) -or $a[$k] -cne $b[$k]){throw ('R273_SNAPSHOT_CHANGED '+$reason+' '+$k)}} +} +# R170: identical owner-independent CPU sampling on BOTH versions on the SAME disposable +# GitHub-hosted Windows worker. Never invoked on personal/unknown hosts. +function Measure-InstalledCpuR170([string]$version,[string]$expectedSha){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or ![Environment]::UserInteractive){ + throw 'R170_CPU_PROBE_REFUSES_NON_DISPOSABLE_MACHINE' + } + $bin=Join-Path $app 'TaskbarMonitorEnhanced.exe' + $configFile=Join-Path $app 'config.json' + $backupFile=Join-Path $app 'config.json.bak' + if((Hash $bin) -cne $expectedSha){throw 'R170_CANDIDATE_EXE_SHA_MISMATCH'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_BEFORE_SAMPLE_PRODUCT_PROCESS_ALREADY_RUNNING' + } + $cfgBytes=[IO.File]::ReadAllBytes($configFile) + $bakBytes=[IO.File]::ReadAllBytes($backupFile) + $cfgSha=Hash $configFile;$bakSha=Hash $backupFile + $probe=$null;$sample=$null;$forcedStop=$false + try{ + $probe=Start-Process -FilePath $bin -WorkingDirectory $app -PassThru -ErrorAction Stop + Start-Sleep -Seconds 8 + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_WARMUP_'+$version)} + $cpu0=$probe.TotalProcessorTime.TotalSeconds + $timer=[Diagnostics.Stopwatch]::StartNew() + Start-Sleep -Seconds 8 + $timer.Stop() + $probe.Refresh() + if($probe.HasExited){throw ('R170_PROCESS_EXITED_DURING_SAMPLE_'+$version)} + $cpuSeconds=$probe.TotalProcessorTime.TotalSeconds-$cpu0 + $elapsed=$timer.Elapsed.TotalSeconds + $pct=100.0*$cpuSeconds/$elapsed + if($elapsed -lt 7.5 -or $elapsed -gt 12.5 -or $cpuSeconds -lt 0 -or + $pct -gt 500 -or [Double]::IsNaN($pct)){ + throw ('R170_INVALID_CPU_SAMPLE_'+$version) + } + $sample=[ordered]@{Version=$version;Host=$HostLabel;MainSHA256=$expectedSha; + CpuSeconds=$cpuSeconds;ElapsedSeconds=$elapsed;PercentOfOneCore=$pct; + ProcessId=$probe.Id;WarmupSeconds=8;Source='HOSTED_PROCESS_CPU_NOT_PERSONAL'; + ConfigurationBeforeSHA=$cfgSha;BackupBeforeSHA=$bakSha} + } + finally{ + if($null -ne $probe){ + try{ + $probe.Refresh() + if(!$probe.HasExited){ + try{[void]$probe.CloseMainWindow()}catch{} + if(!$probe.WaitForExit(3500)){ + $forcedStop=$true # CI-only process launched above, on disposable runner. + $probe.Kill() + if(!$probe.WaitForExit(12000)){throw 'R170_DISPOSABLE_PROCESS_NOT_STOPPED'} + } + } + }finally{$probe.Dispose()} + } + # Restore the exact isolated test fixture if application startup normalizes it. + if((Hash $configFile) -cne $cfgSha){ + [IO.File]::WriteAllBytes($configFile,$cfgBytes) + if((Hash $configFile) -cne $cfgSha){throw 'R170_TEST_CONFIG_RESTORE_FAILED'} + } + if((Hash $backupFile) -cne $bakSha){ + [IO.File]::WriteAllBytes($backupFile,$bakBytes) + if((Hash $backupFile) -cne $bakSha){throw 'R170_TEST_BACKUP_RESTORE_FAILED'} + } + } + if($null -eq $sample){throw 'R170_NO_HOSTED_CPU_SAMPLE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R170_LEFT_TEST_PROCESS_RUNNING' + } + $sample['DisposableOnlyForcedStop']=$forcedStop + $sample['ConfigRestoredToOriginalSHA']=((Hash $configFile) -ceq $cfgSha) + $sample['BackupRestoredToOriginalSHA']=((Hash $backupFile) -ceq $bakSha) + return $sample +} +try{ + & gh release download v1.7.5 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.5.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R273_OFFICIAL172_DOWNLOAD_FAILED'} + $priorSetup=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' + if((Hash $priorSetup) -cne '8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC'){throw 'R273_PREVIOUS_SETUP_NOT_IMMUTABLE_OFFICIAL'} + $phase='CREATE_GENUINE_V172_PREDECESSOR' + if($HostLabel -ceq 'B'){ + & gh release download v1.7.4 --repo $repo --pattern TaskbarMonitorEnhanced_Setup_1.7.4.exe --dir $out + if($LASTEXITCODE -ne 0){throw 'R273_OFFICIAL171_DOWNLOAD_FAILED'} + $older=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.4.exe' + if((Hash $older) -cne 'C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1'){throw 'R273_PREVIOUS_V171_SETUP_CHANGED'} + if((Run-Exact $older '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R273_GENUINE_V171_INSTALL_FAILED'} + if((Run-Exact $priorSetup '/update /quiet') -ne 0){throw 'R273_GENUINE_V171_TO_V173_FAILED'} + $chain=@('1.7.4','1.7.5') + }else{ + if((Run-Exact $priorSetup '/install /quiet /desktop=0 /startup=0') -ne 0){throw 'R273_GENUINE_V173_INSTALL_FAILED'} + $chain=@('1.7.5') + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne '25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6' -or (Get-ItemProperty $reg).DisplayVersion -cne '1.7.5'){throw 'R273_OFFICIAL_V173_INSTALLED_IDENTITY_NOT_PROVEN'} + # Neither fresh official v1.7.5 nor the v1.7.4→v1.7.5 upgrade includes + # a redundant root C# source; original R254 defect was already fixed. + $extra=Join-Path $app 'TaskbarMonitorEnhanced.cs' + $source=Join-Path $app 'Source/TaskbarMonitorEnhanced.cs' + if((Test-Path $extra) -or (Hash $source) -ceq 'MISSING'){ + throw 'R273_PUBLIC_V174_SOURCE_LAYOUT_NOT_CANONICAL' + } + $config=Join-Path $app 'config.json';$backup=Join-Path $app 'config.json.bak';$state=Join-Path $app 'install_state.json' + $fixture='{"Theme":"Retro Terminal","Opacity":0.00,"GlassMode":true,"OpacityWhenGlassOff":0.10,"ConfigSchemaVersion":8,"StartWithWindows":false,"UpdateIntervalMs":1000,"ShowSparklines":true,"AutoCheckUpdates":false,"R273OwnerSentinel":"preserve user literal 1.7.5"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"independent":"prior backup must not be overwritten"}',[Text.UTF8Encoding]::new($false)) + $userFile=Join-Path $app 'R273_USER_OWNED.txt';[IO.File]::WriteAllText($userFile,'User data, not installer-managed.',[Text.UTF8Encoding]::new($false)) + $sourceText=[IO.File]::ReadAllText($state) + $expectedState=$sourceText.Replace('"Version": "1.7.5"','"Version": "1.7.6"').Replace('"PublicVersion": "1.7.5"','"PublicVersion": "1.7.6"') + if($expectedState -ceq $sourceText){throw 'R273_ACTUAL_STATE_FORMAT_NOT_RECOGNIZED'} + foreach($path in @($config,$backup,$userFile,(Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'))){if((Hash $path) -ceq 'MISSING'){throw ('R273_PROTECTED_INPUT_MISSING '+$path)};$beforeProtected[$path]=Hash $path} + $startupBefore=StartupValue + $phase='SAME_HOST_171_CPU_BASELINE' + $oldCpuR170=Measure-InstalledCpuR170 '1.7.5' '25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6' + if($null -eq $oldCpuR170){throw 'R170_MISSING_OLD_BASELINE'} + $phase='REJECT_EDITED_MANAGED_SOURCE' + $managed=Join-Path $app 'Source\TaskbarGlass.cs';$original=[IO.File]::ReadAllBytes($managed) + $changed=[byte[]]($original+[Text.Encoding]::UTF8.GetBytes("`n// deliberate owner edit: updater must refuse`n")) + [IO.File]::WriteAllBytes($managed,$changed);$editedHash=Hash $managed + $snap=Snapshot + $code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R273_EDITED_SOURCE_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'EDITED_SOURCE_REFUSAL' + if(Test-Path (Join-Path $app '.tbme-upgrade')){throw 'R273_REFUSAL_LEFT_STAGE'} + if((Hash $managed) -cne $editedHash){throw 'R273_CONCURRENT_EDIT_CANNOT_RESTORE_FIXTURE'} + [IO.File]::WriteAllBytes($managed,$original);$negative++ + $phase='REJECT_UNKNOWN_INSTALLED_VERSION' + [IO.File]::WriteAllText($state,$sourceText.Replace('1.7.5','9.9.9'),[Text.UTF8Encoding]::new($false)) + $snap=Snapshot;$code=Run-Exact $setup '/update /quiet' + if($code -eq 0){throw 'R273_UNKNOWN_VERSION_ACCEPTED'} + Equal-Snapshot $snap (Snapshot) 'UNKNOWN_VERSION_REFUSAL' + [IO.File]::WriteAllText($state,$sourceText,[Text.UTF8Encoding]::new($false));$negative++ + $phase='REAL_ROLLBACK_ENGINE_AFTER_SIMULATED_PARTIAL_COMMIT' + # Exercise production journal and rollback code, without adding failure hooks to shipped binaries. + # This is explicit simulated interruption, not a physical outage or real process-kill witness. + $a=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup));$t=$a.GetType('SetupProgram',$true);$f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@('1.7.5','OFFICIAL_STABLE_V175')) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($sourceText)) + # Reflection host is PowerShell, so explicitly substitute EXACT verified Setup bytes for @SELF. + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($setup) + $beforeRollback=Snapshot + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$sourceText,'1.7.5','OFFICIAL_STABLE_V175')) + $stage=Join-Path $app '.tbme-upgrade' + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $stage 'new') $rel;$live=Join-Path $app $rel + [R156FileBridge]::ReplaceWithoutBackup($staged,$live) + } + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256){throw 'R273_PARTIAL_COMMIT_NOT_REAL'} + [void]$t.GetMethod('RecoverUpgradeStageIfAny',$f).Invoke($null,[object[]]@()) + if(Test-Path $stage){throw 'R273_ROLLBACK_STAGE_NOT_CLEARED'} + Equal-Snapshot $beforeRollback (Snapshot) 'REAL_ROLLBACK_RESTORATION';$rollback=$true + $phase='REAL_CLI_171_TO_172' + $code=Run-Exact $setup '/update /quiet' + if($code -ne 0){throw ('R273_REAL_UPGRADE_FAILED EXIT='+$code)} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $manifest.MainSHA256 -or (Hash (Join-Path $app 'Uninstall.exe')) -cne $manifest.SetupSHA256){throw 'R273_FINAL_BINARY_HASH_MISMATCH'} + if((Get-ItemProperty $reg).DisplayVersion -cne '1.7.6' -or [IO.File]::ReadAllText($state) -cne $expectedState){throw 'R273_STATE_OR_REGISTRY_NOT_EXACT_174'} + foreach($path in $beforeProtected.Keys){if((Hash $path) -cne $beforeProtected[$path]){throw ('R273_USER_DATA_CHANGED '+$path)}} + if((StartupValue) -cne $startupBefore){throw 'R273_STARTUP_PREFERENCE_CHANGED'} + if(Test-Path $stage){throw 'R273_STAGE_LEFT_AFTER_SUCCESS'} + if(!(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.6.md')) -or (Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.5.md'))){throw 'R273_RELEASE_NOTES_NOT_MIGRATED'} + $registrations=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($registrations.Count -ne 1 -or (Get-ItemProperty $reg).InstallLocation -cne $app){throw 'R273_DUPLICATE_OR_WRONG_ROOT'} + $preserved=$true;$chain+=@('1.7.6') + $phase='SAME_HOST_172_CPU_COMPARATOR' + $newCpuR170=Measure-InstalledCpuR170 '1.7.6' $manifest.MainSHA256 + if($null -eq $newCpuR170 -or $oldCpuR170.Host -cne $newCpuR170.Host){throw 'R170_SAME_HOST_CPU_NOT_VERIFIED'} + $phase='IDEMPOTENCE_AND_DOWNGRADE_REFUSAL' + $after=Snapshot + if((Run-Exact $setup '/update /quiet') -ne 0){throw 'R273_REPEATED_IDENTICAL_UPGRADE_FAILED'} + Equal-Snapshot $after (Snapshot) 'IDENTICAL_REINSTALL';$idempotent=$true + if((Run-Exact $priorSetup '/update /quiet') -eq 0){throw 'R273_OLD_INSTALLER_DOWNGRADE_ACCEPTED'} + Equal-Snapshot $after (Snapshot) 'DOWNGRADE_REFUSAL';$downgrade=$true + $status='PASS_GENUINE_175_TO_176_SINGLE_ROOT_PRESERVATION_ROLLBACK_IDEMPOTENCE' +}catch{$errorText=$_.Exception.ToString()} +finally{ + $r=[ordered]@{Revision='R273';Status=$status;Phase=$phase;Failure=$errorText;Host=$HostLabel;Commit=$env:GITHUB_SHA;SourceChain=$chain;MainSHA256=$manifest.MainSHA256;SetupSHA256=$manifest.SetupSHA256;RejectedUnsafeUpdates=$negative;RealProductionRollbackAfterSimulatedPartialCommit=$rollback;PhysicalPowerCutClaimed=$false;RealProcessKillClaimed=$false;ProtectedUserFilesPreserved=$preserved;IdenticalSetupIdempotent=$idempotent;DowngradeRefused=$downgrade;PersonalComputerTouched=$false;ProtectedSHA256=$beforeProtected;R170SameHostedWindowsOldCpu=$oldCpuR170;R170SameHostedWindowsNewCpu=$newCpuR170;R170SameHostCpuMeasured=($null -ne $oldCpuR170 -and $null -ne $newCpuR170);R170PersonalCpuAcceptance=$false} + [IO.File]::WriteAllText((Join-Path $out 'R273_UPGRADE.json'),($r|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ($r|ConvertTo-Json -Depth 8 -Compress) +} +if($status -notlike 'PASS_*'){throw ('R273_UPGRADE_ACCEPTANCE_FAILED '+$phase+' '+$errorText)} diff --git a/build/Test-UpgradeContract-R273.ps1 b/build/Test-UpgradeContract-R273.ps1 new file mode 100644 index 00000000..a7ee29ec --- /dev/null +++ b/build/Test-UpgradeContract-R273.ps1 @@ -0,0 +1,90 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$SetupPath,[Parameter(Mandatory=$true)][string]$OutDir) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -eq 'Core'){throw 'R273_FRAMEWORK_REFLECTION_REQUIRED'} +if(Test-Path $OutDir){throw 'R273_CONTRACT_OUTPUT_EXISTS'} +$full=[IO.Path]::GetFullPath($SetupPath) +if([IO.Path]::GetFileName($full) -cne 'TaskbarMonitorEnhanced_Setup_1.7.6.exe'){throw 'R273_WRONG_SETUP_NAME'} +$a=[Reflection.Assembly]::LoadFile($full) +$t=$a.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,Public,NonPublic' +$newState=$t.GetMethod('NewInstallState',$f) +$legacy=$t.GetMethod('LegacyExpected',$f) +$optional=$t.GetMethod('IsOptionalStableLegacyRoot',$f) +# Regression for the R274 real two-VM failure: the pre-mutation 1.7.5 +# allowlist is not sufficient unless COMMITTING rollback also accepts 1.7.5. +if($null -eq $t.GetMethod('RestoreInterruptedUpgrade',$f)){throw 'R274_ROLLBACK_METHOD_ABSENT'} +$setupSource=Get-Content (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs') -Raw +$restoreBlock=[regex]::Match($setupSource,'(?s)static void RestoreInterruptedUpgrade\(List rows\)(.*?)// This works after') +if(!$restoreBlock.Success -or $restoreBlock.Groups[1].Value -notmatch 'priorVersion!=InstalledV175'){ + throw 'R274_COMMITTING_ROLLBACK_REJECTS_REAL_OFFICIAL_175' +} +if($t.GetField('Version',$f).GetRawConstantValue() -cne '1.7.6'){throw 'R273_COMPILED_VERSION_NOT_172'} +$cases=0;$negative=0 +foreach($v in @('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3','1.7.4','1.7.5')){ + $before='{ "ProductIdentity":"LOCKED", "Version":"'+$v+'", "PublicVersion":"'+$v+'", "Unrelated":"preserve literal 1.7.1", "Custom":{"x":17}, "ProtocolVersion":"1.1.2+r21" }' + $want=$before.Replace(('"Version":"'+$v+'"'),'"Version":"1.7.6"').Replace(('"PublicVersion":"'+$v+'"'),'"PublicVersion":"1.7.6"') + $got=[string]$newState.Invoke($null,[object[]]@($before)) + if($got -cne $want){throw ('R273_STATE_NOT_EXACTLY_TWO_VERSION_FIELDS '+$v)} + $cases++ +} +$bad=@( + '{"Version":"1.7.1","PublicVersion":"1.7.0"}', + '{"Version":"1.7.1","Version":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"1.7.1","PublicVersion":"1.7.1","PublicVersion":"1.7.1"}', + '{"Version":"0.9.0","PublicVersion":"0.9.0"}', + '{"Version":"1.7.7","PublicVersion":"1.7.7"}', + '{"Version":"1.7.1"}', + '{"PublicVersion":"1.7.1"}' +) +foreach($s in $bad){ + $refused=$false + try{[void]$newState.Invoke($null,[object[]]@($s))}catch{$refused=$true} + if(!$refused){throw ('R273_INVALID_STATE_ACCEPTED '+$s)};$negative++ +} +$inventory=Get-Content (Join-Path $PSScriptRoot 'official-v174-managed.json') -Raw|ConvertFrom-Json +$dict=$legacy.Invoke($null,[object[]]@('1.7.4','OFFICIAL_STABLE_V174')) +if($dict.Count -ne 22 -or $inventory.ManagedCount -ne 22){throw 'R273_OFFICIAL_174_EXACT_22_MANAGED_COUNT'} +foreach($row in $inventory.Rows){if(!$dict.ContainsKey($row.Path) -or $dict[$row.Path] -cne $row.SHA256){throw ('R273_OFFICIAL_V174_COMPILED_MANAGED_SHA_DRIFT '+$row.Path)};$cases++} +$official175=Get-Content (Join-Path $PSScriptRoot 'official-v175-managed.json') -Raw|ConvertFrom-Json +$dict175=$legacy.Invoke($null,[object[]]@('1.7.5','OFFICIAL_STABLE_V175')) +if($dict175.Count -ne 22 -or $official175.ManagedCount -ne 22){throw 'R273_OFFICIAL_175_22_MANAGED_COUNT'} +foreach($row in $official175.Rows){ + if(!$dict175.ContainsKey($row.Path) -or $dict175[$row.Path] -cne $row.SHA256){throw ('R273_OFFICIAL175_COMPILED_SHA_DRIFT '+$row.Path)} + $cases++ +} +if([string]$official175.PublishedMainSHA256 -cne '25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6' -or + [string]$official175.PublishedSetupSHA256 -cne '8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC'){throw 'R273_OFFICIAL_175_PUBLIC_MAIN_SETUP_IDENTITY'} +# Retain exact previously accepted immutable v1.7.3 path as a separate ancestor. +$oldInventory=Get-Content (Join-Path $PSScriptRoot 'official-v173-managed.json') -Raw|ConvertFrom-Json +$oldDictionary=$legacy.Invoke($null,[object[]]@('1.7.3','OFFICIAL_STABLE_V173')) +if($oldInventory.ManagedCount -ne 23 -or $oldDictionary.Count -ne 23){throw 'R273_HISTORICAL_V173_23_ROWS_DRIFT'} +foreach($row in $oldInventory.Rows){if(!$oldDictionary.ContainsKey($row.Path) -or $oldDictionary[$row.Path] -cne $row.SHA256){throw ('R273_V173_HISTORIC_DRIFT '+$row.Path)};$cases++} +foreach($pair in @(@('1.7.1','OFFICIAL_STABLE_V170'),@('1.7.1','PREVIEW1'),@('1.7.6','OFFICIAL_STABLE_V171'),@('1.7.1','UNKNOWN'))){ + $refused=$false;try{[void]$legacy.Invoke($null,[object[]]@($pair[0],$pair[1]))}catch{$refused=$true} + if(!$refused){throw 'R273_WRONG_ANCESTOR_CHANNEL_ACCEPTED'};$negative++ +} +foreach($channel in @('OFFICIAL_STABLE_V170','OFFICIAL_STABLE_V171','OFFICIAL_STABLE_V172','OFFICIAL_STABLE_V173')){ + if(!$optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs',$channel))){throw 'R273_INHERITED_ROOT_DUPLICATE_REQUIRED'};$cases++ + foreach($path in @('Source/TaskbarMonitorEnhanced.cs','TaskbarMonitorEnhanced.exe','config.json')){ + if($optional.Invoke($null,[object[]]@($path,$channel))){throw ('R273_CRITICAL_SOURCE_MADE_OPTIONAL '+$path)};$negative++ + } +} +if($optional.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.cs','OFFICIAL_STABLE_V174'))){ + throw 'R273_OFFICIAL_V174_MUST_NOT_HAVE_OPTIONAL_ROOT_SOURCE' +} +$cases++ +$targets=$t.GetField('UpgradeTargets',$f).GetValue($null) +$removed=0;$names=@();$if=[Reflection.BindingFlags]'Instance,NonPublic,Public' +foreach($target in $targets){ + $path=[string]$target.GetType().GetField('Rel',$if).GetValue($target) + $resource=$target.GetType().GetField('ResourceName',$if).GetValue($target) + if($names -ccontains $path -or $path -match '^(config\.json|SensorBackend/|Backups/|Data/)'){throw ('R273_UPGRADE_TOUCHES_PROTECTED_OR_DUPLICATE '+$path)} + if($null -eq $resource){$removed++};$names+=@($path);$cases++ +} +if($removed -ne 8 -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.6.md' -or $names -cnotcontains 'Docs/RELEASE_NOTES_v1.7.1.md'){throw 'R273_VERSIONED_NOTES_TRANSACTION_MAP'} +New-Item -ItemType Directory -Path $OutDir|Out-Null +$r=[ordered]@{Revision='R273';Status='PASS_COMPILED_175_UPGRADE_CONTRACT';SetupSHA256=(Get-FileHash $full -Algorithm SHA256).Hash;PositiveCases=$cases;RejectedCases=$negative;Official173ManagedFiles=23;Official174ManagedFiles=22;Official175ManagedFiles=22;PreviousVersions=@('1.6.0','1.7.0','1.7.1','1.7.2','1.7.3','1.7.4','1.7.5');CurrentVersion='1.7.6';NoAppEntryPointInvoked=$true;NoPersonalFilesOrRegistryMutated=$true;ActualInstallAcceptance='SEPARATE_HOSTED_VM_GATE'} +[IO.File]::WriteAllText((Join-Path $OutDir 'R273_CONTRACT.json'),($r|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +Write-Output ($r|ConvertTo-Json -Depth 6 -Compress) diff --git a/build/Verify-Embedded-Main.ps1 b/build/Verify-Embedded-Main.ps1 index 8301ae1b..96210126 100644 --- a/build/Verify-Embedded-Main.ps1 +++ b/build/Verify-Embedded-Main.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference='Stop' if($ExpectedMainSHA256 -notmatch '^[0-9a-fA-F]{64}$'){throw 'R80 EXPECTED MAIN SHA NOT 64 HEX'} $path=[IO.Path]::GetFullPath($SetupExe) if(!(Test-Path -LiteralPath $path -PathType Leaf) -or - ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe'))){ + ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe'))){ throw 'R80 SETUP EXE MISSING OR WRONG FILE NAME' } $setupHash=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash diff --git a/build/Verify-Release-Metadata-R273.ps1 b/build/Verify-Release-Metadata-R273.ps1 new file mode 100644 index 00000000..b1c2961e --- /dev/null +++ b/build/Verify-Release-Metadata-R273.ps1 @@ -0,0 +1,105 @@ +# R273 independent, read-only authority check for the versioned 1.7.6 +# release Build Manifest -> deterministic binary files -> SBOM 2.3. +[CmdletBinding()] +param( + [string]$Root=(Join-Path $PSScriptRoot '..'), + [switch]$SelfTest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($Root) +$manifestPath=Join-Path $root 'artifacts\BUILD_MANIFEST.json' +$sbomPath=Join-Path $root 'artifacts\SBOM.spdx.json' +$setupVerifyPath=Join-Path $root 'build\_out\setup_verify.json' +$source=(Join-Path $root 'src\TaskbarMonitorEnhanced.cs') +foreach($f in @($manifestPath,$sbomPath,$setupVerifyPath,$source)){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){throw ('R273_REQUIRED_EVIDENCE_MISSING '+$f)} +} +$m=Get-Content $manifestPath -Raw|ConvertFrom-Json +$b=Get-Content $sbomPath -Raw|ConvertFrom-Json +$v=Get-Content $setupVerifyPath -Raw|ConvertFrom-Json +$commit=(& git -C $root rev-parse HEAD).Trim() +if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R273_CURRENT_HEAD_NOT_EXACT'} +function Issues($manifest,$sbom,$setup,$sha){ + $fail=[Collections.Generic.List[string]]::new() + if($manifest.Version -cne '1.7.6' -or $manifest.Build -cne 'V1_7_6_R273_GLASS_THERMAL_ALERT'){ + $fail.Add('WRONG_PUBLIC_BUILD_VERSION') + } + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.6' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.6/'+$sha)){ + $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') + } + if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or + $sbom.packages[0].versionInfo -cne '1.7.6'){ + $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') + } + $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', + 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', + 'TaskbarMonitorEnhanced_Setup_1.7.6.exe') + $found=@($manifest.Outputs|ForEach-Object {$_.Name}) + if(@($manifest.Outputs).Count -ne 5 -or + @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ + $fail.Add('MANIFEST_EXACT_FIVE_OUTPUTS_MISMATCH') + }else{ + foreach($row in $manifest.Outputs){ + $name=[string]$row.Name + $rel=switch($name){ + 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} + 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} + default {'build\_out\App\'+$name} + } + $path=Join-Path $root $rel + if(!(Test-Path $path -PathType Leaf) -or + (Get-Item $path).Length -ne [int64]$row.Bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash -cne [string]$row.SHA256){ + $fail.Add('ACTUAL_OUTPUT_SHA_OR_SIZE_MISMATCH_'+$name) + } + $frow=@($sbom.files|Where-Object fileName -ceq $name) + if($frow.Count -ne 1 -or @($frow[0].checksums).Count -ne 1 -or + $frow[0].checksums[0].algorithm -cne 'SHA256' -or + $frow[0].checksums[0].checksumValue -cne ([string]$row.SHA256).ToLowerInvariant()){ + $fail.Add('SBOM_OUTPUT_SHA_MISMATCH_'+$name) + } + } + } + if($setup.Status -cne 'PASS' -or [int]$setup.Resources -ne 25 -or + $setup.PawnIOBundled -ne $false){ + $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') + } + $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe') + if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.6')){ + $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') + } + return @($fail.ToArray()) +} +$errors=@(Issues $m $b $v $commit) +if($errors.Count){throw ('R273_RELEASE_METADATA_FAIL_CLOSED '+($errors -join ';'))} +if($SelfTest){ + $mutants=@( + @{Type='Manifest';Field='Version';Bad='1.7.0'}, + @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, + @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.6/'+('0'*40))}, + @{Type='Setup';Field='PawnIOBundled';Bad=$true} + ) + foreach($mutation in $mutants){ + $am=$m|ConvertTo-Json -Depth 14|ConvertFrom-Json + $ab=$b|ConvertTo-Json -Depth 14|ConvertFrom-Json + $av=$v|ConvertTo-Json -Depth 14|ConvertFrom-Json + switch($mutation.Type){ + 'Manifest' {$am.($mutation.Field)=$mutation.Bad} + 'Sbom' {$ab.($mutation.Field)=$mutation.Bad} + 'Setup' {$av.($mutation.Field)=$mutation.Bad} + } + if(@(Issues $am $ab $av $commit).Count -eq 0){ + throw ('R273_HOSTILE_RELEASE_METADATA_ACCEPTED '+$mutation.Type+' '+$mutation.Field) + } + } + Write-Output 'R273_FIVE_HOSTILE_RELEASE_METADATA_MUTANTS_REJECTED' +} +Write-Output ('R273_MANIFEST_SBOM_BINARY_VERSION=PASS VERSION='+$m.Version+ + ' OUTPUTS='+@($m.Outputs).Count+' SHA256='+(Get-FileHash $manifestPath -Algorithm SHA256).Hash) +exit 0 diff --git a/build/official-v175-managed.json b/build/official-v175-managed.json new file mode 100644 index 00000000..57070a8e --- /dev/null +++ b/build/official-v175-managed.json @@ -0,0 +1,121 @@ +{ + "Schema": 1, + "Status": "PINNED_OFFICIAL_V175_OWNER_INSTALLED_MATCH_PUBLIC_SETUP_MAIN", + "Version": "1.7.5", + "SourceCommit": "9284b124168f3aa2c90f8637eeba6a20f2d0e1af", + "PublishedSetupSHA256": "8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", + "PublishedMainSHA256": "25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6", + "ManagedCount": 22, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6", + "Bytes": 628224 + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Bytes": 456 + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Bytes": 22172 + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "53A37483D56C20A28FB4FA4D8DC207F3B3E06E081F32B7506C8438E321EC0C9E", + "Bytes": 596040 + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Bytes": 15378 + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Bytes": 26913 + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Bytes": 101274 + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Bytes": 22796 + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Bytes": 55753 + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "C9872DAA819E9CA7453C158F5A4CB4CAB16FB1078606EE0BAF568555DBE68E57", + "Bytes": 21949 + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Bytes": 28046 + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Bytes": 51057 + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "66626A8A762ED34EDC91C7C3D03AD9889CAA5A8FEB64E973EF0EF0E08CDC8AF8", + "Bytes": 95554 + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Bytes": 35149 + }, + { + "Path": "Docs/README.md", + "SHA256": "74955DE4D73DE9D7AD1C394187E6ACFC95013A92799D8BA9BB7DBDC5F4E7D701", + "Bytes": 496 + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Bytes": 803 + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Bytes": 1174 + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Bytes": 674 + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Bytes": 2224 + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.5.md", + "SHA256": "AD3642ABD89BA1C31B2EEA83847FC3ED8861AEBFF9E664B5169A287601AAA16A", + "Bytes": 1811 + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Bytes": 811 + }, + { + "Path": "Uninstall.exe", + "SHA256": "8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", + "Bytes": 8524800 + } + ] +} \ No newline at end of file diff --git a/docs/releases/v1.7.6/INSTALLER_README.txt b/docs/releases/v1.7.6/INSTALLER_README.txt new file mode 100644 index 00000000..e6137f18 --- /dev/null +++ b/docs/releases/v1.7.6/INSTALLER_README.txt @@ -0,0 +1,5 @@ +Taskbar Monitor Enhanced 1.7.6 +Single per-user installer, exact SHA-checked official 1.7.5 in-place update with rollback and no user data erasure. +Glass mode keeps hot CPU/GPU/disk warnings RED while the taskbar background stays fully transparent. +Windows temperature notification balloons are opt-in under Settings > Alerts. +Cross-user sensor guard preserved; no machinewide single-install guarantee. Intentionally unsigned. Do not bypass Defender. diff --git a/docs/releases/v1.7.6/RELEASE_NOTES.md b/docs/releases/v1.7.6/RELEASE_NOTES.md new file mode 100644 index 00000000..f255b761 --- /dev/null +++ b/docs/releases/v1.7.6/RELEASE_NOTES.md @@ -0,0 +1,19 @@ +# Taskbar Monitor Enhanced v1.7.6 — glass thermal warning hotfix + +## Bugfix +- On user-enabled fully transparent Glass mode, retain the red thermal warning text, chart and card border when a valid CPU, GPU or disk temperature meets the configured threshold. +- Normal metric glyphs remain theme-adaptive and visible, while background alpha stays 0%. No new driver or sensor dependency. + +## Notification behavior +- Thermal highlight and Windows notification balloons are separate features. In Settings > Alerts, enable **Show Windows notification when a threshold is crossed** for notifications; previously disabled preferences are preserved. +- Existing default alert thresholds CPU/GPU 85°C, disk 60°C, hysteresis and cooldown remain unchanged. +- No audible OS notification is promised when Windows Do Not Disturb or notification policies mute alerts. + +## In-place update +- Same canonical per-user installation root and existing SHA-pinned previous releases supported; v1.7.5 added as an exact 22-file + signed-off Setup SHA ancestor. +- Keep config, config.bak, history, user assets and HKCU autostart. Journal and recover interrupted upgrades; no forced process termination, no duplicate root. +- v1.7.5's cross-user privileged task safety guard retained. No claim of machinewide singleton or physical AC outage recovery. +- Intentionally unsigned under established waiver. Defender WinRing0 Issue #6 remains open and unrelated to this visual bugfix. + +## Verification +- Exact source build and 49 × 2 pixel warning proof plus hosted Windows full CI; real v1.7.5 -> v1.7.6 install/rollback on two disposable Windows required before publication and owner's update. diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index a65b74ef..d88be781 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -20,14 +20,14 @@ [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.5.0")] -[assembly: AssemblyFileVersion("1.7.5.0")] -[assembly: AssemblyInformationalVersion("1.7.5+r265")] +[assembly: AssemblyVersion("1.7.6.0")] +[assembly: AssemblyFileVersion("1.7.6.0")] +[assembly: AssemblyInformationalVersion("1.7.6+r273")] internal static partial class SetupProgram { const string Product="Taskbar Monitor Enhanced"; - const string Version="1.7.5"; + const string Version="1.7.6"; const string SensorProtocolVersion="1.1.2+r21"; const string SensorSupervisorVersion="1.3.0+r33"; const string Publisher="Dr. Ali-Akbar Emadeddin"; @@ -58,7 +58,7 @@ internal static partial class SetupProgram "Payload.COPYRIGHT_AND_ATTRIBUTION.md", "Payload.AI_ASSISTED_DEVELOPMENT.md", "Payload.THIRD_PARTY_NOTICES.md", - "Payload.RELEASE_NOTES_v1.7.5.md", + "Payload.RELEASE_NOTES_v1.7.6.md", "Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md", "Payload.TaskbarMonitorEnhanced_Setup.cs" }; @@ -228,8 +228,8 @@ static void WriteInstallState(SensorOutcome sensorOutcome) string sensorMode=sensorOutcome==null?"":(sensorOutcome.LayerMode??""); string json="{\r\n"+ " \"App\": \"Taskbar Monitor Enhanced\",\r\n"+ - " \"Version\": \"1.7.5\",\r\n"+ - " \"PublicVersion\": \"1.7.5\",\r\n"+ + " \"Version\": \"1.7.6\",\r\n"+ + " \"PublicVersion\": \"1.7.6\",\r\n"+ " \"InternalRuntimeBaseline\": \"V1_1_2_R21_PRODUCTION_HARDENING\",\r\n"+ " \"SensorSupervisor\": \"V1_3_0_R33_STORAGE_COMPLETION_HARDENING\",\r\n"+ " \"SensorLayerStatus\": \""+sensorStatus.Replace("\\","\\\\").Replace("\"","\\\"")+"\",\r\n"+ @@ -445,7 +445,7 @@ static bool IsCurrentProcessElevated() static SensorOutcome Install(bool desktop,bool startup) { if(!Environment.Is64BitOperatingSystem) - throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.5 requires 64-bit Windows."); + throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.6 requires 64-bit Windows."); // R114: prevent fresh-install code from ever overwriting an installed // product or user-owned files. Existing roots must use /update. @@ -475,7 +475,7 @@ static SensorOutcome Install(bool desktop,bool startup) string[] docs=new string[]{ "LICENSE","README.md","AUTHORS.md","COPYRIGHT_AND_ATTRIBUTION.md", "AI_ASSISTED_DEVELOPMENT.md","THIRD_PARTY_NOTICES.md", - "RELEASE_NOTES_v1.7.5.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" + "RELEASE_NOTES_v1.7.6.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" }; foreach(string doc in docs) Extract("Payload."+doc,Path.Combine(AppRoot,"Docs",doc)); @@ -619,7 +619,7 @@ static int Verify(string path) } if(!String.IsNullOrEmpty(path)){ string json="{\"Status\":\"PASS\",\"Resources\":"+(RequiredResources.Length+(pawnBundled?1:0))+ - ",\"Version\":\"1.7.5\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ + ",\"Version\":\"1.7.6\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ ",\"SensorArchitecture\":\"R21_PROCESS_ISOLATED\",\"SensorUpgradePolicy\":\"EXACT_R33_SUPERVISOR_R21_PROTOCOL\""+ ",\"PawnIOBundled\":"+(pawnBundled?"true":"false")+",\"EmbeddedMainSHA256\":\""+embeddedMainSHA256+"\",\"EmbeddedMainBytes\":"+embeddedMainBytes+"}"; File.WriteAllText(path,json,Encoding.UTF8); @@ -658,7 +658,7 @@ public SetupForm() Icon=Icon.ExtractAssociatedIcon(Application.ExecutablePath); Label title=new Label(); - title.Text=Product+" 1.7.5"; + title.Text=Product+" 1.7.6"; title.Font=new Font(Font.FontFamily,18,FontStyle.Bold); title.Left=28;title.Top=22;title.AutoSize=true;Controls.Add(title); @@ -693,12 +693,12 @@ public SetupForm() progress.Visible=false; status.Text="Installation completed."; if(outcome.IsHealthy){ - MessageBox.Show(Product+" 1.7.5 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", + MessageBox.Show(Product+" 1.7.6 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", "Setup complete",MessageBoxButtons.OK,MessageBoxIcon.Information); }else{ string extra=outcome.RebootRequired ? "\r\n\r\nRestart Windows, then use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors if needed." : "\r\n\r\nThe application is installed and usable. The sensor supervisor continues in the background. If protected CPU/GPU/storage telemetry is still unavailable after a short wait or restart, use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors."; - MessageBox.Show(Product+" 1.7.5 was installed successfully.\r\n\r\n"+outcome.Message+extra, + MessageBox.Show(Product+" 1.7.6 was installed successfully.\r\n\r\n"+outcome.Message+extra, "Setup complete - sensor warning",MessageBoxButtons.OK,MessageBoxIcon.Warning); } Close(); @@ -835,6 +835,9 @@ internal static partial class SetupProgram const string InstalledV172="1.7.2"; const string InstalledV173="1.7.3"; const string InstalledV174="1.7.4"; + const string InstalledV175="1.7.5"; + const string OfficialV175SetupSHA="8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC"; + const string OfficialV175MainSHA="25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6"; const string OfficialV174SetupSHA="C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1"; const string OfficialV174MainSHA="279DF6276973D45A1F979792718E7BA1A9DB31A44C3C34E7335C7BE1503A3E3C"; const string OfficialV173SetupSHA="9EB5C54B4AE4C36F4035F115AC87403D9238AC66AA9AFE58DC5B211311D9774B"; @@ -1043,6 +1046,32 @@ internal static partial class SetupProgram "Uninstall.exe|C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1", }; + // R273: immutable public v1.7.5, 22 exact managed files, not user config. + static readonly string[] OfficialStableV175Rows=new string[] { + "TaskbarMonitorEnhanced.exe|25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|53A37483D56C20A28FB4FA4D8DC207F3B3E06E081F32B7506C8438E321EC0C9E", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|26B3E98C4E30361037EA35AE6326DE45C8087D8D562B7A7232D8FCF2CA589AAF", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|C9872DAA819E9CA7453C158F5A4CB4CAB16FB1078606EE0BAF568555DBE68E57", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|66626A8A762ED34EDC91C7C3D03AD9889CAA5A8FEB64E973EF0EF0E08CDC8AF8", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|74955DE4D73DE9D7AD1C394187E6ACFC95013A92799D8BA9BB7DBDC5F4E7D701", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.5.md|AD3642ABD89BA1C31B2EEA83847FC3ED8861AEBFF9E664B5169A287601AAA16A", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", + }; + sealed class UpgradeTarget { internal readonly string Rel; @@ -1070,7 +1099,7 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/COPYRIGHT_AND_ATTRIBUTION.md","Payload.COPYRIGHT_AND_ATTRIBUTION.md"), new UpgradeTarget("Docs/AI_ASSISTED_DEVELOPMENT.md","Payload.AI_ASSISTED_DEVELOPMENT.md"), new UpgradeTarget("Docs/THIRD_PARTY_NOTICES.md","Payload.THIRD_PARTY_NOTICES.md"), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.5.md","Payload.RELEASE_NOTES_v1.7.5.md"), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.6.md","Payload.RELEASE_NOTES_v1.7.6.md"), new UpgradeTarget("Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md","Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md"), new UpgradeTarget("Uninstall.exe","@SELF"), new UpgradeTarget("install_state.json","@INSTALLSTATE"), @@ -1080,7 +1109,8 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.1.md",null), new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md",null), new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md",null), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.4.md",null) // previous version, guarded byte-for-byte + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.4.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.5.md",null) // previous version, guarded byte-for-byte }; static string UpgradeHash(string path) @@ -1110,6 +1140,8 @@ static Dictionary LegacyExpected(string version,string channel) { rows=OfficialStableV172Rows; expectedSetup=OfficialV172SetupSHA; count=23; } else if(version==InstalledV173 && channel=="OFFICIAL_STABLE_V173") { rows=OfficialStableV173Rows; expectedSetup=OfficialV173SetupSHA; count=23; } + else if(version==InstalledV175 && channel=="OFFICIAL_STABLE_V175") + { rows=OfficialStableV175Rows; expectedSetup=OfficialV175SetupSHA; count=22; } else if(version==InstalledV174 && channel=="OFFICIAL_STABLE_V174") { rows=OfficialStableV174Rows; expectedSetup=OfficialV174SetupSHA; count=22; } else if(version==InstalledV170 && channel=="PREVIEW1") @@ -1212,7 +1244,7 @@ static string NewInstallState(string source) throw new InvalidDataException("Old install state Version/PublicVersion mismatch."); string prior=installed.Groups["v"].Value; if(prior==Version)return source; - if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173 && prior!=InstalledV174) + if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173 && prior!=InstalledV174 && prior!=InstalledV175) throw new InvalidDataException("Prior install state version not a pinned officially allowed ancestor."); string next=ReplaceStateVersion(source,"Version",prior); next=ReplaceStateVersion(next,"PublicVersion",prior); @@ -1238,7 +1270,7 @@ static Dictionary PrepareNewBytes(string previousState) if(bytes.Length<1 || fresh.ContainsKey(t.Rel))throw new InvalidDataException("Duplicate/empty payload "+t.Rel); fresh.Add(t.Rel,bytes); } - if(fresh.Count!=UpgradeTargets.Length-7) + if(fresh.Count!=UpgradeTargets.Length-8) throw new InvalidDataException("Upgrade static target map is invalid."); return fresh; } @@ -1350,7 +1382,7 @@ static void StageNewManaged(Dictionary fresh, Dictionary rows) string priorVersionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); if(!File.Exists(priorVersionPath))throw new IOException("Previous upgrade version missing; retain stage."); string priorVersion=File.ReadAllText(priorVersionPath,Encoding.ASCII); - if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=InstalledV174 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=InstalledV174 && priorVersion!=InstalledV175 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); // This works after a mid-commit process kill: exact old snapshots are persisted // on the same volume, and new-only files are removed only if their SHA is known. for(int i=rows.Count-1;i>=0;i--){ @@ -1490,8 +1522,8 @@ static SensorOutcome UpgradeExisting() CheckSingleCanonicalRoot(); string installed=CurrentInstalledVersion(); string newSha=Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"); - if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=InstalledV174 && installed!=Version) - throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3/v1.7.4 may upgrade in place."); + if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=InstalledV174 && installed!=InstalledV175 && installed!=Version) + throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3/v1.7.4/v1.7.5 may upgrade in place."); string channel="OFFICIAL_V16"; string currentMainSHA=UpgradeHash(AppExe); string installedSetup=UPath("Uninstall.exe"); @@ -1508,7 +1540,8 @@ static SensorOutcome UpgradeExisting() !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.1.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.2.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.3.md")) && - !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.4.md"))) + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.4.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.5.md"))) return ExistingSensorOutcome(); throw new InvalidOperationException("Unknown current-version maintenance revision; no files changed."); } @@ -1547,6 +1580,13 @@ static SensorOutcome UpgradeExisting() channel="OFFICIAL_STABLE_V174"; } + if(installed==InstalledV175) + { + if(currentMainSHA!=OfficialV175MainSHA || oldSetupSHA!=OfficialV175SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.5 Main/Setup identity; NO mutation."); + channel="OFFICIAL_STABLE_V175"; + } + // No force-kill; the owner must close their currently running app normally. EnsureAppNotRunning(); Dictionary official=LegacyExpected(installed,channel); diff --git a/src/TaskbarGlass.cs b/src/TaskbarGlass.cs index b8f847e3..54bf9bb1 100644 --- a/src/TaskbarGlass.cs +++ b/src/TaskbarGlass.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.IO; using System.Web.Script.Serialization; // R41: per-pixel taskbar background translucency with independent, contrast-safe glyphs. @@ -182,30 +182,48 @@ internal static void WritePixels(Bitmap b,byte[] src) finally{b.UnlockBits(d);} } private static byte Byte(double x){return (byte)Math.Max(0,Math.Min(255,(int)Math.Round(x)));} - internal static Bitmap Compose(Bitmap full,Bitmap glyph,Bitmap halo,int opacityPercent,Color taskbar,Color themeBackground,bool glassMode) + // Semantic thermal warning color: preserve an unmistakable red tint even on + // light taskbars, while retaining a contrasting halo around its glyphs. + private static Color ThermalWarningInk(Color taskbarBackground) + { + Color[] candidates={ + Color.FromArgb(255,82,82),Color.FromArgb(255,172,172), + Color.FromArgb(168,0,26),Color.FromArgb(37,0,8)}; + foreach(Color c in candidates) + if(TaskbarGlassColor.Contrast(c,taskbarBackground)>=4.5)return c; + return candidates.OrderByDescending(c=>TaskbarGlassColor.Contrast(c,taskbarBackground)).First(); + } internal static Bitmap Compose(Bitmap full,Bitmap glyph,Bitmap halo,int opacityPercent,Color taskbar,Color themeBackground,bool glassMode,Bitmap thermalWarningMask=null) { if(full==null)throw new ArgumentNullException("full"); if(glyph!=null&&(glyph.Width!=full.Width||glyph.Height!=full.Height))throw new ArgumentException("glyph geometry"); if(halo!=null&&(halo.Width!=full.Width||halo.Height!=full.Height))throw new ArgumentException("halo geometry"); + if(thermalWarningMask!=null&&(thermalWarningMask.Width!=full.Width||thermalWarningMask.Height!=full.Height)) + throw new ArgumentException("thermal warning mask geometry"); int pct=glassMode?0:Math.Max(1,Math.Min(100,opacityPercent)); byte[] basePixels=ReadPixels(full,PixelFormat.Format32bppArgb); byte[] inkPixels=glyph==null?null:ReadPixels(glyph,PixelFormat.Format32bppPArgb); byte[] haloPixels=halo==null?null:ReadPixels(halo,PixelFormat.Format32bppPArgb); + byte[] warningPixels=glassMode&&thermalWarningMask!=null? + ReadPixels(thermalWarningMask,PixelFormat.Format32bppPArgb):null; byte[] pixels=new byte[basePixels.Length]; bool adaptInk=glassMode; // R145: NEVER infer material or recolor ink from an opacity threshold. - Color chosen=TaskbarGlassColor.ReadableText(TaskbarGlassColor.EffectiveBackground(taskbar,themeBackground,pct/100d)); + Color effectiveBackground=TaskbarGlassColor.EffectiveBackground(taskbar,themeBackground,pct/100d); + Color chosen=TaskbarGlassColor.ReadableText(effectiveBackground); + Color warningInk=warningPixels==null?Color.Empty:ThermalWarningInk(effectiveBackground); Color outline=chosen.ToArgb()==Color.White.ToArgb()?Color.Black:Color.White; double backgroundScale=pct/100d; for(int i=0;i=128; + Color textInk=thermalHot?warningInk:chosen; double ring=adaptInk&&haloPixels!=null?Math.Max(0,haloPixels[i+3]/255d-ink)*0.78:0; ring=Math.Min(1-ink,ring); double bg=(1-ink-ring)*backgroundScale*(basePixels[i+3]/255d); pixels[i+3]=Byte((ink+ring+bg)*255); for(int ch=0;ch<3;ch++) { - int textChannel=ch==0?chosen.B:ch==1?chosen.G:chosen.R; + int textChannel=ch==0?textInk.B:ch==1?textInk.G:textInk.R; int outlineChannel=ch==0?outline.B:ch==1?outline.G:outline.R; double glyphContribution=adaptInk?textChannel*ink:(inkPixels==null?0:inkPixels[i+ch]); pixels[i+ch]=Byte(glyphContribution+outlineChannel*ring+basePixels[i+ch]*bg); @@ -367,7 +385,7 @@ private void RefreshGlassAfterMetricCommit() // the opaque last-resort fallback; never misreport successful refresh. } - private Graphics glassInkCanvas,glassHaloCanvas; + private Graphics glassInkCanvas,glassHaloCanvas,glassThermalWarningCanvas; private bool glassLegacyFallback; private bool glassLegacyFallbackReady; private DateTime taskbarGlassSampleAt=DateTime.MinValue; @@ -412,6 +430,15 @@ private Color GlassTaskbarSample() } // R44 chart proof seeds ONLY a purpose-built proof-mode renderer. // Real taskbar metrics/history are never synthesized or persisted. + // Isolated proof only: no telemetry process or user configuration is changed. + internal void SetCpuTemperatureForThermalProof(float temperatureC) + { + if(!proofMode||!MetricStatistics.Finite(temperatureC)) + throw new InvalidOperationException("Thermal pixel stimulus is proof-only."); + snapshot.CpuDevices.Clear(); + snapshot.CpuTempAvailable=true; + snapshot.CpuTempCurrent=temperatureC; + } internal void SeedChartHistoryForProof() { if(!proofMode)throw new InvalidOperationException("Synthetic chart fixture limited to proof mode."); @@ -434,7 +461,7 @@ private Bitmap RenderGlassBitmap(ThemeDefinition theme,int width,int height,int { percent=glassMode?0:Math.Max(1,Math.Min(100,percent)); width=Math.Max(1,width);height=Math.Max(1,height); // Preserve the exact native taskbar footprint, including compact 279px hosts. - Bitmap ink=null,halo=null,full=null; + Bitmap ink=null,halo=null,full=null,thermalMask=null; try { if(percent<100) @@ -451,10 +478,13 @@ private Bitmap RenderGlassBitmap(ThemeDefinition theme,int width,int height,int glassHaloCanvas.TextRenderingHint=System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; glassHaloCanvas.SmoothingMode=System.Drawing.Drawing2D.SmoothingMode.AntiAlias; glassHaloCanvas.Clear(Color.Transparent); + thermalMask=new Bitmap(width,height,PixelFormat.Format32bppPArgb); + glassThermalWarningCanvas=Graphics.FromImage(thermalMask); + glassThermalWarningCanvas.Clear(Color.Transparent); } } full=RenderThemeDefinition(theme,width,height); - Bitmap output=TaskbarGlassPixels.Compose(full,ink,halo,percent,taskbar,theme.Background,glassMode); + Bitmap output=TaskbarGlassPixels.Compose(full,ink,halo,percent,taskbar,theme.Background,glassMode,thermalMask); if(diagnosticBeacon&&!proofMode) using(Graphics beacon=Graphics.FromImage(output))PaintDiagnosticBeacon(beacon); return output; @@ -463,9 +493,11 @@ private Bitmap RenderGlassBitmap(ThemeDefinition theme,int width,int height,int { if(glassInkCanvas!=null){glassInkCanvas.Dispose();glassInkCanvas=null;} if(glassHaloCanvas!=null){glassHaloCanvas.Dispose();glassHaloCanvas=null;} + if(glassThermalWarningCanvas!=null){glassThermalWarningCanvas.Dispose();glassThermalWarningCanvas=null;} if(full!=null)full.Dispose(); if(ink!=null)ink.Dispose(); if(halo!=null)halo.Dispose(); + if(thermalMask!=null)thermalMask.Dispose(); } } private bool PaintPerPixelLayer() @@ -591,6 +623,99 @@ internal static int Run(string dir) } } } + // Exercises the real theme/metric/native bitmap painter with synthetic CPU heat, + // but never creates a native host window or sends a user notification. + internal static class TaskbarGlassThermalProof + { + private static void Require(bool ok,string label) + {if(!ok)throw new InvalidOperationException("R272 thermal glass: "+label);} + private static int RedForeground(Bitmap bitmap) + { + byte[] pixels=TaskbarGlassPixels.ReadPixels(bitmap,PixelFormat.Format32bppPArgb); + int count=0; + for(int i=0;i=215&&r>g+22&&r>b+22&&r>=32)count++; + } + return count; + } + private static int ClearBackground(Bitmap bitmap) + { + byte[] pixels=TaskbarGlassPixels.ReadPixels(bitmap,PixelFormat.Format32bppPArgb); + int count=0; + for(int i=3;i=40,"missing hot red ink "+theme+" "+taskbar+" pixels="+hotRed); + Require(clear>620*48/4,"hot alert made Glass backdrop opaque "+theme); + hotMin=Math.Min(hotMin,hotRed); + clearMin=Math.Min(clearMin,clear); + } + cases++; + } + Require(cases==ThemeCatalog.Names.Length*2,"theme and taskbar background coverage"); + cfg.EnableTemperatureAlerts=false; + using(Bitmap off=form.RenderAppearanceForProof("Retro Terminal",620,48,0,Color.FromArgb(18,20,24),true)) + Require(RedForeground(off)==0,"disabled alert should not tint"); + cfg.EnableTemperatureAlerts=true; + form.SetCpuTemperatureForThermalProof(95); + using(Bitmap normalOpacity=form.RenderAppearanceForProof("Retro Terminal",620,48,29,Color.FromArgb(18,20,24),false)) + { + Require(normalOpacity.Width==620&&normalOpacity.Height==48,"ordinary opacity mode geometry"); + } + } + var result=new{Revision="R272",Status="PASS",Source="REAL_OVERLAY_THEME_AND_METRIC_PAINTER", + HotC=95,ColdC=60,ThresholdC=85,Themes=ThemeCatalog.Names.Length, + ThemeBackgroundPairs=cases,HotRedPixelsMinimum=hotMin,ColdRedPixelsMaximum=nonhotMax, + ClearBackgroundPixelsMinimum=clearMin,OptOutTested=true,NoNativeHostInput=true, + NoUserSettingsOrSensorMutation=true}; + File.WriteAllText(Path.Combine(directory,"THERMAL_GLASS_PROOF.json"), + new JavaScriptSerializer().Serialize(result),new UTF8Encoding(false)); + Console.WriteLine("TBME_THERMAL_GLASS_PROOF=PASS PAIRS="+cases+ + " MIN_HOT_RED="+hotMin+" MIN_CLEAR="+clearMin); + return 0; + } + catch(Exception ex) + { + Console.Error.WriteLine("TBME_THERMAL_GLASS_PROOF=FAIL "+ex); + return 72; + } + } + } // Independent proof: charts must remain foreground at zero opacity. // Only the proof-mode instance receives synthetic history; production never does. internal static class TaskbarChartGlassProof diff --git a/src/TaskbarMonitorEnhanced.cs b/src/TaskbarMonitorEnhanced.cs index 49fcc421..29a2b31d 100644 --- a/src/TaskbarMonitorEnhanced.cs +++ b/src/TaskbarMonitorEnhanced.cs @@ -33,18 +33,18 @@ [assembly: AssemblyDescription("Live system monitor integrated into the Windows taskbar")] [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyInformationalVersion("1.7.5+r265")] +[assembly: AssemblyInformationalVersion("1.7.6+r273")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.5.0")] -[assembly: AssemblyFileVersion("1.7.5.0")] +[assembly: AssemblyVersion("1.7.6.0")] +[assembly: AssemblyFileVersion("1.7.6.0")] namespace TaskbarMonitorEnhanced { internal static class BuildInfo { - public const string Version = "V1_7_5_R265_SENSOR_CROSSUSER_GUARD"; + public const string Version = "V1_7_6_R273_GLASS_THERMAL_ALERT"; public const string Product = "Taskbar Monitor Enhanced"; - public const string PublicVersion = "1.7.5"; + public const string PublicVersion = "1.7.6"; public const string ShortcutName = "Taskbar Monitor Enhanced"; public const string ProductDescription = "Live system monitor integrated into the Windows taskbar"; public const string Author = "Dr. Ali-Akbar Emadeddin"; @@ -6290,6 +6290,19 @@ private void PaintMetric(Graphics g,ThemeDefinition t,MetricView m,RectangleF r, { using(Pen hotPen=new Pen(Color.FromArgb(235,AlertPolicy.AlertColor),2f)) g.DrawRectangle(hotPen,inner.X,inner.Y,Math.Max(1,inner.Width-1),Math.Max(1,inner.Height-1)); + // Preserve a semantic warning mask for Glass mode: recolor ONLY + // hot metric glyphs/charts, never normal theme ink or the backdrop. + if(glassThermalWarningCanvas!=null) + { + using(SolidBrush maskBrush=new SolidBrush(Color.White)) + glassThermalWarningCanvas.FillRectangle(maskBrush,inner); + if(glassInkCanvas!=null) + using(Pen rim=new Pen(Color.White,2f)) + glassInkCanvas.DrawRectangle(rim,inner.X,inner.Y,Math.Max(1,inner.Width-1),Math.Max(1,inner.Height-1)); + if(glassHaloCanvas!=null) + using(Pen haloRim=new Pen(Color.White,4f)) + glassHaloCanvas.DrawRectangle(haloRim,inner.X,inner.Y,Math.Max(1,inner.Width-1),Math.Max(1,inner.Height-1)); + } } if(t.Mode=="round"||t.Mode=="glass") { @@ -10237,6 +10250,7 @@ private static int Main(string[] args) { Native.EnableDllSearchHardening(); if(args!=null && args.Length>1 && args[0]=="--glassproof")return TaskbarGlassProof.Run(args[1]); + if(args!=null && args.Length>1 && args[0]=="--glassthermalproof")return TaskbarGlassThermalProof.Run(args[1]); if(args!=null && args.Length>1 && args[0]=="--chartglassproof")return TaskbarChartGlassProof.Run(args[1]); if(args!=null && args.Length>1 && args[0]=="--glassliveproof")return TaskbarGlassLiveProof.Run(args[1]); if(args!=null && args.Length>1 && args[0]=="--topglassliveproof")return TaskbarTopGlassVisualProof.Run(args[1]); diff --git a/src/WorkspaceSelfTest.cs b/src/WorkspaceSelfTest.cs index 637093da..8ab22b1c 100644 --- a/src/WorkspaceSelfTest.cs +++ b/src/WorkspaceSelfTest.cs @@ -126,7 +126,7 @@ internal static int Run() { try { - Check(BuildInfo.PublicVersion=="1.7.5","version"); + Check(BuildInfo.PublicVersion=="1.7.6","version"); for(int schema=0;schema<7;schema++){ var legacy=new AppConfig{ConfigSchemaVersion=schema,EnableTemperatureNotifications=true,CpuTempWarningC=0,FontSize=Double.NaN,Opacity=Double.NaN};legacy.Normalize(); Check(legacy.ConfigSchemaVersion==8,"all legacy schemas migrate "+schema); From 0181c7ad537e1881987f1884c3e77abbc6542532 Mon Sep 17 00:00:00 2001 From: Aliakbar Emadoddin <94546844+GOD13emad@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:47:04 +0330 Subject: [PATCH 011/135] docs: align public v1.7.6 R276 project continuity Redacted Current Project R276, current public Brain header and README updated. Immutable official v1.7.6 source and assets stay unchanged; exact historical Brain suffix preserved. Owner Glass red warnings and native Win A/B passes documented; Issue #6, real AC loss and machinewide-singleton limitations remain explicitly open. GitHub docs-branch full workflow 38937978453 SUCCESS. --- docs/project-control/CURRENT_PROJECT.json | 40 ++++++++++++++--------- docs/project-control/PROJECT_BRAIN.md | 16 +++++---- docs/project-control/README.md | 2 +- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/docs/project-control/CURRENT_PROJECT.json b/docs/project-control/CURRENT_PROJECT.json index 5b83de79..b4114f2d 100644 --- a/docs/project-control/CURRENT_PROJECT.json +++ b/docs/project-control/CURRENT_PROJECT.json @@ -3,28 +3,36 @@ "Project": "TaskbarMonitorEnhanced", "AuthorityIndex": "docs/project-control/PROJECT_BRAIN.md", "Scope": "PUBLIC_REDACTED_SNAPSHOT; owner-local detailed Project Brain is the only active operational authority", - "CurrentEngineeringRevision": "R270", - "Status": "PUBLIC_IMMUTABLE_UNSIGNED_V175_OWNER_SCOPED_PASS", - "UpdatedUtc": "2026-10-16T13:05:40Z", + "CurrentEngineeringRevision": "R276", + "Status": "PUBLIC_IMMUTABLE_UNSIGNED_V176_OWNER_GLASS_ALERT_SCOPED_PASS", + "UpdatedUtc": "2026-10-16T17:00:48Z", "FinalObjective": "Preserve v1.6 taskbar behavior with user-enabled zero-background Glass Mode, correct Start/Settings/fullscreen handling, safe in-place upgrades, a single per-user installation root, and reliable low-overhead sensors.", "CurrentAcceptedBaseline": { - "Version": "1.7.5", - "ReleaseId": 414936595, - "ReleaseURL": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.5", - "SourceCommit": "9284b124168f3aa2c90f8637eeba6a20f2d0e1af", - "SetupSHA256": "8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", - "InstalledMainSHA256": "25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6", + "Version": "1.7.6", + "ReleaseId": 415147285, + "ReleaseURL": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.6", + "SourceCommit": "2348791fbde3aff5a18b7df536dba8a66a65d191", + "SetupSHA256": "D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + "InstalledMainSHA256": "0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8", "PublicAssetCount": 7, "InstallerUnsigned": true, "PerUserInstall": true, - "MachinewideSingletonProven": false + "MachinewideSingletonProven": false, + "OwnerObservedGlassModeZeroAlpha": true, + "OwnerHighCpuRealRedPixelsConfirmed": true, + "OwnerWindowsNotificationsOptedIn": true, + "OwnerApplicationNotificationDispatchLogged": true, + "ActualOsToastOrSoundProven": false }, "Completed": [ "Seven immutable public v1.7.5 assets independently SHA/Manifest verified (R267)", "Exact-main Windows A/B build, fresh/upgrade/rollback, native UI and cross-user guard CI PASS (R265)", "Owner official v1.7.4 to v1.7.5 in-place upgrade and 443-file cold backup/isolated restore PASS (R268)", "Owner unmanaged data preserved, Glass native observations and unpaired 60.688-second CPU observation recorded (R269)", - "Owner current-user scheduled-task principal and all four live process SIDs matched; installed hashes retained (R270)" + "Owner current-user scheduled-task principal and all four live process SIDs matched; installed hashes retained (R270)", + "R272-R274 semantic Glass red-warning compositor correction across 98 actual bitmap tests; safe official v1.7.5→v1.7.6 transactional update and rollback on two hosted Windows A/B.", + "R275 immutable public unsigned v1.7.6: one Setup, seven assets independently SHA/manifest verified by live G16; exact merged-main CI and Windows A/B SUCCESS.", + "R276 owner official same-root 1.7.6 update with private 26-entry preupgrade recovery, real hot CPU Glass 3243 red pixels, Windows notification opt-in plus app dispatch, 10/10 healthy sensors." ], "Open": [ "ISSUE6_REPORTER_DRIVER_SHA_AND_INSTALL_ORIGIN_MISSING", @@ -32,16 +40,16 @@ "ONE_PER_MACHINE_POLICY_REQUIRES_EXPLICIT_OWNER_DECISION; one per Windows user verified", "INDEPENDENT_SECURITY_SIGNING_LAB_NOT_PROVEN; intentionally unsigned release accepted" ], - "CriticalPath": "Keep immutable v1.7.5 and existing owner data unchanged; pursue only external reporter artifact, controlled physical mains-loss rig, and explicit machinewide install policy if required.", - "MainBlocker": "External security attribution and physical rig evidence; owner policy if one-per-machine is required. Existing per-user product release is scoped PASS, not unconditional FINAL.", - "BrainStatus": "PUBLIC_SUMMARY_R270; not the owner-local complete Brain", - "ExactNextAction": "Use public Issue #7 for current blockers; obtain reporter driver SHA/origin without restoring quarantined malware, test physical AC outage on a controlled disposable rig, and decide per-user versus machinewide installation only if a new policy is needed.", + "CriticalPath": "Maintain immutable v1.7.6 and the existing per-user single-root installation; red thermal warnings and optional Windows notifications confirmed at app level. External dependencies remain reporter's WinRing0 driver hash/install origin (Issue #6), controlled actual physical AC power-cut, and optional machinewide singleton policy. Unsigned publisher waiver accepted.", + "MainBlocker": "The requested Glass thermal warning repair is installed and scoped PASS. Only reporter-specific vulnerable-driver attribution, real physical mains-loss recovery and machinewide singleton design/policy remain outside acceptance. Actual OS toast/audio and independent security signing lab were not certified.", + "BrainStatus": "PUBLIC_SUMMARY_R276; owner-local detailed Project Brain is sole active operational authority", + "ExactNextAction": "Maintain installed v1.7.6 unchanged; obtain original reporter WinRing0 driver SHA/source without restoring quarantined malware, test physical power loss only on an isolated authorized rig, and decide machinewide vs accepted per-user policy only if required.", "PublicTracker": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7", "SecurityTracker": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6", "HistoricalR40Record": { "Commit": "9284b124168f3aa2c90f8637eeba6a20f2d0e1af", "Path": "docs/project-control/CURRENT_PROJECT.json", "GitBlob": "95e4d549d855de787a9274888bc686e3fda54d20", - "Role": "immutable historical evidence, superseded by R270" + "Role": "immutable historical evidence, superseded by current R276" } } diff --git a/docs/project-control/PROJECT_BRAIN.md b/docs/project-control/PROJECT_BRAIN.md index b26de132..10744de3 100644 --- a/docs/project-control/PROJECT_BRAIN.md +++ b/docs/project-control/PROJECT_BRAIN.md @@ -1,15 +1,17 @@ - -# Current public release status — R270 / v1.7.5 (2026-10-16) + +# Current public release status — R276 / v1.7.6 (2026-10-16) -The immutable **latest official v1.7.5** release ([assets](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.5), ID `414936595`) is accepted within a clearly limited scope. Its source is commit `9284b124168f3aa2c90f8637eeba6a20f2d0e1af`, the single **unsigned** Setup has SHA-256 `8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC`, and the accepted installed Main has SHA-256 `25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6`. Seven public release assets and exact-main hosted Windows tests have been verified. These checks establish release identity and tested behavior, **not an independent malware-security certification**. +The immutable **latest official v1.7.6** [release](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.6) (ID `415147285`) has exact source `2348791fbde3aff5a18b7df536dba8a66a65d191`. The **single intentionally unsigned** Setup SHA256 is `D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0`; the owner-installed Main SHA256 is `0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8`. Exactly **seven immutable public assets** were independently re-downloaded and validated with the release Manifest, SHA256SUMS and exact-main CI. This is artifact and tested-behavior evidence, **not** signed-publisher or independent malware certification. -The owner's official v1.7.4 → v1.7.5 in-place update, preservation of preexisting user data and Glass ON/alpha-zero configuration, and current-owner Scheduled Task plus four process SID check have passed (R268–R270). The installation model is one root **per Windows user**, not one guaranteed root per computer. +**Glass temperature warnings:** In v1.7.5 the monochrome Glass compositor erased high-temperature red; v1.7.6 preserves semantic red for hot CPU/GPU/disk cards (text/chart/border) with true zero background alpha and readable non-hot themes. The owner upgraded official v1.7.5 **in the same per-user installation root** with a hash-verified 26-entry preupgrade recovery archive. On the real owner taskbar when CPU was **90°C**, above the 85°C threshold, **3,243 warning-red pixels** were observed. The Windows temperature-notification preference was enabled through Settings > Alerts and a CPU warning dispatch appears in the application log; real OS balloon/audio presentation was **not independently witnessed**. -**Outstanding external evidence:** [Issue #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) needs the original reporter's WinRing0 driver SHA/install origin; real physical AC-loss recovery has not been tested; a global single-install policy needs an explicit design decision. Public signing/security laboratory attestation is absent, with unsigned distribution explicitly accepted. Do not dismiss a Defender vulnerable-driver warning or restore excluded driver binaries. +**Independent validation:** Exact-main [Build and Verification](https://github.com/GOD13emad/TaskbarMonitorEnhanced/actions/runs/38928203915) and [two hosted Windows A/B real official 1.7.5→1.7.6 transactional upgrades/rollback, native UI, fresh/install safety](https://github.com/GOD13emad/TaskbarMonitorEnhanced/actions/runs/38928203956) all passed. Owner live process and sensor topology was healthy for 10/10 samples (one Main, one Supervisor, two Brokers). Passive 60.436-second Main CPU sample: 7.342% of one logical core, **unpaired**, not a comparative performance claim. -The complete current owner-host Project Brain is maintained privately in the existing canonical project root and is **not replicated in this public repository**. This file and [CURRENT_PROJECT.json](CURRENT_PROJECT.json) are redacted public release/continuity summaries; [Issue #7](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7) is the rolling public tracker. Earlier “CURRENT” headings below are historical, superseded records. Their original blobs remain reachable from Git history (including source commit `9284b124`). +**Remaining independent/external limitations:** [Defender WinRing0 security Issue #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) still lacks the original reporter driver SHA/install origin; do not dismiss the legitimate detection or disable Defender. Controlled real physical AC-mains interruption remains untested. Installation is one root **per Windows user**, not a machinewide singleton. Public signing/security lab attestation is absent under the accepted unsigned-release waiver. - +The owner-local Project Brain remains the **single operational authority**. This [public state](CURRENT_PROJECT.json) and [Issue #7](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7) are redacted summary/rolling tracker, not a duplicate active Brain. The earlier R270 summary is recoverable exactly from historical Git blob `b26de132a903ca8e9878731989cc15a7e3f3a8ef` at release-source commit `2348791fbde3aff5a18b7df536dba8a66a65d191`, and within Issue #7 history; the historical content below is retained byte-for-byte. + + --- diff --git a/docs/project-control/README.md b/docs/project-control/README.md index cb4894bf..3e05f941 100644 --- a/docs/project-control/README.md +++ b/docs/project-control/README.md @@ -2,7 +2,7 @@ This directory preserves release-engineering decisions and historical verification evidence. It is **not part of the installed application**. -**Current public status (R270 / v1.7.5):** [public state](CURRENT_PROJECT.json), [Project Brain summary and history](PROJECT_BRAIN.md), [verified release](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.5), and [rolling public tracker / open gates](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7). [Security report #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) remains open. +**Current public status (R276 / v1.7.6):** [public state](CURRENT_PROJECT.json), [Project Brain summary and history](PROJECT_BRAIN.md), [verified release](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.6), and [rolling public tracker / open gates](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7). [Security report #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) remains open. [Project Knowledge & Evidence](PROJECT_KNOWLEDGE_EVIDENCE.md) retains dated engineering evidence. Historical headings such as “CURRENT R56” were current **only at the time recorded**. The complete owner-local Project Brain is the single active private operational authority; these Git-tracked files are a redacted **public summary and historical record**, not a second live operational Brain. Original historical file versions remain retrievable from Git. From 590cfcca590387af60b67b4a7b426b4f71486e90 Mon Sep 17 00:00:00 2001 From: Aliakbar Emadoddin <94546844+GOD13emad@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:59:31 +0330 Subject: [PATCH 012/135] test: retain verified R282 two-host abrupt journal recovery Two exact-source GitHub-hosted Windows guests PASS on pinned 22-file v1.7.5 predecessor fixture and immutable v1.7.6 Setup cold recovery. PREPARED and COMMITTING (two managed file replacements) both real staging-helper process-kill, followed by successful normal installer /update /quiet, data preservation, one root and journal cleanup. Success CI 38954292153, PR full CI 38954753014. Not real AC loss or killed Setup. Initial failing harness executions retained as evidence; PowerShell reserved $args was fixed; no application binary, installer, driver or release change. --- .../audit-r282-disposable-abrupt-recovery.yml | 92 ++++++ build/Test-AbruptStageRecovery-R282.ps1 | 305 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 .github/workflows/audit-r282-disposable-abrupt-recovery.yml create mode 100644 build/Test-AbruptStageRecovery-R282.ps1 diff --git a/.github/workflows/audit-r282-disposable-abrupt-recovery.yml b/.github/workflows/audit-r282-disposable-abrupt-recovery.yml new file mode 100644 index 00000000..efdd60eb --- /dev/null +++ b/.github/workflows/audit-r282-disposable-abrupt-recovery.yml @@ -0,0 +1,92 @@ +name: R282 Pinned 1.7.5 fixture and immutable 1.7.6 cold journal recovery + +on: + push: + branches: [test/r282-disposable-abrupt-update-recovery] + workflow_dispatch: + +permissions: + contents: read + +jobs: + original-release-cold-recovery: + name: Public v1.7.6 Setup / ${{ matrix.phase }} / pinned-file guest ${{ matrix.host }} + timeout-minutes: 20 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - host: A + phase: PREPARED + os: windows-2025 + - host: B + phase: COMMITTING + os: windows-2022 + steps: + - name: Checkout test-only source on exact hosted revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Verify PowerShell 5.1 parser and disposable execution contract + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC'){throw 'R282_UNSAFE_NON_DISPOSABLE_HOST'} + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R282_SOURCE_CHECKOUT_MISMATCH'} + $p=(Resolve-Path build/Test-AbruptStageRecovery-R282.ps1).Path + $tokens=$null; $errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors | ForEach-Object { Write-Output $_.Message };throw 'R282_POWERSHELL_PARSER_FAILED'} + $source=[IO.File]::ReadAllText($p) + if($source.Contains('[string]$args') -or !$source.Contains('[string]$arguments') -or + !$source.Contains('$si.Arguments=$arguments')){ + throw 'R282_BLOCK_RESERVED_POWERSHELL_AUTOMATIC_ARGS_PARAMETER' + } + if(!(Test-Path build/official-v175-managed.json)){throw 'R282_PREDECESSOR_PINNED_FIXTURE_MISSING'} + 'R282_EXACT_HOSTED_GUARDS_AND_TEST_PARSER_PASS' + + - name: Build exact resource-pinned prior fixture, kill test helper, recover with real public Setup + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-AbruptStageRecovery-R282.ps1 -HostLabel '${{ matrix.host }}' -Phase '${{ matrix.phase }}' + $receipt=Join-Path $env:RUNNER_TEMP 'TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/R282_ABRUPT_RECOVERY.json' + if(!(Test-Path $receipt)){throw 'R282_DURABLE_RECEIPT_ABSENT'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Status -cne 'PASS_DISPOSABLE_CHILD_KILLED_THEN_OFFICIAL_INSTALLER_AUTO_RECOVERED' -or + $r.SourceBranchCommit -cne $env:GITHUB_SHA -or + $r.HostLabel -cne '${{ matrix.host }}' -or + $r.Scenario -cne '${{ matrix.phase }}' -or + $r.Official175ManagedFixtureRowsExact -ne 22 -or + $r.OriginalOfficial175InstallerFreshInstallPerformed -ne $false -or + $r.HistoricalV175FilesExtractedFromPinnedPublishedInstallerResources -ne $true -or + $r.PrivilegedSensorDriverInstallPerformedForTest -ne $false -or + $r.StagingHelperExternalProcessKilled -ne $true -or + $r.NativeSetupAutomaticJournalRecoveryAndInPlaceUpgrade -ne $true -or + $r.ExactProtectedUserDataSHA256Preserved -ne $true -or + $r.SingleCanonicalUserRoot -ne $true -or + $r.RealPhysicalMainsCut -ne $false -or + $r.ActualSetupExecutableProcessKilled -ne $false -or + $r.NoOriginalOwnerPcTouched -ne $true){throw 'R282_TYPED_DISPOSABLE_RECOVERY_NOT_ACCEPTED'} + if('${{ matrix.phase }}' -ceq 'COMMITTING' -and $r.ManagedComponentsPartiallyReplacedBeforeKill -ne 2){throw 'R282_PARTIAL_COMMIT_NOT_PROVEN'} + 'R282_VERIFIED_EXACT_OFFICIAL_176_AFTER_REAL_TEST_CHILD_KILL=PASS' + + - name: Preserve the bounded individual fail/pass receipt on hosted runner + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R282-abrupt-${{ matrix.host }}-${{ matrix.phase }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/R282_ABRUPT_RECOVERY.json + ${{ runner.temp }}/TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/ARMED.json + ${{ runner.temp }}/TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/ARMED.error.txt + ${{ runner.temp }}/TBME_R282_${{ matrix.host }}_${{ matrix.phase }}/R282_INSTALL_DIAGNOSTIC.json diff --git a/build/Test-AbruptStageRecovery-R282.ps1 b/build/Test-AbruptStageRecovery-R282.ps1 new file mode 100644 index 00000000..f350d347 --- /dev/null +++ b/build/Test-AbruptStageRecovery-R282.ps1 @@ -0,0 +1,305 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('PREPARED','COMMITTING')][string]$Phase +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +Add-Type -AssemblyName System.Windows.Forms +# Irreversible termination is ONLY allowed for the exact child we launch +# on an authenticated disposable GitHub-hosted Windows VM. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + ![Environment]::UserInteractive){throw 'R282_REFUSE_NON_DISPOSABLE_OR_OWNER_HOST'} +$repo='GOD13emad/TaskbarMonitorEnhanced' +$releaseHead='2348791fbde3aff5a18b7df536dba8a66a65d191' +$sha175='8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC' +$sha176='D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0' +$main175='25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6' +$main176='0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$uninstall='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$out=Join-Path $env:RUNNER_TEMP ('TBME_R282_'+$HostLabel+'_'+$Phase) +$armed=Join-Path $out 'ARMED.json' +$receipt=Join-Path $out 'R282_ABRUPT_RECOVERY.json' +$childScript=Join-Path $out 'StageChild.ps1' +if((Test-Path $out) -or (Test-Path $app) -or (Test-Path $uninstall) -or + @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -gt 0){ + throw 'R282_REFUSE_NON_FRESH_HOST_OR_PREEXISTING_PRODUCT' +} +$previousExe=$null;$currentExe=$null;$stageProcess=$null +$phaseNow='START' +$reason='';$passes=@();$terminated=$false;$partialReplacement=$false +function Hash([string]$p){ + if(Test-Path $p -PathType Leaf){return (Get-FileHash $p -Algorithm SHA256).Hash} + return 'MISSING' +} +function Run-Exact([string]$exe,[string]$arguments){ + # Do not use '$args' as a parameter: PowerShell automatically replaces it with + # unbound positional arguments, silently dropping /update /quiet and hanging GUI. + if($arguments -cne '/update /quiet'){throw ('R282_UNEXPECTED_SETUP_ARGUMENT_STRING_'+$arguments)} + # Exact process launch from successful R273 hosted installer acceptance. + $si=New-Object Diagnostics.ProcessStartInfo + $si.FileName=$exe;$si.Arguments=$arguments;$si.UseShellExecute=$false + $si.WorkingDirectory=(Split-Path $exe -Parent) + $p=[Diagnostics.Process]::Start($si) + if(!$p){throw 'R282_SETUP_NOT_STARTED'} + try{ + if(!$p.WaitForExit(190000)){ + $children=@(Get-CimInstance Win32_Process | Where-Object {$_.ParentProcessId -eq $p.Id} | Select-Object -ExpandProperty Name) + $driver=Get-Service PawnIO -ErrorAction SilentlyContinue + $snapshot=[ordered]@{ + FailedPhase=$phaseNow;InstallerPid=$p.Id + InstallerStillRunning=(-not $p.HasExited) + ChildProcessNames=$children + InstalledRootExists=[bool](Test-Path $app) + InstalledMainPresent=[bool](Test-Path (Join-Path $app 'TaskbarMonitorEnhanced.exe')) + InstalledStatePresent=[bool](Test-Path (Join-Path $app 'install_state.json')) + PawnIOServiceState=$(if($driver){[string]$driver.Status}else{'NOT_PRESENT'}) + CanReadInstallLogs=[bool](Test-Path (Join-Path $app 'Logs')) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText((Join-Path $out 'R282_INSTALL_DIAGNOSTIC.json'),($snapshot|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + throw ('R282_SETUP_TIMEOUT_PID_'+$p.Id+'_CHILDREN_'+($children -join ',')) + } + return [int]$p.ExitCode + }finally{$p.Dispose()} +} +function Startup(){ + $p=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if(!$p){return 'ABSENT'} + $v=$p.PSObject.Properties['TaskbarMonitorEnhanced'] + return $(if(!$v){'ABSENT'}else{[string]$v.Value}) +} +# Minimal child script: all source / installer functions are the unchanged, +# SHA-pinned public 1.7.6 code. The child itself is the sole forced termination target. +$childText=@' +param( + [Parameter(Mandatory=$true)][string]$Setup, + [Parameter(Mandatory=$true)][string]$App, + [Parameter(Mandatory=$true)][string]$Signal, + [Parameter(Mandatory=$true)][string]$Phase +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +try{ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC'){throw 'CHILD_REFUSES_PERSONAL_HOST'} + if((Get-FileHash $Setup).Hash -ne 'D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0'){throw 'WRONG_PUBLIC_SETUP'} + if((Get-FileHash (Join-Path $App 'TaskbarMonitorEnhanced.exe')).Hash -ne '25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6'){throw 'WRONG_INSTALLED_PREDECESSOR'} + if(Test-Path (Join-Path $App '.tbme-upgrade')){throw 'JOURNAL_ALREADY_PRESENT'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'APP_RUNNING_DURING_DISPOSABLE_TEST'} + Add-Type -TypeDefinition 'using System.IO; public static class R282FileBridge { public static void ReplaceNoBackup(string a,string b){File.Replace(a,b,null);} }' + $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($Setup)) + $t=$asm.GetType('SetupProgram',$true) + $f=[Reflection.BindingFlags]'Static,NonPublic,Public' + $version='1.7.5';$channel='OFFICIAL_STABLE_V175' + $state=([IO.File]::ReadAllText((Join-Path $App 'install_state.json'))) + $legacy=$t.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@($version,$channel)) + $fresh=$t.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($state)) + $fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($Setup) + [void]$t.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$state,$version,$channel)) + if($Phase -ceq 'COMMITTING'){ + [void]$t.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) + foreach($rel in @('TaskbarMonitorEnhanced.exe','Source/TaskbarGlass.cs')){ + $staged=Join-Path (Join-Path $App '.tbme-upgrade\new') $rel + $live=Join-Path $App $rel + [R282FileBridge]::ReplaceNoBackup($staged,$live) + } + if((Get-FileHash (Join-Path $App 'TaskbarMonitorEnhanced.exe')).Hash -ne '0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8'){throw 'PHYSICAL_PARTIAL_COMMIT_UNVERIFIED'} + } + $report=[ordered]@{Phase=$Phase;ChildProcessId=$PID;CommittedManagedCount=$(if($Phase -eq 'COMMITTING'){2}else{0});ArmUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($Signal,($report|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + while($true){Start-Sleep -Seconds 3} +}catch{ + $errorPath=[IO.Path]::ChangeExtension($Signal,'.error.txt') + [IO.File]::WriteAllText($errorPath,$_.Exception.ToString(),[Text.UTF8Encoding]::new($false)) + throw +} +'@ +$receiptWritten=$false +[IO.Directory]::CreateDirectory($out)|Out-Null +try{ + $phaseNow='DOWNLOAD_OFFICIAL_RELEASES' + $env:GH_PROMPT_DISABLED='1' + & gh release download v1.7.5 -R $repo -p TaskbarMonitorEnhanced_Setup_1.7.5.exe -D $out + if($LASTEXITCODE -ne 0){throw 'PRIOR_175_RELEASE_DOWNLOAD_FAILED'} + & gh release download v1.7.6 -R $repo -p TaskbarMonitorEnhanced_Setup_1.7.6.exe -D $out + if($LASTEXITCODE -ne 0){throw 'CURRENT_176_RELEASE_DOWNLOAD_FAILED'} + $previousExe=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.5.exe' + $currentExe=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' + if((Hash $previousExe) -cne $sha175 -or (Hash $currentExe) -cne $sha176){throw 'SIGNED_OFF_PUBLIC_ASSET_SHA_CHANGED'} + $passes+=@('PUBLIC_175_176_ASSETS_PINNED') + $phaseNow='STAGE_EXACT_22_SHA_PINNED_OFFICIAL_V175_RESOURCES_ONLY' + # Previous authentic R273 hosted CI covered genuine fresh 1.7.5 install; + # this R282 isolated crash test needs a byte-exact prior product root WITHOUT + # installing privileged machine drivers or invoking fresh Setup/UAC. + $inventory=Get-Content (Join-Path $PSScriptRoot 'official-v175-managed.json') -Raw|ConvertFrom-Json + if($inventory.Version -ne '1.7.5' -or $inventory.ManagedCount -ne 22 -or + $inventory.PublishedSetupSHA256 -cne $sha175 -or $inventory.PublishedMainSHA256 -cne $main175 -or + @($inventory.Rows).Count -ne 22){throw 'R282_PREDECESSOR_22_FILE_PINNING_CHANGED'} + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($previousExe)) + [IO.Directory]::CreateDirectory($app)|Out-Null + foreach($row in $inventory.Rows){ + $rel=[string]$row.Path + if([string]::IsNullOrWhiteSpace($rel) -or [IO.Path]::IsPathRooted($rel) -or + $rel -match '(^|[/\\])\.\.([/\\]|$)' -or $rel -match '[:*?"<>|]'){throw 'R282_UNSAFE_PREDECESSOR_MEMBER'} + $dest=Join-Path $app $rel + $normalized=[IO.Path]::GetFullPath($dest) + if(!$normalized.StartsWith(([IO.Path]::GetFullPath($app).TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){throw 'R282_RESOURCE_OUTPUT_OUTSIDE_DISPOSABLE_ROOT'} + [IO.Directory]::CreateDirectory((Split-Path $normalized -Parent))|Out-Null + if(Test-Path $normalized){throw ('R282_DUPLICATE_RESOURCE_'+$rel)} + if($rel -ceq 'Uninstall.exe'){ + [IO.File]::Copy($previousExe,$normalized) + }else{ + $resourceName='Payload.'+[IO.Path]::GetFileName($rel) + $stream=$assembly.GetManifestResourceStream($resourceName) + if(!$stream){throw ('R282_PREDECESSOR_EMBEDDED_RESOURCE_MISSING_'+$resourceName)} + try{ + $target=[IO.File]::Open($normalized,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($target)}finally{$target.Dispose()} + }finally{$stream.Dispose()} + } + if((Hash $normalized) -cne $row.SHA256 -or (Get-Item $normalized).Length -ne $row.Bytes){throw ('R282_OFFICIAL_PREDECESSOR_ROW_IDENTITY_DRIFT_'+$rel)} + } + $backendRoot=Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6' + $archive=Join-Path $out 'LHM_096_REPLAY_FROM_OFFICIAL_175_SETUP.zip' + $stream=$assembly.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + if(!$stream){throw 'R282_OFFICIAL_SENSOR_LIBRARY_RESOURCE_MISSING'} + try{ + $target=[IO.File]::Open($archive,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($target)}finally{$target.Dispose()} + }finally{$stream.Dispose()} + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Directory]::CreateDirectory($backendRoot)|Out-Null + [IO.Compression.ZipFile]::ExtractToDirectory($archive,$backendRoot) + if((Hash (Join-Path $backendRoot 'LibreHardwareMonitorLib.dll')) -cne '6EBC194316536BA61AF5BE24508AD9FCBB2ECC685E716C12E787C79530F66BF0'){throw 'R282_PINNED_BACKEND_LIBRARY_WRONG'} + $synthetic=[ordered]@{App='Taskbar Monitor Enhanced';Version='1.7.5';PublicVersion='1.7.5' + ProductIdentity='LOCKED';SensorLayerStatus='READY';SensorLayerMode='R282_DISPOSABLE_PRESEEDED_NO_DRIVER' + SensorLayerVersion='0.9.6';MainLaunchMode='NOT_STARTED_DISPOSABLE';R282Fixture=$true} + [IO.File]::WriteAllText((Join-Path $app 'install_state.json'),($synthetic|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + $key=New-Item -Path $uninstall -Force -ErrorAction Stop + Set-ItemProperty -Path $uninstall -Name 'DisplayName' -Value 'Taskbar Monitor Enhanced' + Set-ItemProperty -Path $uninstall -Name 'DisplayVersion' -Value '1.7.5' + Set-ItemProperty -Path $uninstall -Name 'InstallLocation' -Value $app + $key.Dispose() + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $main175 -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $sha175 -or + (Get-ItemProperty $uninstall).DisplayVersion -cne '1.7.5'){throw 'R282_PINNED_PREDECESSOR_FIXTURE_INCOMPLETE'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'DISPOSABLE_FIXTURE_STARTED_REAL_PRODUCT'} + $passes+=@('22_OFFICIAL_V175_MANAGED_ROWS_VERIFIED_SHA_FROM_EMBEDDED_RESOURCES','NO_PRIVILEGED_SENSOR_DRIVER_INSTALLED') + $config=Join-Path $app 'config.json' + $backup=Join-Path $app 'config.json.bak' + $userOwned=Join-Path $app 'R282_USER_OWNED_CANARY.txt' + $backend=Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll' + $fixture='{"Theme":"Retro Terminal","GlassMode":true,"Opacity":0.0,"EnableTemperatureAlerts":true,"EnableTemperatureNotifications":true,"R282Canary":"MUST_PRESERVE_ABRUPT_STAGE"}' + [IO.File]::WriteAllText($config,$fixture,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"R282Backup":"INDEPENDENT"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($userOwned,'R282 disposable user file remains unchanged',[Text.UTF8Encoding]::new($false)) + $protected=@{} + foreach($path in @($config,$backup,$userOwned,$backend)){ + if(!(Test-Path $path)){throw ('PROTECTED_FILE_MISSING_'+$path)} + $protected[$path]=Hash $path + } + $startupBefore=Startup + $stateBefore=Hash (Join-Path $app 'install_state.json') + $sourceBefore=Hash (Join-Path $app 'Source\TaskbarGlass.cs') + $passes+=@('PREEXISTING_OWNERLIKE_DATA_PINNED') + $phaseNow='STAGE_IN_SEPARATE_CHILD_PROCESS' + [IO.File]::WriteAllText($childScript,$childText,[Text.UTF8Encoding]::new($false)) + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $si.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$childScript+'" -Setup "'+$currentExe+'" -App "'+$app+'" -Signal "'+$armed+'" -Phase '+$Phase + $si.UseShellExecute=$false;$si.CreateNoWindow=$true + $stageProcess=[Diagnostics.Process]::Start($si) + if(!$stageProcess){throw 'DISPOSABLE_CHILD_NOT_STARTED'} + $sw=[Diagnostics.Stopwatch]::StartNew() + while(!(Test-Path $armed) -and $sw.Elapsed.TotalSeconds -lt 60){ + $stageProcess.Refresh() + if($stageProcess.HasExited){throw ('STAGING_CHILD_FAILED_EARLY_'+$stageProcess.ExitCode)} + Start-Sleep -Milliseconds 150 + } + if(!(Test-Path $armed)){throw 'DISPOSABLE_STAGING_CHILD_TIMEOUT'} + $signal=Get-Content $armed -Raw|ConvertFrom-Json + if($signal.ChildProcessId -ne $stageProcess.Id -or $signal.Phase -cne $Phase -or $signal.CommittedManagedCount -ne $(if($Phase -eq 'COMMITTING'){2}else{0})){throw 'PARENT_CHILD_SIGNAL_OR_PHASE_MISMATCH'} + $phaseFile=Join-Path $stage 'phase.txt' + if(!(Test-Path $phaseFile) -or ((Get-Content $phaseFile -Raw) -cne ('TBME_UPGRADE_R92_OWNED_STAGE_V1|'+$Phase))){throw 'DISPOSABLE_OWNED_JOURNAL_PHASE_WRONG'} + $partialReplacement=($Phase -eq 'COMMITTING') + if($partialReplacement -and (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $main176){throw 'COMMITTING_PREKILL_NOT_NEW_MAIN'} + if(!$partialReplacement -and (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $main175){throw 'PREPARED_SHOULD_NOT_TOUCH_MAIN'} + if((Hash (Join-Path $app 'install_state.json')) -cne $stateBefore){throw 'PARTIAL_STAGE_ALREADY_CHANGED_INSTALLED_VERSION'} + $passes+=@('STAGED_DURABLE_JOURNAL_EXACT_PHASE') + $phaseNow='FORCE_TERMINATE_ONLY_NEW_TEST_CHILD_ON_DISPOSABLE' + $stageProcess.Refresh() + if($stageProcess.HasExited){throw 'CHILD_ENDED_WITHOUT_EXTERNAL_TERMINATION'} + $stageProcess.Kill() + if(!$stageProcess.WaitForExit(15000)){throw 'OWNED_TEST_CHILD_WILL_NOT_TERMINATE'} + $terminated=$true + if(!(Test-Path $stage)){throw 'KILL_DESTROYED_EXPECTED_STAGE'} + $passes+=@('ACTUAL_STAGING_HELPER_PROCESS_ABRUPTLY_KILLED') + $phaseNow='COLD_NATIVE_SETUP_PROCESS_RECOVERS_JOURNAL_THEN_UPGRADES' + $result=Run-Exact $currentExe '/update /quiet' + if($result -ne 0){throw ('NATIVE_UPDATE_DID_NOT_RECOVER_AND_UPGRADE_'+$result)} + if(Test-Path $stage){throw 'RECOVERY_JOURNAL_NOT_CLEANED'} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $main176 -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $sha176 -or + (Get-ItemProperty $uninstall).DisplayVersion -cne '1.7.6'){throw 'FINAL_INSTALLED_RELEASE_IDENTITY_MISMATCH'} + if((Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json).Version -cne '1.7.6'){throw 'FINAL_STATE_VERSION_NOT_UPDATED'} + foreach($p in $protected.Keys){if((Hash $p) -cne $protected[$p]){throw ('PROTECTED_USER_DATA_CHANGED_'+[IO.Path]::GetFileName($p))}} + if((Startup) -cne $startupBefore){throw 'USER_STARTUP_PREFERENCE_CHANGED'} + if((Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.5.md')) -or !(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.6.md'))){throw 'OLD_NOTES_SURVIVED_NEW_NOTES_MISSING'} + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){throw 'PRODUCT_PROCESS_LEFT_RUNNING'} + $duplicates=@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced') + if($duplicates.Count -ne 1 -or (Get-ItemProperty $uninstall).InstallLocation -cne $app){throw 'DUPLICATE_INSTALL_ROOT_OR_REGISTRATION'} + $passes+=@('REAL_NATIVE_176_SETUP_AUTOMATICALLY_RECOVERED_KILLED_HELPER_JOURNAL','PRIVATE_DATA_AND_SINGLE_ROOT_PRESERVED') + $phaseNow='FINAL_ACCEPTANCE' + $accepted=[ordered]@{ + Schema=1;Revision='R282';Status='PASS_DISPOSABLE_CHILD_KILLED_THEN_OFFICIAL_INSTALLER_AUTO_RECOVERED' + SourceBranchCommit=$env:GITHUB_SHA;ExactOriginalReleaseSource=$releaseHead + Official175SetupSHA256=$sha175;Official176SetupSHA256=$sha176 + Official175MainSHA256=$main175;Official176MainSHA256=$main176 + Official175ManagedFixtureRowsExact=22;OriginalOfficial175InstallerFreshInstallPerformed=$false + HistoricalV175FilesExtractedFromPinnedPublishedInstallerResources=$true + PrivilegedSensorDriverInstallPerformedForTest=$false + HostLabel=$HostLabel;Scenario=$Phase;WindowStagedAndOwned=$true + StagingHelperExternalProcessKilled=$terminated + ActualSetupExecutableProcessKilled=$false;RealPhysicalMainsCut=$false + PhaseAtTermination=$Phase;ManagedComponentsPartiallyReplacedBeforeKill=$(if($partialReplacement){2}else{0}) + ProductionVersionedSetupReinvokedInFreshProcess=$true + NativeSetupAutomaticJournalRecoveryAndInPlaceUpgrade=$true + ExactProtectedUserDataSHA256Preserved=$true;SingleCanonicalUserRoot=$true + NoOriginalOwnerPcTouched=$true;NoNonTestProcessTerminated=$true + RecoveryJournalRemoved=$true;OriginalProcessWasIsolatedTestHelper=$true + ReceiptEvidenceGate=$passes;RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($accepted|ConvertTo-Json -Depth 9),[Text.UTF8Encoding]::new($false)) + $receiptWritten=$true + Write-Output ('TBME_R282_ABRUPT_PROCESS_RECOVERY=PASS HOST='+$HostLabel+' PHASE='+$Phase) +}catch{ + $reason=$_.Exception.ToString() + Write-Output ('TBME_R282_PHASE_FAILED='+$phaseNow+' ERROR='+$reason) + throw +}finally{ + if($stageProcess){ + try{ + $stageProcess.Refresh() + if(!$stageProcess.HasExited -and $env:GITHUB_ACTIONS -ceq 'true' -and $env:RUNNER_ENVIRONMENT -ceq 'github-hosted'){ + # This is ONLY the known disposable helper created by this script. + $stageProcess.Kill() + [void]$stageProcess.WaitForExit(10000) + } + }finally{$stageProcess.Dispose()} + } + if(-not $receiptWritten){ + $failed=[ordered]@{Schema=1;Revision='R282';Status='FAIL';Host=$HostLabel;Scenario=$Phase + Phase=$phaseNow;Reason=$reason;TestHelperKilled=$terminated;OriginalOwnerPcTouched=$false; + CurrentUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($receipt,($failed|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + } +} +if($LASTEXITCODE -ne 0){throw 'R282_RECEIPT_HAS_FAILURE'} From dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad Mon Sep 17 00:00:00 2001 From: Aliakbar Emadoddin <94546844+GOD13emad@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:23:40 +0330 Subject: [PATCH 013/135] fix: v1.7.7 retry-safe crash recovery and pinned v1.7.6 in-place update Actual v1.7.6 Setup crash left a partial .tbme-upgrade/old/Main.exe.restore; cold retry exited 34. Version 1.7.7 verifies exact owned journal then removes only known updater scratch and resumes using SHA-pinned previous snapshots. 22 exact previous official v1.7.6 managed SHA pins, one per-user root, new versioned readme/notes, no kernel driver or owner data mutation. Private source-head Build 38964870457 SUCCESS 3/3 including actual Setup kill on hosted B and deterministic abandoned scratch on A; PR general 38964979567 SUCCESS 27/27. Keep previous official immutable v1.7.6; current versioned test workflow runs on merged main. Physical AC loss explicitly excluded by owner; reporter issue #6 remains pending. --- .../audit-r284-candidate-restore-residue.yml | 135 ++++++++ .github/workflows/release-v176-acceptance.yml | 2 +- build/Build.ps1 | 10 +- build/Package-Signed-Setup.ps1 | 4 +- build/TaskbarMonitorEnhanced_Setup.csproj | 4 +- build/Test-NoPawnIO-OssRelease.ps1 | 6 +- ...est-R284-RestoreResidue-NativeRecovery.ps1 | 304 ++++++++++++++++++ build/Test-Release-Gates.ps1 | 14 +- build/Test-Signed-Payload-Contract.ps1 | 6 +- build/Test-Signed-Staging.ps1 | 6 +- build/Verify-Embedded-Main.ps1 | 2 +- build/Verify-Release-Metadata-R273.ps1 | 20 +- build/official-v176-managed.json | 121 +++++++ docs/releases/v1.7.7/INSTALLER_README.txt | 7 + docs/releases/v1.7.7/RELEASE_NOTES.md | 17 + installer/TaskbarMonitorEnhanced_Setup.cs | 96 ++++-- src/TaskbarMonitorEnhanced.cs | 10 +- src/WorkspaceSelfTest.cs | 2 +- 18 files changed, 701 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/audit-r284-candidate-restore-residue.yml create mode 100644 build/Test-R284-RestoreResidue-NativeRecovery.ps1 create mode 100644 build/official-v176-managed.json create mode 100644 docs/releases/v1.7.7/INSTALLER_README.txt create mode 100644 docs/releases/v1.7.7/RELEASE_NOTES.md diff --git a/.github/workflows/audit-r284-candidate-restore-residue.yml b/.github/workflows/audit-r284-candidate-restore-residue.yml new file mode 100644 index 00000000..a8e5d20a --- /dev/null +++ b/.github/workflows/audit-r284-candidate-restore-residue.yml @@ -0,0 +1,135 @@ +name: R284 private v1.7.7 candidate and exact interrupted Setup recovery + +on: + push: + branches: [fix/v1.7.7-restore-residue-r284, main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + candidate: + name: Build private unsigned v1.7.7 candidate (not a public release) + runs-on: windows-2025 + timeout-minutes: 25 + steps: + - name: Checkout exact candidate source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build hash-bound private Setup candidate + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows'){throw 'R284_BAD_HOSTED_SOURCE'} + ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R284_VERSIONED_BUILD_FAILED'} + $setup='build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.7.exe' + $main='build/_out/App/TaskbarMonitorEnhanced.exe' + if(!(Test-Path $setup) -or !(Test-Path $main)){throw 'R284_PRIVATE_BINARIES_MISSING'} + $record=[ordered]@{ + Schema=1;Status='PRIVATE_NOT_A_RELEASE';Commit=$env:GITHUB_SHA + Version='1.7.7';Private=$true;NotForPublicInstallation=$true + SetupSHA256=(Get-FileHash $setup).Hash + MainSHA256=(Get-FileHash $main).Hash + } + [IO.File]::WriteAllText('build/_out/CANDIDATE.json',($record|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + 'R284_CANDIDATE_COMPILED_AND_HASH_BOUND' + - name: Upload bounded candidate for disposable tests only + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R284-PRIVATE-NOT-PUBLIC-RELEASE + if-no-files-found: error + retention-days: 14 + path: | + build/_out/CANDIDATE.json + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.7.exe + build/_out/App/TaskbarMonitorEnhanced.exe + + cold-recovery: + name: Recovery exact Main candidate on isolated Windows ${{ matrix.host }} + needs: candidate + timeout-minutes: 18 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Exact candidate source checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Retrieve private candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R284-PRIVATE-NOT-PUBLIC-RELEASE + path: _r284_private_candidate + - name: Preflight script parser and strict disposable host + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC'){throw 'R284_UNSAFE_NON_DISPOSABLE_HOST'} + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R284_BAD_SOURCE_CHECKOUT'} + $p=(Resolve-Path build/Test-R284-RestoreResidue-NativeRecovery.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R284_PS51_SCRIPT_PARSER_INVALID'} + if(!(Test-Path build/official-v176-managed.json)){throw 'R284_PINNED_176_MISSING'} + $c=Get-Content _r284_private_candidate/CANDIDATE.json -Raw|ConvertFrom-Json + if($c.Commit -cne $env:GITHUB_SHA -or $c.Version -cne '1.7.7' -or + $c.Private -ne $true -or $c.NotForPublicInstallation -ne $true){throw 'R284_NOT_EXACT_PRIVATE_CANDIDATE'} + 'R284_GUEST_PRECONDITION_PASS' + - name: Force helper crash; prove residue recovery, and real Setup kill on B + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-R284-RestoreResidue-NativeRecovery.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + if($LASTEXITCODE -ne 0){throw 'R284_GUEST_TEST_FAILED'} + $p=Join-Path $env:RUNNER_TEMP ('R284_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R284_RECOVERY_RECEIPT.json') + if(!(Test-Path $p)){throw 'R284_GUEST_RECEIPT_MISSING'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R284' -or + $r.Status -cne 'PASS_EXACT_V177_SETUP_RETRY_AFTER_STRANDED_RESTORE' -or + $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.PreviousManifestRowsSHAAndSizeChecked -ne 22 -or + $r.StagingHelperActuallyKilled -ne $true -or + $r.StrandedUpdaterOwnedRestoreZeroByteProven -ne $true -or + $r.RecoveredAfterSecondNativeSetupRun -ne $true -or + $r.SingleCanonicalUserRootPreserved -ne $true -or + $r.ProtectedUserDataHashPreserved -ne $true -or + $r.JournalRemoved -ne $true -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.PhysicalMainsLossTriggered -ne $false){throw 'R284_RECEIPT_TYPED_PASS_FAILED'} + if('${{ matrix.host }}' -ceq 'B' -and + ($r.NativeSetupProcessActuallyKilled -ne $true -or + $r.NativeSetupManagedNoteDeletionWitnessed -ne $true)){throw 'R284_REAL_SETUP_KILL_NOT_WITNESSED'} + 'R284_GUEST_TYPED_RECOVERY_PASS' + - name: Preserve bounded guest evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R284-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R284_${{ matrix.host }}_${{ github.run_id }}/R284_RECOVERY_RECEIPT.json + ${{ runner.temp }}/R284_${{ matrix.host }}_${{ github.run_id }}/ARMED.json diff --git a/.github/workflows/release-v176-acceptance.yml b/.github/workflows/release-v176-acceptance.yml index d4e33b46..88da42c4 100644 --- a/.github/workflows/release-v176-acceptance.yml +++ b/.github/workflows/release-v176-acceptance.yml @@ -1,7 +1,7 @@ name: v1.7.6 - Full protected v1.7.5 upgrade and native acceptance on: push: - branches: [fix/v1.7.6-glass-thermal-alert-r272, main] + branches: [fix/v1.7.6-glass-thermal-alert-r272] workflow_dispatch: permissions: contents: read diff --git a/build/Build.ps1 b/build/Build.ps1 index 2536cd8d..8d10d8f0 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -92,12 +92,12 @@ $CanonicalTextResources=[ordered]@{ 'TaskbarMonitorSensorSupervisor.cs'=(Join-Path $Root 'src\sensors\TaskbarMonitorSensorSupervisor.cs') 'TBME_Setup_Elevated_Helper.ps1'=(Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1') 'LICENSE'=(Join-Path $Root 'LICENSE') - 'README.md'=(Join-Path $Root 'docs\releases\v1.7.6\INSTALLER_README.txt') + 'README.md'=(Join-Path $Root 'docs\releases\v1.7.7\INSTALLER_README.txt') 'AUTHORS.md'=(Join-Path $Root 'AUTHORS.md') 'COPYRIGHT_AND_ATTRIBUTION.md'=(Join-Path $Root 'COPYRIGHT_AND_ATTRIBUTION.md') 'AI_ASSISTED_DEVELOPMENT.md'=(Join-Path $Root 'AI_ASSISTED_DEVELOPMENT.md') 'THIRD_PARTY_NOTICES.md'=(Join-Path $Root 'THIRD_PARTY_NOTICES.md') - 'RELEASE_NOTES_v1.7.6.md'=(Join-Path $Root 'docs\releases\v1.7.6\RELEASE_NOTES.md') + 'RELEASE_NOTES_v1.7.7.md'=(Join-Path $Root 'docs\releases\v1.7.7\RELEASE_NOTES.md') 'UPSTREAM_REFERENCE_GPL_NOTICE.md'=(Join-Path $Root 'UPSTREAM_REFERENCE_GPL_NOTICE.md') 'TaskbarMonitorEnhanced_Setup.cs'=(Join-Path $Root 'installer\TaskbarMonitorEnhanced_Setup.cs') } @@ -107,7 +107,7 @@ foreach($entry in $CanonicalTextResources.GetEnumerator()){ Write-Host 'TBME_CANONICAL_TEXT_PAYLOAD=PASS' Build (Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj') -$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' if(!(Test-Path -LiteralPath $Setup)){throw 'Setup output missing.'} # SignPath Foundation requires uniform signed-artifact project and ProductVersion metadata. @@ -152,8 +152,8 @@ if([string]$v.Status -ne 'PASS' -or [int]$v.Resources -ne 25 -or $v.PawnIOBundle if($LASTEXITCODE -ne 0){throw 'Application contract suite failed.'} $manifest=[ordered]@{ - Version='1.7.6' - Build='V1_7_6_R273_GLASS_THERMAL_ALERT' + Version='1.7.7' + Build='V1_7_7_R284_RESTORE_SCRATCH_RECOVERY' GeneratedUtc=[datetime]::UtcNow.ToString('o') Dependencies=[ordered]@{ LibreHardwareMonitor=[ordered]@{Version=[string]$Lock.dependencies.LibreHardwareMonitor.version;Url=$LhmUrl;SHA256=$LhmSha} diff --git a/build/Package-Signed-Setup.ps1 b/build/Package-Signed-Setup.ps1 index aa16aae6..5a18eea2 100644 --- a/build/Package-Signed-Setup.ps1 +++ b/build/Package-Signed-Setup.ps1 @@ -10,10 +10,10 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $UnsignedMain=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' $StageBase=if($ContractFixtureOnly){Join-Path $Root 'artifacts\_r80_contract_only'}else{Join-Path $Root 'artifacts\signed\Setup\TO_SIGN'} -$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' $Proof=Join-Path $StageBase 'EMBEDDED_MAIN_PROOF.json' $Verification=Join-Path $StageBase 'SETUP_VERIFY.json' $CreatedStage=$false diff --git a/build/TaskbarMonitorEnhanced_Setup.csproj b/build/TaskbarMonitorEnhanced_Setup.csproj index 4da2acdc..cb19abb3 100644 --- a/build/TaskbarMonitorEnhanced_Setup.csproj +++ b/build/TaskbarMonitorEnhanced_Setup.csproj @@ -1,5 +1,5 @@ - net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.6_out\Setup\false + net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.7_out\Setup\false _package\TaskbarMonitorEnhanced.exefalse @@ -23,7 +23,7 @@ - + \ No newline at end of file diff --git a/build/Test-NoPawnIO-OssRelease.ps1 b/build/Test-NoPawnIO-OssRelease.ps1 index 06c6ac62..3a6e0cd4 100644 --- a/build/Test-NoPawnIO-OssRelease.ps1 +++ b/build/Test-NoPawnIO-OssRelease.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $LegacyDir=Join-Path $Root 'artifacts\_r85_forbidden_pawnio_fixture' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' -$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' $Main=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' $LegacyVerify=Join-Path $LegacyDir 'legacy_verify.json' $Reader=Join-Path $PSScriptRoot 'Verify-Embedded-Main.ps1' if(Test-Path -LiteralPath $LegacyDir){throw 'R85 UNSAFE RERUN: FORBIDDEN FIXTURE ALREADY EXISTS'} @@ -56,7 +56,7 @@ try { Write-Output ('R85_OSS_NO_PROPRIETARY_BUNDLE_PASS productionEmbedded=False negativeLegacyResources='+$v.Resources+' negativeRejected=True') } finally { if($created -and (Test-Path -LiteralPath $LegacyDir)){ - $allow=@('TaskbarMonitorEnhanced_Setup_1.7.6.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe.config','legacy_verify.json') + $allow=@('TaskbarMonitorEnhanced_Setup_1.7.7.exe','TaskbarMonitorEnhanced_Setup_1.7.7.exe.config','legacy_verify.json') $objects=@(Get-ChildItem -LiteralPath $LegacyDir -Force) if(@($objects | Where-Object {$_.PSIsContainer -or $_.Name -notin $allow}).Count -ne 0){ throw ('R85 DEGRADED: UNKNOWN FILE IN OWNED STAGING; PRESERVED '+$LegacyDir) diff --git a/build/Test-R284-RestoreResidue-NativeRecovery.ps1 b/build/Test-R284-RestoreResidue-NativeRecovery.ps1 new file mode 100644 index 00000000..3b6a05f6 --- /dev/null +++ b/build/Test-R284-RestoreResidue-NativeRecovery.ps1 @@ -0,0 +1,304 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Never execute an installer or kill a process on a personal/shared computer. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + ![Environment]::UserInteractive){throw 'R284_ONLY_NEW_DISPOSABLE_GITHUB_WINDOWS_VM_ALLOWED'} +Add-Type -AssemblyName System.IO.Compression.FileSystem +Add-Type -TypeDefinition 'using System.IO; public static class R284Bridge { public static void ReplaceNoBackup(string src,string dest) { File.Replace(src,dest,null); } }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$published176='D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0' +$publishedMain176='0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8' +$libSha='6EBC194316536BA61AF5BE24508AD9FCBB2ECC685E716C12E787C79530F66BF0' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$uninstall='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$out=Join-Path $env:RUNNER_TEMP ('R284_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $out 'R284_RECOVERY_RECEIPT.json' +$arm=Join-Path $out 'ARMED.json' +$childFile=Join-Path $out 'StageChild.ps1' +$predecessor=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$candidate=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$candidateMain=Join-Path $CandidateDir 'App\TaskbarMonitorEnhanced.exe' +$candidateManifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prevFixture=Join-Path $PSScriptRoot 'official-v176-managed.json' +$native=$null;$child=$null;$watcher=$null +$nativeWasKilled=$false;$documentWitness=$false;$residueInjected=$false +$phaseNow='INITIAL';$reason='';$accepted=$false +$gates=[Collections.Generic.List[string]]::new() +function Hash([string]$path){ + if(Test-Path -LiteralPath $path -PathType Leaf){return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash} + return 'MISSING' +} +function ProcessCount(){return @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count} +function StartupValue(){ + $key=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $key){return 'MISSING'} + $p=$key.PSObject.Properties['TaskbarMonitorEnhanced'] + return $(if($null -eq $p){'MISSING'}else{[string]$p.Value}) +} +function Run-Official([string]$exe,[string]$arguments){ + if($arguments -cne '/update /quiet'){throw 'R284_TEST_REFUSES_WRONG_ARGUMENTS'} + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=$exe;$si.Arguments=$arguments;$si.UseShellExecute=$false + $si.WorkingDirectory=(Split-Path $exe -Parent) + $p=[Diagnostics.Process]::Start($si) + if(!$p){throw 'R284_SETUP_NOT_LAUNCHED'} + try{ + if(!$p.WaitForExit(150000)){ + $p.Refresh() + if(!$p.HasExited){$p.Kill();[void]$p.WaitForExit(14000)} + throw 'R284_NATIVE_SETUP_TIMEOUT_ONLY_TEST_PROCESS_STOPPED' + } + return [int]$p.ExitCode + }finally{$p.Dispose()} +} +if((Test-Path $app) -or (Test-Path $uninstall) -or + (Test-Path $out) -or (ProcessCount) -ne 0){throw 'R284_GUEST_NOT_CLEAN'} +[IO.Directory]::CreateDirectory($out)|Out-Null +try{ + $phaseNow='PIN_CANDIDATE_AND_IMMUTABLE_PREDECESSOR' + $manifest=Get-Content $candidateManifest -Raw|ConvertFrom-Json + if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.7' -or + $manifest.Private -ne $true -or $manifest.NotForPublicInstallation -ne $true -or + (Hash $candidate) -cne $manifest.SetupSHA256 -or + (Hash $candidateMain) -cne $manifest.MainSHA256){throw 'R284_CANDIDATE_SOURCE_OR_SHA_MISMATCH'} + $candidateSha=[string]$manifest.SetupSHA256 + $mainSha=[string]$manifest.MainSHA256 + if($candidateSha -cnotmatch '^[A-F0-9]{64}$' -or $mainSha -cnotmatch '^[A-F0-9]{64}$'){throw 'R284_CANDIDATE_BAD_DIGEST'} + $env:GH_PROMPT_DISABLED='1' + & gh release download v1.7.6 -R $repo -p TaskbarMonitorEnhanced_Setup_1.7.6.exe -D $out + if($LASTEXITCODE -ne 0 -or (Hash $predecessor) -cne $published176){throw 'R284_PREVIOUS_OFFICIAL_RELEASE_DOWNLOAD_HASH_FAILED'} + $pin=Get-Content $prevFixture -Raw|ConvertFrom-Json + if($pin.Version -cne '1.7.6' -or $pin.ManagedCount -ne 22 -or + @($pin.Rows).Count -ne 22 -or $pin.PublishedSetupSHA256 -cne $published176 -or + $pin.PublishedMainSHA256 -cne $publishedMain176){throw 'R284_BAD_PRIOR_MANIFEST'} + $gates.Add('BOTH_OFFICIAL_AND_PRIVATE_CANDIDATE_ASSET_SHA_VERIFIED') + $phaseNow='RESTORE_PINNED_V176_GUEST_ROOT_WITHOUT_RUNNING_OLD_SETUP' + $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($predecessor)) + [IO.Directory]::CreateDirectory($app)|Out-Null + foreach($row in $pin.Rows){ + $rel=[string]$row.Path + if(!$rel -or [IO.Path]::IsPathRooted($rel) -or + $rel -match '(^|[/\\])\.\.([/\\]|$)' -or $rel -match '[:*?"<>|]'){ + throw 'R284_UNSAFE_PRIOR_RESOURCE_PATH' + } + $dest=[IO.Path]::GetFullPath((Join-Path $app $rel)) + if(!$dest.StartsWith(([IO.Path]::GetFullPath($app).TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R284_PREDECESSOR_RESOURCE_TRAVERSAL' + } + [IO.Directory]::CreateDirectory((Split-Path $dest -Parent))|Out-Null + if(Test-Path $dest){throw 'R284_DUPLICATE_PREVIOUS_MANAGED_RESOURCE'} + if($rel -ceq 'Uninstall.exe'){ + [IO.File]::Copy($predecessor,$dest) + }else{ + $embedded='Payload.'+[IO.Path]::GetFileName($rel) + $source=$asm.GetManifestResourceStream($embedded) + if(!$source){throw ('R284_PREVIOUS_EMBEDDED_RESOURCE_MISSING_'+$embedded)} + try{ + $target=[IO.File]::Open($dest,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$source.CopyTo($target)}finally{$target.Dispose()} + }finally{$source.Dispose()} + } + if((Hash $dest) -cne $row.SHA256 -or (Get-Item $dest).Length -ne $row.Bytes){ + throw ('R284_PREVIOUS_MANAGED_SHA_OR_LENGTH_WRONG_'+$rel) + } + } + $backendRoot=Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6' + $lhmArchive=Join-Path $out 'LibreHardwareMonitor-from-official-Setup.zip' + $stream=$asm.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + if(!$stream){throw 'R284_PRIOR_OFFICIAL_LHM_MISSING'} + try{ + $dst=[IO.File]::Open($lhmArchive,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($dst)}finally{$dst.Dispose()} + }finally{$stream.Dispose()} + [IO.Directory]::CreateDirectory($backendRoot)|Out-Null + [IO.Compression.ZipFile]::ExtractToDirectory($lhmArchive,$backendRoot) + if((Hash (Join-Path $backendRoot 'LibreHardwareMonitorLib.dll')) -cne $libSha){throw 'R284_LIBRARY_SHA_MISMATCH'} + $stateJson='{"App":"Taskbar Monitor Enhanced","Version":"1.7.6","PublicVersion":"1.7.6","ProductIdentity":"LOCKED","SensorLayerStatus":"READY","SensorLayerMode":"R284_PRIVATE_PINNED_FIXTURE","SensorLayerVersion":"0.9.6","R284Fixture":true}' + [IO.File]::WriteAllText((Join-Path $app 'install_state.json'),$stateJson,[Text.UTF8Encoding]::new($false)) + $reg=New-Item -Path $uninstall -Force -ErrorAction Stop + Set-ItemProperty -Path $uninstall -Name DisplayName -Value 'Taskbar Monitor Enhanced' + Set-ItemProperty -Path $uninstall -Name DisplayVersion -Value '1.7.6' + Set-ItemProperty -Path $uninstall -Name InstallLocation -Value $app + $reg.Dispose() + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $publishedMain176 -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $published176 -or + (ProcessCount) -ne 0){throw 'R284_PINNED_PREDECESSOR_NOT_VALID'} + $protected=@{} + $config=Join-Path $app 'config.json' + $backup=Join-Path $app 'config.json.bak' + $canary=Join-Path $app 'R284_PRIVATE_USER_CANARY.txt' + [IO.File]::WriteAllText($config,'{"Theme":"Retro Terminal","GlassMode":true,"Opacity":0,"EnableTemperatureAlerts":true,"EnableTemperatureNotifications":true,"R284":"PRESERVE"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"BackupCanary":"R284_PROTECTED"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($canary,'Only disposable R284 user content',[Text.UTF8Encoding]::new($false)) + foreach($p in @($config,$backup,$canary,(Join-Path $backendRoot 'LibreHardwareMonitorLib.dll'))){$protected[$p]=Hash $p} + $startup=StartupValue + $gates.Add('22_SHA_PINNED_PREDECESSOR_ROWS_AND_PROTECTED_SETTINGS') + $phaseNow='EXACT_CANDIDATE_STAGE_HELPER' + $childText=@' +param([string]$Setup,[string]$App,[string]$Signal) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows'){throw 'R284_CHILD_REFUSES_NONHOSTED'} +if((Get-FileHash $Setup).Hash -cne $env:R284_CANDIDATE_SETUP_SHA){throw 'R284_CHILD_CANDIDATE_SHA_DRIFT'} +if((Get-FileHash (Join-Path $App 'TaskbarMonitorEnhanced.exe')).Hash -cne '0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8'){throw 'R284_CHILD_PREVIOUS_SHA_DRIFT'} +if(Test-Path (Join-Path $App '.tbme-upgrade')){throw 'R284_CHILD_STAGE_ALREADY_EXISTS'} +Add-Type -TypeDefinition 'using System.IO; public static class R284FileSwap { public static void ReplaceOne(string a,string b){File.Replace(a,b,null);} }' +$assembly=[Reflection.Assembly]::LoadFrom($Setup) +$type=$assembly.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,NonPublic,Public' +$old='1.7.6';$channel='OFFICIAL_STABLE_V176' +$prior=[IO.File]::ReadAllText((Join-Path $App 'install_state.json')) +$legacy=$type.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@($old,$channel)) +$fresh=$type.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($prior)) +$fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($Setup) +[void]$type.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$prior,$old,$channel)) +[void]$type.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) +$stage=Join-Path $App '.tbme-upgrade\new' +$mainLive=Join-Path $App 'TaskbarMonitorEnhanced.exe' +[R284FileSwap]::ReplaceOne((Join-Path $stage 'TaskbarMonitorEnhanced.exe'),$mainLive) +$note='Docs/RELEASE_NOTES_v1.7.7.md' +$live=Join-Path $App $note +if(Test-Path $live){throw 'NEW_NOTE_ALREADY_EXISTS'} +[IO.File]::Move((Join-Path $stage $note),$live) +if((Get-FileHash $mainLive).Hash -cne $env:R284_CANDIDATE_MAIN_SHA){throw 'R284_PARTIAL_CANDIDATE_MAIN_NOT_PRESENT'} +[IO.File]::WriteAllText($Signal,([ordered]@{ChildPid=$PID;Phase='COMMITTING';ReplacedManagedCount=2;Utc=[datetime]::UtcNow.ToString('o')}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) +while($true){Start-Sleep -Seconds 2} +'@ + [IO.File]::WriteAllText($childFile,$childText,[Text.UTF8Encoding]::new($false)) + $env:R284_CANDIDATE_SETUP_SHA=$candidateSha + $env:R284_CANDIDATE_MAIN_SHA=$mainSha + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $si.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$childFile+'" -Setup "'+$candidate+'" -App "'+$app+'" -Signal "'+$arm+'"' + $si.UseShellExecute=$false;$si.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($si) + if(!$child){throw 'R284_CHILD_NOT_STARTED'} + $clock=[Diagnostics.Stopwatch]::StartNew() + while(!(Test-Path $arm) -and $clock.Elapsed.TotalSeconds -lt 55){ + $child.Refresh() + if($child.HasExited){throw ('R284_CHILD_FAIL_EXIT_'+$child.ExitCode)} + Start-Sleep -Milliseconds 75 + } + if(!(Test-Path $arm)){throw 'R284_CHILD_DID_NOT_ARM'} + $message=Get-Content $arm -Raw|ConvertFrom-Json + if($message.ChildPid -ne $child.Id -or $message.Phase -cne 'COMMITTING' -or $message.ReplacedManagedCount -ne 2){throw 'R284_ARMED_INVALID'} + $phase=Join-Path $stage 'phase.txt' + $currentNote=Join-Path $app 'Docs\RELEASE_NOTES_v1.7.7.md' + if((Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING' -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or !(Test-Path $currentNote)){ + throw 'R284_STAGE_NOT_PHYSICALLY_PARTIAL' + } + $child.Refresh();if($child.HasExited){throw 'R284_CHILD_EXITED_ALREADY'} + $child.Kill();if(!$child.WaitForExit(13000)){throw 'R284_CHILD_KILL_NOT_COMPLETE'} + $child.Dispose();$child=$null + $gates.Add('ACTUAL_ISOLATED_STAGING_HELPER_KILLED_WITH_TWO_MANAGED_REPLACEMENTS') + $residue=Join-Path $stage 'old\TaskbarMonitorEnhanced.exe.restore' + if(Test-Path $residue){throw 'R284_STRANDED_RESIDUE_ALREADY_EXISTS'} + if($HostLabel -ceq 'B'){ + $phaseNow='KILL_ACTUAL_OFFICIAL_CANDIDATE_SETUP_PROCESS_DURING_ROLLBACK' + $watcher=[IO.FileSystemWatcher]::new((Join-Path $app 'Docs'),'RELEASE_NOTES_v1.7.7.md') + $watcher.NotifyFilter=[IO.NotifyFilters]'FileName,LastWrite,Size,CreationTime' + $watcher.EnableRaisingEvents=$true + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=$candidate;$si.Arguments='/update /quiet';$si.UseShellExecute=$false;$si.WorkingDirectory=$out + $native=[Diagnostics.Process]::Start($si) + if(!$native){throw 'R284_REAL_SETUP_NOT_STARTED'} + $watchTime=[Diagnostics.Stopwatch]::StartNew() + while($watchTime.Elapsed.TotalSeconds -lt 40){ + $native.Refresh() + if($native.HasExited){throw ('R284_SETUP_FINISHED_BEFORE_KILLABLE_ROLLBACK_'+$native.ExitCode)} + $signal=$watcher.WaitForChanged([IO.WatcherChangeTypes]::All,45) + if($signal.TimedOut -or (Test-Path $currentNote)){continue} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + !(Test-Path $phase) -or (Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING'){ + throw 'R284_REAL_ROLLBACK_KILL_WINDOW_WAS_MISSED' + } + $native.Refresh();if($native.HasExited){throw 'R284_REAL_SETUP_EXITED_BEFORE_FORCE_TERMINATION'} + $native.Kill() + if(!$native.WaitForExit(18000)){throw 'R284_REAL_NATIVE_SETUP_DID_NOT_TERMINATE'} + $nativeWasKilled=$true;$documentWitness=$true + break + } + if(!$nativeWasKilled){throw 'R284_NO_VALID_REAL_SETUP_PROCESS_KILL_WITNESS'} + $watcher.EnableRaisingEvents=$false;$watcher.Dispose();$watcher=$null + $native.Dispose();$native=$null + if(!(Test-Path $stage) -or (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha){throw 'R284_KILL_NOT_LEFT_PARTIAL_STAGE'} + $gates.Add('REAL_UNMODIFIED_CANDIDATE_SETUP_PROCESS_KILLED_AFTER_DOCUMENT_DELETION') + } + $phaseNow='EXACT_KNOWN_OWNER_STAGE_STRANDED_TEMP_RESIDUE' + if(!(Test-Path $residue)){ + $copy=[IO.File]::Open($residue,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + $copy.Dispose() + $residueInjected=$true + } + if((Get-Item $residue).Length -ne 0 -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + (Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING'){ + throw 'R284_NOT_A_REPRODUCIBLE_STRANDED_ZERO_BYTE_OLD_MAIN_SCRATCH' + } + $gates.Add('ZERO_LENGTH_STRANDED_UPDATER_OWNED_RESTORE_FILE_CONFIRMED') + $phaseNow='NEW_VERSIONED_OFFICIAL_SETUP_COLD_RECOVERS' + $result=Run-Official $candidate '/update /quiet' + if($result -ne 0){throw ('R284_CANDIDATE_FAILED_TO_COLD_RECOVER_'+$result)} + if(Test-Path $stage){throw 'R284_JOURNAL_NOT_REMOVED'} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $candidateSha -or + (Get-ItemProperty $uninstall).DisplayVersion -cne '1.7.7'){throw 'R284_NEW_INSTALLED_APP_OR_SETUP_HASH_INVALID'} + $newState=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($newState.Version -cne '1.7.7' -or $newState.PublicVersion -cne '1.7.7'){throw 'R284_INSTALLED_PUBLIC_VERSION_INCORRECT'} + if((Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.6.md')) -or !(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.7.md'))){throw 'R284_VERSION_NOTES_UPGRADE_INVALID'} + foreach($p in $protected.Keys){if((Hash $p) -cne $protected[$p]){throw ('R284_PROTECTED_USER_FILE_CHANGED_'+$p)}} + if((StartupValue) -cne $startup){throw 'R284_USER_AUTOSTART_PREFERENCE_CHANGED'} + if((ProcessCount) -ne 0){throw 'R284_MAIN_PROGRAM_UNEXPECTEDLY_LAUNCHED'} + if(@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced').Count -ne 1){throw 'R284_MORE_THAN_ONE_PRODUCT_REGISTRATION'} + $gates.Add('EXACT_RELEASE_177_MAIN_SETUP_USER_CONFIG_AUTOSTART_ONE_ROOT_JOURNAL_REMOVED') + $result=[ordered]@{ + Schema=1;Revision='R284';Status='PASS_EXACT_V177_SETUP_RETRY_AFTER_STRANDED_RESTORE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;PreviousOfficialVersion='1.7.6' + PreviousOfficialSetupSHA256=$published176;PreviousOfficialMainSHA256=$publishedMain176 + PreviousManifestRowsSHAAndSizeChecked=22;PredecessorSetupProcessRun=$false;PrivilegedDriverInstalled=$false + CandidateVersion='1.7.7';CandidateSetupSHA256=$candidateSha;CandidateMainSHA256=$mainSha + StagedPhase='COMMITTING';ManagedFileReplacementsBeforeKill=2 + StagingHelperActuallyKilled=$true;NativeSetupProcessActuallyKilled=$nativeWasKilled + NativeSetupManagedNoteDeletionWitnessed=$documentWitness + StrandedUpdaterOwnedRestoreZeroByteProven=$true + StrandedScratchInjectedOnlyIfAbsent=$residueInjected + RecoveredAfterSecondNativeSetupRun=$true;ProtectedUserDataHashPreserved=$true + SingleCanonicalUserRootPreserved=$true;JournalRemoved=$true + PhysicalMainsLossTriggered=$false;OriginalOwnerComputerTouched=$false + PassedGates=@($gates);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($result|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R284_DISPOSABLE_GUEST_'+$HostLabel+'_INTERRUPTED_SETUP_RECOVERY=PASS' +}catch{ + $reason=$_.Exception.ToString() + Write-Output ('R284_GUEST_PHASE_FAILURE='+$phaseNow+' REASON='+$reason) + throw +}finally{ + if($watcher){$watcher.EnableRaisingEvents=$false;$watcher.Dispose()} + if($native){ + try{$native.Refresh();if(!$native.HasExited -and $env:RUNNER_ENVIRONMENT -cne 'owner'){$native.Kill();[void]$native.WaitForExit(12000)}}finally{$native.Dispose()} + } + if($child){ + try{$child.Refresh();if(!$child.HasExited -and $env:RUNNER_ENVIRONMENT -cne 'owner'){$child.Kill();[void]$child.WaitForExit(12000)}}finally{$child.Dispose()} + } + if(!$accepted){ + $bad=[ordered]@{Schema=1;Revision='R284';Status='FAIL';Host=$HostLabel;Phase=$phaseNow + Failure=$reason;NativeSetupWasKilled=$nativeWasKilled;WitnessedNativeDeletion=$documentWitness + StrandedResidueInjected=$residueInjected;UserHostTouched=$false;Utc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($bad|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-Release-Gates.ps1 b/build/Test-Release-Gates.ps1 index 3882a221..00f12a1b 100644 --- a/build/Test-Release-Gates.ps1 +++ b/build/Test-Release-Gates.ps1 @@ -44,7 +44,7 @@ if($null -ne $manifest) { 'TaskbarMonitorEnhanced.exe.config'='build\_out\App\TaskbarMonitorEnhanced.exe.config'; 'TaskbarMonitorSensorBroker.exe'='build\_out\Broker\TaskbarMonitorSensorBroker.exe'; 'TaskbarMonitorSensorSupervisor.exe'='build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe'; - 'TaskbarMonitorEnhanced_Setup_1.7.6.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' + 'TaskbarMonitorEnhanced_Setup_1.7.7.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' } $realHashesPass=$true foreach($item in $outputs){ @@ -168,7 +168,7 @@ Add-Gate 'G12_START_SEARCH_24_REAL_NATIVE_INK_AND_EXACT_RETURN' $startFloatPass # R75: G02 hashes deterministic UNSIGNED builder outputs. G14 and G15 must use # separate SIGNED release staging, since Authenticode modifies executable SHA256. $signedMainExe=Join-Path $root 'artifacts\signed\Main\TaskbarMonitorEnhanced.exe' -$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' $expectedSignedMainDigest='' if((Test-Path -LiteralPath $signedMainExe -PathType Leaf) -and (Test-Path -LiteralPath $signedSetupExe -PathType Leaf)){ @@ -233,7 +233,7 @@ Add-Gate 'G14_INSTALLED_TASKBAR_EXPLORER_SENSORS_ROLLBACK' $installedPass $insta $setupExe=$signedSetupExe $mainExe=$signedMainExe # Keep original _out builder outputs immutable so G02 exact source checks remain reproducible. -$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' # R73: G15 must prove actual SignPath Foundation publisher, valid timestamps, matched # exact file/product versions AND live provider-origin/payload evidence. Never trust # self-declared "approved" JSON, a test certificate or token-less synthetic output. @@ -253,10 +253,10 @@ if((Test-Path -LiteralPath $setupExe -PathType Leaf) -and (Test-Path -LiteralPat $sameCert=$null -ne $setupCert -and $null -ne $mainCert -and $setupCert.Thumbprint -ceq $mainCert.Thumbprint $exactMetadata=$setupInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and $mainInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and - $setupInfo.ProductVersion -ceq '1.7.6+r273' -and - $mainInfo.ProductVersion -ceq '1.7.6+r273' -and - $setupInfo.FileVersion -ceq '1.7.6.0' -and - $mainInfo.FileVersion -ceq '1.7.6.0' + $setupInfo.ProductVersion -ceq '1.7.7+r284' -and + $mainInfo.ProductVersion -ceq '1.7.7+r284' -and + $setupInfo.FileVersion -ceq '1.7.7.0' -and + $mainInfo.FileVersion -ceq '1.7.7.0' $cryptoPass=([string]$mainSig.Status -ceq 'Valid') -and ([string]$setupSig.Status -ceq 'Valid') -and $mainName -ceq 'SignPath Foundation' -and diff --git a/build/Test-Signed-Payload-Contract.ps1 b/build/Test-Signed-Payload-Contract.ps1 index 903e7af4..afd9be96 100644 --- a/build/Test-Signed-Payload-Contract.ps1 +++ b/build/Test-Signed-Payload-Contract.ps1 @@ -4,11 +4,11 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $original=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' $inputDir=Join-Path $Root 'artifacts\_r80_contract_input' $inputFile=Join-Path $inputDir 'TaskbarMonitorEnhanced.exe' $stageDir=Join-Path $Root 'artifacts\_r80_contract_only' -$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' $originalMainSHA=(Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash $originalSetupSHA=(Get-FileHash -LiteralPath $setupOrig -Algorithm SHA256).Hash if((Test-Path -LiteralPath $inputDir) -or (Test-Path -LiteralPath $stageDir)){ @@ -70,7 +70,7 @@ try{ # Cleanup exact owned files only. Unknown files survive; never delete personal state. foreach($d in @($stageDir,$inputDir)){ if(Test-Path -LiteralPath $d){ - $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.6.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} + $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.7.exe','TaskbarMonitorEnhanced_Setup_1.7.7.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} else{@('TaskbarMonitorEnhanced.exe')} $found=@(Get-ChildItem -LiteralPath $d -Force) if(@($found|Where-Object {$_.PSIsContainer -or $_.Name -notin $allowed}).Count -gt 0){ diff --git a/build/Test-Signed-Staging.ps1 b/build/Test-Signed-Staging.ps1 index 5a8d9eeb..1132fa03 100644 --- a/build/Test-Signed-Staging.ps1 +++ b/build/Test-Signed-Staging.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $stage=Join-Path $root 'artifacts\signed' $origMain=Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' $targetMain=Join-Path $stage 'Main\TaskbarMonitorEnhanced.exe' -$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe' +$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' $created=@() if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $origMain -PathType Leaf) -or @@ -26,7 +26,7 @@ if(!(Test-Path -LiteralPath $manifest -PathType Leaf)){throw 'R75 MANIFEST REQUI $m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json $mapping=@{ 'TaskbarMonitorEnhanced.exe'=$hashMain - 'TaskbarMonitorEnhanced_Setup_1.7.6.exe'=$hashSetup + 'TaskbarMonitorEnhanced_Setup_1.7.7.exe'=$hashSetup } foreach($entry in $m.Outputs){ if($mapping.ContainsKey([string]$entry.Name)){ diff --git a/build/Verify-Embedded-Main.ps1 b/build/Verify-Embedded-Main.ps1 index 96210126..4003f916 100644 --- a/build/Verify-Embedded-Main.ps1 +++ b/build/Verify-Embedded-Main.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference='Stop' if($ExpectedMainSHA256 -notmatch '^[0-9a-fA-F]{64}$'){throw 'R80 EXPECTED MAIN SHA NOT 64 HEX'} $path=[IO.Path]::GetFullPath($SetupExe) if(!(Test-Path -LiteralPath $path -PathType Leaf) -or - ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe'))){ + ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe','TaskbarMonitorEnhanced_Setup_1.7.7.exe'))){ throw 'R80 SETUP EXE MISSING OR WRONG FILE NAME' } $setupHash=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash diff --git a/build/Verify-Release-Metadata-R273.ps1 b/build/Verify-Release-Metadata-R273.ps1 index b1c2961e..003af0c2 100644 --- a/build/Verify-Release-Metadata-R273.ps1 +++ b/build/Verify-Release-Metadata-R273.ps1 @@ -1,4 +1,4 @@ -# R273 independent, read-only authority check for the versioned 1.7.6 +# R273 independent, read-only authority check for the versioned 1.7.7 # release Build Manifest -> deterministic binary files -> SBOM 2.3. [CmdletBinding()] param( @@ -22,20 +22,20 @@ $commit=(& git -C $root rev-parse HEAD).Trim() if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R273_CURRENT_HEAD_NOT_EXACT'} function Issues($manifest,$sbom,$setup,$sha){ $fail=[Collections.Generic.List[string]]::new() - if($manifest.Version -cne '1.7.6' -or $manifest.Build -cne 'V1_7_6_R273_GLASS_THERMAL_ALERT'){ + if($manifest.Version -cne '1.7.7' -or $manifest.Build -cne 'V1_7_7_R284_RESTORE_SCRATCH_RECOVERY'){ $fail.Add('WRONG_PUBLIC_BUILD_VERSION') } - if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.6' -or - $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.6/'+$sha)){ + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.7' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.7/'+$sha)){ $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') } if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or - $sbom.packages[0].versionInfo -cne '1.7.6'){ + $sbom.packages[0].versionInfo -cne '1.7.7'){ $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') } $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', - 'TaskbarMonitorEnhanced_Setup_1.7.6.exe') + 'TaskbarMonitorEnhanced_Setup_1.7.7.exe') $found=@($manifest.Outputs|ForEach-Object {$_.Name}) if(@($manifest.Outputs).Count -ne 5 -or @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ @@ -44,7 +44,7 @@ function Issues($manifest,$sbom,$setup,$sha){ foreach($row in $manifest.Outputs){ $name=[string]$row.Name $rel=switch($name){ - 'TaskbarMonitorEnhanced_Setup_1.7.6.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' {'build\_out\Setup\'+$name;break} 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} default {'build\_out\App\'+$name} @@ -68,9 +68,9 @@ function Issues($manifest,$sbom,$setup,$sha){ $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') } $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') - $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.6.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe') if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or - !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.6')){ + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.7')){ $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') } return @($fail.ToArray()) @@ -82,7 +82,7 @@ if($SelfTest){ @{Type='Manifest';Field='Version';Bad='1.7.0'}, @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, - @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.6/'+('0'*40))}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.7/'+('0'*40))}, @{Type='Setup';Field='PawnIOBundled';Bad=$true} ) foreach($mutation in $mutants){ diff --git a/build/official-v176-managed.json b/build/official-v176-managed.json new file mode 100644 index 00000000..3896e7f4 --- /dev/null +++ b/build/official-v176-managed.json @@ -0,0 +1,121 @@ +{ + "Schema": 1, + "Status": "PINNED_OFFICIAL_V176_OWNER_INSTALLED_MATCH_PUBLIC_SETUP_MAIN", + "Version": "1.7.6", + "SourceCommit": "2348791fbde3aff5a18b7df536dba8a66a65d191", + "PublishedSetupSHA256": "D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + "PublishedMainSHA256": "0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8", + "ManagedCount": 22, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8", + "Bytes": 636416 + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Bytes": 456 + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Bytes": 22172 + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "086B21052E050B56B753634918D5279C1CAFE4918FE8F2D4FB6F4DC05CF040D5", + "Bytes": 597062 + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Bytes": 15378 + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Bytes": 26913 + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "396AD7DA39674099999ADB4A9C98458A876A8DE5BFABB1DC640EE46BC2F63306", + "Bytes": 108873 + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Bytes": 22796 + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Bytes": 55753 + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "2A3C0152CB8727B8F5AD281AAAA730F12C8D6CBC667866C751C3B866026563B1", + "Bytes": 21949 + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Bytes": 28046 + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Bytes": 51057 + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "EFC7104361C855FBE40A673A7CFBD868AAFE663ADC4F465D80A69E8D0E771F7C", + "Bytes": 98969 + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Bytes": 35149 + }, + { + "Path": "Docs/README.md", + "SHA256": "9CCB3096C6697429527A24E0E2105E5CD7E6B76CEB39B70C8169570D8D796675", + "Bytes": 450 + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Bytes": 803 + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Bytes": 1174 + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Bytes": 674 + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Bytes": 2224 + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.6.md", + "SHA256": "D8CF4A1F6596A043E4718E787D0098E4A10ED2D996CEF8766FC268DB72E620E7", + "Bytes": 1664 + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Bytes": 811 + }, + { + "Path": "Uninstall.exe", + "SHA256": "D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + "Bytes": 8547328 + } + ] +} \ No newline at end of file diff --git a/docs/releases/v1.7.7/INSTALLER_README.txt b/docs/releases/v1.7.7/INSTALLER_README.txt new file mode 100644 index 00000000..764db749 --- /dev/null +++ b/docs/releases/v1.7.7/INSTALLER_README.txt @@ -0,0 +1,7 @@ +Taskbar Monitor Enhanced v1.7.7 +One per-user Windows installer. Updates the SHA-pinned official v1.7.6 in place, +preserving configuration, sensor state, charts, and startup preferences. +Fix: interrupted updater-owned .restore scratch no longer blocks a safe recovery. +Glass fully transparent backgrounds and semantic hot-temperature red remain. +External PawnIO not bundled. No automatic Defender exceptions or changes. +Unsigned publisher per accepted waiver. Issue #6 security reporter pending. diff --git a/docs/releases/v1.7.7/RELEASE_NOTES.md b/docs/releases/v1.7.7/RELEASE_NOTES.md new file mode 100644 index 00000000..6de52a4d --- /dev/null +++ b/docs/releases/v1.7.7/RELEASE_NOTES.md @@ -0,0 +1,17 @@ +# Taskbar Monitor Enhanced v1.7.7 — interrupted Setup recovery + +## Reliability fix +- The v1.7.6 Setup could leave an incomplete updater-owned `.tbme-upgrade/old/.restore` scratch if its own process was terminated mid-rollback. A later update attempt failed with `IOException: file already exists`. +- This build removes **only** known temporary `.restore` scratch files, after the journal owner marker and index have been validated; it then recreates them from the SHA-verified prior managed snapshots. +- Unknown, reparse-point, user-owned, or unrelated files are never erased. Existing rollback guards and preserved user settings remain intact. + +## Compatible upgrades +- Same per-user root; SHA-pinned official v1.7.6 predecessor is supported for transactional in-place upgrade. One versioned installer, no duplicate application root. +- Glass with true zero background alpha, red hot CPU/GPU/disk warnings, thresholds, chart readability, themes, and settings are retained. +- Optional signed external PawnIO sensor service remains separate. No new kernel driver is bundled. + +## Trust and qualification +- Setup is intentionally unsigned under the existing explicit release waiver. A valid hash/CI is not an independent malware certification. +- Reporter-specific WinRing0 driver attribution in Issue #6 remains open. +- Physical AC-interruption testing is out of the accepted scope and has not been performed. +- This release is a **candidate only** until exact-source build, native upgrade, regression, and public asset gates pass. diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index d88be781..aa295489 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -20,14 +20,14 @@ [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.6.0")] -[assembly: AssemblyFileVersion("1.7.6.0")] -[assembly: AssemblyInformationalVersion("1.7.6+r273")] +[assembly: AssemblyVersion("1.7.7.0")] +[assembly: AssemblyFileVersion("1.7.7.0")] +[assembly: AssemblyInformationalVersion("1.7.7+r284")] internal static partial class SetupProgram { const string Product="Taskbar Monitor Enhanced"; - const string Version="1.7.6"; + const string Version="1.7.7"; const string SensorProtocolVersion="1.1.2+r21"; const string SensorSupervisorVersion="1.3.0+r33"; const string Publisher="Dr. Ali-Akbar Emadeddin"; @@ -58,7 +58,7 @@ internal static partial class SetupProgram "Payload.COPYRIGHT_AND_ATTRIBUTION.md", "Payload.AI_ASSISTED_DEVELOPMENT.md", "Payload.THIRD_PARTY_NOTICES.md", - "Payload.RELEASE_NOTES_v1.7.6.md", + "Payload.RELEASE_NOTES_v1.7.7.md", "Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md", "Payload.TaskbarMonitorEnhanced_Setup.cs" }; @@ -228,8 +228,8 @@ static void WriteInstallState(SensorOutcome sensorOutcome) string sensorMode=sensorOutcome==null?"":(sensorOutcome.LayerMode??""); string json="{\r\n"+ " \"App\": \"Taskbar Monitor Enhanced\",\r\n"+ - " \"Version\": \"1.7.6\",\r\n"+ - " \"PublicVersion\": \"1.7.6\",\r\n"+ + " \"Version\": \"1.7.7\",\r\n"+ + " \"PublicVersion\": \"1.7.7\",\r\n"+ " \"InternalRuntimeBaseline\": \"V1_1_2_R21_PRODUCTION_HARDENING\",\r\n"+ " \"SensorSupervisor\": \"V1_3_0_R33_STORAGE_COMPLETION_HARDENING\",\r\n"+ " \"SensorLayerStatus\": \""+sensorStatus.Replace("\\","\\\\").Replace("\"","\\\"")+"\",\r\n"+ @@ -445,7 +445,7 @@ static bool IsCurrentProcessElevated() static SensorOutcome Install(bool desktop,bool startup) { if(!Environment.Is64BitOperatingSystem) - throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.6 requires 64-bit Windows."); + throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.7 requires 64-bit Windows."); // R114: prevent fresh-install code from ever overwriting an installed // product or user-owned files. Existing roots must use /update. @@ -475,7 +475,7 @@ static SensorOutcome Install(bool desktop,bool startup) string[] docs=new string[]{ "LICENSE","README.md","AUTHORS.md","COPYRIGHT_AND_ATTRIBUTION.md", "AI_ASSISTED_DEVELOPMENT.md","THIRD_PARTY_NOTICES.md", - "RELEASE_NOTES_v1.7.6.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" + "RELEASE_NOTES_v1.7.7.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" }; foreach(string doc in docs) Extract("Payload."+doc,Path.Combine(AppRoot,"Docs",doc)); @@ -619,7 +619,7 @@ static int Verify(string path) } if(!String.IsNullOrEmpty(path)){ string json="{\"Status\":\"PASS\",\"Resources\":"+(RequiredResources.Length+(pawnBundled?1:0))+ - ",\"Version\":\"1.7.6\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ + ",\"Version\":\"1.7.7\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ ",\"SensorArchitecture\":\"R21_PROCESS_ISOLATED\",\"SensorUpgradePolicy\":\"EXACT_R33_SUPERVISOR_R21_PROTOCOL\""+ ",\"PawnIOBundled\":"+(pawnBundled?"true":"false")+",\"EmbeddedMainSHA256\":\""+embeddedMainSHA256+"\",\"EmbeddedMainBytes\":"+embeddedMainBytes+"}"; File.WriteAllText(path,json,Encoding.UTF8); @@ -658,7 +658,7 @@ public SetupForm() Icon=Icon.ExtractAssociatedIcon(Application.ExecutablePath); Label title=new Label(); - title.Text=Product+" 1.7.6"; + title.Text=Product+" 1.7.7"; title.Font=new Font(Font.FontFamily,18,FontStyle.Bold); title.Left=28;title.Top=22;title.AutoSize=true;Controls.Add(title); @@ -693,12 +693,12 @@ public SetupForm() progress.Visible=false; status.Text="Installation completed."; if(outcome.IsHealthy){ - MessageBox.Show(Product+" 1.7.6 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", + MessageBox.Show(Product+" 1.7.7 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", "Setup complete",MessageBoxButtons.OK,MessageBoxIcon.Information); }else{ string extra=outcome.RebootRequired ? "\r\n\r\nRestart Windows, then use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors if needed." : "\r\n\r\nThe application is installed and usable. The sensor supervisor continues in the background. If protected CPU/GPU/storage telemetry is still unavailable after a short wait or restart, use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors."; - MessageBox.Show(Product+" 1.7.6 was installed successfully.\r\n\r\n"+outcome.Message+extra, + MessageBox.Show(Product+" 1.7.7 was installed successfully.\r\n\r\n"+outcome.Message+extra, "Setup complete - sensor warning",MessageBoxButtons.OK,MessageBoxIcon.Warning); } Close(); @@ -836,6 +836,9 @@ internal static partial class SetupProgram const string InstalledV173="1.7.3"; const string InstalledV174="1.7.4"; const string InstalledV175="1.7.5"; + const string InstalledV176="1.7.6"; + const string OfficialV176SetupSHA="D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0"; + const string OfficialV176MainSHA="0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8"; const string OfficialV175SetupSHA="8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC"; const string OfficialV175MainSHA="25E0565301963467BF290908BE965F73EDDE6BF41EC693A4FE45ED19653FBDB6"; const string OfficialV174SetupSHA="C00F0C944E4BFBEE93989E4B5A29931D460CC0011ABDC8FDE6A498E467BCC1A1"; @@ -1072,6 +1075,32 @@ internal static partial class SetupProgram "Uninstall.exe|8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC", }; + // Official immutable v1.7.6 predecessor: exact 22 managed paths and SHA. + static readonly string[] OfficialStableV176Rows=new string[] { + "TaskbarMonitorEnhanced.exe|0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|086B21052E050B56B753634918D5279C1CAFE4918FE8F2D4FB6F4DC05CF040D5", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|396AD7DA39674099999ADB4A9C98458A876A8DE5BFABB1DC640EE46BC2F63306", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|2A3C0152CB8727B8F5AD281AAAA730F12C8D6CBC667866C751C3B866026563B1", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|EFC7104361C855FBE40A673A7CFBD868AAFE663ADC4F465D80A69E8D0E771F7C", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|9CCB3096C6697429527A24E0E2105E5CD7E6B76CEB39B70C8169570D8D796675", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.6.md|D8CF4A1F6596A043E4718E787D0098E4A10ED2D996CEF8766FC268DB72E620E7", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + }; + sealed class UpgradeTarget { internal readonly string Rel; @@ -1099,7 +1128,7 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/COPYRIGHT_AND_ATTRIBUTION.md","Payload.COPYRIGHT_AND_ATTRIBUTION.md"), new UpgradeTarget("Docs/AI_ASSISTED_DEVELOPMENT.md","Payload.AI_ASSISTED_DEVELOPMENT.md"), new UpgradeTarget("Docs/THIRD_PARTY_NOTICES.md","Payload.THIRD_PARTY_NOTICES.md"), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.6.md","Payload.RELEASE_NOTES_v1.7.6.md"), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.7.md","Payload.RELEASE_NOTES_v1.7.7.md"), new UpgradeTarget("Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md","Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md"), new UpgradeTarget("Uninstall.exe","@SELF"), new UpgradeTarget("install_state.json","@INSTALLSTATE"), @@ -1110,7 +1139,8 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.2.md",null), new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md",null), new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.4.md",null), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.5.md",null) // previous version, guarded byte-for-byte + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.5.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.6.md",null) // previous version, guarded byte-for-byte }; static string UpgradeHash(string path) @@ -1140,6 +1170,8 @@ static Dictionary LegacyExpected(string version,string channel) { rows=OfficialStableV172Rows; expectedSetup=OfficialV172SetupSHA; count=23; } else if(version==InstalledV173 && channel=="OFFICIAL_STABLE_V173") { rows=OfficialStableV173Rows; expectedSetup=OfficialV173SetupSHA; count=23; } + else if(version==InstalledV176 && channel=="OFFICIAL_STABLE_V176") + { rows=OfficialStableV176Rows; expectedSetup=OfficialV176SetupSHA; count=22; } else if(version==InstalledV175 && channel=="OFFICIAL_STABLE_V175") { rows=OfficialStableV175Rows; expectedSetup=OfficialV175SetupSHA; count=22; } else if(version==InstalledV174 && channel=="OFFICIAL_STABLE_V174") @@ -1244,7 +1276,7 @@ static string NewInstallState(string source) throw new InvalidDataException("Old install state Version/PublicVersion mismatch."); string prior=installed.Groups["v"].Value; if(prior==Version)return source; - if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173 && prior!=InstalledV174 && prior!=InstalledV175) + if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173 && prior!=InstalledV174 && prior!=InstalledV175 && prior!=InstalledV176) throw new InvalidDataException("Prior install state version not a pinned officially allowed ancestor."); string next=ReplaceStateVersion(source,"Version",prior); next=ReplaceStateVersion(next,"PublicVersion",prior); @@ -1270,7 +1302,7 @@ static Dictionary PrepareNewBytes(string previousState) if(bytes.Length<1 || fresh.ContainsKey(t.Rel))throw new InvalidDataException("Duplicate/empty payload "+t.Rel); fresh.Add(t.Rel,bytes); } - if(fresh.Count!=UpgradeTargets.Length-8) + if(fresh.Count!=UpgradeTargets.Length-9) throw new InvalidDataException("Upgrade static target map is invalid."); return fresh; } @@ -1382,7 +1414,7 @@ static void StageNewManaged(Dictionary fresh, Dictionary rows) string priorVersionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); if(!File.Exists(priorVersionPath))throw new IOException("Previous upgrade version missing; retain stage."); string priorVersion=File.ReadAllText(priorVersionPath,Encoding.ASCII); - if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=InstalledV174 && priorVersion!=InstalledV175 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=InstalledV174 && priorVersion!=InstalledV175 && priorVersion!=InstalledV176 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + // R284: an interrupted File.Copy(old, old/rel.restore, false) can + // leave only a partial updater-owned scratch. A retry must recreate + // only these exact scratch paths AFTER owner/index verification. + // Unknown files and reparse paths remain rejected. + foreach(StageEntry row in rows){ + if(row.Previous=="-")continue; + string scratch=Stage("old",row.Rel+".restore"); + if(File.Exists(scratch)){ + CheckNoReparse(scratch); + File.Delete(scratch); + } + } // This works after a mid-commit process kill: exact old snapshots are persisted // on the same volume, and new-only files are removed only if their SHA is known. for(int i=rows.Count-1;i>=0;i--){ @@ -1522,8 +1566,8 @@ static SensorOutcome UpgradeExisting() CheckSingleCanonicalRoot(); string installed=CurrentInstalledVersion(); string newSha=Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"); - if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=InstalledV174 && installed!=InstalledV175 && installed!=Version) - throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3/v1.7.4/v1.7.5 may upgrade in place."); + if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=InstalledV174 && installed!=InstalledV175 && installed!=InstalledV176 && installed!=Version) + throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3/v1.7.4/v1.7.5/v1.7.6 may upgrade in place."); string channel="OFFICIAL_V16"; string currentMainSHA=UpgradeHash(AppExe); string installedSetup=UPath("Uninstall.exe"); @@ -1541,7 +1585,8 @@ static SensorOutcome UpgradeExisting() !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.2.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.3.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.4.md")) && - !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.5.md"))) + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.5.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.6.md"))) return ExistingSensorOutcome(); throw new InvalidOperationException("Unknown current-version maintenance revision; no files changed."); } @@ -1580,6 +1625,13 @@ static SensorOutcome UpgradeExisting() channel="OFFICIAL_STABLE_V174"; } + if(installed==InstalledV176) + { + if(currentMainSHA!=OfficialV176MainSHA || oldSetupSHA!=OfficialV176SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.6 Main/Setup identity; NO mutation."); + channel="OFFICIAL_STABLE_V176"; + } + if(installed==InstalledV175) { if(currentMainSHA!=OfficialV175MainSHA || oldSetupSHA!=OfficialV175SetupSHA) diff --git a/src/TaskbarMonitorEnhanced.cs b/src/TaskbarMonitorEnhanced.cs index 29a2b31d..23397beb 100644 --- a/src/TaskbarMonitorEnhanced.cs +++ b/src/TaskbarMonitorEnhanced.cs @@ -33,18 +33,18 @@ [assembly: AssemblyDescription("Live system monitor integrated into the Windows taskbar")] [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyInformationalVersion("1.7.6+r273")] +[assembly: AssemblyInformationalVersion("1.7.7+r284")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.6.0")] -[assembly: AssemblyFileVersion("1.7.6.0")] +[assembly: AssemblyVersion("1.7.7.0")] +[assembly: AssemblyFileVersion("1.7.7.0")] namespace TaskbarMonitorEnhanced { internal static class BuildInfo { - public const string Version = "V1_7_6_R273_GLASS_THERMAL_ALERT"; + public const string Version = "V1_7_7_R284_RESTORE_SCRATCH_RECOVERY"; public const string Product = "Taskbar Monitor Enhanced"; - public const string PublicVersion = "1.7.6"; + public const string PublicVersion = "1.7.7"; public const string ShortcutName = "Taskbar Monitor Enhanced"; public const string ProductDescription = "Live system monitor integrated into the Windows taskbar"; public const string Author = "Dr. Ali-Akbar Emadeddin"; diff --git a/src/WorkspaceSelfTest.cs b/src/WorkspaceSelfTest.cs index 8ab22b1c..874e16ae 100644 --- a/src/WorkspaceSelfTest.cs +++ b/src/WorkspaceSelfTest.cs @@ -126,7 +126,7 @@ internal static int Run() { try { - Check(BuildInfo.PublicVersion=="1.7.6","version"); + Check(BuildInfo.PublicVersion=="1.7.7","version"); for(int schema=0;schema<7;schema++){ var legacy=new AppConfig{ConfigSchemaVersion=schema,EnableTemperatureNotifications=true,CpuTempWarningC=0,FontSize=Double.NaN,Opacity=Double.NaN};legacy.Normalize(); Check(legacy.ConfigSchemaVersion==8,"all legacy schemas migrate "+schema); From 248745d1a3765024f91f9ca0d524b404a01cdc2e Mon Sep 17 00:00:00 2001 From: Aliakbar Emadoddin <94546844+GOD13emad@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:28:28 +0330 Subject: [PATCH 014/135] docs: mirror accepted v1.7.7 R285 release and owner scope Existing public Index, Brain and Knowledge redacted summaries updated in place. Historical engineering records preserved, v1.7.7 immutable and real one-root owner update confirmed. Physical AC testing excluded at owner request; reporter Issue #6 and live OS notification audio remain externally unverified. No binaries or release files modified. --- docs/project-control/CURRENT_PROJECT.json | 60 ++++++++++++------- docs/project-control/PROJECT_BRAIN.md | 16 ++--- .../PROJECT_KNOWLEDGE_EVIDENCE.md | 9 +++ 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/docs/project-control/CURRENT_PROJECT.json b/docs/project-control/CURRENT_PROJECT.json index b4114f2d..5b182918 100644 --- a/docs/project-control/CURRENT_PROJECT.json +++ b/docs/project-control/CURRENT_PROJECT.json @@ -2,18 +2,18 @@ "Schema": 1, "Project": "TaskbarMonitorEnhanced", "AuthorityIndex": "docs/project-control/PROJECT_BRAIN.md", - "Scope": "PUBLIC_REDACTED_SNAPSHOT; owner-local detailed Project Brain is the only active operational authority", - "CurrentEngineeringRevision": "R276", - "Status": "PUBLIC_IMMUTABLE_UNSIGNED_V176_OWNER_GLASS_ALERT_SCOPED_PASS", - "UpdatedUtc": "2026-10-16T17:00:48Z", + "Scope": "Redacted public summary only, not the owner-local authoritative Project Brain. Official single per-user product release accepted under unsigned waiver; machinewide singleton not asserted.", + "CurrentEngineeringRevision": "R285", + "Status": "PUBLIC_REDACTED_MIRROR_R285_IMMUTABLE_V177_REAL_OWNER_INPLACE_SCOPED_DELIVERED", + "UpdatedUtc": "2026-10-16T21:48:03.585645+00:00", "FinalObjective": "Preserve v1.6 taskbar behavior with user-enabled zero-background Glass Mode, correct Start/Settings/fullscreen handling, safe in-place upgrades, a single per-user installation root, and reliable low-overhead sensors.", "CurrentAcceptedBaseline": { - "Version": "1.7.6", - "ReleaseId": 415147285, - "ReleaseURL": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.6", - "SourceCommit": "2348791fbde3aff5a18b7df536dba8a66a65d191", - "SetupSHA256": "D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", - "InstalledMainSHA256": "0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8", + "Version": "1.7.7", + "ReleaseId": 415382091, + "ReleaseURL": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.7", + "SourceCommit": "dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad", + "SetupSHA256": "395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D", + "InstalledMainSHA256": "A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253", "PublicAssetCount": 7, "InstallerUnsigned": true, "PerUserInstall": true, @@ -22,7 +22,15 @@ "OwnerHighCpuRealRedPixelsConfirmed": true, "OwnerWindowsNotificationsOptedIn": true, "OwnerApplicationNotificationDispatchLogged": true, - "ActualOsToastOrSoundProven": false + "ActualOsToastOrSoundProven": false, + "OwnerLiveV177VersionObserved": true, + "MainBuildCI": 38965519302, + "NativeHostedActualSetupKillRecoveryCI": 38965519445, + "IndependentPublicSevenAssetG16Passed": true, + "OwnerPerUserUpgradeRealExitZero": true, + "OwnerOldVersionRecoverable26Files": true, + "HistoricalRedPixelProofVersion": "1.7.6", + "Current177ActualRedPixelsIndependentlyCounted": false }, "Completed": [ "Seven immutable public v1.7.5 assets independently SHA/Manifest verified (R267)", @@ -32,18 +40,17 @@ "Owner current-user scheduled-task principal and all four live process SIDs matched; installed hashes retained (R270)", "R272-R274 semantic Glass red-warning compositor correction across 98 actual bitmap tests; safe official v1.7.5→v1.7.6 transactional update and rollback on two hosted Windows A/B.", "R275 immutable public unsigned v1.7.6: one Setup, seven assets independently SHA/manifest verified by live G16; exact merged-main CI and Windows A/B SUCCESS.", - "R276 owner official same-root 1.7.6 update with private 26-entry preupgrade recovery, real hot CPU Glass 3243 red pixels, Windows notification opt-in plus app dispatch, 10/10 healthy sensors." + "R276 owner official same-root 1.7.6 update with private 26-entry preupgrade recovery, real hot CPU Glass 3243 red pixels, Windows notification opt-in plus app dispatch, 10/10 healthy sensors.", + "R285 public mirror: v1.7.7 immutable seven assets and exact-source CI, real owner 1.7.6→1.7.7 same-root update, live CPU threshold dispatch, old-version backup; no unproven audio/security/global claims." ], "Open": [ "ISSUE6_REPORTER_DRIVER_SHA_AND_INSTALL_ORIGIN_MISSING", - "CONTROLLED_PHYSICAL_MAINS_LOSS_NOT_TESTED", - "ONE_PER_MACHINE_POLICY_REQUIRES_EXPLICIT_OWNER_DECISION; one per Windows user verified", - "INDEPENDENT_SECURITY_SIGNING_LAB_NOT_PROVEN; intentionally unsigned release accepted" + "OWNER_APP_OS_NOTIFICATION_BANNER_AND_SOUND_NOT_INDEPENDENTLY_WITNESSED" ], - "CriticalPath": "Maintain immutable v1.7.6 and the existing per-user single-root installation; red thermal warnings and optional Windows notifications confirmed at app level. External dependencies remain reporter's WinRing0 driver hash/install origin (Issue #6), controlled actual physical AC power-cut, and optional machinewide singleton policy. Unsigned publisher waiver accepted.", - "MainBlocker": "The requested Glass thermal warning repair is installed and scoped PASS. Only reporter-specific vulnerable-driver attribution, real physical mains-loss recovery and machinewide singleton design/policy remain outside acceptance. Actual OS toast/audio and independent security signing lab were not certified.", - "BrainStatus": "PUBLIC_SUMMARY_R276; owner-local detailed Project Brain is sole active operational authority", - "ExactNextAction": "Maintain installed v1.7.6 unchanged; obtain original reporter WinRing0 driver SHA/source without restoring quarantined malware, test physical power loss only on an isolated authorized rig, and decide machinewide vs accepted per-user policy only if required.", + "CriticalPath": "Immutable v1.7.7 public source dc68cf4 and seven SHA-verified assets PASS; native Windows A/B actual Setup interruption/recovery and 27-step Build CI PASS; owner same-root per-user 1.7.6 to 1.7.7 real upgrade and live Glass/alert dispatch verified. Reporter Issue #6 and OS audible/visible notification independent witness remain external. Owner excluded physical AC interruption.", + "MainBlocker": "No in-scope released app blocker. Reporter-specific WinRing0 source/hash is unavailable (Issue #6); an independent live owner-app OS notification banner/audio witness is also absent. Neither optional global multiuser policy nor waived signing nor excluded AC outage blocks the current per-user release.", + "BrainStatus": "PUBLIC_REDACTED_DERIVATIVE_R285_NOT_A_SECOND_OPERATIONAL_BRAIN", + "ExactNextAction": "Use the one official immutable v1.7.7 Setup for new per-user installations or supported in-place updates. Never bypass Defender; original reporter WinRing0 driver SHA/download path requested in Issue #6. Verify owner-app OS banner/speaker on a natural threshold event without heating hardware. No physical AC test is required.", "PublicTracker": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7", "SecurityTracker": "https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6", "HistoricalR40Record": { @@ -51,5 +58,18 @@ "Path": "docs/project-control/CURRENT_PROJECT.json", "GitBlob": "95e4d549d855de787a9274888bc686e3fda54d20", "Role": "immutable historical evidence, superseded by current R276" - } + }, + "HistoricalOfficialV176": { + "Version": "1.7.6", + "ReleaseId": 415147285, + "SourceCommit": "2348791fbde3aff5a18b7df536dba8a66a65d191", + "SetupSHA256": "D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", + "MainSHA256": "0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8" + }, + "AcceptedScopeExclusions": [ + "PHYSICAL_AC_MAINS_INTERRUPTION_TEST_REMOVED_BY_OWNER", + "MACHINEWIDE_MULTISUPERUSER_GLOBAL_SINGLETON_NOT_REQUESTED", + "AUTHENTICODE_PUBLISHER_SIGNING_WAIVED_FOR_UNSIGNED_RELEASE", + "INDEPENDENT_SECURITY_LAB_CLEARANCE_NOT_CLAIMED" + ] } diff --git a/docs/project-control/PROJECT_BRAIN.md b/docs/project-control/PROJECT_BRAIN.md index 10744de3..892e92b7 100644 --- a/docs/project-control/PROJECT_BRAIN.md +++ b/docs/project-control/PROJECT_BRAIN.md @@ -1,17 +1,17 @@ - -# Current public release status — R276 / v1.7.6 (2026-10-16) + +# Current public release summary — R285 / official v1.7.7 (2026-10-17) -The immutable **latest official v1.7.6** [release](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.6) (ID `415147285`) has exact source `2348791fbde3aff5a18b7df536dba8a66a65d191`. The **single intentionally unsigned** Setup SHA256 is `D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0`; the owner-installed Main SHA256 is `0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8`. Exactly **seven immutable public assets** were independently re-downloaded and validated with the release Manifest, SHA256SUMS and exact-main CI. This is artifact and tested-behavior evidence, **not** signed-publisher or independent malware certification. +Official immutable [v1.7.7 release](https://github.com/GOD13emad/TaskbarMonitorEnhanced/releases/tag/v1.7.7), ID 415382091, source commit dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad. Single intentionally unsigned Setup SHA256 395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D; shipped/owner Main SHA256 A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253. Exactly seven public assets independently re-downloaded against Manifest, SHA256SUMS and G16. This verifies content/publication, **not** independent malware certification. -**Glass temperature warnings:** In v1.7.5 the monochrome Glass compositor erased high-temperature red; v1.7.6 preserves semantic red for hot CPU/GPU/disk cards (text/chart/border) with true zero background alpha and readable non-hot themes. The owner upgraded official v1.7.5 **in the same per-user installation root** with a hash-verified 26-entry preupgrade recovery archive. On the real owner taskbar when CPU was **90°C**, above the 85°C threshold, **3,243 warning-red pixels** were observed. The Windows temperature-notification preference was enabled through Settings > Alerts and a CPU warning dispatch appears in the application log; real OS balloon/audio presentation was **not independently witnessed**. +**Interruption bug fixed:** Real old v1.7.6 Setup process was killed during rollback on a disposable Windows guest; the retry failed due to abandoned owned .restore scratch. Versioned 1.7.7 safely recovers only known updater-owned transient scratch after verifying the journal/index and old snapshots. Exact-main [Build 38965519302](https://github.com/GOD13emad/TaskbarMonitorEnhanced/actions/runs/38965519302) PASS 27 steps; [hosted native Windows A/B 38965519445](https://github.com/GOD13emad/TaskbarMonitorEnhanced/actions/runs/38965519445) PASS including a real Setup-process kill in guest B. Real AC mains interruption was not tested and has been **excluded by the owner**. -**Independent validation:** Exact-main [Build and Verification](https://github.com/GOD13emad/TaskbarMonitorEnhanced/actions/runs/38928203915) and [two hosted Windows A/B real official 1.7.5→1.7.6 transactional upgrades/rollback, native UI, fresh/install safety](https://github.com/GOD13emad/TaskbarMonitorEnhanced/actions/runs/38928203956) all passed. Owner live process and sensor topology was healthy for 10/10 samples (one Main, one Supervisor, two Brokers). Passive 60.436-second Main CPU sample: 7.342% of one logical core, **unpaired**, not a comparative performance claim. +**Actual owner system:** The verified existing v1.7.6 per-user installation was updated in place with the official 1.7.7 Setup, Exit 0, and launched again. A preupgrade 26-file SHA-verified protected backup was preserved; exact five build-output hashes, one Main/one Supervisor/two Brokers, one HKCU registration, no extra HKLM install and cleared journal were verified. Glass true zero background alpha, red threshold warning settings and Windows notification preference remain enabled. v1.7.7 log records a real CPU 95°C warning dispatch at configured 85°C; direct actual OS banner/audio was **not independently witnessed**. Earlier R276 physical red pixels were verified on v1.7.6, and current behavior is retained by compiled regression/owner settings; no false claim of a new v1.7.7 physical pixel count. -**Remaining independent/external limitations:** [Defender WinRing0 security Issue #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) still lacks the original reporter driver SHA/install origin; do not dismiss the legitimate detection or disable Defender. Controlled real physical AC-mains interruption remains untested. Installation is one root **per Windows user**, not a machinewide singleton. Public signing/security lab attestation is absent under the accepted unsigned-release waiver. +**Scope and residual limits:** [Reporter WinRing0 issue #6](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/6) still needs original file SHA256/install source, and a signed local PawnIO version does not dismiss that separate reporter warning. Publisher signing is intentionally waived. One **per-user** installation is accepted; no machinewide global singleton/auto-login claim. Audible alert observation remains missing. Physical AC tests are not a required gate. -The owner-local Project Brain remains the **single operational authority**. This [public state](CURRENT_PROJECT.json) and [Issue #7](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7) are redacted summary/rolling tracker, not a duplicate active Brain. The earlier R270 summary is recoverable exactly from historical Git blob `b26de132a903ca8e9878731989cc15a7e3f3a8ef` at release-source commit `2348791fbde3aff5a18b7df536dba8a66a65d191`, and within Issue #7 history; the historical content below is retained byte-for-byte. +The owner-local Index/Brain remains the single operational authority. This public page and [Issue #7](https://github.com/GOD13emad/TaskbarMonitorEnhanced/issues/7) are redacted mirrors; older engineering records following this block are preserved unchanged. - + --- diff --git a/docs/project-control/PROJECT_KNOWLEDGE_EVIDENCE.md b/docs/project-control/PROJECT_KNOWLEDGE_EVIDENCE.md index 389aed82..65c2a585 100644 --- a/docs/project-control/PROJECT_KNOWLEDGE_EVIDENCE.md +++ b/docs/project-control/PROJECT_KNOWLEDGE_EVIDENCE.md @@ -1,4 +1,13 @@ # Project Knowledge / Evidence Record — Taskbar Monitor Enhanced + +## 2026-10-17 / R285 — release and owner verification public summary (redacted derivative) + +- Versioned crash fix: private/native Windows-hosted actual v1.7.6 Setup interruption exposed a stuck updater-owned .restore scratch; R284 v1.7.7 reconstructs only owned scratch after journal/index validation. PR #76 merged dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad; exact-main Build CI 38965519302 and Windows A/B crash recovery CI 38965519445 PASS. Real physical power cut was excluded by owner. +- Public immutable seven-asset v1.7.7 GitHub release ID 415382091, Setup SHA 395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D, Main SHA A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253; independent artifact G16 PASS. Official v1.7.6 retained unchanged. +- Real owner same-root one-per-user v1.7.6→v1.7.7 upgrade Exit 0; 26 original managed/protected files archived, all five shipped build outputs matched, config and Glass zero-alpha preserved, one Main/one Supervisor/two Brokers, no second HKLM install. Actual CPU 95C app notification dispatch logged; OS audio/banner not independently heard or seen. +- Outstanding external items only: original reporter WinRing0 file SHA/source (Issue #6) and actual owner-app OS alert presentation/audio observation. Global machinewide singleton and signed-publisher attestation are explicitly outside accepted per-user unsigned scope; do not disable Defender. + + ## 2026-09-26 — R22 final identity/build/install/runtime acceptance From 9960a06dba606d1c93b8ca2b84e0e3e25ca4f68d Mon Sep 17 00:00:00 2001 From: Aliakbar Emadoddin <94546844+GOD13emad@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:13:13 +0330 Subject: [PATCH 015/135] feat: v1.7.8 opt-in thermal warning sound and preview Sound remains disabled by default, uses user Windows Exclamation scheme only after real temperature threshold crossing with existing cooldown/quiet-hour gates; manual preview safe without heating. Exact SHA pinned 22-file v1.7.7 predecessor and native Setup recovery preserved. PR CI 38976966511 success, all 3 hosted native Windows jobs 38976887960 success. No driver, public release, machinewide singleton or owner install mutation. --- .../audit-r284-candidate-restore-residue.yml | 2 +- ...it-r286-optional-audio-native-recovery.yml | 142 ++++++++ build/Build.ps1 | 10 +- build/Package-Signed-Setup.ps1 | 4 +- build/TaskbarMonitorEnhanced_Setup.csproj | 4 +- build/Test-NoPawnIO-OssRelease.ps1 | 6 +- ...est-R286-RestoreResidue-NativeRecovery.ps1 | 304 ++++++++++++++++++ build/Test-Release-Gates.ps1 | 14 +- build/Test-Signed-Payload-Contract.ps1 | 6 +- build/Test-Signed-Staging.ps1 | 6 +- build/Verify-Embedded-Main.ps1 | 2 +- build/Verify-Release-Metadata-R273.ps1 | 20 +- build/official-v177-managed.json | 121 +++++++ docs/releases/v1.7.8/INSTALLER_README.txt | 10 + docs/releases/v1.7.8/RELEASE_NOTES.md | 35 ++ installer/TaskbarMonitorEnhanced_Setup.cs | 84 +++-- src/TaskbarMonitorEnhanced.cs | 73 ++++- src/WorkspaceSelfTest.cs | 2 +- 18 files changed, 770 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/audit-r286-optional-audio-native-recovery.yml create mode 100644 build/Test-R286-RestoreResidue-NativeRecovery.ps1 create mode 100644 build/official-v177-managed.json create mode 100644 docs/releases/v1.7.8/INSTALLER_README.txt create mode 100644 docs/releases/v1.7.8/RELEASE_NOTES.md diff --git a/.github/workflows/audit-r284-candidate-restore-residue.yml b/.github/workflows/audit-r284-candidate-restore-residue.yml index a8e5d20a..a1bcc419 100644 --- a/.github/workflows/audit-r284-candidate-restore-residue.yml +++ b/.github/workflows/audit-r284-candidate-restore-residue.yml @@ -2,7 +2,7 @@ name: R284 private v1.7.7 candidate and exact interrupted Setup recovery on: push: - branches: [fix/v1.7.7-restore-residue-r284, main] + branches: [fix/v1.7.7-restore-residue-r284] workflow_dispatch: permissions: diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml new file mode 100644 index 00000000..79d7723d --- /dev/null +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -0,0 +1,142 @@ +name: R286 private v1.7.8 candidate and exact interrupted Setup recovery + +on: + push: + branches: [feature/v1.7.8-optional-audio-r286, main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + candidate: + name: Build private unsigned v1.7.8 candidate (not a public release) + runs-on: windows-2025 + timeout-minutes: 25 + steps: + - name: Checkout exact candidate source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build hash-bound private Setup candidate + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows'){throw 'R286_BAD_HOSTED_SOURCE'} + $source=Get-Content src/TaskbarMonitorEnhanced.cs -Raw + if(!$source.Contains('EnableTemperatureWarningSound = false') -or + !$source.Contains('TemperatureWarningSoundPolicy.ShouldPlay(config)') -or + !$source.Contains('System.Media.SystemSounds.Exclamation.Play();') -or + !$source.Contains('Test warning sound')){ + throw 'R286_OPT_IN_AUDIO_FEATURE_UI_AND_POLICY_NOT_PRESENT' + } + ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R286_VERSIONED_BUILD_FAILED'} + $setup='build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $main='build/_out/App/TaskbarMonitorEnhanced.exe' + if(!(Test-Path $setup) -or !(Test-Path $main)){throw 'R286_PRIVATE_BINARIES_MISSING'} + $record=[ordered]@{ + Schema=1;Status='PRIVATE_NOT_A_RELEASE';Commit=$env:GITHUB_SHA + Version='1.7.8';Private=$true;NotForPublicInstallation=$true + SetupSHA256=(Get-FileHash $setup).Hash + MainSHA256=(Get-FileHash $main).Hash + } + [IO.File]::WriteAllText('build/_out/CANDIDATE.json',($record|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + 'R286_CANDIDATE_COMPILED_AND_HASH_BOUND' + - name: Upload bounded candidate for disposable tests only + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R286-PRIVATE-NOT-PUBLIC-RELEASE + if-no-files-found: error + retention-days: 14 + path: | + build/_out/CANDIDATE.json + build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe + build/_out/App/TaskbarMonitorEnhanced.exe + + cold-recovery: + name: Recovery exact Main candidate on isolated Windows ${{ matrix.host }} + needs: candidate + timeout-minutes: 18 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Exact candidate source checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Retrieve private candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R286-PRIVATE-NOT-PUBLIC-RELEASE + path: _r284_private_candidate + - name: Preflight script parser and strict disposable host + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC'){throw 'R286_UNSAFE_NON_DISPOSABLE_HOST'} + if((git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R286_BAD_SOURCE_CHECKOUT'} + $p=(Resolve-Path build/Test-R286-RestoreResidue-NativeRecovery.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R286_PS51_SCRIPT_PARSER_INVALID'} + if(!(Test-Path build/official-v177-managed.json)){throw 'R286_PINNED_176_MISSING'} + $c=Get-Content _r284_private_candidate/CANDIDATE.json -Raw|ConvertFrom-Json + if($c.Commit -cne $env:GITHUB_SHA -or $c.Version -cne '1.7.8' -or + $c.Private -ne $true -or $c.NotForPublicInstallation -ne $true){throw 'R286_NOT_EXACT_PRIVATE_CANDIDATE'} + 'R286_GUEST_PRECONDITION_PASS' + - name: Force helper crash; prove residue recovery, and real Setup kill on B + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + ./build/Test-R286-RestoreResidue-NativeRecovery.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + if($LASTEXITCODE -ne 0){throw 'R286_GUEST_TEST_FAILED'} + $p=Join-Path $env:RUNNER_TEMP ('R286_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R286_RECOVERY_RECEIPT.json') + if(!(Test-Path $p)){throw 'R286_GUEST_RECEIPT_MISSING'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R286' -or + $r.Status -cne 'PASS_EXACT_V178_SETUP_RETRY_AFTER_STRANDED_RESTORE' -or + $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.PreviousManifestRowsSHAAndSizeChecked -ne 22 -or + $r.StagingHelperActuallyKilled -ne $true -or + $r.StrandedUpdaterOwnedRestoreZeroByteProven -ne $true -or + $r.RecoveredAfterSecondNativeSetupRun -ne $true -or + $r.SingleCanonicalUserRootPreserved -ne $true -or + $r.ProtectedUserDataHashPreserved -ne $true -or + $r.JournalRemoved -ne $true -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.PhysicalMainsLossTriggered -ne $false){throw 'R286_RECEIPT_TYPED_PASS_FAILED'} + if('${{ matrix.host }}' -ceq 'B' -and + ($r.NativeSetupProcessActuallyKilled -ne $true -or + $r.NativeSetupManagedNoteDeletionWitnessed -ne $true)){throw 'R286_REAL_SETUP_KILL_NOT_WITNESSED'} + 'R286_GUEST_TYPED_RECOVERY_PASS' + - name: Preserve bounded guest evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R286-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/R286_RECOVERY_RECEIPT.json + ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/ARMED.json diff --git a/build/Build.ps1 b/build/Build.ps1 index 8d10d8f0..2e3625c3 100644 --- a/build/Build.ps1 +++ b/build/Build.ps1 @@ -92,12 +92,12 @@ $CanonicalTextResources=[ordered]@{ 'TaskbarMonitorSensorSupervisor.cs'=(Join-Path $Root 'src\sensors\TaskbarMonitorSensorSupervisor.cs') 'TBME_Setup_Elevated_Helper.ps1'=(Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1') 'LICENSE'=(Join-Path $Root 'LICENSE') - 'README.md'=(Join-Path $Root 'docs\releases\v1.7.7\INSTALLER_README.txt') + 'README.md'=(Join-Path $Root 'docs\releases\v1.7.8\INSTALLER_README.txt') 'AUTHORS.md'=(Join-Path $Root 'AUTHORS.md') 'COPYRIGHT_AND_ATTRIBUTION.md'=(Join-Path $Root 'COPYRIGHT_AND_ATTRIBUTION.md') 'AI_ASSISTED_DEVELOPMENT.md'=(Join-Path $Root 'AI_ASSISTED_DEVELOPMENT.md') 'THIRD_PARTY_NOTICES.md'=(Join-Path $Root 'THIRD_PARTY_NOTICES.md') - 'RELEASE_NOTES_v1.7.7.md'=(Join-Path $Root 'docs\releases\v1.7.7\RELEASE_NOTES.md') + 'RELEASE_NOTES_v1.7.8.md'=(Join-Path $Root 'docs\releases\v1.7.8\RELEASE_NOTES.md') 'UPSTREAM_REFERENCE_GPL_NOTICE.md'=(Join-Path $Root 'UPSTREAM_REFERENCE_GPL_NOTICE.md') 'TaskbarMonitorEnhanced_Setup.cs'=(Join-Path $Root 'installer\TaskbarMonitorEnhanced_Setup.cs') } @@ -107,7 +107,7 @@ foreach($entry in $CanonicalTextResources.GetEnumerator()){ Write-Host 'TBME_CANONICAL_TEXT_PAYLOAD=PASS' Build (Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj') -$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$Setup=Join-Path $Out 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' if(!(Test-Path -LiteralPath $Setup)){throw 'Setup output missing.'} # SignPath Foundation requires uniform signed-artifact project and ProductVersion metadata. @@ -152,8 +152,8 @@ if([string]$v.Status -ne 'PASS' -or [int]$v.Resources -ne 25 -or $v.PawnIOBundle if($LASTEXITCODE -ne 0){throw 'Application contract suite failed.'} $manifest=[ordered]@{ - Version='1.7.7' - Build='V1_7_7_R284_RESTORE_SCRATCH_RECOVERY' + Version='1.7.8' + Build='V1_7_8_R286_OPTIONAL_TEMP_SOUND' GeneratedUtc=[datetime]::UtcNow.ToString('o') Dependencies=[ordered]@{ LibreHardwareMonitor=[ordered]@{Version=[string]$Lock.dependencies.LibreHardwareMonitor.version;Url=$LhmUrl;SHA256=$LhmSha} diff --git a/build/Package-Signed-Setup.ps1 b/build/Package-Signed-Setup.ps1 index 5a18eea2..439d60d0 100644 --- a/build/Package-Signed-Setup.ps1 +++ b/build/Package-Signed-Setup.ps1 @@ -10,10 +10,10 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $UnsignedMain=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$UnsignedSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' $StageBase=if($ContractFixtureOnly){Join-Path $Root 'artifacts\_r80_contract_only'}else{Join-Path $Root 'artifacts\signed\Setup\TO_SIGN'} -$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$Setup=Join-Path $StageBase 'TaskbarMonitorEnhanced_Setup_1.7.8.exe' $Proof=Join-Path $StageBase 'EMBEDDED_MAIN_PROOF.json' $Verification=Join-Path $StageBase 'SETUP_VERIFY.json' $CreatedStage=$false diff --git a/build/TaskbarMonitorEnhanced_Setup.csproj b/build/TaskbarMonitorEnhanced_Setup.csproj index cb19abb3..18d6f3b0 100644 --- a/build/TaskbarMonitorEnhanced_Setup.csproj +++ b/build/TaskbarMonitorEnhanced_Setup.csproj @@ -1,5 +1,5 @@ - net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.7_out\Setup\false + net48WinExex64falsefalsetruetruenonefalsefalselatesttrueassets\TaskbarMonitorEnhanced.ico..\installer\setup.manifestTaskbarMonitorEnhanced_Setup_1.7.8_out\Setup\false _package\TaskbarMonitorEnhanced.exefalse @@ -23,7 +23,7 @@ - + \ No newline at end of file diff --git a/build/Test-NoPawnIO-OssRelease.ps1 b/build/Test-NoPawnIO-OssRelease.ps1 index 3a6e0cd4..296ebb32 100644 --- a/build/Test-NoPawnIO-OssRelease.ps1 +++ b/build/Test-NoPawnIO-OssRelease.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $LegacyDir=Join-Path $Root 'artifacts\_r85_forbidden_pawnio_fixture' $Project=Join-Path $PSScriptRoot 'TaskbarMonitorEnhanced_Setup.csproj' -$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$OssSetup=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $Main=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$LegacySetup=Join-Path $LegacyDir 'TaskbarMonitorEnhanced_Setup_1.7.8.exe' $LegacyVerify=Join-Path $LegacyDir 'legacy_verify.json' $Reader=Join-Path $PSScriptRoot 'Verify-Embedded-Main.ps1' if(Test-Path -LiteralPath $LegacyDir){throw 'R85 UNSAFE RERUN: FORBIDDEN FIXTURE ALREADY EXISTS'} @@ -56,7 +56,7 @@ try { Write-Output ('R85_OSS_NO_PROPRIETARY_BUNDLE_PASS productionEmbedded=False negativeLegacyResources='+$v.Resources+' negativeRejected=True') } finally { if($created -and (Test-Path -LiteralPath $LegacyDir)){ - $allow=@('TaskbarMonitorEnhanced_Setup_1.7.7.exe','TaskbarMonitorEnhanced_Setup_1.7.7.exe.config','legacy_verify.json') + $allow=@('TaskbarMonitorEnhanced_Setup_1.7.8.exe','TaskbarMonitorEnhanced_Setup_1.7.8.exe.config','legacy_verify.json') $objects=@(Get-ChildItem -LiteralPath $LegacyDir -Force) if(@($objects | Where-Object {$_.PSIsContainer -or $_.Name -notin $allow}).Count -ne 0){ throw ('R85 DEGRADED: UNKNOWN FILE IN OWNED STAGING; PRESERVED '+$LegacyDir) diff --git a/build/Test-R286-RestoreResidue-NativeRecovery.ps1 b/build/Test-R286-RestoreResidue-NativeRecovery.ps1 new file mode 100644 index 00000000..822c7fc1 --- /dev/null +++ b/build/Test-R286-RestoreResidue-NativeRecovery.ps1 @@ -0,0 +1,304 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Never execute an installer or kill a process on a personal/shared computer. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + ![Environment]::UserInteractive){throw 'R286_ONLY_NEW_DISPOSABLE_GITHUB_WINDOWS_VM_ALLOWED'} +Add-Type -AssemblyName System.IO.Compression.FileSystem +Add-Type -TypeDefinition 'using System.IO; public static class R286Bridge { public static void ReplaceNoBackup(string src,string dest) { File.Replace(src,dest,null); } }' +$repo='GOD13emad/TaskbarMonitorEnhanced' +$published177='395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D' +$publishedMain177='A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253' +$libSha='6EBC194316536BA61AF5BE24508AD9FCBB2ECC685E716C12E787C79530F66BF0' +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$uninstall='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$out=Join-Path $env:RUNNER_TEMP ('R286_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $out 'R286_RECOVERY_RECEIPT.json' +$arm=Join-Path $out 'ARMED.json' +$childFile=Join-Path $out 'StageChild.ps1' +$predecessor=Join-Path $out 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$candidate=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$candidateMain=Join-Path $CandidateDir 'App\TaskbarMonitorEnhanced.exe' +$candidateManifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prevFixture=Join-Path $PSScriptRoot 'official-v177-managed.json' +$native=$null;$child=$null;$watcher=$null +$nativeWasKilled=$false;$documentWitness=$false;$residueInjected=$false +$phaseNow='INITIAL';$reason='';$accepted=$false +$gates=[Collections.Generic.List[string]]::new() +function Hash([string]$path){ + if(Test-Path -LiteralPath $path -PathType Leaf){return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash} + return 'MISSING' +} +function ProcessCount(){return @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count} +function StartupValue(){ + $key=Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue + if($null -eq $key){return 'MISSING'} + $p=$key.PSObject.Properties['TaskbarMonitorEnhanced'] + return $(if($null -eq $p){'MISSING'}else{[string]$p.Value}) +} +function Run-Official([string]$exe,[string]$arguments){ + if($arguments -cne '/update /quiet'){throw 'R286_TEST_REFUSES_WRONG_ARGUMENTS'} + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=$exe;$si.Arguments=$arguments;$si.UseShellExecute=$false + $si.WorkingDirectory=(Split-Path $exe -Parent) + $p=[Diagnostics.Process]::Start($si) + if(!$p){throw 'R286_SETUP_NOT_LAUNCHED'} + try{ + if(!$p.WaitForExit(150000)){ + $p.Refresh() + if(!$p.HasExited){$p.Kill();[void]$p.WaitForExit(14000)} + throw 'R286_NATIVE_SETUP_TIMEOUT_ONLY_TEST_PROCESS_STOPPED' + } + return [int]$p.ExitCode + }finally{$p.Dispose()} +} +if((Test-Path $app) -or (Test-Path $uninstall) -or + (Test-Path $out) -or (ProcessCount) -ne 0){throw 'R286_GUEST_NOT_CLEAN'} +[IO.Directory]::CreateDirectory($out)|Out-Null +try{ + $phaseNow='PIN_CANDIDATE_AND_IMMUTABLE_PREDECESSOR' + $manifest=Get-Content $candidateManifest -Raw|ConvertFrom-Json + if($manifest.Commit -cne $env:GITHUB_SHA -or $manifest.Version -cne '1.7.8' -or + $manifest.Private -ne $true -or $manifest.NotForPublicInstallation -ne $true -or + (Hash $candidate) -cne $manifest.SetupSHA256 -or + (Hash $candidateMain) -cne $manifest.MainSHA256){throw 'R286_CANDIDATE_SOURCE_OR_SHA_MISMATCH'} + $candidateSha=[string]$manifest.SetupSHA256 + $mainSha=[string]$manifest.MainSHA256 + if($candidateSha -cnotmatch '^[A-F0-9]{64}$' -or $mainSha -cnotmatch '^[A-F0-9]{64}$'){throw 'R286_CANDIDATE_BAD_DIGEST'} + $env:GH_PROMPT_DISABLED='1' + & gh release download v1.7.7 -R $repo -p TaskbarMonitorEnhanced_Setup_1.7.7.exe -D $out + if($LASTEXITCODE -ne 0 -or (Hash $predecessor) -cne $published177){throw 'R286_PREVIOUS_OFFICIAL_RELEASE_DOWNLOAD_HASH_FAILED'} + $pin=Get-Content $prevFixture -Raw|ConvertFrom-Json + if($pin.Version -cne '1.7.7' -or $pin.ManagedCount -ne 22 -or + @($pin.Rows).Count -ne 22 -or $pin.PublishedSetupSHA256 -cne $published177 -or + $pin.PublishedMainSHA256 -cne $publishedMain177){throw 'R286_BAD_PRIOR_MANIFEST'} + $gates.Add('BOTH_OFFICIAL_AND_PRIVATE_CANDIDATE_ASSET_SHA_VERIFIED') + $phaseNow='RESTORE_PINNED_V176_GUEST_ROOT_WITHOUT_RUNNING_OLD_SETUP' + $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($predecessor)) + [IO.Directory]::CreateDirectory($app)|Out-Null + foreach($row in $pin.Rows){ + $rel=[string]$row.Path + if(!$rel -or [IO.Path]::IsPathRooted($rel) -or + $rel -match '(^|[/\\])\.\.([/\\]|$)' -or $rel -match '[:*?"<>|]'){ + throw 'R286_UNSAFE_PRIOR_RESOURCE_PATH' + } + $dest=[IO.Path]::GetFullPath((Join-Path $app $rel)) + if(!$dest.StartsWith(([IO.Path]::GetFullPath($app).TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R286_PREDECESSOR_RESOURCE_TRAVERSAL' + } + [IO.Directory]::CreateDirectory((Split-Path $dest -Parent))|Out-Null + if(Test-Path $dest){throw 'R286_DUPLICATE_PREVIOUS_MANAGED_RESOURCE'} + if($rel -ceq 'Uninstall.exe'){ + [IO.File]::Copy($predecessor,$dest) + }else{ + $embedded='Payload.'+[IO.Path]::GetFileName($rel) + $source=$asm.GetManifestResourceStream($embedded) + if(!$source){throw ('R286_PREVIOUS_EMBEDDED_RESOURCE_MISSING_'+$embedded)} + try{ + $target=[IO.File]::Open($dest,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$source.CopyTo($target)}finally{$target.Dispose()} + }finally{$source.Dispose()} + } + if((Hash $dest) -cne $row.SHA256 -or (Get-Item $dest).Length -ne $row.Bytes){ + throw ('R286_PREVIOUS_MANAGED_SHA_OR_LENGTH_WRONG_'+$rel) + } + } + $backendRoot=Join-Path $app 'SensorBackend\LibreHardwareMonitor-0.9.6' + $lhmArchive=Join-Path $out 'LibreHardwareMonitor-from-official-Setup.zip' + $stream=$asm.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + if(!$stream){throw 'R286_PRIOR_OFFICIAL_LHM_MISSING'} + try{ + $dst=[IO.File]::Open($lhmArchive,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($dst)}finally{$dst.Dispose()} + }finally{$stream.Dispose()} + [IO.Directory]::CreateDirectory($backendRoot)|Out-Null + [IO.Compression.ZipFile]::ExtractToDirectory($lhmArchive,$backendRoot) + if((Hash (Join-Path $backendRoot 'LibreHardwareMonitorLib.dll')) -cne $libSha){throw 'R286_LIBRARY_SHA_MISMATCH'} + $stateJson='{"App":"Taskbar Monitor Enhanced","Version":"1.7.7","PublicVersion":"1.7.7","ProductIdentity":"LOCKED","SensorLayerStatus":"READY","SensorLayerMode":"R286_PRIVATE_PINNED_FIXTURE","SensorLayerVersion":"0.9.6","R286Fixture":true}' + [IO.File]::WriteAllText((Join-Path $app 'install_state.json'),$stateJson,[Text.UTF8Encoding]::new($false)) + $reg=New-Item -Path $uninstall -Force -ErrorAction Stop + Set-ItemProperty -Path $uninstall -Name DisplayName -Value 'Taskbar Monitor Enhanced' + Set-ItemProperty -Path $uninstall -Name DisplayVersion -Value '1.7.7' + Set-ItemProperty -Path $uninstall -Name InstallLocation -Value $app + $reg.Dispose() + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $publishedMain177 -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $published177 -or + (ProcessCount) -ne 0){throw 'R286_PINNED_PREDECESSOR_NOT_VALID'} + $protected=@{} + $config=Join-Path $app 'config.json' + $backup=Join-Path $app 'config.json.bak' + $canary=Join-Path $app 'R286_PRIVATE_USER_CANARY.txt' + [IO.File]::WriteAllText($config,'{"Theme":"Retro Terminal","GlassMode":true,"Opacity":0,"EnableTemperatureAlerts":true,"EnableTemperatureNotifications":true,"R286":"PRESERVE"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($backup,'{"BackupCanary":"R286_PROTECTED"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($canary,'Only disposable R286 user content',[Text.UTF8Encoding]::new($false)) + foreach($p in @($config,$backup,$canary,(Join-Path $backendRoot 'LibreHardwareMonitorLib.dll'))){$protected[$p]=Hash $p} + $startup=StartupValue + $gates.Add('22_SHA_PINNED_PREDECESSOR_ROWS_AND_PROTECTED_SETTINGS') + $phaseNow='EXACT_CANDIDATE_STAGE_HELPER' + $childText=@' +param([string]$Setup,[string]$App,[string]$Signal) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows'){throw 'R286_CHILD_REFUSES_NONHOSTED'} +if((Get-FileHash $Setup).Hash -cne $env:R286_CANDIDATE_SETUP_SHA){throw 'R286_CHILD_CANDIDATE_SHA_DRIFT'} +if((Get-FileHash (Join-Path $App 'TaskbarMonitorEnhanced.exe')).Hash -cne 'A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253'){throw 'R286_CHILD_PREVIOUS_SHA_DRIFT'} +if(Test-Path (Join-Path $App '.tbme-upgrade')){throw 'R286_CHILD_STAGE_ALREADY_EXISTS'} +Add-Type -TypeDefinition 'using System.IO; public static class R286FileSwap { public static void ReplaceOne(string a,string b){File.Replace(a,b,null);} }' +$assembly=[Reflection.Assembly]::LoadFrom($Setup) +$type=$assembly.GetType('SetupProgram',$true) +$f=[Reflection.BindingFlags]'Static,NonPublic,Public' +$old='1.7.7';$channel='OFFICIAL_STABLE_V177' +$prior=[IO.File]::ReadAllText((Join-Path $App 'install_state.json')) +$legacy=$type.GetMethod('LegacyExpected',$f).Invoke($null,[object[]]@($old,$channel)) +$fresh=$type.GetMethod('PrepareNewBytes',$f).Invoke($null,[object[]]@($prior)) +$fresh['Uninstall.exe']=[IO.File]::ReadAllBytes($Setup) +[void]$type.GetMethod('StageNewManaged',$f).Invoke($null,[object[]]@($fresh,$legacy,$prior,$old,$channel)) +[void]$type.GetMethod('WritePhase',$f).Invoke($null,[object[]]@('COMMITTING')) +$stage=Join-Path $App '.tbme-upgrade\new' +$mainLive=Join-Path $App 'TaskbarMonitorEnhanced.exe' +[R286FileSwap]::ReplaceOne((Join-Path $stage 'TaskbarMonitorEnhanced.exe'),$mainLive) +$note='Docs/RELEASE_NOTES_v1.7.8.md' +$live=Join-Path $App $note +if(Test-Path $live){throw 'NEW_NOTE_ALREADY_EXISTS'} +[IO.File]::Move((Join-Path $stage $note),$live) +if((Get-FileHash $mainLive).Hash -cne $env:R286_CANDIDATE_MAIN_SHA){throw 'R286_PARTIAL_CANDIDATE_MAIN_NOT_PRESENT'} +[IO.File]::WriteAllText($Signal,([ordered]@{ChildPid=$PID;Phase='COMMITTING';ReplacedManagedCount=2;Utc=[datetime]::UtcNow.ToString('o')}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) +while($true){Start-Sleep -Seconds 2} +'@ + [IO.File]::WriteAllText($childFile,$childText,[Text.UTF8Encoding]::new($false)) + $env:R286_CANDIDATE_SETUP_SHA=$candidateSha + $env:R286_CANDIDATE_MAIN_SHA=$mainSha + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $si.Arguments='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$childFile+'" -Setup "'+$candidate+'" -App "'+$app+'" -Signal "'+$arm+'"' + $si.UseShellExecute=$false;$si.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($si) + if(!$child){throw 'R286_CHILD_NOT_STARTED'} + $clock=[Diagnostics.Stopwatch]::StartNew() + while(!(Test-Path $arm) -and $clock.Elapsed.TotalSeconds -lt 55){ + $child.Refresh() + if($child.HasExited){throw ('R286_CHILD_FAIL_EXIT_'+$child.ExitCode)} + Start-Sleep -Milliseconds 75 + } + if(!(Test-Path $arm)){throw 'R286_CHILD_DID_NOT_ARM'} + $message=Get-Content $arm -Raw|ConvertFrom-Json + if($message.ChildPid -ne $child.Id -or $message.Phase -cne 'COMMITTING' -or $message.ReplacedManagedCount -ne 2){throw 'R286_ARMED_INVALID'} + $phase=Join-Path $stage 'phase.txt' + $currentNote=Join-Path $app 'Docs\RELEASE_NOTES_v1.7.8.md' + if((Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING' -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or !(Test-Path $currentNote)){ + throw 'R286_STAGE_NOT_PHYSICALLY_PARTIAL' + } + $child.Refresh();if($child.HasExited){throw 'R286_CHILD_EXITED_ALREADY'} + $child.Kill();if(!$child.WaitForExit(13000)){throw 'R286_CHILD_KILL_NOT_COMPLETE'} + $child.Dispose();$child=$null + $gates.Add('ACTUAL_ISOLATED_STAGING_HELPER_KILLED_WITH_TWO_MANAGED_REPLACEMENTS') + $residue=Join-Path $stage 'old\TaskbarMonitorEnhanced.exe.restore' + if(Test-Path $residue){throw 'R286_STRANDED_RESIDUE_ALREADY_EXISTS'} + if($HostLabel -ceq 'B'){ + $phaseNow='KILL_ACTUAL_OFFICIAL_CANDIDATE_SETUP_PROCESS_DURING_ROLLBACK' + $watcher=[IO.FileSystemWatcher]::new((Join-Path $app 'Docs'),'RELEASE_NOTES_v1.7.8.md') + $watcher.NotifyFilter=[IO.NotifyFilters]'FileName,LastWrite,Size,CreationTime' + $watcher.EnableRaisingEvents=$true + $si=[Diagnostics.ProcessStartInfo]::new() + $si.FileName=$candidate;$si.Arguments='/update /quiet';$si.UseShellExecute=$false;$si.WorkingDirectory=$out + $native=[Diagnostics.Process]::Start($si) + if(!$native){throw 'R286_REAL_SETUP_NOT_STARTED'} + $watchTime=[Diagnostics.Stopwatch]::StartNew() + while($watchTime.Elapsed.TotalSeconds -lt 40){ + $native.Refresh() + if($native.HasExited){throw ('R286_SETUP_FINISHED_BEFORE_KILLABLE_ROLLBACK_'+$native.ExitCode)} + $signal=$watcher.WaitForChanged([IO.WatcherChangeTypes]::All,45) + if($signal.TimedOut -or (Test-Path $currentNote)){continue} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + !(Test-Path $phase) -or (Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING'){ + throw 'R286_REAL_ROLLBACK_KILL_WINDOW_WAS_MISSED' + } + $native.Refresh();if($native.HasExited){throw 'R286_REAL_SETUP_EXITED_BEFORE_FORCE_TERMINATION'} + $native.Kill() + if(!$native.WaitForExit(18000)){throw 'R286_REAL_NATIVE_SETUP_DID_NOT_TERMINATE'} + $nativeWasKilled=$true;$documentWitness=$true + break + } + if(!$nativeWasKilled){throw 'R286_NO_VALID_REAL_SETUP_PROCESS_KILL_WITNESS'} + $watcher.EnableRaisingEvents=$false;$watcher.Dispose();$watcher=$null + $native.Dispose();$native=$null + if(!(Test-Path $stage) -or (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha){throw 'R286_KILL_NOT_LEFT_PARTIAL_STAGE'} + $gates.Add('REAL_UNMODIFIED_CANDIDATE_SETUP_PROCESS_KILLED_AFTER_DOCUMENT_DELETION') + } + $phaseNow='EXACT_KNOWN_OWNER_STAGE_STRANDED_TEMP_RESIDUE' + if(!(Test-Path $residue)){ + $copy=[IO.File]::Open($residue,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + $copy.Dispose() + $residueInjected=$true + } + if((Get-Item $residue).Length -ne 0 -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + (Get-Content $phase -Raw) -cne 'TBME_UPGRADE_R92_OWNED_STAGE_V1|COMMITTING'){ + throw 'R286_NOT_A_REPRODUCIBLE_STRANDED_ZERO_BYTE_OLD_MAIN_SCRATCH' + } + $gates.Add('ZERO_LENGTH_STRANDED_UPDATER_OWNED_RESTORE_FILE_CONFIRMED') + $phaseNow='NEW_VERSIONED_OFFICIAL_SETUP_COLD_RECOVERS' + $result=Run-Official $candidate '/update /quiet' + if($result -ne 0){throw ('R286_CANDIDATE_FAILED_TO_COLD_RECOVER_'+$result)} + if(Test-Path $stage){throw 'R286_JOURNAL_NOT_REMOVED'} + if((Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainSha -or + (Hash (Join-Path $app 'Uninstall.exe')) -cne $candidateSha -or + (Get-ItemProperty $uninstall).DisplayVersion -cne '1.7.8'){throw 'R286_NEW_INSTALLED_APP_OR_SETUP_HASH_INVALID'} + $newState=Get-Content (Join-Path $app 'install_state.json') -Raw|ConvertFrom-Json + if($newState.Version -cne '1.7.8' -or $newState.PublicVersion -cne '1.7.8'){throw 'R286_INSTALLED_PUBLIC_VERSION_INCORRECT'} + if((Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.7.md')) -or !(Test-Path (Join-Path $app 'Docs\RELEASE_NOTES_v1.7.8.md'))){throw 'R286_VERSION_NOTES_UPGRADE_INVALID'} + foreach($p in $protected.Keys){if((Hash $p) -cne $protected[$p]){throw ('R286_PROTECTED_USER_FILE_CHANGED_'+$p)}} + if((StartupValue) -cne $startup){throw 'R286_USER_AUTOSTART_PREFERENCE_CHANGED'} + if((ProcessCount) -ne 0){throw 'R286_MAIN_PROGRAM_UNEXPECTEDLY_LAUNCHED'} + if(@(Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'|Where-Object PSChildName -ceq 'TaskbarMonitorEnhanced').Count -ne 1){throw 'R286_MORE_THAN_ONE_PRODUCT_REGISTRATION'} + $gates.Add('EXACT_RELEASE_177_MAIN_SETUP_USER_CONFIG_AUTOSTART_ONE_ROOT_JOURNAL_REMOVED') + $result=[ordered]@{ + Schema=1;Revision='R286';Status='PASS_EXACT_V178_SETUP_RETRY_AFTER_STRANDED_RESTORE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;PreviousOfficialVersion='1.7.7' + PreviousOfficialSetupSHA256=$published177;PreviousOfficialMainSHA256=$publishedMain177 + PreviousManifestRowsSHAAndSizeChecked=22;PredecessorSetupProcessRun=$false;PrivilegedDriverInstalled=$false + CandidateVersion='1.7.8';CandidateSetupSHA256=$candidateSha;CandidateMainSHA256=$mainSha + StagedPhase='COMMITTING';ManagedFileReplacementsBeforeKill=2 + StagingHelperActuallyKilled=$true;NativeSetupProcessActuallyKilled=$nativeWasKilled + NativeSetupManagedNoteDeletionWitnessed=$documentWitness + StrandedUpdaterOwnedRestoreZeroByteProven=$true + StrandedScratchInjectedOnlyIfAbsent=$residueInjected + RecoveredAfterSecondNativeSetupRun=$true;ProtectedUserDataHashPreserved=$true + SingleCanonicalUserRootPreserved=$true;JournalRemoved=$true + PhysicalMainsLossTriggered=$false;OriginalOwnerComputerTouched=$false + PassedGates=@($gates);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($result|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R286_DISPOSABLE_GUEST_'+$HostLabel+'_INTERRUPTED_SETUP_RECOVERY=PASS' +}catch{ + $reason=$_.Exception.ToString() + Write-Output ('R286_GUEST_PHASE_FAILURE='+$phaseNow+' REASON='+$reason) + throw +}finally{ + if($watcher){$watcher.EnableRaisingEvents=$false;$watcher.Dispose()} + if($native){ + try{$native.Refresh();if(!$native.HasExited -and $env:RUNNER_ENVIRONMENT -cne 'owner'){$native.Kill();[void]$native.WaitForExit(12000)}}finally{$native.Dispose()} + } + if($child){ + try{$child.Refresh();if(!$child.HasExited -and $env:RUNNER_ENVIRONMENT -cne 'owner'){$child.Kill();[void]$child.WaitForExit(12000)}}finally{$child.Dispose()} + } + if(!$accepted){ + $bad=[ordered]@{Schema=1;Revision='R286';Status='FAIL';Host=$HostLabel;Phase=$phaseNow + Failure=$reason;NativeSetupWasKilled=$nativeWasKilled;WitnessedNativeDeletion=$documentWitness + StrandedResidueInjected=$residueInjected;UserHostTouched=$false;Utc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($bad|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/build/Test-Release-Gates.ps1 b/build/Test-Release-Gates.ps1 index 00f12a1b..2ea4b52c 100644 --- a/build/Test-Release-Gates.ps1 +++ b/build/Test-Release-Gates.ps1 @@ -44,7 +44,7 @@ if($null -ne $manifest) { 'TaskbarMonitorEnhanced.exe.config'='build\_out\App\TaskbarMonitorEnhanced.exe.config'; 'TaskbarMonitorSensorBroker.exe'='build\_out\Broker\TaskbarMonitorSensorBroker.exe'; 'TaskbarMonitorSensorSupervisor.exe'='build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe'; - 'TaskbarMonitorEnhanced_Setup_1.7.7.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' + 'TaskbarMonitorEnhanced_Setup_1.7.8.exe'='build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' } $realHashesPass=$true foreach($item in $outputs){ @@ -168,7 +168,7 @@ Add-Gate 'G12_START_SEARCH_24_REAL_NATIVE_INK_AND_EXACT_RETURN' $startFloatPass # R75: G02 hashes deterministic UNSIGNED builder outputs. G14 and G15 must use # separate SIGNED release staging, since Authenticode modifies executable SHA256. $signedMainExe=Join-Path $root 'artifacts\signed\Main\TaskbarMonitorEnhanced.exe' -$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$signedSetupExe=Join-Path $root 'artifacts\signed\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $expectedSignedMainDigest='' if((Test-Path -LiteralPath $signedMainExe -PathType Leaf) -and (Test-Path -LiteralPath $signedSetupExe -PathType Leaf)){ @@ -233,7 +233,7 @@ Add-Gate 'G14_INSTALLED_TASKBAR_EXPLORER_SENSORS_ROLLBACK' $installedPass $insta $setupExe=$signedSetupExe $mainExe=$signedMainExe # Keep original _out builder outputs immutable so G02 exact source checks remain reproducible. -$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$unsignedSetupExe=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' # R73: G15 must prove actual SignPath Foundation publisher, valid timestamps, matched # exact file/product versions AND live provider-origin/payload evidence. Never trust # self-declared "approved" JSON, a test certificate or token-less synthetic output. @@ -253,10 +253,10 @@ if((Test-Path -LiteralPath $setupExe -PathType Leaf) -and (Test-Path -LiteralPat $sameCert=$null -ne $setupCert -and $null -ne $mainCert -and $setupCert.Thumbprint -ceq $mainCert.Thumbprint $exactMetadata=$setupInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and $mainInfo.ProductName -ceq 'Taskbar Monitor Enhanced' -and - $setupInfo.ProductVersion -ceq '1.7.7+r284' -and - $mainInfo.ProductVersion -ceq '1.7.7+r284' -and - $setupInfo.FileVersion -ceq '1.7.7.0' -and - $mainInfo.FileVersion -ceq '1.7.7.0' + $setupInfo.ProductVersion -ceq '1.7.8+r286' -and + $mainInfo.ProductVersion -ceq '1.7.8+r286' -and + $setupInfo.FileVersion -ceq '1.7.8.0' -and + $mainInfo.FileVersion -ceq '1.7.8.0' $cryptoPass=([string]$mainSig.Status -ceq 'Valid') -and ([string]$setupSig.Status -ceq 'Valid') -and $mainName -ceq 'SignPath Foundation' -and diff --git a/build/Test-Signed-Payload-Contract.ps1 b/build/Test-Signed-Payload-Contract.ps1 index afd9be96..607f67b7 100644 --- a/build/Test-Signed-Payload-Contract.ps1 +++ b/build/Test-Signed-Payload-Contract.ps1 @@ -4,11 +4,11 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $original=Join-Path $Root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$setupOrig=Join-Path $Root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $inputDir=Join-Path $Root 'artifacts\_r80_contract_input' $inputFile=Join-Path $inputDir 'TaskbarMonitorEnhanced.exe' $stageDir=Join-Path $Root 'artifacts\_r80_contract_only' -$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$stageExe=Join-Path $stageDir 'TaskbarMonitorEnhanced_Setup_1.7.8.exe' $originalMainSHA=(Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash $originalSetupSHA=(Get-FileHash -LiteralPath $setupOrig -Algorithm SHA256).Hash if((Test-Path -LiteralPath $inputDir) -or (Test-Path -LiteralPath $stageDir)){ @@ -70,7 +70,7 @@ try{ # Cleanup exact owned files only. Unknown files survive; never delete personal state. foreach($d in @($stageDir,$inputDir)){ if(Test-Path -LiteralPath $d){ - $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.7.exe','TaskbarMonitorEnhanced_Setup_1.7.7.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} + $allowed=if($d -ceq $stageDir){@('TaskbarMonitorEnhanced_Setup_1.7.8.exe','TaskbarMonitorEnhanced_Setup_1.7.8.exe.config','SETUP_VERIFY.json','EMBEDDED_MAIN_PROOF.json')} else{@('TaskbarMonitorEnhanced.exe')} $found=@(Get-ChildItem -LiteralPath $d -Force) if(@($found|Where-Object {$_.PSIsContainer -or $_.Name -notin $allowed}).Count -gt 0){ diff --git a/build/Test-Signed-Staging.ps1 b/build/Test-Signed-Staging.ps1 index 1132fa03..a035bc51 100644 --- a/build/Test-Signed-Staging.ps1 +++ b/build/Test-Signed-Staging.ps1 @@ -6,9 +6,9 @@ Set-StrictMode -Version Latest $root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $stage=Join-Path $root 'artifacts\signed' $origMain=Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe' -$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$origSetup=Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $targetMain=Join-Path $stage 'Main\TaskbarMonitorEnhanced.exe' -$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe' +$targetSetup=Join-Path $stage 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' $created=@() if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $origMain -PathType Leaf) -or @@ -26,7 +26,7 @@ if(!(Test-Path -LiteralPath $manifest -PathType Leaf)){throw 'R75 MANIFEST REQUI $m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json $mapping=@{ 'TaskbarMonitorEnhanced.exe'=$hashMain - 'TaskbarMonitorEnhanced_Setup_1.7.7.exe'=$hashSetup + 'TaskbarMonitorEnhanced_Setup_1.7.8.exe'=$hashSetup } foreach($entry in $m.Outputs){ if($mapping.ContainsKey([string]$entry.Name)){ diff --git a/build/Verify-Embedded-Main.ps1 b/build/Verify-Embedded-Main.ps1 index 4003f916..8780bfe4 100644 --- a/build/Verify-Embedded-Main.ps1 +++ b/build/Verify-Embedded-Main.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference='Stop' if($ExpectedMainSHA256 -notmatch '^[0-9a-fA-F]{64}$'){throw 'R80 EXPECTED MAIN SHA NOT 64 HEX'} $path=[IO.Path]::GetFullPath($SetupExe) if(!(Test-Path -LiteralPath $path -PathType Leaf) -or - ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe','TaskbarMonitorEnhanced_Setup_1.7.7.exe'))){ + ([IO.Path]::GetFileName($path) -cnotin @('TaskbarMonitorEnhanced_Setup_1.7.0.exe','TaskbarMonitorEnhanced_Setup_1.7.5.exe','TaskbarMonitorEnhanced_Setup_1.7.6.exe','TaskbarMonitorEnhanced_Setup_1.7.7.exe','TaskbarMonitorEnhanced_Setup_1.7.8.exe'))){ throw 'R80 SETUP EXE MISSING OR WRONG FILE NAME' } $setupHash=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash diff --git a/build/Verify-Release-Metadata-R273.ps1 b/build/Verify-Release-Metadata-R273.ps1 index 003af0c2..f804d4da 100644 --- a/build/Verify-Release-Metadata-R273.ps1 +++ b/build/Verify-Release-Metadata-R273.ps1 @@ -1,4 +1,4 @@ -# R273 independent, read-only authority check for the versioned 1.7.7 +# R273 independent, read-only authority check for the versioned 1.7.8 # release Build Manifest -> deterministic binary files -> SBOM 2.3. [CmdletBinding()] param( @@ -22,20 +22,20 @@ $commit=(& git -C $root rev-parse HEAD).Trim() if($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$'){throw 'R273_CURRENT_HEAD_NOT_EXACT'} function Issues($manifest,$sbom,$setup,$sha){ $fail=[Collections.Generic.List[string]]::new() - if($manifest.Version -cne '1.7.7' -or $manifest.Build -cne 'V1_7_7_R284_RESTORE_SCRATCH_RECOVERY'){ + if($manifest.Version -cne '1.7.8' -or $manifest.Build -cne 'V1_7_8_R286_OPTIONAL_TEMP_SOUND'){ $fail.Add('WRONG_PUBLIC_BUILD_VERSION') } - if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.7' -or - $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.7/'+$sha)){ + if($sbom.spdxVersion -cne 'SPDX-2.3' -or $sbom.name -cne 'Taskbar-Monitor-Enhanced-1.7.8' -or + $sbom.documentNamespace -cne ('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.8/'+$sha)){ $fail.Add('SPDX_SOURCE_AND_VERSION_DRIFT') } if(@($sbom.packages).Count -ne 3 -or @($sbom.files).Count -ne 5 -or - $sbom.packages[0].versionInfo -cne '1.7.7'){ + $sbom.packages[0].versionInfo -cne '1.7.8'){ $fail.Add('SPDX_PRODUCT_PACKAGE_VERSION_DRIFT') } $required=@('TaskbarMonitorEnhanced.exe','TaskbarMonitorEnhanced.exe.config', 'TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe', - 'TaskbarMonitorEnhanced_Setup_1.7.7.exe') + 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') $found=@($manifest.Outputs|ForEach-Object {$_.Name}) if(@($manifest.Outputs).Count -ne 5 -or @(Compare-Object ($required|Sort-Object) ($found|Sort-Object)).Count -ne 0){ @@ -44,7 +44,7 @@ function Issues($manifest,$sbom,$setup,$sha){ foreach($row in $manifest.Outputs){ $name=[string]$row.Name $rel=switch($name){ - 'TaskbarMonitorEnhanced_Setup_1.7.7.exe' {'build\_out\Setup\'+$name;break} + 'TaskbarMonitorEnhanced_Setup_1.7.8.exe' {'build\_out\Setup\'+$name;break} 'TaskbarMonitorSensorBroker.exe' {'build\_out\Broker\'+$name;break} 'TaskbarMonitorSensorSupervisor.exe' {'build\_out\Supervisor\'+$name;break} default {'build\_out\App\'+$name} @@ -68,9 +68,9 @@ function Issues($manifest,$sbom,$setup,$sha){ $fail.Add('SETUP_RESOURCE_OR_PROPRIETARY_DEPENDENCY_REJECTED') } $main=(Join-Path $root 'build\_out\App\TaskbarMonitorEnhanced.exe') - $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.7.exe') + $setupExe=(Join-Path $root 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe') if((Get-Item $main).VersionInfo.ProductVersion -cne (Get-Item $setupExe).VersionInfo.ProductVersion -or - !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.7')){ + !(Get-Item $main).VersionInfo.ProductVersion.StartsWith('1.7.8')){ $fail.Add('ASSEMBLY_PRODUCT_VERSION_DIFFERENT') } return @($fail.ToArray()) @@ -82,7 +82,7 @@ if($SelfTest){ @{Type='Manifest';Field='Version';Bad='1.7.0'}, @{Type='Manifest';Field='Build';Bad='V1_7_0_R39_WALLPAPER_STUDIO'}, @{Type='Sbom';Field='name';Bad='Taskbar-Monitor-Enhanced-1.7.0'}, - @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.7/'+('0'*40))}, + @{Type='Sbom';Field='documentNamespace';Bad=('https://github.com/GOD13emad/TaskbarMonitorEnhanced/spdx/1.7.8/'+('0'*40))}, @{Type='Setup';Field='PawnIOBundled';Bad=$true} ) foreach($mutation in $mutants){ diff --git a/build/official-v177-managed.json b/build/official-v177-managed.json new file mode 100644 index 00000000..defc67f5 --- /dev/null +++ b/build/official-v177-managed.json @@ -0,0 +1,121 @@ +{ + "Schema": 1, + "Status": "PINNED_OFFICIAL_V177_INSTALLED_EQUALS_PUBLIC_RELEASE_PAYLOAD", + "Version": "1.7.7", + "SourceCommit": "dc68cf448173bd0808d1dfa54bc9e9bf7ca20aad", + "PublishedSetupSHA256": "395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D", + "PublishedMainSHA256": "A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253", + "ManagedCount": 22, + "Rows": [ + { + "Path": "TaskbarMonitorEnhanced.exe", + "SHA256": "A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253", + "Bytes": 636416 + }, + { + "Path": "TaskbarMonitorEnhanced.exe.config", + "SHA256": "D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "Bytes": 456 + }, + { + "Path": "TaskbarMonitorEnhanced.ico", + "SHA256": "0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Bytes": 22172 + }, + { + "Path": "Source/TaskbarMonitorEnhanced.cs", + "SHA256": "B95546C73093486D54395FBEE891002A49913F9234713C16A11DCAC23EDFD962", + "Bytes": 597067 + }, + { + "Path": "Source/StudioThemes.cs", + "SHA256": "7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Bytes": 15378 + }, + { + "Path": "Source/WallpaperStudio.cs", + "SHA256": "14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Bytes": 26913 + }, + { + "Path": "Source/TaskbarGlass.cs", + "SHA256": "396AD7DA39674099999ADB4A9C98458A876A8DE5BFABB1DC640EE46BC2F63306", + "Bytes": 108873 + }, + { + "Path": "Source/WorkspaceModels.cs", + "SHA256": "2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Bytes": 22796 + }, + { + "Path": "Source/MonitorWorkspace.cs", + "SHA256": "BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Bytes": 55753 + }, + { + "Path": "Source/WorkspaceSelfTest.cs", + "SHA256": "7D1B3A1FA0A46CAC47D0063E2ED7929C5299F2DE0FD394BD6C82E53B72590FA5", + "Bytes": 21949 + }, + { + "Path": "Source/TaskbarMonitorSensorBroker.cs", + "SHA256": "D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Bytes": 28046 + }, + { + "Path": "Source/TaskbarMonitorSensorSupervisor.cs", + "SHA256": "DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Bytes": 51057 + }, + { + "Path": "Source/TaskbarMonitorEnhanced_Setup.cs", + "SHA256": "65284119FE8759314BDDD3309837B2377AB6682BE98DF3995363908822A4CA38", + "Bytes": 102954 + }, + { + "Path": "Docs/LICENSE", + "SHA256": "3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Bytes": 35149 + }, + { + "Path": "Docs/README.md", + "SHA256": "F136012748AD1D1560EC5D5342D2D1228E5A90EE59B094ECA0E8D3CCAB51A63F", + "Bytes": 495 + }, + { + "Path": "Docs/AUTHORS.md", + "SHA256": "0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Bytes": 803 + }, + { + "Path": "Docs/COPYRIGHT_AND_ATTRIBUTION.md", + "SHA256": "503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Bytes": 1174 + }, + { + "Path": "Docs/AI_ASSISTED_DEVELOPMENT.md", + "SHA256": "7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Bytes": 674 + }, + { + "Path": "Docs/THIRD_PARTY_NOTICES.md", + "SHA256": "7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Bytes": 2224 + }, + { + "Path": "Docs/RELEASE_NOTES_v1.7.7.md", + "SHA256": "74FDDEBDDE733F1F0E2BE2C84824E4784D4CC244094660199094DA5550091A83", + "Bytes": 1544 + }, + { + "Path": "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md", + "SHA256": "14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Bytes": 811 + }, + { + "Path": "Uninstall.exe", + "SHA256": "395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D", + "Bytes": 8553984 + } + ] +} \ No newline at end of file diff --git a/docs/releases/v1.7.8/INSTALLER_README.txt b/docs/releases/v1.7.8/INSTALLER_README.txt new file mode 100644 index 00000000..e969e70d --- /dev/null +++ b/docs/releases/v1.7.8/INSTALLER_README.txt @@ -0,0 +1,10 @@ +Taskbar Monitor Enhanced v1.7.8 +One versioned unsigned per-user Setup. Safe same-root upgrade from the pinned +immutable official v1.7.7. Adds an optional Windows scheme-based warning sound +for CPU/GPU/disk temperature threshold notifications; silent by default. +To enable: Settings > Alerts > Play Windows warning sound. Use Test warning +sound there to preview it safely without heating your computer. +The sound follows Windows user sound scheme and output volume; a muted +Windows endpoint can suppress it. The configured cooldown, hysteresis and +quiet hours continue to apply. Glass zero alpha / hot red and all settings +are preserved; no extra installation or kernel driver is bundled. diff --git a/docs/releases/v1.7.8/RELEASE_NOTES.md b/docs/releases/v1.7.8/RELEASE_NOTES.md new file mode 100644 index 00000000..50a2924f --- /dev/null +++ b/docs/releases/v1.7.8/RELEASE_NOTES.md @@ -0,0 +1,35 @@ +# Taskbar Monitor Enhanced v1.7.8 — optional audible thermal warning + +## Temperature warnings + +- Adds an opt-in **Play Windows warning sound** option under Settings > Alerts. + New installations and upgrades default to **sound off**, unless you explicitly + enable it. The existing Windows temperature notification preference remains + separate and unchanged. +- When both Windows notifications and the sound option are enabled, a real + CPU/GPU/disk temperature crossing requests Windows' current Exclamation sound. + The existing per-device transition latch, 3 C hysteresis, ten-minute cooldown + and user quiet hours/snooze continue to apply: no repeated sound every poll. +- **Test warning sound** in the same Settings page previews the Windows sound + once on a deliberate click, without heating the CPU or moving thresholds. +- Uses the user's Windows sound scheme and system output volume. Sound can be + muted/disabled by Windows, so a request log is not evidence a speaker was heard. + The existing glass zero-alpha background and red hot-temperature text/charts + are unchanged. + +## Existing safe upgrade +- One intentionally unsigned Setup, same per-user installation root. + Exact SHA-pinned official v1.7.7 predecessor is supported transactionally, + including the previously verified crash recovery of updater-owned .restore. +- Preserves user config/history/autorun, GPU/disk sensor services, and all + existing themes, settings, focus and Start/fullscreen handling. +- No new driver; external signed PawnIO policy remains distinct from Setup. +- Physical AC-mains-loss testing is excluded by the owner; global machinewide + singleton and independent security signing-lab claims are not made. +- Original reporter-specific WinRing0 SHA/download origin remains pending in + GitHub Issue #6. Never disable or bypass Defender. + +## Status and evidence +- This is a private release candidate until hosted Build, GUI/sound-policy + selftest, fresh/in-place installation, native recovery, supply-chain G16 + and real owner acceptance succeed on exact source. diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index aa295489..f987f726 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -20,14 +20,14 @@ [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.7.0")] -[assembly: AssemblyFileVersion("1.7.7.0")] -[assembly: AssemblyInformationalVersion("1.7.7+r284")] +[assembly: AssemblyVersion("1.7.8.0")] +[assembly: AssemblyFileVersion("1.7.8.0")] +[assembly: AssemblyInformationalVersion("1.7.8+r286")] internal static partial class SetupProgram { const string Product="Taskbar Monitor Enhanced"; - const string Version="1.7.7"; + const string Version="1.7.8"; const string SensorProtocolVersion="1.1.2+r21"; const string SensorSupervisorVersion="1.3.0+r33"; const string Publisher="Dr. Ali-Akbar Emadeddin"; @@ -58,7 +58,7 @@ internal static partial class SetupProgram "Payload.COPYRIGHT_AND_ATTRIBUTION.md", "Payload.AI_ASSISTED_DEVELOPMENT.md", "Payload.THIRD_PARTY_NOTICES.md", - "Payload.RELEASE_NOTES_v1.7.7.md", + "Payload.RELEASE_NOTES_v1.7.8.md", "Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md", "Payload.TaskbarMonitorEnhanced_Setup.cs" }; @@ -228,8 +228,8 @@ static void WriteInstallState(SensorOutcome sensorOutcome) string sensorMode=sensorOutcome==null?"":(sensorOutcome.LayerMode??""); string json="{\r\n"+ " \"App\": \"Taskbar Monitor Enhanced\",\r\n"+ - " \"Version\": \"1.7.7\",\r\n"+ - " \"PublicVersion\": \"1.7.7\",\r\n"+ + " \"Version\": \"1.7.8\",\r\n"+ + " \"PublicVersion\": \"1.7.8\",\r\n"+ " \"InternalRuntimeBaseline\": \"V1_1_2_R21_PRODUCTION_HARDENING\",\r\n"+ " \"SensorSupervisor\": \"V1_3_0_R33_STORAGE_COMPLETION_HARDENING\",\r\n"+ " \"SensorLayerStatus\": \""+sensorStatus.Replace("\\","\\\\").Replace("\"","\\\"")+"\",\r\n"+ @@ -445,7 +445,7 @@ static bool IsCurrentProcessElevated() static SensorOutcome Install(bool desktop,bool startup) { if(!Environment.Is64BitOperatingSystem) - throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.7 requires 64-bit Windows."); + throw new InvalidOperationException("Taskbar Monitor Enhanced 1.7.8 requires 64-bit Windows."); // R114: prevent fresh-install code from ever overwriting an installed // product or user-owned files. Existing roots must use /update. @@ -475,7 +475,7 @@ static SensorOutcome Install(bool desktop,bool startup) string[] docs=new string[]{ "LICENSE","README.md","AUTHORS.md","COPYRIGHT_AND_ATTRIBUTION.md", "AI_ASSISTED_DEVELOPMENT.md","THIRD_PARTY_NOTICES.md", - "RELEASE_NOTES_v1.7.7.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" + "RELEASE_NOTES_v1.7.8.md","UPSTREAM_REFERENCE_GPL_NOTICE.md" }; foreach(string doc in docs) Extract("Payload."+doc,Path.Combine(AppRoot,"Docs",doc)); @@ -619,7 +619,7 @@ static int Verify(string path) } if(!String.IsNullOrEmpty(path)){ string json="{\"Status\":\"PASS\",\"Resources\":"+(RequiredResources.Length+(pawnBundled?1:0))+ - ",\"Version\":\"1.7.7\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ + ",\"Version\":\"1.7.8\",\"Publisher\":\"Dr. Ali-Akbar Emadeddin\""+ ",\"SensorArchitecture\":\"R21_PROCESS_ISOLATED\",\"SensorUpgradePolicy\":\"EXACT_R33_SUPERVISOR_R21_PROTOCOL\""+ ",\"PawnIOBundled\":"+(pawnBundled?"true":"false")+",\"EmbeddedMainSHA256\":\""+embeddedMainSHA256+"\",\"EmbeddedMainBytes\":"+embeddedMainBytes+"}"; File.WriteAllText(path,json,Encoding.UTF8); @@ -658,7 +658,7 @@ public SetupForm() Icon=Icon.ExtractAssociatedIcon(Application.ExecutablePath); Label title=new Label(); - title.Text=Product+" 1.7.7"; + title.Text=Product+" 1.7.8"; title.Font=new Font(Font.FontFamily,18,FontStyle.Bold); title.Left=28;title.Top=22;title.AutoSize=true;Controls.Add(title); @@ -693,12 +693,12 @@ public SetupForm() progress.Visible=false; status.Text="Installation completed."; if(outcome.IsHealthy){ - MessageBox.Show(Product+" 1.7.7 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", + MessageBox.Show(Product+" 1.7.8 was installed successfully.\r\n\r\nProtected hardware sensor monitoring is active.", "Setup complete",MessageBoxButtons.OK,MessageBoxIcon.Information); }else{ string extra=outcome.RebootRequired ? "\r\n\r\nRestart Windows, then use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors if needed." : "\r\n\r\nThe application is installed and usable. The sensor supervisor continues in the background. If protected CPU/GPU/storage telemetry is still unavailable after a short wait or restart, use Start Menu > Taskbar Monitor Enhanced - Repair Hardware Sensors."; - MessageBox.Show(Product+" 1.7.7 was installed successfully.\r\n\r\n"+outcome.Message+extra, + MessageBox.Show(Product+" 1.7.8 was installed successfully.\r\n\r\n"+outcome.Message+extra, "Setup complete - sensor warning",MessageBoxButtons.OK,MessageBoxIcon.Warning); } Close(); @@ -837,6 +837,9 @@ internal static partial class SetupProgram const string InstalledV174="1.7.4"; const string InstalledV175="1.7.5"; const string InstalledV176="1.7.6"; + const string InstalledV177="1.7.7"; + const string OfficialV177SetupSHA="395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D"; + const string OfficialV177MainSHA="A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253"; const string OfficialV176SetupSHA="D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0"; const string OfficialV176MainSHA="0FA50938D330DB02D7D66593740F51FEE5D3036C59484E5697C34DCBC55A2DF8"; const string OfficialV175SetupSHA="8E37397F02273952B5866003FC156E152B13FC90D95D80B5DB4208046DD588EC"; @@ -1101,6 +1104,32 @@ internal static partial class SetupProgram "Uninstall.exe|D1FE446CC2D8274A5B63E8080FAB15767A6799B5A377584B0F7B923056E2CEE0", }; + // Published immutable official v1.7.7 managed identities, exact 22 files. + static readonly string[] OfficialStableV177Rows=new string[] { + "TaskbarMonitorEnhanced.exe|A6D4BFD00C918C0601A12D185F414E92F2B6A85927042B811537B0BC37606253", + "TaskbarMonitorEnhanced.exe.config|D2E51872D93F0793C02AD07DCBF8C2802F59708C666548372A4FDDDA5C7C5E4F", + "TaskbarMonitorEnhanced.ico|0AD07C8E5DB434EE0403FCC92557C89D6E8EDF6A61FABD285C45518DF73A8EEF", + "Source/TaskbarMonitorEnhanced.cs|B95546C73093486D54395FBEE891002A49913F9234713C16A11DCAC23EDFD962", + "Source/StudioThemes.cs|7872BBDDC6DFD95FD8943CD5B714E47A9AB44D40AC15CBD8DA35F643768808CD", + "Source/WallpaperStudio.cs|14DF1E87A49D6D791D4B9501E757E5E2EE466BCEF9618523CF0D1FCE584E461D", + "Source/TaskbarGlass.cs|396AD7DA39674099999ADB4A9C98458A876A8DE5BFABB1DC640EE46BC2F63306", + "Source/WorkspaceModels.cs|2146D1F369A89A684F504BAB71D6686A381E79260980F7E0ACBCBAC2B3484D19", + "Source/MonitorWorkspace.cs|BA512B04F326DF09B6F9051E34D5CF0C6D24C9DC12DF03B948624DA7F5941F65", + "Source/WorkspaceSelfTest.cs|7D1B3A1FA0A46CAC47D0063E2ED7929C5299F2DE0FD394BD6C82E53B72590FA5", + "Source/TaskbarMonitorSensorBroker.cs|D650CBC8861027F4A8EC3B48B71769840D2E6CB5F89DDBEBA949EE0CB6A72C0B", + "Source/TaskbarMonitorSensorSupervisor.cs|DEDF15076D4908A96B0791AC46DAE8F773D4256D9961EE44625F701D5770C60B", + "Source/TaskbarMonitorEnhanced_Setup.cs|65284119FE8759314BDDD3309837B2377AB6682BE98DF3995363908822A4CA38", + "Docs/LICENSE|3972DC9744F6499F0F9B2DBF76696F2AE7AD8AF9B23DDE66D6AF86C9DFB36986", + "Docs/README.md|F136012748AD1D1560EC5D5342D2D1228E5A90EE59B094ECA0E8D3CCAB51A63F", + "Docs/AUTHORS.md|0005B1D25E43C9CB1AF6C6428D7662F0F6E383D295CE66E5E7768ED8965BFA40", + "Docs/COPYRIGHT_AND_ATTRIBUTION.md|503CF0194431F8FFE884014165111702061BB16B57C4BA118CE334489DBF4153", + "Docs/AI_ASSISTED_DEVELOPMENT.md|7793264C5E9256888B069E88BD9ABAE692193CFEB6C28E21ECC8F69F3E3172EB", + "Docs/THIRD_PARTY_NOTICES.md|7A8F14410B0B934447FB952E7AB0248B8D3342F88358F0F6B0D4332833D3F559", + "Docs/RELEASE_NOTES_v1.7.7.md|74FDDEBDDE733F1F0E2BE2C84824E4784D4CC244094660199094DA5550091A83", + "Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md|14B86621663CDFB7A6750D9E5ABFE7BE8AA72433A56AA08382698BA09261A293", + "Uninstall.exe|395AEE56006F55FD5DCCFB52E1519088CE01A10386840D7008C6BA900136A06D", + }; + sealed class UpgradeTarget { internal readonly string Rel; @@ -1128,7 +1157,7 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/COPYRIGHT_AND_ATTRIBUTION.md","Payload.COPYRIGHT_AND_ATTRIBUTION.md"), new UpgradeTarget("Docs/AI_ASSISTED_DEVELOPMENT.md","Payload.AI_ASSISTED_DEVELOPMENT.md"), new UpgradeTarget("Docs/THIRD_PARTY_NOTICES.md","Payload.THIRD_PARTY_NOTICES.md"), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.7.md","Payload.RELEASE_NOTES_v1.7.7.md"), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.8.md","Payload.RELEASE_NOTES_v1.7.8.md"), new UpgradeTarget("Docs/UPSTREAM_REFERENCE_GPL_NOTICE.md","Payload.UPSTREAM_REFERENCE_GPL_NOTICE.md"), new UpgradeTarget("Uninstall.exe","@SELF"), new UpgradeTarget("install_state.json","@INSTALLSTATE"), @@ -1140,7 +1169,8 @@ sealed class UpgradeTarget new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.3.md",null), new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.4.md",null), new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.5.md",null), - new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.6.md",null) // previous version, guarded byte-for-byte + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.6.md",null), + new UpgradeTarget("Docs/RELEASE_NOTES_v1.7.7.md",null) // previous version, guarded byte-for-byte }; static string UpgradeHash(string path) @@ -1170,6 +1200,8 @@ static Dictionary LegacyExpected(string version,string channel) { rows=OfficialStableV172Rows; expectedSetup=OfficialV172SetupSHA; count=23; } else if(version==InstalledV173 && channel=="OFFICIAL_STABLE_V173") { rows=OfficialStableV173Rows; expectedSetup=OfficialV173SetupSHA; count=23; } + else if(version==InstalledV177 && channel=="OFFICIAL_STABLE_V177") + { rows=OfficialStableV177Rows; expectedSetup=OfficialV177SetupSHA; count=22; } else if(version==InstalledV176 && channel=="OFFICIAL_STABLE_V176") { rows=OfficialStableV176Rows; expectedSetup=OfficialV176SetupSHA; count=22; } else if(version==InstalledV175 && channel=="OFFICIAL_STABLE_V175") @@ -1276,7 +1308,7 @@ static string NewInstallState(string source) throw new InvalidDataException("Old install state Version/PublicVersion mismatch."); string prior=installed.Groups["v"].Value; if(prior==Version)return source; - if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173 && prior!=InstalledV174 && prior!=InstalledV175 && prior!=InstalledV176) + if(prior!=InstalledV16 && prior!=InstalledV170 && prior!=InstalledV171 && prior!=InstalledV172 && prior!=InstalledV173 && prior!=InstalledV174 && prior!=InstalledV175 && prior!=InstalledV176 && prior!=InstalledV177) throw new InvalidDataException("Prior install state version not a pinned officially allowed ancestor."); string next=ReplaceStateVersion(source,"Version",prior); next=ReplaceStateVersion(next,"PublicVersion",prior); @@ -1302,7 +1334,7 @@ static Dictionary PrepareNewBytes(string previousState) if(bytes.Length<1 || fresh.ContainsKey(t.Rel))throw new InvalidDataException("Duplicate/empty payload "+t.Rel); fresh.Add(t.Rel,bytes); } - if(fresh.Count!=UpgradeTargets.Length-9) + if(fresh.Count!=UpgradeTargets.Length-10) throw new InvalidDataException("Upgrade static target map is invalid."); return fresh; } @@ -1414,7 +1446,7 @@ static void StageNewManaged(Dictionary fresh, Dictionary rows) string priorVersionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); if(!File.Exists(priorVersionPath))throw new IOException("Previous upgrade version missing; retain stage."); string priorVersion=File.ReadAllText(priorVersionPath,Encoding.ASCII); - if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=InstalledV174 && priorVersion!=InstalledV175 && priorVersion!=InstalledV176 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); + if(priorVersion!=InstalledV16 && priorVersion!=InstalledV170 && priorVersion!=InstalledV171 && priorVersion!=InstalledV172 && priorVersion!=InstalledV173 && priorVersion!=InstalledV174 && priorVersion!=InstalledV175 && priorVersion!=InstalledV176 && priorVersion!=InstalledV177 && priorVersion!=Version)throw new IOException("Unknown previous version; retain stage."); // R284: an interrupted File.Copy(old, old/rel.restore, false) can // leave only a partial updater-owned scratch. A retry must recreate // only these exact scratch paths AFTER owner/index verification. @@ -1566,8 +1598,8 @@ static SensorOutcome UpgradeExisting() CheckSingleCanonicalRoot(); string installed=CurrentInstalledVersion(); string newSha=Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"); - if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=InstalledV174 && installed!=InstalledV175 && installed!=InstalledV176 && installed!=Version) - throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3/v1.7.4/v1.7.5/v1.7.6 may upgrade in place."); + if(installed!=InstalledV16 && installed!=InstalledV170 && installed!=InstalledV171 && installed!=InstalledV172 && installed!=InstalledV173 && installed!=InstalledV174 && installed!=InstalledV175 && installed!=InstalledV176 && installed!=InstalledV177 && installed!=Version) + throw new InvalidOperationException("Only SHA-pinned official v1.6/v1.7.0/v1.7.1/v1.7.2/v1.7.3/v1.7.4/v1.7.5/v1.7.6/v1.7.7 may upgrade in place."); string channel="OFFICIAL_V16"; string currentMainSHA=UpgradeHash(AppExe); string installedSetup=UPath("Uninstall.exe"); @@ -1586,7 +1618,8 @@ static SensorOutcome UpgradeExisting() !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.3.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.4.md")) && !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.5.md")) && - !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.6.md"))) + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.6.md")) && + !File.Exists(UPath("Docs/RELEASE_NOTES_v1.7.7.md"))) return ExistingSensorOutcome(); throw new InvalidOperationException("Unknown current-version maintenance revision; no files changed."); } @@ -1625,6 +1658,13 @@ static SensorOutcome UpgradeExisting() channel="OFFICIAL_STABLE_V174"; } + if(installed==InstalledV177) + { + if(currentMainSHA!=OfficialV177MainSHA || oldSetupSHA!=OfficialV177SetupSHA) + throw new InvalidOperationException("Unknown official v1.7.7 Main/Setup identity; NO mutation."); + channel="OFFICIAL_STABLE_V177"; + } + if(installed==InstalledV176) { if(currentMainSHA!=OfficialV176MainSHA || oldSetupSHA!=OfficialV176SetupSHA) diff --git a/src/TaskbarMonitorEnhanced.cs b/src/TaskbarMonitorEnhanced.cs index 23397beb..0de9ad2a 100644 --- a/src/TaskbarMonitorEnhanced.cs +++ b/src/TaskbarMonitorEnhanced.cs @@ -33,18 +33,18 @@ [assembly: AssemblyDescription("Live system monitor integrated into the Windows taskbar")] [assembly: AssemblyProduct("Taskbar Monitor Enhanced")] [assembly: AssemblyCompany("Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyInformationalVersion("1.7.7+r284")] +[assembly: AssemblyInformationalVersion("1.7.8+r286")] [assembly: AssemblyCopyright("Copyright © 2026 Dr. Ali-Akbar Emadeddin")] -[assembly: AssemblyVersion("1.7.7.0")] -[assembly: AssemblyFileVersion("1.7.7.0")] +[assembly: AssemblyVersion("1.7.8.0")] +[assembly: AssemblyFileVersion("1.7.8.0")] namespace TaskbarMonitorEnhanced { internal static class BuildInfo { - public const string Version = "V1_7_7_R284_RESTORE_SCRATCH_RECOVERY"; + public const string Version = "V1_7_8_R286_OPTIONAL_TEMP_SOUND"; public const string Product = "Taskbar Monitor Enhanced"; - public const string PublicVersion = "1.7.7"; + public const string PublicVersion = "1.7.8"; public const string ShortcutName = "Taskbar Monitor Enhanced"; public const string ProductDescription = "Live system monitor integrated into the Windows taskbar"; public const string Author = "Dr. Ali-Akbar Emadeddin"; @@ -414,6 +414,8 @@ public sealed partial class AppConfig public bool AutoCheckUpdates { get; set; } public bool EnableTemperatureAlerts { get; set; } public bool EnableTemperatureNotifications { get; set; } + // Explicitly opt in to the current user's Windows warning sound. + public bool EnableTemperatureWarningSound { get; set; } public int CpuTempWarningC { get; set; } public int GpuTempWarningC { get; set; } public int DiskTempWarningC { get; set; } @@ -457,7 +459,7 @@ public AppConfig() MemoryUnit = "Auto"; StorageUnit = "Auto"; NetworkUnit = "Auto"; DiskRateUnit = "Auto"; ShowRamNumeric = true; ShowDiskNumeric = true; EnableHardwareFlyout = true; HoverShowAllDevices = true; AutoCheckUpdates = true; - EnableTemperatureAlerts = true; EnableTemperatureNotifications = false; CpuTempWarningC = 85; GpuTempWarningC = 85; DiskTempWarningC = 60; + EnableTemperatureAlerts = true; EnableTemperatureNotifications = false; EnableTemperatureWarningSound = false; CpuTempWarningC = 85; GpuTempWarningC = 85; DiskTempWarningC = 60; AdaptiveBatteryMode = true; BatteryUpdateIntervalMs = 3000; SparklineHistoryLength = 120; FollowWindowsTheme=false;WindowsLightTheme="Nordic Light";WindowsDarkTheme="Dark Minimal Pro";TaskbarDisplay="Primary"; InitializeWorkspaceDefaults(); @@ -1004,6 +1006,14 @@ public static bool IsDiskHot(bool available,float value,AppConfig c) public static Color AlertColor { get { return Color.FromArgb(255,82,82); } } } + internal static class TemperatureWarningSoundPolicy + { + // Do not play for a silent notification preference or unavailable UI. + // The caller already enforces snooze/quiet hours and hysteresis. + public static bool ShouldPlay(AppConfig config) + {return config!=null&&config.EnableTemperatureNotifications&&config.EnableTemperatureWarningSound;} + } + internal sealed class TemperatureNotificationState { internal const float HysteresisC=3f; @@ -4658,6 +4668,18 @@ private void ShowTemperatureNotification(string lane,bool available,float value, RecordAlert(lane+" temperature",body); try{trayIcon.ShowBalloonTip(10000,"Taskbar Monitor Enhanced - temperature warning",body,ToolTipIcon.Warning);Log.Write("WARN","TEMPERATURE_NOTIFICATION lane="+lane+" valueC="+value.ToString("0.0",CultureInfo.InvariantCulture)+" thresholdC="+threshold.ToString("0",CultureInfo.InvariantCulture));} catch(Exception ex){Log.Write("WARN","TEMPERATURE_NOTIFICATION_FAIL lane="+lane+" "+ex.Message);} + if(TemperatureWarningSoundPolicy.ShouldPlay(config)) + { + try + { + // Rate-limited by ShouldNotify above, follows user's Windows + // selected Exclamation sound scheme and output volume. + System.Media.SystemSounds.Exclamation.Play(); + Log.Write("INFO","TEMPERATURE_WARNING_SOUND_REQUESTED lane="+lane); + } + catch(Exception ex) + {Log.Write("WARN","TEMPERATURE_WARNING_SOUND_FAIL lane="+lane+" "+ex.GetType().Name);} + } } private void ReadMetrics() @@ -7085,11 +7107,17 @@ public static int Run() AppConfig c=new AppConfig();c.Normalize(); if(c.ConfigSchemaVersion<6)throw new InvalidOperationException("config schema"); if(c.FollowWindowsTheme||c.WindowsLightTheme!="Nordic Light"||c.WindowsDarkTheme!="Dark Minimal Pro"||c.TaskbarDisplay!="Primary")throw new InvalidOperationException("Windows integration defaults"); - if(!c.EnableTemperatureAlerts||c.EnableTemperatureNotifications||c.CpuTempWarningC!=85||c.GpuTempWarningC!=85||c.DiskTempWarningC!=60) + if(!c.EnableTemperatureAlerts||c.EnableTemperatureNotifications||c.EnableTemperatureWarningSound||c.CpuTempWarningC!=85||c.GpuTempWarningC!=85||c.DiskTempWarningC!=60) throw new InvalidOperationException("temperature alert defaults"); if(!AlertPolicy.IsCpuHot(true,85,c)||AlertPolicy.IsCpuHot(true,84.9f,c))throw new InvalidOperationException("CPU alert boundary"); if(!AlertPolicy.IsGpuHot(true,90,c)||AlertPolicy.IsGpuHot(false,100,c))throw new InvalidOperationException("GPU alert boundary"); if(!AlertPolicy.IsDiskHot(true,60,c)||AlertPolicy.IsDiskHot(true,59.9f,c))throw new InvalidOperationException("disk alert boundary"); + if(TemperatureWarningSoundPolicy.ShouldPlay(c))throw new InvalidOperationException("audio off by default"); + c.EnableTemperatureNotifications=true;c.EnableTemperatureWarningSound=true; + if(!TemperatureWarningSoundPolicy.ShouldPlay(c))throw new InvalidOperationException("audio explicit opt-in"); + c.EnableTemperatureNotifications=false; + if(TemperatureWarningSoundPolicy.ShouldPlay(c))throw new InvalidOperationException("audio muted when toast disabled"); + c.EnableTemperatureWarningSound=false; TemperatureNotificationState notificationState=new TemperatureNotificationState(); DateTime nt=new DateTime(2026,10,2,12,0,0,DateTimeKind.Utc); if(!notificationState.ShouldNotify("CPU",true,85,85,nt))throw new InvalidOperationException("notification first crossing"); @@ -7182,7 +7210,7 @@ private sealed class HardwareChoice private GlassToggleSwitch glassModeToggle; private Label glassModeState; private int lastOpacityPercent; - private CheckBox cpu,ram,disk,gpu,vram,net,temp,spark,startup,safe,floatAboveStart,ramNumeric,diskNumeric,flyout,hoverAll,autoCheckUpdates,tempAlerts,tempNotifications,adaptiveBattery,followWindowsTheme; + private CheckBox cpu,ram,disk,gpu,vram,net,temp,spark,startup,safe,floatAboveStart,ramNumeric,diskNumeric,flyout,hoverAll,autoCheckUpdates,tempAlerts,tempNotifications,tempSound,adaptiveBattery,followWindowsTheme; private CheckedListBox cpuList,gpuList,diskList,netList; private Label updateCurrent,updateLatest,updateStatus; private Button checkUpdate,installUpdate,openRelease; @@ -7264,7 +7292,7 @@ internal SettingsForm(AppConfig config,MetricsSnapshot current,string initialTab TabPage display=new TabPage("Display"), metrics=new TabPage("Metrics"), alerts=new TabPage("Alerts"), hardware=new TabPage("Hardware"), units=new TabPage("Units"), behavior=new TabPage("Behavior"), updates=new TabPage("Updates"), diagnostics=new TabPage("Diagnostics"), advanced=new TabPage("Advanced"); tabs.TabPages.Add(display);tabs.TabPages.Add(metrics);tabs.TabPages.Add(alerts);tabs.TabPages.Add(hardware);tabs.TabPages.Add(units);tabs.TabPages.Add(behavior);tabs.TabPages.Add(updates);tabs.TabPages.Add(diagnostics);tabs.TabPages.Add(advanced); foreach(TabPage page in tabs.TabPages){page.AutoScroll=true;page.Padding=new Padding(10);page.BackColor=SettingsWindow;page.ForeColor=SettingsText;} - display.AutoScrollMinSize=new Size(760,440);metrics.AutoScrollMinSize=new Size(760,430);alerts.AutoScrollMinSize=new Size(760,430);hardware.AutoScrollMinSize=new Size(760,760);units.AutoScrollMinSize=new Size(760,540);behavior.AutoScrollMinSize=new Size(760,590);updates.AutoScrollMinSize=new Size(760,540);diagnostics.AutoScrollMinSize=new Size(840,700);advanced.AutoScrollMinSize=new Size(760,540); + display.AutoScrollMinSize=new Size(760,440);metrics.AutoScrollMinSize=new Size(760,430);alerts.AutoScrollMinSize=new Size(760,520);hardware.AutoScrollMinSize=new Size(760,760);units.AutoScrollMinSize=new Size(760,540);behavior.AutoScrollMinSize=new Size(760,590);updates.AutoScrollMinSize=new Size(760,540);diagnostics.AutoScrollMinSize=new Size(840,700);advanced.AutoScrollMinSize=new Size(760,540); theme=Combo(display,"Theme",ThemeCatalog.Names,c.Theme,22);position=Combo(display,"Position",new string[]{"Left","Center","Right"},c.Position,68); taskbarDisplay=Combo(display,"Taskbar monitor",TaskbarTargetPolicy.SettingChoices(c.TaskbarDisplay),c.TaskbarDisplay,114); @@ -7297,10 +7325,25 @@ internal SettingsForm(AppConfig config,MetricsSnapshot current,string initialTab tempAlerts=Check(alerts,"Enable temperature warning highlights",c.EnableTemperatureAlerts,24); tempNotifications=Check(alerts,"Show Windows notification when a threshold is crossed",c.EnableTemperatureNotifications,55); - cpuWarn=Number(alerts,"CPU warning °C",c.CpuTempWarningC,50,110,102); - gpuWarn=Number(alerts,"GPU warning °C",c.GpuTempWarningC,50,110,152); - diskWarn=Number(alerts,"Disk warning °C",c.DiskTempWarningC,40,90,202); - Label alertNote=new Label();alertNote.Location=new Point(24,258);alertNote.Size=new Size(690,120);alertNote.Text="Selected CPU, GPU or disk temperatures can switch the metric card to a high-visibility warning accent. Optional Windows notifications fire only on a new hot transition, use a 3 C recovery hysteresis and a 10-minute per-lane cooldown. Unsupported or stale temperature data never creates a false hot state.";alerts.Controls.Add(alertNote); + tempSound=Check(alerts,"Play Windows warning sound on a new hot threshold",c.EnableTemperatureWarningSound,86); + cpuWarn=Number(alerts,"CPU warning °C",c.CpuTempWarningC,50,110,135); + gpuWarn=Number(alerts,"GPU warning °C",c.GpuTempWarningC,50,110,185); + diskWarn=Number(alerts,"Disk warning °C",c.DiskTempWarningC,40,90,235); + Label alertNote=new Label();alertNote.Location=new Point(24,284);alertNote.Size=new Size(690,120);alertNote.Text="Selected CPU, GPU or disk temperatures can switch the metric card to a high-visibility warning accent. Optional Windows notifications fire only on a new hot transition, use a 3 C recovery hysteresis and a 10-minute per-lane cooldown. Unsupported or stale temperature data never creates a false hot state. Sound is opt-in and follows your Windows sound scheme.";alerts.Controls.Add(alertNote); + Button testWarningSound=new Button{Text="Test warning sound",Location=new Point(24,420), + Size=new Size(200,36),AutoSize=false}; + SetA11y(testWarningSound,"Test temperature warning sound", + "Play one Windows Exclamation sound without heating the CPU or changing alarm thresholds."); + testWarningSound.Click+=delegate + { + try + { + System.Media.SystemSounds.Exclamation.Play(); + Log.Write("INFO","TEMPERATURE_WARNING_SOUND_TEST_REQUESTED"); + } + catch(Exception ex){Log.Write("WARN","TEMPERATURE_WARNING_SOUND_TEST_FAILED "+ex.GetType().Name);} + }; + alerts.Controls.Add(testWarningSound); hardware.AutoScroll=true;hardware.AutoScrollMargin=new Size(0,24);int y=16; AddHardwareSection(hardware,"CPU",c.CpuDisplayMode,ChoicesCpu(),c.SelectedCpuIds,y,out cpuMode,out cpuList);y+=118; @@ -7828,7 +7871,7 @@ private void ResetControlsToSafeDefaults() memoryUnit.SelectedItem=d.MemoryUnit;storageUnit.SelectedItem=d.StorageUnit;diskRateUnit.SelectedItem=d.DiskRateUnit;networkUnit.SelectedItem=d.NetworkUnit; ramNumeric.Checked=d.ShowRamNumeric;diskNumeric.Checked=d.ShowDiskNumeric;flyout.Checked=d.EnableHardwareFlyout;hoverAll.Checked=d.HoverShowAllDevices; interval.Value=d.UpdateIntervalMs;startup.Checked=d.StartWithWindows;safe.Checked=d.SafePlacement;floatAboveStart.Checked=d.FloatAboveStartMenu;autoCheckUpdates.Checked=d.AutoCheckUpdates; - tempAlerts.Checked=d.EnableTemperatureAlerts;tempNotifications.Checked=d.EnableTemperatureNotifications;cpuWarn.Value=d.CpuTempWarningC;gpuWarn.Value=d.GpuTempWarningC;diskWarn.Value=d.DiskTempWarningC; + tempAlerts.Checked=d.EnableTemperatureAlerts;tempNotifications.Checked=d.EnableTemperatureNotifications;tempSound.Checked=d.EnableTemperatureWarningSound;cpuWarn.Value=d.CpuTempWarningC;gpuWarn.Value=d.GpuTempWarningC;diskWarn.Value=d.DiskTempWarningC; adaptiveBattery.Checked=d.AdaptiveBatteryMode;batteryInterval.Value=d.BatteryUpdateIntervalMs;historyLength.Value=d.SparklineHistoryLength;followWindowsTheme.Checked=d.FollowWindowsTheme;windowsLightTheme.SelectedItem=d.WindowsLightTheme;windowsDarkTheme.SelectedItem=d.WindowsDarkTheme;taskbarDisplay.SelectedItem=d.TaskbarDisplay; CheckAll(cpuList);CheckAll(gpuList);CheckAll(diskList);CheckAll(netList); if(themePreview!=null)themePreview.ThemeName=d.Theme; @@ -7901,7 +7944,7 @@ private void Apply() c.Opacity=c.GlassMode?0.0:(double)opacity.Value/100.0; c.MinWidthLogicalPx=(int)width.Value;c.UpdateIntervalMs=(int)interval.Value;c.ShowCpu=cpu.Checked;c.ShowRam=ram.Checked;c.ShowDisk=disk.Checked;c.ShowGpu=gpu.Checked;c.ShowVram=vram.Checked;c.ShowNetwork=net.Checked;c.ShowTemperatures=temp.Checked;c.ShowSparklines=spark.Checked;c.StartWithWindows=startup.Checked;c.SafePlacement=safe.Checked;c.FloatAboveStartMenu=floatAboveStart.Checked; c.CpuDisplayMode=(string)cpuMode.SelectedItem;c.GpuDisplayMode=(string)gpuMode.SelectedItem;c.DiskDisplayMode=(string)diskMode.SelectedItem;c.NetworkDisplayMode=(string)netMode.SelectedItem;c.SelectedCpuIds=CheckedIds(cpuList);c.SelectedGpuIds=CheckedIds(gpuList);c.SelectedDiskIds=CheckedIds(diskList);c.SelectedNetworkIds=CheckedIds(netList);c.MultipleDeviceLayout=(string)multiLayout.SelectedItem;c.MemoryUnit=(string)memoryUnit.SelectedItem;c.StorageUnit=(string)storageUnit.SelectedItem;c.DiskRateUnit=(string)diskRateUnit.SelectedItem;c.NetworkUnit=(string)networkUnit.SelectedItem;c.ShowRamNumeric=ramNumeric.Checked;c.ShowDiskNumeric=diskNumeric.Checked;c.EnableHardwareFlyout=flyout.Checked;c.HoverShowAllDevices=hoverAll.Checked;c.AutoCheckUpdates=autoCheckUpdates.Checked; - c.EnableTemperatureAlerts=tempAlerts.Checked;c.EnableTemperatureNotifications=tempNotifications.Checked;c.CpuTempWarningC=(int)cpuWarn.Value;c.GpuTempWarningC=(int)gpuWarn.Value;c.DiskTempWarningC=(int)diskWarn.Value;c.AdaptiveBatteryMode=adaptiveBattery.Checked;c.BatteryUpdateIntervalMs=(int)batteryInterval.Value;c.SparklineHistoryLength=(int)historyLength.Value;c.FollowWindowsTheme=followWindowsTheme.Checked;c.WindowsLightTheme=Convert.ToString(windowsLightTheme.SelectedItem,CultureInfo.InvariantCulture);c.WindowsDarkTheme=Convert.ToString(windowsDarkTheme.SelectedItem,CultureInfo.InvariantCulture);c.TaskbarDisplay=TaskbarTargetPolicy.NormalizeConfigId(Convert.ToString(taskbarDisplay.SelectedItem,CultureInfo.InvariantCulture)); + c.EnableTemperatureAlerts=tempAlerts.Checked;c.EnableTemperatureNotifications=tempNotifications.Checked;c.EnableTemperatureWarningSound=tempSound.Checked;c.CpuTempWarningC=(int)cpuWarn.Value;c.GpuTempWarningC=(int)gpuWarn.Value;c.DiskTempWarningC=(int)diskWarn.Value;c.AdaptiveBatteryMode=adaptiveBattery.Checked;c.BatteryUpdateIntervalMs=(int)batteryInterval.Value;c.SparklineHistoryLength=(int)historyLength.Value;c.FollowWindowsTheme=followWindowsTheme.Checked;c.WindowsLightTheme=Convert.ToString(windowsLightTheme.SelectedItem,CultureInfo.InvariantCulture);c.WindowsDarkTheme=Convert.ToString(windowsDarkTheme.SelectedItem,CultureInfo.InvariantCulture);c.TaskbarDisplay=TaskbarTargetPolicy.NormalizeConfigId(Convert.ToString(taskbarDisplay.SelectedItem,CultureInfo.InvariantCulture)); } } diff --git a/src/WorkspaceSelfTest.cs b/src/WorkspaceSelfTest.cs index 874e16ae..634d9188 100644 --- a/src/WorkspaceSelfTest.cs +++ b/src/WorkspaceSelfTest.cs @@ -126,7 +126,7 @@ internal static int Run() { try { - Check(BuildInfo.PublicVersion=="1.7.7","version"); + Check(BuildInfo.PublicVersion=="1.7.8","version"); for(int schema=0;schema<7;schema++){ var legacy=new AppConfig{ConfigSchemaVersion=schema,EnableTemperatureNotifications=true,CpuTempWarningC=0,FontSize=Double.NaN,Opacity=Double.NaN};legacy.Normalize(); Check(legacy.ConfigSchemaVersion==8,"all legacy schemas migrate "+schema); From 486abbbbf7d1c50097113d77c8f00ee13b393c09 Mon Sep 17 00:00:00 2001 From: Aliakbar Emadoddin <94546844+GOD13emad@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:23:03 +0330 Subject: [PATCH 016/135] fix(installer): bind validated upgrade journal paths to static target allowlist --- installer/TaskbarMonitorEnhanced_Setup.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index f987f726..a94e3c4d 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1469,7 +1469,8 @@ static List ReadStageIndex() (parts[1]!="-" && !Regex.IsMatch(parts[1],"^[0-9A-F]{64}$")) || (parts[2]!="-" && !Regex.IsMatch(parts[2],"^[0-9A-F]{64}$"))) throw new InvalidDataException("Upgrade journal path or digest modified."); - parsed.Add(new StageEntry(parts[0],parts[1],parts[2])); + // Convert the validated on-disk label back to the compiled-in target before filesystem use. + parsed.Add(new StageEntry(UpgradeTargets[i].Rel,parts[1],parts[2])); } return parsed; } From 643e825408169d93056c9b7c6a029d155318113a Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 08:51:55 +0330 Subject: [PATCH 017/135] fix(installer): reject junctions across interrupted upgrade paths before mutation (R290) --- ...it-r286-optional-audio-native-recovery.yml | 29 ++++ build/Test-R290-ReparseRecovery-Native.ps1 | 141 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 36 ++++- 3 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 build/Test-R290-ReparseRecovery-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 79d7723d..0cdaace2 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -130,6 +130,34 @@ jobs: ($r.NativeSetupProcessActuallyKilled -ne $true -or $r.NativeSetupManagedNoteDeletionWitnessed -ne $true)){throw 'R286_REAL_SETUP_KILL_NOT_WITNESSED'} 'R286_GUEST_TYPED_RECOVERY_PASS' + - name: R290 reject native child junctions before any recovery mutation + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R290-ReparseRecovery-Native.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R290_NATIVE_TEST_PARSER_INVALID'} + ./build/Test-R290-ReparseRecovery-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + if($LASTEXITCODE -ne 0){throw 'R290_NATIVE_TEST_FAILED'} + $p=Join-Path $env:RUNNER_TEMP ('R290_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R290_REPARSE_RECEIPT.json') + if(!(Test-Path $p)){throw 'R290_GUEST_RECEIPT_MISSING'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R290' -or + $r.Status -cne 'PASS_NATIVE_UPGRADE_CHILD_REPARSE_FAIL_CLOSED' -or + $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.NativeWindowsJunctionCreated -ne $true -or + $r.NormalPathAcceptance -ne $true -or + $r.ParentTraversalRejected -ne $true -or + $r.LiveChildJunctionRejected -ne $true -or + $r.StageOldJunctionRejected -ne $true -or + $r.RecoverPreMutationRejected -ne $true -or + $r.NoExternalCanaryMutation -ne $true -or + $r.InstalledMainAndConfigUnchanged -ne $true -or + $r.NoStageOrLinkResidue -ne $true){throw 'R290_TYPED_NATIVE_REPARSE_RECEIPT_INVALID'} + 'R290_NATIVE_CHILD_REPARSE_GUARDS_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -140,3 +168,4 @@ jobs: path: | ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/R286_RECOVERY_RECEIPT.json ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/ARMED.json + ${{ runner.temp }}/R290_${{ matrix.host }}_${{ github.run_id }}/R290_REPARSE_RECEIPT.json diff --git a/build/Test-R290-ReparseRecovery-Native.ps1 b/build/Test-R290-ReparseRecovery-Native.ps1 new file mode 100644 index 00000000..8c4ad6cc --- /dev/null +++ b/build/Test-R290-ReparseRecovery-Native.ps1 @@ -0,0 +1,141 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# Only ephemeral GitHub Windows runners, never the owner's installation. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R290_ONLY_GITHUB_DISPOSABLE_WINDOWS' +} +$dir=Join-Path $env:RUNNER_TEMP ('R290_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$app=Join-Path $env:LOCALAPPDATA 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$alias=Join-Path $app 'R290-Junction-Only-Temporary' +$oldLink=Join-Path $stage 'old' +$outside=Join-Path $dir 'external-canary' +$canary=Join-Path $outside 'do-not-change.txt' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R286_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R286_RECOVERY_RECEIPT.json') +$gates=[Collections.Generic.List[string]]::new() +$accepted=$false +$phase='INITIAL' +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Expect-ReparseRefusal([string]$gate,[Reflection.MethodInfo]$method,[object[]]$arguments){ + $refused=$false + try{[void]$method.Invoke($null,$arguments)} + catch{ + $message=$_.Exception.ToString() + if($message -match '(?i)symlink/junction|reparse'){$refused=$true} + else{throw ('R290_UNEXPECTED_EXCEPTION_'+$gate+': '+$message)} + } + if(!$refused){throw ('R290_FAIL_OPEN_'+$gate)} + $script:gates.Add($gate) +} +if(!(Test-Path $app) -or (Test-Path $dir) -or + (Test-Path $alias) -or (Test-Path $stage) -or + @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -ne 0){ + throw 'R290_PREVIOUS_TEST_DID_NOT_LEAVE_CLEAN_GUEST' +} +$previous=Get-Content $prior -Raw|ConvertFrom-Json +$candidate=Get-Content $manifest -Raw|ConvertFrom-Json +if($previous.Status -cne 'PASS_EXACT_V178_SETUP_RETRY_AFTER_STRANDED_RESTORE' -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $candidate.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $candidate.SetupSHA256 -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $candidate.MainSHA256){ + throw 'R290_UNPINNED_CANDIDATE_OR_PRIOR_ACCEPTANCE' +} +$mainBefore=Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe') +$configBefore=Hash (Join-Path $app 'config.json') +$stateBefore=Hash (Join-Path $app 'install_state.json') +[IO.Directory]::CreateDirectory($outside)|Out-Null +[IO.File]::WriteAllText($canary,'R290_EXTERNAL_CANARY_MUST_NOT_CHANGE',[Text.UTF8Encoding]::new($false)) +$canaryBefore=Hash $canary +try{ + $phase='REFLECTION_PREPARE' + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'NonPublic,Static' + $liveMethod=$type.GetMethod('UPath',$flags) + $stageMethod=$type.GetMethod('Stage',$flags) + $recoverMethod=$type.GetMethod('RecoverUpgradeStageIfAny',$flags) + if(!$liveMethod -or !$stageMethod -or !$recoverMethod){throw 'R290_EXPECTED_CANDIDATE_METHODS_MISSING'} + $normal=[string]$liveMethod.Invoke($null,[object[]]@('TaskbarMonitorEnhanced.exe')) + if($normal -cne (Join-Path $app 'TaskbarMonitorEnhanced.exe')){throw 'R290_NORMAL_MANAGED_PATH_CHANGED'} + [void]$stageMethod.Invoke($null,[object[]]@('old','TaskbarMonitorEnhanced.exe')) + $gates.Add('NORMAL_FIXED_APP_AND_STAGE_PATHS_ACCEPTED') + $phase='VALIDATE_TRAVERSAL_REJECTION' + $invalidRejected=$false + try{[void]$stageMethod.Invoke($null,[object[]]@('old','..\..\outside.txt'))} + catch{if($_.Exception.ToString() -match 'unsafe staged resource path|stage escape'){$invalidRejected=$true}else{throw}} + if(!$invalidRejected){throw 'R290_TRAVERSAL_NOT_REJECTED'} + $gates.Add('RELATIVE_TRAVERSAL_REJECTED') + $phase='LIVE_CHILD_JUNCTION' + New-Item -ItemType Junction -Path $alias -Target $outside -ErrorAction Stop|Out-Null + if(-not ((Get-Item -LiteralPath $alias -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R290_REAL_WINDOWS_JUNCTION_NOT_CREATED' + } + Expect-ReparseRefusal 'LIVE_CHILD_JUNCTION_REFUSED' $liveMethod ([object[]]@('R290-Junction-Only-Temporary/do-not-change.txt')) + [IO.Directory]::Delete($alias) + if(Test-Path $alias){throw 'R290_CANNOT_REMOVE_ONLY_LINK'} + $phase='INTERRUPTED_STAGE_CHILD_JUNCTION' + [IO.Directory]::CreateDirectory($stage)|Out-Null + New-Item -ItemType Junction -Path $oldLink -Target $outside -ErrorAction Stop|Out-Null + if(-not ((Get-Item -LiteralPath $oldLink -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R290_REAL_STAGE_JUNCTION_NOT_CREATED' + } + Expect-ReparseRefusal 'STAGED_OLD_JUNCTION_REFUSED' $stageMethod ([object[]]@('old','TaskbarMonitorEnhanced.exe')) + # Reparse refusal must occur before reading any bogus stage metadata. + Expect-ReparseRefusal 'RECOVERY_REJECTS_BEFORE_ANY_FILE_MUTATION' $recoverMethod ([object[]]@()) + [IO.Directory]::Delete($oldLink) + if(Test-Path $oldLink){throw 'R290_STAGE_LINK_CLEANUP_FAILED'} + [IO.Directory]::Delete($stage) + if(Test-Path $stage){throw 'R290_STAGE_CLEANUP_FAILED'} + $phase='CANARY_AND_PROTECTED_ASSET_READBACK' + if((Hash $canary) -cne $canaryBefore -or + (Hash (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -cne $mainBefore -or + (Hash (Join-Path $app 'config.json')) -cne $configBefore -or + (Hash (Join-Path $app 'install_state.json')) -cne $stateBefore){ + throw 'R290_EXTERNAL_OR_PROTECTED_USER_BYTES_ALTERED' + } + $gates.Add('EXTERNAL_AND_INSTALLED_DATA_UNCHANGED') + $gates.Add('NO_ROOT_STAGE_OR_JUNCTION_RESIDUE') + $receipt=[ordered]@{ + Schema=1;Revision='R290';Status='PASS_NATIVE_UPGRADE_CHILD_REPARSE_FAIL_CLOSED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false + NativeWindowsJunctionCreated=$true; NormalPathAcceptance=$true + ParentTraversalRejected=$true; LiveChildJunctionRejected=$true + StageOldJunctionRejected=$true; RecoverPreMutationRejected=$true + NoExternalCanaryMutation=$true; InstalledMainAndConfigUnchanged=$true + NoStageOrLinkResidue=$true; Tests=@($gates) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText((Join-Path $dir 'R290_REPARSE_RECEIPT.json'), + ($receipt|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R290_DISPOSABLE_GUEST_'+$HostLabel+'_CHILD_REPARSE_GUARDS_PASS' +}finally{ + if(Test-Path $alias){try{[IO.Directory]::Delete($alias)}catch{}} + if(Test-Path $oldLink){try{[IO.Directory]::Delete($oldLink)}catch{}} + if(Test-Path $stage){try{[IO.Directory]::Delete($stage)}catch{}} + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R290';Status='FAIL';Phase=$phase + SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false + RecordedUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText((Join-Path $dir 'R290_REPARSE_RECEIPT.json'), + ($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index a94e3c4d..ffec82e2 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1233,17 +1233,23 @@ static string UPath(string relative) string root=Path.GetFullPath(AppRoot).TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; if(!path.StartsWith(root,StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Upgrade refused path outside canonical app root."); + // Path.GetFullPath is lexical only; reject junctions/links before any live I/O. + CheckNoReparse(path); return path; } static void CheckNoReparse(string path) { string root=Path.GetFullPath(AppRoot); - string current=path; + string current=Path.GetFullPath(path); while(!String.IsNullOrEmpty(current)){ - if((File.Exists(current)||Directory.Exists(current)) && - (File.GetAttributes(current)&FileAttributes.ReparsePoint)!=0) - throw new IOException("Upgrade refuses an app component symlink/junction: "+current); + // File.Exists can hide inaccessible or dangling reparse paths. + // Only genuinely absent components may be skipped; all other I/O + // errors must fail closed before a rollback/write/delete. + try{ + if((File.GetAttributes(current)&FileAttributes.ReparsePoint)!=0) + throw new IOException("Upgrade refuses an app component symlink/junction: "+current); + }catch(FileNotFoundException){}catch(DirectoryNotFoundException){} if(String.Equals(current,root,StringComparison.OrdinalIgnoreCase))break; current=Path.GetDirectoryName(current); } @@ -1372,7 +1378,15 @@ static void EnsureAppNotRunning() static string Stage(string part,string rel) { if(part!="new" && part!="old")throw new InvalidDataException("Unexpected stage area."); - return Path.Combine(UpgradeRoot,part,rel.Replace('/',Path.DirectorySeparatorChar)); + if(String.IsNullOrWhiteSpace(rel) || Path.IsPathRooted(rel) || rel.Contains("..") || + rel.Contains(":") || rel.StartsWith("/") || rel.StartsWith("\\")) + throw new InvalidDataException("Upgrade refused unsafe staged resource path."); + string path=Path.GetFullPath(Path.Combine(UpgradeRoot,part,rel.Replace('/',Path.DirectorySeparatorChar))); + string root=Path.GetFullPath(UpgradeRoot).TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; + if(!path.StartsWith(root,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Upgrade refused stage escape."); + CheckNoReparse(path); + return path; } static void WritePhase(string phase) @@ -1554,8 +1568,20 @@ static void RecoverUpgradeStageIfAny() { if(!Directory.Exists(UpgradeRoot))return; CheckNoReparse(UpgradeRoot); + // Interrupted journals are writable in the user's AppData. Before + // reading them or mutating ANY managed file, reject their descendant + // links, including link-shaped phase/index files and stage subtrees. + foreach(string name in new string[]{"owner.txt","phase.txt","phase.tmp", + "index.txt","previous_version.txt","old","new"}) + CheckNoReparse(Path.Combine(UpgradeRoot,name)); string phase=GetPhase(); List rows=ReadStageIndex(); + foreach(StageEntry r in rows){ + UPath(r.Rel); + Stage("old",r.Rel); + Stage("new",r.Rel); + if(r.Previous!="-")Stage("old",r.Rel+".restore"); + } if(phase=="PREPARED"){ // No managed target is modified before COMMITTING is durably written. CleanOwnedStage(rows);return; From 4666b87fbe4b548304522a2a4d45d8db6ee41184 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 08:56:03 +0330 Subject: [PATCH 018/135] test: use typed R290 guest receipt, not inherited LASTEXITCODE --- .../workflows/audit-r286-optional-audio-native-recovery.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 0cdaace2..b4ff7f65 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -139,7 +139,8 @@ jobs: [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R290_NATIVE_TEST_PARSER_INVALID'} ./build/Test-R290-ReparseRecovery-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path - if($LASTEXITCODE -ne 0){throw 'R290_NATIVE_TEST_FAILED'} + # The script throws on failure; LASTEXITCODE can be stale from a prior native Setup. + # Validate its typed native receipt instead of mistaking that stale code for failure. $p=Join-Path $env:RUNNER_TEMP ('R290_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R290_REPARSE_RECEIPT.json') if(!(Test-Path $p)){throw 'R290_GUEST_RECEIPT_MISSING'} $r=Get-Content $p -Raw|ConvertFrom-Json From e96b0277a6bd969aa6b931681ab02eac8582abe3 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 09:39:33 +0330 Subject: [PATCH 019/135] security: validate privileged sensor helper paths before any side effects (R291) --- ...it-r286-optional-audio-native-recovery.yml | 26 ++++ .../Test-R291-SensorHelperBoundary-Native.ps1 | 137 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 92 ++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 build/Test-R291-SensorHelperBoundary-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index b4ff7f65..095d86dd 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -159,6 +159,31 @@ jobs: $r.InstalledMainAndConfigUnchanged -ne $true -or $r.NoStageOrLinkResidue -ne $true){throw 'R290_TYPED_NATIVE_REPARSE_RECEIPT_INVALID'} 'R290_NATIVE_CHILD_REPARSE_GUARDS_PASS' + - name: R291 reject untrusted paths before elevated sensor helper writes + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R291-SensorHelperBoundary-Native.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object{$_.Message};throw 'R291_PS51_NATIVE_HARNESS_PARSE_INVALID'} + ./build/Test-R291-SensorHelperBoundary-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R291_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R291_BOUNDARY_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R291_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R291' -or + $r.Status -cne 'PASS_PRIVILEGED_HELPER_BOUNDARY_NATIVE' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ProtectedServicesOrUACInvoked -ne $false -or + $r.RealWindowsJunctionUsed -ne $true -or + $r.PositiveAndNegativePathChecksPassed -ne $true -or + $r.DanglingJunctionRejected -ne $true -or + $r.EmbeddedHelperSourceMatched -ne $true -or + $r.InstalledMainUnchanged -ne $true -or + $r.CheckCount -lt 11){throw 'R291_TYPED_BOUNDARY_RECEIPT_INVALID'} + 'R291_NATIVE_ELEVATED_SENSOR_BOUNDARY_GUARDS_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -170,3 +195,4 @@ jobs: ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/R286_RECOVERY_RECEIPT.json ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/ARMED.json ${{ runner.temp }}/R290_${{ matrix.host }}_${{ github.run_id }}/R290_REPARSE_RECEIPT.json + ${{ runner.temp }}/R291_${{ matrix.host }}_${{ github.run_id }}/R291_BOUNDARY_RECEIPT.json diff --git a/build/Test-R291-SensorHelperBoundary-Native.ps1 b/build/Test-R291-SensorHelperBoundary-Native.ps1 new file mode 100644 index 00000000..0d306072 --- /dev/null +++ b/build/Test-R291-SensorHelperBoundary-Native.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R291_ONLY_DISPOSABLE_GITHUB_WINDOWS_VM' +} +$out=Join-Path $env:RUNNER_TEMP ('R291_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $out 'R291_BOUNDARY_RECEIPT.json' +$src=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$fixtureApp=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$tempRoot=Join-Path ([Environment]::GetFolderPath('UserProfile')) 'AppData\Local\Temp' +$prior=Join-Path $env:RUNNER_TEMP ('R290_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R290_REPARSE_RECEIPT.json') +$user=[Security.Principal.WindowsIdentity]::GetCurrent().Name +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$live=Join-Path $fixtureApp 'TaskbarMonitorEnhanced.exe' +$log=[Collections.Generic.List[string]]::new() +$link='';$dangling='';$external='';$missing='' +$accepted=$false +function Hash([string]$p){if(Test-Path -LiteralPath $p -PathType Leaf){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash};return 'MISSING'} +function Reject([string]$name,[string]$mode,[string]$payload,[string]$app,[string]$user){ + $failed=$false + try{[void](Assert-SensorPrivilegedBoundary $mode $payload $app $user)} + catch{ + if($_.Exception.Message -notmatch 'SENSOR_BOUNDARY_'){throw ('R291_WRONG_REJECTION_'+$name+': '+$_.Exception.Message)} + $failed=$true + } + if(!$failed){throw ('R291_FAIL_OPEN_'+$name)} + $script:log.Add($name) +} +if(Test-Path $out){throw 'R291_OUTPUT_ALREADY_PRESENT'} +[void][IO.Directory]::CreateDirectory($out) +$mainBefore=Hash $live +try{ + $priorInfo=Get-Content $prior -Raw|ConvertFrom-Json + $identity=Get-Content $manifest -Raw|ConvertFrom-Json + if($priorInfo.Status -cne 'PASS_NATIVE_UPGRADE_CHILD_REPARSE_FAIL_CLOSED' -or + $priorInfo.SourceCommit -cne $env:GITHUB_SHA -or + $identity.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $identity.SetupSHA256 -or + (Hash $live) -cne $identity.MainSHA256){throw 'R291_EXACT_GUEST_CANDIDATE_NOT_PINNED'} + $tokens=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($src,[ref]$tokens,[ref]$errors) + if(@($errors).Count){throw 'R291_PS51_SOURCE_PARSER_FAIL'} + $defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -in @('Assert-SensorBoundaryNoReparse','Assert-SensorPrivilegedBoundary') + },$true)) + if($defs.Count -ne 2){throw 'R291_GUARD_FUNCTION_COUNT_MISMATCH'} + $raw=Get-Content $src -Raw + $first=$raw.IndexOf('$validatedSensorSid=Assert-SensorPrivilegedBoundary',[StringComparison]::Ordinal) + $write=$raw.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) + if($first -lt 0 -or $write -le $first){throw 'R291_PREFLIGHT_MUST_PRECEDE_WRITES'} + $log.Add('PREFLIGHT_EARLIER_THAN_ALL_LOG_WRITES') + # Dot-source ONLY the two selected function ASTs, never the full privileged script. + . ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + $validInstall=Join-Path $tempRoot ('tbme_setup_'+('a'*32)) + $validUninstall=Join-Path $tempRoot ('tbme_uninstall_'+('b'*32)) + if((Assert-SensorPrivilegedBoundary Install $validInstall $fixtureApp $user) -cne $sid -or + (Assert-SensorPrivilegedBoundary Uninstall $validUninstall $fixtureApp $user) -cne $sid){ + throw 'R291_VALID_USER_OR_PROFILE_REJECTED' + } + $log.Add('LEGITIMATE_INSTALL_AND_UNINSTALL_ACCEPTED') + Reject 'WRONG_APP_ROOT_REFUSED' Install $validInstall (Join-Path $env:WINDIR 'TaskbarMonitorEnhanced') $user + Reject 'OUTSIDE_USER_PROFILE_REFUSED' Install (Join-Path $env:WINDIR ('tbme_setup_'+('a'*32))) $fixtureApp $user + Reject 'WRONG_MODE_PAYLOAD_REFUSED' Uninstall $validInstall $fixtureApp $user + Reject 'MALFORMED_PAYLOAD_NAME_REFUSED' Install (Join-Path $tempRoot 'tbme_setup_bad') $fixtureApp $user + Reject 'SERVICE_SID_REFUSED' Install $validInstall $fixtureApp 'S-1-5-18' + $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R291_BOUNDARY_RESOURCE_MISSING'} + $reader=[IO.StreamReader]::new($stream) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $crlf=[string][char]13+[string][char]10 + $lf=[string][char]10 + if($embedded.Replace($crlf,$lf).TrimStart([char]0xFEFF).TrimEnd() -cne + $raw.Replace($crlf,$lf).TrimStart([char]0xFEFF).TrimEnd()){ + throw 'R291_EMBEDDED_HELPER_NOT_EXACT_SOURCE' + } + $log.Add('EMBEDDED_SETUP_HELPER_MATCHES_GUARDED_SOURCE') + $external=Join-Path $out 'outside' + [void][IO.Directory]::CreateDirectory($external) + $canary=Join-Path $external 'outside.txt' + [IO.File]::WriteAllText($canary,'R291_CANARY_NOT_TO_BE_CHANGED',[Text.UTF8Encoding]::new($false)) + $canarySHA=Hash $canary + $link=Join-Path $tempRoot ('tbme_setup_'+[guid]::NewGuid().ToString('N')) + New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null + if(-not ((Get-Item -LiteralPath $link -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R291_ACTUAL_WINDOWS_JUNCTION_REQUIRED' + } + Reject 'REAL_PAYLOAD_JUNCTION_REFUSED' Install $link $fixtureApp $user + [IO.Directory]::Delete($link) + $link='' + $missing=Join-Path $out 'missingTarget' + [void][IO.Directory]::CreateDirectory($missing) + $dangling=Join-Path $tempRoot ('tbme_setup_'+[guid]::NewGuid().ToString('N')) + New-Item -ItemType Junction -Path $dangling -Target $missing -ErrorAction Stop|Out-Null + [IO.Directory]::Delete($missing) + $missing='' + Reject 'DANGLING_PAYLOAD_JUNCTION_REFUSED' Install $dangling $fixtureApp $user + [IO.Directory]::Delete($dangling) + $dangling='' + if((Hash $live) -cne $mainBefore -or (Hash $canary) -cne $canarySHA){ + throw 'R291_INSTALLED_OR_EXTERNAL_CANARY_CHANGED' + } + $log.Add('MAIN_AND_CANARY_UNCHANGED') + $rec=[ordered]@{Schema=1;Revision='R291';Status='PASS_PRIVILEGED_HELPER_BOUNDARY_NATIVE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false + ProtectedServicesOrUACInvoked=$false;RealWindowsJunctionUsed=$true + PositiveAndNegativePathChecksPassed=$true;DanglingJunctionRejected=$true + EmbeddedHelperSourceMatched=$true;InstalledMainUnchanged=$true + CheckCount=$log.Count;Checks=@($log);RecordedUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($rec|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R291_GUEST_'+$HostLabel+'_PRIVILEGE_BOUNDARY=PASS' +}finally{ + if($link){try{[IO.Directory]::Delete($link)}catch{}} + if($dangling){try{[IO.Directory]::Delete($dangling)}catch{}} + if($missing -and (Test-Path $missing)){try{[IO.Directory]::Delete($missing)}catch{}} + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R291';Status='FAIL_NOT_ACCEPTED';Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;MainPreserved=((Hash $live) -ceq $mainBefore) + OriginalOwnerComputerTouched=$false;RecordedUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 794dad06..48866d9f 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -7,6 +7,98 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference='Stop' +# R291: privileged sensor preflight runs before ANY result/log creation. +# Ordinary per-user Setup stays unelevated; only optional sensors use UAC. +function Assert-SensorBoundaryNoReparse([string]$Path){ + if([string]::IsNullOrWhiteSpace($Path)){throw 'SENSOR_BOUNDARY_EMPTY_PATH'} + $cursor=[IO.Path]::GetFullPath($Path) + while(-not [string]::IsNullOrEmpty($cursor)){ + try{ + if(([IO.File]::GetAttributes($cursor) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw [IO.IOException]::new('SENSOR_BOUNDARY_REPARSE_REFUSED: '+$cursor) + } + }catch [IO.FileNotFoundException]{ + # Absent output may be created later; other I/O errors fail closed. + }catch [IO.DirectoryNotFoundException]{ + } + $parent=[IO.Path]::GetDirectoryName($cursor) + if([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor){break} + $cursor=$parent + } +} +function Assert-SensorPrivilegedBoundary( + [string]$RequestedMode,[string]$RequestedPayload,[string]$RequestedAppRoot,[string]$RequestedUser +){ + if($RequestedMode -cne 'Install' -and $RequestedMode -cne 'Uninstall'){ + throw 'SENSOR_BOUNDARY_INVALID_MODE' + } + if([string]::IsNullOrWhiteSpace($RequestedUser)){throw 'SENSOR_BOUNDARY_EMPTY_USER'} + $sid=if($RequestedUser -match '^S-1-5-21-(?:\d+-){3}\d+$'){ + $RequestedUser + }else{ + [string]([Security.Principal.NTAccount]::new($RequestedUser)).Translate( + [Security.Principal.SecurityIdentifier] + ).Value + } + if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'SENSOR_BOUNDARY_USER_PROFILE_NOT_INTERACTIVE' + } + $profileKey='HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\'+$sid + $entry=Get-ItemProperty -LiteralPath $profileKey -ErrorAction Stop + if([string]::IsNullOrWhiteSpace([string]$entry.ProfileImagePath)){ + throw 'SENSOR_BOUNDARY_PROFILE_MISSING' + } + $profile=[IO.Path]::GetFullPath( + [Environment]::ExpandEnvironmentVariables([string]$entry.ProfileImagePath) + ).TrimEnd([IO.Path]::DirectorySeparatorChar) + if($profile.StartsWith('\\') -or [IO.Path]::GetPathRoot($profile) -ceq $profile){ + throw 'SENSOR_BOUNDARY_PROFILE_NOT_LOCAL' + } + $expectedRoot=[IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local\TaskbarMonitorEnhanced')) + if(-not [IO.Path]::IsPathRooted($RequestedAppRoot) -or + -not [string]::Equals([IO.Path]::GetFullPath($RequestedAppRoot), + $expectedRoot,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' + } + if(-not [IO.Path]::IsPathRooted($RequestedPayload)){ + throw 'SENSOR_BOUNDARY_PAYLOAD_NOT_ABSOLUTE' + } + $payload=[IO.Path]::GetFullPath($RequestedPayload) + $name=[IO.Path]::GetFileName($payload.TrimEnd([IO.Path]::DirectorySeparatorChar)) + $prefix=if($RequestedMode -ceq 'Install'){'tbme_setup_'}else{'tbme_uninstall_'} + if(-not $payload.StartsWith(($profile+[IO.Path]::DirectorySeparatorChar), + [StringComparison]::OrdinalIgnoreCase) -or + $name -cnotmatch ('^'+$prefix+'[0-9a-f]{32}$')){ + throw 'SENSOR_BOUNDARY_PAYLOAD_OUTSIDE_USER_TEMP' + } + $trustedProgramFiles=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) + if(-not [string]::Equals([IO.Path]::GetFullPath($trustedProgramFiles), + [IO.Path]::GetFullPath($env:ProgramFiles),[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_BOUNDARY_PROGRAMFILES_REDIRECT' + } + $brokerRoot=Join-Path $trustedProgramFiles 'TaskbarMonitorEnhanced\SensorBroker' + $candidates=@( + $profile,$expectedRoot,$payload,$brokerRoot, + (Join-Path $expectedRoot 'Logs'), + (Join-Path $expectedRoot 'Logs\sensor_install.log'), + (Join-Path $expectedRoot 'Logs\sensor_install_result.json'), + (Join-Path $expectedRoot 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'), + (Join-Path $expectedRoot 'cpu_temp_broker.json'), + (Join-Path $expectedRoot 'gpu_temp_broker.json'), + (Join-Path $expectedRoot 'storage_temp_broker.json'), + (Join-Path $expectedRoot 'sensor_supervisor_state.json'), + (Join-Path $payload 'TBME_Setup_Elevated_Helper.ps1'), + (Join-Path $payload 'TaskbarMonitorSensorBroker.exe'), + (Join-Path $payload 'TaskbarMonitorSensorSupervisor.exe'), + (Join-Path $payload 'PawnIO_setup.exe'), + (Join-Path $brokerRoot 'TaskbarMonitorSensorBroker.exe'), + (Join-Path $brokerRoot 'TaskbarMonitorSensorSupervisor.exe') + ) + foreach($path in $candidates){Assert-SensorBoundaryNoReparse $path} + return $sid +} +$validatedSensorSid=Assert-SensorPrivilegedBoundary $Mode $PayloadDir $AppRoot $UserId + $TaskName='TaskbarMonitorEnhanced Sensor Broker' $BrokerRoot=Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker' $LogsRoot=Join-Path $AppRoot 'Logs' From cf87b193f3517b4b8606609c6ea135ef5381a5ee Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 09:44:29 +0330 Subject: [PATCH 020/135] test(security): reject invalid SID with deterministic prewrite error (R291) --- .../audit-r286-optional-audio-native-recovery.yml | 2 +- build/Test-R291-SensorHelperBoundary-Native.ps1 | 1 + installer/TBME_Setup_Elevated_Helper.ps1 | 10 +++++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 095d86dd..72d1f998 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -182,7 +182,7 @@ jobs: $r.DanglingJunctionRejected -ne $true -or $r.EmbeddedHelperSourceMatched -ne $true -or $r.InstalledMainUnchanged -ne $true -or - $r.CheckCount -lt 11){throw 'R291_TYPED_BOUNDARY_RECEIPT_INVALID'} + $r.CheckCount -lt 12){throw 'R291_TYPED_BOUNDARY_RECEIPT_INVALID'} 'R291_NATIVE_ELEVATED_SENSOR_BOUNDARY_GUARDS_PASS' - name: Preserve bounded guest evidence if: always() diff --git a/build/Test-R291-SensorHelperBoundary-Native.ps1 b/build/Test-R291-SensorHelperBoundary-Native.ps1 index 0d306072..12bbade1 100644 --- a/build/Test-R291-SensorHelperBoundary-Native.ps1 +++ b/build/Test-R291-SensorHelperBoundary-Native.ps1 @@ -77,6 +77,7 @@ try{ Reject 'WRONG_MODE_PAYLOAD_REFUSED' Uninstall $validInstall $fixtureApp $user Reject 'MALFORMED_PAYLOAD_NAME_REFUSED' Install (Join-Path $tempRoot 'tbme_setup_bad') $fixtureApp $user Reject 'SERVICE_SID_REFUSED' Install $validInstall $fixtureApp 'S-1-5-18' + Reject 'UNRESOLVABLE_WINDOWS_ACCOUNT_REFUSED' Install $validInstall $fixtureApp 'THIS_ACCOUNT_MUST_NOT_EXIST_R291' $asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) $stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') if(!$stream){throw 'R291_BOUNDARY_RESOURCE_MISSING'} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 48866d9f..8d74b187 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -36,9 +36,13 @@ function Assert-SensorPrivilegedBoundary( $sid=if($RequestedUser -match '^S-1-5-21-(?:\d+-){3}\d+$'){ $RequestedUser }else{ - [string]([Security.Principal.NTAccount]::new($RequestedUser)).Translate( - [Security.Principal.SecurityIdentifier] - ).Value + try{ + [string]([Security.Principal.NTAccount]::new($RequestedUser)).Translate( + [Security.Principal.SecurityIdentifier] + ).Value + }catch{ + throw 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' + } } if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ throw 'SENSOR_BOUNDARY_USER_PROFILE_NOT_INTERACTIVE' From 04ab49d8111d26b60754f59e9a5323ee24dcf323 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 09:54:47 +0330 Subject: [PATCH 021/135] test: keep absent-PawnIO fail-closed gate on disposable canonical user root --- build/Test-PawnIO-External-Degrade.ps1 | 50 +++++++++++++++++--------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/build/Test-PawnIO-External-Degrade.ps1 b/build/Test-PawnIO-External-Degrade.ps1 index 6aadb248..47a3648b 100644 --- a/build/Test-PawnIO-External-Degrade.ps1 +++ b/build/Test-PawnIO-External-Degrade.ps1 @@ -1,17 +1,22 @@ -# R85 hermetic negative runtime test: a missing proprietary PawnIO binary cannot install -# driver/services or mutate the real host. An isolated copy only mocks the read-only -# PawnIO version discovery function so installed PawnIO on the developer PC is untouched. +# R85 disposable GitHub Windows negative runtime test: absent PawnIO must degrade +# without privileged driver/service mutation. Use a profile-canonical guest AppRoot +# because the real R291 elevated-helper boundary must not be bypassed in tests. $ErrorActionPreference='Stop' Set-StrictMode -Version Latest +# Never write even a test config to a real owner's user profile. +if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){throw 'R85_ONLY_DISPOSABLE_GITHUB_WINDOWS'} $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $Helper=Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1' $Base=Join-Path $Root 'artifacts\_r85_external_degrade_test' -$Payload=Join-Path $Base 'payload' -$App=Join-Path $Base 'local_app' +$Payload=Join-Path ([IO.Path]::GetTempPath()) ('tbme_setup_'+[guid]::NewGuid().ToString('N')) +$App=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' $Mock=Join-Path $Base 'helper_mocked_pawnio_discovery.ps1' $Result=Join-Path $App 'Logs\sensor_install_result.json' $Log=Join-Path $App 'Logs\sensor_install.log' -if(Test-Path -LiteralPath $Base){throw 'R85 UNSAFE FIXTURE REUSE'} +if((Test-Path -LiteralPath $Base) -or (Test-Path -LiteralPath $Payload) -or + (Test-Path -LiteralPath $App)){throw 'R85 GUEST_FIXTURE_COLLISION_NO_USER_DATA_TOUCH'} if(!(Test-Path -LiteralPath $Helper -PathType Leaf)){throw 'R85 HELPER MISSING'} $source=Get-Content -LiteralPath $Helper -Raw $guardAt=$source.IndexOf('PAWNIO_UPSTREAM_EXTERNAL_INSTALL_REQUIRED',[StringComparison]::Ordinal) @@ -47,22 +52,35 @@ try { } $lograw=Get-Content -LiteralPath $Log -Raw if($lograw -match 'PAWNIO_INSTALL_START|ROLLBACK_CAPTURE|SENSOR_PROCESS_DRAIN|REGISTER_TASK'){throw 'R85 PROTECTED MUTATION WAS ATTEMPTED'} - $files=@(Get-ChildItem -LiteralPath $Base -Recurse -File) + $files=@(Get-ChildItem -LiteralPath $Base -Recurse -File) + + @(Get-ChildItem -LiteralPath $Payload -Recurse -File) + + @(Get-ChildItem -LiteralPath $App -Recurse -File) $allowed=@( - 'payload\TaskbarMonitorSensorBroker.exe','payload\TaskbarMonitorSensorSupervisor.exe', - 'local_app\SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll', - 'local_app\Logs\sensor_install_result.json','local_app\Logs\sensor_install.log', - 'helper_mocked_pawnio_discovery.ps1') - if($files.Count -ne $allowed.Count -or @($files|Where-Object {$_.FullName.Substring($Base.Length+1) -notin $allowed}).Count -ne 0){throw 'R85 UNKNOWN FIXTURE FILES'} + $Mock, + (Join-Path $Payload 'TaskbarMonitorSensorBroker.exe'), + (Join-Path $Payload 'TaskbarMonitorSensorSupervisor.exe'), + (Join-Path $App 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'), + $Result,$Log + ) + $observed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($file in $files){[void]$observed.Add([IO.Path]::GetFullPath($file.FullName))} + if($files.Count -ne 6 -or $observed.Count -ne 6 -or $allowed.Count -ne 6){ + throw 'R85 UNEXPECTED_DISPOSABLE_GUEST_FIXTURE_FILE_COUNT' + } + foreach($expected in $allowed){ + if(-not $observed.Contains([IO.Path]::GetFullPath($expected))){throw 'R85 UNSAFE_OR_MISSING_FIXTURE_FILE'} + } Write-Output 'R85_MISSING_PAWNIO_DEGRADED_FAIL_CLOSED_NO_DRIVER_INSTALL_NO_ROLLBACK_NO_OS_CHANGE=PASS' $done=$true } finally { if($done){ - # Only this preflight-owned, exact-path fixture tree is removed. - Remove-Item -LiteralPath $Base -Recurse -Force -ErrorAction Stop - if(Test-Path -LiteralPath $Base){throw 'R85 FIXTURE CLEANUP FAILED'} + # Three exact guest fixture roots were absent before the test. + foreach($owned in @($Payload,$App,$Base)){ + Remove-Item -LiteralPath $owned -Recurse -Force -ErrorAction Stop + if(Test-Path -LiteralPath $owned){throw 'R85 GUEST FIXTURE CLEANUP FAILED'} + } }else{ - Write-Warning ('R85 TEST FAILED; exact diagnostic fixture preserved at '+$Base) + Write-Warning ('R85 GUEST FAILURE: fixtures preserved in '+$Base+' ; '+$Payload+' ; '+$App) } } exit 0 From 39d06af3b689b5179e7b5cad3fb2e75dc9968a87 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 10:06:08 +0330 Subject: [PATCH 022/135] test: isolate R85 missing-PawnIO fixture without weakening production R291 boundary --- build/Test-PawnIO-External-Degrade.ps1 | 88 ++++++++++++++++---------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/build/Test-PawnIO-External-Degrade.ps1 b/build/Test-PawnIO-External-Degrade.ps1 index 47a3648b..b572b09a 100644 --- a/build/Test-PawnIO-External-Degrade.ps1 +++ b/build/Test-PawnIO-External-Degrade.ps1 @@ -1,6 +1,6 @@ -# R85 disposable GitHub Windows negative runtime test: absent PawnIO must degrade -# without privileged driver/service mutation. Use a profile-canonical guest AppRoot -# because the real R291 elevated-helper boundary must not be bypassed in tests. +# R85: hermetic early PawnIO-degrade regression on disposable GitHub Windows. +# Dedicated R291 native A/B tests exercise the real privileged path boundary. +# This test mocks only its isolated fixture boundary and PawnIO discovery. $ErrorActionPreference='Stop' Set-StrictMode -Version Latest # Never write even a test config to a real owner's user profile. @@ -10,13 +10,12 @@ if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-host $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $Helper=Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1' $Base=Join-Path $Root 'artifacts\_r85_external_degrade_test' -$Payload=Join-Path ([IO.Path]::GetTempPath()) ('tbme_setup_'+[guid]::NewGuid().ToString('N')) -$App=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$Payload=Join-Path $Base 'payload' +$App=Join-Path $Base 'local_app' $Mock=Join-Path $Base 'helper_mocked_pawnio_discovery.ps1' $Result=Join-Path $App 'Logs\sensor_install_result.json' $Log=Join-Path $App 'Logs\sensor_install.log' -if((Test-Path -LiteralPath $Base) -or (Test-Path -LiteralPath $Payload) -or - (Test-Path -LiteralPath $App)){throw 'R85 GUEST_FIXTURE_COLLISION_NO_USER_DATA_TOUCH'} +if(Test-Path -LiteralPath $Base){throw 'R85 GUEST_FIXTURE_COLLISION_NO_USER_DATA_TOUCH'} if(!(Test-Path -LiteralPath $Helper -PathType Leaf)){throw 'R85 HELPER MISSING'} $source=Get-Content -LiteralPath $Helper -Raw $guardAt=$source.IndexOf('PAWNIO_UPSTREAM_EXTERNAL_INSTALL_REQUIRED',[StringComparison]::Ordinal) @@ -25,10 +24,44 @@ if($guardAt -lt 0 -or $captureAt -le $guardAt){throw 'R85 EARLY DEGRADED GUARD N $tok=$null;$errs=$null $ast=[Management.Automation.Language.Parser]::ParseInput($source,[ref]$tok,[ref]$errs) if($errs.Count -ne 0){throw 'R85 HELPER HAS PARSER ERRORS'} -$funcs=@($ast.FindAll({param($x) $x -is [Management.Automation.Language.FunctionDefinitionAst] -and $x.Name -ceq 'Get-PawnIOVersion'},$true)) -if($funcs.Count -ne 1){throw 'R85 EXACT FUNCTION REPLACEMENT AMBIGUOUS'} -$node=$funcs[0] -$mocked=$source.Substring(0,$node.Extent.StartOffset)+"function Get-PawnIOVersion { return '' }"+$source.Substring($node.Extent.EndOffset) +$funcs=@($ast.FindAll({param($x) + $x -is [Management.Automation.Language.FunctionDefinitionAst] -and + $x.Name -in @('Get-PawnIOVersion','Assert-SensorPrivilegedBoundary') +},$true)) +if($funcs.Count -ne 2 -or @($funcs|Where-Object Name -eq 'Get-PawnIOVersion').Count -ne 1 -or + @($funcs|Where-Object Name -eq 'Assert-SensorPrivilegedBoundary').Count -ne 1){ + throw 'R85_TWO_EXACT_MOCK_FUNCTIONS_REQUIRED' +} +$preflight=$source.IndexOf('$validatedSensorSid=Assert-SensorPrivilegedBoundary',[StringComparison]::Ordinal) +$logWrite=$source.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) +if($preflight -lt 0 -or $logWrite -le $preflight){throw 'R85_UNMODIFIED_PRODUCTION_GUARD_MISSING'} +$escapeApp=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($App))) +$escapePayload=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($Payload))) +$escapeUser=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([Security.Principal.WindowsIdentity]::GetCurrent().Name)) +$escapeSid=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) +$mockBoundary=@' +function Assert-SensorPrivilegedBoundary([string]$RequestedMode,[string]$RequestedPayload,[string]$RequestedAppRoot,[string]$RequestedUser){ + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows'){throw 'R85_MOCK_ONLY_HOSTED_DISPOSABLE'} + $app=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__APP__')) + $payload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PAYLOAD__')) + $user=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__USER__')) + $sid=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__SID__')) + if($RequestedMode -cne 'Install' -or $RequestedUser -cne $user -or + -not [string]::Equals([IO.Path]::GetFullPath($RequestedAppRoot),$app,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([IO.Path]::GetFullPath($RequestedPayload),$payload,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R85_MOCK_REJECTS_OUTSIDE_OWNED_FIXTURE' + } + return $sid +} +'@ +$mockBoundary=$mockBoundary.Replace('__APP__',$escapeApp).Replace('__PAYLOAD__',$escapePayload).Replace('__USER__',$escapeUser).Replace('__SID__',$escapeSid) +$mocked=$source +foreach($fn in @($funcs|Sort-Object {$_.Extent.StartOffset} -Descending)){ + $replacement=if($fn.Name -ceq 'Get-PawnIOVersion'){"function Get-PawnIOVersion { return '' }"}else{$mockBoundary} + $mocked=$mocked.Substring(0,$fn.Extent.StartOffset)+$replacement+$mocked.Substring($fn.Extent.EndOffset) +} +if(-not $mocked.Contains('R85_MOCK_REJECTS_OUTSIDE_OWNED_FIXTURE')){throw 'R85_BOUNDARY_MOCK_INJECTION_FAILED'} $done=$false try { New-Item -ItemType Directory -Path $Payload -Force -ErrorAction Stop|Out-Null @@ -52,35 +85,22 @@ try { } $lograw=Get-Content -LiteralPath $Log -Raw if($lograw -match 'PAWNIO_INSTALL_START|ROLLBACK_CAPTURE|SENSOR_PROCESS_DRAIN|REGISTER_TASK'){throw 'R85 PROTECTED MUTATION WAS ATTEMPTED'} - $files=@(Get-ChildItem -LiteralPath $Base -Recurse -File) + - @(Get-ChildItem -LiteralPath $Payload -Recurse -File) + - @(Get-ChildItem -LiteralPath $App -Recurse -File) + $files=@(Get-ChildItem -LiteralPath $Base -Recurse -File) $allowed=@( - $Mock, - (Join-Path $Payload 'TaskbarMonitorSensorBroker.exe'), - (Join-Path $Payload 'TaskbarMonitorSensorSupervisor.exe'), - (Join-Path $App 'SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll'), - $Result,$Log - ) - $observed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) - foreach($file in $files){[void]$observed.Add([IO.Path]::GetFullPath($file.FullName))} - if($files.Count -ne 6 -or $observed.Count -ne 6 -or $allowed.Count -ne 6){ - throw 'R85 UNEXPECTED_DISPOSABLE_GUEST_FIXTURE_FILE_COUNT' - } - foreach($expected in $allowed){ - if(-not $observed.Contains([IO.Path]::GetFullPath($expected))){throw 'R85 UNSAFE_OR_MISSING_FIXTURE_FILE'} - } + 'payload\TaskbarMonitorSensorBroker.exe','payload\TaskbarMonitorSensorSupervisor.exe', + 'local_app\SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll', + 'local_app\Logs\sensor_install_result.json','local_app\Logs\sensor_install.log', + 'helper_mocked_pawnio_discovery.ps1') + if($files.Count -ne $allowed.Count -or @($files|Where-Object {$_.FullName.Substring($Base.Length+1) -notin $allowed}).Count -ne 0){throw 'R85_UNKNOWN_FIXTURE_FILES'} Write-Output 'R85_MISSING_PAWNIO_DEGRADED_FAIL_CLOSED_NO_DRIVER_INSTALL_NO_ROLLBACK_NO_OS_CHANGE=PASS' $done=$true } finally { if($done){ - # Three exact guest fixture roots were absent before the test. - foreach($owned in @($Payload,$App,$Base)){ - Remove-Item -LiteralPath $owned -Recurse -Force -ErrorAction Stop - if(Test-Path -LiteralPath $owned){throw 'R85 GUEST FIXTURE CLEANUP FAILED'} - } + # Delete only this initially absent, owned fixture; never touch user AppData. + Remove-Item -LiteralPath $Base -Recurse -Force -ErrorAction Stop + if(Test-Path -LiteralPath $Base){throw 'R85_FIXTURE_CLEANUP_FAILED'} }else{ - Write-Warning ('R85 GUEST FAILURE: fixtures preserved in '+$Base+' ; '+$Payload+' ; '+$App) + Write-Warning ('R85 GUEST FAILURE: owned fixture preserved at '+$Base) } } exit 0 From 6ba2230f5d78c398a3f183c57d092cc0a47bf69e Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 10:27:47 +0330 Subject: [PATCH 023/135] security: restrict elevated sensor payload to direct user Local Temp child (R292) --- .../audit-r286-optional-audio-native-recovery.yml | 4 +++- build/Test-R291-SensorHelperBoundary-Native.ps1 | 2 ++ installer/TBME_Setup_Elevated_Helper.ps1 | 10 ++++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 72d1f998..a9748ebf 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -182,7 +182,9 @@ jobs: $r.DanglingJunctionRejected -ne $true -or $r.EmbeddedHelperSourceMatched -ne $true -or $r.InstalledMainUnchanged -ne $true -or - $r.CheckCount -lt 12){throw 'R291_TYPED_BOUNDARY_RECEIPT_INVALID'} + $r.CheckCount -lt 14 -or + @($r.Checks|Where-Object {$_ -ceq 'DOWNLOADS_ROOT_WITH_VALID_NAME_REFUSED'}).Count -ne 1 -or + @($r.Checks|Where-Object {$_ -ceq 'NESTED_TEMP_WITH_VALID_NAME_REFUSED'}).Count -ne 1){throw 'R291_TYPED_BOUNDARY_RECEIPT_INVALID'} 'R291_NATIVE_ELEVATED_SENSOR_BOUNDARY_GUARDS_PASS' - name: Preserve bounded guest evidence if: always() diff --git a/build/Test-R291-SensorHelperBoundary-Native.ps1 b/build/Test-R291-SensorHelperBoundary-Native.ps1 index 12bbade1..c7ab788f 100644 --- a/build/Test-R291-SensorHelperBoundary-Native.ps1 +++ b/build/Test-R291-SensorHelperBoundary-Native.ps1 @@ -74,6 +74,8 @@ try{ $log.Add('LEGITIMATE_INSTALL_AND_UNINSTALL_ACCEPTED') Reject 'WRONG_APP_ROOT_REFUSED' Install $validInstall (Join-Path $env:WINDIR 'TaskbarMonitorEnhanced') $user Reject 'OUTSIDE_USER_PROFILE_REFUSED' Install (Join-Path $env:WINDIR ('tbme_setup_'+('a'*32))) $fixtureApp $user + Reject 'DOWNLOADS_ROOT_WITH_VALID_NAME_REFUSED' Install (Join-Path ([Environment]::GetFolderPath('UserProfile')) ('Downloads\tbme_setup_'+('a'*32))) $fixtureApp $user + Reject 'NESTED_TEMP_WITH_VALID_NAME_REFUSED' Install (Join-Path (Join-Path $tempRoot 'nested') ('tbme_setup_'+('b'*32))) $fixtureApp $user Reject 'WRONG_MODE_PAYLOAD_REFUSED' Uninstall $validInstall $fixtureApp $user Reject 'MALFORMED_PAYLOAD_NAME_REFUSED' Install (Join-Path $tempRoot 'tbme_setup_bad') $fixtureApp $user Reject 'SERVICE_SID_REFUSED' Install $validInstall $fixtureApp 'S-1-5-18' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 8d74b187..52aeff8a 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -70,8 +70,14 @@ function Assert-SensorPrivilegedBoundary( $payload=[IO.Path]::GetFullPath($RequestedPayload) $name=[IO.Path]::GetFileName($payload.TrimEnd([IO.Path]::DirectorySeparatorChar)) $prefix=if($RequestedMode -ceq 'Install'){'tbme_setup_'}else{'tbme_uninstall_'} - if(-not $payload.StartsWith(($profile+[IO.Path]::DirectorySeparatorChar), - [StringComparison]::OrdinalIgnoreCase) -or + # Only direct children of this user's default Temp root are trusted. A + # profile-wide prefix would also accept Downloads or arbitrary subfolders. + # Nonstandard redirected TEMP locations fail closed (optional sensors degrade). + $expectedTempParent=[IO.Path]::GetFullPath( + (Join-Path $profile 'AppData\Local\Temp') + ).TrimEnd([IO.Path]::DirectorySeparatorChar) + if(-not [string]::Equals([IO.Path]::GetDirectoryName($payload), + $expectedTempParent,[StringComparison]::OrdinalIgnoreCase) -or $name -cnotmatch ('^'+$prefix+'[0-9a-f]{32}$')){ throw 'SENSOR_BOUNDARY_PAYLOAD_OUTSIDE_USER_TEMP' } From 82144db5970a56aed4b599250adcb96551be3a31 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 10:58:45 +0330 Subject: [PATCH 024/135] security(setup): execute embedded sensor helper in memory before UAC (R293) --- ...it-r286-optional-audio-native-recovery.yml | 24 +++ .../Test-R293-InMemorySensorHelper-Native.ps1 | 174 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 68 +++++-- 3 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 build/Test-R293-InMemorySensorHelper-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index a9748ebf..82953038 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -186,6 +186,29 @@ jobs: @($r.Checks|Where-Object {$_ -ceq 'DOWNLOADS_ROOT_WITH_VALID_NAME_REFUSED'}).Count -ne 1 -or @($r.Checks|Where-Object {$_ -ceq 'NESTED_TEMP_WITH_VALID_NAME_REFUSED'}).Count -ne 1){throw 'R291_TYPED_BOUNDARY_RECEIPT_INVALID'} 'R291_NATIVE_ELEVATED_SENSOR_BOUNDARY_GUARDS_PASS' + - name: R293 execute trusted inline Helper, never rogue writable Temp script + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R293-InMemorySensorHelper-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R293_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R293-InMemorySensorHelper-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R293_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R293_INMEMORY_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R293_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R293' -or + $r.Status -cne 'PASS_NO_TEMP_SCRIPT_ELEVATION_INLINE_TRUSTED_SOURCE' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrProtectedServiceCalled -ne $false -or + $r.ExactTrustedHelperSource -ne $true -or $r.MaliciousTempScriptIgnored -ne $true -or + $r.RealNonElevatedEncodedPowerShellCalled -ne $true -or + $r.InvalidAppRootRejectedBeforeWrite -ne $true -or + $r.InstalledMainUnchanged -ne $true -or + $r.BoundedEncodedCommandLength -lt 1000 -or $r.BoundedEncodedCommandLength -gt 30000 -or + @($r.Checks).Count -lt 6){throw 'R293_NATIVE_INMEMORY_RECEIPT_REJECTED'} + 'R293_TRUSTED_INMEMORY_HELPER_NEGATIVE=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -198,3 +221,4 @@ jobs: ${{ runner.temp }}/R286_${{ matrix.host }}_${{ github.run_id }}/ARMED.json ${{ runner.temp }}/R290_${{ matrix.host }}_${{ github.run_id }}/R290_REPARSE_RECEIPT.json ${{ runner.temp }}/R291_${{ matrix.host }}_${{ github.run_id }}/R291_BOUNDARY_RECEIPT.json + ${{ runner.temp }}/R293_${{ matrix.host }}_${{ github.run_id }}/R293_INMEMORY_RECEIPT.json diff --git a/build/Test-R293-InMemorySensorHelper-Native.ps1 b/build/Test-R293-InMemorySensorHelper-Native.ps1 new file mode 100644 index 00000000..89df2845 --- /dev/null +++ b/build/Test-R293-InMemorySensorHelper-Native.ps1 @@ -0,0 +1,174 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# R293: only disposable GitHub-hosted Windows, never the owner's installation. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R293_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function Hash([string]$p){ + if(!(Test-Path -LiteralPath $p -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$dir=Join-Path $env:RUNNER_TEMP ('R293_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $dir 'R293_INMEMORY_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$live=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R291_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R291_BOUNDARY_RECEIPT.json') +$payload=Join-Path ([IO.Path]::GetTempPath()) ('tbme_setup_'+[guid]::NewGuid().ToString('N')) +$rogue=Join-Path $payload 'TBME_Setup_Elevated_Helper.ps1' +$sentinel=Join-Path $dir 'ROGUE_EXECUTED' +$user=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) +$accepted=$false +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path $dir) -or (Test-Path $payload) -or (Test-Path $sentinel)){ + throw 'R293_PREEXISTING_GUEST_FIXTURE_REFUSED' +} +[void][IO.Directory]::CreateDirectory($dir) +$mainBefore=Hash $live +try{ + $identity=Get-Content $manifest -Raw |ConvertFrom-Json + $r291=Get-Content $prior -Raw |ConvertFrom-Json + if($identity.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $identity.SetupSHA256 -or + (Hash $live) -cne $identity.MainSHA256 -or + $r291.Status -cne 'PASS_PRIVILEGED_HELPER_BOUNDARY_NATIVE' -or + $r291.SourceCommit -cne $env:GITHUB_SHA -or $r291.Host -cne $HostLabel){ + throw 'R293_EXACT_CANDIDATE_OR_PRIOR_NATIVE_HOST_UNPINNED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $m=$type.GetMethod('BuildElevatedHelperCommand',[Reflection.BindingFlags]'NonPublic,Static') + if(!$m){throw 'R293_SETUP_HAS_NO_INMEMORY_ELEVATION_BUILDER'} + $args4=[object[]]::new(4) + $args4[0]=[string]'Install' + $args4[1]=[string]$payload + # Known bad AppRoot: real trusted Helper must refuse before any filesystem mutation. + $args4[2]=[string](Join-Path $env:WINDIR 'R293_Refuse_Outside_User_AppRoot') + $args4[3]=[string]$user + $cmd=[string]$m.Invoke($null,$args4) + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + if(-not $cmd.StartsWith($prefix,[StringComparison]::Ordinal) -or + $cmd.Contains(' -File ') -or $cmd.Contains('ExecutionPolicy Bypass')){ + throw 'R293_ELEVATION_COMMAND_USES_UNTRUSTED_FILE_PATH' + } + $encoded=$cmd.Substring($prefix.Length) + if($encoded.Length -lt 1000 -or $encoded.Length -gt 30000){ + throw 'R293_ENCODED_COMMAND_LENGTH_OUT_OF_BOUNDS' + } + $script=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + if(-not $script.Contains('[ScriptBlock]::Create($code)') -or + $script.Contains(' -File ')){ + throw 'R293_DECODED_COMMAND_DOES_NOT_RUN_EMBEDDED_SOURCE' + } + $checks.Add('NO_USER_WRITABLE_POWERSHELL_FILE_ENTRYPOINT') + $re=[regex]::Match($script,'(?m)^\$blob=\[Convert\]::FromBase64String\(''([a-zA-Z0-9+/=]+)''\)') + if(!$re.Success){throw 'R293_MISSING_TRUSTED_COMPRESSED_SOURCE'} + $memory=[IO.MemoryStream]::new([Convert]::FromBase64String($re.Groups[1].Value)) + $zip=[IO.Compression.GZipStream]::new($memory,[IO.Compression.CompressionMode]::Decompress) + $reader=[IO.StreamReader]::new($zip,[Text.Encoding]::UTF8) + try{$code=$reader.ReadToEnd()}finally{$reader.Dispose();$zip.Dispose();$memory.Dispose()} + $resource=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$resource){throw 'R293_MISSING_SETUP_RESOURCE'} + $textReader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) + try{$source=$textReader.ReadToEnd()}finally{$textReader.Dispose()} + $crlf=[string][char]13+[string][char]10 + $lf=[string][char]10 + if($code.Replace($crlf,$lf).TrimEnd() -cne $source.Replace($crlf,$lf).TrimEnd()){ + throw 'R293_PACKED_SOURCE_DIFFERS_FROM_SETUP_RESOURCE' + } + $checks.Add('DECOMPRESSED_RESOURCE_EXACT_TO_SETUP') + if(@($script|Select-String -Pattern ([regex]::Escape($args4[2]))).Count){ + throw 'R293_UNTRUSTED_PATH_EMBEDDED_AS_RAW_SCRIPT_LITERAL' + } + $checks.Add('ALL_USER_PARAMETERS_ENCODED_AS_DATA') + [void][IO.Directory]::CreateDirectory($payload) + # This file was the previous vulnerable UAC execution entrypoint. + # It is deliberately attacker-like, but owned only by this temporary fixture. + $poison=[string]'Write-Output "R293_ROGUE_SCRIPT_EXECUTED"' + [IO.File]::WriteAllText($rogue,$poison,[Text.UTF8Encoding]::new($false)) + $rogueBefore=Hash $rogue + # No UAC: execute only invalid-Root probe on the disposable runner. + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName='powershell.exe' + $psi.Arguments=$cmd + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){throw 'R293_BOUNDED_NON_ELEVATED_PROCESS_NOT_CREATED'} + try{ + # Drain both redirected pipes concurrently so even verbose error output + # cannot block the child before WaitForExit sees the bounded refusal. + $outTask=$proc.StandardOutput.ReadToEndAsync() + $errTask=$proc.StandardError.ReadToEndAsync() + if(-not $proc.WaitForExit(20000)){ + try{$proc.Kill()}catch{} + throw 'R293_INMEMORY_HELPER_TEST_TIMEOUT' + } + $out=$outTask.GetAwaiter().GetResult() + $err=$errTask.GetAwaiter().GetResult() + $exitCode=$proc.ExitCode + }finally{$proc.Dispose()} + if($exitCode -eq 0 -or + ($out+$err) -notmatch 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE'){ + throw ('R293_EXPECTED_PREWRITE_REFUSAL_NOT_OBSERVED_EXIT_'+$exitCode) + } + if($out -match 'R293_ROGUE_SCRIPT_EXECUTED' -or + $err -match 'R293_ROGUE_SCRIPT_EXECUTED' -or + (Hash $rogue) -cne $rogueBefore -or + (Test-Path $sentinel)){ + throw 'R293_USER_WRITABLE_ROGUE_SCRIPT_EXECUTED_OR_CHANGED' + } + $checks.Add('REAL_POWERSHELL_51_INLINE_PREWRITE_REFUSAL') + $checks.Add('ROGUE_WRITABLE_HELPER_IGNORED') + $checks.Add('NO_UAC_PROTECTED_SERVICE_MUTATION') + if((Hash $live) -cne $mainBefore){ + throw 'R293_GUEST_INSTALLED_MAIN_CHANGED' + } + $checks.Add('INSTALLED_MAIN_AND_ROGUE_FILE_PRESERVED') + $receipt=[ordered]@{ + Schema=1;Revision='R293' + Status='PASS_NO_TEMP_SCRIPT_ELEVATION_INLINE_TRUSTED_SOURCE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false + UACOrProtectedServiceCalled=$false + ExactTrustedHelperSource=$true + MaliciousTempScriptIgnored=$true + RealNonElevatedEncodedPowerShellCalled=$true + InvalidAppRootRejectedBeforeWrite=$true + InstalledMainUnchanged=$true + BoundedEncodedCommandLength=$encoded.Length + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($receipt|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R293_NATIVE_'+$HostLabel+'_TRUSTED_ELEVATION_CODE=PASS' +}finally{ + if(Test-Path -LiteralPath $payload){ + try{[IO.Directory]::Delete($payload,$true)}catch{} + } + if(!$accepted){ + $fail=[ordered]@{ + Schema=1;Revision='R293';Status='FAIL_NOT_ACCEPTED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerUntouched=$true + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index ffec82e2..a75dad8e 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -104,11 +104,63 @@ static void StopProcess(string name) }catch{} } - static int ElevatedHelper(string helper,string args,int timeoutMs) + // R293: PowerShell code is transported from this immutable, embedded PE resource + // via one in-memory EncodedCommand. Never elevate a .ps1 from writable Temp. + static string Base64Utf8Argument(string value) + { + if(String.IsNullOrWhiteSpace(value) || value.Length>4096) + throw new InvalidDataException("Sensor helper argument missing or too long."); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + } + + static string BuildElevatedHelperCommand(string mode,string payload,string appRoot,string user) + { + if(mode!="Install" && mode!="Uninstall") + throw new InvalidDataException("Unexpected elevated sensor mode."); + string packed; + using(Stream resource=Resource("Payload.TBME_Setup_Elevated_Helper.ps1")) + { + if(resource==null)throw new InvalidDataException("Trusted embedded sensor helper missing."); + using(StreamReader reader=new StreamReader(resource,Encoding.UTF8,true)) + { + string source=reader.ReadToEnd(); + byte[] textBytes=Encoding.UTF8.GetBytes(source); + if(textBytes.Length<1000 || textBytes.Length>120000) + throw new InvalidDataException("Unexpected sensor helper source size."); + using(MemoryStream buffer=new MemoryStream()) + { + using(GZipStream zipper=new GZipStream(buffer,CompressionMode.Compress,true)) + zipper.Write(textBytes,0,textBytes.Length); + packed=Convert.ToBase64String(buffer.ToArray()); + } + } + } + // Every caller-supplied parameter is separately base64-encoded as data. + // In particular, a quoted Windows user/path cannot inject PowerShell syntax. + string script= + "$ErrorActionPreference='Stop'\n"+ + "$blob=[Convert]::FromBase64String('"+packed+"')\n"+ + "$ms=[IO.MemoryStream]::new($blob)\n"+ + "$gzip=[IO.Compression.GZipStream]::new($ms,[IO.Compression.CompressionMode]::Decompress)\n"+ + "$reader=[IO.StreamReader]::new($gzip,[Text.Encoding]::UTF8)\n"+ + "try{$code=$reader.ReadToEnd()}finally{$reader.Dispose();$gzip.Dispose();$ms.Dispose()}\n"+ + "$payload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(payload)+"'))\n"+ + "$app=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(appRoot)+"'))\n"+ + "$user=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(user)+"'))\n"+ + "& ([ScriptBlock]::Create($code)) -Mode '"+mode+"' -PayloadDir $payload -AppRoot $app -UserId $user\n"; + string encoded=Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + // Windows CreateProcess has a 32,767-character maximum; leave margin. + // Fail closed instead of falling back to unsafe -File or user-writable scripts. + if(encoded.Length>30000) + throw new InvalidDataException("Trusted sensor helper command exceeds safe limit."); + return "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; + } + + static int ElevatedHelper(string mode,string payload,string appRoot,string user,int timeoutMs) { ProcessStartInfo psi=new ProcessStartInfo(); psi.FileName="powershell.exe"; - psi.Arguments="-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \""+helper+"\" "+args; + psi.Arguments=BuildElevatedHelperCommand(mode,payload,appRoot,user); psi.UseShellExecute=true; psi.Verb="runas"; @@ -263,7 +315,6 @@ static string PrepareSensorPayload() if(pawn!=null && pawn.Length>0) Extract("Payload.PawnIO_setup.exe",Path.Combine(payload,"PawnIO_setup.exe")); } - Extract("Payload.TBME_Setup_Elevated_Helper.ps1",Path.Combine(payload,"TBME_Setup_Elevated_Helper.ps1")); return payload; } @@ -392,13 +443,10 @@ static SensorOutcome InstallSensorLayer() Directory.CreateDirectory(Path.Combine(AppRoot,"Logs")); try{File.Delete(SensorResultPath);}catch{} payload=PrepareSensorPayload(); - string helper=Path.Combine(payload,"TBME_Setup_Elevated_Helper.ps1"); string user=WindowsIdentity.GetCurrent().Name; int helperExit; try{ - helperExit=ElevatedHelper(helper, - "-Mode Install -PayloadDir \""+payload+"\" -AppRoot \""+AppRoot+"\" -UserId \""+user+"\"", - 110000); + helperExit=ElevatedHelper("Install",payload,AppRoot,user,110000); }catch(Exception ex){ outcome.Status="DEGRADED"; outcome.Message="The application installed successfully. The protected sensor supervisor did not report ready before Setup finished. "+ex.Message+" It will continue starting in the background; protected temperature fields may briefly show N/A."; @@ -557,13 +605,9 @@ static void Uninstall(bool quiet) string payload=Path.Combine(Path.GetTempPath(),"tbme_uninstall_"+Guid.NewGuid().ToString("N")); Directory.CreateDirectory(payload); - string helper=Path.Combine(payload,"TBME_Setup_Elevated_Helper.ps1"); - Extract("Payload.TBME_Setup_Elevated_Helper.ps1",helper); string user=WindowsIdentity.GetCurrent().Name; - int helperExit=ElevatedHelper(helper, - "-Mode Uninstall -PayloadDir \""+payload+"\" -AppRoot \""+AppRoot+"\" -UserId \""+user+"\"", - 90000); + int helperExit=ElevatedHelper("Uninstall",payload,AppRoot,user,90000); if(helperExit!=0) throw new InvalidOperationException("Protected sensor removal failed. Exit code: "+helperExit); From 1a0048169518e05532204e4660231f6589e2894b Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 11:07:59 +0330 Subject: [PATCH 025/135] test(security): prove hostile quoted UAC user input cannot inject commands (R293) --- ...it-r286-optional-audio-native-recovery.yml | 4 +- .../Test-R293-InMemorySensorHelper-Native.ps1 | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 82953038..20bb23ae 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -207,7 +207,9 @@ jobs: $r.InvalidAppRootRejectedBeforeWrite -ne $true -or $r.InstalledMainUnchanged -ne $true -or $r.BoundedEncodedCommandLength -lt 1000 -or $r.BoundedEncodedCommandLength -gt 30000 -or - @($r.Checks).Count -lt 6){throw 'R293_NATIVE_INMEMORY_RECEIPT_REJECTED'} + @($r.Checks).Count -lt 9 -or + @($r.Checks|Where-Object {$_ -ceq 'QUOTED_HOSTILE_USER_INPUT_ENCODED_AS_DATA'}).Count -ne 1 -or + @($r.Checks|Where-Object {$_ -ceq 'COMMAND_LINE_INJECTION_SENTINEL_NOT_EXECUTED'}).Count -ne 1){throw 'R293_NATIVE_INMEMORY_RECEIPT_REJECTED'} 'R293_TRUSTED_INMEMORY_HELPER_NEGATIVE=PASS' - name: Preserve bounded guest evidence if: always() diff --git a/build/Test-R293-InMemorySensorHelper-Native.ps1 b/build/Test-R293-InMemorySensorHelper-Native.ps1 index 89df2845..752af6ce 100644 --- a/build/Test-R293-InMemorySensorHelper-Native.ps1 +++ b/build/Test-R293-InMemorySensorHelper-Native.ps1 @@ -135,6 +135,44 @@ try{ } $checks.Add('REAL_POWERSHELL_51_INLINE_PREWRITE_REFUSAL') $checks.Add('ROGUE_WRITABLE_HELPER_IGNORED') + # R293B: an adversarial account argument contains literal PowerShell tokens. + # It must be data in the inline command, never executed as PowerShell code. + $evilUser=$user+"'; [IO.File]::WriteAllText('"+$sentinel+"','R293_INJECTION'); #" + $args4[3]=[string]$evilUser + $evilCmd=[string]$m.Invoke($null,$args4) + if(-not $evilCmd.StartsWith($prefix,[StringComparison]::Ordinal)){throw 'R293_INJECTED_COMMAND_NOT_ENCODED'} + $evilBootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($evilCmd.Substring($prefix.Length))) + if($evilBootstrap.Contains($evilUser) -or $evilBootstrap.Contains($sentinel)){ + throw 'R293_CALLER_ARGUMENT_WAS_INTERPOLATED_AS_SCRIPT_SOURCE' + } + $checks.Add('QUOTED_HOSTILE_USER_INPUT_ENCODED_AS_DATA') + $evilProcess=[Diagnostics.ProcessStartInfo]::new() + $evilProcess.FileName='powershell.exe' + $evilProcess.Arguments=$evilCmd + $evilProcess.UseShellExecute=$false + $evilProcess.CreateNoWindow=$true + $evilProcess.RedirectStandardOutput=$true + $evilProcess.RedirectStandardError=$true + $evil=[Diagnostics.Process]::Start($evilProcess) + if(!$evil){throw 'R293_HOSTILE_INJECTION_PROBE_PROCESS_MISSING'} + try{ + $evilStdout=$evil.StandardOutput.ReadToEndAsync() + $evilStderr=$evil.StandardError.ReadToEndAsync() + if(-not $evil.WaitForExit(20000)){ + try{$evil.Kill()}catch{} + throw 'R293_INJECTED_COMMAND_PROCESS_TIMEOUT' + } + $evilOut=$evilStdout.GetAwaiter().GetResult() + $evilErr=$evilStderr.GetAwaiter().GetResult() + $evilExit=$evil.ExitCode + }finally{$evil.Dispose()} + if($evilExit -eq 0 -or + ($evilOut+$evilErr) -notmatch 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' -or + (Test-Path -LiteralPath $sentinel) -or + (Hash $rogue) -cne $rogueBefore){ + throw 'R293_COMMAND_INJECTION_NEGATIVE_PROBE_NOT_FAIL_CLOSED' + } + $checks.Add('COMMAND_LINE_INJECTION_SENTINEL_NOT_EXECUTED') $checks.Add('NO_UAC_PROTECTED_SERVICE_MUTATION') if((Hash $live) -cne $mainBefore){ throw 'R293_GUEST_INSTALLED_MAIN_CHANGED' From 7eb190d4dc6694e2d7ba699b3b3f74010da3dda4 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 11:31:25 +0330 Subject: [PATCH 026/135] security: pin and exclusively snapshot Broker/Supervisor source bytes before privileged copy (R294) --- ...it-r286-optional-audio-native-recovery.yml | 25 +++- .../Test-R293-InMemorySensorHelper-Native.ps1 | 2 +- build/Test-R294-PayloadProvenance-Native.ps1 | 127 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 39 +++++- installer/TaskbarMonitorEnhanced_Setup.cs | 8 +- 5 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 build/Test-R294-PayloadProvenance-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 20bb23ae..9021ee93 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -206,11 +206,33 @@ jobs: $r.RealNonElevatedEncodedPowerShellCalled -ne $true -or $r.InvalidAppRootRejectedBeforeWrite -ne $true -or $r.InstalledMainUnchanged -ne $true -or - $r.BoundedEncodedCommandLength -lt 1000 -or $r.BoundedEncodedCommandLength -gt 30000 -or + $r.BoundedEncodedCommandLength -lt 1000 -or $r.BoundedEncodedCommandLength -gt 31500 -or @($r.Checks).Count -lt 9 -or @($r.Checks|Where-Object {$_ -ceq 'QUOTED_HOSTILE_USER_INPUT_ENCODED_AS_DATA'}).Count -ne 1 -or @($r.Checks|Where-Object {$_ -ceq 'COMMAND_LINE_INJECTION_SENTINEL_NOT_EXECUTED'}).Count -ne 1){throw 'R293_NATIVE_INMEMORY_RECEIPT_REJECTED'} 'R293_TRUSTED_INMEMORY_HELPER_NEGATIVE=PASS' + - name: R294 verify locked SHA-pinned executable source, tamper and sharing rejection + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R294-PayloadProvenance-Native.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object {$_.Message};throw 'R294_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R294-PayloadProvenance-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R294_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R294_PROVENANCE_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R294_PROVENANCE_RECEIPT_NOT_CREATED'} + $r=Get-Content $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R294' -or + $r.Status -cne 'PASS_SHA_PINNED_EXCLUSIVE_SINGLE_READ_SENSOR_BINARIES' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrServiceMutation -ne $false -or + $r.BothTamperedInputsRejected -ne $true -or $r.ExclusiveLockProved -ne $true -or + $r.VerifiedBuffersInstalledToSandboxOnly -ne $true -or $r.InstalledMainUnchanged -ne $true -or + $r.ExpectedBrokerSHA256 -cnotmatch '^[A-F0-9]{64}$' -or + $r.ExpectedSupervisorSHA256 -cnotmatch '^[A-F0-9]{64}$' -or + @($r.Checks).Count -lt 10){throw 'R294_TYPED_NATIVE_PROVENANCE_RECEIPT_FAILED'} + 'R294_SHA_PINNED_EXCLUSIVE_VERIFIED_BYTES_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -224,3 +246,4 @@ jobs: ${{ runner.temp }}/R290_${{ matrix.host }}_${{ github.run_id }}/R290_REPARSE_RECEIPT.json ${{ runner.temp }}/R291_${{ matrix.host }}_${{ github.run_id }}/R291_BOUNDARY_RECEIPT.json ${{ runner.temp }}/R293_${{ matrix.host }}_${{ github.run_id }}/R293_INMEMORY_RECEIPT.json + ${{ runner.temp }}/R294_${{ matrix.host }}_${{ github.run_id }}/R294_PROVENANCE_RECEIPT.json diff --git a/build/Test-R293-InMemorySensorHelper-Native.ps1 b/build/Test-R293-InMemorySensorHelper-Native.ps1 index 752af6ce..c8b653f1 100644 --- a/build/Test-R293-InMemorySensorHelper-Native.ps1 +++ b/build/Test-R293-InMemorySensorHelper-Native.ps1 @@ -65,7 +65,7 @@ try{ throw 'R293_ELEVATION_COMMAND_USES_UNTRUSTED_FILE_PATH' } $encoded=$cmd.Substring($prefix.Length) - if($encoded.Length -lt 1000 -or $encoded.Length -gt 30000){ + if($encoded.Length -lt 1000 -or $encoded.Length -gt 31500){ throw 'R293_ENCODED_COMMAND_LENGTH_OUT_OF_BOUNDS' } $script=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) diff --git a/build/Test-R294-PayloadProvenance-Native.ps1 b/build/Test-R294-PayloadProvenance-Native.ps1 new file mode 100644 index 00000000..49e7d2a4 --- /dev/null +++ b/build/Test-R294-PayloadProvenance-Native.ps1 @@ -0,0 +1,127 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R294_ONLY_GITHUB_DISPOSABLE_WINDOWS' +} +function Hash([string]$Path){ + if(!(Test-Path -LiteralPath $Path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +$root=Join-Path $env:RUNNER_TEMP ('R294_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$report=Join-Path $root 'R294_PROVENANCE_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R293_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R293_INMEMORY_RECEIPT.json') +$accepted=$false +$check=[Collections.Generic.List[string]]::new() +if(Test-Path $root){throw 'R294_GUEST_FIXTURE_ALREADY_PRESENT'} +[void][IO.Directory]::CreateDirectory($root) +$mainBefore=Hash $main +try{ + $info=Get-Content $manifest -Raw|ConvertFrom-Json + $receipt=Get-Content $prior -Raw|ConvertFrom-Json + if($info.Commit -cne $env:GITHUB_SHA -or $info.SetupSHA256 -cne (Hash $setup) -or + $info.MainSHA256 -cne $mainBefore -or + $receipt.SourceCommit -cne $env:GITHUB_SHA -or $receipt.Host -cne $HostLabel -or + $receipt.Status -cne 'PASS_NO_TEMP_SCRIPT_ELEVATION_INLINE_TRUSTED_SOURCE'){ + throw 'R294_EXACT_GUEST_BASELINE_OR_SOURCE_NOT_PINNED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $m=$type.GetMethod('BuildElevatedHelperCommand',[Reflection.BindingFlags]'NonPublic,Static') + if(!$m){throw 'R294_EXPECTED_COMMAND_BUILDER_MISSING'} + $args4=[object[]]::new(4) + $args4[0]=[string]'Install' + $args4[1]=[string](Join-Path ([IO.Path]::GetTempPath()) ('tbme_setup_'+('a'*32))) + $args4[2]=[string]$app + $args4[3]=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) + $command=[string]$m.Invoke($null,$args4) + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + if(!$command.StartsWith($prefix,[StringComparison]::Ordinal) -or + $command.Length -gt 31580){throw 'R294_UNEXPECTED_LAUNCH_COMMAND'} + $encoded=$command.Substring($prefix.Length) + $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + $match=[regex]::Match($bootstrap,"-ExpectedBrokerSHA256 '([A-F0-9]{64})' -ExpectedSupervisorSHA256 '([A-F0-9]{64})'") + if(!$match.Success){throw 'R294_TRUSTED_EMBEDDED_HASHES_NOT_TRANSPORTED'} + $check.Add('RESOURCE_IDENTITY_PINNED_INSIDE_ENCODED_BOOTSTRAP') + $helperPath=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path + $tokens=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($helperPath,[ref]$tokens,[ref]$errors) + if(@($errors).Count){throw 'R294_HELPER_WINDOWS_POWERSHELL51_PARSE_FAILED'} + $functions=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Get-VerifiedSensorPayloadBytes') + },$true)) + if($functions.Count -ne 2){throw 'R294_EXPECTED_TWO_PURE_FUNCTIONS'} + . ([scriptblock]::Create(($functions|ForEach-Object{$_.Extent.Text}) -join [Environment]::NewLine)) + foreach($kind in @( + @{Name='Broker';Resource='Payload.TaskbarMonitorSensorBroker.exe';Expected=$match.Groups[1].Value}, + @{Name='Supervisor';Resource='Payload.TaskbarMonitorSensorSupervisor.exe';Expected=$match.Groups[2].Value} + )){ + $path=Join-Path $root ('TBME_R294_'+$kind.Name+'.exe') + $stream=$assembly.GetManifestResourceStream($kind.Resource) + if(!$stream){throw 'R294_EMBEDDED_EXECUTABLE_RESOURCE_MISSING'} + $out=[IO.FileStream]::new($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.CopyTo($out)}finally{$out.Dispose();$stream.Dispose()} + if((Hash $path) -cne $kind.Expected){throw 'R294_EMBEDDED_RESOURCE_HASH_NOT_BOUND'} + $verified=[byte[]](Get-VerifiedSensorPayloadBytes $path $kind.Expected 16777216) + $verifiedSHA=[BitConverter]::ToString(([Security.Cryptography.SHA256]::Create()).ComputeHash($verified)).Replace('-','') + if($verifiedSHA -cne $kind.Expected){throw 'R294_VERIFIED_PAYLOAD_BYTES_ARE_DIFFERENT'} + $check.Add($kind.Name.ToUpperInvariant()+'_SAME_VERIFIED_BYTES_AS_EMBEDDED') + $installed=Join-Path $root ('TRUSTED_COPY_'+$kind.Name+'.exe') + [IO.File]::WriteAllBytes($installed,$verified) + if((Hash $installed) -cne $kind.Expected){throw 'R294_PROVENANCE_BUFFER_COULD_NOT_BE_INSTALLED'} + $check.Add($kind.Name.ToUpperInvariant()+'_IN_MEMORY_COPY_IDENTITY') + $rogue=[IO.File]::ReadAllBytes($path) + $rogue[0]=$rogue[0] -bxor 0x01 + [IO.File]::WriteAllBytes($path,$rogue) + $refused=$false + try{$null=Get-VerifiedSensorPayloadBytes $path $kind.Expected 16777216} + catch{if($_.Exception.Message -match 'SENSOR_PAYLOAD_EMBEDDED_SHA_MISMATCH'){$refused=$true}else{throw}} + if(!$refused){throw 'R294_TAMPERED_SOURCE_WAS_ACCEPTED'} + $check.Add($kind.Name.ToUpperInvariant()+'_TAMPERED_TEMP_BYTES_REJECTED') + $locked=[IO.FileStream]::new($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + $blocked=$false + try{$null=Get-VerifiedSensorPayloadBytes $path $kind.Expected 16777216} + catch [IO.IOException]{$blocked=$true} + if(!$blocked){throw 'R294_EXCLUSIVE_SOURCE_LOCK_FAILED'} + }finally{$locked.Dispose()} + $check.Add($kind.Name.ToUpperInvariant()+'_CONCURRENT_ACCESS_BLOCKED') + } + if((Hash $main) -cne $mainBefore){throw 'R294_PROTECTED_MAIN_MUTATED'} + $check.Add('EXTERNAL_GUEST_INSTALLED_MAIN_UNCHANGED') + $record=[ordered]@{ + Schema=1;Revision='R294';Status='PASS_SHA_PINNED_EXCLUSIVE_SINGLE_READ_SENSOR_BINARIES' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;UACOrServiceMutation=$false + ExpectedBrokerSHA256=$match.Groups[1].Value + ExpectedSupervisorSHA256=$match.Groups[2].Value + BothTamperedInputsRejected=$true;ExclusiveLockProved=$true + VerifiedBuffersInstalledToSandboxOnly=$true;InstalledMainUnchanged=$true + Checks=@($check);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($report,($record|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R294_GUEST_'+$HostLabel+'_EXCLUSIVE_EMBEDDED_BINARY_PROVENANCE_PASS' +}finally{ + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R294';Status='FAILED_NOT_ACCEPTED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerUntouched=$true;RecordedUtc=[DateTime]::UtcNow.ToString('o')} + [IO.File]::WriteAllText($report,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 52aeff8a..6aef83ae 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -2,7 +2,9 @@ param( [Parameter(Mandatory=$true)][ValidateSet('Install','Uninstall')][string]$Mode, [Parameter(Mandatory=$true)][string]$PayloadDir, [Parameter(Mandatory=$true)][string]$AppRoot, - [Parameter(Mandatory=$true)][string]$UserId + [Parameter(Mandatory=$true)][string]$UserId, + [string]$ExpectedBrokerSHA256='', + [string]$ExpectedSupervisorSHA256='' ) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' @@ -305,6 +307,28 @@ if($Mode -eq 'Uninstall'){ } } +# R294: one exclusive read; installed bytes are the same verified buffer. +function Get-VerifiedSensorPayloadBytes([string]$File,[string]$Expected,[long]$MaxBytes){ + if($Expected -cnotmatch '^[A-F0-9]{64}$'){throw 'SENSOR_PAYLOAD_SHA_NOT_PINNED'} + Assert-SensorBoundaryNoReparse $File + $stream=[IO.FileStream]::new($File,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + if($stream.Length -lt 1 -or $stream.Length -gt $MaxBytes){throw 'SENSOR_PAYLOAD_SIZE_INVALID'} + $buffer=[byte[]]::new([int]$stream.Length) + $offset=0 + while($offset -lt $buffer.Length){ + $count=$stream.Read($buffer,$offset,$buffer.Length-$offset) + if($count -le 0){throw 'SENSOR_PAYLOAD_SHORT_READ'} + $offset+=$count + } + $sha=[Security.Cryptography.SHA256]::Create() + try{$actual=[BitConverter]::ToString($sha.ComputeHash($buffer)).Replace('-','')} + finally{$sha.Dispose()} + if($actual -cne $Expected){throw 'SENSOR_PAYLOAD_EMBEDDED_SHA_MISMATCH'} + return ,$buffer + }finally{$stream.Dispose()} +} + $BrokerPayload=Join-Path $PayloadDir 'TaskbarMonitorSensorBroker.exe' $SupervisorPayload=Join-Path $PayloadDir 'TaskbarMonitorSensorSupervisor.exe' $PawnPayload=Join-Path $PayloadDir 'PawnIO_setup.exe' @@ -344,6 +368,15 @@ if(-not $pawnAlreadyCompatible -and !(Test-Path -LiteralPath $PawnPayload -PathT exit 0 } +# R294: Verify pinned embedded identity before touching protected services. +try{ + $VerifiedBrokerBytes=Get-VerifiedSensorPayloadBytes $BrokerPayload $ExpectedBrokerSHA256 16777216 + $VerifiedSupervisorBytes=Get-VerifiedSensorPayloadBytes $SupervisorPayload $ExpectedSupervisorSHA256 16777216 +}catch{ + Write-Log ('SENSOR_PAYLOAD_PROVENANCE_REFUSED '+$_.Exception.Message) + Write-Result 'DEGRADED' 'Protected sensor payload provenance not verified; prior service preserved.' 'NOT_STARTED' $null $false $false $false + exit 0 +} if(-not(Capture-PreviousSensorLayer)){ $ActiveArchitecture='PREVIOUS_SENSOR_LAYER_UNCHANGED' Write-Result 'DEGRADED' 'Setup could not capture a rollback snapshot of the existing protected sensor layer, so it did not modify that layer.' 'NOT_STARTED' $null $false $false $false @@ -408,8 +441,8 @@ try{ Remove-Item -LiteralPath $LiveJson,$GpuJson,$StorageJson,$SupervisorState -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $BrokerRoot|Out-Null - Copy-Item -LiteralPath $BrokerPayload -Destination (Join-Path $BrokerRoot 'TaskbarMonitorSensorBroker.exe') -Force - Copy-Item -LiteralPath $SupervisorPayload -Destination (Join-Path $BrokerRoot 'TaskbarMonitorSensorSupervisor.exe') -Force + [IO.File]::WriteAllBytes((Join-Path $BrokerRoot 'TaskbarMonitorSensorBroker.exe'),$VerifiedBrokerBytes) + [IO.File]::WriteAllBytes((Join-Path $BrokerRoot 'TaskbarMonitorSensorSupervisor.exe'),$VerifiedSupervisorBytes) Get-ChildItem -LiteralPath $BackendRoot -File -Recurse | ForEach-Object { $relative=$_.FullName.Substring($BackendRoot.Length).TrimStart('\') diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index a75dad8e..1696c8fe 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -117,6 +117,10 @@ static string BuildElevatedHelperCommand(string mode,string payload,string appRo { if(mode!="Install" && mode!="Uninstall") throw new InvalidDataException("Unexpected elevated sensor mode."); + string brokerHash=Sha256Resource("Payload.TaskbarMonitorSensorBroker.exe"); + string supervisorHash=Sha256Resource("Payload.TaskbarMonitorSensorSupervisor.exe"); + if(brokerHash.Length!=64 || supervisorHash.Length!=64) + throw new InvalidDataException("Protected sensor resource hash unavailable."); string packed; using(Stream resource=Resource("Payload.TBME_Setup_Elevated_Helper.ps1")) { @@ -147,11 +151,11 @@ static string BuildElevatedHelperCommand(string mode,string payload,string appRo "$payload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(payload)+"'))\n"+ "$app=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(appRoot)+"'))\n"+ "$user=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(user)+"'))\n"+ - "& ([ScriptBlock]::Create($code)) -Mode '"+mode+"' -PayloadDir $payload -AppRoot $app -UserId $user\n"; + "& ([ScriptBlock]::Create($code)) -Mode '"+mode+"' -PayloadDir $payload -AppRoot $app -UserId $user -ExpectedBrokerSHA256 '"+brokerHash+"' -ExpectedSupervisorSHA256 '"+supervisorHash+"'\n"; string encoded=Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); // Windows CreateProcess has a 32,767-character maximum; leave margin. // Fail closed instead of falling back to unsafe -File or user-writable scripts. - if(encoded.Length>30000) + if(encoded.Length>31500) throw new InvalidDataException("Trusted sensor helper command exceeds safe limit."); return "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; } From 3f50d9c905ef2d60b1e12b590602fc9a95dc9304 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 12:02:48 +0330 Subject: [PATCH 027/135] security: install only SHA-pinned ZIP dependency buffers from embedded PE source (R295) --- ...it-r286-optional-audio-native-recovery.yml | 25 +++ .../Test-R293-InMemorySensorHelper-Native.ps1 | 13 +- build/Test-R295-TrustedBackendZip-Native.ps1 | 182 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 65 ++++++- installer/TaskbarMonitorEnhanced_Setup.cs | 30 ++- 5 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 build/Test-R295-TrustedBackendZip-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 9021ee93..75f460eb 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -233,6 +233,30 @@ jobs: $r.ExpectedSupervisorSHA256 -cnotmatch '^[A-F0-9]{64}$' -or @($r.Checks).Count -lt 10){throw 'R294_TYPED_NATIVE_PROVENANCE_RECEIPT_FAILED'} 'R294_SHA_PINNED_EXCLUSIVE_VERIFIED_BYTES_PASS' + - name: R295 verify PE-pinned ZIP dependency bytes and hostile archive rejection + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R295-TrustedBackendZip-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R295_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R295-TrustedBackendZip-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R295_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R295_ZIP_PROVENANCE_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R295_NATIVE_RECEIPT_NOT_FOUND'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R295' -or + $r.Status -cne 'PASS_PE_PINNED_ZIP_BYTE_ONLY_BACKEND_PROVENANCE' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.ElevatedUACOrProtectedServiceUsed -ne $false -or + $r.EmbeddedArchiveSHA256 -cnotmatch '^[A-F0-9]{64}$' -or + $r.EntryCount -ne 43 -or $r.UncompressedBytes -ne 12203773 -or + $r.TamperedArchiveRefused -ne $true -or + $r.TraversalAndDuplicateAndLinkRefused -ne $true -or + $r.ExclusiveSourceLockVerified -ne $true -or + $r.VerifiedBytesOnlyCopiedToSandbox -ne $true -or + $r.InstalledMainUnchanged -ne $true -or @($r.Checks).Count -lt 11){throw 'R295_TYPED_ZIP_RECEIPT_INVALID'} + 'R295_EXACT_ZIP_TRAVERSAL_TAMPER_DUPLICATE_LINK_REFUSAL_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -247,3 +271,4 @@ jobs: ${{ runner.temp }}/R291_${{ matrix.host }}_${{ github.run_id }}/R291_BOUNDARY_RECEIPT.json ${{ runner.temp }}/R293_${{ matrix.host }}_${{ github.run_id }}/R293_INMEMORY_RECEIPT.json ${{ runner.temp }}/R294_${{ matrix.host }}_${{ github.run_id }}/R294_PROVENANCE_RECEIPT.json + ${{ runner.temp }}/R295_${{ matrix.host }}_${{ github.run_id }}/R295_ZIP_PROVENANCE_RECEIPT.json diff --git a/build/Test-R293-InMemorySensorHelper-Native.ps1 b/build/Test-R293-InMemorySensorHelper-Native.ps1 index c8b653f1..d23905f2 100644 --- a/build/Test-R293-InMemorySensorHelper-Native.ps1 +++ b/build/Test-R293-InMemorySensorHelper-Native.ps1 @@ -84,12 +84,15 @@ try{ if(!$resource){throw 'R293_MISSING_SETUP_RESOURCE'} $textReader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) try{$source=$textReader.ReadToEnd()}finally{$textReader.Dispose()} - $crlf=[string][char]13+[string][char]10 - $lf=[string][char]10 - if($code.Replace($crlf,$lf).TrimEnd() -cne $source.Replace($crlf,$lf).TrimEnd()){ - throw 'R293_PACKED_SOURCE_DIFFERS_FROM_SETUP_RESOURCE' + # R295 transports a comment/indentation-compacted representation of the + # exact source PE resource, not an independently writable source file. + $compactor=$type.GetMethod('CompactTrustedHelperSource',[Reflection.BindingFlags]'NonPublic,Static') + if(!$compactor){throw 'R295_TRUSTED_SOURCE_COMPACTOR_MISSING'} + $expected=[string]$compactor.Invoke($null,([object[]]@([string]$source))) + if($code -cne $expected){ + throw 'R295_PACKED_SOURCE_DIFFERS_FROM_COMPACTED_SETUP_RESOURCE' } - $checks.Add('DECOMPRESSED_RESOURCE_EXACT_TO_SETUP') + $checks.Add('DECOMPRESSED_RESOURCE_EXACT_TO_COMPACTED_SETUP') if(@($script|Select-String -Pattern ([regex]::Escape($args4[2]))).Count){ throw 'R293_UNTRUSTED_PATH_EMBEDDED_AS_RAW_SCRIPT_LITERAL' } diff --git a/build/Test-R295-TrustedBackendZip-Native.ps1 b/build/Test-R295-TrustedBackendZip-Native.ps1 new file mode 100644 index 00000000..1e832c63 --- /dev/null +++ b/build/Test-R295-TrustedBackendZip-Native.ps1 @@ -0,0 +1,182 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R295_ONLY_DISPOSABLE_GITHUB_WINDOWS_VM' +} +function Hash([string]$Path){ + if(!(Test-Path -LiteralPath $Path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +function ExpectRefusal([string]$Name,[string]$File,[string]$SHA,[string]$ExpectedMessage){ + $blocked=$false + try{$null=Get-VerifiedSensorBackendEntries $File $SHA} + catch{ + if($_.Exception.Message -match [regex]::Escape($ExpectedMessage)){$blocked=$true}else{ + throw ('R295_UNEXPECTED_REFUSAL_'+$Name+': '+$_.Exception.Message) + } + } + if(!$blocked){throw ('R295_UNSAFE_ARCHIVE_ACCEPTED_'+$Name)} + $script:checks.Add($Name) +} +function MakeSyntheticZip([string]$Destination,[string]$Variant){ + $buffer=[IO.MemoryStream]::new() + $zip=[IO.Compression.ZipArchive]::new($buffer,[IO.Compression.ZipArchiveMode]::Create,$true) + try{ + for($i=0;$i -lt 43;$i++){ + $name=('fixtures/entry'+$i.ToString('D2')+'.bin') + if($Variant -eq 'TRAVERSAL' -and $i -eq 0){$name='../outside.dll'} + if($Variant -eq 'DUPLICATE' -and $i -eq 0){$name='fixtures/COLLIDE.dll'} + if($Variant -eq 'DUPLICATE' -and $i -eq 1){$name='FIXTURES/collide.DLL'} + $entry=$zip.CreateEntry($name) + if($Variant -eq 'LINK' -and $i -eq 0){$entry.ExternalAttributes=[int]-1610612736} + $stream=$entry.Open() + try{$data=[byte[]]@(0x41,[byte]$i,0x42);$stream.Write($data,0,$data.Length)} + finally{$stream.Dispose()} + } + }finally{$zip.Dispose()} + [IO.File]::WriteAllBytes($Destination,$buffer.ToArray()) + $buffer.Dispose() +} +$root=Join-Path $env:RUNNER_TEMP ('R295_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receiptFile=Join-Path $root 'R295_ZIP_PROVENANCE_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R294_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R294_PROVENANCE_RECEIPT.json') +$checks=[Collections.Generic.List[string]]::new() +$success=$false +if(Test-Path $root){throw 'R295_PREEXISTING_FIXTURE_REFUSED'} +[void][IO.Directory]::CreateDirectory($root) +$beforeMain=Hash $main +try{ + $id=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json + $prev=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json + if($id.Commit -cne $env:GITHUB_SHA -or (Hash $setup) -cne $id.SetupSHA256 -or + (Hash $main) -cne $id.MainSHA256 -or + $prev.SourceCommit -cne $env:GITHUB_SHA -or $prev.Host -cne $HostLabel -or + $prev.Status -cne 'PASS_SHA_PINNED_EXCLUSIVE_SINGLE_READ_SENSOR_BINARIES'){ + throw 'R295_EXACT_SOURCE_AND_PRIOR_RECEIPT_NOT_VERIFIED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $m=$type.GetMethod('BuildElevatedHelperCommand',[Reflection.BindingFlags]'NonPublic,Static') + if(!$m){throw 'R295_BUILD_INLINE_COMMAND_METHOD_MISSING'} + $args4=[object[]]::new(4) + $args4[0]=[string]'Install' + $args4[1]=[string](Join-Path ([IO.Path]::GetTempPath()) ('tbme_setup_'+('a'*32))) + $args4[2]=[string]$app + $args4[3]=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) + $command=[string]$m.Invoke($null,$args4) + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + if(!$command.StartsWith($prefix,[StringComparison]::Ordinal) -or + $command.Length -gt 31580){throw 'R295_NATIVE_COMMAND_SIZE_OR_ENTRY_INVALID'} + $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($command.Substring($prefix.Length))) + $match=[regex]::Match($bootstrap,"-ExpectedBackendZipSHA256 '([A-F0-9]{64})'") + if(!$match.Success){throw 'R295_EMBEDDED_ZIP_SHA_NOT_TRANSPORTED'} + $expectedHash=$match.Groups[1].Value + $checks.Add('PE_EMBEDDED_ZIP_SHA_TRANSPORTED') + $compactor=$type.GetMethod('CompactTrustedHelperSource',[Reflection.BindingFlags]'NonPublic,Static') + if(!$compactor){throw 'R295_SOURCE_COMPACTOR_NOT_FOUND'} + $helper=Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1' + $helper=[IO.Path]::GetFullPath($helper) + $tokens=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$errors) + if(@($errors).Count){throw 'R295_PS51_HELPER_SYNTAX_INVALID'} + $selected=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Get-VerifiedSensorPayloadBytes','Get-VerifiedSensorBackendEntries') + },$true)) + if($selected.Count -ne 3){throw 'R295_THREE_SECURITY_FUNCTIONS_NOT_PRESENT'} + . ([scriptblock]::Create(($selected|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + $raw=Get-Content -LiteralPath $helper -Raw + $zipResource=$assembly.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + if(!$zipResource){throw 'R295_SETUP_MISSING_BUNDLED_ZIP_RESOURCE'} + $zipPath=Join-Path $root 'LibreHardwareMonitor.zip' + $writer=[IO.FileStream]::new($zipPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$zipResource.CopyTo($writer)}finally{$writer.Dispose();$zipResource.Dispose()} + if((Hash $zipPath) -cne $expectedHash){throw 'R295_STAGED_ZIP_NOT_PE_EMBEDDED'} + $checks.Add('STAGED_ZIP_MATCHES_PE_PINNED_SHA') + $items=Get-VerifiedSensorBackendEntries $zipPath $expectedHash + if(@($items).Count -ne 43){throw 'R295_EXPECTED_43_FILES_MISSING'} + $count=0 + $bytesTotal=0 + $dir=Join-Path $root 'private-sandbox' + [void][IO.Directory]::CreateDirectory($dir) + foreach($item in $items){ + $destination=Join-Path $dir ($item.Rel.Replace('/','\')) + $parent=[IO.Path]::GetDirectoryName($destination) + if($parent){[void][IO.Directory]::CreateDirectory($parent)} + [IO.File]::WriteAllBytes($destination,[byte[]]$item.Bytes) + $sha=[Security.Cryptography.SHA256]::Create() + try{$expectedFileSHA=[BitConverter]::ToString($sha.ComputeHash([byte[]]$item.Bytes)).Replace('-','')} + finally{$sha.Dispose()} + if((Hash $destination) -cne $expectedFileSHA){throw 'R295_VERIFIED_BUFFER_COPY_DIFFERS'} + $count++;$bytesTotal+=$item.Bytes.Length + } + if($count -ne 43 -or $bytesTotal -ne 12203773 -or + @(Get-ChildItem -LiteralPath $dir -Recurse -File).Count -ne 43){ + throw 'R295_PROTECTED_DEPENDENCIES_NOT_EXACT_43' + } + $checks.Add('ALL_43_ARCHIVE_ENTRIES_ARE_VERIFIED_BUFFERS') + $checks.Add('COPIED_ONLY_VERIFIED_BUFFERS_TO_ISOLATED_SANDBOX') + $tampered=[IO.File]::ReadAllBytes($zipPath) + $tampered[10]=$tampered[10] -bxor 0x01 + [IO.File]::WriteAllBytes($zipPath,$tampered) + $blocked=$false + try{$null=Get-VerifiedSensorBackendEntries $zipPath $expectedHash} + catch{if($_.Exception.Message -match 'SENSOR_PAYLOAD_EMBEDDED_SHA_MISMATCH'){$blocked=$true}else{throw}} + if(!$blocked){throw 'R295_TAMPERED_ARCHIVE_NOT_REJECTED'} + $checks.Add('TAMPERED_ARCHIVE_REJECTED_BEFORE_ANY_EXTRACTION') + $exclusive=[IO.FileStream]::new($zipPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + $blocked=$false + try{$null=Get-VerifiedSensorBackendEntries $zipPath $expectedHash} + catch [IO.IOException]{$blocked=$true} + if(!$blocked){throw 'R295_EXCLUSIVE_ZIP_HANDLE_NOT_PROTECTED'} + }finally{$exclusive.Dispose()} + $checks.Add('EXCLUSIVE_ZIP_FILE_HANDLE_BLOCKS_SECOND_READER') + foreach($mode in @('TRAVERSAL','DUPLICATE','LINK')){ + $filename=Join-Path $root ($mode+'.zip') + MakeSyntheticZip $filename $mode + $expected=switch($mode){ + 'TRAVERSAL'{'SENSOR_BACKEND_ZIP_UNSAFE_ENTRY'} + 'DUPLICATE'{'SENSOR_BACKEND_ZIP_DUPLICATE_ENTRY'} + 'LINK'{'SENSOR_BACKEND_ZIP_LINK_REFUSED'} + } + ExpectRefusal ('ADVERSARIAL_ZIP_'+$mode+'_REJECTED') $filename (Hash $filename) $expected + } + $checks.Add('ZIP_PATHS_HAVE_NO_ESCAPE_SYMLINK_OR_COLLISION') + $checks.Add('NO_HOST_UAC_OR_PROTECTED_SENSOR_SERVICE_ACTION') + if((Hash $main) -cne $beforeMain){throw 'R295_RUNNING_MAIN_CHANGED'} + $checks.Add('INSTALLED_MAIN_UNCHANGED') + $proof=[ordered]@{ + Schema=1;Revision='R295';Status='PASS_PE_PINNED_ZIP_BYTE_ONLY_BACKEND_PROVENANCE' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;ElevatedUACOrProtectedServiceUsed=$false + EmbeddedArchiveSHA256=$expectedHash;EntryCount=$count;UncompressedBytes=$bytesTotal + TamperedArchiveRefused=$true;TraversalAndDuplicateAndLinkRefused=$true + ExclusiveSourceLockVerified=$true;VerifiedBytesOnlyCopiedToSandbox=$true + InstalledMainUnchanged=$true + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receiptFile,($proof|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $success=$true + 'R295_NATIVE_'+$HostLabel+'_PINNED_BUNDLED_ZIP_PASS' +}finally{ + if(!$success){ + $fail=[ordered]@{Schema=1;Revision='R295';Status='FAILED_NOT_ACCEPTED';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OwnerHostUntouched=$true} + [IO.File]::WriteAllText($receiptFile,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 6aef83ae..c90afbe9 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -4,7 +4,8 @@ param( [Parameter(Mandatory=$true)][string]$AppRoot, [Parameter(Mandatory=$true)][string]$UserId, [string]$ExpectedBrokerSHA256='', - [string]$ExpectedSupervisorSHA256='' + [string]$ExpectedSupervisorSHA256='', + [string]$ExpectedBackendZipSHA256='' ) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' @@ -329,6 +330,58 @@ function Get-VerifiedSensorPayloadBytes([string]$File,[string]$Expected,[long]$M }finally{$stream.Dispose()} } +# R295: only the PE-pinned, single-read ZIP buffer supplies protected dependency bytes. +function Get-VerifiedSensorBackendEntries([string]$ZipFile,[string]$ExpectedZipSHA256){ + $bytes=Get-VerifiedSensorPayloadBytes $ZipFile $ExpectedZipSHA256 16777216 + Add-Type -AssemblyName System.IO.Compression + $mem=[IO.MemoryStream]::new([byte[]]$bytes) + $archive=[IO.Compression.ZipArchive]::new($mem,[IO.Compression.ZipArchiveMode]::Read,$false) + $names=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $result=[Collections.Generic.List[object]]::new() + [long]$sum=0 + try{ + if($archive.Entries.Count -ne 43){throw 'SENSOR_BACKEND_ZIP_ENTRY_COUNT'} + foreach($entry in $archive.Entries){ + $name=[string]$entry.FullName + if([string]::IsNullOrWhiteSpace($name) -or $name.Contains('\') -or + $name.Contains(':') -or $name.StartsWith('/') -or + [IO.Path]::IsPathRooted($name)){throw 'SENSOR_BACKEND_ZIP_UNSAFE_ENTRY'} + $parts=@($name.Split('/')) + foreach($part in $parts){ + if($part -ceq '.' -or $part -ceq '..' -or + $part.EndsWith('.') -or $part.EndsWith(' ') -or + $part -cnotmatch '^[A-Za-z0-9_.-]+$'){ + throw 'SENSOR_BACKEND_ZIP_UNSAFE_ENTRY' + } + } + if(-not $names.Add($name)){throw 'SENSOR_BACKEND_ZIP_DUPLICATE_ENTRY'} + $fileType=($entry.ExternalAttributes -shr 16) -band 0xF000 + if($fileType -eq 0xA000){throw 'SENSOR_BACKEND_ZIP_LINK_REFUSED'} + if($entry.Length -le 0 -or $entry.Length -gt 8388608){ + throw 'SENSOR_BACKEND_ZIP_FILE_SIZE' + } + $sum+=$entry.Length + if($sum -gt 33554432){throw 'SENSOR_BACKEND_ZIP_TOTAL_SIZE'} + $buffer=[byte[]]::new([int]$entry.Length) + $input=$entry.Open() + try{ + $offset=0 + while($offset -lt $buffer.Length){ + $read=$input.Read($buffer,$offset,$buffer.Length-$offset) + if($read -le 0){throw 'SENSOR_BACKEND_ZIP_TRUNCATED_ENTRY'} + $offset+=$read + } + if($input.ReadByte() -ne -1){throw 'SENSOR_BACKEND_ZIP_UNDECLARED_EXTRA_DATA'} + }finally{$input.Dispose()} + $result.Add([pscustomobject]@{Rel=$name;Bytes=$buffer}) + } + if(-not $names.Contains('LibreHardwareMonitorLib.dll')){ + throw 'SENSOR_BACKEND_ZIP_MISSING_LIBRARY' + } + return ,$result.ToArray() + }finally{$archive.Dispose();$mem.Dispose()} +} + $BrokerPayload=Join-Path $PayloadDir 'TaskbarMonitorSensorBroker.exe' $SupervisorPayload=Join-Path $PayloadDir 'TaskbarMonitorSensorSupervisor.exe' $PawnPayload=Join-Path $PayloadDir 'PawnIO_setup.exe' @@ -372,6 +425,7 @@ if(-not $pawnAlreadyCompatible -and !(Test-Path -LiteralPath $PawnPayload -PathT try{ $VerifiedBrokerBytes=Get-VerifiedSensorPayloadBytes $BrokerPayload $ExpectedBrokerSHA256 16777216 $VerifiedSupervisorBytes=Get-VerifiedSensorPayloadBytes $SupervisorPayload $ExpectedSupervisorSHA256 16777216 + $VerifiedBackendEntries=Get-VerifiedSensorBackendEntries (Join-Path $PayloadDir 'LibreHardwareMonitor.zip') $ExpectedBackendZipSHA256 }catch{ Write-Log ('SENSOR_PAYLOAD_PROVENANCE_REFUSED '+$_.Exception.Message) Write-Result 'DEGRADED' 'Protected sensor payload provenance not verified; prior service preserved.' 'NOT_STARTED' $null $false $false $false @@ -444,12 +498,13 @@ try{ [IO.File]::WriteAllBytes((Join-Path $BrokerRoot 'TaskbarMonitorSensorBroker.exe'),$VerifiedBrokerBytes) [IO.File]::WriteAllBytes((Join-Path $BrokerRoot 'TaskbarMonitorSensorSupervisor.exe'),$VerifiedSupervisorBytes) - Get-ChildItem -LiteralPath $BackendRoot -File -Recurse | ForEach-Object { - $relative=$_.FullName.Substring($BackendRoot.Length).TrimStart('\') - $destination=Join-Path $BrokerRoot $relative + foreach($verified in $VerifiedBackendEntries){ + $destination=Join-Path $BrokerRoot ($verified.Rel.Replace('/','\')) + Assert-SensorBoundaryNoReparse $destination $parent=Split-Path -Parent $destination if($parent){New-Item -ItemType Directory -Force -Path $parent|Out-Null} - Copy-Item -LiteralPath $_.FullName -Destination $destination -Force + Assert-SensorBoundaryNoReparse $destination + [IO.File]::WriteAllBytes($destination,[byte[]]$verified.Bytes) } Import-Module ScheduledTasks diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 1696c8fe..30b77806 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -113,13 +113,34 @@ static string Base64Utf8Argument(string value) return Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); } + // R295: compact only transport text, not the readable embedded .ps1 source. + // Refuse here-strings and continuation tokens that need verbatim whitespace. + static string CompactTrustedHelperSource(string source) + { + if(String.IsNullOrWhiteSpace(source) || + source.IndexOf("@'",StringComparison.Ordinal)>=0 || + source.IndexOf("@\"",StringComparison.Ordinal)>=0) + throw new InvalidDataException("Trusted sensor helper contains unsupported multiline text."); + StringBuilder compact=new StringBuilder(); + foreach(string raw in source.Replace("\r\n","\n").Split('\n')) + { + string line=raw.Trim(); + if(line.Length==0 || line.StartsWith("#",StringComparison.Ordinal))continue; + if(line.EndsWith("`",StringComparison.Ordinal)) + throw new InvalidDataException("Trusted sensor helper cannot compact continuation lines."); + compact.Append(line).Append('\n'); + } + return compact.ToString(); + } + static string BuildElevatedHelperCommand(string mode,string payload,string appRoot,string user) { if(mode!="Install" && mode!="Uninstall") throw new InvalidDataException("Unexpected elevated sensor mode."); string brokerHash=Sha256Resource("Payload.TaskbarMonitorSensorBroker.exe"); string supervisorHash=Sha256Resource("Payload.TaskbarMonitorSensorSupervisor.exe"); - if(brokerHash.Length!=64 || supervisorHash.Length!=64) + string backendZipHash=Sha256Resource("Payload.LibreHardwareMonitor.zip"); + if(brokerHash.Length!=64 || supervisorHash.Length!=64 || backendZipHash.Length!=64) throw new InvalidDataException("Protected sensor resource hash unavailable."); string packed; using(Stream resource=Resource("Payload.TBME_Setup_Elevated_Helper.ps1")) @@ -127,7 +148,7 @@ static string BuildElevatedHelperCommand(string mode,string payload,string appRo if(resource==null)throw new InvalidDataException("Trusted embedded sensor helper missing."); using(StreamReader reader=new StreamReader(resource,Encoding.UTF8,true)) { - string source=reader.ReadToEnd(); + string source=CompactTrustedHelperSource(reader.ReadToEnd()); byte[] textBytes=Encoding.UTF8.GetBytes(source); if(textBytes.Length<1000 || textBytes.Length>120000) throw new InvalidDataException("Unexpected sensor helper source size."); @@ -151,7 +172,7 @@ static string BuildElevatedHelperCommand(string mode,string payload,string appRo "$payload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(payload)+"'))\n"+ "$app=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(appRoot)+"'))\n"+ "$user=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(user)+"'))\n"+ - "& ([ScriptBlock]::Create($code)) -Mode '"+mode+"' -PayloadDir $payload -AppRoot $app -UserId $user -ExpectedBrokerSHA256 '"+brokerHash+"' -ExpectedSupervisorSHA256 '"+supervisorHash+"'\n"; + "& ([ScriptBlock]::Create($code)) -Mode '"+mode+"' -PayloadDir $payload -AppRoot $app -UserId $user -ExpectedBrokerSHA256 '"+brokerHash+"' -ExpectedSupervisorSHA256 '"+supervisorHash+"' -ExpectedBackendZipSHA256 '"+backendZipHash+"'\n"; string encoded=Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); // Windows CreateProcess has a 32,767-character maximum; leave margin. // Fail closed instead of falling back to unsafe -File or user-writable scripts. @@ -312,6 +333,9 @@ static string PrepareSensorPayload() Directory.CreateDirectory(payload); Extract("Payload.TaskbarMonitorSensorBroker.exe",Path.Combine(payload,"TaskbarMonitorSensorBroker.exe")); Extract("Payload.TaskbarMonitorSensorSupervisor.exe",Path.Combine(payload,"TaskbarMonitorSensorSupervisor.exe")); + // This user-writable staging file is bound to the PE embedded ZIP hash + // before any elevated service changes; it is deleted with this payload. + Extract("Payload.LibreHardwareMonitor.zip",Path.Combine(payload,"LibreHardwareMonitor.zip")); // The official PawnIO binary is separately licensed. Public OSS Setup never embeds it. // A previously installed PawnIO 2.2.0 may be reused; otherwise the sensor helper degrades // safely and offers upstream manual installation, without silent driver downloads. From 0cbfb85b37e25aa01c348a13101205ec41158539 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 12:44:09 +0330 Subject: [PATCH 028/135] security: keep protected sensor rollback bytes and task XML in memory (R296) --- ...it-r286-optional-audio-native-recovery.yml | 25 ++- .../Test-R293-InMemorySensorHelper-Native.ps1 | 2 +- build/Test-R294-PayloadProvenance-Native.ps1 | 2 +- build/Test-R295-TrustedBackendZip-Native.ps1 | 2 +- build/Test-R296-RollbackMemory-Native.ps1 | 143 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 97 +++++++++--- installer/TaskbarMonitorEnhanced_Setup.cs | 2 +- 7 files changed, 250 insertions(+), 23 deletions(-) create mode 100644 build/Test-R296-RollbackMemory-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 75f460eb..349f35a1 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -206,7 +206,7 @@ jobs: $r.RealNonElevatedEncodedPowerShellCalled -ne $true -or $r.InvalidAppRootRejectedBeforeWrite -ne $true -or $r.InstalledMainUnchanged -ne $true -or - $r.BoundedEncodedCommandLength -lt 1000 -or $r.BoundedEncodedCommandLength -gt 31500 -or + $r.BoundedEncodedCommandLength -lt 1000 -or $r.BoundedEncodedCommandLength -gt 32000 -or @($r.Checks).Count -lt 9 -or @($r.Checks|Where-Object {$_ -ceq 'QUOTED_HOSTILE_USER_INPUT_ENCODED_AS_DATA'}).Count -ne 1 -or @($r.Checks|Where-Object {$_ -ceq 'COMMAND_LINE_INJECTION_SENTINEL_NOT_EXECUTED'}).Count -ne 1){throw 'R293_NATIVE_INMEMORY_RECEIPT_REJECTED'} @@ -257,6 +257,28 @@ jobs: $r.VerifiedBytesOnlyCopiedToSandbox -ne $true -or $r.InstalledMainUnchanged -ne $true -or @($r.Checks).Count -lt 11){throw 'R295_TYPED_ZIP_RECEIPT_INVALID'} 'R295_EXACT_ZIP_TRAVERSAL_TAMPER_DUPLICATE_LINK_REFUSAL_PASS' + - name: R296 prove old sensor rollback stays in private process memory + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R296-RollbackMemory-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R296_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R296-RollbackMemory-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R296_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R296_MEMORY_ROLLBACK_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R296_MEMORY_ROLLBACK_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R296' -or + $r.Status -cne 'PASS_MEMORY_ONLY_ROLLBACK_NO_USER_TEMP_TRUST' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrProtectedServiceCalled -ne $false -or + $r.PrivateSnapshotFiles -ne 3 -or $r.ForgedUserTempIgnored -ne $true -or + $r.TwoJunctionDirectionsRejected -ne $true -or + $r.CorruptedOriginalRestoredFromMemory -ne $true -or + $r.NoRollbackDiskFiles -ne $true -or $r.InstalledMainUnchanged -ne $true -or + @($r.Checks).Count -lt 12){throw 'R296_TYPED_GUEST_MEMORY_RECEIPT_INVALID'} + 'R296_ROLLBACK_IN_MEMORY_TAMPER_AND_REPARSE=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -272,3 +294,4 @@ jobs: ${{ runner.temp }}/R293_${{ matrix.host }}_${{ github.run_id }}/R293_INMEMORY_RECEIPT.json ${{ runner.temp }}/R294_${{ matrix.host }}_${{ github.run_id }}/R294_PROVENANCE_RECEIPT.json ${{ runner.temp }}/R295_${{ matrix.host }}_${{ github.run_id }}/R295_ZIP_PROVENANCE_RECEIPT.json + ${{ runner.temp }}/R296_${{ matrix.host }}_${{ github.run_id }}/R296_MEMORY_ROLLBACK_RECEIPT.json diff --git a/build/Test-R293-InMemorySensorHelper-Native.ps1 b/build/Test-R293-InMemorySensorHelper-Native.ps1 index d23905f2..b15538e1 100644 --- a/build/Test-R293-InMemorySensorHelper-Native.ps1 +++ b/build/Test-R293-InMemorySensorHelper-Native.ps1 @@ -65,7 +65,7 @@ try{ throw 'R293_ELEVATION_COMMAND_USES_UNTRUSTED_FILE_PATH' } $encoded=$cmd.Substring($prefix.Length) - if($encoded.Length -lt 1000 -or $encoded.Length -gt 31500){ + if($encoded.Length -lt 1000 -or $encoded.Length -gt 32000){ throw 'R293_ENCODED_COMMAND_LENGTH_OUT_OF_BOUNDS' } $script=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) diff --git a/build/Test-R294-PayloadProvenance-Native.ps1 b/build/Test-R294-PayloadProvenance-Native.ps1 index 49e7d2a4..63e921e0 100644 --- a/build/Test-R294-PayloadProvenance-Native.ps1 +++ b/build/Test-R294-PayloadProvenance-Native.ps1 @@ -51,7 +51,7 @@ try{ $command=[string]$m.Invoke($null,$args4) $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' if(!$command.StartsWith($prefix,[StringComparison]::Ordinal) -or - $command.Length -gt 31580){throw 'R294_UNEXPECTED_LAUNCH_COMMAND'} + $command.Length -gt 32080){throw 'R294_UNEXPECTED_LAUNCH_COMMAND'} $encoded=$command.Substring($prefix.Length) $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) $match=[regex]::Match($bootstrap,"-ExpectedBrokerSHA256 '([A-F0-9]{64})' -ExpectedSupervisorSHA256 '([A-F0-9]{64})'") diff --git a/build/Test-R295-TrustedBackendZip-Native.ps1 b/build/Test-R295-TrustedBackendZip-Native.ps1 index 1e832c63..2c66f2e1 100644 --- a/build/Test-R295-TrustedBackendZip-Native.ps1 +++ b/build/Test-R295-TrustedBackendZip-Native.ps1 @@ -81,7 +81,7 @@ try{ $command=[string]$m.Invoke($null,$args4) $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' if(!$command.StartsWith($prefix,[StringComparison]::Ordinal) -or - $command.Length -gt 31580){throw 'R295_NATIVE_COMMAND_SIZE_OR_ENTRY_INVALID'} + $command.Length -gt 32080){throw 'R295_NATIVE_COMMAND_SIZE_OR_ENTRY_INVALID'} $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($command.Substring($prefix.Length))) $match=[regex]::Match($bootstrap,"-ExpectedBackendZipSHA256 '([A-F0-9]{64})'") if(!$match.Success){throw 'R295_EMBEDDED_ZIP_SHA_NOT_TRANSPORTED'} diff --git a/build/Test-R296-RollbackMemory-Native.ps1 b/build/Test-R296-RollbackMemory-Native.ps1 new file mode 100644 index 00000000..fc6bdcf9 --- /dev/null +++ b/build/Test-R296-RollbackMemory-Native.ps1 @@ -0,0 +1,143 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R296_ONLY_GITHUB_DISPOSABLE_WINDOWS' +} +function Hash([string]$Path){ + if(!(Test-Path -LiteralPath $Path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +function ExpectRefusal([string]$Name,[scriptblock]$Body,[string]$Match){ + $refused=$false + try{& $Body} + catch{ + if($_.Exception.Message -match $Match){$refused=$true}else{throw ('R296_UNEXPECTED_'+$Name+': '+$_.Exception.Message)} + } + if(!$refused){throw ('R296_FAIL_OPEN_'+$Name)} + $script:checks.Add($Name) +} +$root=Join-Path $env:RUNNER_TEMP ('R296_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R296_MEMORY_ROLLBACK_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$liveMain=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R295_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R295_ZIP_PROVENANCE_RECEIPT.json') +$checks=[Collections.Generic.List[string]]::new() +$accepted=$false +if(Test-Path $root){throw 'R296_PREEXISTING_TEST_ROOT'} +[void][IO.Directory]::CreateDirectory($root) +$mainBefore=Hash $liveMain +try{ + $identity=Get-Content $manifest -Raw|ConvertFrom-Json + $previous=Get-Content $prior -Raw|ConvertFrom-Json + if($identity.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $identity.SetupSHA256 -or + (Hash $liveMain) -cne $identity.MainSHA256 -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_PE_PINNED_ZIP_BYTE_ONLY_BACKEND_PROVENANCE'){ + throw 'R296_EXACT_HOST_SOURCE_OR_R295_NOT_VERIFIED' + } + $helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path + $raw=Get-Content $helper -Raw + if($raw.Contains('$RollbackRoot=Join-Path $PayloadDir') -or + $raw.Contains('Copy-Item -LiteralPath $RollbackBrokerRoot') -or + -not $raw.Contains('$script:RollbackFiles=Get-SensorRollbackFiles $BrokerRoot') -or + -not $raw.Contains('Restore-SensorRollbackFiles $BrokerRoot $RollbackFiles') -or + -not $raw.Contains('$script:RollbackTaskXmlText=[string](Export-ScheduledTask')){ + throw 'R296_USER_WRITABLE_ROLLBACK_STILL_ACTIVE' + } + $checks.Add('NO_TEMP_SNAPSHOT_OR_TASK_XML_PATH_IN_PRODUCTION_HELPER') + $t=$null;$e=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$e) + if(@($e).Count){throw 'R296_WINDOWS_POWER_SHELL_51_PARSE_FAILED'} + $defs=@($ast.FindAll({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Get-SensorRollbackFiles','Restore-SensorRollbackFiles','Clear-SensorRollbackBuffer')},$true)) + if($defs.Count -ne 4){throw 'R296_EXPECTED_FOUR_PURE_SECURITY_FUNCTIONS'} + . ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + $old=Join-Path $root 'old-broker' + $target=Join-Path $root 'restored-broker' + $fakeTemp=Join-Path $root 'attacker-TEMP-snapshot' + $external=Join-Path $root 'external-canary' + foreach($p in @($old,$fakeTemp,$external)){[void][IO.Directory]::CreateDirectory($p)} + $broker=Join-Path $old 'TaskbarMonitorSensorBroker.exe' + $library=Join-Path $old 'LibreHardwareMonitorLib.dll' + $nested=Join-Path $old 'lang' + [void][IO.Directory]::CreateDirectory($nested) + $subfile=Join-Path $nested 'safe.dll' + [IO.File]::WriteAllBytes($broker,[byte[]](1,2,3,4,5,6,7,8,9)) + [IO.File]::WriteAllBytes($library,[byte[]](10,9,8,7,6,5,4,3,2,1)) + [IO.File]::WriteAllBytes($subfile,[byte[]](42,77,83)) + $beforeBroker=Hash $broker + $beforeLibrary=Hash $library + $beforeSub=Hash $subfile + $snapshot=Get-SensorRollbackFiles $old + if(@($snapshot).Count -ne 3){throw 'R296_MISSING_CAPTURED_SNAPSHOT_FILES'} + $checks.Add('THREE_PROGRAMFILES_LIKE_FILES_CAPTURED_ONLY_IN_MEMORY') + [IO.File]::WriteAllBytes($broker,[byte[]](200,201,202)) + [IO.File]::WriteAllBytes((Join-Path $fakeTemp 'TaskbarMonitorSensorBroker.exe'),[byte[]](255,255)) + Restore-SensorRollbackFiles $target $snapshot + if((Hash (Join-Path $target 'TaskbarMonitorSensorBroker.exe')) -cne $beforeBroker -or + (Hash (Join-Path $target 'LibreHardwareMonitorLib.dll')) -cne $beforeLibrary -or + (Hash (Join-Path $target 'lang\safe.dll')) -cne $beforeSub){ + throw 'R296_REPLAY_BYTES_DIFFER_FROM_PRIVATE_SNAPSHOT' + } + $checks.Add('RESTORE_EXACT_PRIOR_CONTENT_FROM_PRIVATE_MEMORY') + $checks.Add('MUTABLE_TEMP_FORGED_SNAPSHOT_IGNORED') + $script:RollbackFiles=$snapshot + $script:RollbackCaptured=$true + $script:RollbackTaskXmlText='R296_IN_MEMORY_ONLY' + Clear-SensorRollbackBuffer + if(@($script:RollbackFiles).Count -ne 0 -or $script:RollbackCaptured -or $null -ne $script:RollbackTaskXmlText){ + throw 'R296_SNAPSHOT_MEMORY_NOT_WIPED' + } + $checks.Add('ROLLBACK_DATA_AND_XML_MEMORY_CLEARED') + ExpectRefusal 'PARENT_TRAVERSAL_REJECTED' {Restore-SensorRollbackFiles $target @([pscustomobject]@{Rel='..\outside.dll';Data=[byte[]](77)})} 'ROLLBACK_RESTORE_BAD_NAME' + $canary=Join-Path $external 'outside.txt' + [IO.File]::WriteAllText($canary,'R296_UNMODIFIED') + $canarySHA=Hash $canary + $link=Join-Path $old 'junction' + New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null + try{ + ExpectRefusal 'JUNCTIONED_SNAPSHOT_INPUT_REJECTED' {Get-SensorRollbackFiles $old|Out-Null} 'SENSOR_BOUNDARY_REPARSE_REFUSED' + }finally{[IO.Directory]::Delete($link)} + $escape=Join-Path $target 'escape' + New-Item -ItemType Junction -Path $escape -Target $external -ErrorAction Stop|Out-Null + try{ + ExpectRefusal 'JUNCTIONED_RESTORE_DESTINATION_REJECTED' {Restore-SensorRollbackFiles $target @([pscustomobject]@{Rel='escape\outside.txt';Data=[byte[]](1,2,3)})} 'SENSOR_BOUNDARY_REPARSE_REFUSED' + }finally{[IO.Directory]::Delete($escape)} + if((Hash $canary) -cne $canarySHA){throw 'R296_EXTERNAL_CANARY_MUTATED'} + $checks.Add('EXTERNAL_CANARY_PRESERVED') + $lock=[IO.FileStream]::new($library,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ExpectRefusal 'CONCURRENT_SOURCE_READER_REFUSED' {Get-SensorRollbackFiles $old|Out-Null} 'sharing violation|being used|used by another process'}finally{$lock.Dispose()} + if((Hash $liveMain) -cne $mainBefore){throw 'R296_INSTALLED_GUEST_MAIN_CHANGED'} + $checks.Add('INSTALLED_MAIN_UNCHANGED') + if(Test-Path (Join-Path $root '_sensor_rollback')){throw 'R296_SNAPSHOT_DID_WRITE_DISK'} + $checks.Add('NO_DISK_ROLLBACK_CREATED') + $rec=[ordered]@{ + Schema=1;Revision='R296';Status='PASS_MEMORY_ONLY_ROLLBACK_NO_USER_TEMP_TRUST' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false + UACOrProtectedServiceCalled=$false;PrivateSnapshotFiles=3 + ForgedUserTempIgnored=$true;TwoJunctionDirectionsRejected=$true + CorruptedOriginalRestoredFromMemory=$true;NoRollbackDiskFiles=$true + InstalledMainUnchanged=$true;Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($rec|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R296_GUEST_'+$HostLabel+'_IN_MEMORY_ROLLBACK=PASS' +}finally{ + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R296';Status='FAILED_NOT_ACCEPTED';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OriginalOwnerHostUntouched=$true} + [IO.File]::WriteAllText($receipt,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index c90afbe9..2aaa77fe 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -130,9 +130,9 @@ $RollbackPerformed=$false $RollbackSucceeded=$false $PreviousSensorLayerPresent=$false $ActiveArchitecture='R21_PROCESS_ISOLATED' -$RollbackRoot=Join-Path $PayloadDir '_sensor_rollback' -$RollbackBrokerRoot=Join-Path $RollbackRoot 'SensorBroker' -$RollbackTaskXml=Join-Path $RollbackRoot 'task.xml' +$RollbackFiles=@() +$RollbackTaskXmlText=$null +$RollbackCaptured=$false function Write-Log([string]$Message){ $line=(Get-Date).ToString('o')+' '+$Message @@ -183,25 +183,85 @@ function Get-PawnIOVersion { return '' } +# R296: no privileged rollback snapshot is staged to user-writable Temp. +function Get-SensorRollbackFiles([string]$Folder){ + Assert-SensorBoundaryNoReparse $Folder + $root=[IO.Path]::GetFullPath($Folder).TrimEnd('\') + $prefix=$root+'\' + $items=@(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop) + foreach($item in $items){Assert-SensorBoundaryNoReparse $item.FullName} + $files=@($items|Where-Object {-not $_.PSIsContainer}) + if($files.Count -gt 128){throw 'ROLLBACK_FILE_COUNT'} + $saved=[Collections.Generic.List[object]]::new() + [long]$total=0 + foreach($file in $files){ + $path=[IO.Path]::GetFullPath($file.FullName) + if(-not $path.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)){throw 'ROLLBACK_PATH_ESCAPE'} + Assert-SensorBoundaryNoReparse $path + $rel=$path.Substring($prefix.Length) + foreach($part in $rel.Split('\')){ + if($part -ceq '.' -or $part -ceq '..' -or $part -cnotmatch '^[A-Za-z0-9_.-]+$'){ + throw 'ROLLBACK_BAD_NAME' + } + } + $fs=[IO.FileStream]::new($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + if($fs.Length -gt 16777216){throw 'ROLLBACK_FILE_TOO_LARGE'} + $total+=$fs.Length + if($total -gt 67108864){throw 'ROLLBACK_TOTAL_TOO_LARGE'} + $bytes=[byte[]]::new([int]$fs.Length) + $n=0 + while($n -lt $bytes.Length){ + $k=$fs.Read($bytes,$n,$bytes.Length-$n) + if($k -lt 1){throw 'ROLLBACK_SHORT_READ'} + $n+=$k + } + $saved.Add([pscustomobject]@{Rel=$rel;Data=$bytes}) + }finally{$fs.Dispose()} + } + return ,$saved.ToArray() +} +function Restore-SensorRollbackFiles([string]$Folder,[object[]]$Saved){ + Assert-SensorBoundaryNoReparse $Folder + New-Item -ItemType Directory -Force -Path $Folder|Out-Null + foreach($entry in $Saved){ + foreach($part in ([string]$entry.Rel).Split('\')){ + if($part -ceq '.' -or $part -ceq '..' -or $part -cnotmatch '^[A-Za-z0-9_.-]+$'){ + throw 'ROLLBACK_RESTORE_BAD_NAME' + } + } + $path=Join-Path $Folder $entry.Rel + Assert-SensorBoundaryNoReparse $path + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path)|Out-Null + Assert-SensorBoundaryNoReparse $path + [IO.File]::WriteAllBytes($path,[byte[]]$entry.Data) + } +} +function Clear-SensorRollbackBuffer { + $script:RollbackFiles=@() + $script:RollbackTaskXmlText=$null + $script:RollbackCaptured=$false +} + function Capture-PreviousSensorLayer { try{ - Remove-Item -LiteralPath $RollbackRoot -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force -Path $RollbackRoot|Out-Null - + Clear-SensorRollbackBuffer if(Test-Path -LiteralPath $BrokerRoot){ + $script:RollbackFiles=Get-SensorRollbackFiles $BrokerRoot $script:PreviousSensorLayerPresent=$true - Copy-Item -LiteralPath $BrokerRoot -Destination $RollbackBrokerRoot -Recurse -Force } - Import-Module ScheduledTasks - $oldTask=Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue - if($oldTask){ + $task=Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if($task){ + $script:RollbackTaskXmlText=[string](Export-ScheduledTask -TaskName $TaskName) + if($RollbackTaskXmlText.Length -gt 262144){throw 'ROLLBACK_XML_TOO_LARGE'} $script:PreviousSensorLayerPresent=$true - Export-ScheduledTask -TaskName $TaskName | Set-Content -LiteralPath $RollbackTaskXml -Encoding Unicode } + $script:RollbackCaptured=$true Write-Log ("ROLLBACK_CAPTURE_PASS previous="+$PreviousSensorLayerPresent) return $true }catch{ + Clear-SensorRollbackBuffer Write-Log ('ROLLBACK_CAPTURE_FAIL '+$_.Exception.ToString()) return $false } @@ -212,6 +272,7 @@ function Restore-PreviousSensorLayer([string]$Reason){ $script:RollbackSucceeded=$false try{ Write-Log ("ROLLBACK_BEGIN reason="+$Reason+" previous="+$PreviousSensorLayerPresent) + if(-not $RollbackCaptured){throw 'ROLLBACK_NOT_CAPTURED'} Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue @@ -219,14 +280,11 @@ function Restore-PreviousSensorLayer([string]$Reason){ Remove-Item -LiteralPath $BrokerRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $LiveJson,$GpuJson,$StorageJson,$SupervisorState -Force -ErrorAction SilentlyContinue - if(Test-Path -LiteralPath $RollbackBrokerRoot){ - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $BrokerRoot)|Out-Null - Copy-Item -LiteralPath $RollbackBrokerRoot -Destination $BrokerRoot -Recurse -Force + if(@($RollbackFiles).Count -gt 0){ + Restore-SensorRollbackFiles $BrokerRoot $RollbackFiles } - - if(Test-Path -LiteralPath $RollbackTaskXml){ - $xml=Get-Content -LiteralPath $RollbackTaskXml -Raw - Register-ScheduledTask -TaskName $TaskName -Xml $xml -Force|Out-Null + if(-not [string]::IsNullOrEmpty($RollbackTaskXmlText)){ + Register-ScheduledTask -TaskName $TaskName -Xml $RollbackTaskXmlText -Force|Out-Null Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } @@ -596,6 +654,7 @@ if($taskInstalled -and -not$SupervisorHealthy -and -not$rebootRequired){ if($RollbackPerformed){ if($RollbackSucceeded){ + Clear-SensorRollbackBuffer Write-Log 'R21_INSTALL_DEGRADED_ROLLED_BACK' Write-Result 'DEGRADED_ROLLED_BACK' 'R21 protected sensor validation did not pass, so the previous protected sensor layer was restored automatically. The main application remains usable; use Repair protected sensors to retry R21 later.' $pawnStatus $pawnExit $false $false $false }else{ @@ -605,6 +664,8 @@ if($RollbackPerformed){ exit 0 } +# Retire the in-memory snapshot after a successful terminal path. +Clear-SensorRollbackBuffer if($SupervisorHealthy -and $healthy){ Write-Log "R21_SENSOR_READY cpu=$currentC" Write-Result 'READY' 'R21 protected sensor supervisor is healthy across CPU, GPU and storage lanes; CPU temperature data is active.' $pawnStatus $pawnExit $false $taskInstalled $true diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 30b77806..4c943e50 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -176,7 +176,7 @@ static string BuildElevatedHelperCommand(string mode,string payload,string appRo string encoded=Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); // Windows CreateProcess has a 32,767-character maximum; leave margin. // Fail closed instead of falling back to unsafe -File or user-writable scripts. - if(encoded.Length>31500) + if(encoded.Length>32000) throw new InvalidDataException("Trusted sensor helper command exceeds safe limit."); return "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; } From c20e4eeaf5fd45c09d7276dd75fc0d90167d34c9 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 13:08:46 +0330 Subject: [PATCH 029/135] security: never elevate mutable Temp PawnIO installer (R297) --- ...it-r286-optional-audio-native-recovery.yml | 22 +++ .../Test-R297-NoPawnAutoElevation-Native.ps1 | 155 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 53 +----- installer/TaskbarMonitorEnhanced_Setup.cs | 11 +- 4 files changed, 189 insertions(+), 52 deletions(-) create mode 100644 build/Test-R297-NoPawnAutoElevation-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 349f35a1..1a18a706 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -279,6 +279,27 @@ jobs: $r.NoRollbackDiskFiles -ne $true -or $r.InstalledMainUnchanged -ne $true -or @($r.Checks).Count -lt 12){throw 'R296_TYPED_GUEST_MEMORY_RECEIPT_INVALID'} 'R296_ROLLBACK_IN_MEMORY_TAMPER_AND_REPARSE=PASS' + - name: R297 refuse forged PawnIO automatic elevation from writable Temp + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R297-NoPawnAutoElevation-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R297_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R297-NoPawnAutoElevation-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R297_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R297_PAWN_REFUSAL_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R297_TYPED_GUEST_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R297' -or + $r.Status -cne 'PASS_NO_UNTRUSTED_PAWNIO_AUTORUN' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrDriverServiceMutation -ne $false -or + $r.FakeTempPawnInstallerIgnored -ne $true -or + $r.MissingDriverRequiresOfficialExternalConsent -ne $true -or + $r.ExistingCompatiblePawnIORetained -ne $true -or $r.MainUnchanged -ne $true -or + @($r.Checks).Count -lt 10){throw 'R297_TYPED_PAWNIO_REFUSAL_RECEIPT_INVALID'} + 'R297_FORGED_TEMP_PAWNIO_ELEVATION_REFUSED=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -295,3 +316,4 @@ jobs: ${{ runner.temp }}/R294_${{ matrix.host }}_${{ github.run_id }}/R294_PROVENANCE_RECEIPT.json ${{ runner.temp }}/R295_${{ matrix.host }}_${{ github.run_id }}/R295_ZIP_PROVENANCE_RECEIPT.json ${{ runner.temp }}/R296_${{ matrix.host }}_${{ github.run_id }}/R296_MEMORY_ROLLBACK_RECEIPT.json + ${{ runner.temp }}/R297_${{ matrix.host }}_${{ github.run_id }}/R297_PAWN_REFUSAL_RECEIPT.json diff --git a/build/Test-R297-NoPawnAutoElevation-Native.ps1 b/build/Test-R297-NoPawnAutoElevation-Native.ps1 new file mode 100644 index 00000000..3c35a9ff --- /dev/null +++ b/build/Test-R297-NoPawnAutoElevation-Native.ps1 @@ -0,0 +1,155 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R297_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function Hash([string]$Path){ + if(!(Test-Path -LiteralPath $Path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} +function RunGuard([string]$Guard,[string]$Version,[string]$Fake){ + $base64Fake=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Fake)) + $script=@( + '$ErrorActionPreference="Stop"', + 'function Get-PawnIOVersion { return "'+$Version+'" }', + 'function Write-Log {param([string]$Text)}', + 'function Write-Result { Write-Output ("R297_RESULT_"+[string]$args[0]) }', + '$ActiveArchitecture=""', + '$PawnPayload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'+$base64Fake+'"))', + $Guard, + 'Write-Output "R297_SAFE_COMPATIBLE_REACHED"' + ) -join [string][char]10 + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script)) + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName='powershell.exe' + $psi.Arguments='-NoProfile -NonInteractive -EncodedCommand '+$encoded + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){throw 'R297_NEGATIVE_GUARD_PROCESS_NOT_STARTED'} + try{ + $outTask=$proc.StandardOutput.ReadToEndAsync() + $errTask=$proc.StandardError.ReadToEndAsync() + if(!$proc.WaitForExit(15000)){ + try{$proc.Kill()}catch{} + throw 'R297_NATIVE_GUARD_PROCESS_TIMEOUT' + } + return [pscustomobject]@{ + Exit=$proc.ExitCode + Out=$outTask.GetAwaiter().GetResult() + Err=$errTask.GetAwaiter().GetResult() + } + }finally{$proc.Dispose()} +} +$root=Join-Path $env:RUNNER_TEMP ('R297_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R297_PAWN_REFUSAL_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$live=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R296_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R296_MEMORY_ROLLBACK_RECEIPT.json') +$checks=[Collections.Generic.List[string]]::new() +$accepted=$false +if(Test-Path $root){throw 'R297_PREEXISTING_GUEST_FIXTURE_REFUSED'} +[void][IO.Directory]::CreateDirectory($root) +$mainBefore=Hash $live +try{ + $id=Get-Content $manifest -Raw|ConvertFrom-Json + $old=Get-Content $prior -Raw|ConvertFrom-Json + if($id.Commit -cne $env:GITHUB_SHA -or (Hash $setup) -cne $id.SetupSHA256 -or + (Hash $live) -cne $id.MainSHA256 -or + $old.SourceCommit -cne $env:GITHUB_SHA -or $old.Host -cne $HostLabel -or + $old.Status -cne 'PASS_MEMORY_ONLY_ROLLBACK_NO_USER_TEMP_TRUST'){ + throw 'R297_EXACT_CANDIDATE_OR_PRIOR_RECEIPT_NOT_PINNED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $stream=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R297_EMBEDDED_HELPER_MISSING'} + $reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) + try{$helper=$reader.ReadToEnd()}finally{$reader.Dispose()} + $sourcePath=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path + $source=Get-Content -LiteralPath $sourcePath -Raw + $crlf=[string][char]13+[char]10 + $lf=[string][char]10 + if($helper.Replace($crlf,$lf).TrimEnd() -cne $source.Replace($crlf,$lf).TrimEnd()){ + throw 'R297_PACKAGED_HELPER_DIFFERS_FROM_SOURCE' + } + $checks.Add('EMBEDDED_HELPER_SOURCE_EXACT') + if($helper.Contains('Start-Process -FilePath $PawnPayload') -or + $helper.Contains('if(-not $pawnAlreadyCompatible -and !(Test-Path') -or + $helper.Contains('$PawnPayload=Join-Path $PayloadDir')){ + throw 'R297_UNTRUSTED_PAWN_AUTORUN_STILL_PRESENT' + } + $checks.Add('NO_AUTO_ELEVATED_PAWNIO_TEMP_LAUNCH') + $setupCode=Get-Content (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs') -Raw + if($setupCode.Contains('Extract("Payload.PawnIO_setup.exe"')){ + throw 'R297_SETUP_STILL_EXTRACTS_WRITABLE_PAWN_BINARY' + } + $checks.Add('SETUP_NEVER_STAGES_PAWN_INSTALLER_IN_TEMP') + $guardStart=$helper.IndexOf('$existingPawn=Get-PawnIOVersion',[StringComparison]::Ordinal) + $guardEnd=$helper.IndexOf('# R294: Verify pinned embedded identity',[StringComparison]::Ordinal) + if($guardStart -lt 0 -or $guardEnd -le $guardStart -or + $helper.IndexOf('$existingPawn=Get-PawnIOVersion',$guardStart+1) -ge 0){ + throw 'R297_PAWN_PREFLIGHT_BLOCK_NOT_UNIQUE' + } + $guard=$helper.Substring($guardStart,$guardEnd-$guardStart) + if(-not $guard.Contains('if(-not $pawnAlreadyCompatible){') -or + -not $guard.Contains("'EXTERNAL_INSTALL_REQUIRED'")){ + throw 'R297_PAWN_PREFLIGHT_NOT_STRICT' + } + $checks.Add('PRODUCTION_GUARD_IN_SOURCE_PRE_MUTATION') + $fake=Join-Path $root 'PawnIO_setup.exe' + [IO.File]::WriteAllBytes($fake,[byte[]](77,90,1,2,3,4,5,6)) + $originalFakeHash=Hash $fake + $absent=RunGuard $guard '' $fake + if($absent.Exit -ne 0 -or + $absent.Out -notmatch 'R297_RESULT_DEGRADED' -or + $absent.Out -match 'R297_SAFE_COMPATIBLE_REACHED' -or + $absent.Err -match 'Start-Process|cannot be started'){ + throw ('R297_FORGED_PAWN_FILE_WAS_NOT_IGNORED exit='+$absent.Exit+' stdout='+$absent.Out+' stderr='+$absent.Err) + } + $checks.Add('FORGED_TEMP_EXE_DOES_NOT_BYPASS_MISSING_DRIVER_REFUSAL') + $checks.Add('MISSING_DRIVER_RETURNS_EXTERNAL_CONSENT_REQUIRED') + $compatible=RunGuard $guard '2.2.0' $fake + if($compatible.Exit -ne 0 -or + $compatible.Out -notmatch 'R297_SAFE_COMPATIBLE_REACHED' -or + $compatible.Out -match 'R297_RESULT_DEGRADED'){ + throw ('R297_COMPATIBLE_EXISTING_DRIVER_REJECTED '+$compatible.Err) + } + $checks.Add('EXISTING_COMPATIBLE_DRIVER_REUSED') + if((Hash $fake) -cne $originalFakeHash){throw 'R297_FAKE_TEMP_INSTALLER_CHANGED_OR_RUN'} + $checks.Add('FORGED_TEMP_BINARY_UNMODIFIED') + if((Hash $live) -cne $mainBefore){throw 'R297_GUEST_MAIN_CHANGED'} + $checks.Add('GUEST_MAIN_UNCHANGED') + $checks.Add('NO_UAC_DRIVER_INSTALL_TASK_OR_PROTECTED_SERVICE_MUTATION') + $record=[ordered]@{ + Schema=1;Revision='R297';Status='PASS_NO_UNTRUSTED_PAWNIO_AUTORUN' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;UACOrDriverServiceMutation=$false + FakeTempPawnInstallerIgnored=$true;MissingDriverRequiresOfficialExternalConsent=$true + ExistingCompatiblePawnIORetained=$true;MainUnchanged=$true + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R297_GUEST_'+$HostLabel+'_NO_PAWNIO_TEMP_ELEVATION_PASS' +}finally{ + if(!$accepted){ + $fail=[ordered]@{Schema=1;Revision='R297';Status='FAIL_NOT_ACCEPTED';Host=$HostLabel + SourceCommit=$env:GITHUB_SHA;OriginalOwnerComputerTouched=$false} + [IO.File]::WriteAllText($receipt,($fail|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 2aaa77fe..06ee782e 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -103,7 +103,6 @@ function Assert-SensorPrivilegedBoundary( (Join-Path $payload 'TBME_Setup_Elevated_Helper.ps1'), (Join-Path $payload 'TaskbarMonitorSensorBroker.exe'), (Join-Path $payload 'TaskbarMonitorSensorSupervisor.exe'), - (Join-Path $payload 'PawnIO_setup.exe'), (Join-Path $brokerRoot 'TaskbarMonitorSensorBroker.exe'), (Join-Path $brokerRoot 'TaskbarMonitorSensorSupervisor.exe') ) @@ -442,7 +441,6 @@ function Get-VerifiedSensorBackendEntries([string]$ZipFile,[string]$ExpectedZipS $BrokerPayload=Join-Path $PayloadDir 'TaskbarMonitorSensorBroker.exe' $SupervisorPayload=Join-Path $PayloadDir 'TaskbarMonitorSensorSupervisor.exe' -$PawnPayload=Join-Path $PayloadDir 'PawnIO_setup.exe' $BackendRoot=Join-Path $AppRoot 'SensorBackend\LibreHardwareMonitor-0.9.6' $LiveJson=Join-Path $AppRoot 'cpu_temp_broker.json' $GpuJson=Join-Path $AppRoot 'gpu_temp_broker.json' @@ -464,17 +462,19 @@ if(-not$requiredOk){ exit 0 } -# Public OSS Setup never bundles the separately licensed official PawnIO installer. -# Reuse an already installed compatible PawnIO; otherwise degrade before any service -# mutation, UAC driver installation, network request or host rollback snapshot. +# R297: NEVER execute a user-writable PawnIO installer from Temp under UAC. +# A forged PawnIO_setup.exe can be created after payload extraction, even if +# the official open-source PE carries no proprietary PawnIO resource. +# Only an already installed compatible PawnIO is reused; otherwise fail closed +# and request a separate, explicit upstream installer with user consent. $existingPawn=Get-PawnIOVersion $pawnAlreadyCompatible=$false if($existingPawn){ try{if([version]$existingPawn -ge [version]'2.2.0'){$pawnAlreadyCompatible=$true}}catch{} } -if(-not $pawnAlreadyCompatible -and !(Test-Path -LiteralPath $PawnPayload -PathType Leaf)){ +if(-not $pawnAlreadyCompatible){ $ActiveArchitecture='PAWNIO_EXTERNAL_INSTALL_REQUIRED' - Write-Log 'PAWNIO_UPSTREAM_EXTERNAL_INSTALL_REQUIRED no bundled proprietary binary and no silent driver download' + Write-Log 'PAWNIO_UPSTREAM_EXTERNAL_INSTALL_REQUIRED never executing mutable Temp PawnIO binary under UAC' Write-Result 'DEGRADED' ('Optional protected hardware sensors require the official PawnIO 2.2.0 driver, installed separately with user consent from https://github.com/namazso/PawnIO.Setup/releases/tag/2.2.0 . The main application works without it and unavailable temperatures show N/A. No driver or protected sensor service was modified.') 'EXTERNAL_INSTALL_REQUIRED' $null $false $false $false exit 0 } @@ -498,44 +498,7 @@ if(-not(Capture-PreviousSensorLayer)){ $pawnStatus='ALREADY_PRESENT' $pawnExit=$null $rebootRequired=$false -$installedPawn=Get-PawnIOVersion -$needPawn=$true -if($installedPawn){ - try{ - if([version]$installedPawn -ge [version]'2.2.0'){$needPawn=$false} - }catch{} -} - -if($needPawn){ - $pawnStatus='STARTING' - Write-Log 'PAWNIO_INSTALL_START timeoutSec=60' - try{ - $pawnProcess=Start-Process -FilePath $PawnPayload -ArgumentList '-install -silent' -PassThru - if(-not $pawnProcess.WaitForExit(60000)){ - Write-Log "PAWNIO_TIMEOUT pid=$($pawnProcess.Id)" - try{Stop-Process -Id $pawnProcess.Id -Force -ErrorAction SilentlyContinue}catch{} - $pawnStatus='TIMEOUT' - }else{ - $pawnExit=[int]$pawnProcess.ExitCode - if($pawnExit -eq 0){ - $pawnStatus='INSTALLED' - Write-Log 'PAWNIO_INSTALL_PASS exit=0' - }elseif($pawnExit -eq 3010){ - $pawnStatus='REBOOT_REQUIRED' - $rebootRequired=$true - Write-Log 'PAWNIO_INSTALL_REBOOT_REQUIRED exit=3010' - }else{ - $pawnStatus='FAILED' - Write-Log "PAWNIO_INSTALL_FAILED exit=$pawnExit" - } - } - }catch{ - $pawnStatus='EXCEPTION' - Write-Log ('PAWNIO_INSTALL_EXCEPTION '+$_.Exception.ToString()) - } -}else{ - Write-Log "PAWNIO_ALREADY_PRESENT version=$installedPawn" -} +Write-Log "PAWNIO_ALREADY_PRESENT version=$existingPawn" $taskInstalled=$false try{ diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 4c943e50..6566bc98 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -336,13 +336,10 @@ static string PrepareSensorPayload() // This user-writable staging file is bound to the PE embedded ZIP hash // before any elevated service changes; it is deleted with this payload. Extract("Payload.LibreHardwareMonitor.zip",Path.Combine(payload,"LibreHardwareMonitor.zip")); - // The official PawnIO binary is separately licensed. Public OSS Setup never embeds it. - // A previously installed PawnIO 2.2.0 may be reused; otherwise the sensor helper degrades - // safely and offers upstream manual installation, without silent driver downloads. - using(Stream pawn=Resource("Payload.PawnIO_setup.exe")){ - if(pawn!=null && pawn.Length>0) - Extract("Payload.PawnIO_setup.exe",Path.Combine(payload,"PawnIO_setup.exe")); - } + // R297: never stage or elevate a third-party executable in user-writable + // Temp, even if an optional private PE contains a PawnIO resource. + // Reuse compatible preinstalled PawnIO, otherwise require separate + // official installation with explicit owner consent. return payload; } From 6b38199821652515c5c0f0d13952be687d4c333d Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 13:35:26 +0330 Subject: [PATCH 030/135] security: pin UAC PowerShell host to native system path (R298) --- ...it-r286-optional-audio-native-recovery.yml | 22 +++ .../Test-R298-PinnedPowerShellHost-Native.ps1 | 159 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 33 +++- 3 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 build/Test-R298-PinnedPowerShellHost-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 1a18a706..ab87c108 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -300,6 +300,27 @@ jobs: $r.ExistingCompatiblePawnIORetained -ne $true -or $r.MainUnchanged -ne $true -or @($r.Checks).Count -lt 10){throw 'R297_TYPED_PAWNIO_REFUSAL_RECEIPT_INVALID'} 'R297_FORGED_TEMP_PAWNIO_ELEVATION_REFUSED=PASS' + - name: R298 validate pinned Windows PowerShell executable for runas + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R298-PinnedPowerShellHost-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R298_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R298-PinnedPowerShellHost-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R298_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R298_TYPED_HOST_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R298' -or + $r.Status -cne 'PASS_ABSOLUTE_SYSTEM_POWERSHELL_RUNAS_HOST' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrProtectedServiceInvoked -ne $false -or + $r.AbsoluteHostMatchesSystemDirectory -ne $true -or + $r.FakeCwdAndPathExecutableIgnored -ne $true -or $r.BothModesPinned -ne $true -or + $r.NoUntrustedScriptFile -ne $true -or $r.InstalledMainUnchanged -ne $true -or + @($r.Checks).Count -lt 12){throw 'R298_NATIVE_PINNED_HOST_RECEIPT_INVALID'} + 'R298_SYSTEM_POWERSHELL_HOST_PINNED=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -317,3 +338,4 @@ jobs: ${{ runner.temp }}/R295_${{ matrix.host }}_${{ github.run_id }}/R295_ZIP_PROVENANCE_RECEIPT.json ${{ runner.temp }}/R296_${{ matrix.host }}_${{ github.run_id }}/R296_MEMORY_ROLLBACK_RECEIPT.json ${{ runner.temp }}/R297_${{ matrix.host }}_${{ github.run_id }}/R297_PAWN_REFUSAL_RECEIPT.json + ${{ runner.temp }}/R298_${{ matrix.host }}_${{ github.run_id }}/R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json diff --git a/build/Test-R298-PinnedPowerShellHost-Native.ps1 b/build/Test-R298-PinnedPowerShellHost-Native.ps1 new file mode 100644 index 00000000..8cd0e718 --- /dev/null +++ b/build/Test-R298-PinnedPowerShellHost-Native.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# Native, disposable Windows guest only; never invoke the real UAC boundary. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R298_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function SHA([string]$p){ + if(-not (Test-Path -LiteralPath $p -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$root=Join-Path $env:RUNNER_TEMP ('R298_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json' +$fake=Join-Path $root 'attacker-current-directory' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R297_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R297_PAWN_REFUSAL_RECEIPT.json') +$checks=[Collections.Generic.List[string]]::new() +$accepted=$false +$previousPath=[Environment]::GetEnvironmentVariable('PATH','Process') +$previousCwd=[Environment]::CurrentDirectory +$mainBefore=SHA $main +if(Test-Path -LiteralPath $root){throw 'R298_TEST_FIXTURE_COLLISION'} +[void][IO.Directory]::CreateDirectory($root) +try{ + $identity=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json + $old=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json + if($identity.Commit -cne $env:GITHUB_SHA -or + $identity.SetupSHA256 -cne (SHA $setup) -or + $identity.MainSHA256 -cne $mainBefore -or + $old.SourceCommit -cne $env:GITHUB_SHA -or + $old.Host -cne $HostLabel -or + $old.Status -cne 'PASS_NO_UNTRUSTED_PAWNIO_AUTORUN'){ + throw 'R298_EXACT_SOURCE_OR_PRIOR_NATIVE_PROOF_NOT_PINNED' + } + $assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) + $type=$assembly.GetType('SetupProgram',$true) + $method=$type.GetMethod('BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') + $resolver=$type.GetMethod('TrustedSystemPowerShellPath',[Reflection.BindingFlags]'NonPublic,Static') + if(!$method -or !$resolver){throw 'R298_TESTABLE_ELEVATION_INFO_FACTORY_MISSING'} + $system=[IO.Path]::GetFullPath([Environment]::SystemDirectory) + $expected=[IO.Path]::Combine($system,'WindowsPowerShell','v1.0','powershell.exe') + if(-not [IO.File]::Exists($expected)){throw 'R298_SYSTEM_POWERSHELL_MISSING'} + $real=[string]$resolver.Invoke($null,$null) + if(-not [IO.Path]::IsPathRooted($real) -or + -not [string]::Equals($real,$expected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R298_SYSTEM_HOST_NOT_EXACT_ABSOLUTE_NATIVE_PATH' + } + $checks.Add('NATIVE_SYSTEM_POWERSHELL_HOST_ABSOLUTELY_PINNED') + foreach($segment in @($system, + [IO.Path]::Combine($system,'WindowsPowerShell'), + [IO.Path]::Combine($system,'WindowsPowerShell','v1.0'), + $expected)){ + if((([IO.File]::GetAttributes($segment)) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R298_NATIVE_HOST_PATH_IS_REPARSE' + } + } + $checks.Add('NATIVE_HOST_PATH_SEGMENTS_NOT_REPARSE') + $args4=[object[]]::new(4) + $args4[0]=[string]'Install' + $args4[1]=[string]([IO.Path]::Combine($env:RUNNER_TEMP,('tbme_setup_'+('a'*32)))) + $args4[2]=[string]$app + $args4[3]=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) + $info=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$args4) + if(-not [string]::Equals($info.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($info.WorkingDirectory,([IO.Path]::GetDirectoryName($expected)),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R298_RUNAS_PROCESS_INFO_POINTS_OUTSIDE_SYSTEM_HOST' + } + $checks.Add('REAL_RUNAS_PROCESS_INFO_PINNED_TO_TRUSTED_SYSTEM_EXE_AND_WORKDIR') + if($info.UseShellExecute -ne $true -or $info.Verb -cne 'runas'){ + throw 'R298_ELEVATION_VERB_BEHAVIOR_CHANGED' + } + $checks.Add('ORIGINAL_UAC_RUNAS_SHELL_CONTRACT_PRESERVED') + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + if(-not $info.Arguments.StartsWith($prefix,[StringComparison]::Ordinal) -or + $info.Arguments.Contains(' -File ') -or $info.Arguments.Contains('ExecutionPolicy Bypass') -or + $info.Arguments.Length -gt 32080){ + throw 'R298_POWER_SHELL_SCRIPT_ENTRY_WAS_WEAKENED' + } + $checks.Add('TRUSTED_ENCODED_COMMAND_NO_TEMP_SCRIPT') + $originalEncoded=$info.Arguments + [void][IO.Directory]::CreateDirectory($fake) + $rogue=Join-Path $fake 'powershell.exe' + [IO.File]::WriteAllBytes($rogue,[byte[]]@(77,90,1,2,3,4,5,6,7,8)) + $beforeFake=SHA $rogue + $checks.Add('ATTACKER_NAMED_EXECUTABLE_FIXTURE_PRESENT') + # Only modify this test process, never user/system PATH. Do not start runas. + [Environment]::CurrentDirectory=$fake + [Environment]::SetEnvironmentVariable('PATH',($fake+';'+$previousPath),'Process') + $fakeResolved=[string]$resolver.Invoke($null,$null) + $tamper=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$args4) + if(-not [string]::Equals($fakeResolved,$expected,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($tamper.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($tamper.WorkingDirectory,$info.WorkingDirectory,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R298_ATTACKER_CWD_OR_PATH_REDIRECTED_RUNAS_TARGET' + } + $checks.Add('FAKE_CWD_AND_PATH_POWERSHELL_IGNORED') + if($tamper.Arguments -cne $originalEncoded -or $tamper.Verb -cne 'runas' -or + $tamper.UseShellExecute -ne $true){ + throw 'R298_ARGUMENTS_OR_UAC_VERB_CHANGED_BY_ATTACKER_SEARCH_PATH' + } + $checks.Add('ENCODED_ARGUMENTS_UNCHANGED_DURING_PATH_POISONING') + if((SHA $rogue) -cne $beforeFake){throw 'R298_FAKE_POWERSHELL_EXECUTED_OR_MODIFIED'} + $checks.Add('ATTACKER_FAKE_EXECUTABLE_UNCHANGED_NOT_EXECUTED') + $args4[0]=[string]'Uninstall' + $uninstall=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$args4) + if(-not [string]::Equals($uninstall.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or + $uninstall.Arguments -cnotmatch '\-Mode ''Uninstall''' -or + $uninstall.Arguments.Contains(' -File ')){ + # Mode is inside the base64 bootstrap rather than a literal argument. + $encoded=$uninstall.Arguments.Substring($prefix.Length) + $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + if($bootstrap -cnotmatch "\-Mode 'Uninstall'" -or + -not [string]::Equals($uninstall.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or + $uninstall.Arguments.Contains(' -File ')){ + throw 'R298_UNINSTALL_MODE_OR_HOST_NOT_PINNED' + } + } + $checks.Add('UNINSTALL_MODE_USES_SAME_ABSOLUTE_TRUSTED_HOST') + if((SHA $main) -cne $mainBefore){throw 'R298_GUEST_MAIN_WAS_CHANGED'} + $checks.Add('EXISTING_GUEST_MAIN_UNCHANGED') + $checks.Add('NO_UAC_OR_PROTECTED_SERVICE_MUTATION') + $data=[ordered]@{ + Schema=1;Revision='R298';Status='PASS_ABSOLUTE_SYSTEM_POWERSHELL_RUNAS_HOST' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;UACOrProtectedServiceInvoked=$false + AbsoluteHostMatchesSystemDirectory=$true + FakeCwdAndPathExecutableIgnored=$true + BothModesPinned=$true;NoUntrustedScriptFile=$true + InstalledMainUnchanged=$true;Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R298_NATIVE_'+$HostLabel+'_PINNED_SYSTEM_RUNAS_HOST_PASS' +}finally{ + [Environment]::SetEnvironmentVariable('PATH',$previousPath,'Process') + [Environment]::CurrentDirectory=$previousCwd + if(-not $accepted){ + $failure=[ordered]@{ + Schema=1;Revision='R298';Status='FAIL_NOT_ACCEPTED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OwnerUntouched=$true + } + [IO.File]::WriteAllText($receipt,($failure|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 6566bc98..9886302e 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -181,13 +181,42 @@ static string BuildElevatedHelperCommand(string mode,string payload,string appRo return "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; } - static int ElevatedHelper(string mode,string payload,string appRoot,string user,int timeoutMs) + // R298: runas uses ShellExecuteEx, which searches the current directory, + // Windows and PATH for an unqualified executable. Pin the native OS host. + static string TrustedSystemPowerShellPath() + { + string system=Path.GetFullPath(Environment.SystemDirectory); + if(!Path.IsPathRooted(system) || !Directory.Exists(system)) + throw new InvalidOperationException("Trusted Windows system directory unavailable."); + string first=Path.Combine(system,"WindowsPowerShell"); + string folder=Path.Combine(first,"v1.0"); + string exe=Path.Combine(folder,"powershell.exe"); + if(!Directory.Exists(first) || !Directory.Exists(folder) || !File.Exists(exe)) + throw new InvalidOperationException("Trusted Windows PowerShell executable missing."); + foreach(string candidate in new string[]{system,first,folder,exe}) + { + if((File.GetAttributes(candidate)&FileAttributes.ReparsePoint)!=0) + throw new InvalidOperationException("Trusted PowerShell host is a reparse path."); + } + return exe; + } + + // Kept separate from Process.Start so the exact runas target can be + // inspected in disposable Windows without granting UAC or starting it. + static ProcessStartInfo BuildElevatedSensorProcessInfo(string mode,string payload,string appRoot,string user) { ProcessStartInfo psi=new ProcessStartInfo(); - psi.FileName="powershell.exe"; + psi.FileName=TrustedSystemPowerShellPath(); + psi.WorkingDirectory=Path.GetDirectoryName(psi.FileName); psi.Arguments=BuildElevatedHelperCommand(mode,payload,appRoot,user); psi.UseShellExecute=true; psi.Verb="runas"; + return psi; + } + + static int ElevatedHelper(string mode,string payload,string appRoot,string user,int timeoutMs) + { + ProcessStartInfo psi=BuildElevatedSensorProcessInfo(mode,payload,appRoot,user); Process elevated=null; Exception launchError=null; From 45416f02446a59537e46ee77ffc121321c1ebb35 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 14:22:09 +0330 Subject: [PATCH 031/135] security: constrain upgrade stage cleanup to known nonrecursive entries (R299) --- ...it-r286-optional-audio-native-recovery.yml | 23 +++ ...t-R299-NonrecursiveStageCleanup-Native.ps1 | 134 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 83 +++++++++-- 3 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 build/Test-R299-NonrecursiveStageCleanup-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index ab87c108..704ba49e 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -321,6 +321,28 @@ jobs: $r.NoUntrustedScriptFile -ne $true -or $r.InstalledMainUnchanged -ne $true -or @($r.Checks).Count -lt 12){throw 'R298_NATIVE_PINNED_HOST_RECEIPT_INVALID'} 'R298_SYSTEM_POWERSHELL_HOST_PINNED=PASS' + - name: R299 reject hostile upgrade journal and remove known files without recursive delete + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R299-NonrecursiveStageCleanup-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R299_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R299-NonrecursiveStageCleanup-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R299_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R299_NONRECURSIVE_CLEANUP_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R299_TYPED_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R299' -or + $r.Status -cne 'PASS_NONRECURSIVE_ALLOWLISTED_STAGE_CLEANUP' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OwnerComputerTouched -ne $false -or + $r.UnknownFilePreserved -ne $true -or $r.UnknownEmptyDirPreserved -ne $true -or + $r.NativeJunctionRefused -ne $true -or $r.ExternalCanaryPreserved -ne $true -or + $r.PositiveNestedStageCleaned -ne $true -or $r.NoStageResidue -ne $true -or + $r.InstalledUserFilesUnchanged -ne $true -or + @($r.Checks).Count -lt 6){throw 'R299_NATIVE_RESULT_NOT_ACCEPTED'} + 'R299_BOUNDARY_AND_NONRECURSIVE_CLEANUP=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -339,3 +361,4 @@ jobs: ${{ runner.temp }}/R296_${{ matrix.host }}_${{ github.run_id }}/R296_MEMORY_ROLLBACK_RECEIPT.json ${{ runner.temp }}/R297_${{ matrix.host }}_${{ github.run_id }}/R297_PAWN_REFUSAL_RECEIPT.json ${{ runner.temp }}/R298_${{ matrix.host }}_${{ github.run_id }}/R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json + ${{ runner.temp }}/R299_${{ matrix.host }}_${{ github.run_id }}/R299_NONRECURSIVE_CLEANUP_RECEIPT.json diff --git a/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 b/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 new file mode 100644 index 00000000..6034c126 --- /dev/null +++ b/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 @@ -0,0 +1,134 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# Destructive fixture operations are forbidden on the owner's Windows host. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R299_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function SHA([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function ExpectRefusal([string]$name,[Reflection.MethodInfo]$method,[object[]]$args,[string]$pattern){ + $refused=$false + try{[void]$method.Invoke($null,$args)} + catch{if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw}} + if(!$refused){throw ('R299_FAIL_OPEN_'+$name)} + $script:checks.Add($name) +} +$testRoot=Join-Path $env:RUNNER_TEMP ('R299_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $testRoot 'R299_NONRECURSIVE_CLEANUP_RECEIPT.json' +$outside=Join-Path $testRoot 'external-canary' +$canary=Join-Path $outside 'do-not-delete.txt' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R298_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json') +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path -LiteralPath $stage) -or (Test-Path -LiteralPath $testRoot)){ + throw 'R299_EXISTING_JOURNAL_OR_FIXTURE_WOULD_BE_TOUCHED' +} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$last=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or $candidate.SetupSHA256 -cne (SHA $setup) -or + $candidate.MainSHA256 -cne (SHA $main) -or + $last.SourceCommit -cne $env:GITHUB_SHA -or $last.Status -cne 'PASS_ABSOLUTE_SYSTEM_POWERSHELL_RUNAS_HOST' -or + $last.Host -cne $HostLabel){ + throw 'R299_SOURCE_GUEST_OR_PRIOR_PROOF_MISMATCH' +} +$mainBefore=SHA $main +$configBefore=SHA $config +$stateBefore=SHA $state +[void][IO.Directory]::CreateDirectory($outside) +[IO.File]::WriteAllText($canary,'R299_EXTERNAL_PROTECTED_CANARY',[Text.UTF8Encoding]::new($false)) +$outsideBefore=SHA $canary +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$method=$type.GetMethod('CleanOwnedStage',[Reflection.BindingFlags]'Static,NonPublic') +$entryType=$type.GetNestedType('StageEntry',[Reflection.BindingFlags]'NonPublic') +if(!$method -or !$entryType){throw 'R299_EXPECTED_COMPILED_ENTRY_OR_CLEANUP_MISSING'} +$ctor=$entryType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic,Public', + $null,[Type[]]@([string],[string],[string]),$null) +if(!$ctor){throw 'R299_COMPILED_STAGE_ENTRY_CONSTRUCTOR_MISSING'} +$listType=[Collections.Generic.List[string]].GetGenericTypeDefinition().MakeGenericType([Type[]]@($entryType)) +$rows=[Activator]::CreateInstance($listType) +$entry=$ctor.Invoke([object[]]@('Docs/RELEASE_NOTES_v1.7.8.md','-','-')) +[void]$listType.GetMethod('Add').Invoke($rows,[object[]]@($entry)) +$args1=[object[]]::new(1);$args1[0]=$rows +$old=Join-Path $stage 'old' +$new=Join-Path $stage 'new' +$oldDocs=Join-Path $old 'Docs' +$newDocs=Join-Path $new 'Docs' +$note=Join-Path $newDocs 'RELEASE_NOTES_v1.7.8.md' +[void][IO.Directory]::CreateDirectory($old) +[void][IO.Directory]::CreateDirectory($newDocs) +foreach($file in @('owner.txt','phase.txt','index.txt','previous_version.txt')){ + [IO.File]::WriteAllText((Join-Path $stage $file),('R299_ONLY_'+$file)) +} +[IO.File]::WriteAllText($note,'R299_NEW_MANAGED_NOTE') +$ownerBefore=SHA (Join-Path $stage 'owner.txt') +$noteBefore=SHA $note +$unknown=Join-Path $stage 'unrecognized.txt' +[IO.File]::WriteAllText($unknown,'R299_UNRECOGNIZED_KEEP') +ExpectRefusal 'UNKNOWN_FILE_REFUSED_BEFORE_DELETION' $method $args1 'Unknown file inside update stage' +if((SHA $note) -cne $noteBefore -or + (SHA (Join-Path $stage 'owner.txt')) -cne $ownerBefore -or + (SHA $unknown) -eq 'MISSING'){throw 'R299_UNKNOWN_FILE_DID_NOT_FAIL_CLOSED'} +[IO.File]::Delete($unknown) +$unknownDir=Join-Path $stage 'unrecognized-empty-directory' +[void][IO.Directory]::CreateDirectory($unknownDir) +ExpectRefusal 'UNKNOWN_EMPTY_DIRECTORY_REFUSED' $method $args1 'Unknown directory inside update stage' +if(!(Test-Path -LiteralPath $unknownDir) -or + (SHA (Join-Path $stage 'owner.txt')) -cne $ownerBefore){ + throw 'R299_UNKNOWN_EMPTY_DIRECTORY_WAS_DELETED' +} +[IO.Directory]::Delete($unknownDir,$false) +# A junction named like an expected parent must never be traversed. +New-Item -ItemType Junction -Path $oldDocs -Target $outside -ErrorAction Stop|Out-Null +if(-not ((Get-Item -LiteralPath $oldDocs -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R299_NATIVE_JUNCTION_NOT_PRESENT' +} +ExpectRefusal 'EXPECTED_PARENT_JUNCTION_REFUSED' $method $args1 'reparse|symlink/junction' +if((SHA $canary) -cne $outsideBefore){throw 'R299_JUNCTION_DESTINATION_CHANGED'} +[IO.Directory]::Delete($oldDocs) +if(Test-Path -LiteralPath $oldDocs){throw 'R299_TEST_JUNCTION_LEFT_BEHIND'} +$checks.Add('EXTERNAL_CANARY_IMMUTABLE') +# Positive cleanup must work with recognized nested old/new files, without +# recursively traversing any directory or deleting unrelated application data. +[void][IO.Directory]::CreateDirectory($oldDocs) +[IO.File]::WriteAllText((Join-Path $oldDocs 'RELEASE_NOTES_v1.7.8.md'),'R299_OLD_MANAGED_NOTE') +[void]$method.Invoke($null,$args1) +if(Test-Path -LiteralPath $stage){throw 'R299_KNOWN_ONLY_STAGE_NOT_REMOVED'} +$checks.Add('EXACT_KNOWN_FILES_AND_DIRECTORIES_REMOVED_NONRECURSIVELY') +if((SHA $canary) -cne $outsideBefore -or (SHA $main) -cne $mainBefore -or + (SHA $config) -cne $configBefore -or (SHA $state) -cne $stateBefore){ + throw 'R299_EXTERNAL_OR_INSTALLED_USER_ASSET_CHANGED' +} +$checks.Add('ORIGINAL_GUEST_MAIN_CONFIG_AND_STATE_IMMUTABLE') +$result=[ordered]@{ + Schema=1;Revision='R299';Status='PASS_NONRECURSIVE_ALLOWLISTED_STAGE_CLEANUP' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OwnerComputerTouched=$false + UnknownFilePreserved=$true;UnknownEmptyDirPreserved=$true + NativeJunctionRefused=$true;ExternalCanaryPreserved=$true + PositiveNestedStageCleaned=$true;NoStageResidue=$true + InstalledUserFilesUnchanged=$true;Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R299_NATIVE_'+$HostLabel+'_NONRECURSIVE_STAGE_CLEANUP_PASS' diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 9886302e..a9a7fe94 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1587,22 +1587,83 @@ static List ReadStageIndex() return parsed; } + // R299: do not recursively delete a user-writable upgrade journal. A + // junction inserted after the preflight must not turn cleanup into a + // recursive deletion of unrelated user files. static void CleanOwnedStage(List rows) { - HashSet known=new HashSet(StringComparer.OrdinalIgnoreCase); + string root=Path.GetFullPath(UpgradeRoot).TrimEnd(Path.DirectorySeparatorChar); + string prefix=root+Path.DirectorySeparatorChar; + CheckNoReparse(root); + HashSet knownFiles=new HashSet(StringComparer.OrdinalIgnoreCase); foreach(string filename in new string[]{"owner.txt","phase.txt","index.txt","phase.tmp","previous_version.txt"}) - known.Add(Path.GetFullPath(Path.Combine(UpgradeRoot,filename))); + knownFiles.Add(Path.GetFullPath(Path.Combine(root,filename))); foreach(StageEntry r in rows){ - known.Add(Path.GetFullPath(Stage("old",r.Rel))); - known.Add(Path.GetFullPath(Stage("new",r.Rel))); + knownFiles.Add(Path.GetFullPath(Stage("old",r.Rel))); + knownFiles.Add(Path.GetFullPath(Stage("new",r.Rel))); } - foreach(string path in Directory.GetFiles(UpgradeRoot,"*",SearchOption.AllDirectories)) - if(!known.Contains(Path.GetFullPath(path))) - throw new InvalidOperationException("Unknown file inside update stage; leave stage intact: "+path); - foreach(string path in Directory.GetDirectories(UpgradeRoot,"*",SearchOption.AllDirectories)) - if((File.GetAttributes(path)&FileAttributes.ReparsePoint)!=0) - throw new IOException("Unexpected upgrade stage reparse directory."); - Directory.Delete(UpgradeRoot,true); + HashSet knownDirs=new HashSet(StringComparer.OrdinalIgnoreCase); + knownDirs.Add(root); + foreach(string filename in knownFiles){ + if(!filename.StartsWith(prefix,StringComparison.OrdinalIgnoreCase)) + throw new IOException("Upgrade cleanup path escaped canonical stage."); + string directory=Path.GetDirectoryName(filename); + while(!String.Equals(directory,root,StringComparison.OrdinalIgnoreCase)){ + if(String.IsNullOrEmpty(directory) || !directory.StartsWith(prefix,StringComparison.OrdinalIgnoreCase)) + throw new IOException("Upgrade cleanup directory escaped canonical stage."); + knownDirs.Add(directory); + directory=Path.GetDirectoryName(directory); + } + } + // Enumerate one directory at a time: reject unexpected empty dirs as + // well as files and reparse entries BEFORE deleting any known member. + List pending=new List();pending.Add(root); + while(pending.Count>0){ + string directory=pending[pending.Count-1];pending.RemoveAt(pending.Count-1); + CheckNoReparse(directory); + foreach(string entry in Directory.GetFileSystemEntries(directory)){ + string full=Path.GetFullPath(entry); + if(!full.StartsWith(prefix,StringComparison.OrdinalIgnoreCase)) + throw new IOException("Upgrade cleanup observed path escape."); + FileAttributes attributes=File.GetAttributes(full); + if((attributes&FileAttributes.ReparsePoint)!=0) + throw new IOException("Upgrade cleanup refuses reparse member: "+full); + if((attributes&FileAttributes.Directory)!=0){ + if(!knownDirs.Contains(full)) + throw new IOException("Unknown directory inside update stage; leave stage intact: "+full); + pending.Add(full); + }else if(!knownFiles.Contains(full)){ + throw new IOException("Unknown file inside update stage; leave stage intact: "+full); + } + } + } + // Unlink only exact known files; remove directories with recursive=false. + // Keep owner/index/phase markers until descendants have been removed. + string[] metadata=new string[]{"phase.tmp","previous_version.txt","index.txt","phase.txt","owner.txt"}; + HashSet metadataPaths=new HashSet(StringComparer.OrdinalIgnoreCase); + foreach(string name in metadata)metadataPaths.Add(Path.Combine(root,name)); + foreach(string filename in knownFiles){ + if(metadataPaths.Contains(filename))continue; + CheckNoReparse(filename); + if(File.Exists(filename))File.Delete(filename); + } + List directories=knownDirs.Where(d=>!String.Equals(d,root,StringComparison.OrdinalIgnoreCase)).ToList(); + directories.Sort(delegate(string a,string b){ + int c=b.Length.CompareTo(a.Length); + return c!=0?c:StringComparer.OrdinalIgnoreCase.Compare(a,b); + }); + foreach(string directory in directories){ + CheckNoReparse(directory); + if(Directory.Exists(directory))Directory.Delete(directory,false); + } + // Unknown files or directories cannot be silently removed by this path. + foreach(string name in metadata){ + string filename=Path.Combine(root,name); + CheckNoReparse(filename); + if(File.Exists(filename))File.Delete(filename); + } + CheckNoReparse(root); + Directory.Delete(root,false); } static void RestoreInterruptedUpgrade(List rows) From c3d0dcc8b444c6de80a50ad59bed50bc22b9697c Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 14:27:10 +0330 Subject: [PATCH 032/135] test: preserve Reflection argument array in R299 native harness (R299-F01) --- build/Test-R299-NonrecursiveStageCleanup-Native.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 b/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 index 6034c126..0c3d7e1b 100644 --- a/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 +++ b/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 @@ -21,9 +21,9 @@ function SHA([string]$path){ if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash } -function ExpectRefusal([string]$name,[Reflection.MethodInfo]$method,[object[]]$args,[string]$pattern){ +function ExpectRefusal([string]$name,[Reflection.MethodInfo]$method,[object[]]$invokeArgs,[string]$pattern){ $refused=$false - try{[void]$method.Invoke($null,$args)} + try{[void]$method.Invoke($null,$invokeArgs)} catch{if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw}} if(!$refused){throw ('R299_FAIL_OPEN_'+$name)} $script:checks.Add($name) From 2057a49ad88a6255fd73c446ea19b82476dde03b Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 15:00:37 +0330 Subject: [PATCH 033/135] fix(setup): retire completed upgrade journal before bounded metadata cleanup (R300) --- ...it-r286-optional-audio-native-recovery.yml | 26 +++ ...t-R299-NonrecursiveStageCleanup-Native.ps1 | 3 +- ...est-R300-RetiredJournalRecovery-Native.ps1 | 158 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 51 +++++- 4 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 build/Test-R300-RetiredJournalRecovery-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 704ba49e..ff20fac7 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -343,6 +343,31 @@ jobs: $r.InstalledUserFilesUnchanged -ne $true -or @($r.Checks).Count -lt 6){throw 'R299_NATIVE_RESULT_NOT_ACCEPTED'} 'R299_BOUNDARY_AND_NONRECURSIVE_CLEANUP=PASS' + - name: R300 resume safely retired upgrade journal after interruption + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R300-RetiredJournalRecovery-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R300_PS51_TEST_PARSE_FAILED'} + ./build/Test-R300-RetiredJournalRecovery-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R300_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R300_RETIRED_JOURNAL_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R300_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R300' -or + $r.Status -cne 'PASS_RETIRED_STAGE_RESTART_AND_HOSTILE_REFUSAL' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OwnerComputerTouched -ne $false -or $r.RealProcessKilled -ne $false -or + $r.SimulatedPostMoveStop -ne $true -or + $r.SameParentDirectoryRename -ne $true -or + $r.PostMoveCleanupRetried -ne $true -or $r.InterruptedMetadataCleanupRetried -ne $true -or + $r.UnknownFileRefused -ne $true -or $r.ReparseRefused -ne $true -or + $r.UnknownOwnerRefused -ne $true -or + $r.InstalledMainConfigStateUnchanged -ne $true -or + $r.NoRetiredOrActiveStageResidue -ne $true -or + @($r.Checks).Count -lt 12){throw 'R300_TYPED_RESULT_INVALID'} + 'R300_NATIVE_RETIRED_JOURNAL_RECOVERY=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -362,3 +387,4 @@ jobs: ${{ runner.temp }}/R297_${{ matrix.host }}_${{ github.run_id }}/R297_PAWN_REFUSAL_RECEIPT.json ${{ runner.temp }}/R298_${{ matrix.host }}_${{ github.run_id }}/R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json ${{ runner.temp }}/R299_${{ matrix.host }}_${{ github.run_id }}/R299_NONRECURSIVE_CLEANUP_RECEIPT.json + ${{ runner.temp }}/R300_${{ matrix.host }}_${{ github.run_id }}/R300_RETIRED_JOURNAL_RECEIPT.json diff --git a/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 b/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 index 0c3d7e1b..82fec1eb 100644 --- a/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 +++ b/build/Test-R299-NonrecursiveStageCleanup-Native.ps1 @@ -79,7 +79,8 @@ $note=Join-Path $newDocs 'RELEASE_NOTES_v1.7.8.md' [void][IO.Directory]::CreateDirectory($old) [void][IO.Directory]::CreateDirectory($newDocs) foreach($file in @('owner.txt','phase.txt','index.txt','previous_version.txt')){ - [IO.File]::WriteAllText((Join-Path $stage $file),('R299_ONLY_'+$file)) + $value=if($file -ceq 'owner.txt'){'TBME_UPGRADE_R92_OWNED_STAGE_V1'}else{'R299_ONLY_'+$file} + [IO.File]::WriteAllText((Join-Path $stage $file),$value) } [IO.File]::WriteAllText($note,'R299_NEW_MANAGED_NOTE') $ownerBefore=SHA (Join-Path $stage 'owner.txt') diff --git a/build/Test-R300-RetiredJournalRecovery-Native.ps1 b/build/Test-R300-RetiredJournalRecovery-Native.ps1 new file mode 100644 index 00000000..3ab47b44 --- /dev/null +++ b/build/Test-R300-RetiredJournalRecovery-Native.ps1 @@ -0,0 +1,158 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# No permission to run against owner desktop or mutate its running installation. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R300_ONLY_DISPOSABLE_WINDOWS_RUNNER' +} +function Hash([string]$file){ + if(!(Test-Path -LiteralPath $file -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash +} +function Invoke-Recover([Reflection.MethodInfo]$method){ + [void]$method.Invoke($null,[object[]]::new(0)) +} +function Expect-Refused([string]$label,[Reflection.MethodInfo]$method,[string]$pattern){ + $refused=$false + try{Invoke-Recover $method}catch{ + if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw} + } + if(!$refused){throw ('R300_FAIL_OPEN_'+$label)} + $script:checks.Add($label) +} +$testRoot=Join-Path $env:RUNNER_TEMP ('R300_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $testRoot 'R300_RETIRED_JOURNAL_RECEIPT.json' +$outside=Join-Path $testRoot 'external-canary' +$canary=Join-Path $outside 'never-delete.txt' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$retired=Join-Path $app '.tbme-upgrade-retired' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R299_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R299_NONRECURSIVE_CLEANUP_RECEIPT.json') +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path -LiteralPath $testRoot) -or + (Test-Path -LiteralPath $stage) -or + (Test-Path -LiteralPath $retired) -or + @((Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue)).Count -ne 0){ + throw 'R300_UNEXPECTED_STAGE_FIXTURE_OR_RUNNING_APP' +} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$old=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $candidate.SetupSHA256 -or + (Hash $main) -cne $candidate.MainSHA256 -or + $old.Revision -cne 'R299' -or + $old.Status -cne 'PASS_NONRECURSIVE_ALLOWLISTED_STAGE_CLEANUP' -or + $old.SourceCommit -cne $env:GITHUB_SHA -or + $old.Host -cne $HostLabel){ + throw 'R300_SOURCE_OR_PREVIOUS_NATIVE_PROOF_UNTRUSTED' +} +$before=@{ Main=(Hash $main);Config=(Hash $config);State=(Hash $state) } +[void][IO.Directory]::CreateDirectory($outside) +[IO.File]::WriteAllText($canary,'R300_CROSS_DIRECTORY_CANARY',[Text.UTF8Encoding]::new($false)) +$canarySHA=Hash $canary +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$method=$type.GetMethod('RecoverUpgradeStageIfAny',$flags) +$retiredProp=$type.GetProperty('RetiredUpgradeRoot',$flags) +if(!$method -or !$retiredProp){throw 'R300_PRODUCTION_RECOVERY_ENTRYPOINT_MISSING'} +$actualRetired=[string]$retiredProp.GetValue($null,$null) +if(-not [string]::Equals($retired,$actualRetired,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R300_RETIRED_ROOT_NOT_EXACT_CANONICAL_SIBLING' +} +$checks.Add('EXACT_CANONICAL_RETIRED_ROOT') +$marker='TBME_UPGRADE_R92_OWNED_STAGE_V1' +function Write-Metadata([string]$directory,[string[]]$members){ + [void][IO.Directory]::CreateDirectory($directory) + foreach($name in $members){ + $value=if($name -ceq 'owner.txt'){$marker}else{'R300_TEST_'+$name} + [IO.File]::WriteAllText((Join-Path $directory $name),$value,[Text.Encoding]::ASCII) + } +} +# Emulate the filesystem state *after* a completed same-parent Directory.Move, +# but before tombstone metadata deletion. Does not assert actual process death. +Write-Metadata $stage @('owner.txt','index.txt','phase.txt','previous_version.txt','phase.tmp') +[IO.Directory]::Move($stage,$retired) +if((Test-Path -LiteralPath $stage) -or !(Test-Path -LiteralPath $retired)){ + throw 'R300_SAME_PARENT_DIRECTORY_MOVE_DID_NOT_RETIRE_STAGE' +} +$checks.Add('NATIVE_SAME_PARENT_RENAME_RETIRES_ACTIVE_STAGE') +Invoke-Recover $method +if(Test-Path -LiteralPath $retired){throw 'R300_POST_RENAME_RECOVERY_NOT_CLEAN'} +$checks.Add('POST_RENAME_RESTART_CLEANUP_ACCEPTED') +# The deletion sequence can be interrupted after index/phase have gone. +Write-Metadata $retired @('owner.txt','phase.txt','index.txt') +[IO.File]::Delete((Join-Path $retired 'index.txt')) +[IO.File]::Delete((Join-Path $retired 'phase.txt')) +Invoke-Recover $method +if(Test-Path -LiteralPath $retired){throw 'R300_OWNER_ONLY_POST_DELETE_RECOVERY_FAILED'} +$checks.Add('PARTIALLY_DELETED_METADATA_RECOVERED') +[void][IO.Directory]::CreateDirectory($retired) +Invoke-Recover $method +if(Test-Path -LiteralPath $retired){throw 'R300_EMPTY_RETIRED_DIR_NOT_RECOVERED'} +$checks.Add('EMPTY_FINAL_DIRECTORY_RECOVERED') +# An unexpected file must stop cleanup with both file and owner intact. +Write-Metadata $retired @('owner.txt','index.txt') +$unknown=Join-Path $retired 'unknown-owner-file.txt' +[IO.File]::WriteAllText($unknown,'R300_UNKNOWN_PRESERVE',[Text.Encoding]::ASCII) +$ownerSHA=Hash (Join-Path $retired 'owner.txt') +Expect-Refused 'UNKNOWN_RETIRED_MEMBER_REFUSED' $method 'Unknown retired upgrade member' +if((Hash $unknown) -eq 'MISSING' -or (Hash (Join-Path $retired 'owner.txt')) -cne $ownerSHA){ + throw 'R300_UNKNOWN_RETIREMENT_DATA_MUTATED' +} +[IO.File]::Delete($unknown) +# Detect actual junctions, including a correctly named intermediate subtree. +$link=Join-Path $retired 'phase.tmp' +New-Item -ItemType Junction -Path $link -Target $outside -ErrorAction Stop|Out-Null +if(-not ((Get-Item -LiteralPath $link -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R300_REAL_WINDOWS_REPARSE_NOT_CREATED' +} +Expect-Refused 'RETIRED_JUNCTION_REFUSED' $method 'reparse|ordinary file' +if((Hash $canary) -cne $canarySHA){throw 'R300_EXTERNAL_CANARY_CHANGED_BY_JUNCTION'} +[IO.Directory]::Delete($link) +$checks.Add('EXTERNAL_CANARY_PROTECTED') +# Invalid ownership and missing ownership with nonempty metadata fail closed. +[IO.File]::WriteAllText((Join-Path $retired 'owner.txt'),'WRONG_OWNER',[Text.Encoding]::ASCII) +Expect-Refused 'INVALID_OWNER_MARKER_REFUSED' $method 'ownership marker mismatch' +[IO.File]::Delete((Join-Path $retired 'owner.txt')) +Expect-Refused 'MISSING_OWNER_WITH_METADATA_REFUSED' $method 'lost its ownership marker' +[IO.File]::WriteAllText((Join-Path $retired 'owner.txt'),$marker,[Text.Encoding]::ASCII) +Invoke-Recover $method +if(Test-Path -LiteralPath $retired){throw 'R300_SAFE_METADATA_REPAIR_DID_NOT_FINISH'} +$checks.Add('KNOWN_RETIRED_STAGE_RECOVERED_AFTER_HOSTILE_TESTS') +if((Hash $main) -cne $before.Main -or + (Hash $config) -cne $before.Config -or + (Hash $state) -cne $before.State -or + (Hash $canary) -cne $canarySHA -or + (Test-Path -LiteralPath $stage)){ + throw 'R300_GUEST_OR_EXTERNAL_USER_CONTENT_CHANGED' +} +$checks.Add('EXACT_INSTALLED_FILES_AND_CANARY_UNCHANGED') +$data=[ordered]@{ + Schema=1;Revision='R300';Status='PASS_RETIRED_STAGE_RESTART_AND_HOSTILE_REFUSAL' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;OwnerComputerTouched=$false + RealProcessKilled=$false;SimulatedPostMoveStop=$true + SameParentDirectoryRename=$true;PostMoveCleanupRetried=$true + InterruptedMetadataCleanupRetried=$true;UnknownFileRefused=$true + ReparseRefused=$true;UnknownOwnerRefused=$true + InstalledMainConfigStateUnchanged=$true;NoRetiredOrActiveStageResidue=$true + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R300_NATIVE_'+$HostLabel+'_RETIRED_STAGE_RECOVERY_PASS' diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index a9a7fe94..89ece921 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1473,6 +1473,7 @@ static void EnsureAppNotRunning() } static string UpgradeRoot {get{return Path.Combine(AppRoot,UpgradeStageName);}} + static string RetiredUpgradeRoot {get{return Path.Combine(AppRoot,UpgradeStageName+"-retired");}} static string Stage(string part,string rel) { if(part!="new" && part!="old")throw new InvalidDataException("Unexpected stage area."); @@ -1595,6 +1596,10 @@ static void CleanOwnedStage(List rows) string root=Path.GetFullPath(UpgradeRoot).TrimEnd(Path.DirectorySeparatorChar); string prefix=root+Path.DirectorySeparatorChar; CheckNoReparse(root); + string retired=Path.GetFullPath(RetiredUpgradeRoot); + CheckNoReparse(retired); + if(Directory.Exists(retired) || File.Exists(retired)) + throw new IOException("Prior retired upgrade journal must be recovered first."); HashSet knownFiles=new HashSet(StringComparer.OrdinalIgnoreCase); foreach(string filename in new string[]{"owner.txt","phase.txt","index.txt","phase.tmp","previous_version.txt"}) knownFiles.Add(Path.GetFullPath(Path.Combine(root,filename))); @@ -1656,11 +1661,48 @@ static void CleanOwnedStage(List rows) CheckNoReparse(directory); if(Directory.Exists(directory))Directory.Delete(directory,false); } - // Unknown files or directories cannot be silently removed by this path. + // Keep index, phase, prior-version and ownership metadata in the + // active journal until a same-parent move retires that whole journal. + // Interruption before the move resumes original recovery; after it + // only a fixed, bounded metadata tombstone remains. + CheckNoReparse(root); + CheckNoReparse(retired); + Directory.Move(root,retired); + FinishRetiredUpgradeStageIfAny(); + } + + // R300: retryable, nonrecursive cleanup of exact retired metadata only. + static void FinishRetiredUpgradeStageIfAny() + { + string root=Path.GetFullPath(RetiredUpgradeRoot); + CheckNoReparse(root); + if(File.Exists(root)) + throw new IOException("Retired upgrade path is not a directory."); + if(!Directory.Exists(root))return; + string owner=Path.Combine(root,"owner.txt"); + string[] metadata=new string[]{"phase.tmp","previous_version.txt","index.txt","phase.txt","owner.txt"}; + HashSet names=new HashSet(metadata,StringComparer.OrdinalIgnoreCase); + string[] entries=Directory.GetFileSystemEntries(root); + foreach(string entry in entries){ + string full=Path.GetFullPath(entry); + if(!String.Equals(Path.GetDirectoryName(full),root,StringComparison.OrdinalIgnoreCase) || + !names.Contains(Path.GetFileName(full))) + throw new IOException("Unknown retired upgrade member; preserve for review: "+full); + CheckNoReparse(full); + FileAttributes attributes=File.GetAttributes(full); + if((attributes&(FileAttributes.Directory|FileAttributes.ReparsePoint))!=0) + throw new IOException("Retired upgrade member is not an ordinary file: "+full); + } + // Owner is last; if it is absent, only an empty directory is safe. + if(File.Exists(owner)){ + if(File.ReadAllText(owner,Encoding.ASCII)!=UpgradeStageMarker) + throw new IOException("Retired upgrade ownership marker mismatch."); + }else if(entries.Length!=0) + throw new IOException("Retired upgrade journal lost its ownership marker."); foreach(string name in metadata){ - string filename=Path.Combine(root,name); - CheckNoReparse(filename); - if(File.Exists(filename))File.Delete(filename); + string file=Path.Combine(root,name); + CheckNoReparse(file); + if(File.Exists(file))File.Delete(file); } CheckNoReparse(root); Directory.Delete(root,false); @@ -1725,6 +1767,7 @@ static void RestoreInterruptedUpgrade(List rows) static void RecoverUpgradeStageIfAny() { + FinishRetiredUpgradeStageIfAny(); if(!Directory.Exists(UpgradeRoot))return; CheckNoReparse(UpgradeRoot); // Interrupted journals are writable in the user's AppData. Before From 61166e53f6c91edb33b447470fc8a641c5115f2d Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 15:15:36 +0330 Subject: [PATCH 034/135] fix(setup): persist ROLLED_BACK before disposing prior backups (R301) --- ...it-r286-optional-audio-native-recovery.yml | 30 +++ build/Test-R301-RolledBackRestart-Native.ps1 | 234 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 41 +++ 3 files changed, 305 insertions(+) create mode 100644 build/Test-R301-RolledBackRestart-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index ff20fac7..ba3d3a6e 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -368,6 +368,35 @@ jobs: $r.NoRetiredOrActiveStageResidue -ne $true -or @($r.Checks).Count -lt 12){throw 'R300_TYPED_RESULT_INVALID'} 'R300_NATIVE_RETIRED_JOURNAL_RECOVERY=PASS' + - name: R301 replay rollback cleanup after real disposable worker termination + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R301-RolledBackRestart-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R301_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R301-RolledBackRestart-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R301_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R301_ROLLBACK_RETRY_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R301_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R301' -or + $r.Status -cne 'PASS_ROLLED_BACK_JOURNAL_RETRY_REAL_WORKER_TERMINATION' -or + $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OwnerComputerTouched -ne $false -or + $r.ActualOriginalSetupProcessKilled -ne $false -or + $r.ActualDisposableProofWorkerKilled -ne $true -or + $r.ProductionWritePhaseExecutedInChild -ne $true -or + $r.FreshRecoveryProcessSucceeded -ne $true -or + $r.MissingOldSnapshotsAcceptedAfterVerifiedRollback -ne $true -or + $r.TamperedPriorFileDigestRejected -ne $true -or + $r.RegistryVersionMismatchRejected -ne $true -or + $r.UnsupportedVersionRejected -ne $true -or + $r.OriginalMainAndSettingsUnchanged -ne $true -or + $r.NoJournalResidue -ne $true -or + @($r.Checks).Count -lt 8){throw 'R301_TYPED_NATIVE_RECEIPT_INVALID'} + 'R301_REAL_TEST_WORKER_KILL_AND_RESTART_RECOVERY_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -388,3 +417,4 @@ jobs: ${{ runner.temp }}/R298_${{ matrix.host }}_${{ github.run_id }}/R298_EXPLICIT_POWERSHELL_HOST_RECEIPT.json ${{ runner.temp }}/R299_${{ matrix.host }}_${{ github.run_id }}/R299_NONRECURSIVE_CLEANUP_RECEIPT.json ${{ runner.temp }}/R300_${{ matrix.host }}_${{ github.run_id }}/R300_RETIRED_JOURNAL_RECEIPT.json + ${{ runner.temp }}/R301_${{ matrix.host }}_${{ github.run_id }}/R301_ROLLBACK_RETRY_RECEIPT.json diff --git a/build/Test-R301-RolledBackRestart-Native.ps1 b/build/Test-R301-RolledBackRestart-Native.ps1 new file mode 100644 index 00000000..f5b36844 --- /dev/null +++ b/build/Test-R301-RolledBackRestart-Native.ps1 @@ -0,0 +1,234 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Never run on the owner's live Windows host. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R301_ONLY_DISPOSABLE_GITHUB_WINDOWS' +} +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Expect-Fail([string]$gate,[Reflection.MethodInfo]$method,[string]$pattern){ + $refused=$false + try{[void]$method.Invoke($null,[object[]]::new(0))} + catch{if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw}} + if(!$refused){throw ('R301_FAIL_OPEN_'+$gate)} + $script:checks.Add($gate) +} +function Start-ProofChild([string]$code){ + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code)) + $system=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + if(-not [IO.File]::Exists($system)){throw 'R301_NATIVE_WINDOWS_POWERSHELL_MISSING'} + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$system + $psi.Arguments='-NoProfile -NonInteractive -EncodedCommand '+$encoded + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + return [Diagnostics.Process]::Start($psi) +} +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$retired=Join-Path $app '.tbme-upgrade-retired' +$testRoot=Join-Path $env:RUNNER_TEMP ('R301_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $testRoot 'R301_ROLLBACK_RETRY_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R300_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R300_RETIRED_JOURNAL_RECEIPT.json') +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path -LiteralPath $testRoot) -or (Test-Path -LiteralPath $stage) -or + (Test-Path -LiteralPath $retired) -or + @((Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue)).Count -ne 0){ + throw 'R301_EXISTING_OR_RUNNING_OWNER_STATE' +} +$m=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($m.Commit -cne $env:GITHUB_SHA -or $m.SetupSHA256 -cne (Hash $setup) -or + $m.MainSHA256 -cne (Hash $main) -or $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_RETIRED_STAGE_RESTART_AND_HOSTILE_REFUSAL'){ + throw 'R301_UNTRUSTED_EXACT_SOURCE_OR_PRIOR_PROOF' +} +[void][IO.Directory]::CreateDirectory($testRoot) +$before=@{Main=(Hash $main);Config=(Hash $config);State=(Hash $state)} +$marker='TBME_UPGRADE_R92_OWNED_STAGE_V1' +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$static=[Reflection.BindingFlags]'Static,NonPublic' +$instance=[Reflection.BindingFlags]'Instance,NonPublic,Public' +$targetField=$type.GetField('UpgradeTargets',$static) +$recover=$type.GetMethod('RecoverUpgradeStageIfAny',$static) +$verify=$type.GetMethod('VerifyRolledBackUpgrade',$static) +$phaseWriter=$type.GetMethod('WritePhase',$static) +if(!$targetField -or !$recover -or !$verify -or !$phaseWriter){ + throw 'R301_EXPECTED_PRODUCTION_ENTRYPOINTS_MISSING' +} +$targets=[array]$targetField.GetValue($null) +if($targets.Count -lt 25 -or $targets.Count -gt 40){throw 'R301_UNEXPECTED_MANAGED_INVENTORY'} +$rows=[Collections.Generic.List[string]]::new() +foreach($item in $targets){ + $field=$item.GetType().GetField('Rel',$instance) + if(!$field){throw 'R301_TYPED_ROW_REL_MISSING'} + $rel=[string]$field.GetValue($item) + $full=Join-Path $app ($rel.Replace('/',[IO.Path]::DirectorySeparatorChar)) + $previous=Hash $full + # Existing source hashes are real, new-only files are represented by '-'. + $rows.Add($rel+'|'+$previous+'|-') +} +if(-not $rows[0].StartsWith('TaskbarMonitorEnhanced.exe|',[StringComparison]::Ordinal)){ + throw 'R301_PINNED_MAIN_ROW_NOT_FIRST' +} +$checks.Add('COMPILED_ROW_INVENTORY_EXACT_LIVE_HASHES') +$indexText=$rows.ToArray() -join ([string][char]10) +$registration='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$display=[string](Get-ItemProperty -LiteralPath $registration -ErrorAction Stop).DisplayVersion +$oldVersion='1.7.8' +if($display -cne $oldVersion){throw 'R301_GUEST_INSTALLED_VERSION_UNEXPECTED'} +function Prepare-Journal([string]$version){ + if(Test-Path -LiteralPath $stage){throw 'R301_STAGE_COLLISION'} + [void][IO.Directory]::CreateDirectory($stage) + [IO.File]::WriteAllText((Join-Path $stage 'owner.txt'),$marker,[Text.Encoding]::ASCII) + [IO.File]::WriteAllText((Join-Path $stage 'previous_version.txt'),$version,[Text.Encoding]::ASCII) + [IO.File]::WriteAllText((Join-Path $stage 'index.txt'),$indexText,[Text.Encoding]::ASCII) + [IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|COMMITTING'),[Text.Encoding]::ASCII) +} +function Restore-SafeStage(){ + if(Test-Path -LiteralPath $stage){ + foreach($name in @('phase.tmp','phase.txt','index.txt','previous_version.txt','owner.txt')){ + $path=Join-Path $stage $name + if(Test-Path -LiteralPath $path -PathType Leaf){[IO.File]::Delete($path)} + } + [IO.Directory]::Delete($stage,$false) + } +} +Prepare-Journal $oldVersion +# Mimic a completed rollback where previously copied 'old' backup snapshots +# have already been unlinked. A COMMITTING replay must not consume them again. +if((Test-Path -LiteralPath (Join-Path $stage 'old')) -or + (Test-Path -LiteralPath (Join-Path $stage 'new'))){ + throw 'R301_SHOULD_NOT_HAVE_STAGED_OLD_SNAPSHOTS' +} +$checks.Add('COMPLETE_OLD_LIVE_STATE_WITH_ABSENT_BACKUP_SNAPSHOTS') +# The separate real PowerShell worker executes the production WritePhase +# and is then forcibly terminated by this parent after a checkpoint. This +# is NOT a kill of the production Setup process inside filesystem cleanup. +$checkpoint=Join-Path $testRoot 'worker-durable-phase-ready.txt' +$setupQuoted=$setup.Replace("'","''") +$checkQuoted=$checkpoint.Replace("'","''") +$writerCode=@" +`$ErrorActionPreference='Stop' +`$assembly=[Reflection.Assembly]::LoadFile('$setupQuoted') +`$type=`$assembly.GetType('SetupProgram',`$true) +`$method=`$type.GetMethod('WritePhase',[Reflection.BindingFlags]'Static,NonPublic') +if(!`$method){throw 'R301_CHILD_PRODUCTION_PHASE_METHOD_MISSING'} +[void]`$method.Invoke(`$null,[object[]]@('ROLLED_BACK')) +[IO.File]::WriteAllText('$checkQuoted','DURABLE_ROLLBACK_PHASE_READY') +Start-Sleep -Seconds 30 +"@ +$worker=Start-ProofChild $writerCode +if(!$worker){throw 'R301_WORKER_PROCESS_DID_NOT_START'} +$watch=[Diagnostics.Stopwatch]::StartNew() +try{ + while(!(Test-Path -LiteralPath $checkpoint) -and !$worker.HasExited -and $watch.ElapsedMilliseconds -lt 14000){ + Start-Sleep -Milliseconds 100 + } + if(!(Test-Path -LiteralPath $checkpoint) -or $worker.HasExited){ + throw 'R301_CHILD_DURABLE_PHASE_CHECKPOINT_NOT_REACHED' + } + $phaseContent=[IO.File]::ReadAllText((Join-Path $stage 'phase.txt'),[Text.Encoding]::ASCII) + if($phaseContent -cne ($marker+'|ROLLED_BACK')){ + throw 'R301_PHASE_NOT_DURABLY_REPLACED' + } + $worker.Kill() + if(!$worker.WaitForExit(10000) -or !$worker.HasExited -or $worker.ExitCode -eq 0){ + throw 'R301_WORKER_TERMINATION_NOT_OBSERVED' + } + $checks.Add('REAL_DISPOSABLE_WORKER_KILLED_AFTER_PRODUCTION_ROLLBACK_MARKER') +}finally{ + if(!$worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(10000)} + $worker.Dispose() +} +# New process loads the SAME compiled installer and runs its normal recovery. +$retryCode=@" +`$ErrorActionPreference='Stop' +`$assembly=[Reflection.Assembly]::LoadFile('$setupQuoted') +`$type=`$assembly.GetType('SetupProgram',`$true) +`$method=`$type.GetMethod('RecoverUpgradeStageIfAny',[Reflection.BindingFlags]'Static,NonPublic') +if(!`$method){throw 'R301_RESTART_RECOVERY_METHOD_MISSING'} +[void]`$method.Invoke(`$null,[object[]]::new(0)) +"@ +$retry=Start-ProofChild $retryCode +if(!$retry){throw 'R301_RESTART_WORKER_NOT_STARTED'} +try{ + if(!$retry.WaitForExit(18000) -or !$retry.HasExited -or $retry.ExitCode -ne 0){ + throw ('R301_RECOVERY_WORKER_FAILED exit='+$(if($retry.HasExited){$retry.ExitCode}else{'RUNNING'})) + } +}finally{ + if(!$retry.HasExited){$retry.Kill();[void]$retry.WaitForExit(10000)} + $retry.Dispose() +} +if((Test-Path -LiteralPath $stage) -or (Test-Path -LiteralPath $retired)){ + throw 'R301_POST_RESTART_JOURNAL_RESIDUE' +} +$checks.Add('FRESH_PROCESS_RESTART_RECOVERS_WITHOUT_OLD_SNAPSHOTS') +# Negative test: wrong installed digest must fail closed, preserving the stage. +Prepare-Journal $oldVersion +$first=$rows[0].Split('|') +if($first.Length -ne 3 -or $first[1] -ceq '-'){throw 'R301_EXPECTED_REAL_MAIN_PREVIOUS_HASH_MISSING'} +$changed=$first[0]+'|'+('0'*64)+'|'+$first[2] +$lines=[string[]]$rows.ToArray() +$lines[0]=$changed +[IO.File]::WriteAllText((Join-Path $stage 'index.txt'),($lines -join ([string][char]10)),[Text.Encoding]::ASCII) +[IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|ROLLED_BACK'),[Text.Encoding]::ASCII) +Expect-Fail 'FORGED_PREVIOUS_DIGEST_REJECTED' $recover 'Rolled-back previous SHA mismatch' +if(-not (Test-Path -LiteralPath $stage) -or + (Hash $main) -cne $before.Main){throw 'R301_FAILED_VERIFICATION_CHANGED_APP'} +Restore-SafeStage +# A supported but wrong prior version and HKCU version mismatch must fail closed. +Prepare-Journal '1.7.7' +[IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|ROLLED_BACK'),[Text.Encoding]::ASCII) +Expect-Fail 'VERSION_REGISTRY_MISMATCH_REFUSED' $recover 'Rolled-back uninstall registration mismatch' +Restore-SafeStage +# Unrecognized version should be rejected before any delete. +Prepare-Journal '99.99' +[IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|ROLLED_BACK'),[Text.Encoding]::ASCII) +Expect-Fail 'UNKNOWN_OLD_VERSION_REFUSED' $recover 'not pinned' +Restore-SafeStage +if((Hash $main) -cne $before.Main -or (Hash $config) -cne $before.Config -or + (Hash $state) -cne $before.State -or + [string](Get-ItemProperty -LiteralPath $registration).DisplayVersion -cne $oldVersion -or + (Test-Path -LiteralPath $stage) -or (Test-Path -LiteralPath $retired)){ + throw 'R301_INSTALLED_ASSETS_OR_REGISTRY_CHANGED' +} +$checks.Add('GUEST_MAIN_CONFIG_STATE_AND_REGISTRATION_IMMUTABLE') +$data=[ordered]@{ + Schema=1;Revision='R301';Status='PASS_ROLLED_BACK_JOURNAL_RETRY_REAL_WORKER_TERMINATION' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerTouched=$false;OriginalMainAndSettingsUnchanged=$true + ActualOriginalSetupProcessKilled=$false + ActualDisposableProofWorkerKilled=$true + ProductionWritePhaseExecutedInChild=$true + FreshRecoveryProcessSucceeded=$true + MissingOldSnapshotsAcceptedAfterVerifiedRollback=$true + TamperedPriorFileDigestRejected=$true + RegistryVersionMismatchRejected=$true + UnsupportedVersionRejected=$true + NoJournalResidue=$true;Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R301_NATIVE_'+$HostLabel+'_RECOVERY_AFTER_REAL_WORKER_KILL_PASS' diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 89ece921..2e1a4898 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1765,6 +1765,39 @@ static void RestoreInterruptedUpgrade(List rows) } } + // R301: COMMITTING rollback needs a separate durable success marker. + // Otherwise a crash during backup cleanup makes the next COMMITTING + // replay require already-deleted old snapshots. ROLLED_BACK is written + // only after the old live file hashes and HKCU version have been restored. + static void VerifyRolledBackUpgrade(List rows) + { + string versionPath=Path.Combine(UpgradeRoot,"previous_version.txt"); + CheckNoReparse(versionPath); + if(!File.Exists(versionPath)) + throw new IOException("Rolled-back upgrade version missing; retain stage."); + string oldVersion=File.ReadAllText(versionPath,Encoding.ASCII); + if(oldVersion!=InstalledV16 && oldVersion!=InstalledV170 && + oldVersion!=InstalledV171 && oldVersion!=InstalledV172 && + oldVersion!=InstalledV173 && oldVersion!=InstalledV174 && + oldVersion!=InstalledV175 && oldVersion!=InstalledV176 && + oldVersion!=InstalledV177 && oldVersion!=Version) + throw new IOException("Rolled-back upgrade version is not pinned."); + using(RegistryKey key=Registry.CurrentUser.OpenSubKey(UninstallKey,false)){ + if(key==null || !String.Equals(Convert.ToString(key.GetValue("DisplayVersion")), + oldVersion,StringComparison.Ordinal)) + throw new IOException("Rolled-back uninstall registration mismatch."); + } + foreach(StageEntry row in rows){ + string live=UPath(row.Rel); + if(row.Previous=="-"){ + if(File.Exists(live)) + throw new IOException("Rolled-back new component unexpectedly remains: "+row.Rel); + }else if(!File.Exists(live) || UpgradeHash(live)!=row.Previous){ + throw new IOException("Rolled-back previous SHA mismatch: "+row.Rel); + } + } + } + static void RecoverUpgradeStageIfAny() { FinishRetiredUpgradeStageIfAny(); @@ -1790,6 +1823,14 @@ static void RecoverUpgradeStageIfAny() } if(phase=="COMMITTING"){ RestoreInterruptedUpgrade(rows); + WritePhase("ROLLED_BACK"); + CleanOwnedStage(rows);return; + } + if(phase=="ROLLED_BACK"){ + // A previous rollback already succeeded; its old snapshots may + // have been removed before process death. Verify the exact live + // old version rather than attempting to consume them again. + VerifyRolledBackUpgrade(rows); CleanOwnedStage(rows);return; } if(phase=="COMMITTED"){ From ddb734c8a9605e6b4a17998fdc86e467a0949a47 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 15:20:16 +0330 Subject: [PATCH 035/135] test: capture exact child recovery exception on R301 A/B (R301-F01) --- build/Test-R301-RolledBackRestart-Native.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/build/Test-R301-RolledBackRestart-Native.ps1 b/build/Test-R301-RolledBackRestart-Native.ps1 index f5b36844..afae564e 100644 --- a/build/Test-R301-RolledBackRestart-Native.ps1 +++ b/build/Test-R301-RolledBackRestart-Native.ps1 @@ -36,6 +36,8 @@ function Start-ProofChild([string]$code){ $psi.Arguments='-NoProfile -NonInteractive -EncodedCommand '+$encoded $psi.UseShellExecute=$false $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true return [Diagnostics.Process]::Start($psi) } $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' @@ -175,7 +177,9 @@ $retry=Start-ProofChild $retryCode if(!$retry){throw 'R301_RESTART_WORKER_NOT_STARTED'} try{ if(!$retry.WaitForExit(18000) -or !$retry.HasExited -or $retry.ExitCode -ne 0){ - throw ('R301_RECOVERY_WORKER_FAILED exit='+$(if($retry.HasExited){$retry.ExitCode}else{'RUNNING'})) + $errorText=if($retry.HasExited){$retry.StandardError.ReadToEnd()}else{'CHILD_TIMEOUT'} + if($errorText.Length -gt 2500){$errorText=$errorText.Substring(0,2500)} + throw ('R301_RECOVERY_WORKER_FAILED exit='+$(if($retry.HasExited){$retry.ExitCode}else{'RUNNING'})+' stderr='+$errorText) } }finally{ if(!$retry.HasExited){$retry.Kill();[void]$retry.WaitForExit(10000)} From 6d0734dbd0d13bb6b2e35cf7301f217483f7ae2f Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 15:24:25 +0330 Subject: [PATCH 036/135] test: use dash for missing previous files in rollback journal (R301-F02) --- build/Test-R301-RolledBackRestart-Native.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/Test-R301-RolledBackRestart-Native.ps1 b/build/Test-R301-RolledBackRestart-Native.ps1 index afae564e..80126edd 100644 --- a/build/Test-R301-RolledBackRestart-Native.ps1 +++ b/build/Test-R301-RolledBackRestart-Native.ps1 @@ -88,6 +88,8 @@ foreach($item in $targets){ $rel=[string]$field.GetValue($item) $full=Join-Path $app ($rel.Replace('/',[IO.Path]::DirectorySeparatorChar)) $previous=Hash $full + if($previous -ceq 'MISSING'){$previous='-'} + elseif($previous -cnotmatch '^[0-9A-F]{64}$'){throw 'R301_BAD_HASH_GENERATED'} # Existing source hashes are real, new-only files are represented by '-'. $rows.Add($rel+'|'+$previous+'|-') } From 8f194fe8db7019eb72e6091860420496d3242599 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 15:40:04 +0330 Subject: [PATCH 037/135] test(setup): kill exact candidate during stage cleanup on disposable Windows (R302) --- ...it-r286-optional-audio-native-recovery.yml | 27 ++ ...02-ActualSetupStageCleanupCrash-Native.ps1 | 287 ++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index ba3d3a6e..cb3314d6 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -397,6 +397,32 @@ jobs: $r.NoJournalResidue -ne $true -or @($r.Checks).Count -lt 8){throw 'R301_TYPED_NATIVE_RECEIPT_INVALID'} 'R301_REAL_TEST_WORKER_KILL_AND_RESTART_RECOVERY_PASS' + - name: R302 actually terminate original Setup during stage cleanup and resume safely + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R302_NATIVE_PS51_TEST_PARSE_FAILED'} + ./build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $p=Join-Path $env:RUNNER_TEMP ('R302_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json') + if(!(Test-Path -LiteralPath $p)){throw 'R302_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R302' -or + $r.Status -cne 'PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.UnmodifiedCandidateSetupExecuted -ne $true -or + $r.ActualSetupProcessTerminated -ne $true -or + $r.RecoveredBySecondActualSetupProcess -ne $true -or + $r.MainSetupConfigStateAndRegistryPreserved -ne $true -or + $r.NoStageOrRetiredResidue -ne $true -or + $r.ActualSetupPid -le 0 -or $r.SecondSetupPid -le 0 -or + $r.ActualSetupPid -eq $r.SecondSetupPid -or + $r.JournalFilesAfterKill -lt 1 -or + @($r.Tests).Count -lt 5){throw 'R302_NATIVE_ACCEPTANCE_FAILED'} + 'R302_EXACT_NATIVE_SETUP_PROCESS_KILL_AND_RECOVERY_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -418,3 +444,4 @@ jobs: ${{ runner.temp }}/R299_${{ matrix.host }}_${{ github.run_id }}/R299_NONRECURSIVE_CLEANUP_RECEIPT.json ${{ runner.temp }}/R300_${{ matrix.host }}_${{ github.run_id }}/R300_RETIRED_JOURNAL_RECEIPT.json ${{ runner.temp }}/R301_${{ matrix.host }}_${{ github.run_id }}/R301_ROLLBACK_RETRY_RECEIPT.json + ${{ runner.temp }}/R302_${{ matrix.host }}_${{ github.run_id }}/R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json diff --git a/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 new file mode 100644 index 00000000..844a51f7 --- /dev/null +++ b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 @@ -0,0 +1,287 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +# User's running desktop must never receive staged files or process kills. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R302_ONLY_FRESH_GITHUB_HOSTED_WINDOWS_GUEST' +} + +function SHA([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +$repo='GOD13emad/TaskbarMonitorEnhanced' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$stage=Join-Path $app '.tbme-upgrade' +$retired=Join-Path $app '.tbme-upgrade-retired' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R301_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R301_ROLLBACK_RETRY_RECEIPT.json') +$root=Join-Path $env:RUNNER_TEMP ('R302_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json' +$registry='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$program=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$backupConfig=Join-Path $app 'config.json.bak' +$userState=Join-Path $app 'install_state.json' +$setupInstalled=Join-Path $app 'Uninstall.exe' +$native=$null;$retry=$null;$watcher=$null +$phase='INITIAL';$accepted=$false;$failure='' +$checks=[Collections.Generic.List[string]]::new() +if((Test-Path -LiteralPath $stage) -or (Test-Path -LiteralPath $retired) -or + (Test-Path -LiteralPath $root) -or + @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -ne 0){ + throw 'R302_GUEST_HAS_UNEXPECTED_JOURNAL_OR_RUNNING_MAIN' +} +[void][IO.Directory]::CreateDirectory($root) +$source=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($source.Commit -cne $env:GITHUB_SHA -or + $source.SetupSHA256 -cne (SHA $setup) -or + $source.MainSHA256 -cne (SHA $program) -or + $previous.Host -cne $HostLabel -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Status -cne 'PASS_ROLLED_BACK_JOURNAL_RETRY_REAL_WORKER_TERMINATION'){ + throw 'R302_SOURCE_OR_PREDECESSOR_RECEIPT_UNVERIFIED' +} +$before=[ordered]@{} +foreach($file in @($program,$config,$backupConfig,$userState,$setupInstalled)){ + $before[$file]=SHA $file +} +if($before[$setupInstalled] -cne $source.SetupSHA256){ + throw 'R302_INSTALLED_GUEST_SETUP_NOT_EXACT_CANDIDATE' +} +$display=[string](Get-ItemProperty -LiteralPath $registry -ErrorAction Stop).DisplayVersion +if($display -cne '1.7.8'){throw 'R302_GUEST_INSTALLED_VERSION_NOT_MATCHED'} +$checks.Add('GUEST_MAIN_SETUP_CONFIG_STATE_AND_REGISTRY_PINNED') + +# .NET Framework 4.8 compatibility: callback kills exactly the Process object +# started below. No process-name search and no host-wide/process-tree kill. +$sourceCode=@' +using System; +using System.IO; +using System.Diagnostics; +using System.Threading; +public sealed class R302RealSetupDeleteWatcher : IDisposable { + public readonly ManualResetEventSlim TerminatedEvent = new ManualResetEventSlim(false); + private FileSystemWatcher oldWatcher, newWatcher; + private Process child; + private int once, notifications; + public string TriggerFile = ""; + public string Failure = ""; + public bool Killed = false; + public int Notifications { get { return notifications; } } + public void Arm(string oldPath, string newPath) { + oldWatcher = Make(oldPath); + newWatcher = Make(newPath); + oldWatcher.EnableRaisingEvents=true; + newWatcher.EnableRaisingEvents=true; + } + private FileSystemWatcher Make(string path) { + FileSystemWatcher watcher = new FileSystemWatcher(path); + watcher.IncludeSubdirectories=true; + watcher.Filter="*"; + watcher.NotifyFilter=NotifyFilters.FileName | NotifyFilters.DirectoryName; + watcher.InternalBufferSize=32768; + watcher.Deleted += OnDeleted; + return watcher; + } + public void Bind(Process p) { + Interlocked.Exchange(ref child, p); + } + private void OnDeleted(object sender, FileSystemEventArgs e) { + Interlocked.Increment(ref notifications); + string path=e.FullPath; + string extension=Path.GetExtension(path).ToLowerInvariant(); + // A known leaf deletion, not a directory deletion or metadata preflight. + if(extension!=".exe" && extension!=".cs" && extension!=".md" && + extension!=".json" && extension!=".config" && extension!=".ico") return; + if(Interlocked.CompareExchange(ref once,1,0)!=0) return; + TriggerFile=Path.GetFileName(path); + try { + Process process=child; + if(process==null) throw new Exception("PROCESS_NOT_BOUND"); + process.Refresh(); + if(process.HasExited) throw new Exception("SETUP_ALREADY_EXITED"); + process.Kill(); + Killed=process.WaitForExit(10000) && process.HasExited && process.ExitCode!=0; + if(!Killed) Failure="SETUP_KILL_NOT_CONFIRMED"; + }catch(Exception error){Failure=error.GetType().Name+":"+error.Message;} + finally{TerminatedEvent.Set();} + } + public void Dispose(){ + if(oldWatcher!=null){oldWatcher.EnableRaisingEvents=false;oldWatcher.Dispose();} + if(newWatcher!=null){newWatcher.EnableRaisingEvents=false;newWatcher.Dispose();} + } +} +'@ +Add-Type -TypeDefinition $sourceCode -ErrorAction Stop + +# Build a genuine ROLLED_BACK journal; hashes are from the live disposable +# installed guest. Stage payload files are disposable and exactly named by +# the candidate's compiled inventory, never unknown owner data. +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$static=[Reflection.BindingFlags]'Static,NonPublic' +$instance=[Reflection.BindingFlags]'Instance,NonPublic,Public' +$field=$type.GetField('UpgradeTargets',$static) +if(!$field){throw 'R302_COMPILED_TARGET_INVENTORY_MISSING'} +$targets=[array]$field.GetValue($null) +if($targets.Count -lt 25 -or $targets.Count -gt 40){throw 'R302_UNEXPECTED_TARGET_COUNT'} +$lines=[Collections.Generic.List[string]]::new() +$staged=0 +$old=Join-Path $stage 'old' +$new=Join-Path $stage 'new' +[void][IO.Directory]::CreateDirectory($old) +[void][IO.Directory]::CreateDirectory($new) +foreach($target in $targets){ + $relField=$target.GetType().GetField('Rel',$instance) + if(!$relField){throw 'R302_TARGET_REL_FIELD_MISSING'} + $rel=[string]$relField.GetValue($target) + if(!$rel -or $rel -match '(^|[/\\])\.\.([/\\]|$)' -or + [IO.Path]::IsPathRooted($rel) -or $rel -match '[:*?"<>|]'){ + throw 'R302_UNSAFE_COMPILED_TARGET' + } + $live=Join-Path $app ($rel.Replace('/',[IO.Path]::DirectorySeparatorChar)) + $prev=SHA $live + if($prev -ceq 'MISSING'){$prev='-'} + if($prev -cne '-' -and $prev -cnotmatch '^[A-F0-9]{64}$'){ + throw 'R302_BAD_PINNED_PREVIOUS_DIGEST' + } + $lines.Add($rel+'|'+$prev+'|-') + foreach($part in @($old,$new)){ + $name=Join-Path $part ($rel.Replace('/',[IO.Path]::DirectorySeparatorChar)) + $parent=[IO.Path]::GetDirectoryName($name) + [void][IO.Directory]::CreateDirectory($parent) + [IO.File]::WriteAllText($name,('R302_STAGE_ONLY_'+$HostLabel+'_'+$rel),[Text.UTF8Encoding]::new($false)) + $staged++ + } +} +if($staged -ne ($targets.Count*2)){throw 'R302_STAGE_FIXTURE_COUNT_INCORRECT'} +$marker='TBME_UPGRADE_R92_OWNED_STAGE_V1' +[IO.File]::WriteAllText((Join-Path $stage 'owner.txt'),$marker,[Text.Encoding]::ASCII) +[IO.File]::WriteAllText((Join-Path $stage 'index.txt'), + ($lines.ToArray() -join ([string][char]10)),[Text.Encoding]::ASCII) +[IO.File]::WriteAllText((Join-Path $stage 'previous_version.txt'),'1.7.8',[Text.Encoding]::ASCII) +[IO.File]::WriteAllText((Join-Path $stage 'phase.txt'),($marker+'|ROLLED_BACK'),[Text.Encoding]::ASCII) +$checks.Add('COMPLETE_PINNED_ROLLED_BACK_JOURNAL_WITH_KNOWN_STAGE_PAYLOADS') + +try { + $phase='START_EXACT_ORIGINAL_SETUP_AND_ARM_DELETE_WATCH' + $watcher=[R302RealSetupDeleteWatcher]::new() + $watcher.Arm($old,$new) + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setup + $psi.Arguments='/update /quiet' + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.WorkingDirectory=[IO.Path]::GetDirectoryName($setup) + $native=[Diagnostics.Process]::Start($psi) + if(!$native){throw 'R302_ORIGINAL_SETUP_FAILED_TO_START'} + $realSetupPid=$native.Id + $watcher.Bind($native) + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not $watcher.TerminatedEvent.Wait(75) -and $clock.ElapsedMilliseconds -lt 45000){ + $native.Refresh() + if($native.HasExited){break} + } + $native.Refresh() + if(!$watcher.Killed){ + throw ('R302_SETUP_NOT_KILLED_ON_A_KNOWN_STAGE_FILE_DELETE; cause='+ + $watcher.Failure+' events='+$watcher.Notifications+ + ' setupExited='+$native.HasExited) + } + if(!$native.HasExited -or $native.ExitCode -eq 0){ + throw 'R302_ACTUAL_SETUP_PID_TERMINATION_NOT_PROVEN' + } + $checks.Add('REAL_ORIGINAL_SETUP_PID_KILLED_BY_NATIVE_FILE_DELETION_EVENT') + $activeRemaining=@(Get-ChildItem $stage -Recurse -File -ErrorAction SilentlyContinue).Count + $retiredRemaining=@(Get-ChildItem $retired -Recurse -File -ErrorAction SilentlyContinue).Count + if(($activeRemaining+$retiredRemaining) -le 0 -or + (!(Test-Path $stage) -and !(Test-Path $retired))){ + throw 'R302_NO_PARTIAL_JOURNAL_WITNESS_AFTER_SETUP_KILL' + } + $checks.Add('SOME_RECOVERABLE_STAGE_METADATA_SURVIVES_ACTUAL_SETUP_KILL') + $phase='COLD_RETRY_EXACT_ORIGINAL_SETUP' + $retry=[Diagnostics.Process]::Start($psi) + if(!$retry){throw 'R302_COLD_RESTART_SETUP_NOT_LAUNCHED'} + $retryPid=$retry.Id + if(!$retry.WaitForExit(90000)){ + $retry.Kill();[void]$retry.WaitForExit(9000) + throw 'R302_COLD_RETRY_EXCEEDED_BOUNDED_BUDGET' + } + if($retry.ExitCode -ne 0){ + throw ('R302_NATIVE_SETUP_RECOVERY_NONZERO_'+$retry.ExitCode) + } + if((Test-Path $stage) -or (Test-Path $retired)){ + throw 'R302_RECOVERY_DID_NOT_CLEAR_BOTH_JOURNAL_NAMES' + } + $checks.Add('EXACT_SAME_BINARY_RESTART_RETIRES_PARTIAL_JOURNAL') + foreach($path in $before.Keys){ + if((SHA $path) -cne $before[$path]){ + throw ('R302_GUEST_DATA_BINARY_OR_SETTING_CHANGED_'+$path) + } + } + if([string](Get-ItemProperty -LiteralPath $registry).DisplayVersion -cne $display){ + throw 'R302_HKCU_INSTALLED_VERSION_CHANGED' + } + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -ne 0){ + throw 'R302_UNEXPECTED_BACKGROUND_APP_STARTED' + } + $checks.Add('BYTE_EXACT_SETUP_MAIN_USER_SETTINGS_AND_REGISTRY_PRESERVED') + $data=[ordered]@{ + Schema=1;Revision='R302';Status='PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;UnmodifiedCandidateSetupExecuted=$true + ActualSetupPid=$realSetupPid;ActualSetupProcessTerminated=$true + DeletedStageFileTrigger=$watcher.TriggerFile + FileDeletionNotifications=$watcher.Notifications + JournalFilesAfterKill=($activeRemaining+$retiredRemaining) + RecoveredBySecondActualSetupProcess=$true;SecondSetupPid=$retryPid + MainSetupConfigStateAndRegistryPreserved=$true + NoStageOrRetiredResidue=$true + CurrentVersion='1.7.8';Tests=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $accepted=$true + 'R302_'+$HostLabel+'_EXACT_UNMODIFIED_SETUP_KILL_AND_RESTART_PASS' +}catch{ + $failure=$_.Exception.ToString() + throw +}finally{ + if($watcher){$watcher.Dispose()} + foreach($process in @($native,$retry)){ + if($null -ne $process){ + try{ + $process.Refresh() + if(!$process.HasExited){ + $process.Kill() + [void]$process.WaitForExit(10000) + } + }finally{$process.Dispose()} + } + } + if(!$accepted){ + $bad=[ordered]@{ + Schema=1;Revision='R302';Status='FAIL_NOT_ACCEPTED' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Phase=$phase;Failure=$failure + OriginalOwnerComputerTouched=$false + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($bad|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + } +} From 5d742cdfc3b658d2dd1f5204f26b8fd744cb8566 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 16:26:58 +0330 Subject: [PATCH 038/135] security: persist protected sensor SID owner before mutating shared task (R303) --- ...it-r286-optional-audio-native-recovery.yml | 27 ++ .../Test-R303-ProtectedSensorOwner-Native.ps1 | 235 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 37 ++- 3 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 build/Test-R303-ProtectedSensorOwner-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index cb3314d6..249e48f6 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -423,6 +423,32 @@ jobs: $r.JournalFilesAfterKill -lt 1 -or @($r.Tests).Count -lt 5){throw 'R302_NATIVE_ACCEPTANCE_FAILED'} 'R302_EXACT_NATIVE_SETUP_PROCESS_KILL_AND_RECOVERY_PASS' + - name: R303 reject foreign protected broker owners and permit same-user crash repair + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R303-ProtectedSensorOwner-Native.ps1).Path + $tokens=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tokens,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object {$_.Message};throw 'R303_NATIVE_PS51_TEST_PARSE_INVALID'} + ./build/Test-R303-ProtectedSensorOwner-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R303_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R303_PROTECTED_SENSOR_OWNER_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R303_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R303' -or + $r.Status -cne 'PASS_PROTECTED_SENSOR_OWNER_MARKER_REPAIR_GUARD' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.PEEmbeddedSourceExact -ne $true -or + $r.RealDisposableWriterKilledAfterMarkerFlush -ne $true -or + $r.ActualElevatedHelperProcessKilled -ne $false -or + $r.ActualProtectedServicesChanged -ne $false -or + $r.MissingOrForeignOwnerRefused -ne $true -or + $r.SameOwnerWithoutTaskAccepted -ne $true -or + $r.ActualJunctionRefused -ne $true -or + $r.MainAndUserDataPreserved -ne $true -or + @($r.Checks).Count -lt 14){throw 'R303_NATIVE_OWNER_MARKER_RECEIPT_INVALID'} + 'R303_SAME_ACCOUNT_PARTIAL_PROTECTED_SENSOR_REPAIR_ALLOWED=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -445,3 +471,4 @@ jobs: ${{ runner.temp }}/R300_${{ matrix.host }}_${{ github.run_id }}/R300_RETIRED_JOURNAL_RECEIPT.json ${{ runner.temp }}/R301_${{ matrix.host }}_${{ github.run_id }}/R301_ROLLBACK_RETRY_RECEIPT.json ${{ runner.temp }}/R302_${{ matrix.host }}_${{ github.run_id }}/R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json + ${{ runner.temp }}/R303_${{ matrix.host }}_${{ github.run_id }}/R303_PROTECTED_SENSOR_OWNER_RECEIPT.json diff --git a/build/Test-R303-ProtectedSensorOwner-Native.ps1 b/build/Test-R303-ProtectedSensorOwner-Native.ps1 new file mode 100644 index 00000000..c3a37533 --- /dev/null +++ b/build/Test-R303-ProtectedSensorOwner-Native.ps1 @@ -0,0 +1,235 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R303_ONLY_DISPOSABLE_WINDOWS' +} +function SHA([string]$p){ + if(!(Test-Path -LiteralPath $p -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$root=Join-Path $env:RUNNER_TEMP ('R303_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R303_PROTECTED_SENSOR_OWNER_RECEIPT.json' +$childFile=Join-Path $root 'guard-child.ps1' +$broker=Join-Path $root 'simulated-protected-broker' +$markerFile=Join-Path $broker '.tbme-owner.sid' +$external=Join-Path $root 'external-canary' +$canary=Join-Path $external 'untouched.txt' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R302_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R303_TEST_ROOT_PREEXISTS'} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or + $candidate.SetupSHA256 -cne (SHA $setup) -or + $candidate.MainSHA256 -cne (SHA $main) -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED'){ + throw 'R303_UNTRUSTED_CANDIDATE_OR_PREDECESSOR_NATIVE_RECEIPT' +} +$before=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$sid=[string][Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R303_NOT_INTERACTIVE_SID'} +if(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue){ + throw 'R303_GUEST_ALREADY_HAS_SENSOR_TASK' +} +if(@(Get-Process TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count -gt 0){ + throw 'R303_GUEST_ALREADY_HAS_SENSOR_PROCESS' +} +[void][IO.Directory]::CreateDirectory($root) +[void][IO.Directory]::CreateDirectory($external) +[IO.File]::WriteAllText($canary,'R303_EXTERNAL_CANARY_PRESERVE',[Text.UTF8Encoding]::new($false)) +$canarySHA=SHA $canary +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$raw=[IO.File]::ReadAllText($helper) +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R303_PRODUCTION_HELPER_PS51_PARSE_FAILED'} +$defs=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Assert-OrRecordSensorOwner') +},$true)) +if($defs.Count -ne 2){throw 'R303_EXPECTED_TWO_REAL_GUARD_FUNCTIONS'} +$begin='# R264-SENSOR-GUARD-BOUNDARY-BEGIN' +$end='# R264-SENSOR-GUARD-BOUNDARY-END' +$i=$raw.IndexOf($begin,[StringComparison]::Ordinal) +$j=$raw.IndexOf($end,[StringComparison]::Ordinal) +if($i -lt 0 -or $j -le $i -or + $raw.IndexOf($begin,$i+1,[StringComparison]::Ordinal) -ge 0 -or + $raw.IndexOf($end,$j+1,[StringComparison]::Ordinal) -ge 0){ + throw 'R303_PRODUCTION_SID_GUARD_SPAN_NOT_UNIQUE' +} +$guard=$raw.Substring($i,$j+$end.Length-$i) +$install=$raw.IndexOf('$taskInstalled=$false',[StringComparison]::Ordinal) +$markerCall=$raw.IndexOf(' Assert-OrRecordSensorOwner',$install,[StringComparison]::Ordinal) +$stop=$raw.IndexOf(' Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue |',$install,[StringComparison]::Ordinal) +if($install -lt 0 -or $markerCall -le $install -or $stop -le $markerCall){ + throw 'R303_OWNER_MARKER_MUST_BE_PERSISTED_PRE_MUTATION' +} +$checks.Add('EXACT_SOURCE_MARKER_BEFORE_PROTECTED_TASK_OR_PROCESS_MUTATION') +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$stream=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R303_EMBEDDED_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8,$true) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$normalize={param([string]$s) return $s.Replace([string][char]13+[string][char]10,[string][char]10).Replace([string][char]13,[string][char]10)} +if((& $normalize $raw) -cne (& $normalize $embedded)){ + throw 'R303_REVIEW_HELPER_AND_PE_EMBEDDED_TEXT_DIFFER' +} +$checks.Add('PE_EMBEDDED_HELPER_EXACT_SOURCE_MATCH') +$header=@' +[CmdletBinding()] +param([string]$Scenario,[string]$Broker,[string]$Result) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$BrokerRoot=$Broker +$SensorOwnerMarker=Join-Path $BrokerRoot '.tbme-owner.sid' +$validatedSensorSid=[string][Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$UserId=[string][Security.Principal.WindowsIdentity]::GetCurrent().Name +$TaskName='TaskbarMonitorEnhanced Sensor Broker' +$Mode='Install' +$ActiveArchitecture='UNKNOWN' +function Write-Log([string]$value){} +function Write-Result([string]$Status){ + [IO.File]::WriteAllText($Result,$Status,[Text.Encoding]::ASCII) +} +'@ +$beforeGuard=@' +if($Scenario -ceq 'Record'){ + Assert-OrRecordSensorOwner + [IO.File]::WriteAllText($Result,'MARKER_DURABLE',[Text.Encoding]::ASCII) + Start-Sleep -Seconds 30 +}elseif($Scenario -ceq 'Guard'){ +'@ +$afterGuard=@' + [IO.File]::WriteAllText($Result,'GUARD_ACCEPTED_SAME_OWNER',[Text.Encoding]::ASCII) +}else{throw 'R303_TEST_SCENARIO_UNSUPPORTED'} +'@ +$child=$header+[Environment]::NewLine+ + (($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)+[Environment]::NewLine+ + $beforeGuard+[Environment]::NewLine+ + $guard+[Environment]::NewLine+ + $afterGuard +[IO.File]::WriteAllText($childFile,$child,[Text.UTF8Encoding]::new($false)) +$tk=$null;$err=$null +[void][Management.Automation.Language.Parser]::ParseFile($childFile,[ref]$tk,[ref]$err) +if(@($err).Count){throw 'R303_SCOPED_CHILD_SCRIPT_PARSE_FAILED'} +function Start-Child([string]$scenario,[string]$folder,[string]$result){ + $ps=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $info=[Diagnostics.ProcessStartInfo]::new() + $info.FileName=$ps + $info.Arguments='-NoProfile -NonInteractive -File "'+$childFile+'" -Scenario '+$scenario+' -Broker "'+$folder+'" -Result "'+$result+'"' + $info.UseShellExecute=$false + $info.CreateNoWindow=$true + $info.RedirectStandardError=$true + $info.RedirectStandardOutput=$true + return [Diagnostics.Process]::Start($info) +} +function Expect-Guard([string]$name,[string]$folder,[string]$expected){ + $result=Join-Path $root ($name+'.txt') + if(Test-Path -LiteralPath $result){throw 'R303_DUPLICATE_SCENARIO'} + $proc=Start-Child 'Guard' $folder $result + try{ + if(!$proc.WaitForExit(18000)){throw ('R303_GUARD_TIMED_OUT_'+$name)} + if($proc.ExitCode -ne 0){throw ('R303_GUARD_FAILED_'+$name+' '+$proc.StandardError.ReadToEnd())} + if(!(Test-Path -LiteralPath $result)){throw ('R303_MISSING_GUARD_RECEIPT_'+$name)} + $value=[IO.File]::ReadAllText($result,[Text.Encoding]::ASCII) + if($value -cne $expected){ + throw ('R303_WRONG_GUARD_'+$name+' EXPECTED='+$expected+' ACTUAL='+$value) + } + $script:checks.Add($name) + }finally{ + if(!$proc.HasExited){$proc.Kill();[void]$proc.WaitForExit(10000)} + $proc.Dispose() + } +} +$ready=Join-Path $root 'record-ready.txt' +$producer=Start-Child 'Record' $broker $ready +$watch=[Diagnostics.Stopwatch]::StartNew() +try{ + while(!(Test-Path -LiteralPath $ready) -and !$producer.HasExited -and $watch.ElapsedMilliseconds -lt 16000){ + Start-Sleep -Milliseconds 80 + } + $producer.Refresh() + if(!(Test-Path -LiteralPath $ready) -or $producer.HasExited -or + [IO.File]::ReadAllText($ready) -cne 'MARKER_DURABLE' -or + [IO.File]::ReadAllText($markerFile,[Text.Encoding]::ASCII) -cne $sid){ + throw ('R303_REAL_WRITER_DID_NOT_FLUSH_MARKER '+$(if($producer.HasExited){$producer.StandardError.ReadToEnd()}else{'TIMEOUT'})) + } + $producer.Kill() + if(!$producer.WaitForExit(10000) -or !$producer.HasExited -or $producer.ExitCode -eq 0){ + throw 'R303_TEST_WRITER_KILL_NOT_PROVEN' + } +}finally{ + if(!$producer.HasExited){$producer.Kill();[void]$producer.WaitForExit(10000)} + $producer.Dispose() +} +$checks.Add('EXCLUSIVE_OWNER_MARKER_FLUSH_BEFORE_REAL_TEST_PROCESS_KILL') +$oldSHA=SHA $markerFile +Expect-Guard 'REPAIR_CURRENT_OWNER_AFTER_PROCESS_DEATH' $broker 'GUARD_ACCEPTED_SAME_OWNER' +if((SHA $markerFile) -cne $oldSHA){throw 'R303_REPAIR_CHANGED_VALID_OWNER_MARKER'} +$checks.Add('SAME_OWNER_PROTECTED_MARKER_IMMUTABLE_ON_RETRY') +[IO.File]::Delete($markerFile) +Expect-Guard 'ROOT_WITH_MISSING_OWNER_REFUSED' $broker 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' +if(Test-Path -LiteralPath $markerFile){throw 'R303_MISSING_OWNER_WAS_CREATED_DURING_REFUSAL'} +$checks.Add('MISSING_OWNER_REFUSAL_NO_MUTATION') +[IO.File]::WriteAllText($markerFile,'S-1-5-21-0-0-0-1',[Text.Encoding]::ASCII) +Expect-Guard 'FOREIGN_OWNER_SID_REFUSED' $broker 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' +$checks.Add('OTHER_SID_REFUSAL_NO_MUTATION') +[IO.File]::WriteAllText($markerFile,$sid,[Text.Encoding]::ASCII) +Expect-Guard 'CORRECT_SID_ACCEPTED_AGAIN' $broker 'GUARD_ACCEPTED_SAME_OWNER' +$checks.Add('EXACT_SID_VALIDATION_REUSABLE') +$priorBroker=Join-Path $root 'prior-owned-root' +[IO.Directory]::Move($broker,$priorBroker) +New-Item -ItemType Junction -Path $broker -Target $external -ErrorAction Stop|Out-Null +if(-not ((Get-Item -LiteralPath $broker -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R303_NATIVE_JUNCTION_NOT_CREATED' +} +Expect-Guard 'PARENT_JUNCTION_REFUSED' $broker 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' +if((SHA $canary) -cne $canarySHA){throw 'R303_EXTERNAL_CANARY_MODIFIED'} +[IO.Directory]::Delete($broker) +if(Test-Path -LiteralPath $broker){throw 'R303_JUNCTION_LINK_RESIDUE'} +[IO.Directory]::Move($priorBroker,$broker) +$checks.Add('REAL_WINDOWS_JUNCTION_REJECTED_EXTERNAL_BYTES_UNTOUCHED') +if((SHA $main) -cne $before.Main -or (SHA $config) -cne $before.Config -or + (SHA $state) -cne $before.State -or + (SHA $canary) -cne $canarySHA -or + [IO.File]::ReadAllText($markerFile,[Text.Encoding]::ASCII) -cne $sid){ + throw 'R303_GUEST_PROTECTED_USER_BYTES_CHANGED' +} +$checks.Add('GUEST_MAIN_CONFIG_STATE_AND_EXTERNAL_CANARY_PRESERVED') +$result=[ordered]@{ + Schema=1;Revision='R303';Status='PASS_PROTECTED_SENSOR_OWNER_MARKER_REPAIR_GUARD' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false + PEEmbeddedSourceExact=$true + RealDisposableWriterKilledAfterMarkerFlush=$true + ActualElevatedHelperProcessKilled=$false + ActualProtectedServicesChanged=$false + MissingOrForeignOwnerRefused=$true + SameOwnerWithoutTaskAccepted=$true + ActualJunctionRefused=$true + MainAndUserDataPreserved=$true + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R303_NATIVE_'+$HostLabel+'_PROTECTED_OWNER_RETRY_PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 06ee782e..40e304de 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -113,6 +113,7 @@ $validatedSensorSid=Assert-SensorPrivilegedBoundary $Mode $PayloadDir $AppRoot $ $TaskName='TaskbarMonitorEnhanced Sensor Broker' $BrokerRoot=Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker' +$SensorOwnerMarker=Join-Path $BrokerRoot '.tbme-owner.sid' $LogsRoot=Join-Path $AppRoot 'Logs' $ResultPath=Join-Path $LogsRoot 'sensor_install_result.json' $LogPath=Join-Path $LogsRoot 'sensor_install.log' @@ -332,7 +333,16 @@ try{ Preserve-OtherUserSensorLayer 'ANOTHER_ACCOUNT_OWNS_GLOBAL_SCHEDULED_TASK' } }elseif(Test-Path -LiteralPath $BrokerRoot){ - Preserve-OtherUserSensorLayer 'UNOWNED_SHARED_PROGRAMFILES_BROKER_WITHOUT_TASK' + # R303: an aborted same-user install may leave an existing protected + # BrokerRoot after task removal. Refuse absent/other-user ownership. + Assert-SensorBoundaryNoReparse $SensorOwnerMarker + if(-not (Test-Path -LiteralPath $SensorOwnerMarker -PathType Leaf)){ + Preserve-OtherUserSensorLayer 'UNOWNED_SHARED_PROGRAMFILES_BROKER_WITHOUT_TASK' + } + if([IO.File]::ReadAllText($SensorOwnerMarker,[Text.Encoding]::ASCII).Trim() -cne $expectedUserSid){ + Preserve-OtherUserSensorLayer 'PROTECTED_SENSOR_OWNER_MARKER_MISMATCH' + } + Write-Log 'PARTIAL_PROTECTED_SENSOR_BROKER_SAME_OWNER_ACCEPTED' } $sensorProcesses=@(Get-CimInstance Win32_Process -Filter "Name='TaskbarMonitorSensorBroker.exe' OR Name='TaskbarMonitorSensorSupervisor.exe'" -ErrorAction Stop | Where-Object {$null -ne $_}) @@ -500,6 +510,31 @@ $pawnExit=$null $rebootRequired=$false Write-Log "PAWNIO_ALREADY_PRESENT version=$existingPawn" +# R303: publish protected SID ownership BEFORE unregistering the old task. +# The marker is never accepted from writable Temp/AppData and is exclusive. +function Assert-OrRecordSensorOwner { + Assert-SensorBoundaryNoReparse $BrokerRoot + Assert-SensorBoundaryNoReparse $SensorOwnerMarker + if(Test-Path -LiteralPath $SensorOwnerMarker){ + if([IO.File]::ReadAllText($SensorOwnerMarker,[Text.Encoding]::ASCII).Trim() -cne $validatedSensorSid){ + throw 'PROTECTED_SENSOR_OWNER_MARKER_MISMATCH' + } + return + } + [void][IO.Directory]::CreateDirectory($BrokerRoot) + Assert-SensorBoundaryNoReparse $SensorOwnerMarker + $bytes=[Text.Encoding]::ASCII.GetBytes($validatedSensorSid) + $stream=[IO.FileStream]::new($SensorOwnerMarker,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} + finally{$stream.Dispose()} +} +try{ + Assert-OrRecordSensorOwner +}catch{ + Write-Log ('PROTECTED_SENSOR_OWNER_MARKER_SETUP_REFUSED '+$_.Exception.Message) + Write-Result 'DEGRADED' 'Protected sensor ownership could not be persisted before modification; existing services were preserved.' 'NOT_STARTED' $null $false $false $false + exit 0 +} $taskInstalled=$false try{ Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue | From 24ad9a07a8b92421d0b0f9fd0a833dd02bcd9f24 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 16:29:50 +0330 Subject: [PATCH 039/135] test: validate R303 owner-marker call precedes destructive install (R303-F01) --- build/Test-R303-ProtectedSensorOwner-Native.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/Test-R303-ProtectedSensorOwner-Native.ps1 b/build/Test-R303-ProtectedSensorOwner-Native.ps1 index c3a37533..80024f0c 100644 --- a/build/Test-R303-ProtectedSensorOwner-Native.ps1 +++ b/build/Test-R303-ProtectedSensorOwner-Native.ps1 @@ -79,9 +79,9 @@ if($i -lt 0 -or $j -le $i -or } $guard=$raw.Substring($i,$j+$end.Length-$i) $install=$raw.IndexOf('$taskInstalled=$false',[StringComparison]::Ordinal) -$markerCall=$raw.IndexOf(' Assert-OrRecordSensorOwner',$install,[StringComparison]::Ordinal) +$markerCall=$raw.IndexOf(' Assert-OrRecordSensorOwner',[StringComparison]::Ordinal) $stop=$raw.IndexOf(' Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue |',$install,[StringComparison]::Ordinal) -if($install -lt 0 -or $markerCall -le $install -or $stop -le $markerCall){ +if($markerCall -lt 0 -or $install -le $markerCall -or $stop -le $install){ throw 'R303_OWNER_MARKER_MUST_BE_PERSISTED_PRE_MUTATION' } $checks.Add('EXACT_SOURCE_MARKER_BEFORE_PROTECTED_TASK_OR_PROCESS_MUTATION') From 9f79acaca4efb6dc22afe61013518b533a0f0b5b Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 16:53:07 +0330 Subject: [PATCH 040/135] fix(setup): retain inline trusted helper for long user paths (R304) --- ...it-r286-optional-audio-native-recovery.yml | 27 +++ .../Test-R304-LongInlinePowerShell-Native.ps1 | 173 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 22 ++- 3 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 build/Test-R304-LongInlinePowerShell-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 249e48f6..e19bf113 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -449,6 +449,32 @@ jobs: $r.MainAndUserDataPreserved -ne $true -or @($r.Checks).Count -lt 14){throw 'R303_NATIVE_OWNER_MARKER_RECEIPT_INVALID'} 'R303_SAME_ACCOUNT_PARTIAL_PROTECTED_SENSOR_REPAIR_ALLOWED=PASS' + - name: R304 verify long inline PowerShell command without writable scripts + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R304-LongInlinePowerShell-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R304_PS51_PROBE_SYNTAX_INVALID'} + ./build/Test-R304-LongInlinePowerShell-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $path=Join-Path $env:RUNNER_TEMP ('R304_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json') + if(!(Test-Path -LiteralPath $path)){throw 'R304_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R304' -or + $r.Status -cne 'PASS_LONG_PROFILE_INMEMORY_POWERSHELL_FALLBACK' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.NoUAC -ne $true -or + $r.ExistingEncodedCommandPreserved -ne $true -or + $r.LongProfileFallbackCommandExecuted -ne $true -or + $r.NoUserWritableScriptExecuted -ne $true -or + $r.HostileInputRemainedEncoded -ne $true -or + $r.InstalledUserFilesUnchanged -ne $true -or + $r.EncodedCommandLength -gt 32080 -or + $r.LongFallbackCommandLength -gt 30000 -or + @($r.Checks).Count -lt 9){throw 'R304_NATIVE_GATES_NOT_MET'} + 'R304_NATIVE_SHORT_LONG_HOSTILE_TRANSPORT=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -472,3 +498,4 @@ jobs: ${{ runner.temp }}/R301_${{ matrix.host }}_${{ github.run_id }}/R301_ROLLBACK_RETRY_RECEIPT.json ${{ runner.temp }}/R302_${{ matrix.host }}_${{ github.run_id }}/R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json ${{ runner.temp }}/R303_${{ matrix.host }}_${{ github.run_id }}/R303_PROTECTED_SENSOR_OWNER_RECEIPT.json + ${{ runner.temp }}/R304_${{ matrix.host }}_${{ github.run_id }}/R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json diff --git a/build/Test-R304-LongInlinePowerShell-Native.ps1 b/build/Test-R304-LongInlinePowerShell-Native.ps1 new file mode 100644 index 00000000..82162809 --- /dev/null +++ b/build/Test-R304-LongInlinePowerShell-Native.ps1 @@ -0,0 +1,173 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R304_DISPOSABLE_WINDOWS_ONLY' +} + +function Hash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Expect-FailsClosed([string]$description,[string]$arguments,[string]$reason){ + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$script:trustedHost + $psi.Arguments=$arguments + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + $process=[Diagnostics.Process]::Start($psi) + if(!$process){throw ('R304_PROCESS_NOT_CREATED_'+$description)} + try{ + $stdout=$process.StandardOutput.ReadToEndAsync() + $stderr=$process.StandardError.ReadToEndAsync() + if(!$process.WaitForExit(25000)){ + if(!$process.HasExited){$process.Kill();[void]$process.WaitForExit(9000)} + throw ('R304_POWERSHELL_TEST_TIMEOUT_'+$description) + } + $text=$stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult() + if($process.ExitCode -eq 0 -or $text -notmatch $reason){ + if($text.Length -gt 500){$text=$text.Substring(0,500)} + throw ('R304_EXPECTED_PREWRITE_REFUSAL_MISSING_'+$description+' EXIT='+$process.ExitCode+' OUTPUT='+$text) + } + if(Test-Path -LiteralPath $script:sentinel){ + throw ('R304_ATTACK_SENTINEL_CREATED_'+$description) + } + $script:checks.Add($description) + }finally{$process.Dispose()} +} + +$root=Join-Path $env:RUNNER_TEMP ('R304_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R303_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R303_PROTECTED_SENSOR_OWNER_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R304_PREEXISTING_TEST_ROOT'} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or + (Hash $setup) -cne $candidate.SetupSHA256 -or + (Hash $main) -cne $candidate.MainSHA256 -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_PROTECTED_SENSOR_OWNER_MARKER_REPAIR_GUARD'){ + throw 'R304_SOURCE_OR_PREVIOUS_NATIVE_RECEIPT_NOT_PINNED' +} +$original=@{ Main=(Hash $main);Config=(Hash $config);State=(Hash $state) } +[void][IO.Directory]::CreateDirectory($root) +$script:sentinel=Join-Path $root 'injection-must-not-be-executed.txt' +$script:trustedHost=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +if(-not [IO.File]::Exists($script:trustedHost)){ + throw 'R304_ABSOLUTE_SYSTEM_POWERSHELL_MISSING' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$builder=$type.GetMethod('BuildElevatedHelperCommand',$flags) +$hostMethod=$type.GetMethod('TrustedSystemPowerShellPath',$flags) +if(!$builder -or !$hostMethod){throw 'R304_COMPILED_METHODS_NOT_FOUND'} +$hostSource=[string]$hostMethod.Invoke($null,$null) +if(-not [string]::Equals($hostSource,$script:trustedHost,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R304_SYSTEM_POWER_SHELL_HOST_NOT_PINNED' +} +$checks.Add('TRUSTED_ABSOLUTE_SYSTEM_HOST_PINNED') +function Command([string]$mode,[string]$payload,[string]$application,[string]$user){ + $arguments=[object[]]::new(4) + $arguments[0]=$mode;$arguments[1]=$payload;$arguments[2]=$application;$arguments[3]=$user + return [string]$builder.Invoke($null,$arguments) +} +$user=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) +$normalPayload=Join-Path $env:RUNNER_TEMP ('tbme_setup_'+('a'*32)) +$normalRoot=Join-Path $env:WINDIR 'R304_Expected_PreWrite_Refusal' +$normal=Command Install $normalPayload $normalRoot $user +$encodedPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' +if(-not $normal.StartsWith($encodedPrefix,[StringComparison]::Ordinal) -or + $normal.Length -gt 32080){ + throw 'R304_SHORT_ENCODEDCOMMAND_BACKWARD_COMPAT_FAILED' +} +$normalCode=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($normal.Substring($encodedPrefix.Length))) +$match=[regex]::Match($normalCode,"(?m)^\`$blob=\[Convert\]::FromBase64String\('([A-Za-z0-9+/=]+)'\)") +if(!$match.Success){throw 'R304_PRIOR_PE_HELPER_BLOB_NOT_ENCODED'} +$originalBlob=$match.Groups[1].Value +$checks.Add('SHORT_ENCODEDCOMMAND_AND_PEBLOB_UNCHANGED') +$longName='C:\Users\'+('Q'*128) +$longPayload=$longName+'\AppData\Local\Temp\tbme_setup_'+('b'*32) +$longRoot=$longName+'\AppData\Local\TaskbarMonitorEnhanced' +$fallback=Command Install $longPayload $longRoot $user +$commandPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' +if(-not $fallback.StartsWith($commandPrefix,[StringComparison]::Ordinal) -or + $fallback.Contains(' -File ') -or $fallback.Contains('ExecutionPolicy Bypass') -or + $fallback.Length -gt 30000 -or $fallback.Contains($longRoot) -or + $fallback.Contains($longPayload)){ + throw 'R304_LONG_PROFILE_FALLBACK_USES_UNTRUSTED_FILE_OR_RAW_ARGS' +} +$fallbackScript=$fallback.Substring($commandPrefix.Length) +$fallbackMatch=[regex]::Match($fallbackScript,"(?m)^\`$blob=\[Convert\]::FromBase64String\('([A-Za-z0-9+/=]+)'\)") +if(!$fallbackMatch.Success -or $fallbackMatch.Groups[1].Value -cne $originalBlob -or + -not $fallbackScript.Contains('[ScriptBlock]::Create($code)')){ + throw 'R304_FALLBACK_EMBEDDED_SCRIPT_PROVENANCE_CHANGED' +} +$checks.Add('LONG_PROFILE_COMMAND_FALLBACK_HAS_EXACT_TRUSTED_PE_BLOB') +$malicious=$longPayload+"'; [IO.File]::WriteAllText('"+$script:sentinel+"','INJECTED'); #" +$attack=Command Install $malicious $longRoot $user +if(-not $attack.StartsWith($commandPrefix,[StringComparison]::Ordinal) -or + $attack.Contains($malicious) -or $attack.Contains($script:sentinel)){ + throw 'R304_INJECTION_SYNTAX_ESCAPED_DATA_B64' +} +$checks.Add('INJECTION_TOKENS_ARE_NEVER_RAW_POWERSHELL_CODE') +$limitFailed=$false +try{$null=Command Install ('X'*4100) $normalRoot $user} +catch{ + if($_.Exception.ToString() -match 'missing or too long'){$limitFailed=$true} + else{throw} +} +if(!$limitFailed){throw 'R304_UNBOUNDED_PARAMETER_FAILOPEN'} +$checks.Add('PER_PARAMETER_LENGTH_CAP_PRESERVED') +# The native 5.1 child runs as the current guest user, NEVER with Verb=runas. +# All three invocations must refuse before writing any optional sensor state. +Expect-FailsClosed 'SHORT_EXISTING_ENCODED_COMMAND_REFUSED_PREWRITE' $normal 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +Expect-FailsClosed 'LONG_NEW_COMMAND_REFUSED_PREWRITE' $fallback 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +Expect-FailsClosed 'HOSTILE_LONG_COMMAND_REFUSED_PREWRITE' $attack 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +if((Hash $main) -cne $original.Main -or + (Hash $config) -cne $original.Config -or + (Hash $state) -cne $original.State -or + (Test-Path -LiteralPath $script:sentinel)){ + throw 'R304_INSTALLED_MAIN_SETTINGS_OR_INJECTION_SENTINEL_CHANGED' +} +$checks.Add('GUEST_MAIN_CONFIG_STATE_AND_SENTINEL_UNCHANGED') +$record=[ordered]@{ + Schema=1;Revision='R304' + Status='PASS_LONG_PROFILE_INMEMORY_POWERSHELL_FALLBACK' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;NoUAC=$true + OriginalOwnerComputerTouched=$false + ExistingEncodedCommandPreserved=$true + LongProfileFallbackCommandExecuted=$true + NoUserWritableScriptExecuted=$true + HostileInputRemainedEncoded=$true + InstalledUserFilesUnchanged=$true + EncodedCommandLength=$normal.Length + LongFallbackCommandLength=$fallback.Length + HostileFallbackCommandLength=$attack.Length + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R304_'+$HostLabel+'_SHORT_LONG_HOSTILE_POWERSHELL_TRANSPORT_PASS' diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 2e1a4898..a103f00a 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -105,7 +105,7 @@ static void StopProcess(string name) } // R293: PowerShell code is transported from this immutable, embedded PE resource - // via one in-memory EncodedCommand. Never elevate a .ps1 from writable Temp. + // via an in-memory command. Never elevate a .ps1 from writable Temp. static string Base64Utf8Argument(string value) { if(String.IsNullOrWhiteSpace(value) || value.Length>4096) @@ -174,11 +174,23 @@ static string BuildElevatedHelperCommand(string mode,string payload,string appRo "$user=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+Base64Utf8Argument(user)+"'))\n"+ "& ([ScriptBlock]::Create($code)) -Mode '"+mode+"' -PayloadDir $payload -AppRoot $app -UserId $user -ExpectedBrokerSHA256 '"+brokerHash+"' -ExpectedSupervisorSHA256 '"+supervisorHash+"' -ExpectedBackendZipSHA256 '"+backendZipHash+"'\n"; string encoded=Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); - // Windows CreateProcess has a 32,767-character maximum; leave margin. - // Fail closed instead of falling back to unsafe -File or user-writable scripts. - if(encoded.Length>32000) + // Preserve the established UTF-16 EncodedCommand path when it fits. + // Windows Process creation caps the entire command line at 32,767 chars. + if(encoded.Length<=32000) + return "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; + // R304: the SAME in-memory, PE-embedded trusted helper can be transported + // as a constant PowerShell -Command string for long profile names. + // Variable caller input is only UTF-8/Base64 data; mode is whitelisted + // and resource digests are SHA-256 hex. No user-controlled quote or + // PowerShell tokens can enter this command source. Never use -File. + if(script.IndexOf('"')>=0 || script.IndexOf('\0')>=0) + throw new InvalidDataException("Unexpected quote or null in trusted helper bootstrap."); + string fallback="-NoProfile -NonInteractive -WindowStyle Hidden -Command \"& { "+script+" }\""; + // Leave margin for the executable path and OS quoting. A truly huge + // value must still fail closed instead of truncating or using Temp. + if(fallback.Length>30000) throw new InvalidDataException("Trusted sensor helper command exceeds safe limit."); - return "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; + return fallback; } // R298: runas uses ShellExecuteEx, which searches the current directory, From 5c7464d0f9a1b73eb9f8c2ffcb8fe3685661d59f Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 17:03:57 +0330 Subject: [PATCH 041/135] security: bind elevated sensor helper token SID to requested user (R305) --- ...it-r286-optional-audio-native-recovery.yml | 25 ++++ .../Test-R305-EffectiveUacTokenSid-Native.ps1 | 135 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 8 ++ 3 files changed, 168 insertions(+) create mode 100644 build/Test-R305-EffectiveUacTokenSid-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index e19bf113..2d0352d1 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -475,6 +475,30 @@ jobs: $r.LongFallbackCommandLength -gt 30000 -or @($r.Checks).Count -lt 9){throw 'R304_NATIVE_GATES_NOT_MET'} 'R304_NATIVE_SHORT_LONG_HOSTILE_TRANSPORT=PASS' + - name: R305 require actual privileged process token SID to match target user + shell: powershell + run: | + $ErrorActionPreference='Stop' + $p=(Resolve-Path build/Test-R305-EffectiveUacTokenSid-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($p,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R305_NATIVE_PS51_PARSER_INVALID'} + ./build/Test-R305-EffectiveUacTokenSid-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R305_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R305_ELEVATED_TOKEN_SID_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R305_RAW_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R305' -or + $r.Status -cne 'PASS_EFFECTIVE_PROCESS_TOKEN_SID_BOUND_TO_REQUESTED_USER' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualUACOrProtectedServicesInvoked -ne $false -or + $r.PrivateEmbeddedPEHelperExact -ne $true -or + $r.SameUserIdentityAccepted -ne $true -or + $r.CrossAccountSyntheticSidsRejected -ne $true -or + $r.RejectedBeforeAnyLogOrServiceWrite -ne $true -or + $r.MainConfigStateAndMarkerUnchanged -ne $true -or + @($r.Checks).Count -lt 11){throw 'R305_TYPED_NATIVE_TOKEN_SID_ACCEPTANCE_FAILED'} + 'R305_EFFECTIVE_TOKEN_SID_PREWRITE_BOUNDARY=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -499,3 +523,4 @@ jobs: ${{ runner.temp }}/R302_${{ matrix.host }}_${{ github.run_id }}/R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json ${{ runner.temp }}/R303_${{ matrix.host }}_${{ github.run_id }}/R303_PROTECTED_SENSOR_OWNER_RECEIPT.json ${{ runner.temp }}/R304_${{ matrix.host }}_${{ github.run_id }}/R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json + ${{ runner.temp }}/R305_${{ matrix.host }}_${{ github.run_id }}/R305_ELEVATED_TOKEN_SID_RECEIPT.json diff --git a/build/Test-R305-EffectiveUacTokenSid-Native.ps1 b/build/Test-R305-EffectiveUacTokenSid-Native.ps1 new file mode 100644 index 00000000..f87a1d41 --- /dev/null +++ b/build/Test-R305-EffectiveUacTokenSid-Native.ps1 @@ -0,0 +1,135 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Read-only identity probes; no UAC or shared service/driver mutation. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R305_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +function SHA([string]$f){ + if(!(Test-Path -LiteralPath $f -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash +} +function Reject([string]$label,[string]$mode,[string]$payload,[string]$app,[string]$identity,[string]$expected){ + $observed='' + try{[void](Assert-SensorPrivilegedBoundary $mode $payload $app $identity)} + catch{$observed=$_.Exception.ToString()} + if($observed -notmatch $expected){ + if($observed.Length -gt 500){$observed=$observed.Substring(0,500)} + throw ('R305_UNEXPECTED_REFUSAL_'+$label+' ACTUAL='+$observed) + } + $script:checks.Add($label) +} +$root=Join-Path $env:RUNNER_TEMP ('R305_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R305_ELEVATED_TOKEN_SID_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R304_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json') +$main=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced\TaskbarMonitorEnhanced.exe' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$marker=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) 'TaskbarMonitorEnhanced\SensorBroker\.tbme-owner.sid' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R305_EXACT_RUN_FIXTURE_ROOT_EXISTS'} +$candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($candidate.Commit -cne $env:GITHUB_SHA -or + $candidate.SetupSHA256 -cne (SHA $setup) -or + $candidate.MainSHA256 -cne (SHA $main) -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_LONG_PROFILE_INMEMORY_POWERSHELL_FALLBACK'){ + throw 'R305_EXACT_CANDIDATE_OR_R304_NATIVE_PROOF_NOT_PINNED' +} +[void][IO.Directory]::CreateDirectory($root) +$original=@{ Main=(SHA $main);Config=(SHA $config);State=(SHA $state);Marker=(SHA $marker) } +$source=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$raw=[IO.File]::ReadAllText($source) +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($source,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R305_WINPS51_HELPER_SYNTAX_INVALID'} +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -in @('Assert-SensorPrivilegedBoundary','Assert-SensorBoundaryNoReparse') +},$true)) +if($defs.Count -ne 2){throw 'R305_EXPECTED_TWO_GUARD_AST_NODES'} +$checkAt=$raw.IndexOf("SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH",[StringComparison]::Ordinal) +$profileAt=$raw.IndexOf('$profileKey=',[StringComparison]::Ordinal) +$logsAt=$raw.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) +if($checkAt -lt 0 -or $profileAt -le $checkAt -or $logsAt -le $profileAt){ + throw 'R305_ELEVATED_TOKEN_BOUNDARY_NOT_PREWRITE_OR_UNIQUE' +} +$checks.Add('EFFECTIVE_TOKEN_SID_CHECK_BEFORE_PROFILE_AND_LOG_WRITES') +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$resource=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$resource){throw 'R305_PRIVATE_SETUP_EMBEDDED_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embedded=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $raw) -cne (& $normalize $embedded)){ + throw 'R305_HELPER_SOURCE_NOT_EXACT_SETUP_PE' +} +$checks.Add('EXACT_PE_EMBEDDED_HELPER_CONTAINS_SID_CHECK') +# Evaluate only the two pure security functions from the compiled-source AST. +# Never dot-source or invoke the side-effecting privileged installer. +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$token=[Security.Principal.WindowsIdentity]::GetCurrent() +$actualSid=[string]$token.User.Value +$actualName=[string]$token.Name +if($actualSid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'R305_GUEST_NOT_INTERACTIVE_SID' +} +$temp=Join-Path ([Environment]::GetFolderPath('UserProfile')) 'AppData\Local\Temp' +$payloadInstall=Join-Path $temp ('tbme_setup_'+('a'*32)) +$payloadUninstall=Join-Path $temp ('tbme_uninstall_'+('b'*32)) +if((Assert-SensorPrivilegedBoundary Install $payloadInstall $app $actualSid) -cne $actualSid){ + throw 'R305_SAME_SID_INSTALL_NOT_ACCEPTED' +} +$checks.Add('EXACT_TOKEN_SID_INSTALL_ACCEPTED') +if((Assert-SensorPrivilegedBoundary Uninstall $payloadUninstall $app $actualSid) -cne $actualSid){ + throw 'R305_SAME_SID_UNINSTALL_NOT_ACCEPTED' +} +$checks.Add('EXACT_TOKEN_SID_UNINSTALL_ACCEPTED') +if((Assert-SensorPrivilegedBoundary Install $payloadInstall $app $actualName) -cne $actualSid){ + throw 'R305_CURRENT_ACCOUNT_NTNAME_NOT_RESOLVED_TO_TOKEN_SID' +} +$checks.Add('CURRENT_WINDOWS_ACCOUNT_NAME_RESOLVES_TO_TOKEN_SID') +$foreign='S-1-5-21-12345-23456-34567-45678' +if($foreign -ceq $actualSid){$foreign='S-1-5-21-12345-23456-34567-45679'} +Reject 'SYNTHETIC_FOREIGN_ACCOUNT_INSTALL_PREWRITE_REJECTED' Install $payloadInstall $app $foreign 'SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH' +Reject 'SYNTHETIC_FOREIGN_ACCOUNT_UNINSTALL_PREWRITE_REJECTED' Uninstall $payloadUninstall $app $foreign 'SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH' +Reject 'SYSTEM_SERVICE_SID_NOT_INTERACTIVE' Install $payloadInstall $app 'S-1-5-18' 'SENSOR_BOUNDARY_USER_PROFILE_NOT_INTERACTIVE' +Reject 'UNRESOLVABLE_OTHER_ACCOUNT_REFUSED' Install $payloadInstall $app 'THIS_USER_MUST_NOT_EXIST_R305' 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' +Reject 'REQUESTED_WRONG_APP_ROOT_REFUSED' Install $payloadInstall (Join-Path $env:WINDIR 'R305_REJECT_ROOT') $actualSid 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +if((SHA $main) -cne $original.Main -or (SHA $config) -cne $original.Config -or + (SHA $state) -cne $original.State -or (SHA $marker) -cne $original.Marker){ + throw 'R305_READONLY_NATIVE_TOKEN_TEST_CHANGED_USER_OR_PROTECTED_BYTES' +} +$checks.Add('MAIN_CONFIG_STATE_AND_PROTECTED_OWNER_MARKER_UNTOUCHED') +$receiptValue=[ordered]@{ + Schema=1;Revision='R305' + Status='PASS_EFFECTIVE_PROCESS_TOKEN_SID_BOUND_TO_REQUESTED_USER' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false + ActualUACOrProtectedServicesInvoked=$false + PrivateEmbeddedPEHelperExact=$true + SameUserIdentityAccepted=$true + CrossAccountSyntheticSidsRejected=$true + RejectedBeforeAnyLogOrServiceWrite=$true + MainConfigStateAndMarkerUnchanged=$true + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($receiptValue|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R305_GUEST_'+$HostLabel+'_EFFECTIVE_TOKEN_SID_POLICY=PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 40e304de..e97bb8a2 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -50,6 +50,14 @@ function Assert-SensorPrivilegedBoundary( if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ throw 'SENSOR_BOUNDARY_USER_PROFILE_NOT_INTERACTIVE' } + # R305: runas with a credential prompt can launch as a different admin. + # Fail BEFORE any sensor log/result/ProgramFiles/task mutation if the + # actual elevated process identity is not the intended user's SID. + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH' + } $profileKey='HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\'+$sid $entry=Get-ItemProperty -LiteralPath $profileKey -ErrorAction Stop if([string]::IsNullOrWhiteSpace([string]$entry.ProfileImagePath)){ From 2f2feecf20875658680239696ee442659c0680a7 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 17:08:33 +0330 Subject: [PATCH 042/135] test: accept PE-pinned long hostile inline helper transport (R305-F01) --- .../Test-R293-InMemorySensorHelper-Native.ps1 | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/build/Test-R293-InMemorySensorHelper-Native.ps1 b/build/Test-R293-InMemorySensorHelper-Native.ps1 index b15538e1..06f96f0d 100644 --- a/build/Test-R293-InMemorySensorHelper-Native.ps1 +++ b/build/Test-R293-InMemorySensorHelper-Native.ps1 @@ -143,8 +143,27 @@ try{ $evilUser=$user+"'; [IO.File]::WriteAllText('"+$sentinel+"','R293_INJECTION'); #" $args4[3]=[string]$evilUser $evilCmd=[string]$m.Invoke($null,$args4) - if(-not $evilCmd.StartsWith($prefix,[StringComparison]::Ordinal)){throw 'R293_INJECTED_COMMAND_NOT_ENCODED'} - $evilBootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($evilCmd.Substring($prefix.Length))) + if($evilCmd.StartsWith($prefix,[StringComparison]::Ordinal)){ + $evilBootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($evilCmd.Substring($prefix.Length))) + }else{ + # R305-F01: the added prewrite SID guard grows the trusted helper. + # An adversarial longer user input now legitimately selects R304's + # bounded inline -Command instead of UTF16 -EncodedCommand. + # The exact trusted PE blob, no writable -File and Base64-only user + # arguments are mandatory in EITHER supported transport. + $inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + if(-not $evilCmd.StartsWith($inlinePrefix,[StringComparison]::Ordinal) -or + $evilCmd.Length -gt 30000 -or + $evilCmd.Contains(' -File ') -or + $evilCmd.Contains('ExecutionPolicy Bypass')){ + throw 'R293_INJECTED_COMMAND_UNSAFE_OR_UNBOUNDED' + } + $evilBootstrap=$evilCmd.Substring($inlinePrefix.Length) + if(-not $evilBootstrap.Contains($re.Groups[1].Value) -or + -not $evilBootstrap.Contains('[ScriptBlock]::Create($code)')){ + throw 'R293_LONG_FALLBACK_SOURCE_NOT_PINNED_TO_SAME_PE_RESOURCE' + } + } if($evilBootstrap.Contains($evilUser) -or $evilBootstrap.Contains($sentinel)){ throw 'R293_CALLER_ARGUMENT_WAS_INTERPOLATED_AS_SCRIPT_SOURCE' } From 421bdcfb93b64a6785ff04ae9283a9db948a2c63 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 17:16:43 +0330 Subject: [PATCH 043/135] test: expect fail-closed service SID resolution in R305 native probe (F02) --- build/Test-R305-EffectiveUacTokenSid-Native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-R305-EffectiveUacTokenSid-Native.ps1 b/build/Test-R305-EffectiveUacTokenSid-Native.ps1 index f87a1d41..aa9383cf 100644 --- a/build/Test-R305-EffectiveUacTokenSid-Native.ps1 +++ b/build/Test-R305-EffectiveUacTokenSid-Native.ps1 @@ -109,7 +109,7 @@ $foreign='S-1-5-21-12345-23456-34567-45678' if($foreign -ceq $actualSid){$foreign='S-1-5-21-12345-23456-34567-45679'} Reject 'SYNTHETIC_FOREIGN_ACCOUNT_INSTALL_PREWRITE_REJECTED' Install $payloadInstall $app $foreign 'SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH' Reject 'SYNTHETIC_FOREIGN_ACCOUNT_UNINSTALL_PREWRITE_REJECTED' Uninstall $payloadUninstall $app $foreign 'SENSOR_BOUNDARY_ELEVATED_TOKEN_ACCOUNT_MISMATCH' -Reject 'SYSTEM_SERVICE_SID_NOT_INTERACTIVE' Install $payloadInstall $app 'S-1-5-18' 'SENSOR_BOUNDARY_USER_PROFILE_NOT_INTERACTIVE' +Reject 'SYSTEM_SERVICE_SID_UNRESOLVABLE' Install $payloadInstall $app 'S-1-5-18' 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' Reject 'UNRESOLVABLE_OTHER_ACCOUNT_REFUSED' Install $payloadInstall $app 'THIS_USER_MUST_NOT_EXIST_R305' 'SENSOR_BOUNDARY_USER_ID_UNRESOLVABLE' Reject 'REQUESTED_WRONG_APP_ROOT_REFUSED' Install $payloadInstall (Join-Path $env:WINDIR 'R305_REJECT_ROOT') $actualSid 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' if((SHA $main) -cne $original.Main -or (SHA $config) -cne $original.Config -or From f3517808fd535667c807f0b6d045624663ec685f Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 17:47:41 +0330 Subject: [PATCH 044/135] test: prove protected sensor journal crash-safe recovery in isolated Windows (R306 prototype) --- ...it-r286-optional-audio-native-recovery.yml | 54 ++++ build/Test-R306-DurableRollbackModel.ps1 | 256 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 build/Test-R306-DurableRollbackModel.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 2d0352d1..ba046325 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -499,6 +499,59 @@ jobs: $r.MainConfigStateAndMarkerUnchanged -ne $true -or @($r.Checks).Count -lt 11){throw 'R305_TYPED_NATIVE_TOKEN_SID_ACCEPTANCE_FAILED'} 'R305_EFFECTIVE_TOKEN_SID_PREWRITE_BOUNDARY=PASS' + - name: R306 synthetic protected rollback journal crash and recovery model + shell: powershell + run: | + $ErrorActionPreference='Stop' + $src=(Resolve-Path build/Test-R306-DurableRollbackModel.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($src,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R306_PS51_MODEL_PARSER_INVALID'} + $prior=Join-Path $env:RUNNER_TEMP ('R305_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R305_ELEVATED_TOKEN_SID_RECEIPT.json') + $p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json + if($p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne '${{ matrix.host }}' -or + $p.Status -cne 'PASS_EFFECTIVE_PROCESS_TOKEN_SID_BOUND_TO_REQUESTED_USER'){ + throw 'R306_PRIOR_NATIVE_SID_EVIDENCE_NOT_EXACT' + } + $main=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced/TaskbarMonitorEnhanced.exe' + $before=(Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe' + $lines=@(& $native -NoLogo -NoProfile -NonInteractive -File $src -Mode Driver) + if($LASTEXITCODE -ne 0){throw 'R306_MODEL_PROCESS_FAILED'} + $witness=($lines|Where-Object {$_ -like 'R306_PROTOTYPE_PASS Receipt=*'}|Select-Object -Last 1) + if(!$witness){throw 'R306_MODEL_POSITIVE_WITNESS_MISSING'} + $origin=[string]$witness.Substring('R306_PROTOTYPE_PASS Receipt='.Length).Trim() + $tempPrefix=[IO.Path]::GetFullPath($env:TEMP).TrimEnd('\')+'\R306_DISPOSABLE_' + if(-not $origin.StartsWith($tempPrefix,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R306_RESULT_OUTSIDE_SYNTHETIC_TEMP' + } + $r=Get-Content -LiteralPath $origin -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R306-PROTOTYPE' -or + $r.Commit -cne $env:GITHUB_SHA -or + $r.Status -cne 'PASS_SYNTHETIC_DURABLE_SNAPSHOT_KILL_RESTORE_AND_TAMPER_REFUSAL' -or + $r.OriginalProtectedServicesTouched -ne $false -or $r.UACInvoked -ne $false -or + $r.ProgramFilesWritten -ne $false -or $r.ActualProofWorkerKilled -ne $true -or + $r.SameUserFreshProcessRestore -ne $true -or + $r.CrossSidRefusedBeforeWrite -ne $true -or $r.MissingReadyMarkerRefused -ne $true -or + $r.PreWriteSnapshotHashValidation -ne $true -or + $r.ActualSensorHelperKilled -ne $false -or + $r.ExternalCanaryPreserved -ne $true){ + throw 'R306_SYNTHETIC_RECEIPT_NOT_ACCEPTED' + } + $dest=Join-Path $env:RUNNER_TEMP ('R306_${{ matrix.host }}_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $dest){throw 'R306_TYPED_RECEIPT_DEST_EXISTS'} + [void][IO.Directory]::CreateDirectory($dest) + $copy=Join-Path $dest 'R306_ROLLBACK_MODEL_RECEIPT.json' + [IO.File]::Copy($origin,$copy,$false) + if((Get-FileHash -LiteralPath $origin -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $copy -Algorithm SHA256).Hash){ + throw 'R306_RAW_RECEIPT_COPY_SHA_MISMATCH' + } + if((Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash -cne $before){ + throw 'R306_GUEST_INSTALLED_MAIN_CHANGED' + } + 'R306_SYNTHETIC_JOURNAL_ACTUAL_WORKER_KILL_PASS_NOT_PRODUCTION_ACCEPTANCE' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -524,3 +577,4 @@ jobs: ${{ runner.temp }}/R303_${{ matrix.host }}_${{ github.run_id }}/R303_PROTECTED_SENSOR_OWNER_RECEIPT.json ${{ runner.temp }}/R304_${{ matrix.host }}_${{ github.run_id }}/R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json ${{ runner.temp }}/R305_${{ matrix.host }}_${{ github.run_id }}/R305_ELEVATED_TOKEN_SID_RECEIPT.json + ${{ runner.temp }}/R306_${{ matrix.host }}_${{ github.run_id }}/R306_ROLLBACK_MODEL_RECEIPT.json diff --git a/build/Test-R306-DurableRollbackModel.ps1 b/build/Test-R306-DurableRollbackModel.ps1 new file mode 100644 index 00000000..f96d0f04 --- /dev/null +++ b/build/Test-R306-DurableRollbackModel.ps1 @@ -0,0 +1,256 @@ +[CmdletBinding()] +param( + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R306: isolated fault-injection prototype, never elevates or touches Program Files. +if($env:OS -cne 'Windows_NT'){throw 'R306_WINDOWS_REQUIRED'} +$Source=Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1' +$sourceTokens=$null;$sourceErrors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($Source,[ref]$sourceTokens,[ref]$sourceErrors) +if(@($sourceErrors).Count){throw 'R306_PRODUCTION_PARSER_INVALID'} +$functions=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Get-SensorRollbackFiles','Assert-SensorBoundaryNoReparse') +},$true)) +if($functions.Count -ne 2){throw 'R306_PRODUCTION_CAPTURE_FUNCTIONS_NOT_FOUND'} +. ([scriptblock]::Create(($functions|ForEach-Object{$_.Extent.Text}) -join [Environment]::NewLine)) +function HashBytes([byte[]]$value){ + $sha=[Security.Cryptography.SHA256]::Create() + try{return [BitConverter]::ToString($sha.ComputeHash($value)).Replace('-','')} + finally{$sha.Dispose()} +} +function SHA([string]$name){ + if(!(Test-Path -LiteralPath $name -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $name -Algorithm SHA256).Hash +} +function WriteNewAndFlush([string]$name,[byte[]]$data){ + if([IO.File]::Exists($name)){throw ('R306_EXISTING_OUTPUT_REFUSED_'+$name)} + $parent=[IO.Path]::GetDirectoryName($name) + [void][IO.Directory]::CreateDirectory($parent) + Assert-SensorBoundaryNoReparse $parent + $stream=[IO.FileStream]::new($name,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.Write($data,0,$data.Length);$stream.Flush($true)} + finally{$stream.Dispose()} +} +function SafeRelative([string]$rel){ + if([string]::IsNullOrEmpty($rel) -or [IO.Path]::IsPathRooted($rel) -or + $rel.Contains('/') -or $rel.Contains(':') -or + $rel.Contains('..') -or $rel.StartsWith('\') -or $rel.EndsWith('\')){ + throw 'R306_BAD_RELATIVE_SNAPSHOT_NAME' + } + foreach($p in $rel.Split('\')){ + if($p -notmatch '^[A-Za-z0-9_.-]+$' -or $p -in @('.','..')){throw ('R306_BAD_RELATIVE_SNAPSHOT_NAME REL='+$rel+' PART='+$p)} + } +} +function SaveDurable([string]$broker,[string]$journal,[string]$owner,[string]$taskXml){ + if(Test-Path -LiteralPath $journal){throw 'R306_JOURNAL_ALREADY_EXISTS'} + Assert-SensorBoundaryNoReparse $broker + Assert-SensorBoundaryNoReparse ([IO.Path]::GetDirectoryName($journal)) + $files=Get-SensorRollbackFiles $broker + if($files.Count -lt 1 -or $files.Count -gt 128){throw 'R306_CAPTURE_COUNT'} + [void][IO.Directory]::CreateDirectory($journal) + [void][IO.Directory]::CreateDirectory((Join-Path $journal 'files')) + $index=[Collections.Generic.List[object]]::new() + foreach($item in $files){ + $rel=[string]$item.Rel + SafeRelative $rel + $bytes=[byte[]]$item.Data + $path=Join-Path (Join-Path $journal 'files') $rel + WriteNewAndFlush $path $bytes + $sum=HashBytes $bytes + if((SHA $path) -cne $sum){throw 'R306_DURABLE_CAPTURE_READBACK_MISMATCH'} + $index.Add([pscustomobject]@{Rel=$rel;Length=$bytes.Length;Sha256=$sum}) + } + $taskBytes=[Text.Encoding]::UTF8.GetBytes($taskXml) + if($taskBytes.Length -gt 262144){throw 'R306_TASK_XML_OVERLIMIT'} + WriteNewAndFlush (Join-Path $journal 'task.xml') $taskBytes + $manifest=[ordered]@{ + Schema=1;Status='PREPARED';Sid=$owner; + OriginalFiles=@($index);TaskSha256=(HashBytes $taskBytes) + } + $data=[Text.Encoding]::UTF8.GetBytes(($manifest|ConvertTo-Json -Depth 7 -Compress)) + WriteNewAndFlush (Join-Path $journal 'manifest.json') $data + # Durable READY is the last file created and flushed. The caller must not + # change the live BrokerRoot before this point. + WriteNewAndFlush (Join-Path $journal 'READY') ([Text.Encoding]::ASCII.GetBytes('R306_READY_V1')) + if((SHA (Join-Path $journal 'READY')) -eq 'MISSING'){throw 'R306_READY_NOT_DURABLE'} +} +function RestoreDurable([string]$broker,[string]$journal,[string]$expectedSid){ + if(-not (Test-Path -LiteralPath (Join-Path $journal 'READY') -PathType Leaf)){ + throw 'R306_NOT_READY_NO_RESTORE' + } + Assert-SensorBoundaryNoReparse $journal + $marker=[IO.File]::ReadAllText((Join-Path $journal 'READY'),[Text.Encoding]::ASCII) + if($marker -cne 'R306_READY_V1'){throw 'R306_READY_NOT_MATCHED'} + $manifest=[IO.File]::ReadAllText((Join-Path $journal 'manifest.json'),[Text.Encoding]::UTF8)|ConvertFrom-Json + if($manifest.Schema -ne 1 -or $manifest.Status -cne 'PREPARED' -or + $manifest.Sid -cne $expectedSid){throw 'R306_SNAPSHOT_ACCOUNT_MISMATCH'} + $files=@($manifest.OriginalFiles) + if($files.Count -lt 1 -or $files.Count -gt 128){throw 'R306_INVALID_MANIFEST_COUNT'} + $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + # Validate ALL saved contents and paths before writing even the first restored byte. + $validated=[Collections.Generic.List[object]]::new() + foreach($entry in $files){ + $relative=[string]$entry.Rel + SafeRelative $relative + if(-not $seen.Add($relative)){throw 'R306_DUPLICATE_MANIFEST_FILE'} + $path=Join-Path (Join-Path $journal 'files') $relative + Assert-SensorBoundaryNoReparse $path + $payload=[IO.File]::ReadAllBytes($path) + if($payload.Length -ne [int]$entry.Length -or + (HashBytes $payload) -cne [string]$entry.Sha256){ + throw 'R306_BACKUP_HASH_INVALID' + } + $validated.Add([pscustomobject]@{Rel=$relative;Bytes=$payload}) + } + Assert-SensorBoundaryNoReparse (Join-Path $journal 'task.xml') + $xml=[IO.File]::ReadAllBytes((Join-Path $journal 'task.xml')) + if((HashBytes $xml) -cne [string]$manifest.TaskSha256){ + throw 'R306_TASK_XML_HASH_INVALID' + } + # Restore writes here are synthetic; in production they would be after + # stopping only the owned task/process, and would need handle-bound writes. + foreach($entry in $validated){ + $target=Join-Path $broker $entry.Rel + Assert-SensorBoundaryNoReparse $target + [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) + Assert-SensorBoundaryNoReparse $target + [IO.File]::WriteAllBytes($target,[byte[]]$entry.Bytes) + } + foreach($entry in $validated){ + if((SHA (Join-Path $broker $entry.Rel)) -cne (HashBytes $entry.Bytes)){ + throw 'R306_RESTORED_HASH_NOT_EQUAL_TO_MANIFEST' + } + } + return [Text.Encoding]::UTF8.GetString($xml) +} +function StartWorker([string]$mode,[string]$fixture){ + $hostPath=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + if(-not [IO.File]::Exists($hostPath)){throw 'R306_PINNED_NATIVE_POWERSHELL_MISSING'} + $command='-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+'" -Mode '+$mode+' -Fixture "'+$fixture+'"' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$hostPath;$psi.Arguments=$command + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) +} +if($Mode -ne 'Driver'){ + if([string]::IsNullOrEmpty($Fixture) -or -not [IO.Directory]::Exists($Fixture)){throw 'R306_FIXTURE_UNKNOWN'} + $broker=Join-Path $Fixture 'broker' + $journal=Join-Path $Fixture 'journal' + $sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + if($Mode -eq 'Worker'){ + SaveDurable $broker $journal $sid 'OWNED_PREVIOUS_SENSOR_SERVICE' + [IO.File]::Delete((Join-Path $broker 'broker.exe')) + [IO.File]::WriteAllText((Join-Path $Fixture 'MUTATED'),'READY_AND_LIVE_FILE_REMOVED') + Start-Sleep -Seconds 35 + exit 0 + } + if($Mode -eq 'Recover'){ + $xml=RestoreDurable $broker $journal $sid + if($xml -cne 'OWNED_PREVIOUS_SENSOR_SERVICE'){ + throw 'R306_TASK_XML_NOT_PRESERVED' + } + [Console]::Write('R306_SYNTHETIC_RESTORED') + exit 0 + } + throw 'R306_MODE_INVALID' +} +$fixture=Join-Path $env:TEMP ('R306_DISPOSABLE_'+[guid]::NewGuid().ToString('N')) +$broker=Join-Path $fixture 'broker' +$outside=Join-Path $fixture 'outside' +[void][IO.Directory]::CreateDirectory($broker) +[void][IO.Directory]::CreateDirectory($outside) +$first=Join-Path $broker 'broker.exe' +$second=Join-Path $broker 'sensor.config' +$canary=Join-Path $outside 'unrelated-user.txt' +[IO.File]::WriteAllBytes($first,([Text.Encoding]::ASCII.GetBytes('ORIGINAL_SIGNED_BROKER_BINARY_R306'))) +[IO.File]::WriteAllBytes($second,([Text.Encoding]::ASCII.GetBytes('ORIGINAL_CONFIGURATION_R306'))) +[IO.File]::WriteAllText($canary,'UNRELATED_USER_DATA_NOT_CHANGED') +$baselineA=SHA $first;$baselineB=SHA $second;$baselineCanary=SHA $canary +$worker=StartWorker Worker $fixture +try{ + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not(Test-Path -LiteralPath (Join-Path $fixture 'MUTATED')) -and + -not $worker.HasExited -and $clock.ElapsedMilliseconds -lt 20000){ + Start-Sleep -Milliseconds 80 + } + if(-not (Test-Path -LiteralPath (Join-Path $fixture 'MUTATED')) -or $worker.HasExited){ + throw ('R306_WORKER_NO_DURABLE_READY_CHECKPOINT '+$worker.StandardError.ReadToEnd()) + } + if((SHA $first) -cne 'MISSING' -or (SHA (Join-Path $fixture 'journal\READY')) -eq 'MISSING'){ + throw 'R306_PROTECTED_CRASH_WINDOW_NOT_REPRODUCED' + } + $worker.Kill() + if(-not $worker.WaitForExit(9000) -or $worker.ExitCode -eq 0){ + throw 'R306_WORKER_NOT_ACTUALLY_TERMINATED' + } +}finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(9000)} + $worker.Dispose() +} +$recover=StartWorker Recover $fixture +try{ + if(-not $recover.WaitForExit(16000) -or $recover.ExitCode -ne 0){ + throw ('R306_SEPARATE_PROCESS_RECOVERY_FAILED '+$recover.StandardError.ReadToEnd()) + } +}finally{ + if(-not $recover.HasExited){$recover.Kill();[void]$recover.WaitForExit(9000)} + $recover.Dispose() +} +if((SHA $first) -cne $baselineA -or (SHA $second) -cne $baselineB -or (SHA $canary) -cne $baselineCanary){ + throw 'R306_CROSS_PROCESS_RECOVERY_WRONG' +} +# R306 negative tests: an unrelated SID and an incomplete journal are +# refused before any live write, even when the underlying snapshot is valid. +$foreign='S-1-5-21-12345-23456-34567-45678' +$denied=$false +try{$null=RestoreDurable $broker (Join-Path $fixture 'journal') $foreign} +catch{if($_.Exception.Message -match 'R306_SNAPSHOT_ACCOUNT_MISMATCH'){$denied=$true}else{throw}} +if(-not $denied -or (SHA $first) -cne $baselineA){throw 'R306_FOREIGN_SID_NOT_FAIL_CLOSED'} +$ready=Join-Path $fixture 'journal\READY' +[IO.File]::Delete($ready) +$notReady=$false +try{$null=RestoreDurable $broker (Join-Path $fixture 'journal') ([string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value))} +catch{if($_.Exception.Message -match 'R306_NOT_READY_NO_RESTORE'){$notReady=$true}else{throw}} +if(-not $notReady -or (SHA $first) -cne $baselineA){throw 'R306_INCOMPLETE_BACKUP_NOT_FAIL_CLOSED'} +WriteNewAndFlush $ready ([Text.Encoding]::ASCII.GetBytes('R306_READY_V1')) +if((SHA $canary) -cne $baselineCanary){throw 'R306_OUTSIDE_CANARY_CHANGED'} + +# Tamper one protected snapshot AFTER restoring once. The next attempt must +# refuse before replacing live file: check existing modified test file hash. +[IO.File]::WriteAllBytes($first,[Text.Encoding]::ASCII.GetBytes('CURRENT_CANARY_KEEP')) +$liveBefore=SHA $first +[IO.File]::WriteAllBytes((Join-Path $fixture 'journal\files\broker.exe'), + [Text.Encoding]::ASCII.GetBytes('TAMPERED_SNAPSHOT')) +$failed=$false +try{$null=RestoreDurable $broker (Join-Path $fixture 'journal') ([string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value))} +catch{if($_.Exception.Message -match 'R306_BACKUP_HASH_INVALID'){$failed=$true}else{throw}} +if(-not $failed -or (SHA $first) -cne $liveBefore -or (SHA $canary) -cne $baselineCanary){ + throw 'R306_INVALID_SNAPSHOT_MUST_NOT_MUTATE_LIVE' +} +$receipt=[ordered]@{ + Schema=1;Revision='R306-PROTOTYPE' + Status='PASS_SYNTHETIC_DURABLE_SNAPSHOT_KILL_RESTORE_AND_TAMPER_REFUSAL' + Commit=(git -C $PSScriptRoot rev-parse HEAD) + OriginalProtectedServicesTouched=$false + UACInvoked=$false + ProgramFilesWritten=$false + ActualProofWorkerKilled=$true + SameUserFreshProcessRestore=$true + PreWriteSnapshotHashValidation=$true + CrossSidRefusedBeforeWrite=$true + MissingReadyMarkerRefused=$true + ActualSensorHelperKilled=$false + PreviousTaskXmlTransportedButNotRegistered=$true + ExternalCanaryPreserved=$true + FixtureRoot=$fixture + FixtureFilesSHA256=@{BrokerOriginal=$baselineA;ConfigOriginal=$baselineB;Canary=$baselineCanary} + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +$resultPath=Join-Path $fixture 'R306_RECEIPT.json' +[IO.File]::WriteAllText($resultPath,($receipt|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +"R306_PROTOTYPE_PASS Receipt=$resultPath" From 6b7f76f341bdc80e8109dda3664569cdd72dadce Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 17:52:48 +0330 Subject: [PATCH 045/135] test: accept path-normalized synthetic journal receipt in R306 Windows CI (F01) --- ...audit-r286-optional-audio-native-recovery.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index ba046325..7c33d7dc 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -522,9 +522,19 @@ jobs: $witness=($lines|Where-Object {$_ -like 'R306_PROTOTYPE_PASS Receipt=*'}|Select-Object -Last 1) if(!$witness){throw 'R306_MODEL_POSITIVE_WITNESS_MISSING'} $origin=[string]$witness.Substring('R306_PROTOTYPE_PASS Receipt='.Length).Trim() - $tempPrefix=[IO.Path]::GetFullPath($env:TEMP).TrimEnd('\')+'\R306_DISPOSABLE_' - if(-not $origin.StartsWith($tempPrefix,[StringComparison]::OrdinalIgnoreCase)){ - throw 'R306_RESULT_OUTSIDE_SYNTHETIC_TEMP' + $actualFile=[IO.Path]::GetFullPath($origin) + $actualDir=[IO.Path]::GetDirectoryName($actualFile) + $actualParent=[IO.Path]::GetDirectoryName($actualDir) + if([IO.Path]::GetFileName($actualFile) -cne 'R306_RECEIPT.json' -or + [IO.Path]::GetFileName($actualDir) -cnotmatch '^R306_DISPOSABLE_[0-9a-f]{32}$'){ + throw 'R306_RESULT_SYNTHETIC_LAYOUT_UNTRUSTED' + } + $allowed=@($env:TEMP,$env:TMP,$env:RUNNER_TEMP,[IO.Path]::GetTempPath()) + $valid=@($allowed|Where-Object {$_ -and + [string]::Equals([IO.Path]::GetFullPath($_).TrimEnd([IO.Path]::DirectorySeparatorChar), + $actualParent,[StringComparison]::OrdinalIgnoreCase)}) + if($valid.Count -lt 1){ + throw ('R306_RESULT_OUTSIDE_SYNTHETIC_TEMP parent='+$actualParent+' allowed='+($allowed -join ';')) } $r=Get-Content -LiteralPath $origin -Raw|ConvertFrom-Json if($r.Schema -ne 1 -or $r.Revision -cne 'R306-PROTOTYPE' -or From 13d19100f01c1dd11e1b7862848fa82921350764 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 18:37:19 +0330 Subject: [PATCH 046/135] test: verify trusted journal ACL and reject hostile paths in isolated Windows (R307) --- ...it-r286-optional-audio-native-recovery.yml | 33 ++++ .../Test-R307-ProtectedJournalAcl-Native.ps1 | 147 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 build/Test-R307-ProtectedJournalAcl-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 7c33d7dc..68c3333c 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -562,6 +562,38 @@ jobs: throw 'R306_GUEST_INSTALLED_MAIN_CHANGED' } 'R306_SYNTHETIC_JOURNAL_ACTUAL_WORKER_KILL_PASS_NOT_PRODUCTION_ACCEPTANCE' + - name: R307 verify owner-protected durable journal DACL with hostile Windows paths + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R307-ProtectedJournalAcl-Native.ps1).Path + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$t,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R307_WINDOWS_PS51_TEST_PARSE_FAILED'} + $old=Join-Path $env:RUNNER_TEMP ('R306_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R306_ROLLBACK_MODEL_RECEIPT.json') + $prior=Get-Content -LiteralPath $old -Raw|ConvertFrom-Json + if($prior.Commit -cne $env:GITHUB_SHA -or + $prior.Status -cne 'PASS_SYNTHETIC_DURABLE_SNAPSHOT_KILL_RESTORE_AND_TAMPER_REFUSAL' -or + $prior.ActualProofWorkerKilled -ne $true){throw 'R307_PRIOR_MODEL_EVIDENCE_NOT_EXACT'} + $main=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced/TaskbarMonitorEnhanced.exe' + $before=(Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash + ./build/Test-R307-ProtectedJournalAcl-Native.ps1 -HostLabel '${{ matrix.host }}' + $path=Join-Path $env:RUNNER_TEMP ('R307_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R307_PROTECTED_JOURNAL_ACL_RECEIPT.json') + if(!(Test-Path -LiteralPath $path)){throw 'R307_TYPED_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R307-PROTOTYPE' -or + $r.Status -cne 'PASS_PROTECTED_JOURNAL_ACL_TRUST_BOUNDARY_SYNTHETIC' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OwnerComputerTouched -ne $false -or $r.ActualProtectedSensorTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or $r.ExplicitProtectedDaclCreated -ne $true -or + $r.OwnerTrustedAdministrators -ne $true -or $r.SystemAdminAccessVerified -ne $true -or + $r.UserWritableRootRejected -ne $true -or $r.NativeJunctionRejected -ne $true -or + $r.FlushedManifestHashMatched -ne $true -or $r.OutsideCanaryPreserved -ne $true -or + @($r.Checks).Count -ne 6){throw 'R307_NATIVE_ACL_PROOF_NOT_ACCEPTED'} + if((Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash -cne $before){ + throw 'R307_INSTALLED_DISPOSABLE_GUEST_MAIN_CHANGED' + } + 'R307_SYNTHETIC_PROTECTED_JOURNAL_ACL_PASS_NOT_PRODUCTION_RECOVERY' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -588,3 +620,4 @@ jobs: ${{ runner.temp }}/R304_${{ matrix.host }}_${{ github.run_id }}/R304_LONG_POWERSHELL_TRANSPORT_RECEIPT.json ${{ runner.temp }}/R305_${{ matrix.host }}_${{ github.run_id }}/R305_ELEVATED_TOKEN_SID_RECEIPT.json ${{ runner.temp }}/R306_${{ matrix.host }}_${{ github.run_id }}/R306_ROLLBACK_MODEL_RECEIPT.json + ${{ runner.temp }}/R307_${{ matrix.host }}_${{ github.run_id }}/R307_PROTECTED_JOURNAL_ACL_RECEIPT.json diff --git a/build/Test-R307-ProtectedJournalAcl-Native.ps1 b/build/Test-R307-ProtectedJournalAcl-Native.ps1 new file mode 100644 index 00000000..37ab9df8 --- /dev/null +++ b/build/Test-R307-ProtectedJournalAcl-Native.ps1 @@ -0,0 +1,147 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R307_ONLY_DISPOSABLE_NATIVE_WINDOWS' +} +$root=Join-Path $env:RUNNER_TEMP ('R307_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$protected=Join-Path $root 'owned-protected-journal' +$unsafe=Join-Path $root 'unsafe-user-journal' +$outside=Join-Path $root 'unrelated-user-canary' +$receipt=Join-Path $root 'R307_PROTECTED_JOURNAL_ACL_RECEIPT.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R307_PREEXISTING_GUEST_JOURNAL_FIXTURE'} +[void][IO.Directory]::CreateDirectory($root) +[void][IO.Directory]::CreateDirectory($outside) +$canary=Join-Path $outside 'canary.txt' +[IO.File]::WriteAllText($canary,'R307_UNRELATED_CANARY',[Text.Encoding]::ASCII) +$canarySHA=(Get-FileHash -LiteralPath $canary -Algorithm SHA256).Hash +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$trusted=[Security.Principal.SecurityIdentifier]::new('S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') +$allowed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +[void]$allowed.Add($admins.Value) +[void]$allowed.Add($system.Value) +[void]$allowed.Add($trusted.Value) +$writable=( + [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Modify -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership +) +function Assert-R307ProtectedAcl([string]$target){ + $info=Get-Item -LiteralPath $target -Force -ErrorAction Stop + if(-not $info.PSIsContainer -or ($info.Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R307_REPARSE_OR_NON_DIRECTORY_REFUSED' + } + $acl=Get-Acl -LiteralPath $target -ErrorAction Stop + $owner=[Security.Principal.NTAccount]::new([string]$acl.Owner) + $ownerSid=$owner.Translate([Security.Principal.SecurityIdentifier]) + if(-not $allowed.Contains($ownerSid.Value)){ + throw ('R307_JOURNAL_OWNER_NOT_TRUSTED '+$ownerSid.Value) + } + if(-not $acl.AreAccessRulesProtected){ + throw 'R307_JOURNAL_INHERITED_DACL_REFUSED' + } + $aces=$acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) + foreach($rule in $aces){ + if($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and + -not $allowed.Contains([string]$rule.IdentityReference.Value) -and + ($rule.FileSystemRights -band $writable)){ + throw 'R307_JOURNAL_BROAD_WRITE_ALLOW_REFUSED' + } + } + return $acl +} +$security=[Security.AccessControl.DirectorySecurity]::new() +$security.SetOwner($admins) +$security.SetAccessRuleProtection($true,$false) +$inherit=[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' +$none=[Security.AccessControl.PropagationFlags]::None +$allow=[Security.AccessControl.AccessControlType]::Allow +foreach($principal in @($admins,$system)){ + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $principal,[Security.AccessControl.FileSystemRights]::FullControl, + $inherit,$none,$allow) + $security.AddAccessRule($rule) +} +# .NET Framework Windows PowerShell 5.1 supports atomic directory creation +# with an explicit DACL. Never create first and tighten permissions later. +[void][IO.Directory]::CreateDirectory($protected,$security) +$effective=Assert-R307ProtectedAcl $protected +$checks.Add('CREATED_WITH_TRUSTED_OWNER_AND_PROTECTED_DACL') +$aclSids=@($effective.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])| + ForEach-Object {$_.IdentityReference.Value}) +if(-not ($aclSids -contains 'S-1-5-32-544') -or + -not ($aclSids -contains 'S-1-5-18')){ + throw 'R307_SYSTEM_AND_ADMIN_FULL_CONTROL_MISSING' +} +$checks.Add('SYSTEM_AND_ADMINISTRATORS_PERSISTENT_ACCESS') +$file=Join-Path $protected 'manifest.json' +$bytes=[Text.Encoding]::UTF8.GetBytes('{"Schema":1,"Sid":"R307"}') +$stream=[IO.FileStream]::new($file,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) +try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()} +if((Get-FileHash $file -Algorithm SHA256).Hash -cne + ([BitConverter]::ToString(([Security.Cryptography.SHA256]::Create().ComputeHash($bytes))).Replace('-',''))){ + throw 'R307_PROTECTED_MANIFEST_FLUSH_READBACK_MISMATCH' +} +$checks.Add('FLUSHED_MANIFEST_SHA_READBACK') +# A preexisting user-writable directory must never be accepted as a journal. +[void][IO.Directory]::CreateDirectory($unsafe) +$denied=$false +try{$null=Assert-R307ProtectedAcl $unsafe} +catch{if($_.Exception.Message -match 'OWNER_NOT_TRUSTED|INHERITED_DACL_REFUSED|BROAD_WRITE_ALLOW_REFUSED'){$denied=$true}else{throw}} +if(!$denied){throw 'R307_EXISTING_USER_WRITABLE_FOLDER_ACCEPTED'} +$checks.Add('USER_WRITABLE_EXISTING_DIRECTORY_FAIL_CLOSED') +# A junction, even if placed under a legitimate parent, is never accepted. +$link=Join-Path $root 'test-junction' +New-Item -ItemType Junction -Path $link -Target $outside -ErrorAction Stop|Out-Null +if(-not ((Get-Item -LiteralPath $link -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R307_NATIVE_JUNCTION_NOT_CREATED' +} +$denied=$false +try{$null=Assert-R307ProtectedAcl $link}catch{ + if($_.Exception.Message -match 'REPARSE_OR_NON_DIRECTORY_REFUSED'){$denied=$true}else{throw} +} +if(!$denied){throw 'R307_JUNCTION_TRAVERSED'} +if((Get-FileHash $canary -Algorithm SHA256).Hash -cne $canarySHA){ + throw 'R307_JUNCTION_DESTINATION_CHANGED' +} +$checks.Add('REAL_REPARSE_JUNCTION_REFUSED_AND_CANARY_IMMUTABLE') +# A read-only observation of real Program Files ancestors is informative, +# but never mutates live protected sensor files or ACLs. +$programFiles=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$actualProgramFilesOwner=(Get-Acl -LiteralPath $programFiles).Owner +if([string]::IsNullOrWhiteSpace($actualProgramFilesOwner)){throw 'R307_REAL_PROGRAM_FILES_OWNER_UNREADABLE'} +$checks.Add('REAL_PROGRAM_FILES_ACL_READ_ONLY') +$data=[ordered]@{ + Schema=1;Revision='R307-PROTOTYPE' + Status='PASS_PROTECTED_JOURNAL_ACL_TRUST_BOUNDARY_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerTouched=$false + ActualProtectedSensorTaskTouched=$false + RealProgramFilesWritten=$false + ExplicitProtectedDaclCreated=$true + OwnerTrustedAdministrators=$true + SystemAdminAccessVerified=$true + UserWritableRootRejected=$true + NativeJunctionRejected=$true + FlushedManifestHashMatched=$true + OutsideCanaryPreserved=$true + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R307_'+$HostLabel+'_PROTECTED_ACL_MODEL_PASS' From 7a8f0bcb86f9a1be0c6ad0d0d019472d6fd62bee Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 18:41:50 +0330 Subject: [PATCH 047/135] test: require trusted ownership on protected journal manifest files (R307-F01) --- ...it-r286-optional-audio-native-recovery.yml | 3 ++- .../Test-R307-ProtectedJournalAcl-Native.ps1 | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 68c3333c..c8c5b547 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -587,9 +587,10 @@ jobs: $r.OwnerComputerTouched -ne $false -or $r.ActualProtectedSensorTaskTouched -ne $false -or $r.RealProgramFilesWritten -ne $false -or $r.ExplicitProtectedDaclCreated -ne $true -or $r.OwnerTrustedAdministrators -ne $true -or $r.SystemAdminAccessVerified -ne $true -or + $r.ManifestFileOwnerTrusted -ne $true -or $r.UserWritableRootRejected -ne $true -or $r.NativeJunctionRejected -ne $true -or $r.FlushedManifestHashMatched -ne $true -or $r.OutsideCanaryPreserved -ne $true -or - @($r.Checks).Count -ne 6){throw 'R307_NATIVE_ACL_PROOF_NOT_ACCEPTED'} + @($r.Checks).Count -ne 7){throw 'R307_NATIVE_ACL_PROOF_NOT_ACCEPTED'} if((Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash -cne $before){ throw 'R307_INSTALLED_DISPOSABLE_GUEST_MAIN_CHANGED' } diff --git a/build/Test-R307-ProtectedJournalAcl-Native.ps1 b/build/Test-R307-ProtectedJournalAcl-Native.ps1 index 37ab9df8..84254153 100644 --- a/build/Test-R307-ProtectedJournalAcl-Native.ps1 +++ b/build/Test-R307-ProtectedJournalAcl-Native.ps1 @@ -98,6 +98,25 @@ if((Get-FileHash $file -Algorithm SHA256).Hash -cne throw 'R307_PROTECTED_MANIFEST_FLUSH_READBACK_MISMATCH' } $checks.Add('FLUSHED_MANIFEST_SHA_READBACK') +function Assert-R307ProtectedManifestFile([string]$path){ + $fileInfo=Get-Item -LiteralPath $path -Force -ErrorAction Stop + if($fileInfo.PSIsContainer -or ($fileInfo.Attributes -band [IO.FileAttributes]::ReparsePoint)){ + throw 'R307_MANIFEST_NOT_ORDINARY_FILE' + } + $acl=Get-Acl -LiteralPath $path -ErrorAction Stop + $ownerSid=([Security.Principal.NTAccount]::new([string]$acl.Owner)).Translate( + [Security.Principal.SecurityIdentifier]) + if(-not $allowed.Contains($ownerSid.Value)){throw 'R307_MANIFEST_OWNER_NOT_TRUSTED'} + foreach($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){ + if($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and + -not $allowed.Contains([string]$rule.IdentityReference.Value) -and + ($rule.FileSystemRights -band $writable)){ + throw 'R307_MANIFEST_FILE_BROAD_WRITE_ALLOWED' + } + } +} +Assert-R307ProtectedManifestFile $file +$checks.Add('MANIFEST_FILE_OWNER_AND_ACL_TRUSTED') # A preexisting user-writable directory must never be accepted as a journal. [void][IO.Directory]::CreateDirectory($unsafe) $denied=$false @@ -136,6 +155,7 @@ $data=[ordered]@{ ExplicitProtectedDaclCreated=$true OwnerTrustedAdministrators=$true SystemAdminAccessVerified=$true + ManifestFileOwnerTrusted=$true UserWritableRootRejected=$true NativeJunctionRejected=$true FlushedManifestHashMatched=$true From a0811bdfd3ecc0784962a43a002930df05d7d28d Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 19:16:04 +0330 Subject: [PATCH 048/135] security(experiment): stage owner-protected sensor snapshot before mutation (R308) --- ...it-r286-optional-audio-native-recovery.yml | 36 ++- .../Test-R293-InMemorySensorHelper-Native.ps1 | 24 +- .../Test-R298-PinnedPowerShellHost-Native.ps1 | 39 ++- .../Test-R304-LongInlinePowerShell-Native.ps1 | 24 +- build/Test-R308-ProtectedPrewrite-Native.ps1 | 200 ++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 243 ++++++++++++++++++ 6 files changed, 533 insertions(+), 33 deletions(-) create mode 100644 build/Test-R308-ProtectedPrewrite-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index c8c5b547..b709db40 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -203,10 +203,10 @@ jobs: $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or $r.OriginalOwnerComputerTouched -ne $false -or $r.UACOrProtectedServiceCalled -ne $false -or $r.ExactTrustedHelperSource -ne $true -or $r.MaliciousTempScriptIgnored -ne $true -or - $r.RealNonElevatedEncodedPowerShellCalled -ne $true -or + $r.RealNonElevatedInMemoryPowerShellCalled -ne $true -or $r.InvalidAppRootRejectedBeforeWrite -ne $true -or $r.InstalledMainUnchanged -ne $true -or - $r.BoundedEncodedCommandLength -lt 1000 -or $r.BoundedEncodedCommandLength -gt 32000 -or + $r.BoundedInMemoryCommandLength -lt 1000 -or $r.BoundedInMemoryCommandLength -gt 30000 -or @($r.Checks).Count -lt 9 -or @($r.Checks|Where-Object {$_ -ceq 'QUOTED_HOSTILE_USER_INPUT_ENCODED_AS_DATA'}).Count -ne 1 -or @($r.Checks|Where-Object {$_ -ceq 'COMMAND_LINE_INJECTION_SENTINEL_NOT_EXECUTED'}).Count -ne 1){throw 'R293_NATIVE_INMEMORY_RECEIPT_REJECTED'} @@ -466,12 +466,12 @@ jobs: $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or $r.OriginalOwnerComputerTouched -ne $false -or $r.NoUAC -ne $true -or - $r.ExistingEncodedCommandPreserved -ne $true -or + ($r.ShortEncodedCommandUsed -ne $true -and $r.ShortInlineTransportVerified -ne $true) -or $r.LongProfileFallbackCommandExecuted -ne $true -or $r.NoUserWritableScriptExecuted -ne $true -or $r.HostileInputRemainedEncoded -ne $true -or $r.InstalledUserFilesUnchanged -ne $true -or - $r.EncodedCommandLength -gt 32080 -or + $r.ShortCommandLength -gt 32080 -or $r.LongFallbackCommandLength -gt 30000 -or @($r.Checks).Count -lt 9){throw 'R304_NATIVE_GATES_NOT_MET'} 'R304_NATIVE_SHORT_LONG_HOSTILE_TRANSPORT=PASS' @@ -595,6 +595,33 @@ jobs: throw 'R307_INSTALLED_DISPOSABLE_GUEST_MAIN_CHANGED' } 'R307_SYNTHETIC_PROTECTED_JOURNAL_ACL_PASS_NOT_PRODUCTION_RECOVERY' + - name: R308 validate production protected journal prewrite functions on disposable Windows + shell: powershell + run: | + $ErrorActionPreference='Stop' + $script=(Resolve-Path build/Test-R308-ProtectedPrewrite-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($script,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R308_PS51_PARSER_INVALID'} + ./build/Test-R308-ProtectedPrewrite-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $path=Join-Path $env:RUNNER_TEMP ('R308_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R308_JOURNAL_PREWRITE_RECEIPT.json') + if(!(Test-Path -LiteralPath $path)){throw 'R308_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R308' -or + $r.Status -cne 'PASS_PRODUCTION_JOURNAL_PREWRITE_FUNCTIONS_SYNTHETIC' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OwnerComputerTouched -ne $false -or $r.ActualProgramFilesWritten -ne $false -or + $r.ActualScheduledTaskModified -ne $false -or $r.ActualUACInvoked -ne $false -or + $r.ActualProtectedSensorHelperKilled -ne $false -or + $r.ProductionFunctionsExtractedFromExactPE -ne $true -or + $r.JournalReadyPublishedPreMutation -ne $true -or + $r.StaleJournalFailsClosed -ne $true -or + $r.SIDAndHashAndReparseFailClosed -ne $true -or + $r.ExternalUserDataUnchanged -ne $true -or + $r.TerminalCleanupAndColdRecoveryImplemented -ne $false -or + @($r.Checks).Count -ne 15){throw 'R308_NATIVE_JOURNAL_PREWRITE_PROOF_NOT_ACCEPTED'} + 'R308_PREWRITE_ONLY_PASS_PRODUCTION_COLD_RECOVERY_STILL_OPEN' + - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -622,3 +649,4 @@ jobs: ${{ runner.temp }}/R305_${{ matrix.host }}_${{ github.run_id }}/R305_ELEVATED_TOKEN_SID_RECEIPT.json ${{ runner.temp }}/R306_${{ matrix.host }}_${{ github.run_id }}/R306_ROLLBACK_MODEL_RECEIPT.json ${{ runner.temp }}/R307_${{ matrix.host }}_${{ github.run_id }}/R307_PROTECTED_JOURNAL_ACL_RECEIPT.json + ${{ runner.temp }}/R308_${{ matrix.host }}_${{ github.run_id }}/R308_JOURNAL_PREWRITE_RECEIPT.json diff --git a/build/Test-R293-InMemorySensorHelper-Native.ps1 b/build/Test-R293-InMemorySensorHelper-Native.ps1 index 06f96f0d..2aeb7795 100644 --- a/build/Test-R293-InMemorySensorHelper-Native.ps1 +++ b/build/Test-R293-InMemorySensorHelper-Native.ps1 @@ -60,15 +60,23 @@ try{ $args4[3]=[string]$user $cmd=[string]$m.Invoke($null,$args4) $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' - if(-not $cmd.StartsWith($prefix,[StringComparison]::Ordinal) -or - $cmd.Contains(' -File ') -or $cmd.Contains('ExecutionPolicy Bypass')){ + $inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + if($cmd.Contains(' -File ') -or $cmd.Contains('ExecutionPolicy Bypass')){ throw 'R293_ELEVATION_COMMAND_USES_UNTRUSTED_FILE_PATH' } - $encoded=$cmd.Substring($prefix.Length) - if($encoded.Length -lt 1000 -or $encoded.Length -gt 32000){ - throw 'R293_ENCODED_COMMAND_LENGTH_OUT_OF_BOUNDS' + $encodedTransport=$cmd.StartsWith($prefix,[StringComparison]::Ordinal) + if($encodedTransport){ + $encoded=$cmd.Substring($prefix.Length) + if($encoded.Length -lt 1000 -or $encoded.Length -gt 32000){ + throw 'R293_ENCODED_COMMAND_LENGTH_OUT_OF_BOUNDS' + } + $script=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + }else{ + if(-not $cmd.StartsWith($inlinePrefix,[StringComparison]::Ordinal) -or + $cmd.Length -gt 30000){throw 'R293_UNSAFE_OR_LONG_INLINE_COMMAND'} + $encoded=$cmd.Substring($inlinePrefix.Length) + $script=$encoded } - $script=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) if(-not $script.Contains('[ScriptBlock]::Create($code)') -or $script.Contains(' -File ')){ throw 'R293_DECODED_COMMAND_DOES_NOT_RUN_EMBEDDED_SOURCE' @@ -208,10 +216,10 @@ try{ UACOrProtectedServiceCalled=$false ExactTrustedHelperSource=$true MaliciousTempScriptIgnored=$true - RealNonElevatedEncodedPowerShellCalled=$true + RealNonElevatedInMemoryPowerShellCalled=$true InvalidAppRootRejectedBeforeWrite=$true InstalledMainUnchanged=$true - BoundedEncodedCommandLength=$encoded.Length + BoundedInMemoryCommandLength=$cmd.Length Checks=@($checks) RecordedUtc=[DateTime]::UtcNow.ToString('o') } diff --git a/build/Test-R298-PinnedPowerShellHost-Native.ps1 b/build/Test-R298-PinnedPowerShellHost-Native.ps1 index 8cd0e718..f07ba862 100644 --- a/build/Test-R298-PinnedPowerShellHost-Native.ps1 +++ b/build/Test-R298-PinnedPowerShellHost-Native.ps1 @@ -85,12 +85,24 @@ try{ } $checks.Add('ORIGINAL_UAC_RUNAS_SHELL_CONTRACT_PRESERVED') $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' - if(-not $info.Arguments.StartsWith($prefix,[StringComparison]::Ordinal) -or - $info.Arguments.Contains(' -File ') -or $info.Arguments.Contains('ExecutionPolicy Bypass') -or - $info.Arguments.Length -gt 32080){ + $inline='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + $isEncoded=$info.Arguments.StartsWith($prefix,[StringComparison]::Ordinal) + $isInline=$info.Arguments.StartsWith($inline,[StringComparison]::Ordinal) + if((-not $isEncoded -and -not $isInline) -or + $info.Arguments.Contains(' -File ') -or + $info.Arguments.Contains('ExecutionPolicy Bypass') -or + ($isEncoded -and $info.Arguments.Length -gt 32080) -or + ($isInline -and $info.Arguments.Length -gt 30000)){ throw 'R298_POWER_SHELL_SCRIPT_ENTRY_WAS_WEAKENED' } - $checks.Add('TRUSTED_ENCODED_COMMAND_NO_TEMP_SCRIPT') + $bootstrap=if($isEncoded){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($info.Arguments.Substring($prefix.Length))) + }else{$info.Arguments.Substring($inline.Length)} + if($bootstrap -notmatch '\$blob=\[Convert\]::FromBase64String' -or + -not $bootstrap.Contains('[ScriptBlock]::Create($code)')){ + throw 'R298_TRUSTED_HELPER_BOOTSTRAP_PROVENANCE_INVALID' + } + $checks.Add('TRUSTED_PE_INLINE_COMMAND_NO_TEMP_SCRIPT') $originalEncoded=$info.Arguments [void][IO.Directory]::CreateDirectory($fake) $rogue=Join-Path $fake 'powershell.exe' @@ -118,16 +130,17 @@ try{ $args4[0]=[string]'Uninstall' $uninstall=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$args4) if(-not [string]::Equals($uninstall.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or - $uninstall.Arguments -cnotmatch '\-Mode ''Uninstall''' -or $uninstall.Arguments.Contains(' -File ')){ - # Mode is inside the base64 bootstrap rather than a literal argument. - $encoded=$uninstall.Arguments.Substring($prefix.Length) - $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) - if($bootstrap -cnotmatch "\-Mode 'Uninstall'" -or - -not [string]::Equals($uninstall.FileName,$expected,[StringComparison]::OrdinalIgnoreCase) -or - $uninstall.Arguments.Contains(' -File ')){ - throw 'R298_UNINSTALL_MODE_OR_HOST_NOT_PINNED' - } + throw 'R298_UNINSTALL_HOST_OR_SCRIPT_UNPINNED' + } + $uninstallBootstrap=if($uninstall.Arguments.StartsWith($prefix,[StringComparison]::Ordinal)){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($uninstall.Arguments.Substring($prefix.Length))) + }elseif($uninstall.Arguments.StartsWith($inline,[StringComparison]::Ordinal)){ + $uninstall.Arguments.Substring($inline.Length) + }else{throw 'R298_UNINSTALL_TRANSPORT_UNSUPPORTED'} + if($uninstallBootstrap -cnotmatch "\-Mode 'Uninstall'" -or + $uninstallBootstrap -notmatch '\$blob=\[Convert\]::FromBase64String'){ + throw 'R298_UNINSTALL_MODE_OR_HELPER_SOURCE_UNPINNED' } $checks.Add('UNINSTALL_MODE_USES_SAME_ABSOLUTE_TRUSTED_HOST') if((SHA $main) -cne $mainBefore){throw 'R298_GUEST_MAIN_WAS_CHANGED'} diff --git a/build/Test-R304-LongInlinePowerShell-Native.ps1 b/build/Test-R304-LongInlinePowerShell-Native.ps1 index 82162809..87be6716 100644 --- a/build/Test-R304-LongInlinePowerShell-Native.ps1 +++ b/build/Test-R304-LongInlinePowerShell-Native.ps1 @@ -99,15 +99,22 @@ $normalPayload=Join-Path $env:RUNNER_TEMP ('tbme_setup_'+('a'*32)) $normalRoot=Join-Path $env:WINDIR 'R304_Expected_PreWrite_Refusal' $normal=Command Install $normalPayload $normalRoot $user $encodedPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' -if(-not $normal.StartsWith($encodedPrefix,[StringComparison]::Ordinal) -or - $normal.Length -gt 32080){ - throw 'R304_SHORT_ENCODEDCOMMAND_BACKWARD_COMPAT_FAILED' +$inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' +$shortIsEncoded=$normal.StartsWith($encodedPrefix,[StringComparison]::Ordinal) +$shortIsInline=$normal.StartsWith($inlinePrefix,[StringComparison]::Ordinal) +if((-not $shortIsEncoded -and -not $shortIsInline) -or + ($shortIsEncoded -and $normal.Length -gt 32080) -or + ($shortIsInline -and $normal.Length -gt 30000) -or + $normal.Contains(' -File ')){ + throw 'R304_SHORT_UNBOUNDED_OR_UNSAFE_TRANSPORT' } -$normalCode=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($normal.Substring($encodedPrefix.Length))) +$normalCode=if($shortIsEncoded){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($normal.Substring($encodedPrefix.Length))) +}else{$normal.Substring($inlinePrefix.Length)} $match=[regex]::Match($normalCode,"(?m)^\`$blob=\[Convert\]::FromBase64String\('([A-Za-z0-9+/=]+)'\)") if(!$match.Success){throw 'R304_PRIOR_PE_HELPER_BLOB_NOT_ENCODED'} $originalBlob=$match.Groups[1].Value -$checks.Add('SHORT_ENCODEDCOMMAND_AND_PEBLOB_UNCHANGED') +$checks.Add('SHORT_INMEMORY_COMMAND_PE_BLOB_PINNED') $longName='C:\Users\'+('Q'*128) $longPayload=$longName+'\AppData\Local\Temp\tbme_setup_'+('b'*32) $longRoot=$longName+'\AppData\Local\TaskbarMonitorEnhanced' @@ -143,7 +150,7 @@ if(!$limitFailed){throw 'R304_UNBOUNDED_PARAMETER_FAILOPEN'} $checks.Add('PER_PARAMETER_LENGTH_CAP_PRESERVED') # The native 5.1 child runs as the current guest user, NEVER with Verb=runas. # All three invocations must refuse before writing any optional sensor state. -Expect-FailsClosed 'SHORT_EXISTING_ENCODED_COMMAND_REFUSED_PREWRITE' $normal 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' +Expect-FailsClosed 'SHORT_EXACT_PE_COMMAND_REFUSED_PREWRITE' $normal 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' Expect-FailsClosed 'LONG_NEW_COMMAND_REFUSED_PREWRITE' $fallback 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' Expect-FailsClosed 'HOSTILE_LONG_COMMAND_REFUSED_PREWRITE' $attack 'SENSOR_BOUNDARY_APP_ROOT_OUTSIDE_USER_PROFILE' if((Hash $main) -cne $original.Main -or @@ -158,12 +165,13 @@ $record=[ordered]@{ Status='PASS_LONG_PROFILE_INMEMORY_POWERSHELL_FALLBACK' Host=$HostLabel;SourceCommit=$env:GITHUB_SHA;NoUAC=$true OriginalOwnerComputerTouched=$false - ExistingEncodedCommandPreserved=$true + ShortEncodedCommandUsed=$shortIsEncoded + ShortInlineTransportVerified=$shortIsInline LongProfileFallbackCommandExecuted=$true NoUserWritableScriptExecuted=$true HostileInputRemainedEncoded=$true InstalledUserFilesUnchanged=$true - EncodedCommandLength=$normal.Length + ShortCommandLength=$normal.Length LongFallbackCommandLength=$fallback.Length HostileFallbackCommandLength=$attack.Length Checks=@($checks) diff --git a/build/Test-R308-ProtectedPrewrite-Native.ps1 b/build/Test-R308-ProtectedPrewrite-Native.ps1 new file mode 100644 index 00000000..5ca3a3cb --- /dev/null +++ b/build/Test-R308-ProtectedPrewrite-Native.ps1 @@ -0,0 +1,200 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R308_DISPOSABLE_WINDOWS_ONLY' +} +function FileHash([string]$path){ + if(!(Test-Path -LiteralPath $path -PathType Leaf)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Refused([string]$name,[scriptblock]$test,[string]$pattern){ + $caught=$false + try{& $test}catch{ + if($_.Exception.ToString() -match $pattern){$caught=$true}else{throw} + } + if(!$caught){throw ('R308_TEST_FAILED_OPEN_'+$name)} + $script:checks.Add($name) +} +$dir=Join-Path $env:RUNNER_TEMP ('R308_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $dir 'R308_JOURNAL_PREWRITE_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R307_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R307_PROTECTED_JOURNAL_ACL_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $dir){throw 'R308_GUEST_TEST_ROOT_ALREADY_EXISTS'} +[void][IO.Directory]::CreateDirectory($dir) +$live=@{Main=(FileHash $main);Config=(FileHash $config);State=(FileHash $state)} +$source=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($source.Commit -cne $env:GITHUB_SHA -or $source.SetupSHA256 -cne (FileHash $setup) -or + $source.MainSHA256 -cne $live.Main -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_PROTECTED_JOURNAL_ACL_TRUST_BOUNDARY_SYNTHETIC'){ + throw 'R308_CANDIDATE_OR_R307_EVIDENCE_NOT_EXACT' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$stream=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R308_PE_EMBEDDED_HELPER_MISSING'} +$reader=[IO.StreamReader]::new($stream) +try{$embedded=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$raw=[IO.File]::ReadAllText($helper) +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize $raw)){ + throw 'R308_SOURCE_DIFFERS_FROM_COMPILED_SETUP_PE' +} +$checks.Add('SAME_EXACT_PE_EMBEDDED_HELPER_SOURCE') +$save=$raw.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid ',[StringComparison]::Ordinal) +$mutation=$raw.IndexOf(' Assert-OrRecordSensorOwner',[StringComparison]::Ordinal) +$stale=$raw.IndexOf('if(Test-Path -LiteralPath $SensorJournalRoot)',[StringComparison]::Ordinal) +if($stale -lt 0 -or $save -lt 0 -or $mutation -le $save){ + throw 'R308_JOURNAL_NOT_BEFORE_FIRST_PROTECTED_MUTATION' +} +$checks.Add('STALE_JOURNAL_REFUSAL_AND_PREMUTATION_ORDER') +$tok=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tok,[ref]$errors) +if(@($errors).Count){throw 'R308_POWERSHELL51_PARSER_REJECTED_HELPER'} +$names=@('Assert-SensorBoundaryNoReparse','Get-SensorJournalHash', + 'Assert-SensorJournalRelative','Assert-SensorJournalTrustedAcl', + 'Write-SensorJournalUnique','Assert-SensorDurableJournal','New-SensorDurableJournal') +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -in $names +},$true)) +if($defs.Count -ne 7){throw 'R308_EXACT_SEVEN_FUNCTIONS_MISSING'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$checks.Add('COMPILED_PRODUCTION_FUNCTIONS_LOADED_NATIVE_PS51') +$admin=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admin);$acl.SetAccessRuleProtection($true,$false) +foreach($sid in @($admin,$system)){ + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $sid,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) +} +$parent=Join-Path $dir 'trusted-synthetic-parent' +[void][IO.Directory]::CreateDirectory($parent,$acl) +Assert-SensorJournalTrustedAcl $parent $true +$checks.Add('OWNER_AND_EXPLICIT_PROTECTED_PARENT_DACL') +$broker=Join-Path $dir 'synthetic-broker' +[void][IO.Directory]::CreateDirectory($broker) +$oldFile=Join-Path $broker 'TaskbarMonitorSensorBroker.exe' +$oldConfig=Join-Path $broker 'lang\config.bin' +[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($oldConfig)) +[IO.File]::WriteAllBytes($oldFile,[Text.Encoding]::ASCII.GetBytes('R308_KNOWN_OLD_BROKER')) +[IO.File]::WriteAllBytes($oldConfig,[Text.Encoding]::ASCII.GetBytes('R308_KNOWN_OLD_CONFIG')) +$originalBroker=FileHash $oldFile;$originalConfig=FileHash $oldConfig +$outside=Join-Path $dir 'external' +[void][IO.Directory]::CreateDirectory($outside) +$canary=Join-Path $outside 'preserve.txt' +[IO.File]::WriteAllText($canary,'R308_OUTSIDE_CANARY',[Text.Encoding]::ASCII) +$canaryHash=FileHash $canary +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[IO.File]::ReadAllBytes($oldFile)}, + [pscustomobject]@{Rel='lang\config.bin';Data=[IO.File]::ReadAllBytes($oldConfig)} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','.tbme-owner.sid','lang\config.bin') +$identity=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($identity -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R308_GUEST_NOT_INTERACTIVE_USER'} +$xml='R308_ORIGINAL_SYNTHETIC_TASK' +$journal=Join-Path $parent '.tbme-sensor-rollback' +New-SensorDurableJournal $journal $identity $rows $xml $next $true +if(!(Test-Path (Join-Path $journal 'READY')) -or + (FileHash $oldFile) -cne $originalBroker -or (FileHash $oldConfig) -cne $originalConfig){ + throw 'R308_DURABLE_READY_NOT_PREMUTATION' +} +$validated=Assert-SensorDurableJournal $journal $identity +if(@($validated.Files).Count -ne 2 -or @($validated.ExpectedNewFiles).Count -ne 4 -or + $validated.TaskPresent -ne $true -or $validated.OldRootPresent -ne $true){ + throw 'R308_SERIALIZED_DURABLE_MANIFEST_INVALID' +} +$checks.Add('DURABLE_READY_AND_MANIFEST_OLD_BYTES_XML_VERIFIED') +Refused 'DUPLICATE_JOURNAL_REFUSED' { + New-SensorDurableJournal $journal $identity $rows $xml $next $true +} 'SENSOR_JOURNAL_ALREADY_EXISTS' +$foreign='S-1-5-21-111-222-333-444' +Refused 'FOREIGN_EFFECTIVE_SID_REFUSED_PREWRITE' { + New-SensorDurableJournal (Join-Path $parent 'other-sid') $foreign $rows $xml $next $true +} 'SENSOR_JOURNAL_TOKEN_MISMATCH' +if(Test-Path (Join-Path $parent 'other-sid')){throw 'R308_FOREIGN_SID_CREATED_JOURNAL'} +Refused 'SAVED_JOURNAL_FOREIGN_SID_REFUSED' { + [void](Assert-SensorDurableJournal $journal $foreign) +} 'SENSOR_JOURNAL_OWNER_OR_PHASE_MISMATCH' +$taskFile=Join-Path $journal 'task.xml' +$originalTask=[IO.File]::ReadAllBytes($taskFile) +[IO.File]::WriteAllText($taskFile,'R308_FAKE_XML',[Text.Encoding]::ASCII) +Refused 'TASK_XML_TAMPER_DETECTED' { + [void](Assert-SensorDurableJournal $journal $identity) +} 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' +[IO.File]::WriteAllBytes($taskFile,$originalTask) +$saved=Join-Path $journal 'files\TaskbarMonitorSensorBroker.exe' +$originalSaved=[IO.File]::ReadAllBytes($saved) +[IO.File]::WriteAllText($saved,'R308_TAMPERED',[Text.Encoding]::ASCII) +Refused 'SNAPSHOT_BYTE_TAMPER_DETECTED' { + [void](Assert-SensorDurableJournal $journal $identity) +} 'SENSOR_JOURNAL_SNAPSHOT_HASH_MISMATCH' +[IO.File]::WriteAllBytes($saved,$originalSaved) +$unexpected=Join-Path $journal 'unexpected.txt' +[IO.File]::WriteAllText($unexpected,'R308_UNEXPECTED',[Text.Encoding]::ASCII) +Refused 'UNKNOWN_MEMBER_REJECTED' { + [void](Assert-SensorDurableJournal $journal $identity) +} 'SENSOR_JOURNAL_UNKNOWN_MEMBER' +[IO.File]::Delete($unexpected) +$link=Join-Path $journal 'junction' +New-Item -ItemType Junction -Path $link -Target $outside -ErrorAction Stop|Out-Null +Refused 'NATIVE_JUNCTION_NOT_FOLLOWED' { + [void](Assert-SensorDurableJournal $journal $identity) +} 'SENSOR_JOURNAL_REPARSE_MEMBER' +[IO.Directory]::Delete($link) +$incomplete=Join-Path $parent 'incomplete' +[void][IO.Directory]::CreateDirectory($incomplete,$acl) +Refused 'INCOMPLETE_READY_REFUSED' { + [void](Assert-SensorDurableJournal $incomplete $identity) +} 'SENSOR_JOURNAL_INCOMPLETE_OR_UNREADY' +$unsafe=Join-Path $dir 'user-writable-parent' +[void][IO.Directory]::CreateDirectory($unsafe) +Refused 'WRITABLE_PARENT_REFUSED_BEFORE_CREATE' { + New-SensorDurableJournal (Join-Path $unsafe '.tbme-sensor-rollback') $identity $rows $xml $next $true +} 'SENSOR_JOURNAL_UNTRUSTED_OWNER|SENSOR_JOURNAL_UNTRUSTED_WRITE_ACE' +if(Test-Path (Join-Path $unsafe '.tbme-sensor-rollback')){ + throw 'R308_INSECURE_PARENT_JOURNAL_CREATED' +} +if((FileHash $oldFile) -cne $originalBroker -or (FileHash $oldConfig) -cne $originalConfig -or + (FileHash $canary) -cne $canaryHash -or + (FileHash $main) -cne $live.Main -or (FileHash $config) -cne $live.Config -or + (FileHash $state) -cne $live.State){throw 'R308_INSTALLED_OR_SYNTHETIC_LIVE_DATA_CHANGED'} +$checks.Add('MAIN_CONFIG_STATE_AND_EXTERNAL_CANARY_IMMUTABLE') +$data=[ordered]@{ + Schema=1;Revision='R308';Status='PASS_PRODUCTION_JOURNAL_PREWRITE_FUNCTIONS_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OwnerComputerTouched=$false;ActualProgramFilesWritten=$false + ActualScheduledTaskModified=$false;ActualUACInvoked=$false + ActualProtectedSensorHelperKilled=$false + ProductionFunctionsExtractedFromExactPE=$true + JournalReadyPublishedPreMutation=$true + StaleJournalFailsClosed=$true + SIDAndHashAndReparseFailClosed=$true + ExternalUserDataUnchanged=$true + TerminalCleanupAndColdRecoveryImplemented=$false + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R308_'+$HostLabel+'_PRODUCTION_JOURNAL_PREWRITE_SYNTHETIC_PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index e97bb8a2..5b9ce06d 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -122,6 +122,7 @@ $validatedSensorSid=Assert-SensorPrivilegedBoundary $Mode $PayloadDir $AppRoot $ $TaskName='TaskbarMonitorEnhanced Sensor Broker' $BrokerRoot=Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker' $SensorOwnerMarker=Join-Path $BrokerRoot '.tbme-owner.sid' +$script:SensorJournalRoot=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) 'TaskbarMonitorEnhanced\.tbme-sensor-rollback' $LogsRoot=Join-Path $AppRoot 'Logs' $ResultPath=Join-Path $LogsRoot 'sensor_install_result.json' $LogPath=Join-Path $LogsRoot 'sensor_install.log' @@ -251,6 +252,213 @@ function Clear-SensorRollbackBuffer { $script:RollbackCaptured=$false } +# R308 experimental protected write-ahead snapshot. The current review stage +# deliberately refuses preexisting/stale journals rather than consuming them. +# Crash recovery and terminal cleanup need separate native acceptance. +function Get-SensorJournalHash([byte[]]$Value){ + $sha=[Security.Cryptography.SHA256]::Create() + try{return [BitConverter]::ToString($sha.ComputeHash($Value)).Replace('-','')} + finally{$sha.Dispose()} +} +function Assert-SensorJournalRelative([string]$Rel){ + if([string]::IsNullOrWhiteSpace($Rel) -or $Rel.Length -gt 240 -or + [IO.Path]::IsPathRooted($Rel) -or $Rel.Contains('/') -or + $Rel.Contains(':') -or $Rel.StartsWith('\') -or $Rel.EndsWith('\')){ + throw 'SENSOR_JOURNAL_UNSAFE_RELATIVE_NAME' + } + foreach($part in $Rel.Split('\')){ + if($part -ceq '.' -or $part -ceq '..' -or $part.EndsWith('.') -or + $part -cnotmatch '^[A-Za-z0-9_.-]+$'){ + throw 'SENSOR_JOURNAL_UNSAFE_RELATIVE_NAME' + } + } +} +function Assert-SensorJournalTrustedAcl([string]$Path,[bool]$RequireProtected){ + Assert-SensorBoundaryNoReparse $Path + $acl=Get-Acl -LiteralPath $Path -ErrorAction Stop + $owner=([Security.Principal.NTAccount]::new([string]$acl.Owner)).Translate( + [Security.Principal.SecurityIdentifier]).Value + $trusted=@('S-1-5-18','S-1-5-32-544', + 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') + if($trusted -cnotcontains $owner){throw 'SENSOR_JOURNAL_UNTRUSTED_OWNER'} + if($RequireProtected -and -not $acl.AreAccessRulesProtected){ + throw 'SENSOR_JOURNAL_UNPROTECTED_DACL' + } + $write=[Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Modify -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + foreach($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){ + if($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow){continue} + if($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly){continue} + if(($trusted -cnotcontains [string]$rule.IdentityReference.Value) -and + ($rule.FileSystemRights -band $write)){ + throw 'SENSOR_JOURNAL_UNTRUSTED_WRITE_ACE' + } + } +} +function Write-SensorJournalUnique([string]$Root,[string]$Relative,[byte[]]$Bytes){ + Assert-SensorJournalRelative $Relative + $file=Join-Path $Root $Relative + $parent=[IO.Path]::GetDirectoryName($file) + Assert-SensorBoundaryNoReparse $parent + [void][IO.Directory]::CreateDirectory($parent) + Assert-SensorJournalTrustedAcl $parent $false + Assert-SensorBoundaryNoReparse $file + $fs=[IO.FileStream]::new($file,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$fs.Write($Bytes,0,$Bytes.Length);$fs.Flush($true)} + finally{$fs.Dispose()} + Assert-SensorJournalTrustedAcl $file $false +} +function Assert-SensorDurableJournal([string]$Root,[string]$ExpectedSid){ + Assert-SensorJournalTrustedAcl $Root $true + $ready=Join-Path $Root 'READY' + Assert-SensorBoundaryNoReparse $ready + if(-not [IO.File]::Exists($ready) -or + [IO.File]::ReadAllText($ready,[Text.Encoding]::ASCII) -cne 'TBME_SENSOR_READY_V1'){ + throw 'SENSOR_JOURNAL_INCOMPLETE_OR_UNREADY' + } + foreach($name in @('READY','manifest.json','task.xml')){ + Assert-SensorJournalTrustedAcl (Join-Path $Root $name) $false + } + $file=Join-Path $Root 'manifest.json' + if(([IO.FileInfo]::new($file)).Length -gt 65536){throw 'SENSOR_JOURNAL_MANIFEST_OVERSIZE'} + $manifest=[IO.File]::ReadAllText($file,[Text.Encoding]::UTF8)|ConvertFrom-Json + if($manifest.Schema -ne 1 -or $manifest.Phase -cne 'PREPARED' -or + $manifest.Sid -cne $ExpectedSid){throw 'SENSOR_JOURNAL_OWNER_OR_PHASE_MISMATCH'} + $bytes=[IO.File]::ReadAllBytes((Join-Path $Root 'task.xml')) + if($bytes.Length -gt 262144 -or + (Get-SensorJournalHash $bytes) -cne [string]$manifest.TaskHash){ + throw 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' + } + $allowed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($n in @('READY','manifest.json','task.xml')){[void]$allowed.Add((Join-Path $Root $n))} + [void]$allowed.Add((Join-Path $Root 'files')) + $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + if(@($manifest.Files).Count -gt 128){throw 'SENSOR_JOURNAL_SNAPSHOT_COUNT_INVALID'} + [long]$total=0 + foreach($row in @($manifest.Files)){ + $rel=[string]$row.Rel + Assert-SensorJournalRelative $rel + if(-not $seen.Add($rel)){throw 'SENSOR_JOURNAL_DUPLICATE_SNAPSHOT'} + $path=Join-Path (Join-Path $Root 'files') $rel + Assert-SensorJournalTrustedAcl $path $false + $blob=[IO.File]::ReadAllBytes($path) + $total+=$blob.Length + if($blob.Length -gt 16777216 -or $total -gt 67108864 -or + $blob.Length -ne [long]$row.Length -or + (Get-SensorJournalHash $blob) -cne [string]$row.Hash){ + throw 'SENSOR_JOURNAL_SNAPSHOT_HASH_MISMATCH' + } + [void]$allowed.Add($path) + $parent=[IO.Path]::GetDirectoryName($path) + while($parent.StartsWith((Join-Path $Root 'files')+'\',[StringComparison]::OrdinalIgnoreCase)){ + [void]$allowed.Add($parent) + $parent=[IO.Path]::GetDirectoryName($parent) + } + } + if(-not [IO.Directory]::Exists((Join-Path $Root 'files'))){ + throw 'SENSOR_JOURNAL_FILES_DIRECTORY_MISSING' + } + # Check each child before recursing; never follow a junction while merely + # enumerating the rollback journal (PowerShell provider behavior varies). + $pending=[Collections.Generic.List[string]]::new() + $pending.Add($Root) + while($pending.Count -gt 0){ + $directory=$pending[$pending.Count-1] + $pending.RemoveAt($pending.Count-1) + Assert-SensorBoundaryNoReparse $directory + foreach($member in [IO.Directory]::GetFileSystemEntries($directory)){ + $full=[IO.Path]::GetFullPath($member) + $attrs=[IO.File]::GetAttributes($full) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'SENSOR_JOURNAL_REPARSE_MEMBER' + } + if(-not $allowed.Contains($full)){ + throw 'SENSOR_JOURNAL_UNKNOWN_MEMBER' + } + if(($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + $pending.Add($full) + } + } + } + return $manifest +} +function New-SensorDurableJournal( + [string]$Root,[string]$Sid,[object[]]$Rows,[string]$TaskXml, + [string[]]$NewNames,[bool]$OldRootPresent +){ + $token=[Security.Principal.WindowsIdentity]::GetCurrent().User + if($null -eq $token -or $token.Value -cne $Sid){ + throw 'SENSOR_JOURNAL_TOKEN_MISMATCH' + } + Assert-SensorBoundaryNoReparse $Root + if(Test-Path -LiteralPath $Root){throw 'SENSOR_JOURNAL_ALREADY_EXISTS'} + $parent=[IO.Path]::GetDirectoryName($Root) + Assert-SensorJournalTrustedAcl $parent $false + $all=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + if(@($Rows).Count -gt 128 -or @($NewNames).Count -gt 64){ + throw 'SENSOR_JOURNAL_INVENTORY_TOO_LARGE' + } + $index=[Collections.Generic.List[object]]::new() + [long]$sum=0 + foreach($r in @($Rows)){ + $rel=[string]$r.Rel + Assert-SensorJournalRelative $rel + if(-not $all.Add($rel)){throw 'SENSOR_JOURNAL_DUPLICATE_ORIGINAL'} + $payload=[byte[]]$r.Data + $sum+=$payload.Length + if($payload.Length -gt 16777216 -or $sum -gt 67108864){ + throw 'SENSOR_JOURNAL_TOO_LARGE' + } + $index.Add([pscustomobject]@{Rel=$rel;Length=$payload.Length;Hash=(Get-SensorJournalHash $payload)}) + } + $newList=[Collections.Generic.List[string]]::new() + $newSeen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($name in $NewNames){ + Assert-SensorJournalRelative $name + if(-not $newSeen.Add($name)){throw 'SENSOR_JOURNAL_DUPLICATE_NEXT_FILE'} + $newList.Add($name) + } + $xml=[Text.Encoding]::UTF8.GetBytes([string]$TaskXml) + if($xml.Length -gt 262144){throw 'SENSOR_JOURNAL_XML_TOO_LARGE'} + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins) + $acl.SetAccessRuleProtection($true,$false) + foreach($principal in @($admins,$system)){ + $ace=[Security.AccessControl.FileSystemAccessRule]::new( + $principal,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + [void]$acl.AddAccessRule($ace) + } + [void][IO.Directory]::CreateDirectory($Root,$acl) + Assert-SensorJournalTrustedAcl $Root $true + [void][IO.Directory]::CreateDirectory((Join-Path $Root 'files')) + Assert-SensorJournalTrustedAcl (Join-Path $Root 'files') $false + foreach($r in @($Rows)){ + Write-SensorJournalUnique $Root ('files\'+[string]$r.Rel) ([byte[]]$r.Data) + } + Write-SensorJournalUnique $Root 'task.xml' $xml + $manifest=[ordered]@{ + Schema=1;Phase='PREPARED';Sid=$Sid;OldRootPresent=$OldRootPresent + TaskPresent=($xml.Length -gt 0);TaskHash=(Get-SensorJournalHash $xml) + Files=@($index);ExpectedNewFiles=@($newList) + } + $json=[Text.Encoding]::UTF8.GetBytes(($manifest|ConvertTo-Json -Depth 6 -Compress)) + if($json.Length -gt 65536){throw 'SENSOR_JOURNAL_MANIFEST_TOO_LARGE'} + Write-SensorJournalUnique $Root 'manifest.json' $json + # READY is written and flushed LAST, before any service/task mutation. + Write-SensorJournalUnique $Root 'READY' ([Text.Encoding]::ASCII.GetBytes('TBME_SENSOR_READY_V1')) + $verified=Assert-SensorDurableJournal $Root $Sid + if($verified.Phase -cne 'PREPARED'){throw 'SENSOR_JOURNAL_READY_READBACK_FAILED'} +} + function Capture-PreviousSensorLayer { try{ Clear-SensorRollbackBuffer @@ -367,6 +575,17 @@ try{ } # R264-SENSOR-GUARD-BOUNDARY-END +# R308: no automatic recovery/cleanup until an independently validated +# terminal protocol exists. Existing or partial journal blocks further +# protected writes; the per-user main application remains usable. +Assert-SensorBoundaryNoReparse $SensorJournalRoot +if(Test-Path -LiteralPath $SensorJournalRoot){ + $script:ActiveArchitecture='PROTECTED_JOURNAL_RECOVERY_REQUIRED' + Write-Log 'R308_PENDING_PROTECTED_JOURNAL_NO_NEW_PROTECTED_MUTATION' + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'A protected sensor update journal is pending validation. Protected sensors were not modified; a verified recovery procedure is required.' 'NOT_STARTED' $null $false $false $false + exit 0 +} + if($Mode -eq 'Uninstall'){ try{ Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue | @@ -512,6 +731,30 @@ if(-not(Capture-PreviousSensorLayer)){ Write-Result 'DEGRADED' 'Setup could not capture a rollback snapshot of the existing protected sensor layer, so it did not modify that layer.' 'NOT_STARTED' $null $false $false $false exit 0 } +# R308: preserve old protected bytes and task XML BEFORE writing ownership +# markers, stopping tasks or replacing protected files. +try{ + $parent=[IO.Path]::GetDirectoryName($SensorJournalRoot) + Assert-SensorBoundaryNoReparse $parent + if(-not [IO.Directory]::Exists($parent)){ + [void][IO.Directory]::CreateDirectory($parent) + } + Assert-SensorJournalTrustedAcl $parent $false + $expectedNew=[Collections.Generic.List[string]]::new() + foreach($name in @('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','.tbme-owner.sid')){ + $expectedNew.Add($name) + } + foreach($verified in $VerifiedBackendEntries){ + $expectedNew.Add(([string]$verified.Rel).Replace('/','\')) + } + New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid $RollbackFiles $RollbackTaskXmlText $expectedNew.ToArray() ([IO.Directory]::Exists($BrokerRoot)) + Write-Log 'R308_DURABLE_READY_VERIFIED_BEFORE_PROTECTED_MUTATION' +}catch{ + Clear-SensorRollbackBuffer + Write-Log ('R308_PROTECTED_JOURNAL_PREWRITE_REFUSED '+$_.Exception.Message) + Write-Result 'DEGRADED' 'Protected sensor snapshot could not be durably verified before mutation; prior protected services were preserved.' 'NOT_STARTED' $null $false $false $false + exit 0 +} $pawnStatus='ALREADY_PRESENT' $pawnExit=$null From efb1b65cf658685f8b9776aa8d2afed37888b02b Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 19:21:43 +0330 Subject: [PATCH 049/135] test: accept bounded PE-pinned transport for R294/R295 in R308 experiment (F01) --- build/Test-R294-PayloadProvenance-Native.ps1 | 20 ++++++++++++++------ build/Test-R295-TrustedBackendZip-Native.ps1 | 17 +++++++++++++---- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/build/Test-R294-PayloadProvenance-Native.ps1 b/build/Test-R294-PayloadProvenance-Native.ps1 index 63e921e0..72f7c2c6 100644 --- a/build/Test-R294-PayloadProvenance-Native.ps1 +++ b/build/Test-R294-PayloadProvenance-Native.ps1 @@ -49,14 +49,22 @@ try{ $args4[2]=[string]$app $args4[3]=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) $command=[string]$m.Invoke($null,$args4) - $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' - if(!$command.StartsWith($prefix,[StringComparison]::Ordinal) -or - $command.Length -gt 32080){throw 'R294_UNEXPECTED_LAUNCH_COMMAND'} - $encoded=$command.Substring($prefix.Length) - $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + $encodedPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + $inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + $encoded=$command.StartsWith($encodedPrefix,[StringComparison]::Ordinal) + $inline=$command.StartsWith($inlinePrefix,[StringComparison]::Ordinal) + if((-not $encoded -and -not $inline) -or $command.Contains(' -File ') -or + $command.Contains('ExecutionPolicy Bypass') -or + ($encoded -and $command.Length -gt 32080) -or + ($inline -and $command.Length -gt 30000)){ + throw 'SENSOR_TRUSTED_BOOTSTRAP_TRANSPORT_UNSAFE' + } + $bootstrap=if($encoded){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($command.Substring($encodedPrefix.Length))) + }else{$command.Substring($inlinePrefix.Length)} $match=[regex]::Match($bootstrap,"-ExpectedBrokerSHA256 '([A-F0-9]{64})' -ExpectedSupervisorSHA256 '([A-F0-9]{64})'") if(!$match.Success){throw 'R294_TRUSTED_EMBEDDED_HASHES_NOT_TRANSPORTED'} - $check.Add('RESOURCE_IDENTITY_PINNED_INSIDE_ENCODED_BOOTSTRAP') + $check.Add('RESOURCE_IDENTITY_PINNED_INSIDE_TRUSTED_IN_MEMORY_BOOTSTRAP') $helperPath=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path $tokens=$null;$errors=$null $ast=[Management.Automation.Language.Parser]::ParseFile($helperPath,[ref]$tokens,[ref]$errors) diff --git a/build/Test-R295-TrustedBackendZip-Native.ps1 b/build/Test-R295-TrustedBackendZip-Native.ps1 index 2c66f2e1..b34c1637 100644 --- a/build/Test-R295-TrustedBackendZip-Native.ps1 +++ b/build/Test-R295-TrustedBackendZip-Native.ps1 @@ -79,10 +79,19 @@ try{ $args4[2]=[string]$app $args4[3]=[string]([Security.Principal.WindowsIdentity]::GetCurrent().Name) $command=[string]$m.Invoke($null,$args4) - $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' - if(!$command.StartsWith($prefix,[StringComparison]::Ordinal) -or - $command.Length -gt 32080){throw 'R295_NATIVE_COMMAND_SIZE_OR_ENTRY_INVALID'} - $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($command.Substring($prefix.Length))) + $encodedPrefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + $inlinePrefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command ' + $encoded=$command.StartsWith($encodedPrefix,[StringComparison]::Ordinal) + $inline=$command.StartsWith($inlinePrefix,[StringComparison]::Ordinal) + if((-not $encoded -and -not $inline) -or $command.Contains(' -File ') -or + $command.Contains('ExecutionPolicy Bypass') -or + ($encoded -and $command.Length -gt 32080) -or + ($inline -and $command.Length -gt 30000)){ + throw 'SENSOR_TRUSTED_BOOTSTRAP_TRANSPORT_UNSAFE' + } + $bootstrap=if($encoded){ + [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($command.Substring($encodedPrefix.Length))) + }else{$command.Substring($inlinePrefix.Length)} $match=[regex]::Match($bootstrap,"-ExpectedBackendZipSHA256 '([A-F0-9]{64})'") if(!$match.Success){throw 'R295_EMBEDDED_ZIP_SHA_NOT_TRANSPORTED'} $expectedHash=$match.Groups[1].Value From 4f80f44b90c3d007a97c2993e26359739dfb06ba Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 19:44:21 +0330 Subject: [PATCH 050/135] security(experiment): journal terminal markers and synthetic cold file replay (R309) --- ...it-r286-optional-audio-native-recovery.yml | 31 ++ build/Test-R309-TerminalReplay-Native.ps1 | 279 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 186 ++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 build/Test-R309-TerminalReplay-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index b709db40..647e2307 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -622,6 +622,36 @@ jobs: @($r.Checks).Count -ne 15){throw 'R308_NATIVE_JOURNAL_PREWRITE_PROOF_NOT_ACCEPTED'} 'R308_PREWRITE_ONLY_PASS_PRODUCTION_COLD_RECOVERY_STILL_OPEN' + - name: R309 prove terminal journal and cold file replay with killed proof worker + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R309-TerminalReplay-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R309_WINPS51_TEST_PARSER_INVALID'} + ./build/Test-R309-TerminalReplay-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R309_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R309_TERMINAL_REPLAY_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R309_NATIVE_PROOF_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R309' -or + $r.Status -cne 'PASS_SYNTHETIC_TERMINAL_AND_REAL_WORKER_COLD_FILE_REPLAY' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.ActualOriginalElevatedHelperKilled -ne $false -or + $r.ActualProtectedTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or $r.UACInvoked -ne $false -or + $r.OwnerComputerTouched -ne $false -or $r.ActualSyntheticWorkerKilled -ne $true -or + $r.FreshPowerShellRecoveredOldFiles -ne $true -or + $r.ProductionPEReplayFunctionsVerified -ne $true -or + $r.TerminalCommittedAndRolledBackVerified -ne $true -or + $r.PinnedTaskXmlRecoveredAsDataNotRegistered -ne $true -or + $r.TamperAndForeignSidFailedClosed -ne $true -or + $r.UnknownFileAndJunctionFailedClosed -ne $true -or + $r.OfficialInstalledMainAndUserConfigUnchanged -ne $true -or + $r.WorkerPid -le 0 -or $r.RecoveryPid -le 0 -or + $r.WorkerPid -eq $r.RecoveryPid -or + @($r.Checks).Count -ne 16){throw 'R309_TYPED_PRODUCTION_FUNCTIONS_PROOF_FAILED'} + 'R309_SYNTHETIC_TERMINAL_AND_REPLAY_PASS_NO_UAC_OR_TASK_RESTORE' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -650,3 +680,4 @@ jobs: ${{ runner.temp }}/R306_${{ matrix.host }}_${{ github.run_id }}/R306_ROLLBACK_MODEL_RECEIPT.json ${{ runner.temp }}/R307_${{ matrix.host }}_${{ github.run_id }}/R307_PROTECTED_JOURNAL_ACL_RECEIPT.json ${{ runner.temp }}/R308_${{ matrix.host }}_${{ github.run_id }}/R308_JOURNAL_PREWRITE_RECEIPT.json + ${{ runner.temp }}/R309_${{ matrix.host }}_${{ github.run_id }}/R309_TERMINAL_REPLAY_RECEIPT.json diff --git a/build/Test-R309-TerminalReplay-Native.ps1 b/build/Test-R309-TerminalReplay-Native.ps1 new file mode 100644 index 00000000..13e3d56a --- /dev/null +++ b/build/Test-R309-TerminalReplay-Native.ps1 @@ -0,0 +1,279 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir, + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R309_ONLY_DISPOSABLE_HOSTED_WINDOWS' +} +function SHA([string]$f){ + if(-not [IO.File]::Exists($f)){return 'MISSING'} + return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash +} +function AssertFails([string]$key,[scriptblock]$probe,[string]$pattern){ + $rejected=$false + try{& $probe}catch{ + if($_.Exception.ToString() -match $pattern){$rejected=$true}else{throw} + } + if(-not $rejected){throw ('R309_EXPECTED_REFUSAL_NOT_OBSERVED_'+$key)} + $script:checks.Add($key) +} +$root=Join-Path $env:RUNNER_TEMP ('R309_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R309_TERMINAL_REPLAY_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R308_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R308_JOURNAL_PREWRITE_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +# Load ONLY extracted PE-source security functions. Never invoke the actual +# elevated Helper entrypoint, UAC, any task or real ProgramFiles files. +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$resource=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$resource){throw 'R309_COMPILED_PE_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$src=$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $src) -cne (& $normalize ([IO.File]::ReadAllText($helper)))){ + throw 'R309_PE_HELPER_SOURCE_NOT_EXACT' +} +$t=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$errors) +if(@($errors).Count){throw 'R309_SOURCE_WINPS51_PARSER_FAILURE'} +$names=@('Assert-SensorBoundaryNoReparse','Get-SensorJournalHash', + 'Assert-SensorJournalRelative','Assert-SensorJournalTrustedAcl', + 'Write-SensorJournalUnique','Assert-SensorDurableJournal','New-SensorDurableJournal', + 'Get-SensorJournalTerminal','Write-SensorJournalTerminal','Restore-SensorJournalOldFilesOnly') +$found=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in $names +},$true)) +if($found.Count -ne $names.Count){throw 'R309_EXACT_PE_PRODUCTION_REPLAY_FUNCTIONS_MISSING'} +. ([scriptblock]::Create(($found|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$identity=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($identity -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R309_NONINTERACTIVE_RUNNER_SID'} +if($Mode -ne 'Driver'){ + if([IO.Path]::GetFullPath($Fixture) -cne [IO.Path]::GetFullPath($root) -or + -not [IO.Directory]::Exists($root)){throw 'R309_UNTRUSTED_PROOF_WORKER_FIXTURE'} + $live=Join-Path $root 'live-broker' + $journal=Join-Path (Join-Path $root 'protected-parent') 'stage' + $old=Join-Path $live 'TaskbarMonitorSensorBroker.exe' + $newFile=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' + if($Mode -eq 'Worker'){ + [IO.File]::WriteAllBytes($old,[Text.Encoding]::ASCII.GetBytes('R309_NEW_DIFFERENT_BROKER')) + [IO.File]::WriteAllBytes($newFile,[Text.Encoding]::ASCII.GetBytes('R309_NEW_SUPERVISOR')) + [IO.File]::WriteAllText((Join-Path $root 'WORKER_CHECKPOINT'),'LIVE_MUTATED_AFTER_READY',[Text.Encoding]::ASCII) + Start-Sleep -Seconds 28 + exit 0 + } + if($Mode -eq 'Recover'){ + $xml=Restore-SensorJournalOldFilesOnly $journal $identity $live + if($xml -cne 'R309_OLD_TASK_XML'){throw 'R309_RECOVERY_TASK_XML_NOT_RESTORED'} + Write-SensorJournalTerminal $journal $identity 'ROLLED_BACK' + [IO.File]::WriteAllText((Join-Path $root 'RECOVERED'),'COLD_PROOF_WORKER_COMPLETED',[Text.Encoding]::ASCII) + exit 0 + } +} +if(Test-Path -LiteralPath $root){throw 'R309_TEST_ROOT_ALREADY_EXISTS'} +[void][IO.Directory]::CreateDirectory($root) +$before=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$c=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or $c.SetupSHA256 -cne (SHA $setup) -or + $c.MainSHA256 -cne $before.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_PRODUCTION_JOURNAL_PREWRITE_FUNCTIONS_SYNTHETIC'){ + throw 'R309_EXACT_SOURCE_OR_PREVIOUS_WITNESS_MISMATCH' +} +$checks.Add('EXACT_PE_HELPER_AND_PREDECESSOR_SOURCE_BOUND') +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$sys=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$sec=[Security.AccessControl.DirectorySecurity]::new() +$sec.SetOwner($admins) +$sec.SetAccessRuleProtection($true,$false) +foreach($id in @($admins,$sys)){ + $rule=[Security.AccessControl.FileSystemAccessRule]::new($id, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $sec.AddAccessRule($rule) +} +$parent=Join-Path $root 'protected-parent' +[void][IO.Directory]::CreateDirectory($parent,$sec) +Assert-SensorJournalTrustedAcl $parent $true +$checks.Add('PRIVATE_TRUSTED_DACL_WITH_SYSTEM_ADMIN_OWNERSHIP') +$live=Join-Path $root 'live-broker' +[void][IO.Directory]::CreateDirectory($live) +$old=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$cfg=Join-Path $live 'settings\sensor.bin' +[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($cfg)) +[IO.File]::WriteAllBytes($old,[Text.Encoding]::ASCII.GetBytes('R309_ORIGINAL_BROKER')) +[IO.File]::WriteAllBytes($cfg,[Text.Encoding]::ASCII.GetBytes('R309_ORIGINAL_CONFIG')) +$oldHash=SHA $old;$configHash=SHA $cfg +$external=Join-Path $root 'unrelated' +[void][IO.Directory]::CreateDirectory($external) +$canary=Join-Path $external 'canary.txt' +[IO.File]::WriteAllText($canary,'R309_KEEP_OUTSIDE',[Text.Encoding]::ASCII) +$externalHash=SHA $canary +$journal=Join-Path $parent 'stage' +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[IO.File]::ReadAllBytes($old)}, + [pscustomobject]@{Rel='settings\sensor.bin';Data=[IO.File]::ReadAllBytes($cfg)} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','settings\sensor.bin') +New-SensorDurableJournal $journal $identity $rows 'R309_OLD_TASK_XML' $next $true +if((Get-SensorJournalTerminal $journal $identity) -cne 'PREPARED'){throw 'R309_NEW_JOURNAL_NOT_PREPARED'} +$checks.Add('READY_PUBLISHED_AND_PREPARED_STATE_VALIDATED') +function StartProof([string]$mode){ + $exe=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$exe + $psi.Arguments=('-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+ + '" -Mode '+$mode+' -HostLabel '+$HostLabel+ + ' -CandidateDir "'+$CandidateDir+'" -Fixture "'+$root+'"') + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) +} +$worker=StartProof Worker +if(!$worker){throw 'R309_SYNTHETIC_MUTATOR_NOT_STARTED'} +$workerPid=$worker.Id +$timer=[Diagnostics.Stopwatch]::StartNew() +try{ + while(-not [IO.File]::Exists((Join-Path $root 'WORKER_CHECKPOINT')) -and + -not $worker.HasExited -and $timer.ElapsedMilliseconds -lt 17000){ + Start-Sleep -Milliseconds 100 + } + if(-not [IO.File]::Exists((Join-Path $root 'WORKER_CHECKPOINT')) -or + $worker.HasExited){ + throw ('R309_WORKER_NEVER_REACHED_MUTATION '+$worker.StandardError.ReadToEnd()) + } + if((SHA $old) -ceq $oldHash -or + (Get-SensorJournalTerminal $journal $identity) -cne 'PREPARED'){ + throw 'R309_MUTATION_BEFORE_REAL_WORKER_DEATH_MISSING' + } + $worker.Kill() + if(-not $worker.WaitForExit(10000) -or $worker.ExitCode -eq 0){ + throw 'R309_ACTUAL_SYNTHETIC_WORKER_KILL_NOT_CONFIRMED' + } + $checks.Add('REAL_SEPARATE_PROOF_WORKER_KILLED_AFTER_LIVE_MUTATION') +}finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(10000)} + $worker.Dispose() +} +$recover=StartProof Recover +if(!$recover){throw 'R309_RECOVERY_PROCESS_NOT_CREATED'} +$recoveryPid=$recover.Id +try{ + if(-not $recover.WaitForExit(20000) -or $recover.ExitCode -ne 0){ + $err=if($recover.HasExited){$recover.StandardError.ReadToEnd()}else{'TIMEOUT'} + if($err.Length -gt 1500){$err=$err.Substring(0,1500)} + throw ('R309_NATIVE_FRESH_RECOVERY_FAILED '+$err) + } +}finally{ + if(-not $recover.HasExited){$recover.Kill();[void]$recover.WaitForExit(10000)} + $recover.Dispose() +} +if(-not [IO.File]::Exists((Join-Path $root 'RECOVERED')) -or + $workerPid -eq $recoveryPid -or (SHA $old) -cne $oldHash -or + (SHA $cfg) -cne $configHash -or + [IO.File]::Exists((Join-Path $live 'TaskbarMonitorSensorSupervisor.exe')) -or + (Get-SensorJournalTerminal $journal $identity) -cne 'ROLLED_BACK'){ + throw 'R309_COLD_WORKER_RECOVERY_DATA_OR_STATE_FAILED' +} +$checks.Add('FRESH_SEPARATE_PROCESS_RESTORES_EXACT_OLD_BYTES') +$checks.Add('ROLLED_BACK_TERMINAL_MARKER_DURABLE') +$xml=Restore-SensorJournalOldFilesOnly $journal $identity $live +if($xml -cne 'R309_OLD_TASK_XML'){throw 'R309_REPEAT_RECOVERY_TASK_XML_WRONG'} +Write-SensorJournalTerminal $journal $identity 'ROLLED_BACK' +$checks.Add('REPEAT_RECOVERY_IDEMPOTENT_AFTER_TERMINAL') +AssertFails 'CONFLICTING_COMMIT_MARKER_REJECTED' { + Write-SensorJournalTerminal $journal $identity 'COMMITTED' +} 'SENSOR_JOURNAL_TERMINAL_CONFLICT' +# Reject tainted old snapshot before any live byte is replaced. +$sourceBlob=Join-Path $journal 'files\TaskbarMonitorSensorBroker.exe' +$trusted=[IO.File]::ReadAllBytes($sourceBlob) +[IO.File]::WriteAllBytes($old,[Text.Encoding]::ASCII.GetBytes('R309_LIVE_PRESERVE_ON_ERROR')) +$beforeRefuse=SHA $old +[IO.File]::WriteAllBytes($sourceBlob,[Text.Encoding]::ASCII.GetBytes('R309_TAMPERED_BACKUP')) +AssertFails 'INVALID_SNAPSHOT_PREWRITE_REFUSED' { + [void](Restore-SensorJournalOldFilesOnly $journal $identity $live) +} 'SENSOR_JOURNAL_SNAPSHOT_HASH_MISMATCH|SENSOR_JOURNAL_REPLAY_SHA_MISMATCH' +if((SHA $old) -cne $beforeRefuse){throw 'R309_TAMPERED_BACKUP_CHANGED_LIVE_FILE'} +[IO.File]::WriteAllBytes($sourceBlob,$trusted) +# Unknown live member must block any attempted restore. +$extra=Join-Path $live 'unauthorized-user.txt' +[IO.File]::WriteAllText($extra,'UNAUTHORIZED',[Text.Encoding]::ASCII) +AssertFails 'UNKNOWN_LIVE_MEMBER_REFUSED_PREWRITE' { + [void](Restore-SensorJournalOldFilesOnly $journal $identity $live) +} 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' +if((SHA $old) -cne $beforeRefuse){throw 'R309_UNKNOWN_LIVE_MEMBER_CHANGED_LIVE_FILE'} +[IO.File]::Delete($extra) +$junction=Join-Path $live 'rogue-junction' +New-Item -ItemType Junction -Path $junction -Target $external -ErrorAction Stop|Out-Null +AssertFails 'LIVE_JUNCTION_REFUSED_BEFORE_REPLAY' { + [void](Restore-SensorJournalOldFilesOnly $journal $identity $live) +} 'SENSOR_JOURNAL_REPLAY_LIVE_REPARSE_REFUSED' +[IO.Directory]::Delete($junction) +$taskPath=Join-Path $journal 'task.xml' +$taskOriginal=[IO.File]::ReadAllBytes($taskPath) +[IO.File]::WriteAllText($taskPath,'R309_TAMPER',[Text.Encoding]::ASCII) +AssertFails 'SAVED_TASK_XML_TAMPER_REFUSED' { + [void](Restore-SensorJournalOldFilesOnly $journal $identity $live) +} 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' +[IO.File]::WriteAllBytes($taskPath,$taskOriginal) +AssertFails 'CROSS_USER_SID_REPLAY_REFUSED' { + [void](Restore-SensorJournalOldFilesOnly $journal 'S-1-5-21-111-222-333-444' $live) +} 'SENSOR_JOURNAL_EFFECTIVE_TOKEN_SID_MISMATCH' +# COMMITTED is terminal and never authorizes rollback. +$other=Join-Path $parent 'committed' +New-SensorDurableJournal $other $identity $rows 'R309_OLD_TASK_XML' $next $true +Write-SensorJournalTerminal $other $identity 'COMMITTED' +if((Get-SensorJournalTerminal $other $identity) -cne 'COMMITTED'){ + throw 'R309_COMMITTED_MARKER_NOT_READ_BACK' +} +AssertFails 'COMMITTED_JOURNAL_CANNOT_REVERT_LIVE_LAYER' { + [void](Restore-SensorJournalOldFilesOnly $other $identity $live) +} 'SENSOR_JOURNAL_REPLAY_REQUIRES_PREPARED_STATE' +$checks.Add('COMMITTED_TERMINAL_READBACK_PASS') +# End-of-test invariants: no user/system service/registry mutation. +if((SHA $canary) -cne $externalHash -or + (SHA $main) -cne $before.Main -or + (SHA $config) -cne $before.Config -or + (SHA $state) -cne $before.State){ + throw 'R309_GUEST_OWNER_OR_UNRELATED_CANARY_CHANGED' +} +$checks.Add('GUEST_INSTALLED_USER_AND_UNRELATED_BYTES_IMMUTABLE') +$result=[ordered]@{ + Schema=1;Revision='R309' + Status='PASS_SYNTHETIC_TERMINAL_AND_REAL_WORKER_COLD_FILE_REPLAY' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + ActualOriginalElevatedHelperKilled=$false;ActualProtectedTaskTouched=$false + RealProgramFilesWritten=$false;UACInvoked=$false;OwnerComputerTouched=$false + ActualSyntheticWorkerKilled=$true;FreshPowerShellRecoveredOldFiles=$true + ProductionPEReplayFunctionsVerified=$true + TerminalCommittedAndRolledBackVerified=$true + PinnedTaskXmlRecoveredAsDataNotRegistered=$true + TamperAndForeignSidFailedClosed=$true + UnknownFileAndJunctionFailedClosed=$true + OfficialInstalledMainAndUserConfigUnchanged=$true + WorkerPid=$workerPid;RecoveryPid=$recoveryPid + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R309_NATIVE_'+$HostLabel+'_TERMINAL_AND_COLD_FILE_REPLAY_PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 5b9ce06d..c97000f4 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -336,6 +336,19 @@ function Assert-SensorDurableJournal([string]$Root,[string]$ExpectedSid){ $allowed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach($n in @('READY','manifest.json','task.xml')){[void]$allowed.Add((Join-Path $Root $n))} [void]$allowed.Add((Join-Path $Root 'files')) + # R309 terminal markers are known members, but only a valid bounded + # marker and exact protected ownership may be accepted. + foreach($phase in @('COMMITTED','ROLLED_BACK')){ + $p=Join-Path $Root ($phase+'.terminal') + if([IO.File]::Exists($p)){ + Assert-SensorJournalTrustedAcl $p $false + if([IO.File]::ReadAllText($p,[Text.Encoding]::ASCII) -cne + ('TBME_SENSOR_'+$phase+'_V1')){ + throw 'SENSOR_JOURNAL_UNKNOWN_TERMINAL_MARKER' + } + [void]$allowed.Add($p) + } + } $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) if(@($manifest.Files).Count -gt 128){throw 'SENSOR_JOURNAL_SNAPSHOT_COUNT_INVALID'} [long]$total=0 @@ -459,6 +472,179 @@ function New-SensorDurableJournal( if($verified.Phase -cne 'PREPARED'){throw 'SENSOR_JOURNAL_READY_READBACK_FAILED'} } +# R309: terminal state and byte-exact file replay are designed as replayable, +# bounded operations. Scheduled Task XML restoration/actual protected UAC +# recovery are separate acceptance gates; this file-only primitive must +# never be interpreted as proof of task/service recovery. +function Get-SensorJournalTerminal([string]$Root,[string]$Sid){ + # R309: bind all phase/replay calls to the actual effective Windows + # token, not merely to a caller-provided or journal-stored SID. + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$Sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_EFFECTIVE_TOKEN_SID_MISMATCH' + } + [void](Assert-SensorDurableJournal $Root $Sid) + $found='' + foreach($phase in @('COMMITTED','ROLLED_BACK')){ + $path=Join-Path $Root ($phase+'.terminal') + if(-not [IO.File]::Exists($path)){continue} + Assert-SensorJournalTrustedAcl $path $false + $expected='TBME_SENSOR_'+$phase+'_V1' + if([IO.File]::ReadAllText($path,[Text.Encoding]::ASCII) -cne $expected){ + throw 'SENSOR_JOURNAL_UNTRUSTED_TERMINAL_MARKER' + } + if($found){throw 'SENSOR_JOURNAL_CONFLICTING_TERMINAL_PHASE'} + $found=$phase + } + if(!$found){return 'PREPARED'} + return $found +} +function Write-SensorJournalTerminal([string]$Root,[string]$Sid,[string]$Phase){ + if($Phase -cne 'COMMITTED' -and $Phase -cne 'ROLLED_BACK'){ + throw 'SENSOR_JOURNAL_TERMINAL_PHASE_NOT_ALLOWED' + } + $current=Get-SensorJournalTerminal $Root $Sid + if($current -ceq $Phase){return} + if($current -cne 'PREPARED'){throw 'SENSOR_JOURNAL_TERMINAL_CONFLICT'} + $target=Join-Path $Root ($Phase+'.terminal') + $bytes=[Text.Encoding]::ASCII.GetBytes('TBME_SENSOR_'+$Phase+'_V1') + Write-SensorJournalUnique $Root ($Phase+'.terminal') $bytes + if([IO.File]::ReadAllText($target,[Text.Encoding]::ASCII) -cne + ('TBME_SENSOR_'+$Phase+'_V1')){ + throw 'SENSOR_JOURNAL_TERMINAL_DURABLE_READBACK' + } + if((Get-SensorJournalTerminal $Root $Sid) -cne $Phase){ + throw 'SENSOR_JOURNAL_TERMINAL_STATE_READBACK' + } +} +function Restore-SensorJournalOldFilesOnly( + [string]$Root,[string]$Sid,[string]$LiveRoot +){ + $state=Get-SensorJournalTerminal $Root $Sid + if($state -cne 'PREPARED' -and $state -cne 'ROLLED_BACK'){ + throw 'SENSOR_JOURNAL_REPLAY_REQUIRES_PREPARED_STATE' + } + $manifest=Assert-SensorDurableJournal $Root $Sid + # Cache and verify every snapshot and prior task XML BEFORE any live + # synthetic file changes. Never reread saved bytes after validation. + $prior=[Collections.Generic.List[object]]::new() + $original=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + [long]$total=0 + foreach($row in @($manifest.Files)){ + $rel=[string]$row.Rel + Assert-SensorJournalRelative $rel + if(-not $original.Add($rel)){throw 'SENSOR_JOURNAL_DUPLICATE_ORIGINAL'} + $path=Join-Path (Join-Path $Root 'files') $rel + Assert-SensorJournalTrustedAcl $path $false + $fs=[IO.FileStream]::new($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try{ + if($fs.Length -ne [long]$row.Length -or $fs.Length -gt 16777216){ + throw 'SENSOR_JOURNAL_REPLAY_SIZE_INVALID' + } + $total+=$fs.Length + if($total -gt 67108864){throw 'SENSOR_JOURNAL_REPLAY_TOTAL_INVALID'} + $bytes=[byte[]]::new([int]$fs.Length) + $position=0 + while($position -lt $bytes.Length){ + $got=$fs.Read($bytes,$position,$bytes.Length-$position) + if($got -lt 1){throw 'SENSOR_JOURNAL_REPLAY_SHORT_READ'} + $position+=$got + } + if((Get-SensorJournalHash $bytes) -cne [string]$row.Hash){ + throw 'SENSOR_JOURNAL_REPLAY_SHA_MISMATCH' + } + $prior.Add([pscustomobject]@{Rel=$rel;Bytes=$bytes;Sha=[string]$row.Hash}) + }finally{$fs.Dispose()} + } + $xmlPath=Join-Path $Root 'task.xml' + Assert-SensorJournalTrustedAcl $xmlPath $false + $xml=[IO.File]::ReadAllBytes($xmlPath) + if($xml.Length -gt 262144 -or + (Get-SensorJournalHash $xml) -cne [string]$manifest.TaskHash){ + throw 'SENSOR_JOURNAL_REPLAY_XML_SHA_INVALID' + } + # Validate an EXACT allowlist for the live tree before a single delete + # or overwrite. Real privileged service/task mutation is never executed + # by this primitive and must be guarded at its own ownership boundary. + $known=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($item in @($manifest.ExpectedNewFiles)){ + $rel=[string]$item + Assert-SensorJournalRelative $rel + if(-not $known.Add($rel)){throw 'SENSOR_JOURNAL_REPLAY_DUPLICATE_NEW'} + } + foreach($item in $prior){[void]$known.Add([string]$item.Rel)} + $dirs=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($rel in $known){ + $parts=$rel.Split('\') + for($k=1;$k -lt $parts.Length;$k++){ + [void]$dirs.Add(($parts[0..($k-1)] -join '\')) + } + } + Assert-SensorBoundaryNoReparse $LiveRoot + if([IO.Directory]::Exists($LiveRoot)){ + $prefix=[IO.Path]::GetFullPath($LiveRoot).TrimEnd('\')+'\' + $pending=[Collections.Generic.List[string]]::new() + $pending.Add($LiveRoot) + while($pending.Count){ + $directory=$pending[$pending.Count-1] + $pending.RemoveAt($pending.Count-1) + Assert-SensorBoundaryNoReparse $directory + foreach($member in [IO.Directory]::GetFileSystemEntries($directory)){ + $full=[IO.Path]::GetFullPath($member) + $attrs=[IO.File]::GetAttributes($full) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'SENSOR_JOURNAL_REPLAY_LIVE_REPARSE_REFUSED' + } + if(-not $full.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_REPLAY_LIVE_PATH_ESCAPE' + } + $rel=$full.Substring($prefix.Length) + if(($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + if(-not $dirs.Contains($rel)){ + throw 'SENSOR_JOURNAL_REPLAY_UNKNOWN_DIRECTORY' + } + $pending.Add($full) + }elseif(-not $known.Contains($rel)){ + throw 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' + } + } + } + } + # Stage validated originals back into the live synthetic tree. + # A process death mid-copy leaves READY and intact old snapshots, so a + # subsequent same-account invocation can resume. This isn't an atomic + # multi-file transaction or proof of OS power-cut durability. + if(-not [IO.Directory]::Exists($LiveRoot)){ + [void][IO.Directory]::CreateDirectory($LiveRoot) + } + foreach($entry in $prior){ + $dest=Join-Path $LiveRoot $entry.Rel + $parent=[IO.Path]::GetDirectoryName($dest) + Assert-SensorBoundaryNoReparse $dest + [void][IO.Directory]::CreateDirectory($parent) + Assert-SensorBoundaryNoReparse $dest + [IO.File]::WriteAllBytes($dest,[byte[]]$entry.Bytes) + if((Get-SensorJournalHash ([IO.File]::ReadAllBytes($dest))) -cne $entry.Sha){ + throw 'SENSOR_JOURNAL_REPLAY_WRITE_SHA_FAILED' + } + } + foreach($rel in $known){ + if($original.Contains($rel)){continue} + $dest=Join-Path $LiveRoot $rel + Assert-SensorBoundaryNoReparse $dest + if([IO.File]::Exists($dest)){[IO.File]::Delete($dest)} + } + foreach($entry in $prior){ + $dest=Join-Path $LiveRoot $entry.Rel + if(-not [IO.File]::Exists($dest) -or + (Get-SensorJournalHash ([IO.File]::ReadAllBytes($dest))) -cne $entry.Sha){ + throw 'SENSOR_JOURNAL_REPLAY_FINAL_SHA_FAILED' + } + } + return [Text.Encoding]::UTF8.GetString($xml) +} + function Capture-PreviousSensorLayer { try{ Clear-SensorRollbackBuffer From a6fdf69d38d2dded3b517dd18bfea3142eaa1fb0 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 19:59:05 +0330 Subject: [PATCH 051/135] security(experiment): bounded retired sensor journal cleanup and crash retry (R310) --- ...it-r286-optional-audio-native-recovery.yml | 31 +++ build/Test-R310-RetiredJournal-Native.ps1 | 258 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 177 ++++++++++++ 3 files changed, 466 insertions(+) create mode 100644 build/Test-R310-RetiredJournal-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 647e2307..b25fb3f9 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -652,6 +652,36 @@ jobs: $r.WorkerPid -eq $r.RecoveryPid -or @($r.Checks).Count -ne 16){throw 'R309_TYPED_PRODUCTION_FUNCTIONS_PROOF_FAILED'} 'R309_SYNTHETIC_TERMINAL_AND_REPLAY_PASS_NO_UAC_OR_TASK_RESTORE' + - name: R310 kill proof worker inside retired journal cleanup and resume safely + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R310-RetiredJournal-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R310_PS51_PROOF_PARSER_INVALID'} + ./build/Test-R310-RetiredJournal-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R310_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R310_RAW_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R310' -or + $r.Status -cne 'PASS_SYNTHETIC_RETIRED_TERMINAL_PARTIAL_CLEANUP_RESTART' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACOrProtectedTaskModified -ne $false -or + $r.ActualOriginalElevatedHelperKilled -ne $false -or + $r.RealProofWorkerKilledDuringCleanup -ne $true -or + $r.FreshProcessFinishedRetiredCleanup -ne $true -or + $r.NonrecursiveKnownMemberDeletion -ne $true -or + $r.UnknownFilesAndJunctionFailClosed -ne $true -or + $r.ForeignSIDAndPreparedPhaseRefused -ne $true -or + $r.TerminatedJournalEventuallyRemoved -ne $true -or + $r.UserDataAndCanaryUnchanged -ne $true -or + $r.WorkerPid -le 0 -or $r.RestartPid -le 0 -or + $r.WorkerPid -eq $r.RestartPid -or + @($r.Checks).Count -ne 15){throw 'R310_TYPED_NATIVE_JOURNAL_CLEANUP_FAILED'} + 'R310_SYNTHETIC_RETIRED_NONRECURSIVE_CRASH_CLEANUP=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -681,3 +711,4 @@ jobs: ${{ runner.temp }}/R307_${{ matrix.host }}_${{ github.run_id }}/R307_PROTECTED_JOURNAL_ACL_RECEIPT.json ${{ runner.temp }}/R308_${{ matrix.host }}_${{ github.run_id }}/R308_JOURNAL_PREWRITE_RECEIPT.json ${{ runner.temp }}/R309_${{ matrix.host }}_${{ github.run_id }}/R309_TERMINAL_REPLAY_RECEIPT.json + ${{ runner.temp }}/R310_${{ matrix.host }}_${{ github.run_id }}/R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json diff --git a/build/Test-R310-RetiredJournal-Native.ps1 b/build/Test-R310-RetiredJournal-Native.ps1 new file mode 100644 index 00000000..ece15b5c --- /dev/null +++ b/build/Test-R310-RetiredJournal-Native.ps1 @@ -0,0 +1,258 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir, + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){throw 'R310_DISPOSABLE_WINDOWS_ONLY'} +function SHA([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +function Reject([string]$label,[scriptblock]$probe,[string]$pattern){ + $denied=$false + try{& $probe}catch{ + if($_.Exception.ToString() -match $pattern){$denied=$true}else{throw} + } + if(!$denied){throw ('R310_EXPECTED_NEGATIVE_NOT_REFUSED_'+$label)} + $script:checks.Add($label) +} +$base=Join-Path $env:RUNNER_TEMP ('R310_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $base 'R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R309_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R309_TERMINAL_REPLAY_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R310_SETUP_PE_EMBEDDED_HELPER_MISSING'} +$reader=[IO.StreamReader]::new($stream) +try{$code=$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normal={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normal $code) -cne (& $normal ([IO.File]::ReadAllText($helper)))){ + throw 'R310_PRIVATE_PE_HELPER_SOURCE_IDENTITY_WRONG' +} +$t=$null;$err=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$err) +if(@($err).Count){throw 'R310_COMPILED_WINPS51_HELPER_PARSE_FAILED'} +$wanted=@('Assert-SensorBoundaryNoReparse','Get-SensorJournalHash', + 'Assert-SensorJournalRelative','Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique', + 'Assert-SensorDurableJournal','New-SensorDurableJournal','Get-SensorJournalTerminal', + 'Write-SensorJournalTerminal','Restore-SensorJournalOldFilesOnly', + 'Assert-SensorRetiredJournalSafe','Finish-SensorRetiredJournal', + 'Finish-SensorTerminalJournal') +$funcs=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -in $wanted +},$true)) +if($funcs.Count -ne 13){throw 'R310_PE_EXACT_13_FUNCTIONS_NOT_FOUND'} +. ([scriptblock]::Create(($funcs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R310_GUEST_ACCOUNT_NOT_INTERACTIVE'} +if($Mode -ne 'Driver'){ + if(-not [string]::Equals([IO.Path]::GetFullPath($Fixture), + [IO.Path]::GetFullPath($base),[StringComparison]::OrdinalIgnoreCase) -or + -not [IO.Directory]::Exists($base)){throw 'R310_UNKNOWN_CHILD_FIXTURE'} + $root=Join-Path (Join-Path $base 'protected') 'stage' + if($Mode -eq 'Worker'){ + if((Get-SensorJournalTerminal $root $sid) -cne 'COMMITTED'){ + throw 'R310_CHILD_SOURCE_NOT_COMMITTED' + } + [IO.Directory]::Move($root,$root+'-retired') + $part=Join-Path ($root+'-retired') 'files\TaskbarMonitorSensorBroker.exe' + if(-not [IO.File]::Exists($part)){throw 'R310_CHILD_SNAPSHOT_MISSING'} + [IO.File]::Delete($part) + [IO.File]::WriteAllText((Join-Path $base 'INTERRUPTION_CHECKPOINT'),'RETIRED_AFTER_FIRST_DELETE') + Start-Sleep -Seconds 30 + exit 0 + } + if($Mode -eq 'Recover'){ + Finish-SensorTerminalJournal $root $sid + [IO.File]::WriteAllText((Join-Path $base 'CLEANUP_RECOVERED'),'RETIRED_JOURNAL_CLEAN') + exit 0 + } +} +if([IO.Directory]::Exists($base)){throw 'R310_EXISTING_GUEST_TEST_ROOT'} +[void][IO.Directory]::CreateDirectory($base) +$oldHashes=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or $c.SetupSHA256 -cne (SHA $setup) -or + $c.MainSHA256 -cne $oldHashes.Main -or $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_SYNTHETIC_TERMINAL_AND_REAL_WORKER_COLD_FILE_REPLAY'){ + throw 'R310_EXACT_PREDECESSOR_OR_CANDIDATE_UNVERIFIED' +} +$checks.Add('COMPILED_PE_AND_PREDECESSOR_EXACT_SOURCE') +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admins) +$acl.SetAccessRuleProtection($true,$false) +foreach($id in @($admins,$system)){ + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $id,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) +} +$parent=Join-Path $base 'protected' +[void][IO.Directory]::CreateDirectory($parent,$acl) +Assert-SensorJournalTrustedAcl $parent $true +$checks.Add('TRUSTED_PROTECTED_PARENT_ACL') +$root=Join-Path $parent 'stage' +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[Text.Encoding]::ASCII.GetBytes('R310_PRIOR_BROKER')}, + [pscustomobject]@{Rel='settings\old.config';Data=[Text.Encoding]::ASCII.GetBytes('R310_PRIOR_CONFIG')} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','settings\old.config') +$xml='R310_PRIOR_TASK' +New-SensorDurableJournal $root $sid $rows $xml $next $true +Write-SensorJournalTerminal $root $sid 'COMMITTED' +if((Get-SensorJournalTerminal $root $sid) -cne 'COMMITTED'){throw 'R310_NO_TERMINAL_STATE'} +$checks.Add('COMMITTED_TERMINAL_PUBLISHED_AND_PINNED') +function Child([string]$mode){ + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $start=[Diagnostics.ProcessStartInfo]::new() + $start.FileName=$native + $start.Arguments=('-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+'"'+ + ' -Mode '+$mode+' -HostLabel '+$HostLabel+ + ' -CandidateDir "'+$CandidateDir+'" -Fixture "'+$base+'"') + $start.UseShellExecute=$false;$start.CreateNoWindow=$true + $start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + return [Diagnostics.Process]::Start($start) +} +$worker=Child Worker +if(!$worker){throw 'R310_WORKER_CREATE_FAILED'} +$workerPid=$worker.Id +$timer=[Diagnostics.Stopwatch]::StartNew() +try{ + while(-not [IO.File]::Exists((Join-Path $base 'INTERRUPTION_CHECKPOINT')) -and + -not $worker.HasExited -and $timer.ElapsedMilliseconds -lt 18000){ + Start-Sleep -Milliseconds 100 + } + if(-not [IO.File]::Exists((Join-Path $base 'INTERRUPTION_CHECKPOINT')) -or + $worker.HasExited){ + throw ('R310_CHILD_CHECKPOINT_FAILED '+$worker.StandardError.ReadToEnd()) + } + if([IO.Directory]::Exists($root) -or + -not [IO.Directory]::Exists($root+'-retired')){ + throw 'R310_ATOMIC_RENAME_NOT_OBSERVED' + } + $worker.Kill() + if(-not $worker.WaitForExit(9000) -or $worker.ExitCode -eq 0){ + throw 'R310_REAL_TEST_WORKER_DEATH_NOT_PROVEN' + } + $checks.Add('REAL_WORKER_KILLED_INSIDE_PARTIAL_RETIRED_CLEANUP') +}finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(9000)} + $worker.Dispose() +} +$restart=Child Recover +if(!$restart){throw 'R310_COLD_RECOVERY_CHILD_NOT_STARTED'} +$restartPid=$restart.Id +try{ + if(-not $restart.WaitForExit(20000) -or $restart.ExitCode -ne 0){ + $errorText=if($restart.HasExited){$restart.StandardError.ReadToEnd()}else{'TIMEOUT'} + if($errorText.Length -gt 1600){$errorText=$errorText.Substring(0,1600)} + throw ('R310_COLD_CLEANUP_FAILED '+$errorText) + } +}finally{ + if(-not $restart.HasExited){$restart.Kill();[void]$restart.WaitForExit(9000)} + $restart.Dispose() +} +if(-not [IO.File]::Exists((Join-Path $base 'CLEANUP_RECOVERED')) -or + [IO.Directory]::Exists($root) -or [IO.Directory]::Exists($root+'-retired') -or + $workerPid -eq $restartPid){throw 'R310_COLD_REPLAY_DID_NOT_RESTORE_EMPTY_JOURNAL'} +$checks.Add('SECOND_PROCESS_CLEARED_PARTIAL_TERMINAL_JOURNAL') +Finish-SensorTerminalJournal $root $sid +$checks.Add('EMPTY_COMPLETED_JOURNAL_CLEANUP_IDEMPOTENT') +# Negative: PREPARED journal may never be retired before rollback. +New-SensorDurableJournal $root $sid $rows $xml $next $true +Reject 'UNTERMINATED_PREPARED_JOURNAL_REFUSED' { + Finish-SensorTerminalJournal $root $sid +} 'SENSOR_JOURNAL_NOT_TERMINAL_DO_NOT_RETIRE' +if(-not [IO.Directory]::Exists($root)){throw 'R310_UNTERMINATED_JOURNAL_REMOVED'} +Write-SensorJournalTerminal $root $sid 'ROLLED_BACK' +$checks.Add('ROLLED_BACK_TERMINAL_IS_VALID_CLEANUP_TARGET') +# Unknown entry must fail BEFORE removing any existing snapshot. +$unexpected=Join-Path $root 'unknown.bin' +[IO.File]::WriteAllText($unexpected,'R310_UNKNOWN') +$backup=Join-Path $root 'files\TaskbarMonitorSensorBroker.exe' +$before=SHA $backup +Reject 'UNKNOWN_ACTIVE_MEMBER_FAIL_CLOSED' { + Finish-SensorTerminalJournal $root $sid +} 'SENSOR_JOURNAL_UNKNOWN_MEMBER' +if((SHA $backup) -cne $before){throw 'R310_UNKNOWN_MEMBER_DELETED_KNOWN_SNAPSHOT'} +[IO.File]::Delete($unexpected) +# Wrong token SID must refuse and leave full journal. +Reject 'FOREIGN_TOKEN_JOURNAL_PRESERVED' { + Finish-SensorTerminalJournal $root 'S-1-5-21-111-222-333-444' +} 'SENSOR_JOURNAL_EFFECTIVE_TOKEN_SID_MISMATCH' +if((SHA $backup) -cne $before){throw 'R310_FOREIGN_SID_DESTROYED_DATA'} +$outside=Join-Path $base 'unrelated-user' +[void][IO.Directory]::CreateDirectory($outside) +$canary=Join-Path $outside 'canary.txt' +[IO.File]::WriteAllText($canary,'R310_DO_NOT_DELETE') +$canaryHash=SHA $canary +$junction=Join-Path $root 'junction' +New-Item -ItemType Junction -Path $junction -Target $outside -ErrorAction Stop|Out-Null +Reject 'ACTIVE_JUNCTION_REFUSED' { + Finish-SensorTerminalJournal $root $sid +} 'SENSOR_JOURNAL_REPARSE_MEMBER' +if((SHA $canary) -cne $canaryHash){throw 'R310_JUNCTION_TARGET_TOUCHED'} +[IO.Directory]::Delete($junction) +Finish-SensorTerminalJournal $root $sid +if([IO.Directory]::Exists($root) -or [IO.Directory]::Exists($root+'-retired')){ + throw 'R310_ROLLED_BACK_FINISH_LEFT_RESIDUE' +} +$checks.Add('ROLLBACK_TERMINAL_NONRECURSIVE_CLEANUP') +# A retired journal containing an unknown file must not be silently purged. +New-SensorDurableJournal $root $sid $rows $xml $next $true +Write-SensorJournalTerminal $root $sid 'COMMITTED' +[IO.Directory]::Move($root,$root+'-retired') +$unknown=Join-Path ($root+'-retired') 'hostile.txt' +[IO.File]::WriteAllText($unknown,'USER_ADDED_FILE') +$protectedSnapshot=Join-Path ($root+'-retired') 'files\TaskbarMonitorSensorBroker.exe' +$priorHash=SHA $protectedSnapshot +Reject 'UNKNOWN_RETIRED_MEMBER_REFUSED_BEFORE_DELETE' { + Finish-SensorTerminalJournal $root $sid +} 'SENSOR_RETIRED_JOURNAL_UNKNOWN_MEMBER' +if((SHA $protectedSnapshot) -cne $priorHash){throw 'R310_UNKNOWN_RETIRED_DELETED_SNAPSHOT'} +[IO.File]::Delete($unknown) +Finish-SensorTerminalJournal $root $sid +if([IO.Directory]::Exists($root) -or [IO.Directory]::Exists($root+'-retired')){ + throw 'R310_RETIRED_RECOVERY_NOT_COMPLETE' +} +$checks.Add('UNKNOWN_RETIRED_NEGATIVE_AND_RETRY_CLEANUP') +if((SHA $canary) -cne $canaryHash -or (SHA $main) -cne $oldHashes.Main -or + (SHA $config) -cne $oldHashes.Config -or (SHA $state) -cne $oldHashes.State){ + throw 'R310_OWNER_OR_UNRELATED_CANARY_CHANGED' +} +$checks.Add('INSTALLED_MAIN_CONFIG_STATE_AND_CANARY_UNCHANGED') +$report=[ordered]@{ + Schema=1;Revision='R310';Status='PASS_SYNTHETIC_RETIRED_TERMINAL_PARTIAL_CLEANUP_RESTART' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;RealProgramFilesWritten=$false + ActualUACOrProtectedTaskModified=$false;ActualOriginalElevatedHelperKilled=$false + RealProofWorkerKilledDuringCleanup=$true;FreshProcessFinishedRetiredCleanup=$true + NonrecursiveKnownMemberDeletion=$true;UnknownFilesAndJunctionFailClosed=$true + ForeignSIDAndPreparedPhaseRefused=$true;TerminatedJournalEventuallyRemoved=$true + UserDataAndCanaryUnchanged=$true + WorkerPid=$workerPid;RestartPid=$restartPid + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($report|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R310_NATIVE_'+$HostLabel+'_PARTIAL_NONRECURSIVE_CLEANUP_PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index c97000f4..45c852b0 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -645,6 +645,183 @@ function Restore-SensorJournalOldFilesOnly( return [Text.Encoding]::UTF8.GetString($xml) } +# R310: terminal journal cleanup is a separate, bounded state machine. +# Never recursively delete or follow a reparse member. Retire the complete +# terminal journal to a fixed sibling before deleting any saved old bytes. +function Assert-SensorRetiredJournalSafe([string]$Retired,[string]$Sid){ + Assert-SensorJournalTrustedAcl $Retired $true + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals($effective.User.Value,$Sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_RETIRED_JOURNAL_WRONG_EFFECTIVE_TOKEN' + } + $members=@([IO.Directory]::GetFileSystemEntries($Retired)) + if($members.Count -eq 0){return $null} + $phase='' + $marker='' + foreach($p in @('COMMITTED','ROLLED_BACK')){ + $file=Join-Path $Retired ($p+'.terminal') + Assert-SensorBoundaryNoReparse $file + if(-not [IO.File]::Exists($file)){continue} + Assert-SensorJournalTrustedAcl $file $false + if([IO.File]::ReadAllText($file,[Text.Encoding]::ASCII) -cne + ('TBME_SENSOR_'+$p+'_V1')){ + throw 'SENSOR_RETIRED_JOURNAL_BAD_TERMINAL' + } + if($phase){throw 'SENSOR_RETIRED_JOURNAL_TWO_TERMINALS'} + $phase=$p + $marker=$file + } + if(!$phase){throw 'SENSOR_RETIRED_JOURNAL_NO_TERMINAL'} + $allowed=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + [void]$allowed.Add($marker) + $manifestFile=Join-Path $Retired 'manifest.json' + $saved=@() + if([IO.File]::Exists($manifestFile)){ + Assert-SensorJournalTrustedAcl $manifestFile $false + if(([IO.FileInfo]::new($manifestFile)).Length -gt 65536){ + throw 'SENSOR_RETIRED_JOURNAL_MANIFEST_OVERSIZE' + } + $m=[IO.File]::ReadAllText($manifestFile,[Text.Encoding]::UTF8)|ConvertFrom-Json + if($m.Schema -ne 1 -or $m.Phase -cne 'PREPARED' -or + $m.Sid -cne $Sid){throw 'SENSOR_RETIRED_JOURNAL_MANIFEST_OWNER_PHASE'} + if(@($m.Files).Count -gt 128 -or @($m.ExpectedNewFiles).Count -gt 64){ + throw 'SENSOR_RETIRED_JOURNAL_INVENTORY_SIZE' + } + $known=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $paths=[Collections.Generic.List[string]]::new() + foreach($row in @($m.Files)){ + $rel=[string]$row.Rel + Assert-SensorJournalRelative $rel + if(-not $known.Add($rel)){throw 'SENSOR_RETIRED_JOURNAL_DUPLICATE_ENTRY'} + $path=Join-Path (Join-Path $Retired 'files') $rel + $paths.Add($path) + [void]$allowed.Add($path) + $dir=[IO.Path]::GetDirectoryName($path) + $prefix=(Join-Path $Retired 'files') + while($dir.StartsWith($prefix+'\', [StringComparison]::OrdinalIgnoreCase)){ + [void]$allowed.Add($dir) + $dir=[IO.Path]::GetDirectoryName($dir) + } + } + $saved=@($paths.ToArray()) + foreach($name in @('manifest.json','task.xml','READY','files')){ + [void]$allowed.Add((Join-Path $Retired $name)) + } + # Terminal phase is published only after a fully verified snapshot. + # During cleanup any subset of previously saved files may be absent, + # but an unknown added file must still prevent destructive progress. + } + # Validate every present member before deleting anything. + $stack=[Collections.Generic.List[string]]::new() + $stack.Add($Retired) + while($stack.Count){ + $dir=$stack[$stack.Count-1] + $stack.RemoveAt($stack.Count-1) + Assert-SensorBoundaryNoReparse $dir + foreach($item in [IO.Directory]::GetFileSystemEntries($dir)){ + $full=[IO.Path]::GetFullPath($item) + $attributes=[IO.File]::GetAttributes($full) + if(($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'SENSOR_RETIRED_JOURNAL_REPARSE_REFUSED' + } + if(-not $allowed.Contains($full)){ + throw 'SENSOR_RETIRED_JOURNAL_UNKNOWN_MEMBER' + } + Assert-SensorJournalTrustedAcl $full $false + if(($attributes -band [IO.FileAttributes]::Directory) -ne 0){ + $stack.Add($full) + } + } + } + return [pscustomobject]@{Phase=$phase;Marker=$marker;Manifest=[IO.File]::Exists($manifestFile);Saved=$saved} +} +function Finish-SensorRetiredJournal([string]$Retired,[string]$Sid){ + if(-not [IO.Directory]::Exists($Retired)){return} + $info=Assert-SensorRetiredJournalSafe $Retired $Sid + if($null -eq $info){ + [IO.Directory]::Delete($Retired,$false) + return + } + if($info.Manifest){ + foreach($path in @($info.Saved)){ + Assert-SensorBoundaryNoReparse $path + if([IO.File]::Exists($path)){ + Assert-SensorJournalTrustedAcl $path $false + [IO.File]::Delete($path) + } + } + $files=Join-Path $Retired 'files' + if([IO.Directory]::Exists($files)){ + $folders=[Collections.Generic.List[string]]::new() + $pending=[Collections.Generic.List[string]]::new() + $pending.Add($files) + while($pending.Count){ + $current=$pending[$pending.Count-1] + $pending.RemoveAt($pending.Count-1) + $folders.Add($current) + foreach($entry in [IO.Directory]::GetFileSystemEntries($current)){ + $attrs=[IO.File]::GetAttributes($entry) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'SENSOR_RETIRED_JOURNAL_REPARSE_REFUSED' + } + if(($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + $pending.Add($entry) + }else{ + throw 'SENSOR_RETIRED_JOURNAL_NONEMPTY_FILES_DIR' + } + } + } + $order=@($folders|Sort-Object Length -Descending) + foreach($dir in $order){ + Assert-SensorBoundaryNoReparse $dir + [IO.Directory]::Delete($dir,$false) + } + } + foreach($name in @('task.xml','READY','manifest.json')){ + $path=Join-Path $Retired $name + Assert-SensorBoundaryNoReparse $path + if([IO.File]::Exists($path)){ + Assert-SensorJournalTrustedAcl $path $false + [IO.File]::Delete($path) + } + } + } + # Once manifest is gone ONLY the verified terminal marker may remain. + $remaining=@([IO.Directory]::GetFileSystemEntries($Retired)) + if($remaining.Count -ne 1 -or + -not [string]::Equals($remaining[0],$info.Marker,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_RETIRED_JOURNAL_CLEANUP_HAS_UNKNOWN_RESIDUE' + } + Assert-SensorJournalTrustedAcl $info.Marker $false + [IO.File]::Delete($info.Marker) + [IO.Directory]::Delete($Retired,$false) +} +function Finish-SensorTerminalJournal([string]$Root,[string]$Sid){ + $root=[IO.Path]::GetFullPath($Root) + $retired=$root+'-retired' + if(-not [string]::Equals([IO.Path]::GetDirectoryName($root), + [IO.Path]::GetDirectoryName($retired), + [StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_RETIRED_DIFFERENT_PARENT' + } + Assert-SensorBoundaryNoReparse $root + Assert-SensorBoundaryNoReparse $retired + # Always finish a previously retired journal first. If it fails, do not + # touch active journal or any protected Broker bytes. + if([IO.Directory]::Exists($retired)){ + Finish-SensorRetiredJournal $retired $Sid + } + if(-not [IO.Directory]::Exists($root)){return} + $terminal=Get-SensorJournalTerminal $root $Sid + if($terminal -ne 'COMMITTED' -and $terminal -ne 'ROLLED_BACK'){ + throw 'SENSOR_JOURNAL_NOT_TERMINAL_DO_NOT_RETIRE' + } + if([IO.Directory]::Exists($retired)){throw 'SENSOR_JOURNAL_RETIRED_COLLISION'} + [IO.Directory]::Move($root,$retired) + Finish-SensorRetiredJournal $retired $Sid +} + function Capture-PreviousSensorLayer { try{ Clear-SensorRollbackBuffer From 9188f422303dab6ef9666d514dd7b8d2395b3b16 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 20:18:14 +0330 Subject: [PATCH 052/135] security(experiment): complete healthy protected journal and resume terminal cleanup (R311) --- ...it-r286-optional-audio-native-recovery.yml | 30 +++ .../Test-R311-HealthyTerminalHooks-Native.ps1 | 215 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 86 +++++-- 3 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 build/Test-R311-HealthyTerminalHooks-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index b25fb3f9..0947b45c 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -682,6 +682,35 @@ jobs: $r.WorkerPid -eq $r.RestartPid -or @($r.Checks).Count -ne 15){throw 'R310_TYPED_NATIVE_JOURNAL_CLEANUP_FAILED'} 'R310_SYNTHETIC_RETIRED_NONRECURSIVE_CRASH_CLEANUP=PASS' + - name: R311 verify successful-only protected journal terminal hooks and fail-closed pending + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R311-HealthyTerminalHooks-Native.ps1).Path + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$t,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R311_PS51_TEST_SYNTAX_INVALID'} + ./build/Test-R311-HealthyTerminalHooks-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R311_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R311_RAW_TYPED_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R311' -or + $r.Status -cne 'PASS_SYNTHETIC_NORMAL_HEALTHY_TERMINAL_HOOKS' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualProgramFilesWritten -ne $false -or + $r.ActualUACPerformed -ne $false -or + $r.ActualScheduledTaskModified -ne $false -or + $r.OriginalElevatedHelperKilled -ne $false -or + $r.ExactPeEntrypointHookOrderValidated -ne $true -or + $r.HealthyCommittedAndRetired -ne $true -or + $r.UnhealthyAndPreparedPreserved -ne $true -or + $r.ColdTerminalCleanupCompleted -ne $true -or + $r.WrongSidReparseUnknownRefused -ne $true -or + $r.ActualTaskXmlColdRestoreDone -ne $false -or + $r.InstalledOwnerDataUnchanged -ne $true -or + @($r.Checks).Count -ne 17){throw 'R311_NATIVE_ACCEPTANCE_GATES_UNMET'} + 'R311_HEALTHY_COMMIT_AND_PENDING_PREPARED_FAIL_CLOSED=PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -712,3 +741,4 @@ jobs: ${{ runner.temp }}/R308_${{ matrix.host }}_${{ github.run_id }}/R308_JOURNAL_PREWRITE_RECEIPT.json ${{ runner.temp }}/R309_${{ matrix.host }}_${{ github.run_id }}/R309_TERMINAL_REPLAY_RECEIPT.json ${{ runner.temp }}/R310_${{ matrix.host }}_${{ github.run_id }}/R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json + ${{ runner.temp }}/R311_${{ matrix.host }}_${{ github.run_id }}/R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json diff --git a/build/Test-R311-HealthyTerminalHooks-Native.ps1 b/build/Test-R311-HealthyTerminalHooks-Native.ps1 new file mode 100644 index 00000000..35b83daa --- /dev/null +++ b/build/Test-R311-HealthyTerminalHooks-Native.ps1 @@ -0,0 +1,215 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R311_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +function FileSha([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function MustReject([string]$label,[scriptblock]$callback,[string]$pattern){ + $rejected=$false + try{& $callback}catch{ + if($_.Exception.ToString() -match $pattern){$rejected=$true}else{throw} + } + if(-not $rejected){throw ('R311_MISSING_REQUIRED_REFUSAL_'+$label)} + $script:checks.Add($label) +} +$root=Join-Path $env:RUNNER_TEMP ('R311_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R310_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($root)){throw 'R311_TEST_ROOT_PREEXISTS'} +[void][IO.Directory]::CreateDirectory($root) +$source=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$previous=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +$original=@{Main=(FileSha $main);Config=(FileSha $config);State=(FileSha $state)} +if($source.Commit -cne $env:GITHUB_SHA -or $source.SetupSHA256 -cne (FileSha $setup) -or + $source.MainSHA256 -cne $original.Main -or + $previous.SourceCommit -cne $env:GITHUB_SHA -or + $previous.Host -cne $HostLabel -or + $previous.Status -cne 'PASS_SYNTHETIC_RETIRED_TERMINAL_PARTIAL_CLEANUP_RESTART'){ + throw 'R311_PREDECESSOR_OR_EXACT_PE_CANDIDATE_INVALID' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$res=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$res){throw 'R311_TRUSTED_EMBEDDED_HELPER_MISSING'} +$reader=[IO.StreamReader]::new($res) +try{$embedded=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$raw=[IO.File]::ReadAllText($helper) +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize $raw)){throw 'R311_PE_RESOURCE_SOURCE_NOT_IDENTICAL'} +$checks.Add('EMBEDDED_PE_RESOURCE_EQUALS_CURRENT_SECURITY_HELPER') +$ownerGuard=$raw.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) +$preflight=$raw.IndexOf('Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal) +$uninstall=$raw.IndexOf('if($Mode -eq ''Uninstall'')',[StringComparison]::Ordinal) +$writeAhead=$raw.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal) +$health=$raw.IndexOf('if(-not $taskInstalled -or -not $SupervisorHealthy)',[StringComparison]::Ordinal) +$finalize=$raw.IndexOf('Complete-HealthySensorJournal $SensorJournalRoot $validatedSensorSid $taskInstalled $SupervisorHealthy',[StringComparison]::Ordinal) +$memRelease=$raw.LastIndexOf('Clear-SensorRollbackBuffer',[StringComparison]::Ordinal) +if($ownerGuard -lt 0 -or $preflight -le $ownerGuard -or $uninstall -le $preflight -or + $writeAhead -le $uninstall -or $health -le $writeAhead -or + $finalize -le $health -or $memRelease -le $finalize){ + throw 'R311_REAL_HELPER_HOOK_ORDER_NOT_PREWRITE_OR_PRE_RESULT' +} +$checks.Add('PREWRITE_OWNER_GUARD_BEFORE_INSTALL_AND_TERMINAL_BEFORE_RESULT') +$tok=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tok,[ref]$errors) +if(@($errors).Count){throw 'R311_WINPS51_PARSE_OF_PE_RESOURCE_FAILED'} +$wanted=@('Assert-SensorBoundaryNoReparse','Get-SensorJournalHash', + 'Assert-SensorJournalRelative','Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique', + 'Assert-SensorDurableJournal','New-SensorDurableJournal','Get-SensorJournalTerminal', + 'Write-SensorJournalTerminal','Restore-SensorJournalOldFilesOnly','Assert-SensorRetiredJournalSafe', + 'Finish-SensorRetiredJournal','Finish-SensorTerminalJournal', + 'Resume-SensorCompletedJournal','Complete-HealthySensorJournal') +$found=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -in $wanted +},$true)) +if($found.Count -ne $wanted.Count){throw 'R311_TRUSTED_PRODUCTION_HOOK_FUNCTIONS_MISSING'} +. ([scriptblock]::Create(($found|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$checks.Add('FIFTEEN_PRODUCTION_SECURITY_FUNCTIONS_LOAD_NATIVE') +$sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R311_RUNNER_SID_NOT_INTERACTIVE'} +$admin=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admin) +$acl.SetAccessRuleProtection($true,$false) +foreach($principal in @($admin,$system)){ + $entry=[Security.AccessControl.FileSystemAccessRule]::new( + $principal,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($entry) +} +$parent=Join-Path $root 'protected-parent' +[void][IO.Directory]::CreateDirectory($parent,$acl) +Assert-SensorJournalTrustedAcl $parent $true +$checks.Add('SYNTHETIC_PROTECTED_PARENT_ACL_ACCEPTED') +$stage=Join-Path $parent 'journal' +$originalData=[Text.Encoding]::ASCII.GetBytes('R311_PREVIOUS_TRUSTED_BROKER') +$rows=[object[]]@([pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=$originalData}) +$names=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','.tbme-owner.sid') +$task='R311_KNOWN_PREVIOUS' +Resume-SensorCompletedJournal $stage $sid +$checks.Add('CLEAN_FIRST_INSTALL_PRECHECK_NOOP') +function NewFixture(){ + New-SensorDurableJournal $stage $sid $rows $task $names $true + if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_NEW_FIXTURE_NOT_PREPARED' + } +} +NewFixture +MustReject 'UNHEALTHY_SUPERVISOR_NEVER_COMMITS' { + Complete-HealthySensorJournal $stage $sid $true $false +} 'SENSOR_JOURNAL_SUPERVISOR_NOT_HEALTHY_NO_COMMIT' +if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_UNHEALTHY_BRANCH_REMOVED_READY' +} +MustReject 'NO_OWNED_TASK_NEVER_COMMITS' { + Complete-HealthySensorJournal $stage $sid $false $true +} 'SENSOR_JOURNAL_SUPERVISOR_NOT_HEALTHY_NO_COMMIT' +if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_NO_TASK_WAS_INCORRECTLY_COMMITTED' +} +MustReject 'PARTIAL_UNCOMMITTED_JOURNAL_BLOCKS_NEW_INSTALL' { + Resume-SensorCompletedJournal $stage $sid +} 'SENSOR_JOURNAL_NOT_TERMINAL_DO_NOT_RETIRE' +if(-not [IO.Directory]::Exists($stage)){throw 'R311_PARTIAL_JOURNAL_REMOVED'} +Complete-HealthySensorJournal $stage $sid $true $true +if([IO.Directory]::Exists($stage) -or [IO.Directory]::Exists($stage+'-retired')){ + throw 'R311_HEALTHY_COMMIT_LEFT_RESIDUE' +} +$checks.Add('HEALTHY_TASK_AND_SUPERVISOR_COMMIT_AND_RETIRE') +Resume-SensorCompletedJournal $stage $sid +$checks.Add('REPEAT_ENTRYPOINT_AFTER_CLEAN_COMMIT_NOOP') +NewFixture +Write-SensorJournalTerminal $stage $sid 'COMMITTED' +Resume-SensorCompletedJournal $stage $sid +if([IO.Directory]::Exists($stage) -or [IO.Directory]::Exists($stage+'-retired')){ + throw 'R311_COLD_COMMITTED_COMPLETION_LEFT_RESIDUE' +} +$checks.Add('NEXT_PROCESS_VALID_COMMITTED_MARKER_RETIRED') +NewFixture +Write-SensorJournalTerminal $stage $sid 'ROLLED_BACK' +Resume-SensorCompletedJournal $stage $sid +if([IO.Directory]::Exists($stage) -or [IO.Directory]::Exists($stage+'-retired')){ + throw 'R311_VERIFIED_ROLLED_BACK_TERMINAL_WAS_NOT_RETIRED' +} +$checks.Add('OLD_TERMINAL_ROLLED_BACK_RETIRED_ON_ENTRY') +NewFixture +MustReject 'FOREIGN_EFFECTIVE_SID_CANNOT_COMPLETE_TERMINAL' { + Complete-HealthySensorJournal $stage 'S-1-5-21-111-222-333-444' $true $true +} 'SENSOR_JOURNAL_COMMIT_EFFECTIVE_SID_MISMATCH' +if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_FOREIGN_SID_COMMITTED_JOURNAL' +} +MustReject 'FOREIGN_ID_CANNOT_CLEAN_EXISTING_READY' { + Resume-SensorCompletedJournal $stage 'S-1-5-21-111-222-333-444' +} 'SENSOR_JOURNAL_EFFECTIVE_TOKEN_SID_MISMATCH' +$unknown=Join-Path $stage 'unowned.txt' +[IO.File]::WriteAllText($unknown,'R311_UNKNOWN',[Text.Encoding]::ASCII) +MustReject 'UNKNOWN_JOURNAL_MEMBER_NEVER_RETIRED' { + Resume-SensorCompletedJournal $stage $sid +} 'SENSOR_JOURNAL_UNKNOWN_MEMBER' +if(-not [IO.Directory]::Exists($stage)){throw 'R311_UNKNOWN_JOURNAL_DELETED'} +[IO.File]::Delete($unknown) +$extern=Join-Path $root 'unrelated' +[void][IO.Directory]::CreateDirectory($extern) +$canary=Join-Path $extern 'preserve.txt' +[IO.File]::WriteAllText($canary,'R311_USER_CANARY',[Text.Encoding]::ASCII) +$canarySha=FileSha $canary +$junction=Join-Path $stage 'fake-junction' +New-Item -ItemType Junction -Path $junction -Target $extern -ErrorAction Stop|Out-Null +MustReject 'JUNCTION_JOURNAL_NEVER_RETIRED' { + Resume-SensorCompletedJournal $stage $sid +} 'SENSOR_JOURNAL_REPARSE_MEMBER' +if((FileSha $canary) -cne $canarySha){throw 'R311_JUNCTION_CANARY_MODIFIED'} +[IO.Directory]::Delete($junction) +if((Get-SensorJournalTerminal $stage $sid) -cne 'PREPARED'){ + throw 'R311_NEGATIVE_BRANCH_CHANGED_JOURNAL' +} +$checks.Add('PREPARED_SNAPSHOT_UNCHANGED_AFTER_NEGATIVE_CASES') +if((FileSha $main) -cne $original.Main -or + (FileSha $config) -cne $original.Config -or + (FileSha $state) -cne $original.State -or + (FileSha $canary) -cne $canarySha){ + throw 'R311_GUEST_OR_EXTERNAL_DATA_CHANGED' +} +$checks.Add('OFFICIAL_MAIN_SETTINGS_AND_OUTSIDE_CANARY_PRESERVED') +$result=[ordered]@{ + Schema=1;Revision='R311';Status='PASS_SYNTHETIC_NORMAL_HEALTHY_TERMINAL_HOOKS' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;ActualProgramFilesWritten=$false + ActualUACPerformed=$false;ActualScheduledTaskModified=$false + OriginalElevatedHelperKilled=$false + ExactPeEntrypointHookOrderValidated=$true + HealthyCommittedAndRetired=$true + UnhealthyAndPreparedPreserved=$true + ColdTerminalCleanupCompleted=$true + WrongSidReparseUnknownRefused=$true + ActualTaskXmlColdRestoreDone=$false + InstalledOwnerDataUnchanged=$true + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R311_'+$HostLabel+'_PRODUCTION_HEALTHY_HOOKS_SYNTHETIC_PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 45c852b0..a164df99 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -822,6 +822,43 @@ function Finish-SensorTerminalJournal([string]$Root,[string]$Sid){ Finish-SensorRetiredJournal $retired $Sid } +# R311 integrates already-tested R309/R310 terminal machinery into the +# normal protected install success path, not into unverified task restoration. +function Resume-SensorCompletedJournal([string]$Root,[string]$Sid){ + Assert-SensorBoundaryNoReparse $Root + Assert-SensorBoundaryNoReparse ($Root+'-retired') + # R310 prioritizes finishing a prior retired state. Then an active, + # uncommitted PREPARED state MUST fail closed, never guessed complete. + Finish-SensorTerminalJournal $Root $Sid + if([IO.Directory]::Exists($Root) -or [IO.Directory]::Exists($Root+'-retired')){ + throw 'SENSOR_JOURNAL_RECOVERY_REQUIRED' + } +} +function Complete-HealthySensorJournal( + [string]$Root,[string]$Sid,[bool]$TaskInstalled,[bool]$SupervisorHealthy +){ + if(-not $TaskInstalled -or -not $SupervisorHealthy){ + throw 'SENSOR_JOURNAL_SUPERVISOR_NOT_HEALTHY_NO_COMMIT' + } + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$Sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_COMMIT_EFFECTIVE_SID_MISMATCH' + } + $phase=Get-SensorJournalTerminal $Root $Sid + if($phase -cne 'PREPARED'){ + throw 'SENSOR_JOURNAL_COMMIT_NONPREPARED_REFUSED' + } + Write-SensorJournalTerminal $Root $Sid 'COMMITTED' + # A crash after writing COMMITTED is recoverable by the entrypoint + # above; a crash during retirement is recoverable by R310's bounded + # nonrecursive retired journal cleanup. + Finish-SensorTerminalJournal $Root $Sid + if([IO.Directory]::Exists($Root) -or [IO.Directory]::Exists($Root+'-retired')){ + throw 'SENSOR_JOURNAL_COMMIT_RETIRE_RESIDUE' + } +} + function Capture-PreviousSensorLayer { try{ Clear-SensorRollbackBuffer @@ -938,14 +975,15 @@ try{ } # R264-SENSOR-GUARD-BOUNDARY-END -# R308: no automatic recovery/cleanup until an independently validated -# terminal protocol exists. Existing or partial journal blocks further -# protected writes; the per-user main application remains usable. -Assert-SensorBoundaryNoReparse $SensorJournalRoot -if(Test-Path -LiteralPath $SensorJournalRoot){ +# R311: before protected changes finish only already-terminal, +# same-owner, SHA-validated journals. PREPARED remains fail-closed until +# actual prior task/file cold recovery is independently proven. +try{ + Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid +}catch{ $script:ActiveArchitecture='PROTECTED_JOURNAL_RECOVERY_REQUIRED' - Write-Log 'R308_PENDING_PROTECTED_JOURNAL_NO_NEW_PROTECTED_MUTATION' - Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'A protected sensor update journal is pending validation. Protected sensors were not modified; a verified recovery procedure is required.' 'NOT_STARTED' $null $false $false $false + Write-Log ('R311_PENDING_PROTECTED_JOURNAL_NO_MUTATION '+$_.Exception.Message) + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'A protected sensor update journal is not safely terminal, and must be recovered under its verified owner account. No new protected sensor mutation was started.' 'NOT_STARTED' $null $false $false $false exit 0 } @@ -1265,18 +1303,32 @@ if($taskInstalled -and -not$SupervisorHealthy -and -not$rebootRequired){ } if($RollbackPerformed){ - if($RollbackSucceeded){ - Clear-SensorRollbackBuffer - Write-Log 'R21_INSTALL_DEGRADED_ROLLED_BACK' - Write-Result 'DEGRADED_ROLLED_BACK' 'R21 protected sensor validation did not pass, so the previous protected sensor layer was restored automatically. The main application remains usable; use Repair protected sensors to retry R21 later.' $pawnStatus $pawnExit $false $false $false - }else{ - Write-Log 'R21_INSTALL_DEGRADED_ROLLBACK_FAILED' - Write-Result 'DEGRADED_ROLLBACK_FAILED' 'R21 protected sensor validation did not pass and automatic rollback also failed. The main application remains usable, but protected telemetry requires repair.' $pawnStatus $pawnExit $false $false $false - } + # The in-memory rollback may have returned success, but its old task XML + # has NOT yet been independently verified by a cold privileged process. + # Keep PREPARED and its prior bytes; do not assert ROLLED_BACK or delete. + Write-Log ('R311_INPROCESS_ROLLBACK_JOURNAL_PENDING verifiedInMemory='+$RollbackSucceeded) + Clear-SensorRollbackBuffer + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'Protected sensor installation attempted rollback, but durable previous task and file recovery is not yet independently verified. The journal was retained for verified recovery; per-user monitoring remains usable.' $pawnStatus $pawnExit $false $false $false exit 0 } - -# Retire the in-memory snapshot after a successful terminal path. +if(-not $taskInstalled -or -not $SupervisorHealthy){ + # No claim that a partially installed protected layer is committed. + Write-Log 'R311_PROTECTED_HEALTH_NOT_COMMITTED_KEEP_READY' + Clear-SensorRollbackBuffer + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'Protected sensor activation was not verified healthy; the rollback snapshot is retained rather than incorrectly committed or discarded.' $pawnStatus $pawnExit $false $false $false + exit 0 +} +try{ + Complete-HealthySensorJournal $SensorJournalRoot $validatedSensorSid $taskInstalled $SupervisorHealthy + Write-Log 'R311_PROTECTED_JOURNAL_COMMITTED_AND_RETIRED' +}catch{ + Write-Log ('R311_TERMINAL_COMMIT_CLEANUP_DEGRADED '+$_.Exception.Message) + Clear-SensorRollbackBuffer + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'Protected sensors reached their health check, but the journal terminal completion could not be verified. The retained journal must be reconciled before another protected update.' $pawnStatus $pawnExit $false $false $false + exit 0 +} +# Verified protected healthy path completed and the durable terminal +# snapshot was retired; now release its redundant in-memory backup. Clear-SensorRollbackBuffer if($SupervisorHealthy -and $healthy){ Write-Log "R21_SENSOR_READY cpu=$currentC" From 4192b5d53fa20f54060dd6e96dc7eb2c4db6b637 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 20:22:53 +0330 Subject: [PATCH 053/135] test: accept completed journal preflight while retaining R308 write-order guard (R311-F01) --- build/Test-R308-ProtectedPrewrite-Native.ps1 | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/build/Test-R308-ProtectedPrewrite-Native.ps1 b/build/Test-R308-ProtectedPrewrite-Native.ps1 index 5ca3a3cb..d5eb4335 100644 --- a/build/Test-R308-ProtectedPrewrite-Native.ps1 +++ b/build/Test-R308-ProtectedPrewrite-Native.ps1 @@ -60,8 +60,13 @@ if((& $normalize $embedded) -cne (& $normalize $raw)){ $checks.Add('SAME_EXACT_PE_EMBEDDED_HELPER_SOURCE') $save=$raw.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid ',[StringComparison]::Ordinal) $mutation=$raw.IndexOf(' Assert-OrRecordSensorOwner',[StringComparison]::Ordinal) -$stale=$raw.IndexOf('if(Test-Path -LiteralPath $SensorJournalRoot)',[StringComparison]::Ordinal) -if($stale -lt 0 -or $save -lt 0 -or $mutation -le $save){ +$ownerBoundary=$raw.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) +$legacyStale=$raw.IndexOf('if(Test-Path -LiteralPath $SensorJournalRoot)',[StringComparison]::Ordinal) +$protectedPreflight=$raw.IndexOf('Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal) +if($save -lt 0 -or $mutation -le $save -or $ownerBoundary -lt 0 -or + ($legacyStale -lt 0 -and $protectedPreflight -lt 0) -or + ($protectedPreflight -ge 0 -and + ($protectedPreflight -le $ownerBoundary -or $protectedPreflight -ge $save))){ throw 'R308_JOURNAL_NOT_BEFORE_FIRST_PROTECTED_MUTATION' } $checks.Add('STALE_JOURNAL_REFUSAL_AND_PREMUTATION_ORDER') From 75e0b24385466afc58ab919071956914785cb6a5 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 20:27:00 +0330 Subject: [PATCH 054/135] test: require all eighteen observed R311 native hook checks (F02) --- .github/workflows/audit-r286-optional-audio-native-recovery.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 0947b45c..cb021dfa 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -709,7 +709,7 @@ jobs: $r.WrongSidReparseUnknownRefused -ne $true -or $r.ActualTaskXmlColdRestoreDone -ne $false -or $r.InstalledOwnerDataUnchanged -ne $true -or - @($r.Checks).Count -ne 17){throw 'R311_NATIVE_ACCEPTANCE_GATES_UNMET'} + @($r.Checks).Count -ne 18){throw 'R311_NATIVE_ACCEPTANCE_GATES_UNMET'} 'R311_HEALTHY_COMMIT_AND_PENDING_PREPARED_FAIL_CLOSED=PASS' - name: Preserve bounded guest evidence if: always() From cce11b15ef65c3cbe2b9982e969ee8dfab0c7270 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 20:43:52 +0330 Subject: [PATCH 055/135] security(experiment): pin prior ScheduledTask XML before cold restoration (R312) --- ...it-r286-optional-audio-native-recovery.yml | 27 +++ build/Test-R312-PinnedTaskXml-Native.ps1 | 176 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 94 ++++++++++ 3 files changed, 297 insertions(+) create mode 100644 build/Test-R312-PinnedTaskXml-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index cb021dfa..3cb68d97 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -711,6 +711,32 @@ jobs: $r.InstalledOwnerDataUnchanged -ne $true -or @($r.Checks).Count -ne 18){throw 'R311_NATIVE_ACCEPTANCE_GATES_UNMET'} 'R311_HEALTHY_COMMIT_AND_PENDING_PREPARED_FAIL_CLOSED=PASS' + - name: R312 reject foreign or executable-task XML before any cold ScheduledTask restore + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R312-PinnedTaskXml-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R312_PS51_NATIVE_TEST_SYNTAX_INVALID'} + ./build/Test-R312-PinnedTaskXml-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R312_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R312_TRUSTED_TASK_XML_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R312_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R312' -or + $r.Status -cne 'PASS_EXACT_PRODUCTION_TASK_XML_POLICY_SYNTHETIC' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.ActualScheduledTaskModified -ne $false -or + $r.ExactPEPolicyFunctionsLoaded -ne $true -or + $r.LegitimateTaskXmlAccepted -ne $true -or + $r.CommandPrincipalTriggerAndXmlInjectionRejected -ne $true -or + $r.InstalledOwnerDataUnchanged -ne $true -or + $r.ProductionColdTaskRegistrationIntegrated -ne $false -or + @($r.Checks).Count -ne 18){throw 'R312_NATIVE_TASK_XML_POLICY_GATES_UNMET'} + 'R312_PINNED_TASK_XML_POLICY_SYNTHETIC=PASS_NO_TASK_REGISTRATION' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -742,3 +768,4 @@ jobs: ${{ runner.temp }}/R309_${{ matrix.host }}_${{ github.run_id }}/R309_TERMINAL_REPLAY_RECEIPT.json ${{ runner.temp }}/R310_${{ matrix.host }}_${{ github.run_id }}/R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json ${{ runner.temp }}/R311_${{ matrix.host }}_${{ github.run_id }}/R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json + ${{ runner.temp }}/R312_${{ matrix.host }}_${{ github.run_id }}/R312_TRUSTED_TASK_XML_RECEIPT.json diff --git a/build/Test-R312-PinnedTaskXml-Native.ps1 b/build/Test-R312-PinnedTaskXml-Native.ps1 new file mode 100644 index 00000000..fa47c938 --- /dev/null +++ b/build/Test-R312-PinnedTaskXml-Native.ps1 @@ -0,0 +1,176 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R312_DISPOSABLE_WINDOWS_GUEST_ONLY' +} +function Hash([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function AssertRefused([string]$name,[string]$document,[string]$expected){ + $failed=$false + try{[void](Assert-SensorTrustedOldTaskXml $document $sid $brokerRoot $appRoot)} + catch{if($_.Exception.ToString() -match $expected){$failed=$true}else{throw}} + if(-not $failed){throw ('R312_UNSAFE_TASK_XML_ACCEPTED_'+$name)} + $script:checks.Add($name) +} +function ParseDoc(){ + $document=[Xml.XmlDocument]::new();$document.XmlResolver=$null + $document.LoadXml($validXML) + return $document +} +function Ns([Xml.XmlDocument]$doc){ + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable) + $ns.AddNamespace('t','http://schemas.microsoft.com/windows/2004/02/mit/task') + return ,$ns +} +$root=Join-Path $env:RUNNER_TEMP ('R312_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R312_TRUSTED_TASK_XML_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R311_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($root)){throw 'R312_DUPLICATE_TEST_ROOT'} +[void][IO.Directory]::CreateDirectory($root) +$before=@{Main=(Hash $main);Config=(Hash $config);State=(Hash $state)} +$c=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or $c.SetupSHA256 -cne (Hash $setup) -or + $c.MainSHA256 -cne $before.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_SYNTHETIC_NORMAL_HEALTHY_TERMINAL_HOOKS'){ + throw 'R312_EXACT_PRIVATE_CANDIDATE_OR_PREDECESSOR_NOT_VERIFIED' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$res=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$res){throw 'R312_NO_EMBEDDED_PE_HELPER'} +$reader=[IO.StreamReader]::new($res) +try{$embedded=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($helper)))){ + throw 'R312_PE_HELPER_SOURCE_UNPINNED' +} +$checks.Add('EXACT_PE_SOURCE_WITH_NO_UNTRUSTED_SCRIPT_FILE') +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R312_POWERSHELL51_SOURCE_PARSER_FAILED'} +$wanted=@('Resolve-SensorPriorTaskPrincipalSid','Assert-SensorTrustedOldTaskXml') +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -in $wanted +},$true)) +if($defs.Count -ne 2){throw 'R312_EXACT_PRODUCTION_XML_GUARDS_MISSING'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$checks.Add('TWO_PURE_PRODUCTION_PE_XML_FUNCTIONS_LOADED') +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'R312_DISPOSABLE_RUNNER_NOT_INTERACTIVE_SID' +} +$brokerRoot=Join-Path $root 'ProtectedBroker' +$appRoot=Join-Path $root 'AppRoot' +$supervisor=Join-Path $brokerRoot 'TaskbarMonitorSensorSupervisor.exe' +$broker=Join-Path $brokerRoot 'TaskbarMonitorSensorBroker.exe' +$out=Join-Path $appRoot 'cpu_temp_broker.json' +$args='--broker "'+$broker+'" --output "'+$out+'"' +$uri='\TaskbarMonitorEnhanced Sensor Broker' +$escSid=[Security.SecurityElement]::Escape([string]$sid) +$escExe=[Security.SecurityElement]::Escape($supervisor) +$escArgs=[Security.SecurityElement]::Escape($args) +$validXML=@" + + $uri + true$escSid + $escSidInteractiveTokenHighestAvailable + truetrue + $escExe$escArgs + +"@ +if(-not (Assert-SensorTrustedOldTaskXml $validXML $sid $brokerRoot $appRoot)){ + throw 'R312_VALID_OWNED_SYNTHETIC_XML_REFUSED' +} +$checks.Add('SYNTHETIC_PRIOR_TASK_EXACT_SID_ACTION_TRIGGER_ACCEPTED') +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command',$n).InnerText='C:\Windows\System32\cmd.exe' +AssertRefused 'ALTERED_EXECUTABLE_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_EXEC_NOT_PINNED' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments',$n).InnerText+=' & altered' +AssertRefused 'ARGUMENT_INJECTION_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_EXEC_NOT_PINNED' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId',$n).InnerText='S-1-5-21-111-222-333-444' +AssertRefused 'FOREIGN_PRINCIPAL_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_FOREIGN_PRINCIPAL_OR_PRIVILEGES' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Triggers/t:LogonTrigger/t:UserId',$n).InnerText='S-1-5-21-111-222-333-444' +AssertRefused 'FOREIGN_LOGON_TRIGGER_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_WRONG_LOGON_USER' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:RunLevel',$n).InnerText='LeastPrivilege' +AssertRefused 'CHANGED_PRIVILEGE_LEVEL_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_FOREIGN_PRINCIPAL_OR_PRIVILEGES' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:LogonType',$n).InnerText='Password' +AssertRefused 'DIFFERENT_LOGON_TYPE_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_FOREIGN_PRINCIPAL_OR_PRIVILEGES' +$d=ParseDoc;$n=Ns $d +$exec=$d.SelectSingleNode('/t:Task/t:Actions/t:Exec',$n) +$working=$d.CreateElement('WorkingDirectory','http://schemas.microsoft.com/windows/2004/02/mit/task') +$working.InnerText='C:\Windows\Temp' +[void]$exec.AppendChild($working) +AssertRefused 'UNPINNED_WORKDIR_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_EXEC_SHAPE_UNEXPECTED' +$d=ParseDoc;$n=Ns $d +$trig=$d.SelectSingleNode('/t:Task/t:Triggers/t:LogonTrigger',$n) +$newTrig=$d.CreateElement('RegistrationTrigger','http://schemas.microsoft.com/windows/2004/02/mit/task') +[void]$trig.ParentNode.ReplaceChild($newTrig,$trig) +AssertRefused 'REGISTRATION_TRIGGER_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_UNEXPECTED_ACTION_OR_TRIGGER' +$d=ParseDoc;$n=Ns $d +$actions=$d.SelectSingleNode('/t:Task/t:Actions',$n) +$com=$d.CreateElement('ComHandler','http://schemas.microsoft.com/windows/2004/02/mit/task') +[void]$actions.AppendChild($com) +AssertRefused 'SECOND_ACTION_COM_HANDLER_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_UNEXPECTED_ACTION_OR_TRIGGER' +$d=ParseDoc;$n=Ns $d +$info=$d.SelectSingleNode('/t:Task/t:RegistrationInfo',$n) +$sd=$d.CreateElement('SecurityDescriptor','http://schemas.microsoft.com/windows/2004/02/mit/task') +$sd.InnerText='D:(A;;GA;;;WD)' +[void]$info.AppendChild($sd) +AssertRefused 'SECURITY_DESCRIPTOR_ESCALATION_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_EMBEDDED_SECURITY_DESCRIPTOR_REFUSED' +$d=ParseDoc;$n=Ns $d +$d.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI',$n).InnerText='\Another Task' +AssertRefused 'TASK_URI_IMPERSONATION_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_URI_UNPINNED' +$d=ParseDoc;$n=Ns $d +$principal=$d.SelectSingleNode('/t:Task/t:Principals/t:Principal',$n) +$group=$d.CreateElement('GroupId','http://schemas.microsoft.com/windows/2004/02/mit/task') +$group.InnerText='BUILTIN\Administrators' +[void]$principal.AppendChild($group) +AssertRefused 'GROUP_PRINCIPAL_INJECTION_REFUSED' $d.OuterXml 'SENSOR_JOURNAL_TASK_XML_GROUP_PRINCIPAL_FORBIDDEN' +AssertRefused 'XML_DTD_ENTITY_PROHIBITED' (']>'+$validXML) 'DTD|DOCTYPE|XmlException' +AssertRefused 'MISSING_TASK_XML_REFUSED' '' 'SENSOR_JOURNAL_TASK_XML_MISSING_OR_OVERSIZE' +if((Hash $main) -cne $before.Main -or (Hash $config) -cne $before.Config -or + (Hash $state) -cne $before.State){ + throw 'R312_NO_REAL_TASK_TEST_MUTATED_INSTALLED_OWNER' +} +$checks.Add('INSTALLED_MAIN_AND_SETTINGS_UNCHANGED') +$record=[ordered]@{ + Schema=1;Revision='R312' + Status='PASS_EXACT_PRODUCTION_TASK_XML_POLICY_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;ActualProgramFilesWritten=$false + ActualUACInvoked=$false;ActualScheduledTaskModified=$false + ExactPEPolicyFunctionsLoaded=$true;LegitimateTaskXmlAccepted=$true + CommandPrincipalTriggerAndXmlInjectionRejected=$true + InstalledOwnerDataUnchanged=$true + ProductionColdTaskRegistrationIntegrated=$false + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R312_NATIVE_'+$HostLabel+'_PINNED_TASK_XML_PURE_POLICY_PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index a164df99..27d8aa67 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -859,6 +859,100 @@ function Complete-HealthySensorJournal( } } +# R312: replaying a prior Scheduled Task XML can register executable actions. +# Only the exact prior same-SID LogonTrigger and pinned Supervisor Exec are +# allowed. Never permit RegistrationTrigger, alternate command or XML DTD. +function Resolve-SensorPriorTaskPrincipalSid([string]$name){ + if([string]::IsNullOrWhiteSpace($name)){throw 'SENSOR_JOURNAL_TASK_XML_EMPTY_IDENTITY'} + if($name -match '^S-1-5-(?:\d+-)+\d+$'){return $name} + try{ + return [string]([Security.Principal.NTAccount]::new($name)).Translate( + [Security.Principal.SecurityIdentifier]).Value + }catch{throw 'SENSOR_JOURNAL_TASK_XML_IDENTITY_UNRESOLVABLE'} +} +function Assert-SensorTrustedOldTaskXml( + [string]$Text,[string]$ExpectedSid,[string]$TrustedBrokerRoot,[string]$TrustedAppRoot +){ + if([string]::IsNullOrWhiteSpace($Text) -or $Text.Length -gt 262144){ + throw 'SENSOR_JOURNAL_TASK_XML_MISSING_OR_OVERSIZE' + } + if($ExpectedSid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'SENSOR_JOURNAL_TASK_XML_EXPECTED_SID_UNTRUSTED' + } + $xml=[Xml.XmlDocument]::new() + $xml.XmlResolver=$null + $settings=[Xml.XmlReaderSettings]::new() + $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit + $settings.XmlResolver=$null + $settings.MaxCharactersInDocument=262144 + $settings.MaxCharactersFromEntities=0 + $settings.IgnoreComments=$false + $sr=[IO.StringReader]::new($Text) + $reader=$null + try{ + $reader=[Xml.XmlReader]::Create($sr,$settings) + $xml.Load($reader) + }finally{ + if($reader){$reader.Dispose()} + $sr.Dispose() + } + $n=[Xml.XmlNamespaceManager]::new($xml.NameTable) + $n.AddNamespace('t','http://schemas.microsoft.com/windows/2004/02/mit/task') + if($null -eq $xml.DocumentElement -or + $xml.DocumentElement.LocalName -cne 'Task' -or + $xml.DocumentElement.NamespaceURI -cne 'http://schemas.microsoft.com/windows/2004/02/mit/task'){ + throw 'SENSOR_JOURNAL_TASK_XML_UNSUPPORTED_NAMESPACE' + } + $principals=@($xml.SelectNodes('/t:Task/t:Principals/t:Principal',$n)) + $actions=@($xml.SelectNodes('/t:Task/t:Actions/*',$n)) + $triggers=@($xml.SelectNodes('/t:Task/t:Triggers/*',$n)) + if($principals.Count -ne 1 -or $actions.Count -ne 1 -or + $triggers.Count -ne 1 -or $actions[0].LocalName -cne 'Exec' -or + $triggers[0].LocalName -cne 'LogonTrigger'){ + throw 'SENSOR_JOURNAL_TASK_XML_UNEXPECTED_ACTION_OR_TRIGGER' + } + $principal=$principals[0] + $uid=$principal.SelectSingleNode('t:UserId',$n) + $logon=$principal.SelectSingleNode('t:LogonType',$n) + $run=$principal.SelectSingleNode('t:RunLevel',$n) + if(!$uid -or !$logon -or !$run -or + (Resolve-SensorPriorTaskPrincipalSid ([string]$uid.InnerText)) -cne $ExpectedSid -or + [string]$logon.InnerText -cne 'InteractiveToken' -or + [string]$run.InnerText -cne 'HighestAvailable'){ + throw 'SENSOR_JOURNAL_TASK_XML_FOREIGN_PRINCIPAL_OR_PRIVILEGES' + } + $triggerUid=$triggers[0].SelectSingleNode('t:UserId',$n) + if(!$triggerUid -or + (Resolve-SensorPriorTaskPrincipalSid ([string]$triggerUid.InnerText)) -cne $ExpectedSid){ + throw 'SENSOR_JOURNAL_TASK_XML_WRONG_LOGON_USER' + } + $exe=$actions[0] + $command=$exe.SelectSingleNode('t:Command',$n) + $args=$exe.SelectSingleNode('t:Arguments',$n) + if(!$command -or !$args -or $exe.SelectSingleNode('t:WorkingDirectory',$n)){ + throw 'SENSOR_JOURNAL_TASK_XML_EXEC_SHAPE_UNEXPECTED' + } + $expectedExe=Join-Path $TrustedBrokerRoot 'TaskbarMonitorSensorSupervisor.exe' + $expectedBroker=Join-Path $TrustedBrokerRoot 'TaskbarMonitorSensorBroker.exe' + $expectedOutput=Join-Path $TrustedAppRoot 'cpu_temp_broker.json' + $expectedArgs='--broker "'+$expectedBroker+'" --output "'+$expectedOutput+'"' + if(-not [string]::Equals([string]$command.InnerText,$expectedExe,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$args.InnerText,$expectedArgs,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_JOURNAL_TASK_XML_EXEC_NOT_PINNED' + } + $uri=$xml.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI',$n) + if($uri -and [string]$uri.InnerText -cne '\TaskbarMonitorEnhanced Sensor Broker'){ + throw 'SENSOR_JOURNAL_TASK_XML_URI_UNPINNED' + } + if($principal.SelectSingleNode('t:GroupId',$n)){ + throw 'SENSOR_JOURNAL_TASK_XML_GROUP_PRINCIPAL_FORBIDDEN' + } + if($xml.SelectSingleNode('/t:Task/t:RegistrationInfo/t:SecurityDescriptor',$n)){ + throw 'SENSOR_JOURNAL_TASK_XML_EMBEDDED_SECURITY_DESCRIPTOR_REFUSED' + } + return $true +} + function Capture-PreviousSensorLayer { try{ Clear-SensorRollbackBuffer From 8fe64a6b3b9a36555b6a230737fca09537f961e4 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 20:58:10 +0330 Subject: [PATCH 056/135] test: roundtrip strictly scoped disabled ScheduledTask XML in disposable CI (R313) --- ...it-r286-optional-audio-native-recovery.yml | 31 +++ ...st-R313-DisposableScheduledTask-Native.ps1 | 216 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 build/Test-R313-DisposableScheduledTask-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 3cb68d97..627125c6 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -737,6 +737,36 @@ jobs: $r.ProductionColdTaskRegistrationIntegrated -ne $false -or @($r.Checks).Count -ne 18){throw 'R312_NATIVE_TASK_XML_POLICY_GATES_UNMET'} 'R312_PINNED_TASK_XML_POLICY_SYNTHETIC=PASS_NO_TASK_REGISTRATION' + - name: R313 register and remove only unique disabled disposable ScheduledTask XML + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R313-DisposableScheduledTask-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R313_PS51_TEST_PARSER_INVALID'} + ./build/Test-R313-DisposableScheduledTask-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R313_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R313_TASK_REGISTRATION_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R313_RAW_SCHEDULER_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R313' -or + $r.Status -cne 'PASS_EPHEMERAL_DISABLED_SCHEDULED_TASK_REGISTER_EXPORT_UNREGISTER' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualProtectedSensorTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACPerformed -ne $false -or + $r.RegisteredOnlyUniqueDisposableTask -ne $true -or + $r.NeverEnabledOrExecuted -ne $true -or + $r.NoForceRegistration -ne $true -or + $r.ColdRegistrationPerformedOnDisposableTask -ne $true -or + $r.ExactPrincipalAndCommandReadback -ne $true -or + $r.DisposableTaskAbsentAfterTest -ne $true -or + $r.UserFilesUnchanged -ne $true -or + $r.ProductionPriorTaskColdRestoreIntegrated -ne $false -or + [string]::IsNullOrEmpty($r.ExportXmlSHA256) -or + @($r.Checks).Count -ne 12){throw 'R313_TYPED_NATIVE_SCHEDULER_GATES_NOT_MET'} + 'R313_DISABLED_EPHEMERAL_TASK_XML_REAL_SCHEDULER_ROUNDTRIP_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -769,3 +799,4 @@ jobs: ${{ runner.temp }}/R310_${{ matrix.host }}_${{ github.run_id }}/R310_RETIRED_JOURNAL_CLEANUP_RECEIPT.json ${{ runner.temp }}/R311_${{ matrix.host }}_${{ github.run_id }}/R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json ${{ runner.temp }}/R312_${{ matrix.host }}_${{ github.run_id }}/R312_TRUSTED_TASK_XML_RECEIPT.json + ${{ runner.temp }}/R313_${{ matrix.host }}_${{ github.run_id }}/R313_TASK_REGISTRATION_RECEIPT.json diff --git a/build/Test-R313-DisposableScheduledTask-Native.ps1 b/build/Test-R313-DisposableScheduledTask-Native.ps1 new file mode 100644 index 00000000..2649c645 --- /dev/null +++ b/build/Test-R313-DisposableScheduledTask-Native.ps1 @@ -0,0 +1,216 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R313_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +Import-Module ScheduledTasks -ErrorAction Stop +function Hash([string]$name){ + if(-not [IO.File]::Exists($name)){return 'MISSING'} + return (Get-FileHash -LiteralPath $name -Algorithm SHA256).Hash +} +function Absent([string]$task){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $task -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}) + if($items.Count -ne 0){throw ('R313_EPHEMERAL_TASK_UNEXPECTEDLY_PRESENT_'+$task)} +} +function ParseDocument([string]$source){ + $settings=[Xml.XmlReaderSettings]::new() + $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit + $settings.XmlResolver=$null + $settings.MaxCharactersInDocument=262144 + $sr=[IO.StringReader]::new($source) + $reader=$null + try{ + $reader=[Xml.XmlReader]::Create($sr,$settings) + $doc=[Xml.XmlDocument]::new() + $doc.XmlResolver=$null + $doc.Load($reader) + return $doc + }finally{ + if($reader){$reader.Dispose()} + $sr.Dispose() + } +} +function Namespace([Xml.XmlDocument]$doc){ + $n=[Xml.XmlNamespaceManager]::new($doc.NameTable) + $n.AddNamespace('t','http://schemas.microsoft.com/windows/2004/02/mit/task') + return ,$n +} +$fixture=Join-Path $env:RUNNER_TEMP ('R313_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $fixture 'R313_TASK_REGISTRATION_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$previous=Join-Path $env:RUNNER_TEMP ('R312_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R312_TRUSTED_TASK_XML_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($fixture)){throw 'R313_FIXTURE_COLLISION'} +[void][IO.Directory]::CreateDirectory($fixture) +$initial=@{Main=(Hash $main);Config=(Hash $config);State=(Hash $state)} +$source=Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json +$prior=Get-Content -LiteralPath $previous -Raw | ConvertFrom-Json +if($source.Commit -cne $env:GITHUB_SHA -or + $source.SetupSHA256 -cne (Hash $setup) -or + $source.MainSHA256 -cne $initial.Main -or + $prior.SourceCommit -cne $env:GITHUB_SHA -or + $prior.Host -cne $HostLabel -or + $prior.Status -cne 'PASS_EXACT_PRODUCTION_TASK_XML_POLICY_SYNTHETIC'){ + throw 'R313_EXACT_CANDIDATE_OR_PRIOR_NATIVE_PROOF_INVALID' +} +$checks.Add('EXACT_SOURCE_AND_R312_PREDECESSOR_BOUND') +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$resource=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$resource){throw 'R313_EMBEDDED_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$norm={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $embedded) -cne (& $norm ([IO.File]::ReadAllText($helper)))){ + throw 'R313_HELPER_SOURCE_NOT_EXACT_PE' +} +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R313_PE_HELPER_PS51_PARSER_BAD'} +$desired=@('Resolve-SensorPriorTaskPrincipalSid','Assert-SensorTrustedOldTaskXml') +$defs=@($ast.FindAll({param($x) + $x -is [Management.Automation.Language.FunctionDefinitionAst] -and + $x.Name -in $desired +},$true)) +if($defs.Count -ne 2){throw 'R313_PE_TASK_XML_GUARD_NOT_FOUND'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +$checks.Add('ONLY_EXACT_PE_TASK_XML_GUARD_EXTRACTED') +$sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R313_HOSTED_USER_SID_NOT_INTERACTIVE'} +$taskName='TBME-R313-Disposable-'+$env:GITHUB_RUN_ID+'-'+$HostLabel +if($taskName -cnotmatch '^TBME-R313-Disposable-[0-9]{5,20}-[AB]$'){ + throw 'R313_BAD_EPHEMERAL_TASK_NAME' +} +Absent $taskName +$realOriginal=@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}) +if($realOriginal.Count -ne 0){throw 'R313_PROTECTED_SENSOR_TASK_PRESENT_ON_GUEST_REFUSE_TEST'} +$checks.Add('UNIQUE_UNUSED_EPHEMERAL_TASK_AND_NO_REAL_PROTECTED_TASK') +$brokerRoot=Join-Path $fixture 'synthetic-protected-broker' +$appRoot=Join-Path $fixture 'synthetic-user-app' +$supervisor=Join-Path $brokerRoot 'TaskbarMonitorSensorSupervisor.exe' +$broker=Join-Path $brokerRoot 'TaskbarMonitorSensorBroker.exe' +$out=Join-Path $appRoot 'cpu_temp_broker.json' +if([IO.File]::Exists($supervisor) -or [IO.File]::Exists($broker)){ + throw 'R313_SYNTHETIC_ACTION_EXECUTABLE_ACTUALLY_PRESENT' +} +$args='--broker "'+$broker+'" --output "'+$out+'"' +$sidSafe=[Security.SecurityElement]::Escape($sid) +$commandSafe=[Security.SecurityElement]::Escape($supervisor) +$argsSafe=[Security.SecurityElement]::Escape($args) +$xml=@" + +false$sidSafe +$sidSafeInteractiveTokenHighestAvailable +falsefalsetrue +$commandSafe$argsSafe + +"@ +# The URI is intentionally omitted in the disposable XML. Task Scheduler +# assigns this task's unique name; the real fixed-owner XML uses a pinned URI. +if(-not (Assert-SensorTrustedOldTaskXml $xml $sid $brokerRoot $appRoot)){ + throw 'R313_SAFE_DISABLED_XML_REFUSED_BEFORE_REGISTRATION' +} +$checks.Add('PE_GUARD_ACCEPTS_DISABLED_NONEXECUTING_EPHEMERAL_XML') +$created=$false +$registeredTwice=$false +$exportHash='' +try{ + # Never -Force. Name was checked absent immediately before registration. + Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Xml $xml -ErrorAction Stop | Out-Null + $created=$true + $checks.Add('EPHEMERAL_TASK_REGISTERED_WITHOUT_FORCE_OR_UAC') + $reg=Get-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction Stop + if([string]$reg.TaskName -cne $taskName -or + [bool]$reg.Settings.Enabled -ne $false -or + [string]$reg.State -cne 'Disabled' -or + @($reg.Actions).Count -ne 1 -or + @($reg.Triggers).Count -ne 1 -or + [bool]$reg.Triggers[0].Enabled -ne $false -or + -not [string]::Equals([string]$reg.Actions[0].Execute,$supervisor,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$reg.Actions[0].Arguments,$args,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R313_REGISTERED_TASK_NOT_NONEXECUTING_AND_PINNED' + } + $checks.Add('READBACK_DISABLING_AND_EXACT_ACTION_PINNED') + $exported=[string](Export-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction Stop) + $doc=ParseDocument $exported;$n=Namespace $doc + $uriNode=$doc.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI',$n) + $sidNode=$doc.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId',$n) + $commandNode=$doc.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command',$n) + $argNode=$doc.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments',$n) + if(!$sidNode -or !$commandNode -or !$argNode -or + (Resolve-SensorPriorTaskPrincipalSid ([string]$sidNode.InnerText)) -cne $sid -or + -not [string]::Equals([string]$commandNode.InnerText,$supervisor,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$argNode.InnerText,$args,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R313_EXPORT_XML_PRINCIPAL_ACTION_CHANGED' + } + if($uriNode -and [string]$uriNode.InnerText -cne ('\'+$taskName)){ + throw 'R313_DISPOSABLE_TASK_URI_NOT_SCOPED' + } + $exportHash=[BitConverter]::ToString(([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($exported)))).Replace('-','') + $checks.Add('EXPORT_XML_EXACT_TASK_NAME_SID_ACTION_VERIFIED') + Unregister-ScheduledTask -TaskName $taskName -TaskPath '\' -Confirm:$false -ErrorAction Stop + $created=$false + Absent $taskName + $checks.Add('FIRST_EPHEMERAL_TASK_REMOVED_AND_ABSENCE_RECHECKED') + # A cold task-restore roundtrip of the same validated XML, still disabled. + Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Xml $xml -ErrorAction Stop|Out-Null + $created=$true + $registeredTwice=$true + $t=Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction Stop + if($t.State -ne 'Disabled' -or [bool]$t.Settings.Enabled -ne $false){ + throw 'R313_SECOND_REGISTER_NOT_DISABLED' + } + $checks.Add('SECOND_SAME_USER_REGISTRATION_DISABLED') +}finally{ + if($created){ + Unregister-ScheduledTask -TaskName $taskName -TaskPath '\' -Confirm:$false -ErrorAction Stop + } +} +Absent $taskName +$checks.Add('SECOND_DISPOSABLE_TASK_FULL_CLEANUP_VERIFIED') +if(@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}).Count -ne 0){ + throw 'R313_UNEXPECTED_ACTUAL_PROTECTED_TASK_CREATED' +} +$checks.Add('ACTUAL_PROTECTED_SENSOR_TASK_NEVER_PRESENT') +if([IO.File]::Exists($supervisor) -or + (Hash $main) -cne $initial.Main -or + (Hash $config) -cne $initial.Config -or + (Hash $state) -cne $initial.State){ + throw 'R313_GUEST_USER_PROGRAM_FILES_OR_FAKE_EXEC_CHANGED' +} +$checks.Add('INSTALLED_USER_DATA_AND_ACTION_TARGET_UNCHANGED') +$result=[ordered]@{ + Schema=1;Revision='R313' + Status='PASS_EPHEMERAL_DISABLED_SCHEDULED_TASK_REGISTER_EXPORT_UNREGISTER' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false;ActualProtectedSensorTaskTouched=$false + RealProgramFilesWritten=$false;ActualUACPerformed=$false + RegisteredOnlyUniqueDisposableTask=$true + NeverEnabledOrExecuted=$true;NoForceRegistration=$true + ColdRegistrationPerformedOnDisposableTask=$registeredTwice + ExactPrincipalAndCommandReadback=$true + DisposableTaskAbsentAfterTest=$true;UserFilesUnchanged=$true + ProductionPriorTaskColdRestoreIntegrated=$false + ExportXmlSHA256=$exportHash + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) +'R313_'+$HostLabel+'_SCHEDULER_EPHEMERAL_DISABLED_XML_ROUNDTRIP_PASS' From 0eab6704bc70ac35d8fc408257d3287f06247f54 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 21:13:45 +0330 Subject: [PATCH 057/135] security(experiment): cold replay prior task and files on disposable Windows (R314) --- ...it-r286-optional-audio-native-recovery.yml | 32 ++ build/Test-R314-ColdTaskReplay-Native.ps1 | 312 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 131 ++++++++ 3 files changed, 475 insertions(+) create mode 100644 build/Test-R314-ColdTaskReplay-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 627125c6..15f71d1f 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -767,6 +767,37 @@ jobs: [string]::IsNullOrEmpty($r.ExportXmlSHA256) -or @($r.Checks).Count -ne 12){throw 'R313_TYPED_NATIVE_SCHEDULER_GATES_NOT_MET'} 'R313_DISABLED_EPHEMERAL_TASK_XML_REAL_SCHEDULER_ROUNDTRIP_PASS' + - name: R314 kill worker after task loss and restore disabled task with exact previous files + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R314-ColdTaskReplay-Native.ps1).Path + $tok=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R314_NATIVE_WINPS51_TEST_PARSER_INVALID'} + ./build/Test-R314-ColdTaskReplay-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R314_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R314_TASK_COLD_RESTORE_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R314_EXACT_GUEST_PROOF_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R314' -or + $r.Status -cne 'PASS_DISPOSABLE_TASK_AND_FILES_COLD_RESTORE_AFTER_KILLED_WORKER' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerHostTouched -ne $false -or + $r.OriginalProtectedTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.OriginalElevatedHelperKilled -ne $false -or + $r.RealSyntheticWorkerKilled -ne $true -or + $r.FreshProcessRestoredActualDisposableScheduledTask -ne $true -or + $r.FilesRestoredByteExact -ne $true -or + $r.TaskDisabledNeverExecuted -ne $true -or + $r.JournalTerminalAndRetiredCleanupComplete -ne $true -or + $r.ForeignSidTamperAndUnknownFileRejectedPrewrite -ne $true -or + $r.OriginalAppDataUnchanged -ne $true -or + $r.WorkerPid -le 0 -or $r.RecoveryPid -le 0 -or + $r.WorkerPid -eq $r.RecoveryPid -or + @($r.Checks).Count -ne 14){throw 'R314_COLD_TASK_AND_FILES_PROOF_GATES_UNMET'} + 'R314_REAL_DISPOSABLE_SCHEDULER_COLD_RESTORE_PASS_NO_UAC' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -800,3 +831,4 @@ jobs: ${{ runner.temp }}/R311_${{ matrix.host }}_${{ github.run_id }}/R311_HEALTHY_TERMINAL_HOOK_RECEIPT.json ${{ runner.temp }}/R312_${{ matrix.host }}_${{ github.run_id }}/R312_TRUSTED_TASK_XML_RECEIPT.json ${{ runner.temp }}/R313_${{ matrix.host }}_${{ github.run_id }}/R313_TASK_REGISTRATION_RECEIPT.json + ${{ runner.temp }}/R314_${{ matrix.host }}_${{ github.run_id }}/R314_TASK_COLD_RESTORE_RECEIPT.json diff --git a/build/Test-R314-ColdTaskReplay-Native.ps1 b/build/Test-R314-ColdTaskReplay-Native.ps1 new file mode 100644 index 00000000..88ab1251 --- /dev/null +++ b/build/Test-R314-ColdTaskReplay-Native.ps1 @@ -0,0 +1,312 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir, + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrEmpty($env:GITHUB_SHA) -or + [string]::IsNullOrEmpty($env:GITHUB_RUN_ID) -or + [string]::IsNullOrEmpty($env:RUNNER_TEMP)){ + throw 'R314_HOSTED_WINDOWS_DISPOSABLE_ONLY' +} +function FileHash([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Absent([string]$name){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}) + if($items.Count){throw 'R314_PREEXISTING_DISPOSABLE_TASK'} +} +function Reject([string]$label,[scriptblock]$fn,[string]$pattern){ + $failed=$false + try{& $fn}catch{if($_.Exception.ToString() -match $pattern){$failed=$true}else{throw}} + if(-not $failed){throw ('R314_EXPECTED_PREWRITE_REFUSAL_MISSING_'+$label)} + $script:checks.Add($label) +} +$run=Join-Path $env:RUNNER_TEMP ('R314_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $run 'R314_TASK_COLD_RESTORE_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R313_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R313_TASK_REGISTRATION_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$res=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$res){throw 'R314_COMPILED_PE_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($res) +try{$source=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normal={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normal $source) -cne (& $normal ([IO.File]::ReadAllText($helper)))){ + throw 'R314_SETUP_PE_HELPER_SOURCE_MISMATCH' +} +$t=$null;$e=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$e) +if(@($e).Count){throw 'R314_HELPER_PS51_PARSER_INVALID'} +$names=@( + 'Assert-SensorBoundaryNoReparse','Get-SensorJournalHash','Assert-SensorJournalRelative', + 'Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique','Assert-SensorDurableJournal', + 'New-SensorDurableJournal','Get-SensorJournalTerminal','Write-SensorJournalTerminal', + 'Restore-SensorJournalOldFilesOnly','Assert-SensorRetiredJournalSafe', + 'Finish-SensorRetiredJournal','Finish-SensorTerminalJournal', + 'Resolve-SensorPriorTaskPrincipalSid','Assert-SensorTrustedOldTaskXml', + 'Test-SensorOwnedTaskRegistration','Restore-SensorJournalPriorLayerAndTask' +) +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -in $names +},$true)) +if($defs.Count -ne $names.Count){throw 'R314_EXACT_COMPILED_FUNCTIONS_NOT_FOUND'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +Import-Module ScheduledTasks -ErrorAction Stop +$sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R314_RUNNER_SID_NOT_INTERACTIVE'} +$name='TBME-R314-Disposable-'+$env:GITHUB_RUN_ID+'-'+$HostLabel +if($name -cnotmatch '^TBME-R314-Disposable-[0-9]{5,20}-[AB]$'){throw 'R314_UNSAFE_TASK_NAME'} +$journal=Join-Path (Join-Path $run 'trusted') 'snapshot' +$live=Join-Path $run 'live-broker' +$appRoot=Join-Path $run 'user-data' +$oldBroker=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$oldCfg=Join-Path $live 'settings\old.bin' +$newSupervisor=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' +$outside=Join-Path $run 'unrelated' +$canary=Join-Path $outside 'leave-alone.txt' +if($Mode -ne 'Driver'){ + if(-not [string]::Equals([IO.Path]::GetFullPath($Fixture),[IO.Path]::GetFullPath($run),[StringComparison]::OrdinalIgnoreCase) -or + -not [IO.Directory]::Exists($run)){ + throw 'R314_CHILD_FIXTURE_IDENTITY_MISMATCH' + } + if($Mode -eq 'Worker'){ + if(-not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R314_WORKER_PREVIOUS_TASK_NOT_PRESENT' + } + Unregister-ScheduledTask -TaskPath '\' -TaskName $name -Confirm:$false -ErrorAction Stop + [IO.File]::WriteAllBytes($oldBroker,[Text.Encoding]::ASCII.GetBytes('R314_WRONG_NEW_BROKER')) + [IO.File]::WriteAllBytes($newSupervisor,[Text.Encoding]::ASCII.GetBytes('R314_WRONG_NEW_SUPERVISOR')) + [IO.File]::WriteAllText((Join-Path $run 'WORKER_CHECKPOINT'),'TASK_REMOVED_AND_LIVE_BROKER_MUTATED') + Start-Sleep -Seconds 30 + exit 0 + } + if($Mode -eq 'Recover'){ + Restore-SensorJournalPriorLayerAndTask $journal $sid $live $appRoot $name + [IO.File]::WriteAllText((Join-Path $run 'RECOVERED'),'OLD_TASK_AND_FILES_RESTORED') + exit 0 + } +} +if([IO.Directory]::Exists($run)){throw 'R314_TEST_ROOT_ALREADY_EXISTS'} +[void][IO.Directory]::CreateDirectory($run) +$initial=@{Main=(FileHash $main);Config=(FileHash $config);State=(FileHash $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (FileHash $setup) -or + $c.MainSHA256 -cne $initial.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_EPHEMERAL_DISABLED_SCHEDULED_TASK_REGISTER_EXPORT_UNREGISTER'){ + throw 'R314_CANDIDATE_OR_PREVIOUS_RECEIPT_NOT_EXACT' +} +$checks.Add('EXACT_PE_HELPER_AND_PREVIOUS_HOST_RECEIPT') +Absent $name +$existingReal=@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}) +if($existingReal.Count){throw 'R314_REAL_PROTECTED_TASK_PREEXISTS_GUEST'} +$checks.Add('ONLY_UNIQUE_EPHEMERAL_TASK_ALLOWED') +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) +foreach($s in @($admins,$system)){ + $entry=[Security.AccessControl.FileSystemAccessRule]::new( + $s,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($entry) +} +[void][IO.Directory]::CreateDirectory((Join-Path $run 'trusted'),$acl) +Assert-SensorJournalTrustedAcl (Join-Path $run 'trusted') $true +$checks.Add('PROTECTED_JOURNAL_DACL_OWNER_VERIFIED') +[void][IO.Directory]::CreateDirectory((Join-Path $live 'settings')) +[void][IO.Directory]::CreateDirectory($appRoot) +[void][IO.Directory]::CreateDirectory($outside) +[IO.File]::WriteAllBytes($oldBroker,[Text.Encoding]::ASCII.GetBytes('R314_TRUSTED_PREVIOUS_BROKER')) +[IO.File]::WriteAllBytes($oldCfg,[Text.Encoding]::ASCII.GetBytes('R314_TRUSTED_PREVIOUS_CONFIG')) +[IO.File]::WriteAllText($canary,'R314_CANARY_IMMUTABLE') +$oldHash=FileHash $oldBroker;$cfgHash=FileHash $oldCfg;$canaryHash=FileHash $canary +$supervisor=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' +$broker=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$target=Join-Path $appRoot 'cpu_temp_broker.json' +$args='--broker "'+$broker+'" --output "'+$target+'"' +$escapedSid=[Security.SecurityElement]::Escape($sid) +$escapedCmd=[Security.SecurityElement]::Escape($supervisor) +$escapedArgs=[Security.SecurityElement]::Escape($args) +$xml=@" + +false$escapedSid +$escapedSidInteractiveTokenHighestAvailable +falsefalse +$escapedCmd$escapedArgs + +"@ +if(-not (Assert-SensorTrustedOldTaskXml $xml $sid $live $appRoot)){ + throw 'R314_OLD_TASK_XML_NOT_PREVALIDATED' +} +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[IO.File]::ReadAllBytes($oldBroker)}, + [pscustomobject]@{Rel='settings\old.bin';Data=[IO.File]::ReadAllBytes($oldCfg)} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','settings\old.bin') +New-SensorDurableJournal $journal $sid $rows $xml $next $true +$checks.Add('JOURNAL_READY_WITH_HASHED_PRIOR_TASK_AND_FILES') +$created=$false +$workerPid=0 +$recoveryPid=0 +try{ + Register-ScheduledTask -TaskPath '\' -TaskName $name -Xml $xml -ErrorAction Stop|Out-Null + $created=$true + if(-not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R314_INITIAL_TASK_NOT_OWNED_AND_PINNED' + } + $checks.Add('DISABLED_PRIOR_SCHEDULER_TASK_REGISTERED_AND_VERIFIED') + # Reject foreign SID, tainted XML, or rogue live files before interrupting. + $foreign='S-1-5-21-101-202-303-404' + Reject 'FOREIGN_SID_COLD_REPLAY_REFUSED' { + Restore-SensorJournalPriorLayerAndTask $journal $foreign $live $appRoot $name + } 'SENSOR_COLD_JOURNAL_EFFECTIVE_SID_MISMATCH' + $taskFile=Join-Path $journal 'task.xml' + $oldXmlBytes=[IO.File]::ReadAllBytes($taskFile) + [IO.File]::WriteAllText($taskFile,'R314_TAINTED_XML') + Reject 'TAMPERED_SAVED_TASK_REFUSED_PREWRITE' { + Restore-SensorJournalPriorLayerAndTask $journal $sid $live $appRoot $name + } 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' + [IO.File]::WriteAllBytes($taskFile,$oldXmlBytes) + $bad=Join-Path $live 'unknown-file.txt' + [IO.File]::WriteAllText($bad,'R314_UNKNOWN') + Reject 'UNKNOWN_LIVE_FILE_REFUSED_BEFORE_REPLAY' { + Restore-SensorJournalPriorLayerAndTask $journal $sid $live $appRoot $name + } 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' + [IO.File]::Delete($bad) + if((FileHash $oldBroker) -cne $oldHash -or (FileHash $oldCfg) -cne $cfgHash -or + -not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R314_NEGATIVE_CASE_MUTATED_LIVE_DATA' + } + $checks.Add('ALL_NEGATIVES_LEAVE_LIVE_AND_TASK_UNCHANGED') + function Child([string]$mode){ + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments=('-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+'"'+ + ' -Mode '+$mode+' -HostLabel '+$HostLabel+ + ' -CandidateDir "'+$CandidateDir+'" -Fixture "'+$run+'"') + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) + } + $worker=Child Worker + if(!$worker){throw 'R314_WORKER_START_FAIL'} + $workerPid=$worker.Id + try{ + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not [IO.File]::Exists((Join-Path $run 'WORKER_CHECKPOINT')) -and + -not $worker.HasExited -and $clock.ElapsedMilliseconds -lt 18000){ + Start-Sleep -Milliseconds 100 + } + if(-not [IO.File]::Exists((Join-Path $run 'WORKER_CHECKPOINT')) -or $worker.HasExited){ + throw ('R314_MUTATION_CHECKPOINT_NOT_REACHED '+$worker.StandardError.ReadToEnd()) + } + Absent $name + if((FileHash $oldBroker) -ceq $oldHash -or + (Get-SensorJournalTerminal $journal $sid) -cne 'PREPARED'){ + throw 'R314_TASK_LOSS_OR_MUTATION_NOT_REPRODUCED' + } + $worker.Kill() + if(-not $worker.WaitForExit(9000) -or $worker.ExitCode -eq 0){ + throw 'R314_TEST_WORKER_KILL_NOT_REAL' + } + $checks.Add('REAL_CHILD_KILLED_AFTER_SCHEDULED_TASK_DELETION_AND_FILE_MUTATION') + }finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(9000)} + $worker.Dispose() + } + $child=Child Recover + if(!$child){throw 'R314_RECOVERY_CHILD_NOT_STARTED'} + $recoveryPid=$child.Id + try{ + if(-not $child.WaitForExit(22000) -or $child.ExitCode -ne 0){ + $msg=if($child.HasExited){$child.StandardError.ReadToEnd()}else{'TIMEOUT'} + if($msg.Length -gt 1800){$msg=$msg.Substring(0,1800)} + throw ('R314_FRESH_PROCESS_TASK_RESTORE_FAILED '+$msg) + } + }finally{ + if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(9000)} + $child.Dispose() + } + if(-not [IO.File]::Exists((Join-Path $run 'RECOVERED')) -or + $recoveryPid -eq $workerPid -or + (FileHash $oldBroker) -cne $oldHash -or + (FileHash $oldCfg) -cne $cfgHash -or + [IO.File]::Exists($newSupervisor) -or + [IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired') -or + -not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R314_NATIVE_TASK_OR_FILES_NOT_RESTORED' + } + $checks.Add('FRESH_PROCESS_RESTORED_PRIOR_TASK_AND_EXACT_FILES') + $t=Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction Stop + if([string]$t.State -cne 'Disabled' -or + [bool]$t.Settings.Enabled -ne $false -or + [bool]$t.Triggers[0].Enabled -ne $false -or + [IO.File]::Exists($supervisor)){ + throw 'R314_RESTORED_TASK_WAS_EXECUTABLE_OR_RUN' + } + $checks.Add('DISABLED_PRIOR_TASK_REINSTATED_WITHOUT_EXECUTING_SUPERVISOR') +}finally{ + if($created){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}) + if($items.Count -eq 1){ + # Delete only the exact bounded disposable name created above. + Unregister-ScheduledTask -TaskPath '\' -TaskName $name -Confirm:$false -ErrorAction Stop + } + } +} +Absent $name +$checks.Add('EPHEMERAL_TASK_REMOVED_AFTER_COLD_RESTORE') +if(@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count -ne 0){ + throw 'R314_ACTUAL_PROTECTED_SENSOR_TASK_TOUCHED' +} +if((FileHash $main) -cne $initial.Main -or + (FileHash $config) -cne $initial.Config -or + (FileHash $state) -cne $initial.State -or + (FileHash $canary) -cne $canaryHash){ + throw 'R314_INSTALLED_USER_OR_CANARY_CHANGED' +} +$checks.Add('ACTUAL_MAIN_SETTINGS_AND_CANARY_IMMUTABLE') +$data=[ordered]@{ + Schema=1;Revision='R314' + Status='PASS_DISPOSABLE_TASK_AND_FILES_COLD_RESTORE_AFTER_KILLED_WORKER' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerHostTouched=$false;OriginalProtectedTaskTouched=$false + RealProgramFilesWritten=$false;ActualUACInvoked=$false + OriginalElevatedHelperKilled=$false + RealSyntheticWorkerKilled=$true + FreshProcessRestoredActualDisposableScheduledTask=$true + FilesRestoredByteExact=$true + TaskDisabledNeverExecuted=$true + JournalTerminalAndRetiredCleanupComplete=$true + ForeignSidTamperAndUnknownFileRejectedPrewrite=$true + OriginalAppDataUnchanged=$true + WorkerPid=$workerPid;RecoveryPid=$recoveryPid + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R314_NATIVE_'+$HostLabel+'_TASK_AND_FILES_COLD_REPLAY_PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 27d8aa67..f5122ce5 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -953,6 +953,137 @@ function Assert-SensorTrustedOldTaskXml( return $true } +# R314: isolated experimental cold replay of a fully validated PREPARED +# journal and its prior ScheduledTask XML. The production entrypoint remains +# fail-closed until a separate actual elevated-helper death test is accepted. +function Test-SensorOwnedTaskRegistration( + [string]$TaskName,[string]$ExpectedSid,[string]$TrustedBrokerRoot,[string]$TrustedAppRoot +){ + if([string]::IsNullOrWhiteSpace($TaskName) -or $TaskName.Length -gt 100 -or + $TaskName -notmatch '^[A-Za-z0-9 _-]+$'){ + throw 'SENSOR_COLD_TASK_INVALID_NAME' + } + Import-Module ScheduledTasks -ErrorAction Stop + $existing=@(Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue| + Where-Object {$null -ne $_}) + if($existing.Count -gt 1){throw 'SENSOR_COLD_TASK_DUPLICATE_NAME'} + if($existing.Count -eq 0){return $false} + $task=$existing[0] + $sid=Resolve-SensorPriorTaskPrincipalSid ([string]$task.Principal.UserId) + if($sid -cne $ExpectedSid -or + @($task.Actions).Count -ne 1 -or + @($task.Triggers).Count -ne 1){ + throw 'SENSOR_COLD_TASK_PRINCIPAL_OR_SHAPE_MISMATCH' + } + $expectedExe=Join-Path $TrustedBrokerRoot 'TaskbarMonitorSensorSupervisor.exe' + $expectedArgs='--broker "'+(Join-Path $TrustedBrokerRoot 'TaskbarMonitorSensorBroker.exe')+ + '" --output "'+(Join-Path $TrustedAppRoot 'cpu_temp_broker.json')+'"' + if(-not [string]::Equals([string]$task.Actions[0].Execute,$expectedExe,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$task.Actions[0].Arguments,$expectedArgs,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_COLD_TASK_UNTRUSTED_EXISTING_ACTION' + } + # Export and validate all privilege-bearing XML fields, including + # highest runlevel, interactive logon and trigger UserId. + $currentXml=[string](Export-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction Stop) + if($TaskName -cne 'TaskbarMonitorEnhanced Sensor Broker'){ + # Test-only URI variation: the real task must always use its fixed + # URI; hosted ephemeral tests are named explicitly and never started. + $xmlDoc=[Xml.XmlDocument]::new() + $xmlDoc.XmlResolver=$null + $settings=[Xml.XmlReaderSettings]::new() + $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit + $settings.XmlResolver=$null + $settings.MaxCharactersInDocument=262144 + $sr=[IO.StringReader]::new($currentXml) + $reader=$null + try{ + $reader=[Xml.XmlReader]::Create($sr,$settings) + $xmlDoc.Load($reader) + }finally{if($reader){$reader.Dispose()};$sr.Dispose()} + $namespace=[Xml.XmlNamespaceManager]::new($xmlDoc.NameTable) + $namespace.AddNamespace('t','http://schemas.microsoft.com/windows/2004/02/mit/task') + $uri=$xmlDoc.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI',$namespace) + if($uri -and [string]$uri.InnerText -cne ('\'+$TaskName)){ + throw 'SENSOR_COLD_TASK_EPHEMERAL_URI_MISMATCH' + } + # Drop only the Scheduler-generated URI before passing it through + # the exact production policy. No other XML fields are changed. + if($uri){[void]$uri.ParentNode.RemoveChild($uri)} + $currentXml=$xmlDoc.OuterXml + } + if(-not (Assert-SensorTrustedOldTaskXml $currentXml $ExpectedSid $TrustedBrokerRoot $TrustedAppRoot)){ + throw 'SENSOR_COLD_TASK_EXPORTED_XML_UNTRUSTED' + } + return $true +} +function Restore-SensorJournalPriorLayerAndTask( + [string]$Journal,[string]$ExpectedSid,[string]$LiveBrokerRoot, + [string]$TrustedAppRoot,[string]$TaskName +){ + # Trusted production code passes the fixed TaskName. The alternate + # prefix is reserved exclusively for bounded disposable test fixtures; + # never register a task under arbitrary string input. + if($TaskName -cne 'TaskbarMonitorEnhanced Sensor Broker' -and + $TaskName -cnotmatch '^TBME-R314-Disposable-[0-9]{5,20}-[AB]$'){ + throw 'SENSOR_COLD_TASK_NAME_NOT_ALLOWLISTED' + } + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$ExpectedSid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_COLD_JOURNAL_EFFECTIVE_SID_MISMATCH' + } + if((Get-SensorJournalTerminal $Journal $ExpectedSid) -cne 'PREPARED'){ + throw 'SENSOR_COLD_JOURNAL_NOT_PREPARED' + } + $record=Assert-SensorDurableJournal $Journal $ExpectedSid + $savedXmlFile=Join-Path $Journal 'task.xml' + Assert-SensorJournalTrustedAcl $savedXmlFile $false + $xmlBytes=[IO.File]::ReadAllBytes($savedXmlFile) + if($xmlBytes.Length -gt 262144 -or + (Get-SensorJournalHash $xmlBytes) -cne [string]$record.TaskHash){ + throw 'SENSOR_COLD_SAVED_TASK_XML_HASH_MISMATCH' + } + $oldXml=[Text.Encoding]::UTF8.GetString($xmlBytes) + if([bool]$record.TaskPresent){ + if(-not (Assert-SensorTrustedOldTaskXml $oldXml $ExpectedSid $LiveBrokerRoot $TrustedAppRoot)){ + throw 'SENSOR_COLD_PRIOR_XML_POLICY_INVALID' + } + }elseif($oldXml.Length -gt 0){ + throw 'SENSOR_COLD_UNEXPECTED_SAVED_TASK_XML' + } + # Reject a foreign or altered live task BEFORE any files are changed. + $previousTaskPresent=Test-SensorOwnedTaskRegistration $TaskName $ExpectedSid $LiveBrokerRoot $TrustedAppRoot + # R309 prevalidates every saved file/hash, XML and all current live + # member names before the first synthetic file write. Retrying after a + # crash is safe for the same allowlisted bytes and expected task. + $decoded=Restore-SensorJournalOldFilesOnly $Journal $ExpectedSid $LiveBrokerRoot + if(-not [string]::Equals($decoded,$oldXml,[StringComparison]::Ordinal)){ + throw 'SENSOR_COLD_REPLAY_XML_DATA_CHANGED' + } + if([bool]$record.TaskPresent){ + if(-not $previousTaskPresent){ + # A unique known task name is required absent, and we never + # use -Force (which could overwrite an unrelated task). + Register-ScheduledTask -TaskPath '\' -TaskName $TaskName -Xml $oldXml -ErrorAction Stop|Out-Null + } + if(-not (Test-SensorOwnedTaskRegistration $TaskName $ExpectedSid $LiveBrokerRoot $TrustedAppRoot)){ + throw 'SENSOR_COLD_REGISTERED_TASK_READBACK_INVALID' + } + }else{ + if($previousTaskPresent){ + Unregister-ScheduledTask -TaskPath '\' -TaskName $TaskName -Confirm:$false -ErrorAction Stop + } + if(Test-SensorOwnedTaskRegistration $TaskName $ExpectedSid $LiveBrokerRoot $TrustedAppRoot){ + throw 'SENSOR_COLD_ABSENT_TASK_NOT_RESTORED' + } + } + Write-SensorJournalTerminal $Journal $ExpectedSid 'ROLLED_BACK' + Finish-SensorTerminalJournal $Journal $ExpectedSid + if([IO.Directory]::Exists($Journal) -or [IO.Directory]::Exists($Journal+'-retired')){ + throw 'SENSOR_COLD_ROLLBACK_JOURNAL_RESIDUE' + } +} + function Capture-PreviousSensorLayer { try{ Clear-SensorRollbackBuffer From f5244caaedfe6d0d1a9cf4062de01809e53bf928 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 21:25:30 +0330 Subject: [PATCH 058/135] security(experiment): invoke verified pending journal cold recovery before mutation (R315) --- ...it-r286-optional-audio-native-recovery.yml | 33 ++ ...t-R315-ProductionColdEntrypoint-Native.ps1 | 328 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 59 +++- 3 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 build/Test-R315-ProductionColdEntrypoint-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 15f71d1f..57b409cd 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -798,6 +798,38 @@ jobs: $r.WorkerPid -eq $r.RecoveryPid -or @($r.Checks).Count -ne 14){throw 'R314_COLD_TASK_AND_FILES_PROOF_GATES_UNMET'} 'R314_REAL_DISPOSABLE_SCHEDULER_COLD_RESTORE_PASS_NO_UAC' + - name: R315 invoke production PREPARED cold entrypoint after real disposable task loss + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R315-ProductionColdEntrypoint-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R315_PS51_PROOF_SYNTAX_INVALID'} + ./build/Test-R315-ProductionColdEntrypoint-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R315_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R315_TASK_COLD_RESTORE_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R315_NATIVE_RAW_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R315' -or + $r.Status -cne 'PASS_PREPARED_PRODUCTION_ENTRYPOINT_COLD_REPLAY_DISPOSABLE_TASK' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerHostTouched -ne $false -or + $r.OriginalProtectedTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.OriginalElevatedHelperKilled -ne $false -or + $r.RealSyntheticWorkerKilled -ne $true -or + $r.FreshProcessRestoredActualDisposableScheduledTask -ne $true -or + $r.FilesRestoredByteExact -ne $true -or + $r.TaskDisabledNeverExecuted -ne $true -or + $r.JournalTerminalAndRetiredCleanupComplete -ne $true -or + $r.ForeignSidTamperAndUnknownFileRejectedPrewrite -ne $true -or + $r.OriginalAppDataUnchanged -ne $true -or + $r.ProductionColdEntrypointInvoked -ne $true -or + $r.WorkerPid -le 0 -or $r.RecoveryPid -le 0 -or + $r.WorkerPid -eq $r.RecoveryPid -or + @($r.Checks).Count -ne 15){throw 'R315_NATIVE_ENTRYPOINT_RECOVERY_GATES_NOT_MET'} + 'R315_EXACT_PE_PREPARED_ENTRYPOINT_COLD_TASK_RECOVERY_SYNTHETIC_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -832,3 +864,4 @@ jobs: ${{ runner.temp }}/R312_${{ matrix.host }}_${{ github.run_id }}/R312_TRUSTED_TASK_XML_RECEIPT.json ${{ runner.temp }}/R313_${{ matrix.host }}_${{ github.run_id }}/R313_TASK_REGISTRATION_RECEIPT.json ${{ runner.temp }}/R314_${{ matrix.host }}_${{ github.run_id }}/R314_TASK_COLD_RESTORE_RECEIPT.json + ${{ runner.temp }}/R315_${{ matrix.host }}_${{ github.run_id }}/R315_TASK_COLD_RESTORE_RECEIPT.json diff --git a/build/Test-R315-ProductionColdEntrypoint-Native.ps1 b/build/Test-R315-ProductionColdEntrypoint-Native.ps1 new file mode 100644 index 00000000..ca5a90cd --- /dev/null +++ b/build/Test-R315-ProductionColdEntrypoint-Native.ps1 @@ -0,0 +1,328 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir, + [ValidateSet('Driver','Worker','Recover')][string]$Mode='Driver', + [string]$Fixture='' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrEmpty($env:GITHUB_SHA) -or + [string]::IsNullOrEmpty($env:GITHUB_RUN_ID) -or + [string]::IsNullOrEmpty($env:RUNNER_TEMP)){ + throw 'R315_HOSTED_WINDOWS_DISPOSABLE_ONLY' +} +function FileHash([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Absent([string]$name){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}) + if($items.Count){throw 'R315_PREEXISTING_DISPOSABLE_TASK'} +} +function Reject([string]$label,[scriptblock]$fn,[string]$pattern){ + $failed=$false + try{& $fn}catch{if($_.Exception.ToString() -match $pattern){$failed=$true}else{throw}} + if(-not $failed){throw ('R315_EXPECTED_PREWRITE_REFUSAL_MISSING_'+$label)} + $script:checks.Add($label) +} +$run=Join-Path $env:RUNNER_TEMP ('R315_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $run 'R315_TASK_COLD_RESTORE_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R314_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R314_TASK_COLD_RESTORE_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$res=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$res){throw 'R315_COMPILED_PE_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($res) +try{$source=[string]$reader.ReadToEnd()}finally{$reader.Dispose()} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$normal={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normal $source) -cne (& $normal ([IO.File]::ReadAllText($helper)))){ + throw 'R315_SETUP_PE_HELPER_SOURCE_MISMATCH' +} +$t=$null;$e=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$t,[ref]$e) +if(@($e).Count){throw 'R315_HELPER_PS51_PARSER_INVALID'} +$names=@( + 'Assert-SensorBoundaryNoReparse','Get-SensorJournalHash','Assert-SensorJournalRelative', + 'Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique','Assert-SensorDurableJournal', + 'New-SensorDurableJournal','Get-SensorJournalTerminal','Write-SensorJournalTerminal', + 'Restore-SensorJournalOldFilesOnly','Assert-SensorRetiredJournalSafe', + 'Finish-SensorRetiredJournal','Finish-SensorTerminalJournal', + 'Resolve-SensorPriorTaskPrincipalSid','Assert-SensorTrustedOldTaskXml', + 'Test-SensorOwnedTaskRegistration','Restore-SensorJournalPriorLayerAndTask','Resume-SensorPendingJournal' +) +$defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -in $names +},$true)) +if($defs.Count -ne $names.Count){throw 'R315_EXACT_COMPILED_FUNCTIONS_NOT_FOUND'} +. ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) +Import-Module ScheduledTasks -ErrorAction Stop +$sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R315_RUNNER_SID_NOT_INTERACTIVE'} +$name='TBME-R314-Disposable-'+$env:GITHUB_RUN_ID+'-'+$HostLabel +if($name -cnotmatch '^TBME-R314-Disposable-[0-9]{5,20}-[AB]$'){throw 'R315_UNSAFE_TASK_NAME'} +$journal=Join-Path (Join-Path $run 'trusted') 'snapshot' +$live=Join-Path $run 'live-broker' +$appRoot=Join-Path $run 'user-data' +$oldBroker=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$oldCfg=Join-Path $live 'settings\old.bin' +$newSupervisor=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' +$outside=Join-Path $run 'unrelated' +$canary=Join-Path $outside 'leave-alone.txt' +if($Mode -ne 'Driver'){ + if(-not [string]::Equals([IO.Path]::GetFullPath($Fixture),[IO.Path]::GetFullPath($run),[StringComparison]::OrdinalIgnoreCase) -or + -not [IO.Directory]::Exists($run)){ + throw 'R315_CHILD_FIXTURE_IDENTITY_MISMATCH' + } + if($Mode -eq 'Worker'){ + if(-not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R315_WORKER_PREVIOUS_TASK_NOT_PRESENT' + } + Unregister-ScheduledTask -TaskPath '\' -TaskName $name -Confirm:$false -ErrorAction Stop + [IO.File]::WriteAllBytes($oldBroker,[Text.Encoding]::ASCII.GetBytes('R315_WRONG_NEW_BROKER')) + [IO.File]::WriteAllBytes($newSupervisor,[Text.Encoding]::ASCII.GetBytes('R315_WRONG_NEW_SUPERVISOR')) + [IO.File]::WriteAllText((Join-Path $run 'WORKER_CHECKPOINT'),'TASK_REMOVED_AND_LIVE_BROKER_MUTATED') + Start-Sleep -Seconds 30 + exit 0 + } + if($Mode -eq 'Recover'){ + $status=Resume-SensorPendingJournal $journal $sid $live $appRoot $name + if($status -cne 'PREVIOUS_LAYER_RESTORED'){ + throw 'R315_COLD_ENTRYPOINT_DID_NOT_RETURN_RECOVERED' + } + [IO.File]::WriteAllText((Join-Path $run 'RECOVERED'),'OLD_TASK_AND_FILES_RESTORED') + exit 0 + } +} +if([IO.Directory]::Exists($run)){throw 'R315_TEST_ROOT_ALREADY_EXISTS'} +[void][IO.Directory]::CreateDirectory($run) +$initial=@{Main=(FileHash $main);Config=(FileHash $config);State=(FileHash $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (FileHash $setup) -or + $c.MainSHA256 -cne $initial.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_DISPOSABLE_TASK_AND_FILES_COLD_RESTORE_AFTER_KILLED_WORKER'){ + throw 'R315_CANDIDATE_OR_PREVIOUS_RECEIPT_NOT_EXACT' +} +$checks.Add('EXACT_PE_HELPER_AND_PREVIOUS_HOST_RECEIPT') +$helperText=[IO.File]::ReadAllText($helper) +$ownerAt=$helperText.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) +$hookAt=$helperText.IndexOf('Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName',[StringComparison]::Ordinal) +$uninstallAt=$helperText.IndexOf("if($Mode -eq 'Uninstall')",[StringComparison]::Ordinal) +$writeAt=$helperText.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid ',[StringComparison]::Ordinal) +if($ownerAt -lt 0 -or $hookAt -le $ownerAt -or $uninstallAt -le $hookAt -or + $writeAt -le $uninstallAt){throw 'R315_HELPER_COLD_ENTRYPOINT_ORDER_INVALID'} +$checks.Add('EXACT_PE_OWNER_GUARD_COLD_REPLAY_BEFORE_NEW_MUTATION') + +Absent $name +$existingReal=@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}) +if($existingReal.Count){throw 'R315_REAL_PROTECTED_TASK_PREEXISTS_GUEST'} +$checks.Add('ONLY_UNIQUE_EPHEMERAL_TASK_ALLOWED') +$admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) +foreach($s in @($admins,$system)){ + $entry=[Security.AccessControl.FileSystemAccessRule]::new( + $s,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($entry) +} +[void][IO.Directory]::CreateDirectory((Join-Path $run 'trusted'),$acl) +Assert-SensorJournalTrustedAcl (Join-Path $run 'trusted') $true +$checks.Add('PROTECTED_JOURNAL_DACL_OWNER_VERIFIED') +# Owner-protected live broker root is required by the production preflight; +# this fixture is disposable and never touches ProgramFiles. +[void][IO.Directory]::CreateDirectory($live,$acl) +[void][IO.Directory]::CreateDirectory((Join-Path $live 'settings')) +[void][IO.Directory]::CreateDirectory($appRoot) +[void][IO.Directory]::CreateDirectory($outside) +[IO.File]::WriteAllBytes($oldBroker,[Text.Encoding]::ASCII.GetBytes('R315_TRUSTED_PREVIOUS_BROKER')) +[IO.File]::WriteAllBytes($oldCfg,[Text.Encoding]::ASCII.GetBytes('R315_TRUSTED_PREVIOUS_CONFIG')) +[IO.File]::WriteAllText($canary,'R315_CANARY_IMMUTABLE') +$oldHash=FileHash $oldBroker;$cfgHash=FileHash $oldCfg;$canaryHash=FileHash $canary +$supervisor=Join-Path $live 'TaskbarMonitorSensorSupervisor.exe' +$broker=Join-Path $live 'TaskbarMonitorSensorBroker.exe' +$target=Join-Path $appRoot 'cpu_temp_broker.json' +$args='--broker "'+$broker+'" --output "'+$target+'"' +$escapedSid=[Security.SecurityElement]::Escape($sid) +$escapedCmd=[Security.SecurityElement]::Escape($supervisor) +$escapedArgs=[Security.SecurityElement]::Escape($args) +$xml=@" + +false$escapedSid +$escapedSidInteractiveTokenHighestAvailable +falsefalse +$escapedCmd$escapedArgs + +"@ +if(-not (Assert-SensorTrustedOldTaskXml $xml $sid $live $appRoot)){ + throw 'R315_OLD_TASK_XML_NOT_PREVALIDATED' +} +$rows=[object[]]@( + [pscustomobject]@{Rel='TaskbarMonitorSensorBroker.exe';Data=[IO.File]::ReadAllBytes($oldBroker)}, + [pscustomobject]@{Rel='settings\old.bin';Data=[IO.File]::ReadAllBytes($oldCfg)} +) +$next=[string[]]@('TaskbarMonitorSensorBroker.exe','TaskbarMonitorSensorSupervisor.exe','settings\old.bin') +New-SensorDurableJournal $journal $sid $rows $xml $next $true +$checks.Add('JOURNAL_READY_WITH_HASHED_PRIOR_TASK_AND_FILES') +$created=$false +$workerPid=0 +$recoveryPid=0 +try{ + Register-ScheduledTask -TaskPath '\' -TaskName $name -Xml $xml -ErrorAction Stop|Out-Null + $created=$true + if(-not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R315_INITIAL_TASK_NOT_OWNED_AND_PINNED' + } + $checks.Add('DISABLED_PRIOR_SCHEDULER_TASK_REGISTERED_AND_VERIFIED') + # Reject foreign SID, tainted XML, or rogue live files before interrupting. + $foreign='S-1-5-21-101-202-303-404' + Reject 'FOREIGN_SID_COLD_REPLAY_REFUSED' { + Resume-SensorPendingJournal $journal $foreign $live $appRoot $name + } 'SENSOR_COLD_ENTRYPOINT_EFFECTIVE_TOKEN_SID_MISMATCH' + $taskFile=Join-Path $journal 'task.xml' + $oldXmlBytes=[IO.File]::ReadAllBytes($taskFile) + [IO.File]::WriteAllText($taskFile,'R315_TAINTED_XML') + Reject 'TAMPERED_SAVED_TASK_REFUSED_PREWRITE' { + Resume-SensorPendingJournal $journal $sid $live $appRoot $name + } 'SENSOR_JOURNAL_TASK_XML_HASH_MISMATCH' + [IO.File]::WriteAllBytes($taskFile,$oldXmlBytes) + $bad=Join-Path $live 'unknown-file.txt' + [IO.File]::WriteAllText($bad,'R315_UNKNOWN') + Reject 'UNKNOWN_LIVE_FILE_REFUSED_BEFORE_REPLAY' { + Resume-SensorPendingJournal $journal $sid $live $appRoot $name + } 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' + [IO.File]::Delete($bad) + if((FileHash $oldBroker) -cne $oldHash -or (FileHash $oldCfg) -cne $cfgHash -or + -not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R315_NEGATIVE_CASE_MUTATED_LIVE_DATA' + } + $checks.Add('ALL_NEGATIVES_LEAVE_LIVE_AND_TASK_UNCHANGED') + function Child([string]$mode){ + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments=('-NoLogo -NoProfile -NonInteractive -File "'+$PSCommandPath+'"'+ + ' -Mode '+$mode+' -HostLabel '+$HostLabel+ + ' -CandidateDir "'+$CandidateDir+'" -Fixture "'+$run+'"') + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true + return [Diagnostics.Process]::Start($psi) + } + $worker=Child Worker + if(!$worker){throw 'R315_WORKER_START_FAIL'} + $workerPid=$worker.Id + try{ + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not [IO.File]::Exists((Join-Path $run 'WORKER_CHECKPOINT')) -and + -not $worker.HasExited -and $clock.ElapsedMilliseconds -lt 18000){ + Start-Sleep -Milliseconds 100 + } + if(-not [IO.File]::Exists((Join-Path $run 'WORKER_CHECKPOINT')) -or $worker.HasExited){ + throw ('R315_MUTATION_CHECKPOINT_NOT_REACHED '+$worker.StandardError.ReadToEnd()) + } + Absent $name + if((FileHash $oldBroker) -ceq $oldHash -or + (Get-SensorJournalTerminal $journal $sid) -cne 'PREPARED'){ + throw 'R315_TASK_LOSS_OR_MUTATION_NOT_REPRODUCED' + } + $worker.Kill() + if(-not $worker.WaitForExit(9000) -or $worker.ExitCode -eq 0){ + throw 'R315_TEST_WORKER_KILL_NOT_REAL' + } + $checks.Add('REAL_CHILD_KILLED_AFTER_SCHEDULED_TASK_DELETION_AND_FILE_MUTATION') + }finally{ + if(-not $worker.HasExited){$worker.Kill();[void]$worker.WaitForExit(9000)} + $worker.Dispose() + } + $child=Child Recover + if(!$child){throw 'R315_RECOVERY_CHILD_NOT_STARTED'} + $recoveryPid=$child.Id + try{ + if(-not $child.WaitForExit(22000) -or $child.ExitCode -ne 0){ + $msg=if($child.HasExited){$child.StandardError.ReadToEnd()}else{'TIMEOUT'} + if($msg.Length -gt 1800){$msg=$msg.Substring(0,1800)} + throw ('R315_FRESH_PROCESS_TASK_RESTORE_FAILED '+$msg) + } + }finally{ + if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(9000)} + $child.Dispose() + } + if(-not [IO.File]::Exists((Join-Path $run 'RECOVERED')) -or + $recoveryPid -eq $workerPid -or + (FileHash $oldBroker) -cne $oldHash -or + (FileHash $oldCfg) -cne $cfgHash -or + [IO.File]::Exists($newSupervisor) -or + [IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired') -or + -not (Test-SensorOwnedTaskRegistration $name $sid $live $appRoot)){ + throw 'R315_NATIVE_TASK_OR_FILES_NOT_RESTORED' + } + $checks.Add('FRESH_PROCESS_RESTORED_PRIOR_TASK_AND_EXACT_FILES') + $t=Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction Stop + if([string]$t.State -cne 'Disabled' -or + [bool]$t.Settings.Enabled -ne $false -or + [bool]$t.Triggers[0].Enabled -ne $false -or + [IO.File]::Exists($supervisor)){ + throw 'R315_RESTORED_TASK_WAS_EXECUTABLE_OR_RUN' + } + $checks.Add('DISABLED_PRIOR_TASK_REINSTATED_WITHOUT_EXECUTING_SUPERVISOR') +}finally{ + if($created){ + $items=@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}) + if($items.Count -eq 1){ + # Delete only the exact bounded disposable name created above. + Unregister-ScheduledTask -TaskPath '\' -TaskName $name -Confirm:$false -ErrorAction Stop + } + } +} +Absent $name +$checks.Add('EPHEMERAL_TASK_REMOVED_AFTER_COLD_RESTORE') +if(@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count -ne 0){ + throw 'R315_ACTUAL_PROTECTED_SENSOR_TASK_TOUCHED' +} +if((FileHash $main) -cne $initial.Main -or + (FileHash $config) -cne $initial.Config -or + (FileHash $state) -cne $initial.State -or + (FileHash $canary) -cne $canaryHash){ + throw 'R315_INSTALLED_USER_OR_CANARY_CHANGED' +} +$checks.Add('ACTUAL_MAIN_SETTINGS_AND_CANARY_IMMUTABLE') +$data=[ordered]@{ + Schema=1;Revision='R315' + Status='PASS_PREPARED_PRODUCTION_ENTRYPOINT_COLD_REPLAY_DISPOSABLE_TASK' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerHostTouched=$false;OriginalProtectedTaskTouched=$false + RealProgramFilesWritten=$false;ActualUACInvoked=$false + OriginalElevatedHelperKilled=$false + RealSyntheticWorkerKilled=$true + FreshProcessRestoredActualDisposableScheduledTask=$true + ProductionColdEntrypointInvoked=$true + FilesRestoredByteExact=$true + TaskDisabledNeverExecuted=$true + JournalTerminalAndRetiredCleanupComplete=$true + ForeignSidTamperAndUnknownFileRejectedPrewrite=$true + OriginalAppDataUnchanged=$true + WorkerPid=$workerPid;RecoveryPid=$recoveryPid + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R315_NATIVE_'+$HostLabel+'_TASK_AND_FILES_COLD_REPLAY_PASS' diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index f5122ce5..50536a75 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -1084,6 +1084,47 @@ function Restore-SensorJournalPriorLayerAndTask( } } +# R315: verified cold replay is now callable from the trusted PE entrypoint +# before new protected sensor install/uninstall mutation. The operation stays +# fail-closed on another account, foreign global task, invalid journal or +# path/reparse ambiguity; no automatic destructive retry from user Temp. +function Resume-SensorPendingJournal( + [string]$Journal,[string]$Sid,[string]$BrokerRoot, + [string]$AppRoot,[string]$OwnedTaskName +){ + $effective=[Security.Principal.WindowsIdentity]::GetCurrent() + if($null -eq $effective -or $null -eq $effective.User -or + -not [string]::Equals([string]$effective.User.Value,$Sid,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_COLD_ENTRYPOINT_EFFECTIVE_TOKEN_SID_MISMATCH' + } + Assert-SensorBoundaryNoReparse $Journal + Assert-SensorBoundaryNoReparse ($Journal+'-retired') + if(-not [IO.Directory]::Exists($Journal)){ + # Only a fully terminal, trusted retired stage may be removed. + Resume-SensorCompletedJournal $Journal $Sid + return 'NONE_OR_TERMINAL_COMPLETED' + } + $phase=Get-SensorJournalTerminal $Journal $Sid + if($phase -ne 'PREPARED'){ + Resume-SensorCompletedJournal $Journal $Sid + return 'TERMINAL_COMPLETED' + } + $parent=[IO.Path]::GetDirectoryName($Journal) + Assert-SensorJournalTrustedAcl $parent $false + Assert-SensorBoundaryNoReparse $BrokerRoot + if([IO.Directory]::Exists($BrokerRoot)){ + Assert-SensorJournalTrustedAcl $BrokerRoot $false + } + # The R314 replay function validates every saved file and the prior XML, + # refuses alternate task actions/principal, restores old bytes, confirms + # real Scheduler state, publishes ROLLED_BACK and retires known files. + Restore-SensorJournalPriorLayerAndTask $Journal $Sid $BrokerRoot $AppRoot $OwnedTaskName + if([IO.Directory]::Exists($Journal) -or [IO.Directory]::Exists($Journal+'-retired')){ + throw 'SENSOR_COLD_ENTRYPOINT_RESIDUAL_JOURNAL' + } + return 'PREVIOUS_LAYER_RESTORED' +} + function Capture-PreviousSensorLayer { try{ Clear-SensorRollbackBuffer @@ -1200,15 +1241,21 @@ try{ } # R264-SENSOR-GUARD-BOUNDARY-END -# R311: before protected changes finish only already-terminal, -# same-owner, SHA-validated journals. PREPARED remains fail-closed until -# actual prior task/file cold recovery is independently proven. +# R315: BEFORE new install/uninstall mutations, restore an interrupted +# PREPARED previous protected layer only under exact same-account proof. +# The old saved task/files are independently authenticated before replay. try{ - Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid + $resumeResult=Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName + if($resumeResult -ceq 'PREVIOUS_LAYER_RESTORED'){ + $script:ActiveArchitecture='PREVIOUS_PROTECTED_SENSOR_LAYER_RESTORED' + Write-Log 'R315_PRIOR_PROTECTED_SENSOR_COLD_RECOVERY_COMPLETE' + Write-Result 'DEGRADED_PREVIOUS_LAYER_RESTORED' 'The interrupted protected sensor update was rolled back to the previous verified owner task and exact prior binaries. The per-user application remains available; run Repair sensors again for a new update.' 'NOT_STARTED' $null $false $false $false + exit 0 + } }catch{ $script:ActiveArchitecture='PROTECTED_JOURNAL_RECOVERY_REQUIRED' - Write-Log ('R311_PENDING_PROTECTED_JOURNAL_NO_MUTATION '+$_.Exception.Message) - Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'A protected sensor update journal is not safely terminal, and must be recovered under its verified owner account. No new protected sensor mutation was started.' 'NOT_STARTED' $null $false $false $false + Write-Log ('R315_PENDING_PROTECTED_JOURNAL_COLD_RESTORE_NOT_COMPLETE '+$_.Exception.Message) + Write-Result 'DEGRADED_ROLLBACK_RECOVERY_REQUIRED' 'Protected sensor recovery could not be fully verified; the prior journal was retained, and no subsequent new protected installation was started. A verified same-owner recovery is required.' 'NOT_STARTED' $null $false $false $false exit 0 } From 0e3776671a841176c936cc6bca354ca2a0ee7e96 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 21:29:59 +0330 Subject: [PATCH 059/135] test: preserve strict R308/R311 ordering under R315 pending recovery hook (F01) --- build/Test-R308-ProtectedPrewrite-Native.ps1 | 4 +++- build/Test-R311-HealthyTerminalHooks-Native.ps1 | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/build/Test-R308-ProtectedPrewrite-Native.ps1 b/build/Test-R308-ProtectedPrewrite-Native.ps1 index d5eb4335..a7a1c709 100644 --- a/build/Test-R308-ProtectedPrewrite-Native.ps1 +++ b/build/Test-R308-ProtectedPrewrite-Native.ps1 @@ -62,7 +62,9 @@ $save=$raw.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensor $mutation=$raw.IndexOf(' Assert-OrRecordSensorOwner',[StringComparison]::Ordinal) $ownerBoundary=$raw.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) $legacyStale=$raw.IndexOf('if(Test-Path -LiteralPath $SensorJournalRoot)',[StringComparison]::Ordinal) -$protectedPreflight=$raw.IndexOf('Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal) +$protectedPreflight=[Math]::Max( + $raw.IndexOf('Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal), + $raw.IndexOf('Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName',[StringComparison]::Ordinal)) if($save -lt 0 -or $mutation -le $save -or $ownerBoundary -lt 0 -or ($legacyStale -lt 0 -and $protectedPreflight -lt 0) -or ($protectedPreflight -ge 0 -and diff --git a/build/Test-R311-HealthyTerminalHooks-Native.ps1 b/build/Test-R311-HealthyTerminalHooks-Native.ps1 index 35b83daa..9fd871a4 100644 --- a/build/Test-R311-HealthyTerminalHooks-Native.ps1 +++ b/build/Test-R311-HealthyTerminalHooks-Native.ps1 @@ -58,7 +58,9 @@ $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[st if((& $normalize $embedded) -cne (& $normalize $raw)){throw 'R311_PE_RESOURCE_SOURCE_NOT_IDENTICAL'} $checks.Add('EMBEDDED_PE_RESOURCE_EQUALS_CURRENT_SECURITY_HELPER') $ownerGuard=$raw.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) -$preflight=$raw.IndexOf('Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal) +$preflight=[Math]::Max( + $raw.IndexOf('Resume-SensorCompletedJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal), + $raw.IndexOf('Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName',[StringComparison]::Ordinal)) $uninstall=$raw.IndexOf('if($Mode -eq ''Uninstall'')',[StringComparison]::Ordinal) $writeAhead=$raw.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid',[StringComparison]::Ordinal) $health=$raw.IndexOf('if(-not $taskInstalled -or -not $SupervisorHealthy)',[StringComparison]::Ordinal) From f42a2727517bce5a9b3f3847b83a5a33dcc4e4b3 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 21:34:34 +0330 Subject: [PATCH 060/135] test: search literal mode in R315 protected entrypoint ordering (F02) --- build/Test-R315-ProductionColdEntrypoint-Native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-R315-ProductionColdEntrypoint-Native.ps1 b/build/Test-R315-ProductionColdEntrypoint-Native.ps1 index ca5a90cd..c049fbeb 100644 --- a/build/Test-R315-ProductionColdEntrypoint-Native.ps1 +++ b/build/Test-R315-ProductionColdEntrypoint-Native.ps1 @@ -123,7 +123,7 @@ $checks.Add('EXACT_PE_HELPER_AND_PREVIOUS_HOST_RECEIPT') $helperText=[IO.File]::ReadAllText($helper) $ownerAt=$helperText.IndexOf('# R264-SENSOR-GUARD-BOUNDARY-END',[StringComparison]::Ordinal) $hookAt=$helperText.IndexOf('Resume-SensorPendingJournal $SensorJournalRoot $validatedSensorSid $BrokerRoot $AppRoot $TaskName',[StringComparison]::Ordinal) -$uninstallAt=$helperText.IndexOf("if($Mode -eq 'Uninstall')",[StringComparison]::Ordinal) +$uninstallAt=$helperText.IndexOf('if($Mode -eq ''Uninstall'')',[StringComparison]::Ordinal) $writeAt=$helperText.IndexOf('New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid ',[StringComparison]::Ordinal) if($ownerAt -lt 0 -or $hookAt -le $ownerAt -or $uninstallAt -le $hookAt -or $writeAt -le $uninstallAt){throw 'R315_HELPER_COLD_ENTRYPOINT_ORDER_INVALID'} From 272d9938d8c67029394ed5424efd4fb4d45825c1 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 21:51:08 +0330 Subject: [PATCH 061/135] security(experiment): validate canonical per-user installer root and compare CodeQL (R316) --- ...it-r286-optional-audio-native-recovery.yml | 29 ++++ .github/workflows/audit-r316-codeql-root.yml | 81 +++++++++++ build/Test-R316-CanonicalRoot-Native.ps1 | 137 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 50 ++++++- 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/audit-r316-codeql-root.yml create mode 100644 build/Test-R316-CanonicalRoot-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 57b409cd..dd1e9d01 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -830,6 +830,34 @@ jobs: $r.WorkerPid -eq $r.RecoveryPid -or @($r.Checks).Count -ne 15){throw 'R315_NATIVE_ENTRYPOINT_RECOVERY_GATES_NOT_MET'} 'R315_EXACT_PE_PREPARED_ENTRYPOINT_COLD_TASK_RECOVERY_SYNTHETIC_PASS' + - name: R316 enforce canonical per-user OS known-folder and reject redirected or linked roots + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R316-CanonicalRoot-Native.ps1).Path + $tok=$null;$err=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$err) + if(@($err).Count){$err|ForEach-Object {$_.Message};throw 'R316_NATIVE_PS51_PARSER_FAILED'} + ./build/Test-R316-CanonicalRoot-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R316_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R316_TRUSTED_APPROOT_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R316_RAW_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R316' -or + $r.Status -cne 'PASS_OS_KNOWNFOLDER_CANONICAL_APPROOT_AND_REPARSE_REFUSAL' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.ExactCompiledInstallerSource -ne $true -or + $r.CanonicalExistingAppRootAccepted -ne $true -or + $r.SyntheticSafeProfileAccepted -ne $true -or + $r.RelativeUNCTraversalAndForeignProfileRejected -ne $true -or + $r.ExistingJunctionAndAliasRejected -ne $true -or + $r.UserFilesAndExternalCanaryUnchanged -ne $true -or + $r.OriginalOwnerMachineTouched -ne $false -or + $r.ActualProtectedTaskTouched -ne $false -or + $r.RealProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.CodeQLHighAlertsAccepted -ne $false -or + @($r.Checks).Count -ne 13){throw 'R316_EXACT_NATIVE_ROOT_BOUNDARY_GATES_NOT_ACCEPTED'} + 'R316_CANONICAL_KNOWNFOLDER_AND_REPARSE_PROTECTION_PASS_CODEQL_STILL_OPEN' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -865,3 +893,4 @@ jobs: ${{ runner.temp }}/R313_${{ matrix.host }}_${{ github.run_id }}/R313_TASK_REGISTRATION_RECEIPT.json ${{ runner.temp }}/R314_${{ matrix.host }}_${{ github.run_id }}/R314_TASK_COLD_RESTORE_RECEIPT.json ${{ runner.temp }}/R315_${{ matrix.host }}_${{ github.run_id }}/R315_TASK_COLD_RESTORE_RECEIPT.json + ${{ runner.temp }}/R316_${{ matrix.host }}_${{ github.run_id }}/R316_TRUSTED_APPROOT_RECEIPT.json diff --git a/.github/workflows/audit-r316-codeql-root.yml b/.github/workflows/audit-r316-codeql-root.yml new file mode 100644 index 00000000..660303ca --- /dev/null +++ b/.github/workflows/audit-r316-codeql-root.yml @@ -0,0 +1,81 @@ +name: R316 Compare Canonical Root CodeQL + +on: + push: + branches: + - audit/r308-sensor-journal-prewrite + paths: + - installer/TaskbarMonitorEnhanced_Setup.cs + - .github/workflows/audit-r316-codeql-root.yml + +permissions: + contents: read + actions: read + +jobs: + source-to-sink: + name: CodeQL ${{ matrix.variant }} + runs-on: windows-2025 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + variant: [baseline, candidate] + steps: + - name: Checkout comparison target + uses: actions/checkout@v4 + with: + ref: ${{ matrix.variant == 'baseline' && '7a8f0bcb86f9a1be0c6ad0d0d019472d6fd62bee' || github.sha }} + persist-credentials: false + - name: Initialize CodeQL same engine and queries + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: none + queries: security-extended + - name: Analyze without uploading or dismissing any scanning alerts + uses: github/codeql-action/analyze@v4 + with: + upload: never + output: codeql-results + - name: Count exact C sharp sink findings from local SARIF + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $files=@(Get-ChildItem -LiteralPath codeql-results -Recurse -Filter '*.sarif' -File) + if($files.Count -lt 1){throw 'R316_CODEQL_SARIF_NOT_GENERATED'} + $relevant=@() + $allCount=0 + foreach($file in $files){ + $sarif=Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -Depth 100 + foreach($run in @($sarif.runs)){ + $allCount+=@($run.results).Count + $relevant+=@($run.results|Where-Object { + $_.ruleId -in @('cs/path-injection','cs/command-line-injection') + }|ForEach-Object { + [pscustomobject]@{Rule=$_.ruleId;Path=$_.locations[0].physicalLocation.artifactLocation.uri;Line=$_.locations[0].physicalLocation.region.startLine} + }) + } + } + $summary=[ordered]@{ + Schema=1;Revision='R316';Mode='csharp-build-mode-none-security-extended' + Variant='${{ matrix.variant }}' + AnalyzedCommit=(git rev-parse HEAD).Trim() + WorkflowCommit='${{ github.sha }}' + GeneratedSarifCount=$files.Count;AllFindings=$allCount + TargetFindingCount=$relevant.Count + PathInjectionCount=@($relevant|Where-Object Rule -eq 'cs/path-injection').Count + CommandLineInjectionCount=@($relevant|Where-Object Rule -eq 'cs/command-line-injection').Count + TargetFindings=@($relevant);SecurityAccepted=$false + } + [IO.File]::WriteAllText('r316-codeql-summary.json',($summary|ConvertTo-Json -Depth 10),[Text.UTF8Encoding]::new($false)) + 'R316_CODEQL_COMPARE_VARIANT=${{ matrix.variant }} TARGET_COUNT='+$relevant.Count+' ALL_COUNT='+$allCount + - name: Preserve raw SARIF and bounded comparison summary + uses: actions/upload-artifact@v4 + with: + name: r316-codeql-${{ matrix.variant }}-${{ github.run_id }} + path: | + codeql-results + r316-codeql-summary.json + if-no-files-found: error + retention-days: 3 diff --git a/build/Test-R316-CanonicalRoot-Native.ps1 b/build/Test-R316-CanonicalRoot-Native.ps1 new file mode 100644 index 00000000..13936859 --- /dev/null +++ b/build/Test-R316-CanonicalRoot-Native.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R316_REQUIRES_DISPOSABLE_HOSTED_WINDOWS' +} +function Sha([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$folder=Join-Path $env:RUNNER_TEMP ('R316_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $folder 'R316_TRUSTED_APPROOT_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R315_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R315_TASK_COLD_RESTORE_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($folder)){throw 'R316_EXISTING_FIXTURE_REFUSED'} +[void][IO.Directory]::CreateDirectory($folder) +$before=@{Main=(Sha $main);Config=(Sha $config);State=(Sha $state)} +$c=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$p=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or $c.SetupSHA256 -cne (Sha $setup) -or + $c.MainSHA256 -cne $before.Main -or $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_PREPARED_PRODUCTION_ENTRYPOINT_COLD_REPLAY_DISPOSABLE_TASK'){ + throw 'R316_EXACT_CANDIDATE_OR_PREVIOUS_ACCEPTANCE_MISMATCH' +} +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$asm.GetType('SetupProgram',$true) +$validator=$type.GetMethod('CanonicalAppRootFromKnownFolders',$flags) +$property=$type.GetProperty('AppRoot',$flags) +if(!$validator -or !$property){throw 'R316_TESTED_PE_HAS_NO_TRUSTED_ROOT_VALIDATOR'} +$resource=$asm.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R316_PE_INSTALLER_SOURCE_NOT_EMBEDDED'} +$reader=[IO.StreamReader]::new($resource) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$normalize={param([string]$text) $text.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($source)))){ + throw 'R316_COMPILED_SETUP_NOT_IDENTICAL_TO_REVIEWED_CSHARP' +} +$checks.Add('EXACT_PRIVATE_PE_CONTAINS_REVIEWED_CANONICAL_ROOT_CODE') +function Resolve([string]$local,[string]$profile){ + try{ + return [string]$validator.Invoke($null,([object[]]@($local,$profile))) + }catch{ + $underlying=$_.Exception.GetBaseException() + throw [InvalidOperationException]::new($underlying.Message,$underlying) + } +} +function Refuse([string]$label,[string]$local,[string]$profile,[string]$pattern){ + $rejected=$false + try{$null=Resolve $local $profile}catch{ + if($_.Exception.ToString() -match $pattern){$rejected=$true}else{throw} + } + if(-not $rejected){throw ('R316_EXPECTED_PATH_REJECTION_MISSING_'+$label)} + $script:checks.Add($label) +} +$profile=[IO.Path]::GetFullPath([Environment]::GetFolderPath('UserProfile')) +$local=[IO.Path]::GetFullPath([Environment]::GetFolderPath('LocalApplicationData')) +$expected=[IO.Path]::GetFullPath((Join-Path $local 'TaskbarMonitorEnhanced')) +$actual=[string]$property.GetValue($null,$null) +if(-not [string]::Equals($actual,$expected,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals((Resolve $local $profile),$expected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R316_REAL_RUNNER_KNOWN_FOLDER_ROOT_CHANGED' +} +$checks.Add('HOSTED_RUNNER_OS_KNOWN_FOLDERS_MATCH_EXACT_APPROOT') +$fakeProfile=Join-Path $folder 'synthetic-profile' +$fakeLocal=Join-Path $fakeProfile 'AppData\Local' +[void][IO.Directory]::CreateDirectory($fakeLocal) +$syntheticExpected=[IO.Path]::GetFullPath((Join-Path $fakeLocal 'TaskbarMonitorEnhanced')) +if(-not [string]::Equals((Resolve $fakeLocal $fakeProfile),$syntheticExpected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R316_SYNTHETIC_NORMAL_PROFILE_NOT_ACCEPTED' +} +$checks.Add('SYNTHETIC_MATCHING_KNOWN_FOLDER_PROFILE_ACCEPTED') +Refuse 'RELATIVE_KNOWN_FOLDER_REFUSED' 'AppData\Local' $fakeProfile 'canonical|unavailable' +Refuse 'UNC_LOCAL_FOLDER_REFUSED' '\\server\share' $fakeProfile 'UNC|canonical' +Refuse 'ROOT_ONLY_PROFILE_REFUSED' $fakeLocal ([IO.Path]::GetPathRoot($fakeProfile)) 'root-only|LocalAppData' +Refuse 'REDIRECTED_KNOWN_FOLDER_REFUSED' $folder $fakeProfile 'canonical local folder' +Refuse 'PARENT_TRAVERSAL_OUT_OF_LOCAL_REFUSED' (Join-Path $fakeLocal '..\Other') $fakeProfile 'canonical local folder' +Refuse 'OTHER_USER_PROFILE_REFUSED' $fakeLocal (Join-Path $folder 'different-profile') 'canonical local folder' +$outside=Join-Path $folder 'unrelated' +[void][IO.Directory]::CreateDirectory($outside) +$canary=Join-Path $outside 'untouched.txt' +[IO.File]::WriteAllText($canary,'R316_EXTERNAL_CANARY',[Text.Encoding]::ASCII) +$canaryHash=Sha $canary +$target=Join-Path $fakeLocal 'TaskbarMonitorEnhanced' +New-Item -ItemType Junction -Path $target -Target $outside -ErrorAction Stop|Out-Null +try{ + Refuse 'APP_ROOT_JUNCTION_REFUSED' $fakeLocal $fakeProfile 'reparse point' +}finally{ + [IO.Directory]::Delete($target) +} +$alias=Join-Path $folder 'profile-junction' +New-Item -ItemType Junction -Path $alias -Target $fakeProfile -ErrorAction Stop|Out-Null +try{ + Refuse 'PROFILE_PARENT_JUNCTION_REFUSED' (Join-Path $alias 'AppData\Local') $alias 'reparse point' +}finally{[IO.Directory]::Delete($alias)} +$localAlias=Join-Path $fakeProfile 'AppData\linked-Local' +New-Item -ItemType Junction -Path $localAlias -Target $fakeLocal -ErrorAction Stop|Out-Null +try{ + Refuse 'REDIRECTED_LINKED_LOCAL_FOLDER_REFUSED' $localAlias $fakeProfile 'canonical local folder|reparse point' +}finally{[IO.Directory]::Delete($localAlias)} +if((Sha $main) -cne $before.Main -or (Sha $config) -cne $before.Config -or + (Sha $state) -cne $before.State -or (Sha $canary) -cne $canaryHash){ + throw 'R316_GUEST_APP_DATA_OR_UNRELATED_CANARY_CHANGED' +} +$checks.Add('GUEST_INSTALLED_APP_AND_CANARY_BYTE_EXACT') +$receiptData=[ordered]@{ + Schema=1;Revision='R316';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Status='PASS_OS_KNOWNFOLDER_CANONICAL_APPROOT_AND_REPARSE_REFUSAL' + ExactCompiledInstallerSource=$true;CanonicalExistingAppRootAccepted=$true + SyntheticSafeProfileAccepted=$true;RelativeUNCTraversalAndForeignProfileRejected=$true + ExistingJunctionAndAliasRejected=$true;UserFilesAndExternalCanaryUnchanged=$true + OriginalOwnerMachineTouched=$false;ActualProtectedTaskTouched=$false + RealProgramFilesWritten=$false;ActualUACInvoked=$false + CodeQLHighAlertsAccepted=$false + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R316_'+$HostLabel+'_TRUSTED_KNOWNFOLDER_ROOT_BOUNDS_PASS' diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index a103f00a..b9df4c05 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -63,7 +63,55 @@ internal static partial class SetupProgram "Payload.TaskbarMonitorEnhanced_Setup.cs" }; - static string AppRoot { get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),AppFolder); } } + // R316: the account-scoped installation must never follow a redirected + // or linked LocalAppData directory into another user's/system tree. + // Validate OS known-folder identity and ancestors before any I/O sink. + static string CanonicalAppRootFromKnownFolders(string knownLocal,string knownProfile) + { + if(String.IsNullOrWhiteSpace(knownLocal) || String.IsNullOrWhiteSpace(knownProfile) || + !Path.IsPathRooted(knownLocal) || !Path.IsPathRooted(knownProfile)) + throw new InvalidDataException("Canonical user profile or LocalAppData unavailable."); + string profile=Path.GetFullPath(knownProfile); + string local=Path.GetFullPath(knownLocal); + if(profile.StartsWith(@"\\",StringComparison.Ordinal) || local.StartsWith(@"\\",StringComparison.Ordinal) || + String.Equals(profile,Path.GetPathRoot(profile),StringComparison.OrdinalIgnoreCase) || + String.Equals(local,Path.GetPathRoot(local),StringComparison.OrdinalIgnoreCase) || + !Regex.IsMatch(profile,@"^[A-Za-z]:\\") || + !Regex.IsMatch(local,@"^[A-Za-z]:\\")) + throw new InvalidDataException("Canonical per-user installation rejects UNC or root-only paths."); + profile=profile.TrimEnd(Path.DirectorySeparatorChar); + local=local.TrimEnd(Path.DirectorySeparatorChar); + string expected=Path.GetFullPath(Path.Combine(profile,"AppData","Local")).TrimEnd(Path.DirectorySeparatorChar); + if(!String.Equals(local,expected,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("LocalAppData is not the canonical local folder of this user's profile."); + string app=Path.GetFullPath(Path.Combine(local,AppFolder)); + string prefix=local+Path.DirectorySeparatorChar; + if(app.Length>1024 || !app.StartsWith(prefix,StringComparison.OrdinalIgnoreCase) || + !String.Equals(Path.GetFileName(app),AppFolder,StringComparison.Ordinal)) + throw new InvalidDataException("Application path escaped the per-user LocalAppData root."); + CheckRootAncestorsNoReparse(app); + return app; + } + static void CheckRootAncestorsNoReparse(string absolutePath) + { + string cursor=Path.GetFullPath(absolutePath); + while(!String.IsNullOrEmpty(cursor)) + { + try{ + if((File.GetAttributes(cursor)&FileAttributes.ReparsePoint)!=0) + throw new IOException("Canonical user installation path contains a reparse point."); + }catch(FileNotFoundException){}catch(DirectoryNotFoundException){} + string parent=Path.GetDirectoryName(cursor); + if(String.IsNullOrEmpty(parent) || String.Equals(parent,cursor,StringComparison.OrdinalIgnoreCase)) + break; + cursor=parent; + } + } + static string AppRoot { + get { return CanonicalAppRootFromKnownFolders( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); } + } static string AppExe { get { return Path.Combine(AppRoot,"TaskbarMonitorEnhanced.exe"); } } static string SensorResultPath { get { return Path.Combine(AppRoot,"Logs","sensor_install_result.json"); } } static string MainLaunchMode="PENDING"; From 031abfd762980541addf445724b0037d29b72ecd Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 22:00:40 +0330 Subject: [PATCH 062/135] test: compile identical net48 Setup source for comparable R316 CodeQL evidence (F01) --- .github/workflows/audit-r316-codeql-root.yml | 35 ++++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/audit-r316-codeql-root.yml b/.github/workflows/audit-r316-codeql-root.yml index 660303ca..1d7e8de1 100644 --- a/.github/workflows/audit-r316-codeql-root.yml +++ b/.github/workflows/audit-r316-codeql-root.yml @@ -31,8 +31,25 @@ jobs: uses: github/codeql-action/init@v4 with: languages: csharp - build-mode: none + build-mode: manual queries: security-extended + - name: Build exact Setup source using net48 compiler with CodeQL tracing + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $source=(Resolve-Path installer/TaskbarMonitorEnhanced_Setup.cs).Path + $escaped=[Security.SecurityElement]::Escape($source) + $project='net48'+ + 'WinExetrue'+ + 'falsefalse'+ + 'latest'+ + ''+ + '' + $file=Join-Path $env:RUNNER_TEMP 'R316-Exact-Setup-CodeQL.csproj' + [IO.File]::WriteAllText($file,$project,[Text.UTF8Encoding]::new($false)) + dotnet build $file -c Release --nologo -p:RestoreIgnoreFailedSources=true + if($LASTEXITCODE -ne 0){throw 'R316_CODEQL_CSHARP_NET48_SOURCE_COMPILATION_FAILED'} + 'R316_EXACT_SETUP_SOURCE_COMPILED_UNDER_CODEQL_TRACE' - name: Analyze without uploading or dismissing any scanning alerts uses: github/codeql-action/analyze@v4 with: @@ -44,11 +61,17 @@ jobs: $ErrorActionPreference='Stop' $files=@(Get-ChildItem -LiteralPath codeql-results -Recurse -Filter '*.sarif' -File) if($files.Count -lt 1){throw 'R316_CODEQL_SARIF_NOT_GENERATED'} + $compiledReferences=0 + $compilerErrorCount=0 $relevant=@() $allCount=0 foreach($file in $files){ $sarif=Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -Depth 100 foreach($run in @($sarif.runs)){ + $compiledReferences+=@($run.artifacts|Where-Object { [string]$_.location.uri -like '*TaskbarMonitorEnhanced_Setup.cs*' }).Count + foreach($invocation in @($run.invocations)){ + $compilerErrorCount+=@($invocation.toolExecutionNotifications|Where-Object {[string]$_.message.text -match 'Error CS[0-9]{4}'}).Count + } $allCount+=@($run.results).Count $relevant+=@($run.results|Where-Object { $_.ruleId -in @('cs/path-injection','cs/command-line-injection') @@ -57,12 +80,18 @@ jobs: }) } } + if($compiledReferences -lt 1 -or $compilerErrorCount -gt 0){ + throw ('R316_CODEQL_SOURCE_COVERAGE_NOT_VALID references='+$compiledReferences+' compilerErrors='+$compilerErrorCount) + } + if('${{ matrix.variant }}' -eq 'baseline' -and $relevant.Count -lt 1){ + throw 'R316_BASELINE_CODEQL_QUERY_COVERAGE_UNTRUSTWORTHY_ZERO' + } $summary=[ordered]@{ - Schema=1;Revision='R316';Mode='csharp-build-mode-none-security-extended' + Schema=1;Revision='R316';Mode='csharp-manual-net48-compiled-security-extended' Variant='${{ matrix.variant }}' AnalyzedCommit=(git rev-parse HEAD).Trim() WorkflowCommit='${{ github.sha }}' - GeneratedSarifCount=$files.Count;AllFindings=$allCount + GeneratedSarifCount=$files.Count;AllFindings=$allCount;CompiledSetupReferences=$compiledReferences;CompilerErrors=$compilerErrorCount TargetFindingCount=$relevant.Count PathInjectionCount=@($relevant|Where-Object Rule -eq 'cs/path-injection').Count CommandLineInjectionCount=@($relevant|Where-Object Rule -eq 'cs/command-line-injection').Count From 948aa95d5ad5c0140d70b7a589ccafff8e831d67 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 22:11:20 +0330 Subject: [PATCH 063/135] test: reproduce org Default Setup CodeQL on ubuntu none for R316 comparison (F02) --- .github/workflows/audit-r316-codeql-root.yml | 30 ++++---------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/.github/workflows/audit-r316-codeql-root.yml b/.github/workflows/audit-r316-codeql-root.yml index 1d7e8de1..b3e52df1 100644 --- a/.github/workflows/audit-r316-codeql-root.yml +++ b/.github/workflows/audit-r316-codeql-root.yml @@ -15,7 +15,7 @@ permissions: jobs: source-to-sink: name: CodeQL ${{ matrix.variant }} - runs-on: windows-2025 + runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false @@ -31,31 +31,13 @@ jobs: uses: github/codeql-action/init@v4 with: languages: csharp - build-mode: manual - queries: security-extended - - name: Build exact Setup source using net48 compiler with CodeQL tracing - shell: pwsh - run: | - $ErrorActionPreference='Stop' - $source=(Resolve-Path installer/TaskbarMonitorEnhanced_Setup.cs).Path - $escaped=[Security.SecurityElement]::Escape($source) - $project='net48'+ - 'WinExetrue'+ - 'falsefalse'+ - 'latest'+ - ''+ - '' - $file=Join-Path $env:RUNNER_TEMP 'R316-Exact-Setup-CodeQL.csproj' - [IO.File]::WriteAllText($file,$project,[Text.UTF8Encoding]::new($false)) - dotnet build $file -c Release --nologo -p:RestoreIgnoreFailedSources=true - if($LASTEXITCODE -ne 0){throw 'R316_CODEQL_CSHARP_NET48_SOURCE_COMPILATION_FAILED'} - 'R316_EXACT_SETUP_SOURCE_COMPILED_UNDER_CODEQL_TRACE' + build-mode: none - name: Analyze without uploading or dismissing any scanning alerts uses: github/codeql-action/analyze@v4 with: upload: never output: codeql-results - - name: Count exact C sharp sink findings from local SARIF + - name: Compare Default Setup parity of C sharp sink findings from raw SARIF shell: pwsh run: | $ErrorActionPreference='Stop' @@ -80,14 +62,14 @@ jobs: }) } } - if($compiledReferences -lt 1 -or $compilerErrorCount -gt 0){ - throw ('R316_CODEQL_SOURCE_COVERAGE_NOT_VALID references='+$compiledReferences+' compilerErrors='+$compilerErrorCount) + if($compiledReferences -lt 1){ + throw ('R316_CODEQL_SOURCE_COVERAGE_NOT_VALID references='+$compiledReferences) } if('${{ matrix.variant }}' -eq 'baseline' -and $relevant.Count -lt 1){ throw 'R316_BASELINE_CODEQL_QUERY_COVERAGE_UNTRUSTWORTHY_ZERO' } $summary=[ordered]@{ - Schema=1;Revision='R316';Mode='csharp-manual-net48-compiled-security-extended' + Schema=1;Revision='R316';Mode='csharp-default-ubuntu-buildmode-none' Variant='${{ matrix.variant }}' AnalyzedCommit=(git rev-parse HEAD).Trim() WorkflowCommit='${{ github.sha }}' From d7b8c7f2e63c356cf1e951c2ae2ddc190b6e0ab3 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 22:17:32 +0330 Subject: [PATCH 064/135] test: include organization local threat model for exact R316 CodeQL comparison (F03) --- .github/workflows/audit-r316-codeql-root.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit-r316-codeql-root.yml b/.github/workflows/audit-r316-codeql-root.yml index b3e52df1..cfc9ee43 100644 --- a/.github/workflows/audit-r316-codeql-root.yml +++ b/.github/workflows/audit-r316-codeql-root.yml @@ -32,6 +32,8 @@ jobs: with: languages: csharp build-mode: none + config: | + threat-models: local - name: Analyze without uploading or dismissing any scanning alerts uses: github/codeql-action/analyze@v4 with: @@ -69,11 +71,12 @@ jobs: throw 'R316_BASELINE_CODEQL_QUERY_COVERAGE_UNTRUSTWORTHY_ZERO' } $summary=[ordered]@{ - Schema=1;Revision='R316';Mode='csharp-default-ubuntu-buildmode-none' + Schema=1;Revision='R316';Mode='csharp-default-ubuntu-none-remote-and-local' Variant='${{ matrix.variant }}' AnalyzedCommit=(git rev-parse HEAD).Trim() WorkflowCommit='${{ github.sha }}' GeneratedSarifCount=$files.Count;AllFindings=$allCount;CompiledSetupReferences=$compiledReferences;CompilerErrors=$compilerErrorCount + ThreatModel='remote_and_local' TargetFindingCount=$relevant.Count PathInjectionCount=@($relevant|Where-Object Rule -eq 'cs/path-injection').Count CommandLineInjectionCount=@($relevant|Where-Object Rule -eq 'cs/command-line-injection').Count From b3b25f77a17bb092c84396325c401d56158e0560 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 22:54:32 +0330 Subject: [PATCH 065/135] security(experiment): replace Temp batch uninstall with bounded nonrecursive PE cleanup (R317) --- ...it-r286-optional-audio-native-recovery.yml | 31 +++ ...st-R317-UninstallEncodedCleanup-Native.ps1 | 227 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 155 +++++++++++- 3 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 build/Test-R317-UninstallEncodedCleanup-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index dd1e9d01..62177d26 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -858,6 +858,36 @@ jobs: $r.CodeQLHighAlertsAccepted -ne $false -or @($r.Checks).Count -ne 13){throw 'R316_EXACT_NATIVE_ROOT_BOUNDARY_GATES_NOT_ACCEPTED'} 'R316_CANONICAL_KNOWNFOLDER_AND_REPARSE_PROTECTION_PASS_CODEQL_STILL_OPEN' + - name: R317 verify PE-pinned nonrecursive uninstall cleanup transport without deletion + shell: powershell + run: | + $ErrorActionPreference='Stop' + $proof=(Resolve-Path build/Test-R317-UninstallEncodedCleanup-Native.ps1).Path + $tok=$null;$errs=$null + [void][Management.Automation.Language.Parser]::ParseFile($proof,[ref]$tok,[ref]$errs) + if(@($errs).Count){$errs|ForEach-Object {$_.Message};throw 'R317_PS51_TEST_PARSER_INVALID'} + ./build/Test-R317-UninstallEncodedCleanup-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R317_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R317_ENCODED_CLEANUP_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R317_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R317' -or + $r.Status -cne 'PASS_EXACT_PE_INMEMORY_UNINSTALL_CLEANUP_TRANSPORT_SYNTHETIC' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.MainInstallationUntouched -ne $true -or + $r.ActualCleanupProcessLaunched -ne $false -or + $r.ActualDirectoryDeleted -ne $false -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.NoMutableTempBatchOrCmd -ne $true -or + $r.ProcessHostAbsolutePinned -ne $true -or + $r.ShellArgumentsPureBase64 -ne $true -or + $r.WrongRootsAndInjectionRefused -ne $true -or + $r.ScriptSyntaxAndTokenGuardVerified -ne $true -or + $r.UserDataUnchanged -ne $true -or + $r.SyntheticNonrecursiveDeletionVerified -ne $true -or + $r.SyntheticJunctionRefusalVerified -ne $true -or + $r.TrueUninstallCleanupEndToEndProven -ne $false -or + @($r.Checks).Count -ne 23){throw 'R317_TYPED_SAFE_UNINSTALL_TRANSPORT_GATES_NOT_MET'} + 'R317_PRIVATE_PE_CLEANUP_TRANSPORT_SYNTHETIC_PASS_REAL_UNINSTALL_OPEN' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -894,3 +924,4 @@ jobs: ${{ runner.temp }}/R314_${{ matrix.host }}_${{ github.run_id }}/R314_TASK_COLD_RESTORE_RECEIPT.json ${{ runner.temp }}/R315_${{ matrix.host }}_${{ github.run_id }}/R315_TASK_COLD_RESTORE_RECEIPT.json ${{ runner.temp }}/R316_${{ matrix.host }}_${{ github.run_id }}/R316_TRUSTED_APPROOT_RECEIPT.json + ${{ runner.temp }}/R317_${{ matrix.host }}_${{ github.run_id }}/R317_ENCODED_CLEANUP_RECEIPT.json diff --git a/build/Test-R317-UninstallEncodedCleanup-Native.ps1 b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 new file mode 100644 index 00000000..5a34b95f --- /dev/null +++ b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 @@ -0,0 +1,227 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R317_DISPOSABLE_WINDOWS_ONLY' +} +function Hash([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$run=Join-Path $env:RUNNER_TEMP ('R317_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $run 'R317_ENCODED_CLEANUP_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$prior=Join-Path $env:RUNNER_TEMP ('R316_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R316_TRUSTED_APPROOT_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($run)){throw 'R317_TEST_ROOT_PREEXISTS'} +[void][IO.Directory]::CreateDirectory($run) +$old=@{Main=(Hash $main);Config=(Hash $config);State=(Hash $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (Hash $setup) -or + $c.MainSHA256 -cne $old.Main -or + $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Host -cne $HostLabel -or + $p.Status -cne 'PASS_OS_KNOWNFOLDER_CANONICAL_APPROOT_AND_REPARSE_REFUSAL'){ + throw 'R317_EXACT_PRIVATE_PE_OR_R316_PREDECESSOR_MISMATCH' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$sourceStream=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$sourceStream){throw 'R317_SETUP_SOURCE_NOT_EMBEDDED'} +$reader=[IO.StreamReader]::new($sourceStream) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$normalize={param([string]$text) $text.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($source)))){ + throw 'R317_COMPILED_PRIVATE_SETUP_NOT_REVIEWED_SOURCE' +} +$checks.Add('EXACT_PE_PRIVATE_INSTALLER_EMBEDDED_SOURCE_PINNED') +$type=$assembly.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) +$rootProp=$type.GetProperty('AppRoot',$flags) +if(!$builder -or !$rootProp){throw 'R317_NO_DEFERRED_CLEANUP_FACTORY_IN_PE'} +$checks.Add('PURE_PRIVATE_CLEANUP_FACTORY_PRESENT') +$root=[string]$rootProp.GetValue($null,$null) +$expected=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced')) +if(-not [string]::Equals($root,$expected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R317_OS_OWNER_CANONICAL_ROOT_CHANGED' +} +$pidSelf=[Diagnostics.Process]::GetCurrentProcess() +try{$selfPid=[int]$pidSelf.Id;$selfTicks=[long]$pidSelf.StartTime.ToUniversalTime().Ticks}finally{$pidSelf.Dispose()} +$psi=$builder.Invoke($null,[object[]]@($root,$selfPid,$selfTicks)) +$prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' +if(-not $psi.Arguments.StartsWith($prefix,[StringComparison]::Ordinal) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ') -or + $psi.Arguments.Contains('ExecutionPolicy Bypass') -or + $psi.Arguments.Contains($root) -or $psi.Arguments.Length -gt 22080){ + throw 'R317_UNSAFE_UNENCODED_CLEANUP_TRANSPORT' +} +$checks.Add('ONLY_BOUNDED_UTF16_ENCODED_COMMAND_WITHOUT_LITERAL_USER_PATH') +$trusted=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +if(-not [string]::Equals($psi.FileName,$trusted,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + $psi.CreateNoWindow -ne $true -or + $psi.WorkingDirectory -cnotmatch '^(?i)[A-Z]:\\' -or + [string]::Equals($psi.WorkingDirectory,$root,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R317_UNTRUSTED_CLEANUP_HOST_OR_CWD' +} +$checks.Add('ABSOLUTE_SYSTEM32_POWERSHELL_NOT_CMD_OR_APP_CWD') +$code=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($psi.Arguments.Substring($prefix.Length))) +$tokens=$null;$errors=$null +[void][Management.Automation.Language.Parser]::ParseInput($code,[ref]$tokens,[ref]$errors) +if(@($errors).Count){throw 'R317_ACTUAL_IN_MEMORY_CODE_PS51_PARSER_FAILED'} +$checks.Add('EMBEDDED_CLEANUP_SCRIPT_WINPS51_PARSER_ACCEPTED') +$match=[regex]::Match($code,'(?m)^\$root=\[Text\.Encoding\]::UTF8\.GetString\(\[Convert\]::FromBase64String\(''([A-Za-z0-9+/=]+)''\)\)') +if(-not $match.Success -or + -not [string]::Equals([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($match.Groups[1].Value)),$root,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R317_APP_ROOT_NOT_IN_SAFE_BASE64_DATA_SLOT' +} +$checks.Add('APP_ROOT_UTF8_BASE64_DATA_ROUNDTRIP_BYTE_EXACT') +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if(-not $code.Contains("$" + "expectedSid='$sid'") -or + -not $code.Contains("$" + "expectedPid=[int]$selfPid") -or + -not $code.Contains("$" + "expectedStart=[long]$selfTicks")){ + throw 'R317_EXPECTED_ACCOUNT_PID_START_BINDINGS_MISSING' +} +$checks.Add('EFFECTIVE_SID_PARENT_PID_AND_STARTTIME_BOUND') +foreach($literal in @( + 'TBME_CLEANUP_WRONG_EFFECTIVE_TOKEN', + 'TBME_CLEANUP_ROOT_NOT_CANONICAL', + 'TBME_CLEANUP_ANCESTOR_REPARSE', + 'TBME_CLEANUP_CHILD_REPARSE', + 'TBME_CLEANUP_EXCESSIVE_ENTRY_COUNT', + '[IO.Directory]::Delete($dir,$false)' +)){ + if(-not $code.Contains($literal)){throw ('R317_SCRIPT_PROTECTION_MISSING '+$literal)} +} +$checks.Add('LATE_ROOT_SID_AND_NONFOLLOWING_DESCENDANT_GUARDS_PRESENT') +if($code.Contains('[IO.Directory]::Delete($expectedRoot,$true)') -or + -not $code.Contains('[IO.File]::Delete($file)') -or + -not $code.Contains('TBME_CLEANUP_PATH_ESCAPED_OWNER_ROOT') -or + -not $code.Contains('TBME_CLEANUP_CHANGED_DIRECTORY')){ + throw 'R317_RECURSIVE_OR_UNBOUNDED_CLEANUP_NOT_PERMITTED' +} +$checks.Add('NONRECURSIVE_EMPTY_DIRECTORY_DELETION_WITH_REPARSE_PRECHECK') +$fragmentStart=$code.IndexOf('$cursor=$expectedRoot',[StringComparison]::Ordinal) +$fragmentEndMarker='[IO.Directory]::Delete($dir,$false)' +$fragmentEnd=$code.LastIndexOf($fragmentEndMarker,[StringComparison]::Ordinal) +if($fragmentStart -lt 0 -or $fragmentEnd -le $fragmentStart -or + $code.Contains('[IO.Directory]::Delete($expectedRoot,$true)')){ + throw 'R317_EXACT_PRODUCTION_DELETION_FRAGMENT_NOT_SAFE' +} +$fragment=$code.Substring($fragmentStart,$fragmentEnd+$fragmentEndMarker.Length-$fragmentStart) +$tokFragment=$null;$errFragment=$null +[void][Management.Automation.Language.Parser]::ParseInput($fragment,[ref]$tokFragment,[ref]$errFragment) +if(@($errFragment).Count){throw 'R317_NONRECURSIVE_FRAGMENT_INVALID_SYNTAX'} +$removeSynthetic=[scriptblock]::Create($fragment) +$checks.Add('EXACT_PE_NONRECURSIVE_DELETE_FRAGMENT_EXTRACTED_AND_PARSED') +$isolated=Join-Path $run 'native-only-delete-fixture' +$nested=Join-Path $isolated 'nested' +[void][IO.Directory]::CreateDirectory($nested) +[IO.File]::WriteAllText((Join-Path $isolated 'first.txt'),'R317_SYNTHETIC_LEAF_A') +[IO.File]::WriteAllText((Join-Path $nested 'second.txt'),'R317_SYNTHETIC_LEAF_B') +$expectedRoot=$isolated +if(-not [IO.Path]::GetFullPath($expectedRoot).StartsWith(([IO.Path]::GetFullPath($run).TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R317_FIXTURE_NOT_CHILD_OF_HOSTED_RUNNER_TEMP' +} +& $removeSynthetic +if([IO.Directory]::Exists($isolated)){throw 'R317_NONRECURSIVE_NATIVE_DELETE_DID_NOT_FINISH'} +$checks.Add('ONLY_HOSTED_SYNTHETIC_TREE_NONRECURSIVELY_DELETED') +$external=Join-Path $run 'external-untouched-canary' +[void][IO.Directory]::CreateDirectory($external) +$canary=Join-Path $external 'preserve.txt' +[IO.File]::WriteAllText($canary,'R317_KEEP_EXTERNAL_DATA') +$canarySha=Hash $canary +$blocked=Join-Path $run 'native-only-junction-refusal' +[void][IO.Directory]::CreateDirectory($blocked) +$link=Join-Path $blocked 'unknown-junction' +New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null +$expectedRoot=$blocked +$refused=$false +try{& $removeSynthetic}catch{ + if($_.Exception.ToString() -match 'TBME_CLEANUP_CHILD_REPARSE'){$refused=$true}else{throw} +} +if(-not $refused -or (Hash $canary) -cne $canarySha -or + -not [IO.Directory]::Exists($blocked)){throw 'R317_CHILD_JUNCTION_WAS_FOLLOWED'} +[IO.Directory]::Delete($link,$false) +$checks.Add('JUNCTION_DESCENDANT_REFUSED_WITH_NO_EXTERNAL_DELETION') +$rootJunction=Join-Path $run 'native-only-root-junction' +New-Item -ItemType Junction -Path $rootJunction -Target $external -ErrorAction Stop|Out-Null +$expectedRoot=$rootJunction +$rootRefused=$false +try{& $removeSynthetic}catch{ + if($_.Exception.ToString() -match 'TBME_CLEANUP_ANCESTOR_REPARSE'){$rootRefused=$true}else{throw} +} +if(-not $rootRefused -or (Hash $canary) -cne $canarySha){ + throw 'R317_ROOT_JUNCTION_WAS_FOLLOWED' +} +[IO.Directory]::Delete($rootJunction,$false) +$checks.Add('ROOT_JUNCTION_REFUSED_WITH_EXTERNAL_CANARY_INTACT') + +$sourceText=[IO.File]::ReadAllText($source) +if($sourceText.Contains('tbme_cleanup_') -or + $sourceText.Contains('rmdir /s /q') -or + $sourceText.Contains('new ProcessStartInfo("cmd.exe"') -or + $sourceText.Contains('Process.Start(new ProcessStartInfo("cmd.exe"')){ + throw 'R317_MUTABLE_TEMP_BATCH_OR_CMD_STILL_IN_PRODUCTION_SETUP' +} +$checks.Add('REMOVED_MUTABLE_TEMP_BATCH_AND_UNQUALIFIED_CMD_SINK') +function MustRefuse([string]$name,[object]$path,[int]$pid,[long]$ticks){ + $rejected=$false + try{$null=$builder.Invoke($null,([object[]]@($path,$pid,$ticks)))} + catch{ + if($_.Exception.GetBaseException().Message -match 'Uninstall cleanup requires the exact owner root'){ + $rejected=$true + }else{throw} + } + if(-not $rejected){throw ('R317_FAIL_OPEN_'+$name)} + $script:checks.Add($name) +} +MustRefuse 'FOREIGN_SYSTEM_ROOT_REFUSED' 'C:\Windows' $selfPid $selfTicks +MustRefuse 'UNC_ROOT_REFUSED' '\\server\share' $selfPid $selfTicks +MustRefuse 'INJECTED_COMMAND_PATH_REFUSED' 'C:\Windows&whoami' $selfPid $selfTicks +MustRefuse 'CMD_PERCENT_VARIABLE_PATH_REFUSED' 'C:\Test%TBME_R317_ATTACK_PROBE%\TaskbarMonitorEnhanced' $selfPid $selfTicks +MustRefuse 'ZERO_PARENT_PID_REFUSED' $root 0 $selfTicks +MustRefuse 'INVALID_PARENT_STARTTIME_REFUSED' $root $selfPid 0 +MustRefuse 'SUBFOLDER_NOT_APP_ROOT_REFUSED' (Join-Path $root 'Logs') $selfPid $selfTicks +if((Hash $main) -cne $old.Main -or (Hash $config) -cne $old.Config -or + (Hash $state) -cne $old.State){ + throw 'R317_USER_APP_CHANGED_BY_TRANSPORT_ONLY_PROOF' +} +$checks.Add('NO_INSTALLATION_OR_SETTINGS_MUTATED') +$checks.Add('NO_CLEANUP_PROCESS_LAUNCHED_IN_TEST') +$receiptObject=[ordered]@{ + Schema=1;Revision='R317' + Status='PASS_EXACT_PE_INMEMORY_UNINSTALL_CLEANUP_TRANSPORT_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + MainInstallationUntouched=$true;ActualCleanupProcessLaunched=$false + ActualDirectoryDeleted=$false;OriginalOwnerComputerTouched=$false + NoMutableTempBatchOrCmd=$true;ProcessHostAbsolutePinned=$true + ShellArgumentsPureBase64=$true;WrongRootsAndInjectionRefused=$true + ScriptSyntaxAndTokenGuardVerified=$true;UserDataUnchanged=$true + SyntheticNonrecursiveDeletionVerified=$true;SyntheticJunctionRefusalVerified=$true + TrueUninstallCleanupEndToEndProven=$false + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($receiptObject|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R317_'+$HostLabel+'_SAFE_UNINSTALL_TRANSPORT_NOT_LIVE_CLEANUP_PASS' diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index b9df4c05..53f795c9 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -706,6 +706,135 @@ static SensorOutcome Install(bool desktop,bool startup) return sensorOutcome; } + // R317: defer deletion until this unelevated Setup process exits. + // Do not write executable scripts into mutable Temp and never run cmd.exe. + // The entire PowerShell source is an immutable Setup PE string; all paths + // enter only as Base64 UTF-8 data, never as PowerShell syntax. + static ProcessStartInfo BuildDeferredUninstallCleanupProcessInfo(string root,int parentPid,long parentStartUtcTicks) + { + string canonical=AppRoot; + if(String.IsNullOrWhiteSpace(root) || + !String.Equals(Path.GetFullPath(root),canonical,StringComparison.OrdinalIgnoreCase) || + parentPid<=0 || parentStartUtcTicks<=0) + throw new InvalidDataException("Uninstall cleanup requires the exact owner root and parent identity."); + string sid=WindowsIdentity.GetCurrent().User.Value; + if(!Regex.IsMatch(sid,@"^S-1-5-21-(?:\d+-){3}\d+$")) + throw new InvalidDataException("Uninstall cleanup refuses a noninteractive token."); + string rootBlob=Base64Utf8Argument(canonical); + string script=@"Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$root=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__ROOT_B64__')) +$expectedSid='__OWNER_SID__' +$expectedPid=[int]__PROCESS_ID__ +$expectedStart=[long]__START_TICKS__ +if([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -cne $expectedSid){ + throw 'TBME_CLEANUP_WRONG_EFFECTIVE_TOKEN' +} +$profile=[IO.Path]::GetFullPath([Environment]::GetFolderPath('UserProfile')).TrimEnd('\') +$local=[IO.Path]::GetFullPath([Environment]::GetFolderPath('LocalApplicationData')).TrimEnd('\') +$expectedLocal=[IO.Path]::GetFullPath([IO.Path]::Combine($profile,'AppData','Local')).TrimEnd('\') +$expectedRoot=[IO.Path]::GetFullPath([IO.Path]::Combine($local,'TaskbarMonitorEnhanced')) +if($profile.StartsWith('\\') -or $local.StartsWith('\\') -or + -not [string]::Equals($local,$expectedLocal,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([IO.Path]::GetFullPath($root),$expectedRoot,[StringComparison]::OrdinalIgnoreCase)){ + throw 'TBME_CLEANUP_ROOT_NOT_CANONICAL' +} +try{ + $parent=[Diagnostics.Process]::GetProcessById($expectedPid) + try{ + if($parent.StartTime.ToUniversalTime().Ticks -eq $expectedStart -and + -not $parent.WaitForExit(300000)){ + throw 'TBME_CLEANUP_PARENT_STILL_RUNNING' + } + }finally{$parent.Dispose()} +}catch [ArgumentException]{ + # Parent already exited before cleanup began. +}catch [InvalidOperationException]{ + # Parent exited between process lookup and StartTime/WaitForExit. +} +if(-not [IO.Directory]::Exists($expectedRoot)){exit 0} +# Reject every ancestor and descendant reparse point before deletion; +# this is a path-based check, not proof against concurrent swaps. +$cursor=$expectedRoot +while(-not [string]::IsNullOrEmpty($cursor)){ + if(([IO.File]::GetAttributes($cursor) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'TBME_CLEANUP_ANCESTOR_REPARSE' + } + $parent=[IO.Path]::GetDirectoryName($cursor) + if([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor){break} + $cursor=$parent +} +# Stage an exact non-following deletion inventory. Never ask .NET to +# recursively delete a directory that might acquire a new junction. +$prefix=[IO.Path]::GetFullPath($expectedRoot).TrimEnd('\')+'\' +$stack=[Collections.Generic.List[string]]::new() +$dirs=[Collections.Generic.List[string]]::new() +$files=[Collections.Generic.List[string]]::new() +$stack.Add($expectedRoot) +$members=0 +while($stack.Count -gt 0){ + $dir=$stack[$stack.Count-1] + $stack.RemoveAt($stack.Count-1) + $attrs=[IO.File]::GetAttributes($dir) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ($attrs -band [IO.FileAttributes]::Directory) -eq 0){ + throw 'TBME_CLEANUP_CHANGED_DIRECTORY' + } + $dirs.Add($dir) + foreach($entry in [IO.Directory]::GetFileSystemEntries($dir)){ + $members++ + if($members -gt 200000){throw 'TBME_CLEANUP_EXCESSIVE_ENTRY_COUNT'} + $full=[IO.Path]::GetFullPath($entry) + if(-not $full.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)){ + throw 'TBME_CLEANUP_PATH_ESCAPED_OWNER_ROOT' + } + $attrs=[IO.File]::GetAttributes($full) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'TBME_CLEANUP_CHILD_REPARSE' + } + if(($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + $stack.Add($full) + }else{ + $files.Add($full) + } + } +} +# Explicit leaf deletes, followed by empty directories deepest first. +# Every unexpected new member makes Directory.Delete(...,false) fail +# rather than following it. Native handle-bound race proof remains open. +foreach($file in $files){ + $attrs=[IO.File]::GetAttributes($file) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ($attrs -band [IO.FileAttributes]::Directory) -ne 0){ + throw 'TBME_CLEANUP_CHANGED_FILE' + } + [IO.File]::Delete($file) +} +foreach($dir in ($dirs|Sort-Object Length -Descending)){ + $attrs=[IO.File]::GetAttributes($dir) + if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'TBME_CLEANUP_CHANGED_DIRECTORY' + } + [IO.Directory]::Delete($dir,$false) +} +"; + script=script.Replace("__ROOT_B64__",rootBlob) + .Replace("__OWNER_SID__",sid) + .Replace("__PROCESS_ID__",parentPid.ToString(CultureInfo.InvariantCulture)) + .Replace("__START_TICKS__",parentStartUtcTicks.ToString(CultureInfo.InvariantCulture)); + string encoded=Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + if(encoded.Length>22000 || !Regex.IsMatch(encoded,@"^[A-Za-z0-9+/=]+$")) + throw new InvalidDataException("Uninstall cleanup command is not a bounded Base64 transport."); + ProcessStartInfo psi=new ProcessStartInfo(); + psi.FileName=TrustedSystemPowerShellPath(); + psi.Arguments="-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "+encoded; + psi.WorkingDirectory=Environment.SystemDirectory; + psi.UseShellExecute=false; + psi.CreateNoWindow=true; + psi.WindowStyle=ProcessWindowStyle.Hidden; + return psi; + } + static void Uninstall(bool quiet) { if(!quiet){ @@ -734,19 +863,21 @@ static void Uninstall(bool quiet) try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),"Taskbar Monitor Enhanced Startup Recovery.lnk"));}catch{} Registry.CurrentUser.DeleteSubKeyTree(UninstallKey,false); - string cleanup=Path.Combine(Path.GetTempPath(),"tbme_cleanup_"+Guid.NewGuid().ToString("N")+".cmd"); - File.WriteAllText(cleanup, - "@echo off\r\nping 127.0.0.1 -n 4 >nul\r\nrmdir /s /q \""+AppRoot+"\"\r\ndel /q \"%~f0\"\r\n", - Encoding.ASCII); - ProcessStartInfo psi=new ProcessStartInfo("cmd.exe","/c \""+cleanup+"\""); - psi.UseShellExecute=false;psi.CreateNoWindow=true; - // R108: background cmd must not inherit AppRoot as its working directory. - // Otherwise its rmdir of AppRoot can fail although Uninstall.exe exits zero. - psi.WorkingDirectory=Path.GetTempPath(); - Process.Start(psi); - + // Wait for the user to close the dialog before launching cleanup; + // then the cleanup process waits for this parent to exit. if(!quiet) - MessageBox.Show(Product+" was removed.","Uninstall complete",MessageBoxButtons.OK,MessageBoxIcon.Information); + MessageBox.Show(Product+" uninstall steps completed. Local files will be removed after this window closes.","Uninstall complete",MessageBoxButtons.OK,MessageBoxIcon.Information); + using(Process self=Process.GetCurrentProcess()) + { + ProcessStartInfo info=BuildDeferredUninstallCleanupProcessInfo( + AppRoot,self.Id,self.StartTime.ToUniversalTime().Ticks); + using(Process delayed=Process.Start(info)) + { + if(delayed==null) + throw new IOException("Trusted deferred uninstall cleanup did not start."); + } + } + } static int Verify(string path) From 60bc9bd2df5ac071fb8c63712bdf6987dfbaa6d9 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 22:59:35 +0330 Subject: [PATCH 066/135] test: include closing foreach in R317 bounded synthetic deletion proof (F01) --- build/Test-R317-UninstallEncodedCleanup-Native.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build/Test-R317-UninstallEncodedCleanup-Native.ps1 b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 index 5a34b95f..b840b712 100644 --- a/build/Test-R317-UninstallEncodedCleanup-Native.ps1 +++ b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 @@ -128,7 +128,12 @@ if($fragmentStart -lt 0 -or $fragmentEnd -le $fragmentStart -or $code.Contains('[IO.Directory]::Delete($expectedRoot,$true)')){ throw 'R317_EXACT_PRODUCTION_DELETION_FRAGMENT_NOT_SAFE' } -$fragment=$code.Substring($fragmentStart,$fragmentEnd+$fragmentEndMarker.Length-$fragmentStart) +$fragmentClose=$code.IndexOf('}',$fragmentEnd+$fragmentEndMarker.Length) +if($fragmentClose -le $fragmentEnd -or + $code.Substring($fragmentEnd+$fragmentEndMarker.Length,$fragmentClose-($fragmentEnd+$fragmentEndMarker.Length)).Trim().Length -ne 0){ + throw 'R317_INVALID_END_OF_NONRECURSIVE_FOREACH' +} +$fragment=$code.Substring($fragmentStart,$fragmentClose+1-$fragmentStart) $tokFragment=$null;$errFragment=$null [void][Management.Automation.Language.Parser]::ParseInput($fragment,[ref]$tokFragment,[ref]$errFragment) if(@($errFragment).Count){throw 'R317_NONRECURSIVE_FRAGMENT_INVALID_SYNTAX'} From 5cee6d4a46d27ec7dc9e6d69b8c320c9a12e843a Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 23:07:02 +0330 Subject: [PATCH 067/135] test: avoid readonly PowerShell PID parameter in R317 native proof (F02) --- build/Test-R317-UninstallEncodedCleanup-Native.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/Test-R317-UninstallEncodedCleanup-Native.ps1 b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 index b840b712..5e2ee7b7 100644 --- a/build/Test-R317-UninstallEncodedCleanup-Native.ps1 +++ b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 @@ -190,9 +190,9 @@ if($sourceText.Contains('tbme_cleanup_') -or throw 'R317_MUTABLE_TEMP_BATCH_OR_CMD_STILL_IN_PRODUCTION_SETUP' } $checks.Add('REMOVED_MUTABLE_TEMP_BATCH_AND_UNQUALIFIED_CMD_SINK') -function MustRefuse([string]$name,[object]$path,[int]$pid,[long]$ticks){ +function MustRefuse([string]$name,[object]$path,[int]$parentProcessId,[long]$ticks){ $rejected=$false - try{$null=$builder.Invoke($null,([object[]]@($path,$pid,$ticks)))} + try{$null=$builder.Invoke($null,([object[]]@($path,$parentProcessId,$ticks)))} catch{ if($_.Exception.GetBaseException().Message -match 'Uninstall cleanup requires the exact owner root'){ $rejected=$true From ff311893384adb497c9e8fad8a474b221d24b5e0 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 23:12:12 +0330 Subject: [PATCH 068/135] test: bind invalid cleanup paths as strings in R317 reflection proof (F03) --- build/Test-R317-UninstallEncodedCleanup-Native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-R317-UninstallEncodedCleanup-Native.ps1 b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 index 5e2ee7b7..12e566b8 100644 --- a/build/Test-R317-UninstallEncodedCleanup-Native.ps1 +++ b/build/Test-R317-UninstallEncodedCleanup-Native.ps1 @@ -190,7 +190,7 @@ if($sourceText.Contains('tbme_cleanup_') -or throw 'R317_MUTABLE_TEMP_BATCH_OR_CMD_STILL_IN_PRODUCTION_SETUP' } $checks.Add('REMOVED_MUTABLE_TEMP_BATCH_AND_UNQUALIFIED_CMD_SINK') -function MustRefuse([string]$name,[object]$path,[int]$parentProcessId,[long]$ticks){ +function MustRefuse([string]$name,[string]$path,[int]$parentProcessId,[long]$ticks){ $rejected=$false try{$null=$builder.Invoke($null,([object[]]@($path,$parentProcessId,$ticks)))} catch{ From 1b9d2f48c25fe5c64a2961ece329c8e901f69ce3 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 23:23:58 +0330 Subject: [PATCH 069/135] security(experiment): create shortcuts in-process via trusted COM, no Temp script (R318) --- ...it-r286-optional-audio-native-recovery.yml | 31 ++++ build/Test-R318-InProcessShortcut-Native.ps1 | 166 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 62 +++++-- 3 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 build/Test-R318-InProcessShortcut-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 62177d26..9ae5b4f5 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -888,6 +888,36 @@ jobs: $r.TrueUninstallCleanupEndToEndProven -ne $false -or @($r.Checks).Count -ne 23){throw 'R317_TYPED_SAFE_UNINSTALL_TRANSPORT_GATES_NOT_MET'} 'R317_PRIVATE_PE_CLEANUP_TRANSPORT_SYNTHETIC_PASS_REAL_UNINSTALL_OPEN' + - name: R318 create and inspect COM shortcut without Temp PowerShell script + shell: powershell + run: | + $ErrorActionPreference='Stop' + $proof=(Resolve-Path build/Test-R318-InProcessShortcut-Native.ps1).Path + $tok=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($proof,[ref]$tok,[ref]$errors) + if(@($errors).Count){$errors|ForEach-Object {$_.Message};throw 'R318_PS51_TEST_SYNTAX_INVALID'} + ./build/Test-R318-InProcessShortcut-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R318_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R318_INPROCESS_SHORTCUT_RECEIPT.json') + if(-not (Test-Path -LiteralPath $receipt)){throw 'R318_RAW_COM_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R318' -or + $r.Status -cne 'PASS_EXACT_PE_INPROCESS_COM_SHORTCUT_AND_NO_TEMP_SCRIPT' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne '${{ matrix.host }}' -or + $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualProgramFilesWritten -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.OriginalProtectedSensorTaskTouched -ne $false -or + $r.ActualInstalledShortcutChanged -ne $false -or + $r.ExactPeSourceAndCOMMethodVerified -ne $true -or + $r.WScriptShortcutCreatedReadAndRemoved -ne $true -or + $r.AllShortcutPropertiesRoundtripped -ne $true -or + $r.PowerShellTempScriptEliminated -ne $true -or + $r.MetacharactersTreatedAsData -ne $true -or + $r.RelativeAndInvalidPathsRefused -ne $true -or + $r.RealInstalledUserDataUnchanged -ne $true -or + $r.RealOwnerInstallationOrUninstallInvoked -ne $false -or + @($r.Checks).Count -ne 18){throw 'R318_TYPED_NATIVE_SHORTCUT_PROOF_GATES_NOT_MET'} + 'R318_COM_SHORTCUT_NO_TEMP_SCRIPT_NATIVE_SYNTHETIC_PASS' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -925,3 +955,4 @@ jobs: ${{ runner.temp }}/R315_${{ matrix.host }}_${{ github.run_id }}/R315_TASK_COLD_RESTORE_RECEIPT.json ${{ runner.temp }}/R316_${{ matrix.host }}_${{ github.run_id }}/R316_TRUSTED_APPROOT_RECEIPT.json ${{ runner.temp }}/R317_${{ matrix.host }}_${{ github.run_id }}/R317_ENCODED_CLEANUP_RECEIPT.json + ${{ runner.temp }}/R318_${{ matrix.host }}_${{ github.run_id }}/R318_INPROCESS_SHORTCUT_RECEIPT.json diff --git a/build/Test-R318-InProcessShortcut-Native.ps1 b/build/Test-R318-InProcessShortcut-Native.ps1 new file mode 100644 index 00000000..dbecce32 --- /dev/null +++ b/build/Test-R318-InProcessShortcut-Native.ps1 @@ -0,0 +1,166 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R318_DISPOSABLE_WINDOWS_GUEST_ONLY' +} +function SHA([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$dir=Join-Path $env:RUNNER_TEMP ('R318_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $dir 'R318_INPROCESS_SHORTCUT_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R317_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R317_ENCODED_CLEANUP_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($dir)){throw 'R318_TEST_FIXTURE_ALREADY_PRESENT'} +[void][IO.Directory]::CreateDirectory($dir) +$initial=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$c=Get-Content $manifest -Raw|ConvertFrom-Json +$p=Get-Content $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (SHA $setup) -or + $c.MainSHA256 -cne $initial.Main -or + $p.Host -cne $HostLabel -or $p.SourceCommit -cne $env:GITHUB_SHA -or + $p.Status -cne 'PASS_EXACT_PE_INMEMORY_UNINSTALL_CLEANUP_TRANSPORT_SYNTHETIC'){ + throw 'R318_EXACT_CANDIDATE_OR_R317_PROOF_NOT_VERIFIED' +} +$checks.Add('EXACT_PE_CANDIDATE_AND_PREVIOUS_UNINSTALL_PROOF') +$asm=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$stream=$asm.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$stream){throw 'R318_COMPILATION_SOURCE_NOT_EMBEDDED'} +$reader=[IO.StreamReader]::new($stream) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$file=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +$raw=[IO.File]::ReadAllText($file) +if((& $normalize $embedded) -cne (& $normalize $raw)){ + throw 'R318_EXACT_PE_SOURCE_DIFFERS_FROM_PRODUCTION' +} +$checks.Add('EXACT_PE_EMBEDDED_SHORTCUT_SOURCE_MATCH') +$type=$asm.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$paramTypes=[Type[]]@([string],[string],[string],[string]) +$method=$type.GetMethod('Shortcut',$flags,$null,$paramTypes,$null) +$rootProperty=$type.GetProperty('AppRoot',$flags) +if(-not $method -or -not $rootProperty){throw 'R318_EXACT_FOUR_ARGUMENT_COM_SHORTCUT_METHOD_MISSING'} +$checks.Add('EXACT_PRIVATE_FOUR_ARG_METHOD_REFLECTED') +$open=$raw.IndexOf('static void Shortcut(string path,string target,string arguments,string description)',[StringComparison]::Ordinal) +$close=$raw.IndexOf('static string Sha256File(string path)',[StringComparison]::Ordinal) +if($open -lt 0 -or $close -le $open){throw 'R318_COM_SHORTCUT_METHOD_ANCHOR_INVALID'} +$implementation=$raw.Substring($open,$close-$open) +foreach($unsafe in @('tbme_shortcut_','ExecutionPolicy Bypass','Process.Start(','ProcessStartInfo(','File.WriteAllText(','powershell.exe','cmd.exe')){ + if($implementation.IndexOf($unsafe,[StringComparison]::OrdinalIgnoreCase) -ge 0){ + throw ('R318_MUTABLE_SCRIPT_OR_PROCESS_STILL_IN_SHORTCUT '+$unsafe) + } +} +foreach($needed in @('WScript.Shell','CreateShortcut','BindingFlags.SetProperty','InvokeMember("Save"','FinalReleaseComObject')){ + if(-not $implementation.Contains($needed)){throw ('R318_INPROCESS_COM_PRIMITIVE_MISSING '+$needed)} +} +$checks.Add('NO_TEMP_SCRIPT_NO_EXTERNAL_PROCESS_IN_SHORTCUT_METHOD') +$target=Join-Path ([Environment]::SystemDirectory) 'notepad.exe' +if(-not [IO.File]::Exists($target)){throw 'R318_SYNTHETIC_TARGET_NOT_PRESENT'} +$link=Join-Path $dir "R318 ' % & harmless.lnk" +$arguments='--startup-recovery "%PATH%" & echo harmless; | $literal' +$description="R318 literal ' quotes & percent % untouched" +$wsh=$null;$shortcut=$null +try{ + if([IO.File]::Exists($link)){throw 'R318_EPHEMERAL_SHORTCUT_ALREADY_PRESENT'} + $null=$method.Invoke($null,[object[]]@([string]$link,[string]$target,[string]$arguments,[string]$description)) + if(-not [IO.File]::Exists($link)){throw 'R318_COM_SHORTCUT_NOT_PERSISTED'} + $checks.Add('COM_CREATED_ONLY_DISPOSABLE_LINK_FILE') + $wsh=New-Object -ComObject WScript.Shell + $shortcut=$wsh.CreateShortcut($link) + $actualTarget=[IO.Path]::GetFullPath([string]$shortcut.TargetPath) + if(-not [string]::Equals($actualTarget,$target,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R318_COM_SHORTCUT_TARGET_CHANGED' + } + $checks.Add('SAVED_SHORTCUT_TARGET_EXACT_AFTER_COM_READBACK') + if([string]$shortcut.Arguments -cne $arguments){ + throw 'R318_SHORTCUT_LITERAL_METACHARACTERS_CHANGED' + } + $checks.Add('LITERAL_COMMAND_METACHARACTER_ARGS_ARE_DATA_NOT_SCRIPT') + if([string]$shortcut.Description -cne $description){ + throw 'R318_SHORTCUT_DESCRIPTION_CHANGED' + } + $checks.Add('SHORTCUT_DESCRIPTION_WITH_QUOTE_ROUNDTRIP') + $root=[string]$rootProperty.GetValue($null,$null) + if(-not [string]::Equals([string]$shortcut.WorkingDirectory,$root,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R318_SHORTCUT_WORKING_DIRECTORY_NOT_OWNER_APPROOT' + } + $checks.Add('SHORTCUT_WORKING_DIRECTORY_PINNED_TO_OWNER_ROOT') + if([int]$shortcut.WindowStyle -ne 1){throw 'R318_SAVED_SHORTCUT_WINDOWSTYLE_NOT_EXPECTED'} + $checks.Add('SAVED_SHORTCUT_WINDOWSTYLE_ONE') + $icon=[string]$shortcut.IconLocation + if($icon -notmatch '(?i)TaskbarMonitorEnhanced\.exe\s*,\s*0$'){ + throw 'R318_ICON_LOCATION_NOT_OWNER_EXECUTABLE' + } + $checks.Add('SAVED_SHORTCUT_ICON_LOCATION_PINNED') +}finally{ + foreach($obj in @($shortcut,$wsh)){ + if($null -ne $obj -and [Runtime.InteropServices.Marshal]::IsComObject($obj)){ + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($obj) + } + } + if([IO.File]::Exists($link)){[IO.File]::Delete($link)} +} +if([IO.File]::Exists($link)){throw 'R318_SHORTCUT_FIXTURE_CLEANUP_FAILED'} +$checks.Add('SYNTHETIC_LINK_REMOVED_AFTER_TEST') +function Reject([string]$label,[string]$badLink,[string]$badTarget,[string]$needle){ + $denied=$false + try{$null=$method.Invoke($null,[object[]]@($badLink,$badTarget,'','bad'))} + catch{ + if($_.Exception.GetBaseException().Message -match $needle){$denied=$true}else{throw} + } + if(-not $denied){throw ('R318_UNSAFE_INPUT_ACCEPTED_'+$label)} + $script:checks.Add($label) +} +Reject 'RELATIVE_LINK_REJECTED' 'shortcut.lnk' $target 'absolute' +Reject 'WRONG_EXTENSION_REJECTED' (Join-Path $dir 'bad.ps1') $target 'absolute' +Reject 'RELATIVE_TARGET_REJECTED' (Join-Path $dir 'bad-target.lnk') 'notepad.exe' 'absolute' +Reject 'MISSING_LINK_DIRECTORY_REJECTED' (Join-Path $dir 'missing\bad.lnk') $target 'directory is not available' +$remains=@([IO.Directory]::GetFileSystemEntries($dir)) +if($remains.Count -ne 0){throw 'R318_EPHEMERAL_LINK_RESIDUE_OR_SCRIPT'} +$checks.Add('NO_EXTRA_TEMP_EXECUTABLE_OR_SHORTCUT_FIXTURE_RESIDUE') +if((SHA $main) -cne $initial.Main -or (SHA $config) -cne $initial.Config -or + (SHA $state) -cne $initial.State){ + throw 'R318_HOSTED_INSTALLED_USER_FILES_CHANGED' +} +$checks.Add('HOSTED_INSTALLED_MAIN_CONFIG_STATE_BYTE_EXACT') +$record=[ordered]@{ + Schema=1;Revision='R318' + Status='PASS_EXACT_PE_INPROCESS_COM_SHORTCUT_AND_NO_TEMP_SCRIPT' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + OriginalOwnerComputerTouched=$false + ActualProgramFilesWritten=$false;ActualUACInvoked=$false + OriginalProtectedSensorTaskTouched=$false + ActualInstalledShortcutChanged=$false + ExactPeSourceAndCOMMethodVerified=$true + WScriptShortcutCreatedReadAndRemoved=$true + AllShortcutPropertiesRoundtripped=$true + PowerShellTempScriptEliminated=$true + MetacharactersTreatedAsData=$true + RelativeAndInvalidPathsRefused=$true + RealInstalledUserDataUnchanged=$true + RealOwnerInstallationOrUninstallInvoked=$false + Checks=@($checks) + RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R318_'+$HostLabel+'_EXACT_PE_COM_SHORTCUT_NO_TEMP_SCRIPT_SYNTHETIC_PASS' diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 53f795c9..6a9b71ca 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -321,28 +321,52 @@ static void Shortcut(string path,string target) Shortcut(path,target,"","Live system monitor integrated into the Windows taskbar"); } + // R318: WScript.Shell COM is invoked directly inside the Setup PE. + // Never generate or execute writable Temp scripts to create shortcuts. static void Shortcut(string path,string target,string arguments,string description) { - string temp=Path.Combine(Path.GetTempPath(),"tbme_shortcut_"+Guid.NewGuid().ToString("N")+".ps1"); - string text= - "$ErrorActionPreference='Stop'\r\n"+ - "$w=New-Object -ComObject WScript.Shell\r\n"+ - "$s=$w.CreateShortcut('"+path.Replace("'","''")+"')\r\n"+ - "$s.TargetPath='"+target.Replace("'","''")+"'\r\n"+ - "$s.Arguments='"+arguments.Replace("'","''")+"'\r\n"+ - "$s.WorkingDirectory='"+AppRoot.Replace("'","''")+"'\r\n"+ - "$s.IconLocation='"+AppExe.Replace("'","''")+",0'\r\n"+ - "$s.Description='"+description.Replace("'","''")+"'\r\n"+ - "$s.WindowStyle=1\r\n$s.Save()\r\n"; - File.WriteAllText(temp,text,Encoding.UTF8); - try{ - ProcessStartInfo psi=new ProcessStartInfo("powershell.exe","-NoProfile -ExecutionPolicy Bypass -File \""+temp+"\""); - psi.UseShellExecute=false;psi.CreateNoWindow=true; - Process p=Process.Start(psi); - if(p==null || !p.WaitForExit(15000) || p.ExitCode!=0) - throw new InvalidOperationException("Shortcut creation failed."); + if(String.IsNullOrWhiteSpace(path) || String.IsNullOrWhiteSpace(target) || + !Path.IsPathRooted(path) || !Path.IsPathRooted(target) || + !String.Equals(Path.GetExtension(path),".lnk",StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Shortcut requires absolute .lnk and target paths."); + string linkPath=Path.GetFullPath(path); + string targetPath=Path.GetFullPath(target); + string linkDirectory=Path.GetDirectoryName(linkPath); + if(String.IsNullOrWhiteSpace(linkDirectory) || !Directory.Exists(linkDirectory)) + throw new DirectoryNotFoundException("Shortcut directory is not available."); + // Block an existing malicious linked destination before opening it; + // user-local directory reparse races remain a separate security gate. + if(File.Exists(linkPath) && (File.GetAttributes(linkPath)&FileAttributes.ReparsePoint)!=0) + throw new IOException("Shortcut destination is a reparse point."); + Type shellType=Type.GetTypeFromProgID("WScript.Shell",true); + object shell=null; + object shortcut=null; + try + { + shell=Activator.CreateInstance(shellType); + shortcut=shell.GetType().InvokeMember("CreateShortcut", + BindingFlags.InvokeMethod,null,shell,new object[]{linkPath}); + if(shortcut==null) + throw new InvalidOperationException("Windows Script Host did not create a shortcut object."); + Type shortcutType=shortcut.GetType(); + BindingFlags set=BindingFlags.SetProperty; + shortcutType.InvokeMember("TargetPath",set,null,shortcut,new object[]{targetPath}); + shortcutType.InvokeMember("Arguments",set,null,shortcut,new object[]{arguments??""}); + shortcutType.InvokeMember("WorkingDirectory",set,null,shortcut,new object[]{AppRoot}); + shortcutType.InvokeMember("IconLocation",set,null,shortcut,new object[]{AppExe+",0"}); + shortcutType.InvokeMember("Description",set,null,shortcut,new object[]{description??""}); + shortcutType.InvokeMember("WindowStyle",set,null,shortcut,new object[]{1}); + shortcutType.InvokeMember("Save",BindingFlags.InvokeMethod,null,shortcut,new object[0]); + if(!File.Exists(linkPath)) + throw new IOException("Windows Script Host did not persist the shortcut."); + } + finally + { + if(shortcut!=null && System.Runtime.InteropServices.Marshal.IsComObject(shortcut)) + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shortcut); + if(shell!=null && System.Runtime.InteropServices.Marshal.IsComObject(shell)) + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shell); } - finally{try{File.Delete(temp);}catch{}} } static string Sha256File(string path) From 74ebcf9f0159e3309eb319d82cc549f1efc2e273 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sat, 17 Oct 2026 23:44:17 +0330 Subject: [PATCH 070/135] test: isolated hosted private install uninstall and post-exit cleanup (R319) --- ...it-r286-optional-audio-native-recovery.yml | 79 ++++++ ...19-ActualHostedInstallUninstall-Native.ps1 | 254 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 build/Test-R319-ActualHostedInstallUninstall-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 9ae5b4f5..09eb8a3e 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -956,3 +956,82 @@ jobs: ${{ runner.temp }}/R316_${{ matrix.host }}_${{ github.run_id }}/R316_TRUSTED_APPROOT_RECEIPT.json ${{ runner.temp }}/R317_${{ matrix.host }}_${{ github.run_id }}/R317_ENCODED_CLEANUP_RECEIPT.json ${{ runner.temp }}/R318_${{ matrix.host }}_${{ github.run_id }}/R318_INPROCESS_SHORTCUT_RECEIPT.json + + + fresh-install-uninstall: + name: R319 fresh private install then uninstall on isolated ${{ matrix.host }} + needs: candidate + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact private candidate source, new isolated host + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Retrieve private not-for-release binaries only + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: R286-PRIVATE-NOT-PUBLIC-RELEASE + path: _r284_private_candidate + - name: R319 fresh Windows host safety and PowerShell 5.1 syntax preflight + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R319_NOT_EXACT_DISPOSABLE_SOURCE'} + $test=(Resolve-Path build/Test-R319-ActualHostedInstallUninstall-Native.ps1).Path + $t=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$t,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R319_NATIVE_PS51_SYNTAX_INVALID'} + if(!(Test-Path '_r284_private_candidate/CANDIDATE.json')){throw 'R319_PRIVATE_MANIFEST_MISSING'} + $c=Get-Content '_r284_private_candidate/CANDIDATE.json' -Raw|ConvertFrom-Json + if($c.Commit -cne $env:GITHUB_SHA -or $c.Private -ne $true -or + $c.NotForPublicInstallation -ne $true){throw 'R319_UNTRUSTED_OR_PUBLIC_CANDIDATE'} + 'R319_NEW_HOST_STRICT_PREFLIGHT_PASS' + - name: R319 run actual private 1.7.8 install uninstall and deferred cleanup on new Windows guest + shell: powershell + run: | + $ErrorActionPreference='Stop' + ./build/Test-R319-ActualHostedInstallUninstall-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R319_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R319_PRIVATE_INSTALL_UNINSTALL_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R319_TYPED_RAW_RECEIPT_ABSENT'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R319' -or + $r.Status -cne 'PASS_REAL_PRIVATE_INSTALL_UNINSTALL_ON_DISPOSABLE_HOST' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.PrivateCandidateOnly -ne $true -or $r.OriginalOwnerComputerTouched -ne $false -or + $r.ActualWindowsHostedInstallExecuted -ne $true -or + $r.ActualWindowsHostedUninstallExecuted -ne $true -or + $r.ActualProductUserRootRemoved -ne $true -or + $r.ActualProtectedTaskAbsent -ne $true -or + $r.ActualProtectedBrokerAbsent -ne $true -or + $r.ActualShortcutLinksRemoved -ne $true -or + $r.ConfirmedNoBundledPawnIODriverLaunched -ne $true -or + $r.TrueUACPromptInteractionTested -ne $false -or + $r.InstallExitCode -ne 0 -or $r.UninstallExitCode -ne 0 -or + $r.MainInstalledSHA256 -cne $r.MainExpectedSHA256 -or + $r.UninstallerInstalledSHA256 -cne $r.SetupExpectedSHA256 -or + [string]::IsNullOrWhiteSpace([string]$r.SensorStatus) -or + $r.DeferredCleanupSeconds -gt 70 -or $r.Failure -or + @($r.Checks).Count -ne 18){throw 'R319_REAL_HOSTED_INSTALL_UNINSTALL_RECEIPT_GATES_FAILED'} + 'R319_PRIVATE_HOSTED_INSTALL_UNINSTALL_SUCCESS_NON_RELEASE' + - name: Preserve bounded R319 guest receipt even on failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R319-new-host-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: warn + retention-days: 14 + path: | + ${{ runner.temp }}/R319_${{ matrix.host }}_${{ github.run_id }}/R319_PRIVATE_INSTALL_UNINSTALL_RECEIPT.json diff --git a/build/Test-R319-ActualHostedInstallUninstall-Native.ps1 b/build/Test-R319-ActualHostedInstallUninstall-Native.ps1 new file mode 100644 index 00000000..d11d9834 --- /dev/null +++ b/build/Test-R319-ActualHostedInstallUninstall-Native.ps1 @@ -0,0 +1,254 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R319_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +function Hash([string]$path){ + if(-not [IO.File]::Exists($path)){return 'MISSING'} + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash +} +function Absent([string]$path,[string]$name){ + if(Test-Path -LiteralPath $path){throw ('R319_EXPECTED_PRETEST_ABSENCE_'+$name)} +} +function ExecuteBounded([string]$file,[string[]]$childArguments,[int]$timeoutMs){ + if(-not [IO.File]::Exists($file) -or $childArguments.Count -lt 1){ + throw 'R319_UNTRUSTED_EXEC_OR_EMPTY_ARGS' + } + $info=[Diagnostics.ProcessStartInfo]::new() + $info.FileName=[IO.Path]::GetFullPath($file) + $info.UseShellExecute=$false + $info.CreateNoWindow=$true + # Windows PowerShell 5.1 runs on .NET Framework, whose + # ProcessStartInfo does not expose ArgumentList. Only fixed, tested, + # whitespace-free CLI switches are legal here (no shell parsing). + $allowed=@('/install','/quiet','/desktop=0','/startup=0','/uninstall') + foreach($a in $childArguments){ + if($allowed -cnotcontains [string]$a -or + [string]$a -cnotmatch '^/[a-z0-9=-]+$'){ + throw 'R319_UNSUPPORTED_OR_INJECTABLE_SETUP_ARGUMENT' + } + } + $info.Arguments=($childArguments -join ' ') + $p=$null + try{ + $p=[Diagnostics.Process]::Start($info) + if(!$p){throw 'R319_CHILD_NOT_STARTED'} + if(!$p.WaitForExit($timeoutMs)){ + if(-not $p.HasExited){$p.Kill();[void]$p.WaitForExit(10000)} + throw 'R319_BOUND_CHILD_PROCESS_TIMEOUT' + } + return [int]$p.ExitCode + }finally{if($p){$p.Dispose()}} +} +$testRoot=Join-Path $env:RUNNER_TEMP ('R319_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $testRoot 'R319_PRIVATE_INSTALL_UNINSTALL_RECEIPT.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$uninstall=Join-Path $app 'Uninstall.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$programRoot=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' +$brokerRoot=Join-Path $programRoot 'SensorBroker' +$startDir=[Environment]::GetFolderPath('Programs') +$startLink=Join-Path $startDir 'Taskbar Monitor Enhanced.lnk' +$repairLink=Join-Path $startDir 'Taskbar Monitor Enhanced - Repair Hardware Sensors.lnk' +$desktopLink=Join-Path ([Environment]::GetFolderPath('DesktopDirectory')) 'Taskbar Monitor Enhanced.lnk' +$startupLink=Join-Path ([Environment]::GetFolderPath('Startup')) 'Taskbar Monitor Enhanced Startup Recovery.lnk' +$uninstallKey='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +$runKey='HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$checks=[Collections.Generic.List[string]]::new() +if([IO.Directory]::Exists($testRoot)){throw 'R319_PREEXISTING_GUEST_RECEIPT_ROOT'} +[void][IO.Directory]::CreateDirectory($testRoot) +$installExit=-999 +$uninstallExit=-999 +$installedMainHash='' +$installedUninstallHash='' +$sensorStatus='' +$failure='' +$cleanupSeconds=0 +$originalUserState='EMPTY_DISPOSABLE_RUNNER' +# Always write a typed failure receipt, even if candidate manifest validation +# fails before assigning the production file hashes. +$candidate=[pscustomobject]@{MainSHA256='';SetupSHA256=''} +try{ + $candidate=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json + if($candidate.Commit -cne $env:GITHUB_SHA -or + $candidate.Version -cne '1.7.8' -or + $candidate.Private -ne $true -or + $candidate.NotForPublicInstallation -ne $true -or + $candidate.SetupSHA256 -cne (Hash $setup)){ + throw 'R319_UNVERIFIED_PRIVATE_SETUP_CANDIDATE' + } + $checks.Add('EXACT_PRIVATE_SETUP_COMMIT_AND_SHA_BEFORE_EXECUTION') + if(-not [Environment]::Is64BitOperatingSystem){ + throw 'R319_WINDOWS_64_BIT_REQUIRED' + } + foreach($entry in @( + [pscustomobject]@{P=$app;N='APP_ROOT'}, + [pscustomobject]@{P=$startLink;N='START_MENU'}, + [pscustomobject]@{P=$repairLink;N='REPAIR_LINK'}, + [pscustomobject]@{P=$desktopLink;N='DESKTOP_LINK'}, + [pscustomobject]@{P=$startupLink;N='STARTUP_LINK'}, + [pscustomobject]@{P=$uninstallKey;N='UNINSTALL_REGISTRY'}, + [pscustomobject]@{P=$brokerRoot;N='PROTECTED_BROKER_ROOT'} + )){Absent $entry.P $entry.N} + if(@(Get-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R319_PREEXISTING_PROTECTED_TASK' + } + if(@(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R319_PREEXISTING_PRODUCT_PROCESS' + } + $startBag=Get-ItemProperty -LiteralPath $runKey -ErrorAction SilentlyContinue + if($startBag -and $startBag.PSObject.Properties.Name -contains 'TaskbarMonitorEnhanced'){ + throw 'R319_PREEXISTING_RUN_VALUE' + } + $checks.Add('BRAND_NEW_HOSTED_USER_APP_REGISTRY_SHORTCUTS_AND_TASK') + # Installed driver is never explicitly installed from a writable Temp + # PawnIO executable by this Setup. Accept graceful sensor degradation. + $setupSource=Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs') -Raw + if(-not $setupSource.Contains('require separate') -or + -not $setupSource.Contains('official installation with explicit owner consent') -or + $setupSource.Contains('PawnIO_setup.exe",Path.Combine(payload')){ + throw 'R319_PREVENT_UNVERIFIED_THIRD_PARTY_DRIVER_INSTALL' + } + $checks.Add('NO_PAWNIO_DRIVER_EXECUTABLE_IS_LAUNCHED_BY_SETUP_SOURCE') + $installExit=ExecuteBounded $setup ([string[]]@('/install','/quiet','/desktop=0','/startup=0')) 210000 + if($installExit -ne 0){throw ('R319_REAL_PRIVATE_SETUP_INSTALL_EXIT_'+$installExit)} + $checks.Add('ACTUAL_PRIVATE_QUIET_INSTALL_PROCESS_EXIT_ZERO') + if(-not [IO.File]::Exists($main) -or -not [IO.File]::Exists($uninstall) -or + -not [IO.File]::Exists($state) -or -not (Test-Path -LiteralPath $uninstallKey)){ + throw 'R319_REAL_INSTALLED_FILES_OR_REGISTRATION_MISSING' + } + $installedMainHash=Hash $main + $installedUninstallHash=Hash $uninstall + if($installedMainHash -cne $candidate.MainSHA256 -or + $installedUninstallHash -cne $candidate.SetupSHA256){ + throw 'R319_EXACT_INSTALLED_MAIN_OR_UNINSTALL_SHA_MISMATCH' + } + $checks.Add('INSTALLED_MAIN_AND_UNINSTALL_MATCH_EXACT_PRIVATE_CANDIDATE') + $stateData=Get-Content -LiteralPath $state -Raw|ConvertFrom-Json + if($stateData.Version -cne '1.7.8' -or + [string]::IsNullOrWhiteSpace([string]$stateData.SensorLayerStatus)){ + throw 'R319_REAL_INSTALL_STATE_NOT_VERSION_BOUND' + } + $sensorStatus=[string]$stateData.SensorLayerStatus + $checks.Add('VERSION_AND_SENSOR_STATUS_PERSISTED_IN_REAL_INSTALL_STATE') + if(-not [IO.File]::Exists($startLink) -or -not [IO.File]::Exists($repairLink) -or + [IO.File]::Exists($desktopLink) -or [IO.File]::Exists($startupLink)){ + throw 'R319_EXPECTED_START_AND_REPAIR_SHORTCUTS_NOT_EXACT' + } + $checks.Add('START_AND_REPAIR_CREATED_DESKTOP_AND_STARTUP_DISABLED') + $shell=$null;$startShortcut=$null;$repairShortcut=$null + try{ + $shell=New-Object -ComObject WScript.Shell + $startShortcut=$shell.CreateShortcut($startLink) + $repairShortcut=$shell.CreateShortcut($repairLink) + if(-not [string]::Equals([string]$startShortcut.TargetPath,$main,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([string]$repairShortcut.TargetPath,$uninstall,[StringComparison]::OrdinalIgnoreCase) -or + [string]$repairShortcut.Arguments -cne '/repair-sensors'){ + throw 'R319_ACTUAL_COM_SHORTCUT_TARGET_OR_ARGS_WRONG' + } + }finally{ + foreach($o in @($startShortcut,$repairShortcut,$shell)){ + if($null -ne $o -and [Runtime.InteropServices.Marshal]::IsComObject($o)){ + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($o) + } + } + } + $checks.Add('REAL_INSTALLED_COM_SHORTCUT_TARGET_AND_ARGS_READBACK') + $runBag=Get-ItemProperty -LiteralPath $runKey -ErrorAction SilentlyContinue + if($runBag -and $runBag.PSObject.Properties.Name -contains 'TaskbarMonitorEnhanced'){ + throw 'R319_DISABLED_STARTUP_REGISTRY_FLAG_IGNORED' + } + $checks.Add('DISABLED_AUTOSTART_REGISTRY_NOT_ENABLED') + $tempScripts=@(Get-ChildItem -LiteralPath $env:RUNNER_TEMP -File -Filter 'tbme_shortcut_*.ps1' -ErrorAction SilentlyContinue) + if($tempScripts.Count){throw 'R319_UNEXPECTED_SHORTCUT_EXECUTABLE_TEMP_SCRIPT'} + $checks.Add('NO_MUTABLE_SHORTCUT_POWERSHELL_SCRIPT_CREATED') + # Only terminate this disposable runner's exact product process, if the + # installer elected to launch one. Never terminate other process names. + foreach($proc in @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue)){ + try{ + $proc.Kill() + [void]$proc.WaitForExit(5000) + }finally{$proc.Dispose()} + } + $checks.Add('DISPOSABLE_OWN_PRODUCT_PROCESS_STOPPED_BEFORE_UNINSTALL') + $uninstallExit=ExecuteBounded $uninstall ([string[]]@('/uninstall','/quiet')) 210000 + if($uninstallExit -ne 0){throw ('R319_REAL_PRIVATE_UNINSTALL_EXIT_'+$uninstallExit)} + $checks.Add('REAL_PRIVATE_UNINSTALL_PROCESS_EXIT_ZERO') + $clock=[Diagnostics.Stopwatch]::StartNew() + while([IO.Directory]::Exists($app) -and $clock.Elapsed.TotalSeconds -lt 70){ + Start-Sleep -Milliseconds 350 + } + $cleanupSeconds=[int][Math]::Ceiling($clock.Elapsed.TotalSeconds) + if([IO.Directory]::Exists($app)){ + throw 'R319_REAL_DEFERRED_INMEMORY_CLEANUP_LEFT_APP_ROOT' + } + $checks.Add('DEFERRED_ENCODEDCOMMAND_CLEANUP_REMOVED_EXACT_APPROOT') + foreach($entry in @( + [pscustomobject]@{P=$uninstallKey;N='UNINSTALL_REGISTRY'}, + [pscustomobject]@{P=$startLink;N='START_LINK'}, + [pscustomobject]@{P=$repairLink;N='REPAIR_LINK'}, + [pscustomobject]@{P=$desktopLink;N='DESKTOP_LINK'}, + [pscustomobject]@{P=$startupLink;N='STARTUP_LINK'} + )){Absent $entry.P $entry.N} + $checks.Add('ACTUAL_SHORTCUTS_AND_UNINSTALL_REGISTRATION_REMOVED') + $afterRun=Get-ItemProperty -LiteralPath $runKey -ErrorAction SilentlyContinue + if($afterRun -and $afterRun.PSObject.Properties.Name -contains 'TaskbarMonitorEnhanced'){ + throw 'R319_RUN_VALUE_LEFT_AFTER_UNINSTALL' + } + $checks.Add('ACTUAL_RUN_REGISTRY_VALUE_ABSENT') + if(@(Get-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R319_PROTECTED_SENSOR_TASK_LEFT_REGISTERED' + } + $checks.Add('ACTUAL_OWNED_PROTECTED_TASK_ABSENT_AFTER_UNINSTALL') + if([IO.Directory]::Exists($brokerRoot)){ + throw 'R319_OWNED_PROTECTED_BROKER_FILES_NOT_REMOVED' + } + $checks.Add('ACTUAL_PROTECTED_SENSOR_BROKER_FOLDER_ABSENT') + $checks.Add('NO_OWNER_PC_OR_PUBLIC_RELEASE_MUTATION') +}catch{ + $failure=$_.Exception.Message +}finally{ + $result=[ordered]@{ + Schema=1;Revision='R319';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Status=if(!$failure -and $checks.Count -eq 18){'PASS_REAL_PRIVATE_INSTALL_UNINSTALL_ON_DISPOSABLE_HOST'}else{'FAIL_CLOSED_DISPOSABLE_HOST_ONLY'} + PrivateCandidateOnly=$true;OriginalOwnerComputerTouched=$false + ActualWindowsHostedInstallExecuted=($installExit -eq 0) + ActualWindowsHostedUninstallExecuted=($uninstallExit -eq 0) + ActualProductUserRootRemoved=(-not [IO.Directory]::Exists($app)) + ActualProtectedTaskAbsent=(@(Get-ScheduledTask -TaskPath '\' -TaskName $taskName -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count -eq 0) + ActualProtectedBrokerAbsent=(-not [IO.Directory]::Exists($brokerRoot)) + ActualShortcutLinksRemoved=(-not [IO.File]::Exists($startLink) -and -not [IO.File]::Exists($repairLink)) + ConfirmedNoBundledPawnIODriverLaunched=$true + TrueUACPromptInteractionTested=$false + MainExpectedSHA256=$candidate.MainSHA256 + MainInstalledSHA256=$installedMainHash + SetupExpectedSHA256=$candidate.SetupSHA256 + UninstallerInstalledSHA256=$installedUninstallHash + SensorStatus=$sensorStatus + InstallExitCode=$installExit;UninstallExitCode=$uninstallExit + DeferredCleanupSeconds=$cleanupSeconds + Checks=@($checks) + Failure=$failure + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + 'R319_TEST_STATUS='+$result.Status+' FAILURE='+$failure +} +if($failure){throw ('R319_PRIVATE_INSTALLED_UNINSTALL_FAILED '+$failure)} +'R319_REAL_PRIVATE_178_HOSTED_INSTALL_AND_UNINSTALL_PASS' From 70888bd41db0bbbcd22ae1f60c59ac255573ab4a Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 00:00:06 +0330 Subject: [PATCH 071/135] test: bind original Setup watcher before suspended guest process resumes (R320) --- ...it-r286-optional-audio-native-recovery.yml | 1 + ...02-ActualSetupStageCleanupCrash-Native.ps1 | 69 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 09eb8a3e..22028c7f 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -414,6 +414,7 @@ jobs: $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or $r.OriginalOwnerComputerTouched -ne $false -or $r.UnmodifiedCandidateSetupExecuted -ne $true -or + $r.NativeExactSetupSuspendedBoundBeforeResume -ne $true -or $r.ActualSetupProcessTerminated -ne $true -or $r.RecoveredBySecondActualSetupProcess -ne $true -or $r.MainSetupConfigStateAndRegistryPreserved -ne $true -or diff --git a/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 index 844a51f7..4380691c 100644 --- a/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 +++ b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 @@ -74,6 +74,69 @@ using System; using System.IO; using System.Diagnostics; using System.Threading; +using System.Text; +using System.ComponentModel; +using System.Runtime.InteropServices; +// R320 test-only: create the UNMODIFIED candidate Setup suspended, arm and +// bind the watcher to its exact process ID, then resume. IDLE priority gives +// the watcher a better chance to preempt fast NTFS cleanup on Windows 2022. +public static class R320SuspendedSetupLauncher { + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] + private struct STARTUPINFO { + public uint cb;public string lpReserved;public string lpDesktop;public string lpTitle; + public uint dwX;public uint dwY;public uint dwXSize;public uint dwYSize; + public uint dwXCountChars;public uint dwYCountChars;public uint dwFillAttribute; + public uint dwFlags;public ushort wShowWindow;public ushort cbReserved2; + public IntPtr lpReserved2;public IntPtr hStdInput;public IntPtr hStdOutput;public IntPtr hStdError; + } + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION { + public IntPtr hProcess;public IntPtr hThread;public uint dwProcessId;public uint dwThreadId; + } + [DllImport("kernel32.dll",EntryPoint="CreateProcessW",CharSet=CharSet.Unicode,SetLastError=true)] + private static extern bool CreateProcess(string app,StringBuilder command, + IntPtr procSecurity,IntPtr threadSecurity,bool inheritHandles,uint flags, + IntPtr environment,string workingDir,ref STARTUPINFO startup,out PROCESS_INFORMATION process); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool TerminateProcess(IntPtr process,uint exit); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool CloseHandle(IntPtr handle); + public static Process StartOriginalSuspended(string setup,string args,string directory,R302RealSetupDeleteWatcher watcher){ + if(watcher==null || String.IsNullOrWhiteSpace(setup) || + !Path.IsPathRooted(setup) || !File.Exists(setup) || + args!="/update /quiet" || !Directory.Exists(directory) || + !String.Equals(Path.GetDirectoryName(Path.GetFullPath(setup)),Path.GetFullPath(directory), + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("R320_TEST_ONLY_UNTRUSTED_SETUP_LAUNCH_ARGUMENT"); + STARTUPINFO si=new STARTUPINFO(); + si.cb=(uint)Marshal.SizeOf(typeof(STARTUPINFO)); + PROCESS_INFORMATION pi; + StringBuilder line=new StringBuilder("\""+Path.GetFullPath(setup)+"\" "+args); + const uint CREATE_SUSPENDED=0x00000004,IDLE_PRIORITY_CLASS=0x00000040,CREATE_NO_WINDOW=0x08000000; + if(!CreateProcess(Path.GetFullPath(setup),line,IntPtr.Zero,IntPtr.Zero,false, + CREATE_SUSPENDED|IDLE_PRIORITY_CLASS|CREATE_NO_WINDOW, + IntPtr.Zero,directory,ref si,out pi)) + throw new Win32Exception(Marshal.GetLastWin32Error(),"R320_ORIGINAL_SETUP_CREATE_SUSPENDED_FAILED"); + bool resumed=false;Process child=null; + try{ + child=Process.GetProcessById(checked((int)pi.dwProcessId)); + watcher.Bind(child); // guaranteed before the suspended primary thread runs + uint previous=ResumeThread(pi.hThread); + if(previous!=1)throw new Win32Exception(Marshal.GetLastWin32Error(),"R320_ORIGINAL_SETUP_RESUME_FAILED"); + resumed=true; + return child; + }finally{ + if(!resumed){ + if(pi.hProcess!=IntPtr.Zero)TerminateProcess(pi.hProcess,125); + if(child!=null)child.Dispose(); + } + if(pi.hThread!=IntPtr.Zero)CloseHandle(pi.hThread); + if(pi.hProcess!=IntPtr.Zero)CloseHandle(pi.hProcess); + } + } +} public sealed class R302RealSetupDeleteWatcher : IDisposable { public readonly ManualResetEventSlim TerminatedEvent = new ManualResetEventSlim(false); private FileSystemWatcher oldWatcher, newWatcher; @@ -188,10 +251,11 @@ try { $psi.UseShellExecute=$false $psi.CreateNoWindow=$true $psi.WorkingDirectory=[IO.Path]::GetDirectoryName($setup) - $native=[Diagnostics.Process]::Start($psi) + $native=[R320SuspendedSetupLauncher]::StartOriginalSuspended( + $setup,'/update /quiet',[IO.Path]::GetDirectoryName($setup),$watcher) if(!$native){throw 'R302_ORIGINAL_SETUP_FAILED_TO_START'} $realSetupPid=$native.Id - $watcher.Bind($native) + $checks.Add('R320_EXACT_ORIGINAL_SETUP_LAUNCHED_SUSPENDED_WATCHER_BOUND_BEFORE_RESUME') $clock=[Diagnostics.Stopwatch]::StartNew() while(-not $watcher.TerminatedEvent.Wait(75) -and $clock.ElapsedMilliseconds -lt 45000){ $native.Refresh() @@ -245,6 +309,7 @@ try { Schema=1;Revision='R302';Status='PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED' Host=$HostLabel;SourceCommit=$env:GITHUB_SHA OriginalOwnerComputerTouched=$false;UnmodifiedCandidateSetupExecuted=$true + NativeExactSetupSuspendedBoundBeforeResume=$true ActualSetupPid=$realSetupPid;ActualSetupProcessTerminated=$true DeletedStageFileTrigger=$watcher.TriggerFile FileDeletionNotifications=$watcher.Notifications From 8563f79dbbb78358cccfa5e2132ad88484af5fc1 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 00:06:07 +0330 Subject: [PATCH 072/135] test: verify native attached Setup exit code from pinned Windows handle (R320-F01) --- ...302-ActualSetupStageCleanupCrash-Native.ps1 | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 index 4380691c..b1b6bd81 100644 --- a/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 +++ b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 @@ -103,6 +103,16 @@ public static class R320SuspendedSetupLauncher { private static extern bool TerminateProcess(IntPtr process,uint exit); [DllImport("kernel32.dll",SetLastError=true)] private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool GetExitCodeProcess(IntPtr handle,out uint exitCode); + public static bool NonzeroNativeExitCode(Process process) { + if(process==null || !process.HasExited) + throw new InvalidOperationException("R320_NATIVE_EXIT_QUERY_BEFORE_TERMINATION"); + uint exit; + if(!GetExitCodeProcess(process.Handle,out exit)) + throw new Win32Exception(Marshal.GetLastWin32Error(),"R320_NATIVE_EXITCODE_QUERY_FAILED"); + return exit!=0 && exit!=259; // STILL_ACTIVE and success are not kill witnesses + } public static Process StartOriginalSuspended(string setup,string args,string directory,R302RealSetupDeleteWatcher watcher){ if(watcher==null || String.IsNullOrWhiteSpace(setup) || !Path.IsPathRooted(setup) || !File.Exists(setup) || @@ -122,6 +132,10 @@ public static class R320SuspendedSetupLauncher { bool resumed=false;Process child=null; try{ child=Process.GetProcessById(checked((int)pi.dwProcessId)); + // Materialize and retain the actual native handle before resuming, so + // GetExitCodeProcess remains usable after this short-lived process dies. + if(child.Handle==IntPtr.Zero) + throw new InvalidOperationException("R320_PROCESS_HANDLE_NOT_PINNED"); watcher.Bind(child); // guaranteed before the suspended primary thread runs uint previous=ResumeThread(pi.hThread); if(previous!=1)throw new Win32Exception(Marshal.GetLastWin32Error(),"R320_ORIGINAL_SETUP_RESUME_FAILED"); @@ -179,7 +193,7 @@ public sealed class R302RealSetupDeleteWatcher : IDisposable { process.Refresh(); if(process.HasExited) throw new Exception("SETUP_ALREADY_EXITED"); process.Kill(); - Killed=process.WaitForExit(10000) && process.HasExited && process.ExitCode!=0; + Killed=process.WaitForExit(10000) && process.HasExited && R320SuspendedSetupLauncher.NonzeroNativeExitCode(process); if(!Killed) Failure="SETUP_KILL_NOT_CONFIRMED"; }catch(Exception error){Failure=error.GetType().Name+":"+error.Message;} finally{TerminatedEvent.Set();} @@ -267,7 +281,7 @@ try { $watcher.Failure+' events='+$watcher.Notifications+ ' setupExited='+$native.HasExited) } - if(!$native.HasExited -or $native.ExitCode -eq 0){ + if(!$native.HasExited -or -not [R320SuspendedSetupLauncher]::NonzeroNativeExitCode($native)){ throw 'R302_ACTUAL_SETUP_PID_TERMINATION_NOT_PROVEN' } $checks.Add('REAL_ORIGINAL_SETUP_PID_KILLED_BY_NATIVE_FILE_DELETION_EVENT') From 69084ddda885863346835a86fc20ea34a86396cb Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 00:25:54 +0330 Subject: [PATCH 073/135] security(experiment): pin verified Main executable handle during autolaunch (R321) --- ...it-r286-optional-audio-native-recovery.yml | 31 ++++ build/Test-R321-PinnedMainLaunch-Native.ps1 | 160 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 57 ++++++- 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 build/Test-R321-PinnedMainLaunch-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 22028c7f..3ba04c7c 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -919,6 +919,36 @@ jobs: $r.RealOwnerInstallationOrUninstallInvoked -ne $false -or @($r.Checks).Count -ne 18){throw 'R318_TYPED_NATIVE_SHORTCUT_PROOF_GATES_NOT_MET'} 'R318_COM_SHORTCUT_NO_TEMP_SCRIPT_NATIVE_SYNTHETIC_PASS' + - name: R321 refuse substituted Main binary and pin its read handle before autolaunch + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R321-PinnedMainLaunch-Native.ps1).Path + $tok=$null;$e=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$e) + if(@($e).Count){$e|ForEach-Object {$_.Message};throw 'R321_NATIVE_TEST_PS51_SYNTAX_INVALID'} + ./build/Test-R321-PinnedMainLaunch-Native.ps1 -HostLabel '${{ matrix.host }}' -CandidateDir (Resolve-Path _r284_private_candidate).Path + $receipt=Join-Path $env:RUNNER_TEMP ('R321_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R321_PINNED_MAIN_PRELAUNCH_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt)){throw 'R321_NATIVE_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R321' -or + $r.Status -cne 'PASS_PINNED_MAIN_BEFORE_AUTOLAUNCH_SYNTHETIC' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.ExactPEInstallerSourceVerified -ne $true -or + $r.VerifiedMainSHA256 -cnotmatch '^[0-9A-F]{64}$' -or + $r.ReadHandleHeldAcrossLaunchPath -ne $true -or + $r.ExclusiveWriterDenied -ne $true -or + $r.RenameBeforeLaunchDenied -ne $true -or + $r.AlteredSameNameBinaryRejected -ne $true -or + $r.JunctionAndUnsafePathsRejected -ne $true -or + $r.MainWasNotActuallyLaunchedInFixture -ne $true -or + $r.OriginalOwnerHostTouched -ne $false -or + $r.ActualProtectedTaskTouched -ne $false -or + $r.ActualUACInvoked -ne $false -or + $r.SecurityCodeQLAccepted -ne $false -or + $r.GuestMainAndUserSettingsPreserved -ne $true -or + @($r.Checks).Count -ne 13){throw 'R321_TYPED_PINNED_MAIN_PROOF_FAILED'} + 'R321_PINNED_IMMUTABLE_MAIN_HANDLE_BOUNDED=PASS_NOT_SECURITY_FINAL' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -955,6 +985,7 @@ jobs: ${{ runner.temp }}/R314_${{ matrix.host }}_${{ github.run_id }}/R314_TASK_COLD_RESTORE_RECEIPT.json ${{ runner.temp }}/R315_${{ matrix.host }}_${{ github.run_id }}/R315_TASK_COLD_RESTORE_RECEIPT.json ${{ runner.temp }}/R316_${{ matrix.host }}_${{ github.run_id }}/R316_TRUSTED_APPROOT_RECEIPT.json + ${{ runner.temp }}/R321_${{ matrix.host }}_${{ github.run_id }}/R321_PINNED_MAIN_PRELAUNCH_RECEIPT.json ${{ runner.temp }}/R317_${{ matrix.host }}_${{ github.run_id }}/R317_ENCODED_CLEANUP_RECEIPT.json ${{ runner.temp }}/R318_${{ matrix.host }}_${{ github.run_id }}/R318_INPROCESS_SHORTCUT_RECEIPT.json diff --git a/build/Test-R321-PinnedMainLaunch-Native.ps1 b/build/Test-R321-PinnedMainLaunch-Native.ps1 new file mode 100644 index 00000000..1e3156eb --- /dev/null +++ b/build/Test-R321-PinnedMainLaunch-Native.ps1 @@ -0,0 +1,160 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateDir +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R321_ONLY_DISPOSABLE_GITHUB_WINDOWS_GUEST' +} +function SHA([string]$p){ + if(-not [IO.File]::Exists($p)){return 'MISSING'} + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} +$root=Join-Path $env:RUNNER_TEMP ('R321_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R321_PINNED_MAIN_PRELAUNCH_RECEIPT.json' +$manifest=Join-Path $CandidateDir 'CANDIDATE.json' +$setup=Join-Path $CandidateDir 'Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$prior=Join-Path $env:RUNNER_TEMP ('R302_'+$HostLabel+'_'+$env:GITHUB_RUN_ID+'\R302_REAL_SETUP_CLEANUP_KILL_RECEIPT.json') +$app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$config=Join-Path $app 'config.json' +$state=Join-Path $app 'install_state.json' +$checks=[Collections.Generic.List[string]]::new() +if(Test-Path -LiteralPath $root){throw 'R321_EXISTING_TEST_FIXTURE'} +[void][IO.Directory]::CreateDirectory($root) +$old=@{Main=(SHA $main);Config=(SHA $config);State=(SHA $state)} +$c=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +$prev=Get-Content -LiteralPath $prior -Raw|ConvertFrom-Json +if($c.Commit -cne $env:GITHUB_SHA -or + $c.SetupSHA256 -cne (SHA $setup) -or + $c.MainSHA256 -cne $old.Main -or + $prev.Host -cne $HostLabel -or + $prev.SourceCommit -cne $env:GITHUB_SHA -or + $prev.Status -cne 'PASS_NATIVE_SETUP_KILLED_DURING_STAGE_CLEANUP_RECOVERED'){ + throw 'R321_CANDIDATE_OR_PREVIOUS_NATIVE_PROOF_UNTRUSTED' +} +$assembly=[Reflection.Assembly]::LoadFile([IO.Path]::GetFullPath($setup)) +$type=$assembly.GetType('SetupProgram',$true) +$method=$type.GetMethod('OpenPinnedVerifiedMainForLaunch',[Reflection.BindingFlags]'Static,NonPublic') +if(!$method){throw 'R321_COMPILED_SETUP_GUARD_NOT_FOUND'} +$srcRes=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$srcRes){throw 'R321_COMPILED_SOURCE_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($srcRes) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$normal={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normal $embedded) -cne (& $normal ([IO.File]::ReadAllText($source)))){ + throw 'R321_PRIVATE_EXE_COMPILED_SOURCE_DIFFERS' +} +$code=[IO.File]::ReadAllText($source) +$start=$code.IndexOf('using(FileStream pinned=OpenPinnedVerifiedMainForLaunch(',[StringComparison]::Ordinal) +$launch=$code.IndexOf('Process.Start(AppExe)',[StringComparison]::Ordinal) +$finish=$code.IndexOf('MainLaunchMode="LAUNCHED_NON_ELEVATED_SETUP"', [StringComparison]::Ordinal) +if($start -lt 0 -or $launch -le $start -or $finish -le $launch){ + throw 'R321_GUARD_NOT_BOUND_ACROSS_PROCESS_START' +} +$checks.Add('EXACT_EMBEDDED_SOURCE_AND_REAL_LAUNCH_GUARD_ORDER') +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') +if(!$resource){throw 'R321_TRUSTED_MAIN_RESOURCE_MISSING'} +$sourceStream=[IO.MemoryStream]::new() +try{$resource.CopyTo($sourceStream)}finally{$resource.Dispose()} +$bytes=$sourceStream.ToArray() +$sourceStream.Dispose() +$expected=[BitConverter]::ToString(([Security.Cryptography.SHA256]::Create().ComputeHash($bytes))).Replace('-','') +if($expected.Length -ne 64 -or $bytes.Length -lt 1024){throw 'R321_PE_RESOURCE_HASH_OR_SIZE_BAD'} +$checks.Add('EXACT_PE_MAIN_SHA256_AND_SOURCE_LENGTH') +$folder=Join-Path $root 'safe' +[void][IO.Directory]::CreateDirectory($folder) +$file=Join-Path $folder 'TaskbarMonitorEnhanced.exe' +[IO.File]::WriteAllBytes($file,$bytes) +function Validate([string]$path,[string]$dir,[string]$expectedHash){ + try{return $method.Invoke($null,[object[]]@($path,$dir,$expectedHash))} + catch{throw [InvalidOperationException]::new($_.Exception.GetBaseException().Message,$_.Exception)} +} +function Reject([string]$label,[string]$path,[string]$dir,[string]$expectedHash,[string]$pattern){ + $refused=$false + try{ + $x=Validate $path $dir $expectedHash + if($null -ne $x){$x.Dispose()} + }catch{if($_.Exception.ToString() -match $pattern){$refused=$true}else{throw}} + if(!$refused){throw ('R321_FAIL_OPEN_'+$label)} + $script:checks.Add($label) +} +$lock=[IO.FileStream](Validate $file $folder $expected) +if(!$lock.CanRead -or $lock.Length -ne $bytes.Length -or + -not [string]::Equals([IO.Path]::GetFullPath($lock.Name),[IO.Path]::GetFullPath($file),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R321_VALID_MAIN_LOCK_IS_NOT_PINNED' +} +$checks.Add('SHA_VERIFIED_MAIN_RETURNED_READABLE_PINNED_HANDLE') +$writeDenied=$false;$renameDenied=$false +try{ + try{ + $writer=[IO.FileStream]::new($file,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::ReadWrite) + $writer.Dispose() + }catch [IO.IOException]{$writeDenied=$true} + try{[IO.File]::Move($file,(Join-Path $folder 'wrong-name.exe'))} + catch [IO.IOException]{$renameDenied=$true} + if(-not $writeDenied -or -not $renameDenied){ + throw ('R321_OPEN_HANDLE_SHARING_DID_NOT_PROTECT_FILE WRITE='+$writeDenied+' RENAME='+$renameDenied) + } + if((SHA $file) -cne $expected){throw 'R321_PINNED_BYTES_CHANGED'} +}finally{$lock.Dispose()} +$checks.Add('FILE_WRITE_AND_RENAME_DENIED_DURING_PROCESS_START_WINDOW') +# These hostile test cases must all fail before any GUI/process is launched. +Reject 'WRONG_EMBEDDED_MAIN_HASH_REJECTED' $file $folder ('0'*64) 'does not match' +Reject 'UNRELATED_ROOT_REJECTED' $file (Join-Path $root 'foreign') $expected 'escaped' +Reject 'UNEXPECTED_EXE_BASENAME_REJECTED' (Join-Path $folder 'evil.exe') $folder $expected 'escaped' +Reject 'RELATIVE_MAIN_PATH_REJECTED' 'TaskbarMonitorEnhanced.exe' $folder $expected 'incomplete' +Reject 'UNC_INSTALL_ROOT_REJECTED' '\\server\share\TaskbarMonitorEnhanced.exe' '\\server\share' $expected 'escaped' +$altered=[byte[]]$bytes.Clone() +$altered[36]=($altered[36] -bxor 1) +[IO.File]::WriteAllBytes($file,$altered) +Reject 'SAME_NAME_BINARY_TAMPER_REJECTED' $file $folder $expected 'does not match' +[IO.File]::WriteAllBytes($file,$bytes) +if((SHA $file) -cne $expected){throw 'R321_TEST_RESTORE_CANDIDATE_FAILED'} +$checks.Add('TEST_ONLY_MAIN_RESOURCE_EXACTLY_RESTORED') +# A folder junction with a valid PE at the target cannot escape the pin. +$outside=Join-Path $root 'outside' +[void][IO.Directory]::CreateDirectory($outside) +$outerFile=Join-Path $outside 'TaskbarMonitorEnhanced.exe' +[IO.File]::WriteAllBytes($outerFile,$bytes) +$canary=Join-Path $outside 'unrelated-canary.txt' +[IO.File]::WriteAllText($canary,'R321_PRESERVE_UNRELATED',[Text.Encoding]::ASCII) +$canaryHash=SHA $canary +$link=Join-Path $root 'junction-root' +New-Item -ItemType Junction -Path $link -Target $outside -ErrorAction Stop|Out-Null +try{ + Reject 'JUNCTION_ANCESTOR_REJECTED' (Join-Path $link 'TaskbarMonitorEnhanced.exe') $link $expected 'reparse' +}finally{[IO.Directory]::Delete($link)} +if((SHA $canary) -cne $canaryHash){throw 'R321_JUNCTION_EXTERNAL_CANARY_CHANGED'} +$checks.Add('JUNCTION_TARGET_FILE_AND_CANARY_PRESERVED') +if((SHA $main) -cne $old.Main -or (SHA $config) -cne $old.Config -or (SHA $state) -cne $old.State){ + throw 'R321_INSTALLED_GUEST_MAIN_OR_SETTINGS_CHANGED' +} +$checks.Add('CURRENT_GUEST_INSTALLATION_SETTINGS_UNCHANGED') +$record=[ordered]@{ + Schema=1;Revision='R321';Status='PASS_PINNED_MAIN_BEFORE_AUTOLAUNCH_SYNTHETIC' + Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + ExactPEInstallerSourceVerified=$true;VerifiedMainSHA256=$expected + ReadHandleHeldAcrossLaunchPath=$true + ExclusiveWriterDenied=$writeDenied + RenameBeforeLaunchDenied=$renameDenied + AlteredSameNameBinaryRejected=$true + JunctionAndUnsafePathsRejected=$true + MainWasNotActuallyLaunchedInFixture=$true + OriginalOwnerHostTouched=$false;ActualProtectedTaskTouched=$false + ActualUACInvoked=$false;SecurityCodeQLAccepted=$false + GuestMainAndUserSettingsPreserved=$true + Checks=@($checks);RecordedUtc=[DateTime]::UtcNow.ToString('o') +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +'R321_'+$HostLabel+'_PINNED_MAIN_HASH_AND_SHARE_BOUNDARY_PASS' diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 6a9b71ca..3aa589d7 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -381,6 +381,55 @@ static string Sha256File(string path) } } + // R321: hold a Windows read-only, no-write/no-delete sharing handle + // over the hash check AND Process.Start. A writable user-local executable + // cannot be replaced by a second handle during this bounded interval. + // This is not a proof against hostile parent-directory rename races. + static FileStream OpenPinnedVerifiedMainForLaunch( + string candidate,string expectedRoot,string embeddedMainSHA) + { + if(String.IsNullOrWhiteSpace(candidate) || + String.IsNullOrWhiteSpace(expectedRoot) || + !Path.IsPathRooted(candidate) || !Path.IsPathRooted(expectedRoot) || + !Regex.IsMatch(embeddedMainSHA??"",@"^[0-9A-F]{64}$")) + throw new InvalidDataException("Installed Main launch identity is incomplete."); + string root=Path.GetFullPath(expectedRoot).TrimEnd(Path.DirectorySeparatorChar); + string path=Path.GetFullPath(candidate); + string expected=Path.Combine(root,"TaskbarMonitorEnhanced.exe"); + if(root.StartsWith(@"\\",StringComparison.Ordinal) || + path.StartsWith(@"\\",StringComparison.Ordinal) || + !String.Equals(path,expected,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Installed Main launch path escaped the trusted root."); + CheckRootAncestorsNoReparse(path); + FileAttributes attrs=File.GetAttributes(path); + if((attrs&FileAttributes.ReparsePoint)!=0 || (attrs&FileAttributes.Directory)!=0) + throw new InvalidDataException("Installed Main launch executable is linked or not a file."); + FileStream pinned=null; + try + { + // Open sharing READ only; forbid unrelated write/delete opens + // while the Setup process starts the exact validated Main. + pinned=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.Read); + if(pinned.Length<1024 || pinned.Length>134217728) + throw new InvalidDataException("Installed Main executable length out of bounds."); + using(SHA256 hasher=SHA256.Create()) + { + byte[] hash=hasher.ComputeHash(pinned); + StringBuilder result=new StringBuilder(); + foreach(byte item in hash)result.Append(item.ToString("X2")); + if(!String.Equals(result.ToString(),embeddedMainSHA,StringComparison.Ordinal)) + throw new InvalidDataException("Installed Main does not match the immutable Setup PE resource."); + } + pinned.Position=0; + return pinned; + } + catch + { + if(pinned!=null)pinned.Dispose(); + throw; + } + } + static string Sha256Resource(string resourceName) { using(Stream s=Resource(resourceName)) @@ -718,7 +767,13 @@ static SensorOutcome Install(bool desktop,bool startup) { try { - Process.Start(AppExe); + // The read-sharing handle pins the same trusted bytes over + // validation and the subsequent process creation. + using(FileStream pinned=OpenPinnedVerifiedMainForLaunch( + AppExe,AppRoot,Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"))) + { + Process.Start(AppExe); + } MainLaunchMode="LAUNCHED_NON_ELEVATED_SETUP"; } catch From 88902164ac18cca831c4887c08e208f2777b770b Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 00:31:16 +0330 Subject: [PATCH 074/135] test: require all fourteen observed R321 Windows Main handle checks (F01) --- .github/workflows/audit-r286-optional-audio-native-recovery.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 3ba04c7c..d853c837 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -947,7 +947,7 @@ jobs: $r.ActualUACInvoked -ne $false -or $r.SecurityCodeQLAccepted -ne $false -or $r.GuestMainAndUserSettingsPreserved -ne $true -or - @($r.Checks).Count -ne 13){throw 'R321_TYPED_PINNED_MAIN_PROOF_FAILED'} + @($r.Checks).Count -ne 14){throw 'R321_TYPED_PINNED_MAIN_PROOF_FAILED'} 'R321_PINNED_IMMUTABLE_MAIN_HANDLE_BOUNDED=PASS_NOT_SECURITY_FINAL' - name: Preserve bounded guest evidence if: always() From dc3860d6d9a7aae100e154484542d8393acf2171 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 01:13:09 +0330 Subject: [PATCH 075/135] fix(setup): verify embedded backend ZIP before extracting without Temp (R322) --- .github/workflows/ci.yml | 5 + build/Test-BackendZip-R322.ps1 | 132 ++++++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 57 +++++++++- 3 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 build/Test-BackendZip-R322.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b516a6f..ab349589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,11 @@ jobs: shell: pwsh run: ./build/Build.ps1 + - name: R322 embedded backend ZIP archive/hostile entries (native .NET Framework) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-BackendZip-R322.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R322 backend ZIP / PE-resource hostile-archive regression failed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-BackendZip-R322.ps1 b/build/Test-BackendZip-R322.ps1 new file mode 100644 index 00000000..25dfe4ee --- /dev/null +++ b/build/Test-BackendZip-R322.ps1 @@ -0,0 +1,132 @@ +[CmdletBinding()] +param( + [string]$SetupPath = '', + [string]$ZipPath = '' +) +Set-StrictMode -Version Latest +if ([string]::IsNullOrWhiteSpace($SetupPath)) { $SetupPath = Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' } +if ([string]::IsNullOrWhiteSpace($ZipPath)) { $ZipPath = Join-Path $PSScriptRoot '_deps\LibreHardwareMonitor.zip' } +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +if (!(Test-Path -LiteralPath $SetupPath) -or !(Test-Path -LiteralPath $ZipPath)) { throw 'R322_REQUIRED_BUILD_MISSING' } + +$lock = Get-Content (Join-Path $PSScriptRoot 'dependencies.lock.json') -Raw | ConvertFrom-Json +$actualZipSha = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash +if ($actualZipSha -ne [string]$lock.dependencies.LibreHardwareMonitor.sha256) { throw 'R322_LOCKED_ZIP_SHA_MISMATCH' } + +$setup = [Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$program = $setup.GetType('SetupProgram', $true) +$method = $program.GetMethod('ExtractBackendFromArchive', [Reflection.BindingFlags]'Static, NonPublic') +if ($null -eq $method) { throw 'R322_EXTRACT_METHOD_MISSING' } +$embedded = $setup.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') +if ($null -eq $embedded) { throw 'R322_PE_RESOURCE_MISSING' } +$sha = [Security.Cryptography.SHA256]::Create() +try { + $embeddedSha = ([BitConverter]::ToString($sha.ComputeHash($embedded))).Replace('-', '') + if ($embeddedSha -ne $actualZipSha) { throw 'R322_PE_RESOURCE_HASH_MISMATCH' } + $embedded.Position = 0 +} finally { $sha.Dispose() } + +$base = Join-Path ([IO.Path]::GetTempPath()) ('tbme_r322_zip_' + [Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base) | Out-Null +$passed = 0 +$caseCount = 0 + +function New-BadZip([string[]]$names, [bool]$large = $false, [bool]$link = $false) { + $memory = New-Object IO.MemoryStream + $zip = [IO.Compression.ZipArchive]::new($memory, [IO.Compression.ZipArchiveMode]::Create, $true) + try { + for ($i = 0; $i -lt $names.Count; $i++) { + $entry = $zip.CreateEntry($names[$i]) + if ($link -and $i -eq 0) { $entry.ExternalAttributes = [int](-1577127936) } + $stream = $entry.Open() + try { + if ($large) { + $content = New-Object 'byte[]' (8*1024*1024+1) + $stream.Write($content,0,$content.Length) + } else { + [byte[]]$content = [Text.Encoding]::ASCII.GetBytes('test') + $stream.Write($content,0,$content.Length) + } + } finally { $stream.Dispose() } + } + } finally { $zip.Dispose() } + return ,$memory.ToArray() +} + +function Check-Refusal([string]$name, [byte[]]$zipData, [string]$marker) { + $script:caseCount++ + $target = Join-Path $base ('negative_' + $script:caseCount) + [IO.Directory]::CreateDirectory($target) | Out-Null + $sentinel = Join-Path $target 'sentinel.txt' + [IO.File]::WriteAllText($sentinel,'original') + $stream = [IO.MemoryStream]::new($zipData,$false) + $observed = '' + try { + $reflectionArgs = New-Object 'object[]' 2 + $reflectionArgs[0] = $stream.PSObject.BaseObject + $reflectionArgs[1] = [string]$target + $method.Invoke($null, $reflectionArgs) | Out-Null + throw ('R322_UNSAFE_ACCEPTED_' + $name) + } catch { + $failure = $_.Exception + while ($null -ne $failure.InnerException) { $failure = $failure.InnerException } + $observed = $failure.Message + } finally { $stream.Dispose() } + if (!$observed.Contains($marker) -or [IO.File]::ReadAllText($sentinel) -ne 'original' -or + @([IO.Directory]::GetFileSystemEntries($target)).Count -ne 1) { + throw ('R322_NEGATIVE_FAILED_' + $name + ' message=' + $observed) + } + $script:passed++ + Write-Output ('PASS ' + $name + ' => ' + $marker) +} + +try { + $positiveRoot = Join-Path $base 'positive' + [IO.Directory]::CreateDirectory($positiveRoot) | Out-Null + [IO.File]::WriteAllText((Join-Path $positiveRoot 'stale.tmp'),'previous backend') + $reflectionArgs = New-Object 'object[]' 2 + $reflectionArgs[0] = $embedded.PSObject.BaseObject + $reflectionArgs[1] = [string]$positiveRoot + try { $method.Invoke($null,$reflectionArgs) | Out-Null } + finally { $embedded.Dispose() } + if ((Test-Path -LiteralPath (Join-Path $positiveRoot 'stale.tmp'))) { throw 'R322_OLD_BACKEND_NOT_REPLACED' } + $zip = [IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $ZipPath).Path) + try { + if ($zip.Entries.Count -ne 43) { throw 'R322_REFERENCE_ENTRY_COUNT_CHANGED' } + foreach ($entry in $zip.Entries) { + $file = Join-Path $positiveRoot ($entry.FullName.Replace('/', '\')) + if (!(Test-Path -LiteralPath $file -PathType Leaf) -or + (Get-Item -LiteralPath $file).Length -ne $entry.Length) { + throw ('R322_EXTRACTED_ENTRY_MISMATCH_' + $entry.FullName) + } + $reader = $entry.Open() + try { + $digest=[Security.Cryptography.SHA256]::Create() + try { + $expected=([BitConverter]::ToString($digest.ComputeHash($reader))).Replace('-','') + $actual=(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash + if($expected -ne $actual){throw ('R322_ENTRY_DIGEST_MISMATCH_' + $entry.FullName)} + } finally {$digest.Dispose()} + } finally {$reader.Dispose()} + } + } finally { $zip.Dispose() } + $caseCount++; $passed++ + Write-Output 'PASS embedded_PE_zip_exact_43_files_and_replaced_backend' + + Check-Refusal 'traversal' (New-BadZip @('../outside.txt')) 'SENSOR_BACKEND_ARCHIVE_PATH_REFUSED' + Check-Refusal 'windows_backslash' (New-BadZip @('..\outside.txt')) 'SENSOR_BACKEND_ARCHIVE_PATH_REFUSED' + Check-Refusal 'absolute_path' (New-BadZip @('/outside.txt')) 'SENSOR_BACKEND_ARCHIVE_PATH_REFUSED' + Check-Refusal 'ads_colon' (New-BadZip @('evil.txt:ads')) 'SENSOR_BACKEND_ARCHIVE_PATH_REFUSED' + Check-Refusal 'case_alias_duplicate' (New-BadZip @('a.bin','A.BIN')) 'SENSOR_BACKEND_ARCHIVE_PATH_OR_DUPLICATE' + Check-Refusal 'symlink' (New-BadZip @('symlink.bin') $false $true) 'SENSOR_BACKEND_ARCHIVE_LINK_REFUSED' + Check-Refusal 'oversize' (New-BadZip @('big.bin') $true $false) 'SENSOR_BACKEND_ARCHIVE_SIZE_REFUSED' + Check-Refusal 'wrong_manifest' (New-BadZip @('valid.bin')) 'SENSOR_BACKEND_ARCHIVE_PIN_MISMATCH' + if (Test-Path -LiteralPath (Join-Path $base 'outside.txt')) { throw 'R322_EXTERNAL_CANARY_MODIFIED' } + if ($caseCount -ne 9 -or $passed -ne 9) { throw 'R322_TEST_COUNT_MISMATCH' } + Write-Output ('R322_BACKEND_ZIP_NATIVE_PASS=' + $passed + '/' + $caseCount) +} finally { + if ($null -ne $embedded) { $embedded.Dispose() } + if (Test-Path -LiteralPath $base) { [IO.Directory]::Delete($base,$true) } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 3aa589d7..51f21777 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -142,6 +142,55 @@ static void Extract(string resource,string path) input.CopyTo(output); } + // R322: consume only the PE-embedded ZIP stream, never a replaceable Temp ZIP. + // Validate the complete archive before modifying the existing user backend. + static void ExtractBackendFromArchive(Stream zipStream,string backendRoot) + { + if(zipStream==null || !zipStream.CanRead || !zipStream.CanSeek || + zipStream.Length>10L*1024*1024) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_UNREADABLE"); + using(ZipArchive archive=new ZipArchive(zipStream,ZipArchiveMode.Read,true)) + { + HashSet seen=new HashSet(StringComparer.OrdinalIgnoreCase); + string root=Path.GetFullPath(backendRoot).TrimEnd(Path.DirectorySeparatorChar); + string prefix=root+Path.DirectorySeparatorChar; + long total=0; + foreach(ZipArchiveEntry entry in archive.Entries) + { + string name=entry.FullName; + if(String.IsNullOrEmpty(name) || name.StartsWith("/",StringComparison.Ordinal) || + name.EndsWith("/",StringComparison.Ordinal) || name.IndexOf('\\')>=0 || + name.IndexOf(':')>=0) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PATH_REFUSED"); + foreach(string segment in name.Split('/')) + if(String.IsNullOrEmpty(segment) || segment=="." || segment==".." || + segment.EndsWith(".",StringComparison.Ordinal) || + segment.EndsWith(" ",StringComparison.Ordinal) || + segment.IndexOfAny(Path.GetInvalidFileNameChars())>=0) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PATH_REFUSED"); + string target=Path.GetFullPath(Path.Combine(root, + name.Replace('/',Path.DirectorySeparatorChar))); + if(!target.StartsWith(prefix,StringComparison.OrdinalIgnoreCase) || + !seen.Add(name)) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PATH_OR_DUPLICATE"); + int unixType=(entry.ExternalAttributes>>16)&0xF000; + int winFlags=(entry.ExternalAttributes>>16)&(int)FileAttributes.ReparsePoint; + if(unixType==0xA000 || winFlags!=0) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_LINK_REFUSED"); + if(entry.Length<0 || entry.Length>8L*1024*1024 || + total+entry.Length>12203773L) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_SIZE_REFUSED"); + total+=entry.Length; + } + if(archive.Entries.Count!=43 || seen.Count!=43 || total!=12203773L) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PIN_MISMATCH"); + CheckRootAncestorsNoReparse(root); + if(Directory.Exists(root))Directory.Delete(root,true); + Directory.CreateDirectory(root); + archive.ExtractToDirectory(root); + } + } + static void StopProcess(string name) { try{ @@ -716,12 +765,8 @@ static SensorOutcome Install(bool desktop,bool startup) Extract("Payload."+doc,Path.Combine(AppRoot,"Docs",doc)); string backendRoot=Path.Combine(AppRoot,"SensorBackend","LibreHardwareMonitor-0.9.6"); - if(Directory.Exists(backendRoot))Directory.Delete(backendRoot,true); - Directory.CreateDirectory(backendRoot); - string lhmZip=Path.Combine(Path.GetTempPath(),"tbme_lhm_"+Guid.NewGuid().ToString("N")+".zip"); - Extract("Payload.LibreHardwareMonitor.zip",lhmZip); - ZipFile.ExtractToDirectory(lhmZip,backendRoot); - try{File.Delete(lhmZip);}catch{} + using(Stream backendZip=Resource("Payload.LibreHardwareMonitor.zip")) + ExtractBackendFromArchive(backendZip,backendRoot); WriteBackendState(backendRoot); SensorOutcome sensorOutcome; From b72b88d170dee14b74cec09fe92ba99c4ce302eb Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 01:43:59 +0330 Subject: [PATCH 076/135] fix(setup): only remove verified owned Windows shortcuts (R323) --- .github/workflows/ci.yml | 5 + build/Test-ShortcutOwnership-R323.ps1 | 137 ++++++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 77 +++++++++++- 3 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 build/Test-ShortcutOwnership-R323.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab349589..07ce735e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,11 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-BackendZip-R322.ps1 if ($LASTEXITCODE -ne 0) { throw 'R322 backend ZIP / PE-resource hostile-archive regression failed.' } + - name: R323 owned-shortcut-only deletion preserves unrelated .lnk files (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-ShortcutOwnership-R323.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R323 foreign-shortcut deletion guard regressed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-ShortcutOwnership-R323.ps1 b/build/Test-ShortcutOwnership-R323.ps1 new file mode 100644 index 00000000..c854458a --- /dev/null +++ b/build/Test-ShortcutOwnership-R323.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [string]$SetupPath = '', + [string]$MainPath = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if ([string]::IsNullOrWhiteSpace($SetupPath)) { + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if ([string]::IsNullOrWhiteSpace($MainPath)) { + $MainPath=Join-Path $PSScriptRoot '_out\App\TaskbarMonitorEnhanced.exe' +} +if (!(Test-Path -LiteralPath $SetupPath -PathType Leaf) -or + !(Test-Path -LiteralPath $MainPath -PathType Leaf)) { + throw 'R323_EXACT_COMPILED_EXECUTABLES_REQUIRED' +} +$main=[IO.Path]::GetFullPath((Resolve-Path -LiteralPath $MainPath).Path) +$setupExe=[IO.Path]::GetFullPath((Resolve-Path -LiteralPath $SetupPath).Path) +$assembly=[Reflection.Assembly]::LoadFrom($setupExe) +$program=$assembly.GetType('SetupProgram',$true) +$method=$program.GetMethod('DeleteOwnedShortcut',[Reflection.BindingFlags]'NonPublic,Static') +if($null -eq $method -or $method.ReturnType -ne [bool]) { + throw 'R323_OWNED_DELETE_METHOD_MISSING' +} +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r323_shortcuts_'+[Guid]::NewGuid().ToString('N')) +[void][IO.Directory]::CreateDirectory($base) +$canary=Join-Path $base 'external-user-owned-canary.txt' +[IO.File]::WriteAllText($canary,'PRESERVE-FOREIGN-OWNER') +$passed=0 +$total=0 +$checked=[Collections.Generic.List[string]]::new() + +function Make-Shortcut([string]$name,[string]$target,[string]$shortcutArguments) { + $dest=Join-Path $base $name + $shell=New-Object -ComObject WScript.Shell + $shortcut=$null + try { + $shortcut=$shell.CreateShortcut($dest) + $shortcut.TargetPath=$target + $shortcut.Arguments=$shortcutArguments + $shortcut.WorkingDirectory=$base + $shortcut.Description='R323 DISPOSABLE TEST ONLY' + $shortcut.Save() + } finally { + if ($null -ne $shortcut) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($shortcut) } + if ($null -ne $shell) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($shell) } + } + if (!(Test-Path -LiteralPath $dest -PathType Leaf)) {throw ('R323_FIXTURE_SHORTCUT_MISSING_'+$name)} + return $dest +} + +function Invoke-Delete([string]$file,[string]$expected,[string]$shortcutArguments) { + $call=New-Object 'object[]' 3 + $call[0]=[string]$file + $call[1]=[string]$expected + $call[2]=[string]$shortcutArguments + return [bool]$method.Invoke($null,$call) +} + +function Assert-Positive([string]$name,[string]$path,[string]$target,[string]$shortcutArguments) { + $script:total++ + if(-not (Invoke-Delete $path $target $shortcutArguments) -or + (Test-Path -LiteralPath $path)) { + throw ('R323_OWNED_SHORTCUT_NOT_REMOVED_'+$name) + } + $script:passed++ + [void]$checked.Add('PASS '+$name) +} + +function Assert-Preserve([string]$name,[string]$path,[string]$target,[string]$shortcutArguments,[bool]$exists) { + $script:total++ + $before=if($exists){(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash}else{'NONE'} + if(Invoke-Delete $path $target $shortcutArguments) { + throw ('R323_FOREIGN_SHORTCUT_WAS_DELETED_'+$name) + } + if($exists){ + if(!(Test-Path -LiteralPath $path -PathType Leaf) -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $before) { + throw ('R323_USER_FILE_CHANGED_'+$name) + } + } elseif(Test-Path -LiteralPath $path){ + throw ('R323_MISSING_SHORTCUT_WAS_CREATED_'+$name) + } + $script:passed++ + [void]$checked.Add('PASS '+$name) +} + +try { + $owned=Make-Shortcut 'owned-main.lnk' $main '' + Assert-Positive 'owned_main_no_arguments' $owned $main '' + + $repair=Make-Shortcut 'owned-repair.lnk' $setupExe '/repair-sensors' + Assert-Positive 'owned_repair_exact_arguments' $repair $setupExe '/repair-sensors' + + $recovery=Make-Shortcut 'owned-recovery.lnk' $main '--startup-recovery' + Assert-Positive 'owned_recovery_exact_arguments' $recovery $main '--startup-recovery' + + $foreign=Make-Shortcut 'foreign-target.lnk' "$env:SystemRoot\System32\notepad.exe" '' + Assert-Preserve 'foreign_target_same_link_name' $foreign $main '' $true + + $wrong=Make-Shortcut 'foreign-argument.lnk' $main '/uninstall' + Assert-Preserve 'foreign_arguments_same_binary' $wrong $main '--startup-recovery' $true + + $corrupt=Join-Path $base 'corrupt.lnk' + [IO.File]::WriteAllBytes($corrupt,[byte[]]@(0,7,41,255,18,99)) + Assert-Preserve 'corrupt_link_refused' $corrupt $main '' $true + + $notShortcut=Join-Path $base 'user-custom.txt' + [IO.File]::WriteAllText($notShortcut,'PRESERVE-USER-TEXT') + Assert-Preserve 'non_lnk_extension_refused' $notShortcut $main '' $true + + $missing=Join-Path $base 'missing.lnk' + Assert-Preserve 'missing_link_is_noop' $missing $main '' $false + + $unsafeArgs=Make-Shortcut 'unsafe-expected-arguments.lnk' $main '-EncodedCommand sensitive' + Assert-Preserve 'unsupported_expected_arguments_refused' $unsafeArgs $main '-EncodedCommand sensitive' $true + + $invalidTarget=Make-Shortcut 'relative-expected-target.lnk' $main '' + Assert-Preserve 'relative_expected_target_refused' $invalidTarget 'relative.exe' '' $true + + $oversize=Join-Path $base 'oversize.lnk' + $buffer=New-Object 'byte[]' (1024*1024+1) + [IO.File]::WriteAllBytes($oversize,$buffer) + Assert-Preserve 'oversize_lnk_refused' $oversize $main '' $true + + if([IO.File]::ReadAllText($canary) -ne 'PRESERVE-FOREIGN-OWNER') { + throw 'R323_EXTERNAL_CANARY_CHANGED' + } + if($passed -ne 11 -or $total -ne 11) { throw 'R323_NATIVE_CHECK_COUNT_MISMATCH' } + $checked | ForEach-Object { Write-Output $_ } + Write-Output ('R323_OWNED_SHORTCUT_NATIVE_PASS='+$passed+'/'+$total) +} finally { + if(Test-Path -LiteralPath $base) { + [IO.Directory]::Delete($base,$true) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 51f21777..6a63d8b2 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -365,6 +365,71 @@ static int ElevatedHelper(string mode,string payload,string appRoot,string user, return elevated.ExitCode; } + // R323: remove only shortcuts that still point at this product and retain + // their exact product arguments. A same-name user shortcut is not ours. + // This is a same-user guard, not an atomic defence against rename races. + static bool DeleteOwnedShortcut(string path,string expectedTarget,string expectedArguments) + { + if(String.IsNullOrWhiteSpace(path) || String.IsNullOrWhiteSpace(expectedTarget) || + !Path.IsPathRooted(path) || !Path.IsPathRooted(expectedTarget) || + !String.Equals(Path.GetExtension(path),".lnk",StringComparison.OrdinalIgnoreCase)) + return false; + string args=expectedArguments??""; + if(args!="" && args!="/repair-sensors" && args!="--startup-recovery") + return false; + string link=Path.GetFullPath(path); + string target=Path.GetFullPath(expectedTarget); + try + { + if(!File.Exists(link) || Directory.Exists(link) || + (File.GetAttributes(link)&FileAttributes.ReparsePoint)!=0) + return false; + long bytes=new FileInfo(link).Length; + if(bytes<1 || bytes>1024*1024)return false; + + Type shellType=Type.GetTypeFromProgID("WScript.Shell",true); + object shell=null; + object shortcut=null; + bool owned=false; + try + { + shell=Activator.CreateInstance(shellType); + shortcut=shell.GetType().InvokeMember("CreateShortcut", + BindingFlags.InvokeMethod,null,shell,new object[]{link}); + if(shortcut==null)return false; + Type shortcutType=shortcut.GetType(); + string actualTarget=shortcutType.InvokeMember("TargetPath", + BindingFlags.GetProperty,null,shortcut,new object[0]) as string; + string actualArguments=shortcutType.InvokeMember("Arguments", + BindingFlags.GetProperty,null,shortcut,new object[0]) as string; + if(!String.IsNullOrWhiteSpace(actualTarget) && Path.IsPathRooted(actualTarget)) + { + owned=String.Equals(Path.GetFullPath(actualTarget),target, + StringComparison.OrdinalIgnoreCase) && + String.Equals((actualArguments??"").Trim(),args,StringComparison.Ordinal); + } + } + finally + { + if(shortcut!=null && System.Runtime.InteropServices.Marshal.IsComObject(shortcut)) + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shortcut); + if(shell!=null && System.Runtime.InteropServices.Marshal.IsComObject(shell)) + System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shell); + } + if(!owned || !File.Exists(link) || + (File.GetAttributes(link)&FileAttributes.ReparsePoint)!=0) + return false; + File.Delete(link); + return !File.Exists(link); + } + catch + { + // A broken or foreign shortcut must never turn uninstall into + // deletion of user-owned content or an elevated fallback. + return false; + } + } + static void Shortcut(string path,string target) { Shortcut(path,target,"","Live system monitor integrated into the Windows taskbar"); @@ -782,7 +847,7 @@ static SensorOutcome Install(bool desktop,bool startup) Shortcut(startLink,AppExe); Shortcut(repairLink,uninstaller,"/repair-sensors","Repair or retry Taskbar Monitor Enhanced hardware sensors"); if(desktop)Shortcut(desktopLink,AppExe); - else try{File.Delete(desktopLink);}catch{} + else try{DeleteOwnedShortcut(desktopLink,AppExe,"");}catch{} string recoveryLink=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),"Taskbar Monitor Enhanced Startup Recovery.lnk"); using(RegistryKey run=Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run")){ @@ -790,7 +855,7 @@ static SensorOutcome Install(bool desktop,bool startup) else run.DeleteValue("TaskbarMonitorEnhanced",false); } if(startup)Shortcut(recoveryLink,AppExe,"--startup-recovery","Delayed recovery launcher for Taskbar Monitor Enhanced Start-with-Windows."); - else try{File.Delete(recoveryLink);}catch{} + else try{DeleteOwnedShortcut(recoveryLink,AppExe,"--startup-recovery");}catch{} using(RegistryKey key=Registry.CurrentUser.CreateSubKey(UninstallKey)){ key.SetValue("DisplayName",Product); @@ -978,13 +1043,13 @@ static void Uninstall(bool quiet) if(helperExit!=0) throw new InvalidOperationException("Protected sensor removal failed. Exit code: "+helperExit); - try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),Product+".lnk"));}catch{} - try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs),Product+".lnk"));}catch{} - try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs),Product+" - Repair Hardware Sensors.lnk"));}catch{} + try{DeleteOwnedShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),Product+".lnk"),AppExe,"");}catch{} + try{DeleteOwnedShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs),Product+".lnk"),AppExe,"");}catch{} + try{DeleteOwnedShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs),Product+" - Repair Hardware Sensors.lnk"),Path.Combine(AppRoot,"Uninstall.exe"),"/repair-sensors");}catch{} using(RegistryKey run=Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run")) run.DeleteValue("TaskbarMonitorEnhanced",false); - try{File.Delete(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),"Taskbar Monitor Enhanced Startup Recovery.lnk"));}catch{} + try{DeleteOwnedShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),"Taskbar Monitor Enhanced Startup Recovery.lnk"),AppExe,"--startup-recovery");}catch{} Registry.CurrentUser.DeleteSubKeyTree(UninstallKey,false); // Wait for the user to close the dialog before launching cleanup; From 67d3eda364bd067d4bcc296c4152dfcd6413ecfd Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 02:01:05 +0330 Subject: [PATCH 077/135] fix(setup): refuse foreign shortcut names before fresh install and Save (R324) --- .github/workflows/ci.yml | 5 + build/Test-ShortcutCreation-R324.ps1 | 164 ++++++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 68 ++++++++- 3 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 build/Test-ShortcutCreation-R324.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07ce735e..f52f3f82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,11 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-ShortcutOwnership-R323.ps1 if ($LASTEXITCODE -ne 0) { throw 'R323 foreign-shortcut deletion guard regressed.' } + - name: R324 reject foreign existing shortcuts before fresh install writes + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-ShortcutCreation-R324.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R324 shortcut ownership-before-Save regression failed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-ShortcutCreation-R324.ps1 b/build/Test-ShortcutCreation-R324.ps1 new file mode 100644 index 00000000..c2e25480 --- /dev/null +++ b/build/Test-ShortcutCreation-R324.ps1 @@ -0,0 +1,164 @@ +[CmdletBinding()] +param([string]$SetupPath = '', [string]$MainPath = '') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if([string]::IsNullOrWhiteSpace($MainPath)){ + $MainPath=Join-Path $PSScriptRoot '_out\App\TaskbarMonitorEnhanced.exe' +} +$setupExe=(Resolve-Path -LiteralPath $SetupPath).Path +$main=(Resolve-Path -LiteralPath $MainPath).Path +$assembly=[Reflection.Assembly]::LoadFrom($setupExe) +$type=$assembly.GetType('SetupProgram',$true) +$create=@($type.GetMethods([Reflection.BindingFlags]'Static,NonPublic')|Where-Object { + $_.Name -eq 'Shortcut' -and $_.GetParameters().Count -eq 4 +})[0] +$check=$type.GetMethod('AssertOwnedOrAbsentShortcut',[Reflection.BindingFlags]'Static,NonPublic') +$preflight=$type.GetMethod('AssertFreshInstallShortcutPreflight',[Reflection.BindingFlags]'Static,NonPublic') +if(!$create -or !$check -or !$preflight){throw 'R324_NATIVE_SOURCE_METHOD_MISSING'} +$root=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r324_link_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($root)|Out-Null +$passed=0 +$total=0 +$shell=New-Object -ComObject WScript.Shell + +function Create-Fixture([string]$file,[string]$target,[string]$shortcutArguments){ + $link=$shell.CreateShortcut($file) + try{ + $link.TargetPath=$target + $link.Arguments=$shortcutArguments + $link.WorkingDirectory=$root + $link.Description='R324 disposable only' + $link.Save() + }finally{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($link)} + if(!(Test-Path -LiteralPath $file -PathType Leaf)){throw 'R324_FIXTURE_NOT_SAVED'} +} +function Make-Call([Reflection.MethodInfo]$method,[object[]]$raw){ + $values=New-Object 'object[]' $raw.Count + for($i=0;$i -lt $raw.Count;$i++){$values[$i]=$raw[$i].PSObject.BaseObject} + return $method.Invoke($null,$values) +} +function Expect-Reject([string]$name,[string]$file,[string]$expectedTarget,[string]$shortcutArguments){ + $script:total++ + $sha=(Get-FileHash $file -Algorithm SHA256).Hash + $reason='' + try{ + Make-Call $create @([string]$file,[string]$expectedTarget,[string]$shortcutArguments,[string]'disposable')|Out-Null + }catch{ + $errorObject=$_.Exception + while($errorObject.InnerException){$errorObject=$errorObject.InnerException} + $reason=$errorObject.Message + } + if(!$reason.Contains('FOREIGN_SHORTCUT_NAME_CONFLICT') -or + (Get-FileHash $file -Algorithm SHA256).Hash -ne $sha){ + throw ('R324_UNOWNED_LINK_OVERWRITTEN_OR_WRONG_REASON_'+$name+':'+$reason) + } + $script:passed++ + Write-Output ('PASS refused_'+$name) +} +function Expect-OwnSave([string]$name,[string]$file,[string]$target,[string]$shortcutArguments){ + $script:total++ + Make-Call $create @([string]$file,[string]$target,[string]$shortcutArguments,[string]'disposable')|Out-Null + if(!(Test-Path $file -PathType Leaf)){throw ('R324_OWNED_LINK_NOT_SAVED_'+$name)} + $link=$shell.CreateShortcut($file) + try{ + $targetOK=[string]::Equals( + [IO.Path]::GetFullPath([string]$link.TargetPath), + [IO.Path]::GetFullPath($target), + [StringComparison]::OrdinalIgnoreCase) + $argumentsOK=[string]::Equals([string]$link.Arguments,[string]$shortcutArguments,[StringComparison]::Ordinal) + }finally{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($link)} + if(!$targetOK -or !$argumentsOK){throw ('R324_OWNED_LINK_CHANGED_IDENTITY_'+$name)} + $script:passed++ + Write-Output ('PASS owned_'+$name) +} +try{ + $foreign=Join-Path $root 'foreign.lnk' + Create-Fixture $foreign "$env:WINDIR\System32\notepad.exe" '' + Expect-Reject 'other_target' $foreign $main '' + + $mismatch=Join-Path $root 'foreign-arguments.lnk' + Create-Fixture $mismatch $main '/uninstall' + Expect-Reject 'other_arguments' $mismatch $main '--startup-recovery' + + $corrupt=Join-Path $root 'corrupt.lnk' + [IO.File]::WriteAllBytes($corrupt,[byte[]]@(3,4,7,9,12,55)) + Expect-Reject 'corrupt_existing_lnk' $corrupt $main '' + + $tooLarge=Join-Path $root 'oversized.lnk' + [IO.File]::WriteAllBytes($tooLarge,(New-Object 'byte[]' (1024*1024+1))) + Expect-Reject 'oversized_existing_lnk' $tooLarge $main '' + + $normal=Join-Path $root 'owned-main.lnk' + Create-Fixture $normal $main '' + Expect-OwnSave 'same_main_no_arguments' $normal $main '' + + $repair=Join-Path $root 'owned-repair.lnk' + Create-Fixture $repair $setupExe '/repair-sensors' + Expect-OwnSave 'same_repair' $repair $setupExe '/repair-sensors' + + $recovery=Join-Path $root 'owned-recovery.lnk' + Create-Fixture $recovery $main '--startup-recovery' + Expect-OwnSave 'same_recovery' $recovery $main '--startup-recovery' + + $fresh=Join-Path $root 'new-link.lnk' + Expect-OwnSave 'brand_new' $fresh $main '' + + if($passed -ne 8 -or $total -ne 8){throw 'R324_NATIVE_OWNERSHIP_COUNT_MISMATCH'} + Write-Output 'R324_SHORTCUT_WRITE_NATIVE_PASS=8/8' +}finally{ + if($null -ne $shell){[void][Runtime.InteropServices.Marshal]::ReleaseComObject($shell)} + if(Test-Path -LiteralPath $root){[IO.Directory]::Delete($root,$true)} +} + +if($env:GITHUB_ACTIONS -eq 'true'){ + if($env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC') { + throw 'R324_FRESH_INSTALL_NEGATIVE_GITHUB_DISPOSABLE_ONLY' + } + $appRoot=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $programs=[Environment]::GetFolderPath('Programs') + $existing=Join-Path $programs 'Taskbar Monitor Enhanced.lnk' + if(Test-Path $appRoot -or Test-Path $existing){throw 'R324_GUEST_NOT_FRESH'} + $userShell=New-Object -ComObject WScript.Shell + $created=$false + $sentinelHash='' + try{ + $link=$userShell.CreateShortcut($existing) + try{ + $link.TargetPath="$env:WINDIR\System32\notepad.exe" + $link.Arguments='' + $link.Save() + $created=$true + }finally{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($link)} + $sentinelHash=(Get-FileHash $existing -Algorithm SHA256).Hash + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$setupExe + $psi.Arguments='/install /quiet /desktop=0 /startup=0' + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){throw 'R324_ORIGINAL_SETUP_NOT_LAUNCHED'} + try{ + if(!$proc.WaitForExit(30000)){ + if(!$proc.HasExited){$proc.Kill();[void]$proc.WaitForExit(5000)} + throw 'R324_SETUP_PREFLIGHT_TIMEOUT' + } + $code=$proc.ExitCode + }finally{$proc.Dispose()} + if($code -eq 0 -or (Test-Path $appRoot) -or + (Get-FileHash $existing -Algorithm SHA256).Hash -ne $sentinelHash){ + throw ('R324_FOREIGN_GUEST_LINK_CHANGED_OR_APP_INSTALLED_EXIT_'+$code) + } + Write-Output 'R324_REAL_HOSTED_SETUP_FOREIGN_START_LINK_FAILS_BEFORE_APPROOT=PASS' + }finally{ + if($created -and (Test-Path $existing) -and + (Get-FileHash $existing -Algorithm SHA256).Hash -eq $sentinelHash){ + Remove-Item -LiteralPath $existing -Force + } + if($null -ne $userShell){[void][Runtime.InteropServices.Marshal]::ReleaseComObject($userShell)} + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 6a63d8b2..38a0cb7d 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -368,7 +368,7 @@ static int ElevatedHelper(string mode,string payload,string appRoot,string user, // R323: remove only shortcuts that still point at this product and retain // their exact product arguments. A same-name user shortcut is not ours. // This is a same-user guard, not an atomic defence against rename races. - static bool DeleteOwnedShortcut(string path,string expectedTarget,string expectedArguments) + static bool IsOwnedShortcut(string path,string expectedTarget,string expectedArguments) { if(String.IsNullOrWhiteSpace(path) || String.IsNullOrWhiteSpace(expectedTarget) || !Path.IsPathRooted(path) || !Path.IsPathRooted(expectedTarget) || @@ -419,8 +419,8 @@ static bool DeleteOwnedShortcut(string path,string expectedTarget,string expecte if(!owned || !File.Exists(link) || (File.GetAttributes(link)&FileAttributes.ReparsePoint)!=0) return false; - File.Delete(link); - return !File.Exists(link); + return true; + } catch { @@ -430,6 +430,57 @@ static bool DeleteOwnedShortcut(string path,string expectedTarget,string expecte } } + static bool DeleteOwnedShortcut(string path,string expectedTarget,string expectedArguments) + { + if(!IsOwnedShortcut(path,expectedTarget,expectedArguments)) + return false; + try + { + File.Delete(Path.GetFullPath(path)); + return !File.Exists(path); + } + catch{return false;} + } + + // R324: even a dangling reparse link occupies its exact name. + // Detect a file-system entry without following its target for ownership. + static bool ShortcutNameOccupied(string path) + { + try{File.GetAttributes(path);return true;} + catch(FileNotFoundException){return false;} + catch(DirectoryNotFoundException){return false;} + } + + // R324: the user may have an unrelated existing shortcut with this name. + // Existing owned shortcuts can be refreshed, but foreign ones are immutable. + static void AssertOwnedOrAbsentShortcut(string path,string target,string args) + { + if(ShortcutNameOccupied(path) && !IsOwnedShortcut(path,target,args)) + throw new IOException("FOREIGN_SHORTCUT_NAME_CONFLICT: refusing to overwrite another shortcut."); + } + + // No installation root exists at this point. No current shortcut may be + // attributed to this fresh installation, even if it claims our target. + static void AssertFreshShortcutNameUnused(string path) + { + if(ShortcutNameOccupied(path)) + throw new IOException("FOREIGN_SHORTCUT_NAME_CONFLICT: fresh install cannot replace an existing shortcut."); + } + + static void AssertFreshInstallShortcutPreflight(bool desktop,bool startup) + { + string programs=Environment.GetFolderPath(Environment.SpecialFolder.Programs); + AssertFreshShortcutNameUnused(Path.Combine(programs,Product+".lnk")); + AssertFreshShortcutNameUnused(Path.Combine(programs,Product+" - Repair Hardware Sensors.lnk")); + if(desktop) + AssertFreshShortcutNameUnused(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),Product+".lnk")); + if(startup) + AssertFreshShortcutNameUnused(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Startup), + "Taskbar Monitor Enhanced Startup Recovery.lnk")); + } + static void Shortcut(string path,string target) { Shortcut(path,target,"","Live system monitor integrated into the Windows taskbar"); @@ -452,6 +503,8 @@ static void Shortcut(string path,string target,string arguments,string descripti // user-local directory reparse races remain a separate security gate. if(File.Exists(linkPath) && (File.GetAttributes(linkPath)&FileAttributes.ReparsePoint)!=0) throw new IOException("Shortcut destination is a reparse point."); + // Refuse an unexpected same-name shortcut before COM Save can overwrite it. + AssertOwnedOrAbsentShortcut(linkPath,targetPath,arguments); Type shellType=Type.GetTypeFromProgID("WScript.Shell",true); object shell=null; object shortcut=null; @@ -470,6 +523,9 @@ static void Shortcut(string path,string target,string arguments,string descripti shortcutType.InvokeMember("IconLocation",set,null,shortcut,new object[]{AppExe+",0"}); shortcutType.InvokeMember("Description",set,null,shortcut,new object[]{description??""}); shortcutType.InvokeMember("WindowStyle",set,null,shortcut,new object[]{1}); + // Recheck immediately before COM Save: fail closed if another + // same-name shortcut appeared while its object was prepared. + AssertOwnedOrAbsentShortcut(linkPath,targetPath,arguments); shortcutType.InvokeMember("Save",BindingFlags.InvokeMethod,null,shortcut,new object[0]); if(!File.Exists(linkPath)) throw new IOException("Windows Script Host did not persist the shortcut."); @@ -800,6 +856,8 @@ static SensorOutcome Install(bool desktop,bool startup) // product or user-owned files. Existing roots must use /update. if(Directory.Exists(AppRoot) || ExistingRegistered()) throw new InvalidOperationException("Existing installation detected; use the guarded in-place updater."); + // Ownership conflicts are detected before touching Main, processes or AppRoot. + AssertFreshInstallShortcutPreflight(desktop,startup); StopProcess("TaskbarMonitorEnhanced"); Directory.CreateDirectory(AppRoot); @@ -847,7 +905,7 @@ static SensorOutcome Install(bool desktop,bool startup) Shortcut(startLink,AppExe); Shortcut(repairLink,uninstaller,"/repair-sensors","Repair or retry Taskbar Monitor Enhanced hardware sensors"); if(desktop)Shortcut(desktopLink,AppExe); - else try{DeleteOwnedShortcut(desktopLink,AppExe,"");}catch{} + // Fresh install with desktop disabled does not touch any existing link. string recoveryLink=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup),"Taskbar Monitor Enhanced Startup Recovery.lnk"); using(RegistryKey run=Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run")){ @@ -855,7 +913,7 @@ static SensorOutcome Install(bool desktop,bool startup) else run.DeleteValue("TaskbarMonitorEnhanced",false); } if(startup)Shortcut(recoveryLink,AppExe,"--startup-recovery","Delayed recovery launcher for Taskbar Monitor Enhanced Start-with-Windows."); - else try{DeleteOwnedShortcut(recoveryLink,AppExe,"--startup-recovery");}catch{} + // Fresh install with startup disabled does not touch any existing link. using(RegistryKey key=Registry.CurrentUser.CreateSubKey(UninstallKey)){ key.SetValue("DisplayName",Product); From b50b3ee183140aa03dcd76c5aebdab09512ab191 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 02:04:30 +0330 Subject: [PATCH 078/135] test(r324): fix guarded hosted shortcut preflight Test-Path expression (F01) --- build/Test-ShortcutCreation-R324.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-ShortcutCreation-R324.ps1 b/build/Test-ShortcutCreation-R324.ps1 index c2e25480..6adf3893 100644 --- a/build/Test-ShortcutCreation-R324.ps1 +++ b/build/Test-ShortcutCreation-R324.ps1 @@ -122,7 +122,7 @@ if($env:GITHUB_ACTIONS -eq 'true'){ $appRoot=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' $programs=[Environment]::GetFolderPath('Programs') $existing=Join-Path $programs 'Taskbar Monitor Enhanced.lnk' - if(Test-Path $appRoot -or Test-Path $existing){throw 'R324_GUEST_NOT_FRESH'} + if((Test-Path $appRoot) -or (Test-Path $existing)){throw 'R324_GUEST_NOT_FRESH'} $userShell=New-Object -ComObject WScript.Shell $created=$false $sentinelHash='' From e6a6c0cb788391106ab9e2e0077fef6eefc4396a Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 02:10:56 +0330 Subject: [PATCH 079/135] test(r324): isolate original Setup foreign shortcut refusal on fresh Windows A/B (F02) --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++ build/Test-ShortcutCreation-R324.ps1 | 37 ++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f52f3f82..4555cc06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,3 +207,70 @@ jobs: - name: Print build manifest shell: pwsh run: Get-Content ./artifacts/BUILD_MANIFEST.json + + r324-fresh-shortcut: + name: R324 genuine fresh Windows shortcut negative ${{ matrix.host }} + needs: build + runs-on: ${{ matrix.os }} + timeout-minutes: 12 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + permissions: + contents: read + steps: + - name: Checkout exact disposable test source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Download exact private build + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v8 + with: + name: build-evidence + path: r324_built_private + - name: Original Setup must refuse foreign Start shortcut before any app root write + shell: powershell + env: + TBME_R324_RUN_FRESH_HOST_NEGATIVE: 'true' + R324_HOST_LABEL: ${{ matrix.host }} + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA) {throw 'R324_GUEST_SOURCE_NOT_PINNED'} + $files=@(Get-ChildItem r324_built_private -Recurse -File) + $mf=@($files|Where-Object Name -eq 'BUILD_MANIFEST.json') + $sx=@($files|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $mx=@($files|Where-Object Name -eq 'TaskbarMonitorEnhanced.exe') + if($mf.Count -ne 1 -or $sx.Count -ne 1 -or $mx.Count -ne 1){throw 'R324_UNEXPECTED_CANDIDATE_FILES'} + $manifest=Get-Content -LiteralPath $mf[0].FullName -Raw|ConvertFrom-Json + $sm=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $mm=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced.exe') + if($sm.Count -ne 1 -or $mm.Count -ne 1 -or + (Get-FileHash $sx[0].FullName -Algorithm SHA256).Hash -cne [string]$sm[0].SHA256 -or + (Get-FileHash $mx[0].FullName -Algorithm SHA256).Hash -cne [string]$mm[0].SHA256){ + throw 'R324_CANDIDATE_BINARY_DIGEST_MISMATCH' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-ShortcutCreation-R324.ps1 -SetupPath $sx[0].FullName -MainPath $mx[0].FullName + if($LASTEXITCODE -ne 0){throw 'R324_REAL_SETUP_NEGATIVE_FAILED'} + $proof=Join-Path $env:RUNNER_TEMP ('R324_'+$env:R324_HOST_LABEL+'_'+$env:GITHUB_RUN_ID+'\R324_HOSTED_SHORTCUT_RECEIPT.json') + $r=Get-Content -LiteralPath $proof -Raw|ConvertFrom-Json + if($r.SourceCommit -cne $env:GITHUB_SHA -or $r.Host -cne $env:R324_HOST_LABEL -or + $r.Status -cne 'PASS_REAL_FRESH_SETUP_REFUSES_FOREIGN_START_SHORTCUT_BEFORE_APPROOT' -or + !$r.UserAppRootAbsentBeforeAndAfter -or !$r.SameStartShortcutSHA256Preserved -or + !$r.UnownedLinkRemovedAfterWitness -or $r.NativeReflectionChecksPassed -ne 8){ + throw 'R324_TYPED_RECEIPT_CONTRACT_FAILED' + } + - name: Preserve bounded raw hosted negative receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R324-fresh-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R324_${{ matrix.host }}_${{ github.run_id }}/R324_HOSTED_SHORTCUT_RECEIPT.json diff --git a/build/Test-ShortcutCreation-R324.ps1 b/build/Test-ShortcutCreation-R324.ps1 index 6adf3893..1e6727e8 100644 --- a/build/Test-ShortcutCreation-R324.ps1 +++ b/build/Test-ShortcutCreation-R324.ps1 @@ -113,8 +113,11 @@ try{ if(Test-Path -LiteralPath $root){[IO.Directory]::Delete($root,$true)} } -if($env:GITHUB_ACTIONS -eq 'true'){ - if($env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or +# Real /install negative is isolated to a dedicated fresh hosted Windows job. +# The general build job may have already created product test state. +if($env:TBME_R324_RUN_FRESH_HOST_NEGATIVE -eq 'true'){ + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC') { throw 'R324_FRESH_INSTALL_NEGATIVE_GITHUB_DISPOSABLE_ONLY' @@ -161,4 +164,34 @@ if($env:GITHUB_ACTIONS -eq 'true'){ } if($null -ne $userShell){[void][Runtime.InteropServices.Marshal]::ReleaseComObject($userShell)} } + if((Test-Path -LiteralPath $appRoot) -or (Test-Path -LiteralPath $existing) -or + !$created -or $code -eq 0 -or $env:R324_HOST_LABEL -cnotin @('A','B') -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA)){ + throw 'R324_GUEST_CLEANUP_OR_SOURCE_WITNESS_INVALID' + } + $proofDir=Join-Path $env:RUNNER_TEMP ('R324_'+$env:R324_HOST_LABEL+'_'+$env:GITHUB_RUN_ID) + [IO.Directory]::CreateDirectory($proofDir)|Out-Null + $evidence=[ordered]@{ + Schema=1 + Revision='R324' + Status='PASS_REAL_FRESH_SETUP_REFUSES_FOREIGN_START_SHORTCUT_BEFORE_APPROOT' + Host=$env:R324_HOST_LABEL + SourceCommit=$env:GITHUB_SHA + HostedWindows=$env:RUNNER_OS + RealSetupExecuted=$true + SetupNonzeroExitCode=[int]$code + ExistingStartShortcutSHA256=[string]$sentinelHash + SameStartShortcutSHA256Preserved=$true + UserAppRootAbsentBeforeAndAfter=$true + UnownedLinkRemovedAfterWitness=$true + NoUacHelperStarted=$true + SetupSHA256=(Get-FileHash -LiteralPath $setupExe -Algorithm SHA256).Hash + MainSHA256=(Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash + NativeReflectionChecksPassed=8 + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + [IO.File]::WriteAllText((Join-Path $proofDir 'R324_HOSTED_SHORTCUT_RECEIPT.json'), + ($evidence|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + Write-Output 'R324_HOSTED_SHORTCUT_CLEANUP_AND_TYPED_RECEIPT=PASS' } From 7381c9e4c330715e2aacbd679d68a2531ffd1216 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 02:15:16 +0330 Subject: [PATCH 080/135] fix(setup): combine shortcut ownership and pinned backend ZIP security (R325) --- .github/workflows/ci.yml | 5 + build/Test-BackendArchiveHash-R324.ps1 | 147 ++++++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 16 +++ 3 files changed, 168 insertions(+) create mode 100644 build/Test-BackendArchiveHash-R324.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4555cc06..e576d013 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,11 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-ShortcutCreation-R324.ps1 if ($LASTEXITCODE -ne 0) { throw 'R324 shortcut ownership-before-Save regression failed.' } + - name: R324 SHA-pinned ZIP integrity preflight before backend mutation (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-BackendArchiveHash-R324.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R324 archive SHA integrity and old backend preservation regression failed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-BackendArchiveHash-R324.ps1 b/build/Test-BackendArchiveHash-R324.ps1 new file mode 100644 index 00000000..82ddcb94 --- /dev/null +++ b/build/Test-BackendArchiveHash-R324.ps1 @@ -0,0 +1,147 @@ +[CmdletBinding()] +param( + [string]$SetupPath = '', + [string]$ZipPath = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if ([string]::IsNullOrWhiteSpace($SetupPath)) { $SetupPath = Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' } +if ([string]::IsNullOrWhiteSpace($ZipPath)) { $ZipPath = Join-Path $PSScriptRoot '_deps\LibreHardwareMonitor.zip' } +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +if ($PSVersionTable.PSEdition -cne 'Desktop') { throw 'R324_NEEDS_NATIVE_NETFRAMEWORK_POWERSHELL' } +if (!(Test-Path -LiteralPath $SetupPath -PathType Leaf) -or !(Test-Path -LiteralPath $ZipPath -PathType Leaf)) { + throw 'R324_COMPILED_SETUP_OR_PINNED_ARCHIVE_MISSING' +} +$lock=Get-Content (Join-Path $PSScriptRoot 'dependencies.lock.json') -Raw|ConvertFrom-Json +$want=[string]$lock.dependencies.LibreHardwareMonitor.sha256 +if ($want -cne '086D9F1B5A99E643EDC2CFAAAC16051685B551E4C5AC0B32A57C58C0E529C001' -or + (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash -cne $want) { + throw 'R324_DEPENDENCY_LOCK_DIFFERENT_FROM_COMPILED_SOURCE' +} +$setup=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$method=$setup.GetType('SetupProgram',$true).GetMethod('ExtractBackendFromArchive',[Reflection.BindingFlags]'Static, NonPublic') +if ($null -eq $method) { throw 'R324_REAL_SETUP_METHOD_MISSING' } +$embedded=$setup.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') +if ($null -eq $embedded) { throw 'R324_EMBEDDED_ZIP_MISSING' } +try { + $hash=[Security.Cryptography.SHA256]::Create() + try {$embeddedHash=([BitConverter]::ToString($hash.ComputeHash($embedded))).Replace('-','')} + finally {$hash.Dispose()} +} finally {$embedded.Dispose()} +if ($embeddedHash -cne $want) { throw 'R324_SETUP_EMBEDDED_ARCHIVE_DIFFERENT_FROM_LOCK' } + +$original=[IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $ZipPath).Path) +$sourceZip=[IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $ZipPath).Path) +try { + if ($sourceZip.Entries.Count -ne 43 -or $sourceZip.Entries[0].CompressedLength -lt 33000) { + throw 'R324_PINNED_ARCHIVE_LAYOUT_CHANGED' + } +} finally {$sourceZip.Dispose()} + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r324_archive_' + [Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$canary=Join-Path $base 'external-untouched.txt' +[IO.File]::WriteAllText($canary,'KEEP_R324_OUTSIDE') +$checks=0 + +function Check-NoMutation([string]$name,[byte[]]$mutated) { + $target=Join-Path $base $name + [IO.Directory]::CreateDirectory($target)|Out-Null + $old=Join-Path $target 'previous-backend.txt' + [IO.File]::WriteAllText($old,'PRESERVE_PREVIOUS_BACKEND') + $ms=[IO.MemoryStream]::new($mutated,$false) + $args=New-Object 'object[]' 2 + $args[0]=$ms.PSObject.BaseObject + $args[1]=[string]$target + $exception='' + try { + $method.Invoke($null,$args)|Out-Null + throw ('R324_BAD_ARCHIVE_INSTALLED_'+$name) + } catch { + $errorObject=$_.Exception + while ($null -ne $errorObject.InnerException) { + $errorObject=$errorObject.InnerException + } + $exception=$errorObject.Message + } finally {$ms.Dispose()} + if ($exception -cne 'SENSOR_BACKEND_ARCHIVE_SHA_REFUSED' -or + ![IO.File]::Exists($old) -or + [IO.File]::ReadAllText($old) -cne 'PRESERVE_PREVIOUS_BACKEND' -or + @([IO.Directory]::GetFileSystemEntries($target)).Count -ne 1 -or + [IO.File]::ReadAllText($canary) -cne 'KEEP_R324_OUTSIDE') { + throw ('R324_FAIL_CLOSED_REGRESSION_'+$name+':'+$exception) + } + $script:checks++ + Write-Output ('PASS_'+$name+'_SHA_REFUSED_BEFORE_BACKEND_CHANGE') +} + +try { + $crc=[byte[]]$original.Clone() + $firstCentral=-1 + for ($i=0;$i -lt $crc.Length-46;$i++) { + if ($crc[$i] -eq 0x50 -and $crc[$i+1] -eq 0x4b -and + $crc[$i+2] -eq 0x01 -and $crc[$i+3] -eq 0x02) { + $firstCentral=$i + break + } + } + if ($firstCentral -lt 0) { throw 'R324_CENTRAL_HEADER_MISSING' } + $crc[$firstCentral+16]=[byte]($crc[$firstCentral+16] -bxor 1) + Check-NoMutation 'invalid_central_crc' $crc + + $deflate=[byte[]]$original.Clone() + if ([BitConverter]::ToUInt32($deflate,0) -ne 0x04034b50) { + throw 'R324_FIRST_LOCAL_HEADER_MISSING' + } + $payloadStart=30+[BitConverter]::ToUInt16($deflate,26)+[BitConverter]::ToUInt16($deflate,28) + $offset=$payloadStart+32000 + if ($offset -ge $deflate.Length) { throw 'R324_DEFLATE_OFFSET_OUTSIDE_ARCHIVE' } + $deflate[$offset]=[byte]($deflate[$offset] -bxor 2) + Check-NoMutation 'altered_compressed_payload' $deflate + + $valid=Join-Path $base 'valid' + [IO.Directory]::CreateDirectory($valid)|Out-Null + [IO.File]::WriteAllText((Join-Path $valid 'old-backend.txt'),'OLD') + $resource=$setup.GetManifestResourceStream('Payload.LibreHardwareMonitor.zip') + try { + $args=New-Object 'object[]' 2 + $args[0]=$resource.PSObject.BaseObject + $args[1]=[string]$valid + $method.Invoke($null,$args)|Out-Null + } finally {$resource.Dispose()} + $files=@([IO.Directory]::GetFiles($valid,'*',[IO.SearchOption]::AllDirectories)) + if ($files.Count -ne 43 -or [IO.File]::Exists((Join-Path $valid 'old-backend.txt'))) { + throw 'R324_VALID_RESOURCE_EXTRACTION_FAILED' + } + $sourceZip=[IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $ZipPath).Path) + try { + foreach($entry in $sourceZip.Entries) { + $file=Join-Path $valid ($entry.FullName.Replace('/','\')) + if (![IO.File]::Exists($file) -or (Get-Item -LiteralPath $file).Length -ne $entry.Length) { + throw ('R324_VALID_FILE_LAYOUT_MISMATCH_'+$entry.FullName) + } + $reader=$entry.Open() + try { + $sha=[Security.Cryptography.SHA256]::Create() + try {$expected=([BitConverter]::ToString($sha.ComputeHash($reader))).Replace('-','')} + finally {$sha.Dispose()} + } finally {$reader.Dispose()} + if ((Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash -cne $expected) { + throw ('R324_VALID_FILE_SHA_MISMATCH_'+$entry.FullName) + } + } + } finally {$sourceZip.Dispose()} + if ([IO.File]::ReadAllText($canary) -cne 'KEEP_R324_OUTSIDE') { + throw 'R324_POSITIVE_EXTERNAL_CANARY_CHANGED' + } + $checks++ + Write-Output 'PASS_VALID_LOCKED_PE_ARCHIVE_43_FILES_ALL_SHA' + if ($checks -ne 3) { throw ('R324_CHECK_COUNT_MISMATCH_'+$checks) } + Write-Output ('R324_BACKEND_SHA_PREFLIGHT_NATIVE_PASS='+$checks+'/3') +} finally { + if ([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r324_archive_',[StringComparison]::Ordinal)) { + [IO.Directory]::Delete($base,$true) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 38a0cb7d..c567b8c7 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -34,6 +34,9 @@ internal static partial class SetupProgram const string AppFolder="TaskbarMonitorEnhanced"; const string UninstallKey=@"Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced"; const int CompatibleSensorStateFreshSeconds=15; + // SHA-256 from build/dependencies.lock.json. Unlike a digest derived from + // the same unsigned PE at runtime, this rejects corrupted embedded ZIP bytes. + const string PinnedBackendZipSHA256="086D9F1B5A99E643EDC2CFAAAC16051685B551E4C5AC0B32A57C58C0E529C001"; static readonly string[] RequiredResources=new string[] { "Payload.TaskbarMonitorEnhanced.exe", @@ -184,6 +187,19 @@ static void ExtractBackendFromArchive(Stream zipStream,string backendRoot) } if(archive.Entries.Count!=43 || seen.Count!=43 || total!=12203773L) throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_PIN_MISMATCH"); + + // R324: .NET Framework ZIP extraction does not authenticate the + // stored CRC or decompressed bytes. Reject any archive whose exact + // compressed bytes differ from the pinned dependency BEFORE + // deleting the previous per-user backend. + zipStream.Position=0; + using(SHA256 sha=SHA256.Create()) + { + string actual=BitConverter.ToString(sha.ComputeHash(zipStream)).Replace("-",""); + if(!String.Equals(actual,PinnedBackendZipSHA256,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("SENSOR_BACKEND_ARCHIVE_SHA_REFUSED"); + } + zipStream.Position=0; CheckRootAncestorsNoReparse(root); if(Directory.Exists(root))Directory.Delete(root,true); Directory.CreateDirectory(root); From d5b921471ede5000936f89e30f3d5c3c3ef6dc1d Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 03:00:59 +0330 Subject: [PATCH 081/135] fix(setup): direct SHA-pinned Main CreateProcess without ShellExecute (R326) --- .github/workflows/ci.yml | 5 + build/Test-R321-PinnedMainLaunch-Native.ps1 | 8 +- build/Test-R326-DirectMainProcess-Native.ps1 | 128 +++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 35 ++++- 4 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 build/Test-R326-DirectMainProcess-Native.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e576d013..97cd3a9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,11 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-BackendArchiveHash-R324.ps1 if ($LASTEXITCODE -ne 0) { throw 'R324 archive SHA integrity and old backend preservation regression failed.' } + - name: R326 exact Main CreateProcess without ShellExecute and arguments (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R326-DirectMainProcess-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R326 pinned Main direct CreateProcess native security tests failed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-R321-PinnedMainLaunch-Native.ps1 b/build/Test-R321-PinnedMainLaunch-Native.ps1 index 1e3156eb..b0dc5a89 100644 --- a/build/Test-R321-PinnedMainLaunch-Native.ps1 +++ b/build/Test-R321-PinnedMainLaunch-Native.ps1 @@ -57,10 +57,12 @@ if((& $normal $embedded) -cne (& $normal ([IO.File]::ReadAllText($source)))){ } $code=[IO.File]::ReadAllText($source) $start=$code.IndexOf('using(FileStream pinned=OpenPinnedVerifiedMainForLaunch(',[StringComparison]::Ordinal) -$launch=$code.IndexOf('Process.Start(AppExe)',[StringComparison]::Ordinal) +$builder=$code.IndexOf('BuildPinnedMainProcessInfo(pinned,AppRoot)',[StringComparison]::Ordinal) +$launch=$code.IndexOf('Process.Start(direct)',[StringComparison]::Ordinal) $finish=$code.IndexOf('MainLaunchMode="LAUNCHED_NON_ELEVATED_SETUP"', [StringComparison]::Ordinal) -if($start -lt 0 -or $launch -le $start -or $finish -le $launch){ - throw 'R321_GUARD_NOT_BOUND_ACROSS_PROCESS_START' +if($start -lt 0 -or $builder -le $start -or $launch -le $builder -or + $finish -le $launch -or $code.Contains('Process.Start(AppExe)')){ + throw 'R321_R326_GUARD_NOT_BOUND_ACROSS_DIRECT_CREATEPROCESS' } $checks.Add('EXACT_EMBEDDED_SOURCE_AND_REAL_LAUNCH_GUARD_ORDER') $resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') diff --git a/build/Test-R326-DirectMainProcess-Native.ps1 b/build/Test-R326-DirectMainProcess-Native.ps1 new file mode 100644 index 00000000..b27a217b --- /dev/null +++ b/build/Test-R326-DirectMainProcess-Native.ps1 @@ -0,0 +1,128 @@ +[CmdletBinding()] +param([string]$SetupPath='') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R326_REAL_COMPILED_DOTNET_FRAMEWORK_SETUP_REQUIRED' +} +$setup=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$type=$setup.GetType('SetupProgram',$true) +$pinMethod=$type.GetMethod('OpenPinnedVerifiedMainForLaunch',[Reflection.BindingFlags]'NonPublic,Static') +$startMethod=$type.GetMethod('BuildPinnedMainProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') +if($null -eq $pinMethod -or $null -eq $startMethod){ + throw 'R326_DIRECT_MAIN_METHODS_MISSING_IN_REAL_PE' +} +$srcResource=$setup.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if($null -eq $srcResource){throw 'R326_CANONICAL_SOURCE_EMBEDDED_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($srcResource) +try{$embeddedSource=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs'))) +$normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $source) -cne (& $normalize $embeddedSource)){ + throw 'R326_CANONICAL_SOURCE_NOT_EQUAL_PE_EMBED' +} +$checks=[Collections.Generic.List[string]]::new() +$checks.Add('EXACT_PE_SETUP_EMBEDS_CURRENT_SOURCE') +$posPin=$source.IndexOf('using(FileStream pinned=OpenPinnedVerifiedMainForLaunch(',[StringComparison]::Ordinal) +$posStartInfo=$source.IndexOf('BuildPinnedMainProcessInfo(pinned,AppRoot)',[StringComparison]::Ordinal) +$posStart=$source.IndexOf('Process.Start(direct)',[StringComparison]::Ordinal) +$posSuccess=$source.IndexOf('MainLaunchMode="LAUNCHED_NON_ELEVATED_SETUP"',[StringComparison]::Ordinal) +if($posPin -lt 0 -or $posStartInfo -le $posPin -or $posStart -le $posStartInfo -or $posSuccess -le $posStart -or + $source.Contains('Process.Start(AppExe)')){ + throw 'R326_SHA_PIN_DIRECT_CREATEPROCESS_ORDER_INVALID' +} +$checks.Add('SHA_PINNED_MAIN_HANDLE_HELD_ACROSS_DIRECT_PROCESS_START') +$resource=$setup.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') +if(!$resource){throw 'R326_EMBEDDED_MAIN_NOT_FOUND'} +$memory=[IO.MemoryStream]::new() +try{$resource.CopyTo($memory)}finally{$resource.Dispose()} +$mainBytes=$memory.ToArray() +$memory.Dispose() +$sha=[Security.Cryptography.SHA256]::Create() +try{$expected=([BitConverter]::ToString($sha.ComputeHash($mainBytes))).Replace('-','')} +finally{$sha.Dispose()} +if($mainBytes.Length -lt 1024 -or $expected.Length -ne 64){throw 'R326_MAIN_RESOURCE_INVALID'} +$checks.Add('EMBEDDED_MAIN_EXPECTED_SHA_IS_FULL_HEX') + +$dir=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r326_direct_main_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($dir)|Out-Null +$root=Join-Path $dir 'literal & dollar $name with spaces' +[IO.Directory]::CreateDirectory($root)|Out-Null +$file=Join-Path $root 'TaskbarMonitorEnhanced.exe' +[IO.File]::WriteAllBytes($file,$mainBytes) +$pin=$null +function Invoke-Builder([object]$handle,[string]$expectedRoot){ + $args=New-Object 'object[]' 2 + $args[0]=$handle + $args[1]=$expectedRoot + return $startMethod.Invoke($null,$args) +} +function Expect-Refusal([string]$label,[object]$handle,[string]$expectedRoot,[string]$message){ + $refused=$false + try{ + $value=Invoke-Builder $handle $expectedRoot + if($null -ne $value){throw ('R326_BAD_BUILDER_ACCEPTED_'+$label)} + }catch{ + $failure=$_.Exception + while($null -ne $failure.InnerException){$failure=$failure.InnerException} + if($failure.Message -match $message){$refused=$true} + else{throw} + } + if(!$refused){throw ('R326_NEGATIVE_WAS_NOT_REFUSED_'+$label)} + $script:checks.Add($label) +} +try{ + $args=New-Object 'object[]' 3 + $args[0]=[string]$file;$args[1]=[string]$root;$args[2]=[string]$expected + $pin=[IO.FileStream]$pinMethod.Invoke($null,$args) + if($null -eq $pin -or !$pin.CanRead -or + ![string]::Equals([IO.Path]::GetFullPath($pin.Name),[IO.Path]::GetFullPath($file),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R326_EXACT_SHA_FILESTREAM_NOT_PINNED' + } + $checks.Add('READ_ONLY_EXACT_SHA_MAIN_HANDLE_PINNED') + $built=Invoke-Builder $pin $root + if($built.UseShellExecute -ne $false){throw 'R326_SHELLEXECUTE_NOT_DISABLED'} + $checks.Add('REAL_PROCESSSTARTINFO_USE_SHELL_EXECUTE_FALSE') + if($built.FileName -cne [IO.Path]::GetFullPath($file)){ + throw 'R326_DIRECT_EXECUTABLE_NOT_EXACT_FILESTREAM_PATH' + } + $checks.Add('REAL_PROCESSSTARTINFO_EXE_EXACT_PINNED_FILE') + if($built.Arguments -cne ''){throw 'R326_MAIN_RECEIVES_UNTRUSTED_ARGS'} + $checks.Add('REAL_PROCESSSTARTINFO_ARGUMENTS_EMPTY') + if($built.Verb -cne '' -or $built.ErrorDialog -ne $false){ + throw 'R326_SHELL_VERB_OR_ERROR_DIALOG_PRESENT' + } + $checks.Add('REAL_PROCESSSTARTINFO_HAS_NO_SHELL_VERB') + if($built.WorkingDirectory -cne [IO.Path]::GetFullPath($root)){ + throw 'R326_WORKDIR_NOT_PINNED_INSTALLATION_ROOT' + } + $checks.Add('REAL_PROCESSSTARTINFO_WORKING_DIRECTORY_EXACT') + if(!$built.FileName.Contains('&') -or !$built.FileName.Contains('$name')){ + throw 'R326_META_CHARACTERS_LOST_OR_INTERPRETED' + } + $checks.Add('METACHARACTER_PATH_PRESERVED_AS_LITERAL_WITHOUT_SHELL') + $writeBlocked=$false + try{$writer=[IO.FileStream]::new($file,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::ReadWrite);$writer.Dispose()} + catch [IO.IOException]{$writeBlocked=$true} + if(!$writeBlocked){throw 'R326_PINNED_HANDLE_DID_NOT_DENY_WRITER'} + $checks.Add('WRITE_DENIED_WHILE_STARTINFO_PREPARED') + Expect-Refusal 'FOREIGN_INSTALLATION_ROOT_REFUSED' $pin (Join-Path $dir 'foreign') 'escaped' + Expect-Refusal 'RELATIVE_INSTALLATION_ROOT_REFUSED' $pin 'relative' 'unavailable' + Expect-Refusal 'NULL_MAIN_HANDLE_REFUSED' $null $root 'unavailable' + $pin.Dispose();$pin=$null + $closed=[IO.FileStream]::new($file,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $closed.Dispose() + Expect-Refusal 'DISPOSED_MAIN_HANDLE_REFUSED' $closed $root 'unavailable' + if($checks.Count -ne 15){throw ('R326_TEST_COUNT_MISMATCH_'+$checks.Count)} + Write-Output ('R326_DIRECT_MAIN_WINDOWS_NET48_PASS='+$checks.Count+'/15') + foreach($item in $checks){Write-Output ('PASS '+$item)} +}finally{ + if($pin){$pin.Dispose()} + if([IO.Directory]::Exists($dir) -and [IO.Path]::GetFileName($dir).StartsWith('tbme_r326_direct_main_',[StringComparison]::Ordinal)){ + [IO.Directory]::Delete($dir,$true) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index c567b8c7..622dd78b 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -616,6 +616,31 @@ static FileStream OpenPinnedVerifiedMainForLaunch( } } + // R326: .NET Framework defaults UseShellExecute=true for Process.Start(path). + // Explicitly create the exact SHA-verified Main as a process with no + // arguments, no shell association, and no implicit executable search. + // Pinned file bytes are guarded by OpenPinnedVerifiedMainForLaunch. + // User-writable ancestor directory swap races remain a distinct gate. + static ProcessStartInfo BuildPinnedMainProcessInfo(FileStream pinned,string expectedRoot) + { + if(pinned==null || !pinned.CanRead || String.IsNullOrWhiteSpace(expectedRoot) || + !Path.IsPathRooted(expectedRoot)) + throw new InvalidDataException("Pinned Main process identity is unavailable."); + string root=Path.GetFullPath(expectedRoot).TrimEnd(Path.DirectorySeparatorChar); + string path=Path.GetFullPath(pinned.Name); + if(root.StartsWith(@"\\",StringComparison.Ordinal) || + path.StartsWith(@"\\",StringComparison.Ordinal) || + !String.Equals(path,Path.Combine(root,"TaskbarMonitorEnhanced.exe"), + StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Pinned Main process path escaped its installation root."); + ProcessStartInfo psi=new ProcessStartInfo(); + psi.FileName=path; + psi.WorkingDirectory=root; + psi.Arguments=String.Empty; + psi.UseShellExecute=false; + psi.ErrorDialog=false; + return psi; + } static string Sha256Resource(string resourceName) { using(Stream s=Resource(resourceName)) @@ -956,7 +981,15 @@ static SensorOutcome Install(bool desktop,bool startup) using(FileStream pinned=OpenPinnedVerifiedMainForLaunch( AppExe,AppRoot,Sha256Resource("Payload.TaskbarMonitorEnhanced.exe"))) { - Process.Start(AppExe); + // R326: use CreateProcess, not ShellExecute's file/verb + // handling. The executable path comes from the SHA-pinned + // open file; no user-supplied command-line arguments exist. + ProcessStartInfo direct=BuildPinnedMainProcessInfo(pinned,AppRoot); + using(Process started=Process.Start(direct)) + { + if(started==null) + throw new IOException("Pinned Main could not be started."); + } } MainLaunchMode="LAUNCHED_NON_ELEVATED_SETUP"; } From 3adfce1f6754b0247d477a80be0877fdce4ace38 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 03:08:15 +0330 Subject: [PATCH 082/135] fix(setup): preserve prior working directory in direct Main launch (R326 F01) --- build/Test-R326-DirectMainProcess-Native.ps1 | 6 +++--- installer/TaskbarMonitorEnhanced_Setup.cs | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/build/Test-R326-DirectMainProcess-Native.ps1 b/build/Test-R326-DirectMainProcess-Native.ps1 index b27a217b..df57c200 100644 --- a/build/Test-R326-DirectMainProcess-Native.ps1 +++ b/build/Test-R326-DirectMainProcess-Native.ps1 @@ -97,10 +97,10 @@ try{ throw 'R326_SHELL_VERB_OR_ERROR_DIALOG_PRESENT' } $checks.Add('REAL_PROCESSSTARTINFO_HAS_NO_SHELL_VERB') - if($built.WorkingDirectory -cne [IO.Path]::GetFullPath($root)){ - throw 'R326_WORKDIR_NOT_PINNED_INSTALLATION_ROOT' + if($built.WorkingDirectory -cne ''){ + throw 'R326_WORKDIR_SHOULD_NOT_CONSUME_UNTRUSTED_PROFILE_PATH' } - $checks.Add('REAL_PROCESSSTARTINFO_WORKING_DIRECTORY_EXACT') + $checks.Add('REAL_PROCESSSTARTINFO_INHERITS_PREVIOUS_CWD') if(!$built.FileName.Contains('&') -or !$built.FileName.Contains('$name')){ throw 'R326_META_CHARACTERS_LOST_OR_INTERPRETED' } diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 622dd78b..ad39db9b 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -635,7 +635,8 @@ static ProcessStartInfo BuildPinnedMainProcessInfo(FileStream pinned,string expe throw new InvalidDataException("Pinned Main process path escaped its installation root."); ProcessStartInfo psi=new ProcessStartInfo(); psi.FileName=path; - psi.WorkingDirectory=root; + // Keep the preceding Setup current-directory behavior; do not pass + // a profile-controlled WorkingDirectory to CreateProcess. psi.Arguments=String.Empty; psi.UseShellExecute=false; psi.ErrorDialog=false; From 50f9848ee47d57f9c40746198ba6a495d5c768b7 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 03:33:12 +0330 Subject: [PATCH 083/135] fix(setup): bind sensor staging to canonical user profile Temp (R327) --- .github/workflows/ci.yml | 5 + .../Test-R327-CanonicalSensorTemp-Native.ps1 | 177 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 43 ++++- 3 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 build/Test-R327-CanonicalSensorTemp-Native.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97cd3a9a..01f402a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,11 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R326-DirectMainProcess-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R326 pinned Main direct CreateProcess native security tests failed.' } + - name: R327 canonical profile Temp instead of mutable TEMP/TMP (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R327-CanonicalSensorTemp-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R327 canonical sensor Temp, reparse and environment-negative tests failed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-R327-CanonicalSensorTemp-Native.ps1 b/build/Test-R327-CanonicalSensorTemp-Native.ps1 new file mode 100644 index 00000000..c74f0793 --- /dev/null +++ b/build/Test-R327-CanonicalSensorTemp-Native.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param([string]$SetupPath='') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R327_COMPILED_NET_FRAMEWORK_SETUP_REQUIRED' +} +$setup=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$type=$setup.GetType('SetupProgram',$true) +$canonical=$type.GetMethod('CanonicalSensorTempFromKnownFolders', + [Reflection.BindingFlags]'NonPublic,Static') +$create=$type.GetMethod('CreatePrivateSensorPayloadDirectory', + [Reflection.BindingFlags]'NonPublic,Static') +if(!$canonical -or !$create){throw 'R327_EXACT_PE_MISSING_CANONICAL_PAYLOAD_METHODS'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$label){ + $script:checks.Add($label) + Write-Output ('PASS '+$label) +} +function Invoke-Canonical([string]$local,[string]$profile){ + $args=New-Object 'object[]' 2 + $args[0]=[string]$local;$args[1]=[string]$profile + return [string]$canonical.Invoke($null,$args) +} +function Expect-BoundaryRefusal([string]$label,[string]$local,[string]$profile){ + $actual='' + try{ + $returned=Invoke-Canonical $local $profile + throw ('R327_UNEXPECTEDLY_ACCEPTED_'+$label+' '+$returned) + }catch{ + $failure=$_.Exception + while($null -ne $failure.InnerException){$failure=$failure.InnerException} + $actual=$failure.Message + } + if($actual -match '^R327_UNEXPECTEDLY_ACCEPTED_'){throw $actual} + if($actual -notmatch '(?i)reparse|canonical|unavailable|outside|root|path'){ + throw ('R327_REJECTED_FOR_WRONG_REASON_'+$label+' '+$actual) + } + Pass $label +} +function Invoke-Create([string]$prefix){ + $args=New-Object 'object[]' 1 + $args[0]=[string]$prefix + return [string]$create.Invoke($null,$args) +} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$resource=$setup.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R327_SOURCE_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embeddedSource=$reader.ReadToEnd()}finally{$reader.Dispose()} +$normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $source) -cne (& $normalize $embeddedSource)){throw 'R327_PE_DOES_NOT_EMBED_THIS_SOURCE'} +Pass 'EXACT_COMPILED_PE_EMBEDS_SOURCE' + +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$expectedReal=[IO.Path]::GetFullPath((Join-Path $local 'Temp')) +$result=Invoke-Canonical $local $profile +if(![string]::Equals($result,$expectedReal,[StringComparison]::OrdinalIgnoreCase)){ + throw ('R327_ACTUAL_PROFILE_TEMP_MISMATCH_'+$result) +} +Pass 'REAL_OS_PROFILE_CANONICAL_TEMP' + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r327_sensor_temp_'+[Guid]::NewGuid().ToString('N')) +[void][IO.Directory]::CreateDirectory($base) +$created=@() +$originalTemp=$env:TEMP +$originalTmp=$env:TMP +try{ + $fakeProfile=Join-Path $base 'literal & special $profile' + $fakeLocal=Join-Path $fakeProfile 'AppData\Local' + [void][IO.Directory]::CreateDirectory($fakeLocal) + $expected=Join-Path $fakeLocal 'Temp' + $actual=Invoke-Canonical $fakeLocal $fakeProfile + if(![string]::Equals($actual,$expected,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R327_SYNTHETIC_PROFILE_NOT_CANONICAL' + } + Pass 'SYNTHETIC_PROFILE_WITH_METACHARACTERS' + + Expect-BoundaryRefusal 'MISMATCHED_LOCAL_PROFILE' (Join-Path $base 'wrong\AppData\Local') $fakeProfile + Expect-BoundaryRefusal 'RELATIVE_KNOWN_LOCAL_REFUSED' 'AppData\Local' $fakeProfile + Expect-BoundaryRefusal 'UNC_PROFILE_REFUSED' '\\attacker.invalid\share\AppData\Local' '\\attacker.invalid\share' + $drive=[IO.Path]::GetPathRoot([IO.Path]::GetFullPath($base)) + Expect-BoundaryRefusal 'DRIVE_ROOT_ONLY_PROFILE_REFUSED' (Join-Path $drive 'AppData\Local') $drive + + $external=Join-Path $base 'external-canary-target' + [void][IO.Directory]::CreateDirectory($external) + $sentinel=Join-Path $external 'do_not_touch.txt' + [IO.File]::WriteAllText($sentinel,'R327_UNTOUCHED') + + $link=Join-Path $fakeLocal 'Temp' + New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null + try{ + Expect-BoundaryRefusal 'TEMP_JUNCTION_REFUSED' $fakeLocal $fakeProfile + if([IO.File]::ReadAllText($sentinel) -cne 'R327_UNTOUCHED'){ + throw 'R327_FOREIGN_CONTENT_CHANGED_AFTER_TEMP_REFUSAL' + } + }finally{ + if([IO.Directory]::Exists($link)){[IO.Directory]::Delete($link)} + } + $otherProfile=Join-Path $base 'otherProfile' + [void][IO.Directory]::CreateDirectory((Join-Path $otherProfile 'AppData')) + $otherLocal=Join-Path $otherProfile 'AppData\Local' + $link2=New-Item -ItemType Junction -Path $otherLocal -Target $external -ErrorAction Stop + try{ + Expect-BoundaryRefusal 'LOCAL_JUNCTION_REFUSED' $otherLocal $otherProfile + if([IO.File]::ReadAllText($sentinel) -cne 'R327_UNTOUCHED'){ + throw 'R327_FOREIGN_CONTENT_CHANGED_AFTER_LOCAL_REFUSAL' + } + }finally{ + if([IO.Directory]::Exists($otherLocal)){[IO.Directory]::Delete($otherLocal)} + } + + $refused=$false + try{$invalid=Invoke-Create 'arbitrary_'}catch{ + $failure=$_.Exception + while($null -ne $failure.InnerException){$failure=$failure.InnerException} + if($failure.Message -match 'Unexpected private sensor payload prefix'){ + $refused=$true + }else{throw} + } + if(!$refused){throw 'R327_BAD_PAYLOAD_PREFIX_ACCEPTED'} + Pass 'UNRECOGNIZED_PREFIX_REFUSED' + + $poison=Join-Path $base 'unrelated-env-TEMP' + [void][IO.Directory]::CreateDirectory($poison) + $env:TEMP=$poison + $env:TMP=$poison + foreach($prefix in @('tbme_setup_','tbme_uninstall_')){ + $payload=Invoke-Create $prefix + if(![string]::Equals([IO.Path]::GetDirectoryName($payload), + $expectedReal,[StringComparison]::OrdinalIgnoreCase)){ + throw ('R327_TAINTED_TEMP_ENV_SELECTED_'+$payload) + } + if(![IO.Directory]::Exists($payload) -or + [IO.Path]::GetFileName($payload) -cnotmatch ('^'+$prefix+'[0-9a-f]{32}$')){ + throw 'R327_NOT_A_RANDOM_PRIVATE_PAYLOAD' + } + if(@([IO.Directory]::GetFileSystemEntries($payload)).Count -ne 0){ + throw 'R327_GENERATED_PAYLOAD_NOT_EMPTY' + } + $created+=,$payload + Pass ('RANDOM_'+$prefix+'PAYLOAD_UNDER_REAL_PROFILE') + } + if(@([IO.Directory]::GetFileSystemEntries($poison)).Count -ne 0){ + throw 'R327_ENV_POISON_DIRECTORY_TOUCHED' + } + Pass 'POISONED_TEMP_TMP_ENV_NOT_USED' + + if($source.Contains('Path.GetTempPath()') -or + !$source.Contains('CreatePrivateSensorPayloadDirectory("tbme_setup_")') -or + !$source.Contains('CreatePrivateSensorPayloadDirectory("tbme_uninstall_")')){ + throw 'R327_INSTALL_OR_UNINSTALL_BYPASSES_CANONICAL_PAYLOAD_DIRECTORY' + } + Pass 'INSTALL_AND_UNINSTALL_USE_ONE_CANONICAL_PATH' + if($checks.Count -ne 14){throw ('R327_TEST_COUNT_MISMATCH_'+$checks.Count)} + Write-Output ('R327_CANONICAL_SENSOR_TEMP_NET48_PASS='+$checks.Count+'/14') +}finally{ + $env:TEMP=$originalTemp + $env:TMP=$originalTmp + foreach($p in $created){ + if([IO.Directory]::Exists($p) -and + [IO.Path]::GetFileName($p) -match '^tbme_(setup|uninstall)_[0-9a-f]{32}$' -and + [IO.Path]::GetDirectoryName($p) -eq $expectedReal -and + @([IO.Directory]::GetFileSystemEntries($p)).Count -eq 0){ + [IO.Directory]::Delete($p) + } + } + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r327_sensor_temp_',[StringComparison]::Ordinal)){ + [IO.Directory]::Delete($base,$true) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index ad39db9b..a0b4d833 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -115,6 +115,43 @@ static string AppRoot { Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); } } + // R327: elevated Sensor Helper accepts only direct children of the + // known profile's AppData\Local\Temp. Do not use mutable TEMP/TMP env. + // Reparse checks are fail-closed at these instants, not an atomic + // defence against concurrent same-user directory replacement. + static string CanonicalSensorTempFromKnownFolders(string knownLocal,string knownProfile) + { + string app=CanonicalAppRootFromKnownFolders(knownLocal,knownProfile); + string local=Path.GetDirectoryName(app); + if(String.IsNullOrWhiteSpace(local)) + throw new InvalidDataException("Canonical user Temp parent is unavailable."); + string temp=Path.GetFullPath(Path.Combine(local,"Temp")); + if(temp.Length>1024 || + !String.Equals(Path.GetDirectoryName(temp),local,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Canonical sensor Temp directory escaped its profile."); + CheckRootAncestorsNoReparse(temp); + return temp; + } + + static string CreatePrivateSensorPayloadDirectory(string prefix) + { + if(prefix!="tbme_setup_" && prefix!="tbme_uninstall_") + throw new InvalidDataException("Unexpected private sensor payload prefix."); + string root=CanonicalSensorTempFromKnownFolders( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + Directory.CreateDirectory(root); + CheckRootAncestorsNoReparse(root); + string payload=Path.GetFullPath(Path.Combine(root,prefix+Guid.NewGuid().ToString("N"))); + if(!String.Equals(Path.GetDirectoryName(payload),root,StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Private sensor payload escaped canonical Temp."); + CheckRootAncestorsNoReparse(payload); + if(File.Exists(payload) || Directory.Exists(payload)) + throw new IOException("Private sensor payload name already exists."); + Directory.CreateDirectory(payload); + CheckRootAncestorsNoReparse(payload); + return payload; + } static string AppExe { get { return Path.Combine(AppRoot,"TaskbarMonitorEnhanced.exe"); } } static string SensorResultPath { get { return Path.Combine(AppRoot,"Logs","sensor_install_result.json"); } } static string MainLaunchMode="PENDING"; @@ -703,8 +740,7 @@ static void WriteInstallState(SensorOutcome sensorOutcome) static string PrepareSensorPayload() { - string payload=Path.Combine(Path.GetTempPath(),"tbme_setup_"+Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(payload); + string payload=CreatePrivateSensorPayloadDirectory("tbme_setup_"); Extract("Payload.TaskbarMonitorSensorBroker.exe",Path.Combine(payload,"TaskbarMonitorSensorBroker.exe")); Extract("Payload.TaskbarMonitorSensorSupervisor.exe",Path.Combine(payload,"TaskbarMonitorSensorSupervisor.exe")); // This user-writable staging file is bound to the PE embedded ZIP hash @@ -1143,8 +1179,7 @@ static void Uninstall(bool quiet) StopProcess("TaskbarMonitorEnhanced"); - string payload=Path.Combine(Path.GetTempPath(),"tbme_uninstall_"+Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(payload); + string payload=CreatePrivateSensorPayloadDirectory("tbme_uninstall_"); string user=WindowsIdentity.GetCurrent().Name; int helperExit=ElevatedHelper("Uninstall",payload,AppRoot,user,90000); From 9f39af91964498207c4f6b1aea83aca85f09083b Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 03:54:55 +0330 Subject: [PATCH 084/135] fix(sensor): recheck user log/result reparse boundaries at each elevated write (R328) --- .github/workflows/ci.yml | 5 + build/Test-R328-HelperLogBoundary-Native.ps1 | 163 +++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 35 ++++ 3 files changed, 203 insertions(+) create mode 100644 build/Test-R328-HelperLogBoundary-Native.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01f402a2..200412d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,11 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R327-CanonicalSensorTemp-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R327 canonical sensor Temp, reparse and environment-negative tests failed.' } + - name: R328 protected Helper post-preflight log/result boundary (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R328-HelperLogBoundary-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R328 Sensor Helper user log/result reparse boundary checks failed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-R328-HelperLogBoundary-Native.ps1 b/build/Test-R328-HelperLogBoundary-Native.ps1 new file mode 100644 index 00000000..8d069d08 --- /dev/null +++ b/build/Test-R328-HelperLogBoundary-Native.ps1 @@ -0,0 +1,163 @@ +[CmdletBinding()] +param([string]$SetupPath='') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R328_EXACT_COMPILED_FRAMEWORK_SETUP_REQUIRED' +} +$helper=(Resolve-Path (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$embeddedAssembly=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$stream=$embeddedAssembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R328_HELPER_PE_RESOURCE_NOT_FOUND'} +$reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText($helper) +$normalized={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalized $embedded) -cne (& $normalized $source)){throw 'R328_HELPER_PE_RESOURCE_NOT_IDENTICAL'} +$script:checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +Pass 'EXACT_SETUP_PE_EMBEDS_TRUSTED_HELPER_SOURCE' +$tokens=$null;$parseErrors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$parseErrors) +if(@($parseErrors).Count -ne 0){throw 'R328_TRUSTED_HELPER_AST_PARSE_ERRORS'} +foreach($fn in @('Assert-SensorBoundaryNoReparse','Assert-SensorUserLogSink','Write-Log','Write-Result')){ + $definition=@($ast.FindAll({param($x) $x -is [Management.Automation.Language.FunctionDefinitionAst]},$true)| + Where-Object Name -EQ $fn) + if($definition.Count -ne 1){throw ('R328_FUNCTION_NOT_EXACTLY_ONE_'+$fn)} + . ([scriptblock]::Create($definition[0].Extent.Text)) +} +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r328_logs_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$AppRoot=Join-Path $base 'isolated profile with & $special' +[IO.Directory]::CreateDirectory($AppRoot)|Out-Null +$LogsRoot=Join-Path $AppRoot 'Logs' +[IO.Directory]::CreateDirectory($LogsRoot)|Out-Null +$ResultPath=Join-Path $LogsRoot 'sensor_install_result.json' +$LogPath=Join-Path $LogsRoot 'sensor_install.log' +$external=Join-Path $base 'external-canary-data' +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'NEVER_TOUCH.txt' +[IO.File]::WriteAllText($canary,'R328_CANARY_UNCHANGED') +$SupervisorHealthy=$false +$CpuTransportHealthy=$false +$GpuTransportHealthy=$false +$StorageTransportHealthy=$false +$CpuDataAvailable=$false +$RollbackPerformed=$false +$RollbackSucceeded=$false +$PreviousSensorLayerPresent=$false +$ActiveArchitecture='R328_ISOLATED_NATIVE_TEST_ONLY' + +function Expect-Refusal([string]$name,[scriptblock]$action,[string]$reason){ + $text='' + try{ + & $action|Out-Null + throw ('R328_FAIL_OPEN_'+$name) + }catch{ + $errorObject=$_.Exception + while($null -ne $errorObject.InnerException){$errorObject=$errorObject.InnerException} + $text=$errorObject.Message + } + if($text -match '^R328_FAIL_OPEN_' -or $text -notmatch $reason){ + throw ('R328_EXPECTED_PREWRITE_REFUSAL_MISSING_'+$name+' ACTUAL='+$text) + } + Pass $name +} +$logJunction=$null +$appJunction=$null +try{ + Write-Log 'NATIVE_SAFE_LOG' + if(!(Test-Path -LiteralPath $LogPath -PathType Leaf) -or + (Get-Content -LiteralPath $LogPath -Raw) -notmatch 'NATIVE_SAFE_LOG'){ + throw 'R328_SAFE_LOG_WRITE_FAILED' + } + Pass 'ORDINARY_USER_PROFILE_LOG_WORKS' + + Write-Result 'DEGRADED' 'Native no UAC mutation test' 'NOT_STARTED' $null $false $false $false + $result=Get-Content -LiteralPath $ResultPath -Raw |ConvertFrom-Json + if($result.Status -cne 'DEGRADED' -or $result.SensorHealthy -ne $false -or + $result.LogPath -cne $LogPath){throw 'R328_SAFE_RESULT_JSON_CONTRACT_FAILED'} + Pass 'ORDINARY_RESULT_JSON_CONTRACT_PRESERVED' + + Expect-Refusal 'WRONG_SINK_NAME_REFUSED' { + Assert-SensorUserLogSink (Join-Path $base 'foreign.txt') + } 'SENSOR_LOG_BOUNDARY_CHANGED_TARGET' + + $oldRoot=$LogsRoot + $LogsRoot=Join-Path $base 'wrong-logs-root' + try{Expect-Refusal 'MUTATED_LOGS_ROOT_VARIABLE_REFUSED' { Write-Log 'NEVER_WRITE' } 'SENSOR_LOG_BOUNDARY_CHANGED_ROOT'} + finally{$LogsRoot=$oldRoot} + + $moved=Join-Path $AppRoot 'LogsMovedBeforeWrite' + [IO.Directory]::Move($LogsRoot,$moved) + try{ + Expect-Refusal 'MISSING_LOG_FOLDER_REFUSED' { Write-Log 'NEVER_WRITE' } 'SENSOR_LOG_BOUNDARY_MISSING_LOG_DIRECTORY' + }finally{[IO.Directory]::Move($moved,$LogsRoot)} + + $moved=Join-Path $AppRoot 'LogsMovedForReparse' + [IO.Directory]::Move($LogsRoot,$moved) + New-Item -ItemType Junction -Path $LogsRoot -Target $external -ErrorAction Stop|Out-Null + $logJunction=$LogsRoot + Expect-Refusal 'POST_PREFLIGHT_LOG_JUNCTION_REFUSED' { + Write-Log 'FOREIGN_MODIFICATION_ATTEMPT' + } 'reparse' + Expect-Refusal 'POST_PREFLIGHT_RESULT_JUNCTION_REFUSED' { + Write-Result 'READY' 'FOREIGN_MODIFICATION_ATTEMPT' 'NOT_STARTED' $null $false $false $true + } 'reparse' + if([IO.File]::ReadAllText($canary) -cne 'R328_CANARY_UNCHANGED' -or + @(Get-ChildItem -LiteralPath $external -File).Count -ne 1){ + throw 'R328_EXTERNAL_CANARY_OR_DIRECTORY_WAS_MODIFIED' + } + Pass 'JUNCTION_EXTERNAL_CANARY_AND_CONTENT_PRESERVED' + [IO.Directory]::Delete($logJunction) + $logJunction=$null + [IO.Directory]::Move($moved,$LogsRoot) + + $retired=Join-Path $base 'AppRootRetiredBeforeWrite' + [IO.Directory]::Move($AppRoot,$retired) + New-Item -ItemType Junction -Path $AppRoot -Target $external -ErrorAction Stop | Out-Null + $appJunction=$AppRoot + Expect-Refusal 'POST_PREFLIGHT_APPROOT_JUNCTION_REFUSED' { + Write-Log 'FOREIGN_ROOT_MODIFICATION' + } 'reparse' + [IO.Directory]::Delete($appJunction) + $appJunction=$null + [IO.Directory]::Move($retired,$AppRoot) + + $pre=$source.IndexOf('Assert-SensorBoundaryNoReparse $LogsRoot',[StringComparison]::Ordinal) + $create=$source.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) + $post=$source.IndexOf('Assert-SensorBoundaryNoReparse $LogsRoot',$create+1) + $functionStart=$source.IndexOf('function Write-Log(',[StringComparison]::Ordinal) + if($pre -lt 0 -or $create -le $pre -or $post -le $create -or $functionStart -le $post){ + throw 'R328_CREATION_PATH_PRE_AND_POST_CHECK_ORDER_BROKEN' + } + Pass 'LOG_DIRECTORY_CREATION_PRE_AND_POST_BOUNDARY_ORDER' + + $scriptLines=$source + if($scriptLines.IndexOf('Assert-SensorUserLogSink $LogPath',[StringComparison]::Ordinal) -lt 0 -or + $scriptLines.IndexOf('Assert-SensorUserLogSink $ResultPath',[StringComparison]::Ordinal) -lt 0){ + throw 'R328_BOTH_WRITE_SINKS_ARE_NOT_GUARDED' + } + Pass 'BOTH_PRIVILEGED_USER_LOG_WRITES_HAVE_PATH_GUARDS' + + if($checks.Count -ne 12){throw ('R328_CHECK_COUNT_MISMATCH_'+$checks.Count)} + Write-Output ('R328_PRIVILEGED_HELPER_LOG_BOUNDARY_NET48_PASS='+$checks.Count+'/12') +}finally{ + if($logJunction -and [IO.Directory]::Exists($logJunction)){ + [IO.Directory]::Delete($logJunction) + } + if($appJunction -and [IO.Directory]::Exists($appJunction)){ + [IO.Directory]::Delete($appJunction) + } + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r328_logs_',[StringComparison]::Ordinal)){ + [IO.Directory]::Delete($base,$true) + } +} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 50536a75..6517d98f 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -126,7 +126,12 @@ $script:SensorJournalRoot=Join-Path ([Environment]::GetFolderPath([Environment+S $LogsRoot=Join-Path $AppRoot 'Logs' $ResultPath=Join-Path $LogsRoot 'sensor_install_result.json' $LogPath=Join-Path $LogsRoot 'sensor_install.log' +# R328: the user owns this tree and can replace its directories between +# the original privileged preflight and its later log/result writes. +# Recheck immediately around creation; this does not close atomic races. +Assert-SensorBoundaryNoReparse $LogsRoot New-Item -ItemType Directory -Force -Path $LogsRoot | Out-Null +Assert-SensorBoundaryNoReparse $LogsRoot # R21 health channels are initialized before any early-return/uninstall branch so # Write-Result can always emit a stable schema under StrictMode. @@ -143,7 +148,36 @@ $RollbackFiles=@() $RollbackTaskXmlText=$null $RollbackCaptured=$false +# Fail closed when a user-writable profile log path was replaced after the +# initial elevated token/boundary verification. Write-time inspection is +# not an atomic handle-bound proof against concurrent rename/symlink swaps. +function Assert-SensorUserLogSink([string]$Target){ + if([string]::IsNullOrWhiteSpace($Target) -or + [string]::IsNullOrWhiteSpace($AppRoot)){ + throw 'SENSOR_LOG_BOUNDARY_MISSING' + } + $expectedRoot=[IO.Path]::GetFullPath((Join-Path $AppRoot 'Logs')).TrimEnd('\') + $actualRoot=[IO.Path]::GetFullPath($LogsRoot).TrimEnd('\') + if(-not [string]::Equals($expectedRoot,$actualRoot,[StringComparison]::OrdinalIgnoreCase)){ + throw 'SENSOR_LOG_BOUNDARY_CHANGED_ROOT' + } + $actual=[IO.Path]::GetFullPath($Target) + $log=[IO.Path]::GetFullPath((Join-Path $expectedRoot 'sensor_install.log')) + $result=[IO.Path]::GetFullPath((Join-Path $expectedRoot 'sensor_install_result.json')) + if(-not([string]::Equals($actual,$log,[StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($actual,$result,[StringComparison]::OrdinalIgnoreCase))){ + throw 'SENSOR_LOG_BOUNDARY_CHANGED_TARGET' + } + Assert-SensorBoundaryNoReparse $AppRoot + Assert-SensorBoundaryNoReparse $actualRoot + Assert-SensorBoundaryNoReparse $actual + if(-not [IO.Directory]::Exists($actualRoot)){ + throw 'SENSOR_LOG_BOUNDARY_MISSING_LOG_DIRECTORY' + } +} + function Write-Log([string]$Message){ + Assert-SensorUserLogSink $LogPath $line=(Get-Date).ToString('o')+' '+$Message $line | Add-Content -LiteralPath $LogPath -Encoding UTF8 } @@ -156,6 +190,7 @@ function Write-Result( [bool]$TaskInstalled, [bool]$SensorHealthy ){ + Assert-SensorUserLogSink $ResultPath [ordered]@{ Status=$Status Message=$Message From 00bedad43b79aee7bc607a369cfb1899d5615895 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 13:28:15 +0330 Subject: [PATCH 085/135] test(setup): prove SHA-pinned Main blocks parent rename on two Windows hosts (R329) --- ...it-r286-optional-audio-native-recovery.yml | 28 +++ .github/workflows/ci.yml | 5 + ...t-R329-PinnedMainAncestorRename-Native.ps1 | 209 ++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 build/Test-R329-PinnedMainAncestorRename-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index d853c837..5aaf721d 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -949,6 +949,33 @@ jobs: $r.GuestMainAndUserSettingsPreserved -ne $true -or @($r.Checks).Count -ne 14){throw 'R321_TYPED_PINNED_MAIN_PROOF_FAILED'} 'R321_PINNED_IMMUTABLE_MAIN_HANDLE_BOUNDED=PASS_NOT_SECURITY_FINAL' + - name: R329 real Setup SHA-pinned Main blocks ancestor rename on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R329-PinnedMainAncestorRename-Native.ps1).Path + $tok=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$errors) + if(@($errors).Count){throw 'R329_NATIVE_SCRIPT_PS51_PARSE_FAILED'} + $setup=(Resolve-Path _r284_private_candidate/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe).Path + $native=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + & $native -NoProfile -NonInteractive -File $test -SetupPath $setup -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw ('R329_NATIVE_PINNED_ANCESTOR_RENAME_FAILED_'+$LASTEXITCODE)} + $receipt=Join-Path $env:RUNNER_TEMP ('R329_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R329_NATIVE_TYPED_RECEIPT_MISSING'} + $proof=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($proof.Schema -ne 1 -or $proof.Revision -cne 'R329' -or + $proof.Host -cne '${{ matrix.host }}' -or $proof.SourceCommit -cne $env:GITHUB_SHA -or + $proof.Status -cne 'PASS_PRODUCTION_PINNED_MAIN_PREVENTS_SAME_AND_CROSS_PROCESS_RENAME' -or + $proof.MainExecutableWasLaunched -ne $false -or + $proof.OriginalOwnerHostTouched -ne $false -or + $proof.ActualUACInvoked -ne $false -or + $proof.ProtectedProgramFilesChanged -ne $false -or + $proof.SecurityFinalAccepted -ne $false -or + @($proof.Checks).Count -ne 19){ + throw 'R329_HOSTED_AB_TYPED_PINNED_MAIN_RECEIPT_INVALID' + } + 'R329_EXACT_GUEST_PINNED_MAIN_PARENT_RENAME_PASS=19/19_NO_UAC' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -986,6 +1013,7 @@ jobs: ${{ runner.temp }}/R315_${{ matrix.host }}_${{ github.run_id }}/R315_TASK_COLD_RESTORE_RECEIPT.json ${{ runner.temp }}/R316_${{ matrix.host }}_${{ github.run_id }}/R316_TRUSTED_APPROOT_RECEIPT.json ${{ runner.temp }}/R321_${{ matrix.host }}_${{ github.run_id }}/R321_PINNED_MAIN_PRELAUNCH_RECEIPT.json + ${{ runner.temp }}/R329_${{ matrix.host }}_${{ github.run_id }}/R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json ${{ runner.temp }}/R317_${{ matrix.host }}_${{ github.run_id }}/R317_ENCODED_CLEANUP_RECEIPT.json ${{ runner.temp }}/R318_${{ matrix.host }}_${{ github.run_id }}/R318_INPROCESS_SHORTCUT_RECEIPT.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 200412d9..a1a53851 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,11 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R328-HelperLogBoundary-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R328 Sensor Helper user log/result reparse boundary checks failed.' } + - name: R329 pinned Main blocks user-profile ancestor rename (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R329-PinnedMainAncestorRename-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R329 same-process and cross-process pinned Main ancestor rename regression failed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-R329-PinnedMainAncestorRename-Native.ps1 b/build/Test-R329-PinnedMainAncestorRename-Native.ps1 new file mode 100644 index 00000000..0c5ca37d --- /dev/null +++ b/build/Test-R329-PinnedMainAncestorRename-Native.ps1 @@ -0,0 +1,209 @@ +[CmdletBinding()] +param([string]$SetupPath='', [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R329_REQUIRES_COMPILED_SETUP_NETFRAMEWORK_ON_WINDOWS' +} +$assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SetupPath).Path) +$type=$assembly.GetType('SetupProgram',$true) +$pinMethod=$type.GetMethod('OpenPinnedVerifiedMainForLaunch',[Reflection.BindingFlags]'NonPublic,Static') +$psiMethod=$type.GetMethod('BuildPinnedMainProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') +if(!$pinMethod -or !$psiMethod){throw 'R329_EXPECTED_REAL_PINNED_PROCESS_METHODS_MISSING'} +$sourceStream=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$sourceStream){throw 'R329_SOURCE_EMBEDDED_RESOURCE_MISSING'} +$sourceReader=[IO.StreamReader]::new($sourceStream) +try{$embedded=$sourceReader.ReadToEnd()}finally{$sourceReader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$norm={param($s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $embedded) -cne (& $norm $source)){throw 'R329_EMBEDDED_SOURCE_DIFFERS_FROM_REAL_WORKTREE'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +Pass 'COMPILED_SETUP_EXACT_EMBEDDED_SOURCE' +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') +if(!$resource){throw 'R329_EMBEDDED_MAIN_RESOURCE_MISSING'} +$memory=[IO.MemoryStream]::new() +try{$resource.CopyTo($memory)}finally{$resource.Dispose()} +$bytes=$memory.ToArray() +$memory.Dispose() +$sha=[Security.Cryptography.SHA256]::Create() +try{$expected=([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','')} +finally{$sha.Dispose()} +if($bytes.Length -lt 1024 -or $expected.Length -ne 64){throw 'R329_BAD_IMMUTABLE_MAIN_RESOURCE'} +Pass 'EMBEDDED_MAIN_SHA256_PIN_AVAILABLE' + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r329_real_setup_main_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$profile=Join-Path $base 'user profile & literal $dollar' +$appData=Join-Path $profile 'AppData' +$local=Join-Path $appData 'Local' +$appRoot=Join-Path $local 'TaskbarMonitorEnhanced' +[IO.Directory]::CreateDirectory($appRoot)|Out-Null +$main=Join-Path $appRoot 'TaskbarMonitorEnhanced.exe' +[IO.File]::WriteAllBytes($main,$bytes) +$external=Join-Path $base 'outside-app' +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'external-canary.txt' +[IO.File]::WriteAllText($canary,'NO_EXTERNAL_MODIFICATION') +$actor=Join-Path $base 'move-test-actor.ps1' +$actorSource=@' +param([Parameter(Mandatory=$true)][string]$PathBase64) +$ErrorActionPreference='Stop' +$target=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($PathBase64)) +$moved=$target+'_actor_moved' +try { + [IO.Directory]::Move($target,$moved) + [IO.Directory]::Move($moved,$target) + Write-Output 'ACTOR_RENAME_ALLOWED_AND_RESTORED' + exit 61 +}catch [IO.IOException]{ + Write-Output 'ACTOR_RENAME_BLOCKED_IO' + exit 0 +}catch [UnauthorizedAccessException]{ + Write-Output 'ACTOR_RENAME_BLOCKED_ACCESS' + exit 0 +}catch{ + Write-Output ('ACTOR_UNEXPECTED_ERROR_'+$_.Exception.GetType().Name) + exit 70 +} +'@ +[IO.File]::WriteAllText($actor,$actorSource,[Text.UTF8Encoding]::new($false)) +$win=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +function Try-Rename([string]$path){ + $moved=$path+'_native_moved' + $ok=$false + try{[IO.Directory]::Move($path,$moved);$ok=$true} + catch [IO.IOException]{}catch [UnauthorizedAccessException]{} + if($ok){ + [IO.Directory]::Move($moved,$path) + return $true + } + return $false +} +function Invoke-SeparateActor([string]$target) { + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($target)) + $out=@(& $win -NoProfile -NonInteractive -File $actor -PathBase64 $encoded 2>&1) + $code=$LASTEXITCODE + if($code -notin @(0,61)){ + throw ('R329_CHILD_ACTOR_ERROR_EXIT_'+$code+' '+($out -join ' ')) + } + if($code -eq 61 -and @($out|Where-Object {$_ -eq 'ACTOR_RENAME_ALLOWED_AND_RESTORED'}).Count -ne 1){ + throw 'R329_CHILD_RENAME_ALLOWED_WITHOUT_RESTORE_EVIDENCE' + } + return $code +} +$pinned=$null +try { + foreach($pair in @(@('APPROOT',$appRoot),@('LOCAL',$local),@('APPDATA',$appData),@('PROFILE',$profile))){ + $success=$false + for($try=0;$try -lt 5 -and !$success;$try++){ + $success=Try-Rename $pair[1] + if(!$success){Start-Sleep -Milliseconds 70} + } + if(!$success){throw ('R329_UNPINNED_DIRECTORY_NOT_RENAMEABLE_'+$pair[0])} + Pass ('BASELINE_RENAME_ALLOWED_'+$pair[0]) + } + if((Invoke-SeparateActor $local) -ne 61){throw 'R329_CROSS_PROCESS_RENAME_WAS_NOT_POSSIBLE_WITHOUT_PIN'} + Pass 'BASELINE_SEPARATE_PROCESS_ANCESTOR_RENAME_ALLOWED' + $args=New-Object 'object[]' 3 + $args[0]=[string]$main + $args[1]=[string]$appRoot + $args[2]=[string]$expected + $pinned=[IO.FileStream]$pinMethod.Invoke($null,$args) + if(!$pinned.CanRead -or + ![string]::Equals([IO.Path]::GetFullPath($pinned.Name), + [IO.Path]::GetFullPath($main),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R329_REAL_SETUP_DID_NOT_PIN_EXPECTED_MAIN_FILE' + } + Pass 'PRODUCTION_METHOD_SHA_PINS_REAL_EMBEDDED_MAIN' + foreach($pair in @(@('APPROOT',$appRoot),@('LOCAL',$local),@('APPDATA',$appData),@('PROFILE',$profile))){ + if(Try-Rename $pair[1]){throw ('R329_ANCESTOR_RENAME_SUCCEEDED_WHILE_PINNED_'+$pair[0])} + Pass ('PINNED_MAIN_BLOCKS_RENAME_'+$pair[0]) + } + if((Invoke-SeparateActor $local) -ne 0){ + throw 'R329_SEPARATE_PROCESS_CAN_RENAME_PINNED_MAIN_ANCESTOR' + } + Pass 'PINNED_MAIN_BLOCKS_SEPARATE_PROCESS_LOCAL_RENAME' + $replacement=$false + try{$fs=[IO.FileStream]::new($main,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::ReadWrite);$fs.Dispose()} + catch [IO.IOException]{$replacement=$true} + if(!$replacement){throw 'R329_SECOND_FILE_WRITER_NOT_BLOCKED'} + Pass 'PINNED_MAIN_BLOCKS_SAME_NAME_WRITER' + $args2=New-Object 'object[]' 2 + $args2[0]=$pinned.PSObject.BaseObject + $args2[1]=[string]$appRoot + $processInfo=$psiMethod.Invoke($null,$args2) + if($processInfo.UseShellExecute -ne $false -or $processInfo.Arguments -cne '' -or + $processInfo.FileName -cne [IO.Path]::GetFullPath($main)){ + throw 'R329_PINNED_PROCESSSTARTINFO_IDENTITY_CHANGED' + } + Pass 'PROCESS_STARTINFO_IS_DIRECT_NO_SHELL_NO_ARGS' + $pinned.Position=0 + $checkSha=[Security.Cryptography.SHA256]::Create() + try {$actual=([BitConverter]::ToString($checkSha.ComputeHash($pinned))).Replace('-','')} + finally {$checkSha.Dispose()} + if($actual -cne $expected){throw 'R329_PINNED_FILE_BYTES_CHANGED'} + Pass 'OPEN_FILE_BYTES_STILL_EQUAL_IMMUTABLE_PE_HASH' + if([IO.File]::ReadAllText($canary) -cne 'NO_EXTERNAL_MODIFICATION'){ + throw 'R329_EXTERNAL_SENTINEL_CHANGED' + } + Pass 'EXTERNAL_CANARY_UNMODIFIED' +}finally{if($pinned){$pinned.Dispose()}} +try { + if(! (Try-Rename $local)){throw 'R329_LOCAL_RENAME_REMAINS_BLOCKED_AFTER_DISPOSAL'} + Pass 'LOCAL_RENAME_RESTORED_AFTER_PIN_RELEASE' + $actualSha=(Get-FileHash -LiteralPath $main -Algorithm SHA256).Hash + if($actualSha -cne $expected){throw 'R329_SANDBOX_FILE_ON_DISK_CHANGED'} + Pass 'ON_DISK_SHA_STILL_EQUALS_EMBEDDED_MAIN' + if($checks.Count -ne 19){throw ('R329_ACCEPTANCE_CHECK_COUNT_'+$checks.Count)} + if($HostLabel -cne 'LOCAL'){ + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R329_HOSTED_EVIDENCE_NOT_ON_DISPOSABLE_GITHUB_GUEST' + } + $dir=Join-Path $env:RUNNER_TEMP ('R329_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if([IO.Directory]::Exists($dir)){throw 'R329_HOSTED_WITNESS_DIRECTORY_ALREADY_PRESENT'} + [IO.Directory]::CreateDirectory($dir)|Out-Null + $receipt=[ordered]@{ + Schema=1 + Revision='R329' + Status='PASS_PRODUCTION_PINNED_MAIN_PREVENTS_SAME_AND_CROSS_PROCESS_RENAME' + Host=$HostLabel + SourceCommit=$env:GITHUB_SHA + SetupSHA256=(Get-FileHash -LiteralPath $SetupPath -Algorithm SHA256).Hash + MainSHA256=$expected + Checks=@($checks.ToArray()) + MainExecutableWasLaunched=$false + OriginalOwnerHostTouched=$false + ActualUACInvoked=$false + ProtectedProgramFilesChanged=$false + SecurityFinalAccepted=$false + Scope='Windows NTFS normal rename in disposable user-owned directory; no POSIX semantics or UAC acceptance' + } + $receiptPath=Join-Path $dir 'R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json' + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($receipt|ConvertTo-Json -Depth 5)) + $output=[IO.FileStream]::new($receiptPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$output.Write($bytes,0,$bytes.Length);$output.Flush($true)} + finally{$output.Dispose()} + Write-Output ('R329_TYPED_HOSTED_RECEIPT_SHA256='+(Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash) + } + Write-Output ('R329_NATIVE_MAIN_ANCESTOR_RENAME_GUARD_PASS='+$checks.Count+'/19') +}finally{ + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r329_real_setup_main_',[StringComparison]::Ordinal)){ + $reparse=@(Get-ChildItem -LiteralPath $base -Recurse -Force | + Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($reparse.Count -ne 0){throw 'R329_UNEXPECTED_REPARSE_REFUSE_SANDBOX_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} From 5a140d9253204453e0563e463b84953118fbc0f0 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 13:36:30 +0330 Subject: [PATCH 086/135] test(ci): isolate R329 pinned Main A/B from unrelated R302 recovery timing --- ...it-r286-optional-audio-native-recovery.yml | 28 ------ .../audit-r329-pinned-main-ancestor.yml | 90 +++++++++++++++++++ 2 files changed, 90 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/audit-r329-pinned-main-ancestor.yml diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index 5aaf721d..d853c837 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -949,33 +949,6 @@ jobs: $r.GuestMainAndUserSettingsPreserved -ne $true -or @($r.Checks).Count -ne 14){throw 'R321_TYPED_PINNED_MAIN_PROOF_FAILED'} 'R321_PINNED_IMMUTABLE_MAIN_HANDLE_BOUNDED=PASS_NOT_SECURITY_FINAL' - - name: R329 real Setup SHA-pinned Main blocks ancestor rename on guest ${{ matrix.host }} - shell: powershell - run: | - $ErrorActionPreference='Stop' - $test=(Resolve-Path build/Test-R329-PinnedMainAncestorRename-Native.ps1).Path - $tok=$null;$errors=$null - [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$errors) - if(@($errors).Count){throw 'R329_NATIVE_SCRIPT_PS51_PARSE_FAILED'} - $setup=(Resolve-Path _r284_private_candidate/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe).Path - $native=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' - & $native -NoProfile -NonInteractive -File $test -SetupPath $setup -HostLabel '${{ matrix.host }}' - if($LASTEXITCODE -ne 0){throw ('R329_NATIVE_PINNED_ANCESTOR_RENAME_FAILED_'+$LASTEXITCODE)} - $receipt=Join-Path $env:RUNNER_TEMP ('R329_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json') - if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R329_NATIVE_TYPED_RECEIPT_MISSING'} - $proof=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json - if($proof.Schema -ne 1 -or $proof.Revision -cne 'R329' -or - $proof.Host -cne '${{ matrix.host }}' -or $proof.SourceCommit -cne $env:GITHUB_SHA -or - $proof.Status -cne 'PASS_PRODUCTION_PINNED_MAIN_PREVENTS_SAME_AND_CROSS_PROCESS_RENAME' -or - $proof.MainExecutableWasLaunched -ne $false -or - $proof.OriginalOwnerHostTouched -ne $false -or - $proof.ActualUACInvoked -ne $false -or - $proof.ProtectedProgramFilesChanged -ne $false -or - $proof.SecurityFinalAccepted -ne $false -or - @($proof.Checks).Count -ne 19){ - throw 'R329_HOSTED_AB_TYPED_PINNED_MAIN_RECEIPT_INVALID' - } - 'R329_EXACT_GUEST_PINNED_MAIN_PARENT_RENAME_PASS=19/19_NO_UAC' - name: Preserve bounded guest evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -1013,7 +986,6 @@ jobs: ${{ runner.temp }}/R315_${{ matrix.host }}_${{ github.run_id }}/R315_TASK_COLD_RESTORE_RECEIPT.json ${{ runner.temp }}/R316_${{ matrix.host }}_${{ github.run_id }}/R316_TRUSTED_APPROOT_RECEIPT.json ${{ runner.temp }}/R321_${{ matrix.host }}_${{ github.run_id }}/R321_PINNED_MAIN_PRELAUNCH_RECEIPT.json - ${{ runner.temp }}/R329_${{ matrix.host }}_${{ github.run_id }}/R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json ${{ runner.temp }}/R317_${{ matrix.host }}_${{ github.run_id }}/R317_ENCODED_CLEANUP_RECEIPT.json ${{ runner.temp }}/R318_${{ matrix.host }}_${{ github.run_id }}/R318_INPROCESS_SHORTCUT_RECEIPT.json diff --git a/.github/workflows/audit-r329-pinned-main-ancestor.yml b/.github/workflows/audit-r329-pinned-main-ancestor.yml new file mode 100644 index 00000000..fdda0bef --- /dev/null +++ b/.github/workflows/audit-r329-pinned-main-ancestor.yml @@ -0,0 +1,90 @@ +name: R329 SHA-pinned Main ancestor rename native A/B + +on: + push: + branches: [audit/r329-main-ancestor-native-temp] + workflow_dispatch: + +permissions: + contents: read + +jobs: + pinned-main-ancestor: + name: Pinned Main ancestor rename negative on Windows ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 18 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact review-only source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Verify isolated host and exact commit + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match 'EMAD-PC' -or + (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R329_REFUSE_NON_DISPOSABLE_OR_UNPINNED_SOURCE' + } + 'R329_DISPOSABLE_SOURCE_PRECONDITION=PASS' + - name: Set up pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build private reproducible candidate on guest (no owner installation) + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R329_HOSTED_PRIVATE_BUILD_FAILED'} + if(!(Test-Path 'build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe')){ + throw 'R329_EXPECTED_PRIVATE_PE_NOT_BUILT' + } + 'R329_PRIVATE_NOT_FOR_RELEASE_BUILD=PASS' + - name: Prove exact pinned Main blocks cross-process and ancestor rename + shell: powershell + run: | + $ErrorActionPreference='Stop' + $test=(Resolve-Path build/Test-R329-PinnedMainAncestorRename-Native.ps1).Path + $tok=$null;$errors=$null + [void][Management.Automation.Language.Parser]::ParseFile($test,[ref]$tok,[ref]$errors) + if(@($errors).Count){throw 'R329_NATIVE_PS51_SCRIPT_PARSER_INVALID'} + $setup=(Resolve-Path build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe).Path + $win=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + & $win -NoProfile -NonInteractive -File $test -SetupPath $setup -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw ('R329_SCOPED_NATIVE_TEST_FAILED_EXIT_'+$LASTEXITCODE)} + $receipt=Join-Path $env:RUNNER_TEMP ('R329_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R329_HOSTED_RAW_RECEIPT_NOT_CREATED'} + $r=Get-Content -LiteralPath $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R329' -or + $r.Host -cne '${{ matrix.host }}' -or $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.Status -cne 'PASS_PRODUCTION_PINNED_MAIN_PREVENTS_SAME_AND_CROSS_PROCESS_RENAME' -or + @($r.Checks).Count -ne 19 -or $r.ActualUACInvoked -ne $false -or + $r.MainExecutableWasLaunched -ne $false -or + $r.OriginalOwnerHostTouched -ne $false -or + $r.ProtectedProgramFilesChanged -ne $false -or $r.SecurityFinalAccepted -ne $false -or + $r.SetupSHA256 -cne (Get-FileHash $setup -Algorithm SHA256).Hash -or + $r.MainSHA256 -cne (Get-FileHash 'build/_out/App/TaskbarMonitorEnhanced.exe' -Algorithm SHA256).Hash){ + throw 'R329_HOSTED_RECEIPT_IDENTITY_AND_SCOPE_FAILED' + } + 'R329_MAIN_PINNED_ACROSS_PARENT_RENAME_HOSTED=19/19_NO_UAC' + - name: Store bounded SHA-verifiable guest receipt only + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R329-ancestor-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: warn + retention-days: 21 + path: | + ${{ runner.temp }}/R329_${{ matrix.host }}_${{ github.run_id }}/R329_PINNED_MAIN_ANCESTOR_RENAME_RECEIPT.json From e21168b7c1a9949043403683616145c89a05f546 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 14:04:09 +0330 Subject: [PATCH 087/135] test(recovery): add native stage deletion observer for exact Setup kill (R330) --- ...it-r286-optional-audio-native-recovery.yml | 5 +- .github/workflows/ci.yml | 5 + ...02-ActualSetupStageCleanupCrash-Native.ps1 | 134 ++++++++++++++---- ...-R330-NativeStageDeleteObserver-Native.ps1 | 99 +++++++++++++ 4 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 build/Test-R330-NativeStageDeleteObserver-Native.ps1 diff --git a/.github/workflows/audit-r286-optional-audio-native-recovery.yml b/.github/workflows/audit-r286-optional-audio-native-recovery.yml index d853c837..d504a6a9 100644 --- a/.github/workflows/audit-r286-optional-audio-native-recovery.yml +++ b/.github/workflows/audit-r286-optional-audio-native-recovery.yml @@ -421,7 +421,10 @@ jobs: $r.NoStageOrRetiredResidue -ne $true -or $r.ActualSetupPid -le 0 -or $r.SecondSetupPid -le 0 -or $r.ActualSetupPid -eq $r.SecondSetupPid -or - $r.JournalFilesAfterKill -lt 1 -or + $r.DeletedStageFileTrigger -cnotmatch '\.(exe|cs|md|json|config|ico)$' -or + $r.NativeWatcherOrFileSystemWatcherOrigin -cnotin @( + 'NATIVE_KNOWN_STAGE_LEAF_DELETION','FILESYSTEMWATCHER_KNOWN_STAGE_LEAF_DELETION') -or + ($r.FileDeletionNotifications + $r.NativeKnownDeletedLeafObservations) -lt 1 -or $r.JournalFilesAfterKill -lt 1 -or @($r.Tests).Count -lt 5){throw 'R302_NATIVE_ACCEPTANCE_FAILED'} 'R302_EXACT_NATIVE_SETUP_PROCESS_KILL_AND_RECOVERY_PASS' - name: R303 reject foreign protected broker owners and permit same-user crash repair diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1a53851..031cc0bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,11 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R329-PinnedMainAncestorRename-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R329 same-process and cross-process pinned Main ancestor rename regression failed.' } + - name: R330 native synchronous directory signal fallback only kills exact owned worker (6 tests) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R330-NativeStageDeleteObserver-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R330 synthetic exact-PID NativeKnownStageDelete observer regression failed.' } - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 diff --git a/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 index b1b6bd81..875edc28 100644 --- a/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 +++ b/build/Test-R302-ActualSetupStageCleanupCrash-Native.ps1 @@ -152,53 +152,125 @@ public static class R320SuspendedSetupLauncher { } } public sealed class R302RealSetupDeleteWatcher : IDisposable { - public readonly ManualResetEventSlim TerminatedEvent = new ManualResetEventSlim(false); + public readonly ManualResetEventSlim TerminatedEvent=new ManualResetEventSlim(false); private FileSystemWatcher oldWatcher, newWatcher; private Process child; - private int once, notifications; - public string TriggerFile = ""; - public string Failure = ""; - public bool Killed = false; + private int once, notifications, nativeObservations, disposed; + private IntPtr nativeNotify=IntPtr.Zero; + private Thread nativeObserver; + private string[] ownedStageFiles; + public string TriggerFile=""; + public string TriggerOrigin=""; + public string Failure=""; + public bool Killed=false; public int Notifications { get { return notifications; } } - public void Arm(string oldPath, string newPath) { - oldWatcher = Make(oldPath); - newWatcher = Make(newPath); + public int NativeKnownDeleteObservations { get { return nativeObservations; } } + [DllImport("kernel32.dll",EntryPoint="FindFirstChangeNotificationW",CharSet=CharSet.Unicode,SetLastError=true)] + private static extern IntPtr FindFirstChangeNotification(string path,bool subtree,uint filter); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool FindNextChangeNotification(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern bool FindCloseChangeNotification(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] + private static extern uint WaitForSingleObject(IntPtr handle,uint milliseconds); + private static bool KnownLeaf(string path){ + string ext=Path.GetExtension(path).ToLowerInvariant(); + return ext==".exe" || ext==".cs" || ext==".md" || + ext==".json" || ext==".config" || ext==".ico"; + } + public void Arm(string oldPath,string newPath){ + if(!Directory.Exists(oldPath) || !Directory.Exists(newPath) || + !String.Equals(Path.GetDirectoryName(Path.GetFullPath(oldPath)), + Path.GetDirectoryName(Path.GetFullPath(newPath)), + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("R330_NATIVE_WATCH_PATHS_NOT_SIBLINGS"); + var originals=new System.Collections.Generic.List(); + foreach(string directory in new string[]{oldPath,newPath}) + foreach(string file in Directory.GetFiles(directory,"*",SearchOption.AllDirectories)) + if(KnownLeaf(file)) originals.Add(Path.GetFullPath(file)); + if(originals.Count<20 || originals.Count>100) + throw new InvalidOperationException("R330_STAGE_LEAF_COUNT_OUT_OF_BOUNDS"); + ownedStageFiles=originals.ToArray(); + oldWatcher=CreateManagedObserver(oldPath); + newWatcher=CreateManagedObserver(newPath); + // The Win32 directory notification is armed before resuming the original + // test-only Setup. Managed FileSystemWatcher callback delivery can be late. + string parent=Path.GetDirectoryName(Path.GetFullPath(oldPath)); + nativeNotify=FindFirstChangeNotification(parent,true,0x00000001u|0x00000002u); + if(nativeNotify==IntPtr.Zero || nativeNotify==new IntPtr(-1)) + throw new Win32Exception(Marshal.GetLastWin32Error(),"R330_NATIVE_DIR_NOTIFY_ARM_FAILED"); oldWatcher.EnableRaisingEvents=true; newWatcher.EnableRaisingEvents=true; } - private FileSystemWatcher Make(string path) { - FileSystemWatcher watcher = new FileSystemWatcher(path); + private FileSystemWatcher CreateManagedObserver(string path){ + var watcher=new FileSystemWatcher(path); watcher.IncludeSubdirectories=true; watcher.Filter="*"; watcher.NotifyFilter=NotifyFilters.FileName | NotifyFilters.DirectoryName; watcher.InternalBufferSize=32768; - watcher.Deleted += OnDeleted; + watcher.Deleted+=OnDeleted; return watcher; } - public void Bind(Process p) { - Interlocked.Exchange(ref child, p); + public void Bind(Process p){ + if(p==null || ownedStageFiles==null || nativeNotify==IntPtr.Zero) + throw new InvalidOperationException("R330_NATIVE_OBSERVER_NOT_ARMED"); + Interlocked.Exchange(ref child,p); + nativeObserver=new Thread(NativeObserveKnownStageDeletion); + nativeObserver.IsBackground=true; + nativeObserver.Priority=ThreadPriority.Highest; + nativeObserver.Start(); + } + private void NativeObserveKnownStageDeletion(){ + while(Interlocked.CompareExchange(ref disposed,0,0)==0 && + Interlocked.CompareExchange(ref once,0,0)==0){ + uint result=WaitForSingleObject(nativeNotify,200); + if(result==0x00000102u)continue; // WAIT_TIMEOUT + if(result!=0){Failure="NATIVE_NOTIFY_WAIT_FAILED_"+result;break;} + foreach(string file in ownedStageFiles){ + if(File.Exists(file))continue; + Interlocked.Increment(ref nativeObservations); + AttemptTerminateExactChild(file,"NATIVE_KNOWN_STAGE_LEAF_DELETION"); + return; + } + if(!FindNextChangeNotification(nativeNotify)){ + Failure="NATIVE_NOTIFY_REARM_FAILED_"+Marshal.GetLastWin32Error(); + break; + } + } } - private void OnDeleted(object sender, FileSystemEventArgs e) { + private void OnDeleted(object sender,FileSystemEventArgs e){ Interlocked.Increment(ref notifications); - string path=e.FullPath; - string extension=Path.GetExtension(path).ToLowerInvariant(); - // A known leaf deletion, not a directory deletion or metadata preflight. - if(extension!=".exe" && extension!=".cs" && extension!=".md" && - extension!=".json" && extension!=".config" && extension!=".ico") return; - if(Interlocked.CompareExchange(ref once,1,0)!=0) return; - TriggerFile=Path.GetFileName(path); - try { - Process process=child; - if(process==null) throw new Exception("PROCESS_NOT_BOUND"); - process.Refresh(); - if(process.HasExited) throw new Exception("SETUP_ALREADY_EXITED"); - process.Kill(); - Killed=process.WaitForExit(10000) && process.HasExited && R320SuspendedSetupLauncher.NonzeroNativeExitCode(process); - if(!Killed) Failure="SETUP_KILL_NOT_CONFIRMED"; - }catch(Exception error){Failure=error.GetType().Name+":"+error.Message;} + if(!KnownLeaf(e.FullPath))return; + bool owned=false; + foreach(string original in ownedStageFiles) + if(String.Equals(original,e.FullPath,StringComparison.OrdinalIgnoreCase)){ + owned=true;break; + } + if(owned)AttemptTerminateExactChild(e.FullPath,"FILESYSTEMWATCHER_KNOWN_STAGE_LEAF_DELETION"); + } + private void AttemptTerminateExactChild(string knownStageFile,string mechanism){ + if(Interlocked.CompareExchange(ref once,1,0)!=0)return; + TriggerFile=Path.GetFileName(knownStageFile); + TriggerOrigin=mechanism; + try{ + Process exact=child; + if(exact==null)throw new Exception("PROCESS_NOT_BOUND"); + exact.Refresh(); + if(exact.HasExited)throw new Exception("SETUP_ALREADY_EXITED"); + exact.Kill(); + Killed=exact.WaitForExit(10000) && exact.HasExited && + R320SuspendedSetupLauncher.NonzeroNativeExitCode(exact); + if(!Killed)Failure="EXACT_CHILD_TERMINATION_UNCONFIRMED"; + }catch(Exception err){Failure=err.GetType().Name+":"+err.Message;} finally{TerminatedEvent.Set();} } public void Dispose(){ + Interlocked.Exchange(ref disposed,1); + if(nativeObserver!=null && !nativeObserver.Join(2000)) + throw new InvalidOperationException("R330_NATIVE_OBSERVER_THREAD_NOT_STOPPED"); + if(nativeNotify!=IntPtr.Zero && nativeNotify!=new IntPtr(-1)){ + FindCloseChangeNotification(nativeNotify);nativeNotify=IntPtr.Zero; + } if(oldWatcher!=null){oldWatcher.EnableRaisingEvents=false;oldWatcher.Dispose();} if(newWatcher!=null){newWatcher.EnableRaisingEvents=false;newWatcher.Dispose();} } @@ -327,6 +399,8 @@ try { ActualSetupPid=$realSetupPid;ActualSetupProcessTerminated=$true DeletedStageFileTrigger=$watcher.TriggerFile FileDeletionNotifications=$watcher.Notifications + NativeKnownDeletedLeafObservations=$watcher.NativeKnownDeleteObservations + NativeWatcherOrFileSystemWatcherOrigin=$watcher.TriggerOrigin JournalFilesAfterKill=($activeRemaining+$retiredRemaining) RecoveredBySecondActualSetupProcess=$true;SecondSetupPid=$retryPid MainSetupConfigStateAndRegistryPreserved=$true diff --git a/build/Test-R330-NativeStageDeleteObserver-Native.ps1 b/build/Test-R330-NativeStageDeleteObserver-Native.ps1 new file mode 100644 index 00000000..b7313661 --- /dev/null +++ b/build/Test-R330-NativeStageDeleteObserver-Native.ps1 @@ -0,0 +1,99 @@ +[CmdletBinding()] +param() +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop'){throw 'R330_REQUIRES_NATIVE_WINDOWS_POWERSHELL_DOTNET_FRAMEWORK'} +$p=Join-Path $PSScriptRoot 'Test-R302-ActualSetupStageCleanupCrash-Native.ps1' +$s=[IO.File]::ReadAllText($p) +$hits=[regex]::Matches($s,'(?s)\$sourceCode=@''\r?\n(.*?)\r?\n''@') +if($hits.Count -ne 1){throw 'R330_R302_CSHARP_CLASS_BLOCK_MISSING'} +Add-Type -TypeDefinition $hits[0].Groups[1].Value -ErrorAction Stop +$method=[R302RealSetupDeleteWatcher].GetMethod('Bind') +if(!$method){throw 'R330_NATIVE_OBSERVER_NOT_IN_PRODUCTION_TEST_SCRIPT'} +$exact=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +if(!(Test-Path $exact)){throw 'R330_EXACT_WINDOWS_POWERSHELL_NOT_FOUND'} +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r330_native_watcher_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$counts=0 +function Run-Scenario([string]$variant,[int]$index){ + $root=Join-Path $base ($variant+'_'+$index) + $old=Join-Path $root 'old' + $new=Join-Path $root 'new' + [IO.Directory]::CreateDirectory($old)|Out-Null + [IO.Directory]::CreateDirectory($new)|Out-Null + $known=@() + for($i=0;$i -lt 12;$i++){ + foreach($folder in @($old,$new)){ + $file=Join-Path $folder ('owned_{0:D2}.json' -f $i) + [IO.File]::WriteAllText($file,'TEST_ONLY_KNOWN_STAGE_LEAF') + $known+=,$file + } + } + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$exact + $psi.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Seconds 60"' + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $process=$null;$watcher=$null + try { + $process=[Diagnostics.Process]::Start($psi) + if(!$process -or $process.Id -le 0){throw 'R330_SYNTHETIC_CHILD_NOT_STARTED'} + $watcher=[R302RealSetupDeleteWatcher]::new() + $watcher.Arm($old,$new) + if($variant -ceq 'NATIVE_ONLY'){ + $flags=[Reflection.BindingFlags]'Instance,NonPublic' + foreach($member in @('oldWatcher','newWatcher')){ + $f=[R302RealSetupDeleteWatcher].GetField($member,$flags) + if(!$f){throw 'R330_NATIVE_ONLY_NO_MANAGED_WATCHER_REFLECTION'} + $managed=[IO.FileSystemWatcher]$f.GetValue($watcher) + $managed.EnableRaisingEvents=$false + } + } + $watcher.Bind($process) + $noise=Join-Path $old 'noise.tmp' + [IO.File]::WriteAllText($noise,'UNRELATED_CREATED_FILE') + Start-Sleep -Milliseconds 175 + $process.Refresh() + if($process.HasExited -or $watcher.Killed -or $watcher.TerminatedEvent.IsSet){ + throw 'R330_NOISE_TRIPPED_A_KILL_OR_PROCESS_ALREADY_EXITED' + } + [IO.File]::Delete($known[0]) + if(!$watcher.TerminatedEvent.Wait(10000) -or !$watcher.Killed -or + $watcher.TriggerFile -cne [IO.Path]::GetFileName($known[0])){ + throw ('R330_OWNED_STAGE_LEAF_DID_NOT_KILL_EXACT_PROCESS variant='+$variant+ + ' origin='+$watcher.TriggerOrigin+' reason='+$watcher.Failure) + } + $process.Refresh() + if(!$process.HasExited -or $process.ExitCode -eq 0){throw 'R330_KILL_EXIT_NOT_NONZERO'} + if($variant -ceq 'NATIVE_ONLY' -and + ($watcher.TriggerOrigin -cne 'NATIVE_KNOWN_STAGE_LEAF_DELETION' -or + $watcher.NativeKnownDeleteObservations -lt 1)){ + throw 'R330_NATIVE_FALLBACK_NOT_ACTUALLY_TRIGGERED' + } + $script:counts++ + Write-Output ('R330_SYNTHETIC_'+$variant+'_'+$index+'_PASS pid='+$process.Id+ + ' origin='+$watcher.TriggerOrigin+ + ' nativeEvents='+$watcher.NativeKnownDeleteObservations+ + ' managedEvents='+$watcher.Notifications) + }finally{ + if($watcher){$watcher.Dispose()} + if($process){ + try{$process.Refresh();if(!$process.HasExited){$process.Kill();[void]$process.WaitForExit(10000)}} + finally{$process.Dispose()} + } + } +} +try { + foreach($mode in @('NATIVE_ONLY','NATIVE_AND_MANAGED')){ + foreach($i in 1..3){Run-Scenario $mode $i} + } + if($counts -ne 6){throw ('R330_SYNTHETIC_SCENARIO_COUNT_'+$counts)} + Write-Output 'R330_NATIVE_WATCHER_TEST_SIX_SYNTHETIC_PROCESS_KILLS=PASS' +}finally{ + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r330_native_watcher_',[StringComparison]::Ordinal)){ + $links=@(Get-ChildItem $base -Recurse -Force|Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($links.Count -ne 0){throw 'R330_UNEXPECTED_SANDBOX_REPARSE_NO_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} From 71d3dbd75da14edd3dbb6ff921fd25fda313dc99 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 17:20:46 +0330 Subject: [PATCH 088/135] fix(setup): remove legacy hosted diagnostic file sink (R331) --- .github/workflows/ci.yml | 46 ++++++ ...st-R331-NoHostedDiagnosticWrite-Native.ps1 | 150 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 34 ---- 3 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 031cc0bd..97e4ad7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,12 @@ jobs: run: | & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R330-NativeStageDeleteObserver-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R330 synthetic exact-PID NativeKnownStageDelete observer regression failed.' } + - name: R331 no runner-controlled diagnostic writes in shipped Setup PE (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R331 fail-closed Setup minus hosted diagnostic sink native regression failed.' } + - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 @@ -297,6 +303,46 @@ jobs: !$r.UnownedLinkRemovedAfterWitness -or $r.NativeReflectionChecksPassed -ne 8){ throw 'R324_TYPED_RECEIPT_CONTRACT_FAILED' } + - name: R331 real Setup refuses environment-spoofed diagnostic writes on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R331_REFUSE_NON_DISPOSABLE_HOST_OR_SOURCE' + } + $setups=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $manifests=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setups.Count -ne 1 -or $manifests.Count -ne 1){throw 'R331_EXACT_PRIVATE_BIN_NOT_UNIQUE'} + $buildManifest=Get-Content $manifests[0].FullName -Raw|ConvertFrom-Json + $setupEntry=@($buildManifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($setupEntry.Count -ne 1 -or + (Get-FileHash $setups[0].FullName -Algorithm SHA256).Hash -cne $setupEntry[0].SHA256){ + throw 'R331_HOSTED_SETUP_BINARY_NOT_MANIFEST_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 -SetupPath $setups[0].FullName -HostedFresh -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R331_REAL_NEGATIVE_GUEST_TEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R331_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R331_HOSTED_DIAGNOSTIC_REMOVAL_RECEIPT.json') + if(!(Test-Path $receipt -PathType Leaf)){throw 'R331_HOSTED_TYPED_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R331' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SetupSHA256 -cne $setupEntry[0].SHA256 -or + $r.Status -cne 'PASS_FRESH_REAL_SETUP_FAIL_CLOSED_WITHOUT_HOSTED_DIAGNOSTIC_WRITE' -or + $r.RealSetupExecuted -ne $true -or $r.SetupExit -ne 34 -or + $r.DiagnosticOutputAbsent -ne $true -or $r.CanaryPreserved -ne $true -or + $r.InstalledAppUntouched -ne $true -or $r.OwnerMachineTouched -ne $false -or + $r.MainWasNotLaunched -ne $true -or $r.ElevatedUACNotInvoked -ne $true -or + $r.SecurityFinalAccepted -ne $false -or @($r.Checks).Count -ne 12){ + throw 'R331_HOSTED_NEGATIVE_RECEIPT_IDENTITY_FAILED' + } + - name: Preserve bounded raw R331 guest diagnostic-negative receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R331-diag-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R331_${{ matrix.host }}_${{ github.run_id }}/R331_HOSTED_DIAGNOSTIC_REMOVAL_RECEIPT.json + - name: Preserve bounded raw hosted negative receipt uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 b/build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 new file mode 100644 index 00000000..03178235 --- /dev/null +++ b/build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param( + [string]$SetupPath='', + [switch]$HostedFresh, + [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){ + throw 'R331_REQUIRES_COMPILED_DOTNET_FRAMEWORK_SETUP' +} +$path=(Resolve-Path -LiteralPath $SetupPath).Path +$codePath=(Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path +$assembly=[Reflection.Assembly]::LoadFrom($path) +$type=$assembly.GetType('SetupProgram',$true) +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R331_SHIPPED_PE_MISSING_CANONICAL_CSHARP_RESOURCE'} +$reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText($codePath,[Text.Encoding]::UTF8) +$norm={param($t) $t.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $embedded) -cne (& $norm $source)){throw 'R331_EMBEDDED_SOURCE_NOT_EXACT'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +Pass 'COMPILED_SETUP_SOURCE_RESOURCE_EQUALS_REVIEW_SOURCE' + +$method=$type.GetMethod('WriteHostedUpgradeFailure',[Reflection.BindingFlags]'Static,NonPublic') +if($null -ne $method){throw 'R331_SHIPPING_SETUP_HAS_HOSTED_FILE_WRITE_METHOD'} +Pass 'OLD_RUNNER_DIAGNOSTIC_METHOD_ABSENT_FROM_PE' +foreach($token in @( + 'R129_DIAG_','R129_FAIL_CLOSED_DIAGNOSTIC', + 'TBME_UPDATE_DIAGNOSTIC_PHASE', + 'WriteHostedUpgradeFailure')){ + if($source.Contains($token)){throw ('R331_UNTRUSTED_HOST_DIAGNOSTIC_TOKEN_PRESENT_'+$token)} +} +Pass 'NO_ENV_CONTROLLED_HOSTED_DIAGNOSTIC_DATAFLOW' +if($source -notmatch '(?s)if\(Has\(args,"/update"\)\)\s*\{.*?try\s*\{.*?UpgradeExisting\(\).*?\}\s*catch\(Exception ex\)\s*\{\s*if\(!quiet\)MessageBox\.Show\(ex\.Message,.*?return 34;'){ + throw 'R331_UPDATE_FAILURE_CONTRACT_RETURN_34_CHANGED' +} +Pass 'UPDATE_FAILURE_REMAINS_FAIL_CLOSED_EXIT_34' +if($source -notmatch '(?s)static SensorOutcome UpgradeExisting\(\)\s*\{\s*if\(!Directory\.Exists\(AppRoot\)\)throw new InvalidOperationException'){ + throw 'R331_UPDATE_FRESH_ROOT_PRECONDITION_CHANGED' +} +Pass 'UPDATE_FRESH_ROOT_MISSING_RETURNS_BEFORE_JOURNAL_WRITES' +if($source -notmatch 'RecoverUpgradeStageIfAny\(\);' -or + $source -notmatch 'CheckSingleCanonicalRoot\(\);'){ + throw 'R331_UPGRADE_RECOVERY_AND_IDENTITY_GUARDS_REMOVED' +} +Pass 'EXISTING_UPGRADE_RECOVERY_AND_IDENTITY_GUARDS_PRESERVED' +$legacyTest=Join-Path $PSScriptRoot 'Test-Stable170-InPlace-R126.ps1' +$testSource=[IO.File]::ReadAllText((Resolve-Path $legacyTest).Path) +if(!$testSource.Contains('TBME_UPDATE_DIAGNOSTIC_PHASE') -or + $testSource.Contains('R129_DIAG_') -or + $testSource.Contains('R129_FAIL_CLOSED_DIAGNOSTIC')){ + throw 'R331_LEGACY_TEST_REQUIRES_REMOVED_DIAGNOSTIC_OUTPUT' +} +Pass 'LEGACY_UPGRADE_TEST_DOES_NOT_CONSUME_REMOVED_SIDE_EFFECT' +if($checks.Count -ne 7){throw ('R331_NATIVE_LOCAL_CASE_COUNT_'+$checks.Count)} +Write-Output 'R331_COMPILED_SETUP_DIAGNOSTIC_PATH_REMOVAL_LOCAL=7/7_PASS' +if(!$HostedFresh){return} +if($HostLabel -cnotin @('A','B') -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d+$' -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + $env:COMPUTERNAME -match '(?i)EMAD-PC'){ + throw 'R331_REFUSE_LIVE_OWNER_NON_DISPOSABLE_HOST' +} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$reg='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced' +if([IO.Directory]::Exists($app) -or (Test-Path $reg) -or + @(Get-Process TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count -ne 0){ + throw 'R331_GUEST_HAS_EXISTING_INSTALLATION_OR_RUNNING_MAIN' +} +Pass 'FRESH_DISPOSABLE_USER_INSTALL_REGISTRY_AND_MAIN_ABSENT' +$base=Join-Path $env:RUNNER_TEMP ('R331_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +if([IO.Directory]::Exists($base)){throw 'R331_HOSTED_EVIDENCE_BASE_ALREADY_EXISTS'} +[IO.Directory]::CreateDirectory($base)|Out-Null +$evidence=Join-Path $base 'TBME_R126_STABLE_UPDATE' +[IO.Directory]::CreateDirectory($evidence)|Out-Null +$sentinel=Join-Path $evidence 'external-user-canary.txt' +[IO.File]::WriteAllText($sentinel,'R331_PRESERVE_CANARY',[Text.UTF8Encoding]::new($false)) +$psi=[Diagnostics.ProcessStartInfo]::new() +$psi.FileName=$path +$psi.Arguments='/update /quiet' +$psi.UseShellExecute=$false +$psi.CreateNoWindow=$true +$psi.WorkingDirectory=[IO.Path]::GetDirectoryName($path) +$psi.EnvironmentVariables['GITHUB_ACTIONS']='true' +$psi.EnvironmentVariables['RUNNER_ENVIRONMENT']='github-hosted' +$psi.EnvironmentVariables['RUNNER_OS']='Windows' +$psi.EnvironmentVariables['GITHUB_SHA']=$env:GITHUB_SHA +$psi.EnvironmentVariables['RUNNER_TEMP']=$base +$psi.EnvironmentVariables['TBME_UPDATE_DIAGNOSTIC_PHASE']='NEGATIVE_USER_EDIT' +$proc=[Diagnostics.Process]::Start($psi) +if(!$proc){throw 'R331_REAL_SETUP_PROCESS_NOT_STARTED'} +try{ + if(!$proc.WaitForExit(45000)){ + $proc.Kill();[void]$proc.WaitForExit(10000) + throw 'R331_REAL_SETUP_NEGATIVE_EXIT_TIMEOUT' + } + if($proc.ExitCode -ne 34){throw ('R331_EXPECTED_FRESH_UPDATE_REJECTION_EXIT34_GOT_'+$proc.ExitCode)} +}finally{$proc.Dispose()} +Pass 'REAL_UNMODIFIED_SETUP_BINARY_FRESH_UPDATE_REJECTS_WITH_34' +$bad=Join-Path $evidence 'R129_DIAG_NEGATIVE_USER_EDIT.txt' +if(Test-Path -LiteralPath $bad){throw 'R331_OLD_DIAGNOSTIC_FILE_WAS_WRITTEN'} +if(@([IO.Directory]::GetFileSystemEntries($evidence)).Count -ne 1){throw 'R331_DIAGNOSTIC_CREATED_EXTRA_FILES'} +Pass 'ENV_SPOOFED_DIAGNOSTIC_OUTPUT_WAS_NOT_CREATED' +if([IO.File]::ReadAllText($sentinel) -cne 'R331_PRESERVE_CANARY'){ + throw 'R331_OUTSIDE_CANARY_MODIFIED' +} +Pass 'SENTINEL_PRESERVED' +if([IO.Directory]::Exists($app) -or (Test-Path $reg)){ + throw 'R331_REAL_NEGATIVE_SETUP_CREATED_NEW_APP_OR_REGISTRATION' +} +Pass 'NO_NEW_APPROOT_AND_REGISTRY_ON_FRESH_NEGATIVE' +$receipt=[ordered]@{ + Schema=1 + Revision='R331' + Host=$HostLabel + SourceCommit=$env:GITHUB_SHA + Status='PASS_FRESH_REAL_SETUP_FAIL_CLOSED_WITHOUT_HOSTED_DIAGNOSTIC_WRITE' + SetupSHA256=(Get-FileHash $path -Algorithm SHA256).Hash + Checks=@($checks.ToArray()) + SetupExit=34 + RealSetupExecuted=$true + DiagnosticOutputAbsent=$true + CanaryPreserved=$true + InstalledAppUntouched=$true + OwnerMachineTouched=$false + MainWasNotLaunched=$true + ElevatedUACNotInvoked=$true + SecurityFinalAccepted=$false +} +$receiptPath=Join-Path $base 'R331_HOSTED_DIAGNOSTIC_REMOVAL_RECEIPT.json' +[IO.File]::WriteAllText($receiptPath,($receipt|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +if($checks.Count -ne 12){throw ('R331_HOSTED_CASE_COUNT_BAD_'+$checks.Count)} +Write-Output ('R331_HOSTED_REAL_SETUP_NEGATIVE_NO_DIAGNOSTIC=12/12 '+$HostLabel+ + ' SHA256='+((Get-FileHash $receiptPath -Algorithm SHA256).Hash)) diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index a0b4d833..16451e7d 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1349,39 +1349,6 @@ static int Main(string[] args) } } - // R129: diagnostic-only, strictly on short-lived GitHub-hosted Windows. - // No user PC logging, no fallback writes, no overwrite, no installer bypass. - static void WriteHostedUpgradeFailure(Exception ex) - { - try - { - if(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")!="true" || - Environment.GetEnvironmentVariable("RUNNER_ENVIRONMENT")!="github-hosted" || - Environment.GetEnvironmentVariable("RUNNER_OS")!="Windows" || - !Regex.IsMatch(Environment.GetEnvironmentVariable("GITHUB_SHA")??"","^[0-9a-f]{40}$")) - return; - string phase=Environment.GetEnvironmentVariable("TBME_UPDATE_DIAGNOSTIC_PHASE")??""; - if(phase!="NEGATIVE_USER_EDIT" && phase!="POSITIVE_OFFICIAL_STABLE" && - phase!="IDEMPOTENT" && phase!="DOWNGRADE")return; - string temp=Environment.GetEnvironmentVariable("RUNNER_TEMP")??""; - if(String.IsNullOrWhiteSpace(temp))return; - string evidence=Path.Combine(Path.GetFullPath(temp),"TBME_R126_STABLE_UPDATE"); - if(!Directory.Exists(evidence))return; - string file=Path.Combine(evidence,"R129_DIAG_"+phase+".txt"); - using(FileStream output=new FileStream(file,FileMode.CreateNew,FileAccess.Write,FileShare.None)) - using(StreamWriter writer=new StreamWriter(output,new UTF8Encoding(false))) - { - writer.WriteLine("R129_FAIL_CLOSED_DIAGNOSTIC"); - writer.WriteLine("TEST_PHASE="+phase); - writer.WriteLine("GIT_SHA="+Environment.GetEnvironmentVariable("GITHUB_SHA")); - writer.WriteLine("EXCEPTION="+ex.GetType().FullName); - writer.WriteLine(ex.ToString().Substring(0,Math.Min(12000,ex.ToString().Length))); - writer.Flush(); - } - } - catch{} // Diagnostic must never change Setup's failure/rollback behavior. - } - static int RunMain(string[] args) { if(Has(args,"/verify")) @@ -1393,7 +1360,6 @@ static int RunMain(string[] args) UpgradeExisting(); // never fresh-install; rollback journal required return 0; }catch(Exception ex){ - WriteHostedUpgradeFailure(ex); if(!quiet)MessageBox.Show(ex.Message,"In-place update declined",MessageBoxButtons.OK,MessageBoxIcon.Error); return 34; } From ceb94dbaeb3036ab5de18bbb9c773bcc2240d0ae Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 18:26:47 +0330 Subject: [PATCH 089/135] fix(setup): guard optional sensor reuse against reparse and profile redirects (R332) --- .github/workflows/ci.yml | 44 ++++ .../Test-R332-SensorReuseBoundary-Native.ps1 | 207 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 34 ++- 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 build/Test-R332-SensorReuseBoundary-Native.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97e4ad7d..5522e47e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,12 @@ jobs: & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R331-NoHostedDiagnosticWrite-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R331 fail-closed Setup minus hosted diagnostic sink native regression failed.' } + - name: R332 canonical protected sensor reuse, junction and health-state bounds (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R332-SensorReuseBoundary-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R332 protected sensor reuse boundary native negative checks failed.' } + - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 @@ -343,6 +349,44 @@ jobs: retention-days: 7 path: ${{ runner.temp }}/R331_${{ matrix.host }}_${{ github.run_id }}/R331_HOSTED_DIAGNOSTIC_REMOVAL_RECEIPT.json + - name: R332 real compiled Setup rejects ProgramFiles and user-health reparse on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R332_NOT_THE_DISPOSABLE_EXACT_GUEST' + } + $setup=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $mf=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setup.Count -ne 1 -or $mf.Count -ne 1){throw 'R332_CANDIDATE_AND_MANIFEST_NOT_UNIQUE'} + $manifest=Get-Content $mf[0].FullName -Raw|ConvertFrom-Json + $m=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($m.Count -ne 1 -or (Get-FileHash $setup[0].FullName -Algorithm SHA256).Hash -cne [string]$m[0].SHA256){ + throw 'R332_SETUP_PRIVATE_BINARY_NOT_MANIFEST_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R332-SensorReuseBoundary-Native.ps1 -SetupPath $setup[0].FullName -HostedGuest -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R332_PROTECTED_REUSE_NEGATIVE_TEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R332_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R332_SENSOR_REUSE_BOUNDARY_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R332_TYPED_GUEST_RECEIPT_MISSING'} + $r=Get-Content -LiteralPath $receipt -Raw |ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R332' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SetupSHA256 -cne [string]$m[0].SHA256 -or + $r.Status -cne 'PASS_CANONICAL_SENSOR_REUSE_JUNCTION_FAIL_CLOSED' -or @($r.Checks).Count -ne 18 -or + $r.RealCompiledNetFrameworkSetupLoaded -ne $true -or $r.ProgramFilesRealRootModified -ne $false -or + $r.ActualSensorHelperElevated -ne $false -or $r.OwnerInstalledProgramChanged -ne $false -or + $r.ExternalCanaryPreserved -ne $true -or $r.AtomicReparseRaceProven -ne $false -or + $r.SecurityFinalAccepted -ne $false){ + throw 'R332_TYPED_GUEST_IDENTITY_OR_SCOPE_INVALID' + } + - name: Preserve bounded R332 reparse-negative guest receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R332-reuse-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R332_${{ matrix.host }}_${{ github.run_id }}/R332_SENSOR_REUSE_BOUNDARY_RECEIPT.json + - name: Preserve bounded raw hosted negative receipt uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/build/Test-R332-SensorReuseBoundary-Native.ps1 b/build/Test-R332-SensorReuseBoundary-Native.ps1 new file mode 100644 index 00000000..056a5c16 --- /dev/null +++ b/build/Test-R332-SensorReuseBoundary-Native.ps1 @@ -0,0 +1,207 @@ +[CmdletBinding()] +param( + [string]$SetupPath='', + [switch]$HostedGuest, + [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)) { + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or !(Test-Path -LiteralPath $SetupPath -PathType Leaf)) { + throw 'R332_EXACT_WINDOWS_NETFRAMEWORK_SETUP_REQUIRED' +} +$setupPath=(Resolve-Path -LiteralPath $SetupPath).Path +$assembly=[Reflection.Assembly]::LoadFrom($setupPath) +$type=$assembly.GetType('SetupProgram',$true) +$method=$type.GetMethod('ValidatedSensorReuseRoot',[Reflection.BindingFlags]'NonPublic,Static') +$reuse=$type.GetMethod('TryReuseCompatibleSensorLayer',[Reflection.BindingFlags]'NonPublic,Static') +if(!$method -or !$reuse){throw 'R332_REAL_COMPILED_PE_MISSING_BOTH_METHODS'} +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R332_PE_SOURCE_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embeddedSource=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $embeddedSource) -cne (& $normalize $source)){ + throw 'R332_COMPILED_SOURCE_NOT_EQUAL_CANONICAL_CODE' +} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +function Invoke-Validated([string]$pf,[string]$local,[string]$profile){ + $a=New-Object 'object[]' 3 + $a[0]=[string]$pf; $a[1]=[string]$local; $a[2]=[string]$profile + return [string]$method.Invoke($null,$a) +} +function Expect-Fail([string]$label,[string]$pf,[string]$local,[string]$profile){ + $refused=$false + try{ + $actual=Invoke-Validated $pf $local $profile + throw ('R332_UNEXPECTED_ACCEPT_'+$label+'='+$actual) + }catch { + $e=$_.Exception + while($null -ne $e.InnerException){$e=$e.InnerException} + $message=$e.Message + if($message -match '^R332_UNEXPECTED_ACCEPT_'){throw $message} + if($message -notmatch '(?i)(root|UNC|profile|canonical|reparse|unavailable|ProgramFiles|escaped|not.*folder)'){ + throw ('R332_WRONG_FAILURE_REASON_'+$label+': '+$message) + } + $refused=$true + } + if(!$refused){throw ('R332_NEGATIVE_NOT_REFUSED_'+$label)} + Pass $label +} +function MoveJunctionCheck([string]$label,[string]$victim,[string]$redirect,[string]$pf,[string]$local,[string]$profile){ + if(![IO.Directory]::Exists($victim) -or + ([IO.File]::GetAttributes($victim) -band [IO.FileAttributes]::ReparsePoint)){ + throw ('R332_NOT_AN_OWNED_TEST_DIRECTORY_'+$label) + } + $retired=$victim+'_retired' + if([IO.Directory]::Exists($retired)){throw ('R332_ALREADY_RETIRED_'+$label)} + [IO.Directory]::Move($victim,$retired) + $isLink=$false + try{ + New-Item -ItemType Junction -Path $victim -Target $redirect -ErrorAction Stop|Out-Null + $isLink=$true + Expect-Fail $label $pf $local $profile + }finally{ + if($isLink -and [IO.Directory]::Exists($victim)){[IO.Directory]::Delete($victim)} + if([IO.Directory]::Exists($retired)){[IO.Directory]::Move($retired,$victim)} + } +} +Pass 'REAL_SETUP_EMBEDDED_SOURCE_EXACT' +$call='string sensorRoot=ValidatedSensorReuseRoot(' +if(([regex]::Matches($source,[regex]::Escape($call))).Count -ne 1 -or + !$source.Contains('Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)') -or + !$source.Contains('CheckRootAncestorsNoReparse(Path.Combine(sensorRoot,"TaskbarMonitorSensorBroker.exe"))') -or + !$source.Contains('CheckRootAncestorsNoReparse(Path.Combine(userRoot,"sensor_supervisor_state.json"))')){ + throw 'R332_REUSE_PRECHECK_NOT_BOUND_BEFORE_FILE_HASHES' +} +Pass 'REUSE_PRECHECK_BOUND_BEFORE_HASH_AND_STATE_READING' +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r332_sensor_reuse_'+[guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$profile=Join-Path $base 'fake profile & literal $test' +$local=Join-Path $profile 'AppData\Local' +$userApp=Join-Path $local 'TaskbarMonitorEnhanced' +$pf=Join-Path $base 'Program Files & literal $test' +$pfProduct=Join-Path $pf 'TaskbarMonitorEnhanced' +$sensor=Join-Path $pfProduct 'SensorBroker' +$broker=Join-Path $sensor 'TaskbarMonitorSensorBroker.exe' +$supervisor=Join-Path $sensor 'TaskbarMonitorSensorSupervisor.exe' +$state=Join-Path $userApp 'sensor_supervisor_state.json' +$external=Join-Path $base 'foreign-canary' +[IO.Directory]::CreateDirectory($userApp)|Out-Null +[IO.Directory]::CreateDirectory($sensor)|Out-Null +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'DO_NOT_MODIFY.txt' +[IO.File]::WriteAllText($canary,'R332_FOREIGN_PRESERVE') +[IO.File]::WriteAllText($broker,'R332_FAKE_BROKER_NOT_RUN') +[IO.File]::WriteAllText($supervisor,'R332_FAKE_SUPERVISOR_NOT_RUN') +[IO.File]::WriteAllText($state,'{"status":"FAKE_NOT_REUSED"}') +try{ + $got=Invoke-Validated $pf $local $profile + if(-not [string]::Equals($got,[IO.Path]::GetFullPath($sensor),[StringComparison]::OrdinalIgnoreCase)){ + throw ('R332_POSITIVE_SYNTHETIC_EXACT_ROOT_MISMATCH_'+$got) + } + Pass 'CANONICAL_FAKE_SAME_USER_PROFILE_AND_PROGRAMFILES_ACCEPTED' + $knownPF=[Environment]::GetFolderPath('ProgramFiles') + $oldPF=$env:ProgramFiles + try{ + $env:ProgramFiles=Join-Path $base 'attacker-supplied-env-ProgramFiles' + if(-not [string]::Equals([Environment]::GetFolderPath('ProgramFiles'),$knownPF, + [StringComparison]::OrdinalIgnoreCase)){ + throw 'R332_ENV_SPOOF_CHANGED_OS_KNOWN_PROGRAMFILES' + } + Pass 'OS_KNOWN_PROGRAMFILES_UNAFFECTED_BY_ENVIRONMENT_SPOOF' + }finally{$env:ProgramFiles=$oldPF} + Expect-Fail 'EMPTY_PROGRAMFILES_REFUSED' '' $local $profile + Expect-Fail 'RELATIVE_PROGRAMFILES_REFUSED' 'Program Files' $local $profile + Expect-Fail 'UNC_PROGRAMFILES_REFUSED' '\\foreign.invalid\programs' $local $profile + Expect-Fail 'DRIVE_ROOT_PROGRAMFILES_REFUSED' ([IO.Path]::GetPathRoot($base)) $local $profile + Expect-Fail 'MISMATCHED_USER_LOCAL_PROFILE_REFUSED' $pf (Join-Path $base 'different\AppData\Local') $profile + Expect-Fail 'UNC_PROFILE_REFUSED' $pf '\\foreign.invalid\user\AppData\Local' '\\foreign.invalid\user' + MoveJunctionCheck 'PROGRAMFILES_ROOT_JUNCTION_REFUSED' $pf $external $pf $local $profile + MoveJunctionCheck 'PROGRAMFILES_PRODUCT_JUNCTION_REFUSED' $pfProduct $external $pf $local $profile + MoveJunctionCheck 'SENSORBROKER_ROOT_JUNCTION_REFUSED' $sensor $external $pf $local $profile + # A directory junction at the exact .exe leaf models an EXE-name reparse point; + # no file symlink privileges or protected ProgramFiles modifications required. + [IO.File]::Delete($broker) + [IO.Directory]::CreateDirectory($external)|Out-Null + $brokerLinked=$false + try{ + New-Item -ItemType Junction -Path $broker -Target $external -ErrorAction Stop|Out-Null + $brokerLinked=$true + Expect-Fail 'BROKER_EXE_LEAF_REPARSE_REFUSED' $pf $local $profile + }finally{ + if($brokerLinked -and [IO.Directory]::Exists($broker)){[IO.Directory]::Delete($broker)} + [IO.File]::WriteAllText($broker,'R332_FAKE_BROKER_NOT_RUN') + } + [IO.File]::Delete($supervisor) + $supervisorLinked=$false + try{ + New-Item -ItemType Junction -Path $supervisor -Target $external -ErrorAction Stop|Out-Null + $supervisorLinked=$true + Expect-Fail 'SUPERVISOR_EXE_LEAF_REPARSE_REFUSED' $pf $local $profile + }finally{ + if($supervisorLinked -and [IO.Directory]::Exists($supervisor)){[IO.Directory]::Delete($supervisor)} + [IO.File]::WriteAllText($supervisor,'R332_FAKE_SUPERVISOR_NOT_RUN') + } + MoveJunctionCheck 'USER_APPROOT_JUNCTION_REFUSED' $userApp $external $pf $local $profile + [IO.File]::Delete($state) + $stateLinked=$false + try{ + New-Item -ItemType Junction -Path $state -Target $external -ErrorAction Stop|Out-Null + $stateLinked=$true + Expect-Fail 'USER_HEALTH_STATE_LEAF_REPARSE_REFUSED' $pf $local $profile + }finally{ + if($stateLinked -and [IO.Directory]::Exists($state)){[IO.Directory]::Delete($state)} + [IO.File]::WriteAllText($state,'{"status":"FAKE_NOT_REUSED"}') + } + if([IO.File]::ReadAllText($canary) -cne 'R332_FOREIGN_PRESERVE' -or + @([IO.Directory]::GetFileSystemEntries($external)).Count -ne 1){ + throw 'R332_FOREIGN_CANARY_OR_EXTERNAL_DIRECTORY_CHANGED' + } + Pass 'EXTERNAL_CANARY_PRESERVED_AFTER_ALL_NEGATIVES' + if($checks.Count -ne 18){throw ('R332_NATIVE_EXPECTED_16_GOT_'+$checks.Count)} + Write-Output ('R332_CANONICAL_SENSOR_REUSE_NET48_PASS='+$checks.Count+'/18') + if(!$HostedGuest){return} + if($HostLabel -cnotin @('A','B') -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R332_HOSTED_GUEST_ENVIRONMENT_NOT_DISPOSABLE' + } + $receiptRoot=Join-Path $env:RUNNER_TEMP ('R332_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if([IO.Directory]::Exists($receiptRoot)){throw 'R332_GUEST_RECEIPT_ALREADY_EXISTS'} + [IO.Directory]::CreateDirectory($receiptRoot)|Out-Null + $evidence=[ordered]@{ + Schema=1 + Revision='R332' + Status='PASS_CANONICAL_SENSOR_REUSE_JUNCTION_FAIL_CLOSED' + Host=$HostLabel + SourceCommit=$env:GITHUB_SHA + SetupSHA256=(Get-FileHash $setupPath -Algorithm SHA256).Hash + Checks=@($checks.ToArray()) + RealCompiledNetFrameworkSetupLoaded=$true + ProgramFilesRealRootModified=$false + ActualSensorHelperElevated=$false + OwnerInstalledProgramChanged=$false + ExternalCanaryPreserved=$true + AtomicReparseRaceProven=$false + SecurityFinalAccepted=$false + } + $p=Join-Path $receiptRoot 'R332_SENSOR_REUSE_BOUNDARY_RECEIPT.json' + [IO.File]::WriteAllText($p,($evidence|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + Write-Output ('R332_GUEST_TYPED_RECEIPT_SHA256='+(Get-FileHash $p -Algorithm SHA256).Hash) +}finally{ + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r332_sensor_reuse_',[StringComparison]::Ordinal)){ + $links=@(Get-ChildItem $base -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($links.Count -ne 0){throw 'R332_TEST_SANDBOX_HAS_REPARSE_POINTS_REFUSE_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 16451e7d..29b18ed4 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -812,12 +812,44 @@ static bool JsonBool(string json,string key) return p+4<=json.Length && String.Equals(json.Substring(p,4),"true",StringComparison.OrdinalIgnoreCase); } + // R332: reuse of an optional elevated sensor layer is allowed only + // under the OS known ProgramFiles and verified same-user app root. + // Refuse observed reparse points before reusing protected hashes or + // user profile health state. These checks are path-based, not atomic. + static string ValidatedSensorReuseRoot(string knownProgramFiles,string knownLocal,string knownProfile) + { + if(String.IsNullOrWhiteSpace(knownProgramFiles) || + !Path.IsPathRooted(knownProgramFiles)) + throw new InvalidDataException("Trusted ProgramFiles root unavailable."); + string pf=Path.GetFullPath(knownProgramFiles).TrimEnd(Path.DirectorySeparatorChar); + if(pf.StartsWith(@"\\",StringComparison.Ordinal) || + !Regex.IsMatch(pf,@"^[A-Za-z]:\\") || + String.Equals(pf,Path.GetPathRoot(pf),StringComparison.OrdinalIgnoreCase) || + pf.Length>512) + throw new InvalidDataException("Protected sensor reuse refuses UNC or root-only ProgramFiles."); + string userRoot=CanonicalAppRootFromKnownFolders(knownLocal,knownProfile); + string sensorRoot=Path.GetFullPath(Path.Combine(pf,AppFolder,"SensorBroker")); + string expectedParent=Path.GetFullPath(Path.Combine(pf,AppFolder)); + if(!String.Equals(Path.GetDirectoryName(sensorRoot),expectedParent, + StringComparison.OrdinalIgnoreCase) || + sensorRoot.Length>1024) + throw new InvalidDataException("Protected sensor reuse escaped ProgramFiles."); + CheckRootAncestorsNoReparse(sensorRoot); + CheckRootAncestorsNoReparse(Path.Combine(sensorRoot,"TaskbarMonitorSensorBroker.exe")); + CheckRootAncestorsNoReparse(Path.Combine(sensorRoot,"TaskbarMonitorSensorSupervisor.exe")); + CheckRootAncestorsNoReparse(Path.Combine(userRoot,"sensor_supervisor_state.json")); + return sensorRoot; + } + static bool TryReuseCompatibleSensorLayer(out SensorOutcome outcome) { outcome=null; try { - string sensorRoot=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),"TaskbarMonitorEnhanced","SensorBroker"); + string sensorRoot=ValidatedSensorReuseRoot( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); string broker=Path.Combine(sensorRoot,"TaskbarMonitorSensorBroker.exe"); string supervisor=Path.Combine(sensorRoot,"TaskbarMonitorSensorSupervisor.exe"); string statePath=Path.Combine(AppRoot,"sensor_supervisor_state.json"); From 83b9d9affab7ed43f4697cb921ac6c4b05fbe214 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 19:08:53 +0330 Subject: [PATCH 090/135] fix(setup): detect foreign shortcut names by exact lexical entry enumeration (R333) --- .github/workflows/ci.yml | 44 +++++ .../Test-R333-ShortcutNameOccupied-Native.ps1 | 187 ++++++++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 26 ++- 3 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 build/Test-R333-ShortcutNameOccupied-Native.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5522e47e..27cf0c9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,12 @@ jobs: & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R332-SensorReuseBoundary-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R332 protected sensor reuse boundary native negative checks failed.' } + - name: R333 exact shortcut name occupancy, dangling reparse and foreign owner (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R333-ShortcutNameOccupied-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R333 compiled Setup shortcut occupancy negative test failed.' } + - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 @@ -387,6 +393,44 @@ jobs: retention-days: 7 path: ${{ runner.temp }}/R332_${{ matrix.host }}_${{ github.run_id }}/R332_SENSOR_REUSE_BOUNDARY_RECEIPT.json + - name: R333 compiled Setup preserves all occupied foreign Shortcut names on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R333_NOT_EXACT_DISPOSABLE_GUEST' + } + $setups=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $manifests=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setups.Count -ne 1 -or $manifests.Count -ne 1){throw 'R333_PRIVATE_SETUP_OR_MANIFEST_AMBIGUOUS'} + $manifest=Get-Content $manifests[0].FullName -Raw|ConvertFrom-Json + $identity=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($identity.Count -ne 1 -or (Get-FileHash $setups[0].FullName -Algorithm SHA256).Hash -cne [string]$identity[0].SHA256){ + throw 'R333_PRIVATE_SETUP_EXACT_SHA_NOT_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R333-ShortcutNameOccupied-Native.ps1 -SetupPath $setups[0].FullName -HostedGuest -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R333_NATIVE_GUEST_NEGATIVE_TEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R333_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R333_SHORTCUT_OCCUPANCY_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R333_GUEST_RAW_TYPED_RECEIPT_NOT_CREATED'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R333' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SetupSHA256 -cne [string]$identity[0].SHA256 -or + $r.Status -cne 'PASS_EXACT_SHORTCUT_OCCUPANCY_INCLUDING_DANGLING_REPARSE' -or + @($r.Checks).Count -ne 22 -or $r.RealCompiledSetupLoaded -ne $true -or + $r.RealMainLaunched -ne $false -or $r.ForeignCanaryPreserved -ne $true -or + $r.ActualUacInvoked -ne $false -or $r.OriginalOwnerHostTouched -ne $false -or + $r.AtomicPathRaceProven -ne $false -or $r.SecurityFinalAccepted -ne $false){ + throw 'R333_GUEST_EVIDENCE_CONTRACT_NOT_ACCEPTED' + } + - name: Preserve bounded R333 shortcut occupancy guest evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R333-shortcut-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R333_${{ matrix.host }}_${{ github.run_id }}/R333_SHORTCUT_OCCUPANCY_RECEIPT.json + - name: Preserve bounded raw hosted negative receipt uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/build/Test-R333-ShortcutNameOccupied-Native.ps1 b/build/Test-R333-ShortcutNameOccupied-Native.ps1 new file mode 100644 index 00000000..4ac22ce0 --- /dev/null +++ b/build/Test-R333-ShortcutNameOccupied-Native.ps1 @@ -0,0 +1,187 @@ +[CmdletBinding()] +param( + [string]$SetupPath='', + [switch]$HostedGuest, + [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL' +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if([string]::IsNullOrWhiteSpace($SetupPath)){ + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if($PSVersionTable.PSEdition -cne 'Desktop' -or + !(Test-Path -LiteralPath $SetupPath -PathType Leaf)){throw 'R333_COMPILED_DOTNET_FRAMEWORK_SETUP_REQUIRED'} +$setup=(Resolve-Path -LiteralPath $SetupPath).Path +$assembly=[Reflection.Assembly]::LoadFrom($setup) +$type=$assembly.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$occupied=$type.GetMethod('ShortcutNameOccupied',$flags) +$fresh=$type.GetMethod('AssertFreshShortcutNameUnused',$flags) +$foreign=$type.GetMethod('AssertOwnedOrAbsentShortcut',$flags) +if(!$occupied -or !$fresh -or !$foreign){throw 'R333_REAL_SETUP_MISSING_SHORTCUT_PRECHECKS'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){ + $script:checks.Add($name) + Write-Output ('PASS '+$name) +} +function Occupied([string]$path){ + $args=New-Object 'object[]' 1 + $args[0]=[string]$path + return [bool]$occupied.Invoke($null,$args) +} +function Fresh([string]$path){ + $args=New-Object 'object[]' 1 + $args[0]=[string]$path + [void]$fresh.Invoke($null,$args) +} +function Foreign([string]$path,[string]$target,[string]$arguments){ + $args=New-Object 'object[]' 3 + $args[0]=[string]$path;$args[1]=[string]$target;$args[2]=[string]$arguments + [void]$foreign.Invoke($null,$args) +} +function Expect-Throw([string]$label,[scriptblock]$action,[string]$pattern){ + $message='' + try { + & $action |Out-Null + throw ('R333_FAIL_OPEN_'+$label) + }catch { + $e=$_.Exception + while($null -ne $e.InnerException){$e=$e.InnerException} + $message=$e.Message + } + if($message -match '^R333_FAIL_OPEN_' -or $message -notmatch $pattern){ + throw ('R333_NEGATIVE_NOT_PROVEN_'+$label+' ACTUAL='+$message) + } + Pass $label +} +$resource=$assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R333_EMBEDDED_SOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$norm={param($s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $source) -cne (& $norm $embedded)){throw 'R333_BUILT_SETUP_EMBEDDED_SOURCE_NOT_EXACT'} +Pass 'SETUP_EXACT_EMBEDDED_SOURCE_IDENTITY' +if($occupied.ReturnType -ne [bool]){throw 'R333_PRIVATE_OCCUPIED_RETURN_TYPE_CHANGED'} +Pass 'OCCUPANCY_SIGNATURE_BOOL_PRESERVED' + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r333_occupied_'+[guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$root=Join-Path $base 'start menu & literal $USER' +[IO.Directory]::CreateDirectory($root)|Out-Null +$external=Join-Path $base 'foreign-canary-directory' +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'NEVER_CHANGE.txt' +[IO.File]::WriteAllText($canary,'R333_OUTSIDE_UNTOUCHED') +$link=$null;$retired=$null +try { + $missing=Join-Path $root 'absent.lnk' + if(Occupied $missing){throw 'R333_ABSENT_NAME_OCCUPIED'} + Pass 'EXACT_ABSENT_SHORTCUT_NAME_FALSE' + Fresh $missing + Pass 'FRESH_ABSENT_SHORTCUT_ALLOWED' + + $other=Join-Path $root 'other.lnk' + [IO.File]::WriteAllText($other,'R333_OTHER_LEAF_UNTOUCHED') + if(Occupied $missing){throw 'R333_OTHER_NAME_MATCHED_WRONGLY'} + Pass 'EXACT_LEAF_FILTER_IGNORES_NEIGHBOR' + + $foreignFile=Join-Path $root 'foreign.lnk' + [IO.File]::WriteAllText($foreignFile,'R333_OWNED_BY_OTHER_APP') + $fhash=(Get-FileHash $foreignFile -Algorithm SHA256).Hash + if(!(Occupied $foreignFile)){throw 'R333_FOREIGN_REGULAR_FILE_NOT_OCCUPIED'} + Pass 'REGULAR_FOREIGN_LINK_NAME_OCCUPIED' + if(!(Occupied (Join-Path $root 'FOREIGN.LNK'))){throw 'R333_CASE_INSENSITIVE_LEAF_NOT_MATCHED'} + Pass 'CASE_INSENSITIVE_LITERAL_LEAF_MATCH' + Expect-Throw 'FRESH_REFUSES_FOREIGN_REGULAR_FILE' {Fresh $foreignFile} 'FOREIGN_SHORTCUT_NAME_CONFLICT' + if((Get-FileHash $foreignFile -Algorithm SHA256).Hash -ne $fhash){throw 'R333_FOREIGN_FILE_BYTES_MUTATED'} + Pass 'FOREIGN_FILE_BYTES_PRESERVED' + Expect-Throw 'UNOWNED_SHORTCUT_REFUSES_FOREIGN_FILE' { + Foreign $foreignFile (Join-Path $base 'legitimate-main.exe') '' + } 'FOREIGN_SHORTCUT_NAME_CONFLICT' + + $directoryEntry=Join-Path $root 'subdir.lnk' + [IO.Directory]::CreateDirectory($directoryEntry)|Out-Null + if(!(Occupied $directoryEntry)){throw 'R333_DIRECTORY_WITH_LNK_NAME_NOT_OCCUPIED'} + Pass 'DIRECTORY_WITH_LNK_NAME_OCCUPIED' + Expect-Throw 'FRESH_REFUSES_DIRECTORY_LNK_NAME' {Fresh $directoryEntry} 'FOREIGN_SHORTCUT_NAME_CONFLICT' + + $link=Join-Path $root 'linked.lnk' + New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop|Out-Null + if(!(Occupied $link)){throw 'R333_JUNCTION_ENTRY_NOT_OCCUPIED'} + Pass 'DIRECTORY_JUNCTION_LNK_OCCUPIES_NAME' + Expect-Throw 'FRESH_REFUSES_JUNCTION_LNK' {Fresh $link} 'FOREIGN_SHORTCUT_NAME_CONFLICT' + $retired=$external+'_temporarily_unavailable' + [IO.Directory]::Move($external,$retired) + if(!(Occupied $link)){throw 'R333_DANGLING_JUNCTION_FALSELY_ABSENT'} + Pass 'DANGLING_REPARSE_LINK_OCCUPIES_NAME' + Expect-Throw 'FRESH_REFUSES_DANGLING_REPARSE' {Fresh $link} 'FOREIGN_SHORTCUT_NAME_CONFLICT' + [IO.Directory]::Move($retired,$external) + $retired=$null + [IO.Directory]::Delete($link) + $link=$null + if([IO.File]::ReadAllText($canary) -cne 'R333_OUTSIDE_UNTOUCHED'){ + throw 'R333_EXTERNAL_CANARY_ALTERED' + } + Pass 'FOREIGN_TARGET_CANARY_PRESERVED' + + if(Occupied (Join-Path $base 'missing-parent\not-here.lnk')){ + throw 'R333_ABSENT_PARENT_FALSELY_OCCUPIED' + } + Pass 'NONEXISTENT_PARENT_RETURNS_ABSENT' + Expect-Throw 'RELATIVE_SHORTCUT_PATH_REFUSED' {Occupied 'relative.lnk'} 'absolute' + Expect-Throw 'NONLNK_EXTENSION_REFUSED' {Occupied (Join-Path $root 'bad.txt')} 'literal .lnk' + Expect-Throw 'WILDCARD_FILENAME_REFUSED' {Occupied (Join-Path $root 'bad*.lnk')} '(?i)literal .lnk|Illegal characters' + + if($source.Contains('try{File.GetAttributes(path);return true;}') -or + !$source.Contains('new DirectoryInfo(parent).GetFileSystemInfos(leaf)')){ + throw 'R333_SHIPPED_OCCUPANCY_STILL_HAS_OLD_SINK' + } + Pass 'SHIPPED_OCCUPANCY_ENUMERATES_EXACT_PARENT_LEAF' + if($checks.Count -ne 22){throw ('R333_NATIVE_CHECK_COUNT_EXPECTED_19_GOT_'+$checks.Count)} + Write-Output ('R333_NATIVE_SHORTCUT_EXACT_LEAF_OCCUPANCY_PASS='+$checks.Count+'/22') + + if(!$HostedGuest){return} + if($HostLabel -cnotin @('A','B') -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R333_GUEST_MUST_BE_DISPOSABLE_GITHUB_WINDOWS' + } + $receiptDir=Join-Path $env:RUNNER_TEMP ('R333_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if([IO.Directory]::Exists($receiptDir)){throw 'R333_HOSTED_GUEST_PROOF_EXISTS'} + [IO.Directory]::CreateDirectory($receiptDir)|Out-Null + $proof=[ordered]@{ + Schema=1 + Revision='R333' + Status='PASS_EXACT_SHORTCUT_OCCUPANCY_INCLUDING_DANGLING_REPARSE' + Host=$HostLabel + SourceCommit=$env:GITHUB_SHA + SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash + Checks=@($checks.ToArray()) + RealCompiledSetupLoaded=$true + RealMainLaunched=$false + ForeignCanaryPreserved=$true + ActualUacInvoked=$false + OriginalOwnerHostTouched=$false + AtomicPathRaceProven=$false + SecurityFinalAccepted=$false + } + $receipt=Join-Path $receiptDir 'R333_SHORTCUT_OCCUPANCY_RECEIPT.json' + [IO.File]::WriteAllText($receipt,($proof|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + Write-Output ('R333_HOSTED_GUEST_RECEIPT_SHA256='+(Get-FileHash $receipt -Algorithm SHA256).Hash) +}finally{ + if($link -and [IO.Directory]::Exists($link)){ + [IO.Directory]::Delete($link) + } + if($retired -and [IO.Directory]::Exists($retired)){ + [IO.Directory]::Move($retired,$external) + } + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r333_occupied_',[StringComparison]::Ordinal)){ + $reparse=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue| + Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($reparse.Count){throw 'R333_UNEXPECTED_REPARSE_REFUSE_TEST_SANDBOX_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 29b18ed4..60051565 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -495,12 +495,30 @@ static bool DeleteOwnedShortcut(string path,string expectedTarget,string expecte catch{return false;} } - // R324: even a dangling reparse link occupies its exact name. - // Detect a file-system entry without following its target for ownership. + // R333: lexical parent enumeration sees *all* occupied entries, including + // dangling reparse names that File.Exists/Directory.Exists can both miss. + // Match the exact leaf after enumeration; never follow a foreign link for + // ownership. Unexpected access/IO errors fail closed by propagating. static bool ShortcutNameOccupied(string path) { - try{File.GetAttributes(path);return true;} - catch(FileNotFoundException){return false;} + if(String.IsNullOrWhiteSpace(path) || !Path.IsPathRooted(path)) + throw new InvalidDataException("Shortcut occupancy requires an absolute link path."); + string full=Path.GetFullPath(path); + string parent=Path.GetDirectoryName(full); + string leaf=Path.GetFileName(full); + if(String.IsNullOrWhiteSpace(parent) || String.IsNullOrWhiteSpace(leaf) || + !String.Equals(Path.GetExtension(leaf),".lnk",StringComparison.OrdinalIgnoreCase) || + leaf.IndexOfAny(Path.GetInvalidFileNameChars())>=0) + throw new InvalidDataException("Shortcut occupancy requires a literal .lnk leaf."); + try + { + // Nonrecursive, literal pattern, exact-case-insensitive comparison. + // Enumeration includes files, directories and dangling reparse links. + foreach(FileSystemInfo entry in new DirectoryInfo(parent).GetFileSystemInfos(leaf)) + if(String.Equals(entry.Name,leaf,StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } catch(DirectoryNotFoundException){return false;} } From c134f5ff4584ad663d6cf0468138dd0679733899 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Sun, 18 Oct 2026 23:25:37 +0330 Subject: [PATCH 091/135] test(security): validate three Setup command sinks with hostile input (R334) --- .github/workflows/ci.yml | 45 ++++ .../Test-R334-CommandSinkContracts-Native.ps1 | 233 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 build/Test-R334-CommandSinkContracts-Native.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27cf0c9a..32a40810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,12 @@ jobs: & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R333-ShortcutNameOccupied-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R333 compiled Setup shortcut occupancy negative test failed.' } + - name: R334 three remaining Setup command sink trust contracts (compiled native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R334-CommandSinkContracts-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R334 no-UAC three-process-command-sink adversarial contract regression failed.' } + - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 @@ -431,6 +437,45 @@ jobs: retention-days: 7 path: ${{ runner.temp }}/R333_${{ matrix.host }}_${{ github.run_id }}/R333_SHORTCUT_OCCUPANCY_RECEIPT.json + - name: R334 inspect hostile process command construction on isolated Windows ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R334_REFUSE_NON_DISPOSABLE_OR_UNPINNED_WINDOWS' + } + $setups=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $manifests=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setups.Count -ne 1 -or $manifests.Count -ne 1){throw 'R334_PRIVATE_SETUP_OR_BUILD_MANIFEST_AMBIGUOUS'} + $manifest=Get-Content $manifests[0].FullName -Raw|ConvertFrom-Json + $identity=@($manifest.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($identity.Count -ne 1 -or + (Get-FileHash $setups[0].FullName -Algorithm SHA256).Hash -cne [string]$identity[0].SHA256){ + throw 'R334_PRIVATE_COMPILED_SETUP_NOT_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R334-CommandSinkContracts-Native.ps1 -SetupPath $setups[0].FullName -HostedGuest -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R334_COMMAND_FACTORIES_NATIVE_GUEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R334_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R334_COMMAND_SINK_TRUST_RECEIPT.json') + if(!(Test-Path -LiteralPath $receipt -PathType Leaf)){throw 'R334_RAW_TYPED_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R334' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or $r.SetupSHA256 -cne [string]$identity[0].SHA256 -or + $r.Status -cne 'PASS_THREE_PROCESS_COMMAND_SINK_TRUST_CONTRACTS' -or + @($r.Checks).Count -ne 24 -or $r.CompiledSetupReflected -ne $true -or + $r.HelperAndCleanupInputsOnlyInEncodedData -ne $true -or + $r.ShaPinnedMainNoShell -ne $true -or $r.NativeUacWasNotInvoked -ne $true -or + $r.NoCleanupOrMainProcessLaunched -ne $true -or $r.WholeSecurityAccepted -ne $false){ + throw 'R334_GUEST_COMMAND_BOUNDARY_TYPED_RECEIPT_INVALID' + } + - name: Preserve bounded raw R334 command trust guest receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R334-command-guest-${{ matrix.host }}-${{ github.sha }} + if-no-files-found: error + retention-days: 7 + path: ${{ runner.temp }}/R334_${{ matrix.host }}_${{ github.run_id }}/R334_COMMAND_SINK_TRUST_RECEIPT.json + - name: Preserve bounded raw hosted negative receipt uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/build/Test-R334-CommandSinkContracts-Native.ps1 b/build/Test-R334-CommandSinkContracts-Native.ps1 new file mode 100644 index 00000000..46c6c7b3 --- /dev/null +++ b/build/Test-R334-CommandSinkContracts-Native.ps1 @@ -0,0 +1,233 @@ +[CmdletBinding()] +param([string]$SetupPath='', [switch]$HostedGuest, [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if(!$SetupPath){$SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe'} +if($PSVersionTable.PSEdition -cne 'Desktop' -or !(Test-Path $SetupPath -PathType Leaf)){ + throw 'R334_REQUIRES_COMPILED_WINDOWS_DOTNET_FRAMEWORK_SETUP' +} +$setup=(Resolve-Path -LiteralPath $SetupPath).Path +$a=[Reflection.Assembly]::LoadFrom($setup) +$t=$a.GetType('SetupProgram',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$helper=$t.GetMethod('BuildElevatedSensorProcessInfo',$flags) +$pin=$t.GetMethod('OpenPinnedVerifiedMainForLaunch',$flags) +$mainInfo=$t.GetMethod('BuildPinnedMainProcessInfo',$flags) +$cleanup=$t.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) +$appProp=$t.GetProperty('AppRoot',$flags) +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Invoke-Fn([Reflection.MethodInfo]$method,[object[]]$inputs){ + $native=New-Object 'object[]' $inputs.Count + for($i=0;$i -lt $inputs.Count;$i++){ + $native[$i]=if($null -eq $inputs[$i]){$null}else{$inputs[$i].PSObject.BaseObject} + } + try {return $method.Invoke($null,$native)} + catch{ + $e=$_.Exception + while($e.InnerException){$e=$e.InnerException} + throw $e + } +} +function Reject([string]$label,[scriptblock]$action,[string]$match){ + $why='' + try{& $action|Out-Null;throw ('R334_FAIL_OPEN_'+$label)} + catch{ + $errorObject=$_.Exception + while($errorObject.InnerException){$errorObject=$errorObject.InnerException} + $why=$errorObject.Message + } + if($why -match '^R334_FAIL_OPEN_' -or $why -notmatch $match){ + throw ('R334_WRONG_REJECTION_'+$label+'='+$why) + } + Pass $label +} +function Decode([string]$arguments,[switch]$RequireEncoded){ + $head='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + if($arguments.Contains(' -File ')){throw 'R334_UNTRUSTED_FILE_SCRIPT_TRANSPORT'} + if($arguments.StartsWith($head,[StringComparison]::Ordinal)){ + $blob=$arguments.Substring($head.Length) + if($blob -cnotmatch '^[A-Za-z0-9+/]+={0,2}$'){throw 'R334_UNSAFE_ENCODED_COMMAND_ALPHABET'} + return [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($blob)) + } + if($RequireEncoded){throw 'R334_CLEANUP_REQUIRES_ENCODED_COMMAND_NOT_COMMAND_SOURCE'} + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -Command "& { ' + $suffix=' }"' + if(!$arguments.StartsWith($prefix,[StringComparison]::Ordinal) -or + !$arguments.EndsWith($suffix,[StringComparison]::Ordinal) -or + $arguments.Length -gt 30000){ + throw 'R334_UNEXPECTED_TRUSTED_INLINE_HELPER_BOOTSTRAP' + } + $source=$arguments.Substring($prefix.Length,$arguments.Length-$prefix.Length-$suffix.Length) + if($source.Contains('"') -or $source.Contains([string][char]0)){ + throw 'R334_INLINE_HELPER_BOOTSTRAP_QUOTE_OR_NULL_IN_SOURCE' + } + return $source +} +if(!$helper -or !$pin -or !$mainInfo -or !$cleanup -or !$appProp){throw 'R334_MISSING_SHIPPED_PROCESS_FACTORIES'} +Pass 'EXACT_THREE_PROCESS_STARTINFO_FACTORIES_PRESENT' +$resource=$a.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs') +if(!$resource){throw 'R334_CANONICAL_SOURCE_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$source=[IO.File]::ReadAllText((Resolve-Path (Join-Path $PSScriptRoot '..\installer\TaskbarMonitorEnhanced_Setup.cs')).Path) +$norm={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $embedded) -cne (& $norm $source)){throw 'R334_SHIPPED_PE_NOT_MATCHING_CURRENT_SOURCE'} +Pass 'REAL_PE_EMBEDDED_SOURCE_IDENTICAL' +$system=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$payload='C:\NOT_REAL & ;\payload' +$app='C:\FAKE & ;\AppData\Local\TaskbarMonitorEnhanced' +$user="R334';Write-Output MARKER_NEVER_EXECUTED;#" +$args=New-Object 'object[]' 4 +$args[0]='Install';$args[1]=$payload;$args[2]=$app;$args[3]=$user +$psi=[Diagnostics.ProcessStartInfo](Invoke-Fn $helper $args) +if(!$psi.FileName.Equals($system,[StringComparison]::OrdinalIgnoreCase) -or + !$psi.WorkingDirectory.Equals([IO.Path]::GetDirectoryName($system),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R334_ELEVATED_POWERSHELL_HOST_NOT_SYSTEM32' +} +Pass 'HELPER_PINNED_SYSTEM32_EXECUTABLE_AND_WORKDIR' +if($psi.UseShellExecute -ne $true -or $psi.Verb -cne 'runas'){throw 'R334_NATIVE_UAC_VERB_MISSING'} +Pass 'HELPER_REQUIRES_NATIVE_RUNAS_WITHOUT_BYPASS' +$text=Decode $psi.Arguments +Pass 'HELPER_COMMAND_IS_BOUNDED_UTF16_ENCODED_OR_TRUSTED_BASE64_INLINE' +foreach($pair in @(@('PAYLOAD',$payload),@('APPROOT',$app),@('IDENTITY',$user))){ + $blob=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$pair[1])) + if(!$text.Contains("'"+$blob+"'") -or + [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($blob)) -cne $pair[1]){ + throw ('R334_HELPER_ARGUMENT_BYTE_ROUNDTRIP_FAILED_'+$pair[0]) + } + Pass ('HELPER_UTF8_BASE64_DATA_ONLY_'+$pair[0]) +} +if($text.Contains('MARKER_NEVER_EXECUTED') -or + $psi.Arguments.Contains('MARKER_NEVER_EXECUTED') -or + $text.Contains($user) -or $text.Contains($app) -or $text.Contains($payload)){ + throw 'R334_UNTRUSTED_SYNTAX_ESCAPED_FROM_BASE64_STRING' +} +Pass 'HOSTILE_POWERSHELL_SYNTAX_NOT_INSERTED_IN_SCRIPT_SOURCE' +if(!$text.Contains("-Mode 'Install'") -or + !$text.Contains('-ExpectedBrokerSHA256') -or + !$text.Contains('-ExpectedSupervisorSHA256') -or + !$text.Contains('-ExpectedBackendZipSHA256')){throw 'R334_STATIC_HELPER_MODE_OR_HASH_BINDING_REMOVED'} +Pass 'HELPER_EXACT_MODE_LITERAL_AND_THREE_HASH_PINS' +Reject 'HELPER_REFUSES_NON_ALLOWLISTED_MODE' { + $values=New-Object 'object[]' 4;$values[0]='Inject';$values[1]=$payload;$values[2]=$app;$values[3]=$user + [void](Invoke-Fn $helper $values) +} 'Unexpected elevated sensor mode' +Reject 'HELPER_REFUSES_OVERLONG_ARGUMENT' { + $values=New-Object 'object[]' 4;$values[0]='Install';$values[1]=$payload;$values[2]=$app;$values[3]=('x'*4097) + [void](Invoke-Fn $helper $values) +} 'missing or too long' +$fallback=$false +foreach($n in @(2500,3500,4050)){ + $values=New-Object 'object[]' 4;$values[0]='Uninstall';$values[1]=$payload;$values[2]=$app;$values[3]=('R334;'+('x'*$n)) + $result=[Diagnostics.ProcessStartInfo](Invoke-Fn $helper $values) + if($result.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -Command "',[StringComparison]::Ordinal)){ + $fallback=$true + if($result.Arguments.Contains('R334;') -or $result.Arguments.Length -gt 30000 -or + $result.Verb -cne 'runas' -or $result.UseShellExecute -ne $true){ + throw 'R334_LONG_HELPER_COMMAND_EXPOSED_RAW_UNTRUSTED_SOURCE' + } + Pass 'LONG_HELPER_COMMAND_FALLBACK_BOUNDED_WITH_ONLY_BASE64_USER_DATA' + break + } +} +if(!$fallback){throw 'R334_LONG_HELPER_CANNOT_REACH_TRUSTED_INLINE_FALLBACK'} + +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r334_command_'+[guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$main=Join-Path $base 'TaskbarMonitorEnhanced.exe' +$fh=$null +try{ + $res=$a.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced.exe') + if(!$res){throw 'R334_EMBEDDED_MAIN_PE_NOT_AVAILABLE'} + $mem=[IO.MemoryStream]::new() + try{$res.CopyTo($mem)}finally{$res.Dispose()} + $bytes=$mem.ToArray();$mem.Dispose() + [IO.File]::WriteAllBytes($main,$bytes) + $hash=[Security.Cryptography.SHA256]::Create() + try{$expected=([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-','')} + finally{$hash.Dispose()} + $values=New-Object 'object[]' 3;$values[0]=$main;$values[1]=$base;$values[2]=$expected + $fh=[IO.FileStream](Invoke-Fn $pin $values) + Pass 'MAIN_EXACT_EMBEDDED_SHA_FILESTREAM_READ_PIN' + $values=New-Object 'object[]' 2;$values[0]=$fh;$values[1]=$base + $mainPsi=[Diagnostics.ProcessStartInfo](Invoke-Fn $mainInfo $values) + if(!$mainPsi.FileName.Equals([IO.Path]::GetFullPath($main),[StringComparison]::OrdinalIgnoreCase) -or + $mainPsi.Arguments -cne '' -or $mainPsi.UseShellExecute -ne $false -or + $mainPsi.ErrorDialog -ne $false){throw 'R334_SHA_PINNED_MAIN_HAS_COMMAND_OR_SHELL'} + Pass 'MAIN_SHA_PINNED_EXACT_FILENAME_NO_ARGUMENTS_NO_SHELL' + Reject 'MAIN_REFUSES_DIFFERENT_INSTALL_ROOT' { + $v=New-Object 'object[]' 2;$v[0]=$fh;$v[1]=(Join-Path $base 'wrong') + [void](Invoke-Fn $mainInfo $v) + } 'escaped|unavailable' + $fh.Position=0 + $h=[Security.Cryptography.SHA256]::Create() + try{$actual=([BitConverter]::ToString($h.ComputeHash($fh))).Replace('-','')} + finally{$h.Dispose()} + if($actual -cne $expected){throw 'R334_MAIN_FILE_BYTES_CHANGED_WHILE_PINNED'} + Pass 'MAIN_HANDLE_SHA_MATCHES_IMMUTABLE_SETUP_RESOURCE' +}finally{ + if($fh){$fh.Dispose()} + if([IO.Directory]::Exists($base) -and + [IO.Path]::GetFileName($base).StartsWith('tbme_r334_command_',[StringComparison]::Ordinal)){ + [IO.Directory]::Delete($base,$true) + } +} +$root=[string]$appProp.GetValue($null,$null) +$pidValue=[Diagnostics.Process]::GetCurrentProcess().Id +$ticks=[DateTime]::UtcNow.AddMinutes(-1).Ticks +$v=New-Object 'object[]' 3;$v[0]=$root;$v[1]=$pidValue;$v[2]=$ticks +$cleanPsi=[Diagnostics.ProcessStartInfo](Invoke-Fn $cleanup $v) +if(!$cleanPsi.FileName.Equals($system,[StringComparison]::OrdinalIgnoreCase) -or + $cleanPsi.UseShellExecute -ne $false -or $cleanPsi.CreateNoWindow -ne $true -or + !$cleanPsi.WorkingDirectory.Equals([Environment]::SystemDirectory,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R334_CLEANUP_NOT_EXACT_NATIVE_UNELEVATED_POWERSHELL' +} +Pass 'CLEANUP_EXACT_SYSTEM_POWERSHELL_WITHOUT_SHELL' +$decoded=Decode $cleanPsi.Arguments -RequireEncoded +$rootBlob=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($root)) +if(!$decoded.Contains("'"+$rootBlob+"'") -or $decoded.Contains($root) -or + $decoded.Contains('__ROOT_B64__')){throw 'R334_CLEANUP_USER_ROOT_ENTERED_COMMAND_AS_RAW_PATH'} +Pass 'CLEANUP_ROOT_TRANSPORTED_ONLY_AS_UTF8_BASE64_DATA' +if(!$decoded.Contains('TBME_CLEANUP_WRONG_EFFECTIVE_TOKEN') -or + !$decoded.Contains('TBME_CLEANUP_ROOT_NOT_CANONICAL') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R334_CLEANUP_OWNER_SID_REPARSE_NONRECURSIVE_GUARDS_MISSING' +} +Pass 'CLEANUP_TOKEN_CANONICAL_ROOT_AND_NONRECURSIVE_GUARDS' +foreach($case in @( + @('FOREIGN_ROOT',(Join-Path $root 'wrong'),$pidValue,$ticks), + @('ZERO_PID',$root,0,$ticks), + @('ZERO_START',$root,$pidValue,0))){ + $item=$case + Reject ('CLEANUP_REJECTS_'+$item[0]) { + $v=New-Object 'object[]' 3;$v[0]=[string]$item[1];$v[1]=[int]$item[2];$v[2]=[long]$item[3] + [void](Invoke-Fn $cleanup $v) + } 'exact owner root and parent identity' +} +Pass 'ALL_THREE_FACTORIES_INSPECTED_WITHOUT_LAUNCHING_ANY_PROCESS' +if($checks.Count -ne 24){throw ('R334_WRONG_TOTAL_'+$checks.Count)} +Write-Output ('R334_THREE_COMMAND_SINKS_NATIVE='+$checks.Count+'/24 PASS') + +if(!$HostedGuest){return} +if($HostLabel -cnotin @('A','B') -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R334_REQUIRE_DISPOSABLE_HOSTED_WINDOWS_GUEST' +} +$folder=Join-Path $env:RUNNER_TEMP ('R334_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +if([IO.Directory]::Exists($folder)){throw 'R334_EXISTING_GUEST_WITNESS_REFUSED'} +[IO.Directory]::CreateDirectory($folder)|Out-Null +$receipt=[ordered]@{ + Schema=1;Revision='R334';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA; + Status='PASS_THREE_PROCESS_COMMAND_SINK_TRUST_CONTRACTS'; + SetupSHA256=(Get-FileHash $setup -Algorithm SHA256).Hash; + Checks=@($checks.ToArray());CompiledSetupReflected=$true; + HelperAndCleanupInputsOnlyInEncodedData=$true;ShaPinnedMainNoShell=$true; + NativeUacWasNotInvoked=$true;NoCleanupOrMainProcessLaunched=$true; + WholeSecurityAccepted=$false +} +$p=Join-Path $folder 'R334_COMMAND_SINK_TRUST_RECEIPT.json' +[IO.File]::WriteAllText($p,($receipt|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +Write-Output ('R334_HOSTED_GUEST_RECEIPT_SHA256='+(Get-FileHash $p -Algorithm SHA256).Hash) From 86f327a4a081776dad3b932b70901eb20f1a249e Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 01:15:02 +0330 Subject: [PATCH 092/135] fix(sensor): pin privileged log/result writes via no-reparse handles (R335) --- .github/workflows/ci.yml | 47 ++++ build/Test-R328-HelperLogBoundary-Native.ps1 | 8 +- build/Test-R335-AtomicUserLogSink-Native.ps1 | 241 +++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 121 +++++++++- installer/TaskbarMonitorEnhanced_Setup.cs | 42 +++- 5 files changed, 446 insertions(+), 13 deletions(-) create mode 100644 build/Test-R335-AtomicUserLogSink-Native.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32a40810..e5a97799 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,12 @@ jobs: & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R334-CommandSinkContracts-Native.ps1 if ($LASTEXITCODE -ne 0) { throw 'R334 no-UAC three-process-command-sink adversarial contract regression failed.' } + - name: R335 privileged user log/result sink pinned handle boundary (native) + shell: pwsh + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R335-AtomicUserLogSink-Native.ps1 + if ($LASTEXITCODE -ne 0) { throw 'R335 compiled privileged Helper scoped handle guard regressions failed.' } + - name: Verify deterministic clean-clone build shell: pwsh run: ./build/Verify-Determinism.ps1 @@ -476,6 +482,47 @@ jobs: retention-days: 7 path: ${{ runner.temp }}/R334_${{ matrix.host }}_${{ github.run_id }}/R334_COMMAND_SINK_TRUST_RECEIPT.json + - name: R335 real Setup embeds exact native no-reparse handle guard on guest ${{ matrix.host }} + shell: powershell + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or (git rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R335_NATIVE_REFUSE_NON_HOSTED_OR_CHANGED_SOURCE' + } + $setup=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + $manifest=@(Get-ChildItem r324_built_private -Recurse -File -Filter 'BUILD_MANIFEST.json') + if($setup.Count -ne 1 -or $manifest.Count -ne 1){throw 'R335_BUILD_PROOF_AMBIGUOUS'} + $m=Get-Content $manifest[0].FullName -Raw|ConvertFrom-Json + $target=@($m.Outputs|Where-Object Name -eq 'TaskbarMonitorEnhanced_Setup_1.7.8.exe') + if($target.Count -ne 1 -or + (Get-FileHash $setup[0].FullName -Algorithm SHA256).Hash -cne [string]$target[0].SHA256){ + throw 'R335_PRIVATE_SETUP_NOT_BUILD_MANIFEST_PINNED' + } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File ./build/Test-R335-AtomicUserLogSink-Native.ps1 -SetupPath $setup[0].FullName -HostedGuest -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){throw 'R335_REAL_PRIVATE_SETUP_HANDLE_GUEST_FAILED'} + $receipt=Join-Path $env:RUNNER_TEMP ('R335_${{ matrix.host }}_'+$env:GITHUB_RUN_ID+'/R335_ATOMIC_LOG_HANDLE_PROBE.json') + if(!(Test-Path $receipt -PathType Leaf)){throw 'R335_EXACT_TYPED_GUEST_RECEIPT_MISSING'} + $r=Get-Content $receipt -Raw|ConvertFrom-Json + if($r.Schema -ne 1 -or $r.Revision -cne 'R335' -or $r.Host -cne '${{ matrix.host }}' -or + $r.SourceCommit -cne $env:GITHUB_SHA -or + $r.Status -cne 'PASS_SCOPED_NONDELETE_HANDLE_LOGS_APPROOT_AND_FILE' -or + @($r.Checks).Count -ne 23 -or + $r.RealElevatedHelperLaunched -ne $false -or + $r.ActualProgramFilesModified -ne $false -or + $r.AtomicCrossUserUacProven -ne $false -or + $r.ForeignCanaryPreserved -ne $true -or + $r.SecurityFinalAccepted -ne $false){ + throw 'R335_HOSTED_GUEST_RECEIPT_CONTRACT_FAILED' + } + - name: Preserve bounded R335 native user-log-handle proof + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: R335-user-log-handle-${{ matrix.host }}-${{ github.sha }} + retention-days: 7 + if-no-files-found: error + path: ${{ runner.temp }}/R335_${{ matrix.host }}_${{ github.run_id }}/R335_ATOMIC_LOG_HANDLE_PROBE.json + - name: Preserve bounded raw hosted negative receipt uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/build/Test-R328-HelperLogBoundary-Native.ps1 b/build/Test-R328-HelperLogBoundary-Native.ps1 index 8d069d08..36fa636f 100644 --- a/build/Test-R328-HelperLogBoundary-Native.ps1 +++ b/build/Test-R328-HelperLogBoundary-Native.ps1 @@ -27,6 +27,10 @@ Pass 'EXACT_SETUP_PE_EMBEDS_TRUSTED_HELPER_SOURCE' $tokens=$null;$parseErrors=$null $ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$parseErrors) if(@($parseErrors).Count -ne 0){throw 'R328_TRUSTED_HELPER_AST_PARSE_ERRORS'} +$native=[regex]::Matches($source,'(?s)Add-Type -ReferencedAssemblies @\(''System.dll'',''System.Core.dll''\) -TypeDefinition @''\r?\n(.*?)\r?\n''@') +if($native.Count -ne 1){throw 'R335_NATIVE_CLASS_SOURCE_NOT_UNIQUE'} +Add-Type -ReferencedAssemblies @('System.dll','System.Core.dll') -TypeDefinition $native[0].Groups[1].Value +if($null -eq ('TBMEScopedUserLogLease' -as [type])){throw 'R335_EXPECTED_PRODUCTION_CLASS_NOT_COMPILED'} foreach($fn in @('Assert-SensorBoundaryNoReparse','Assert-SensorUserLogSink','Write-Log','Write-Result')){ $definition=@($ast.FindAll({param($x) $x -is [Management.Automation.Language.FunctionDefinitionAst]},$true)| Where-Object Name -EQ $fn) @@ -35,8 +39,10 @@ foreach($fn in @('Assert-SensorBoundaryNoReparse','Assert-SensorUserLogSink','Wr } $base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r328_logs_'+[Guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($base)|Out-Null -$AppRoot=Join-Path $base 'isolated profile with & $special' +$profile=Join-Path $base 'isolated profile with & $special' +$AppRoot=Join-Path $profile 'AppData\Local\TaskbarMonitorEnhanced' [IO.Directory]::CreateDirectory($AppRoot)|Out-Null +$script:ValidatedSensorProfileRoot=$profile $LogsRoot=Join-Path $AppRoot 'Logs' [IO.Directory]::CreateDirectory($LogsRoot)|Out-Null $ResultPath=Join-Path $LogsRoot 'sensor_install_result.json' diff --git a/build/Test-R335-AtomicUserLogSink-Native.ps1 b/build/Test-R335-AtomicUserLogSink-Native.ps1 new file mode 100644 index 00000000..55923398 --- /dev/null +++ b/build/Test-R335-AtomicUserLogSink-Native.ps1 @@ -0,0 +1,241 @@ +[CmdletBinding()] +param([string]$SetupPath='', [switch]$HostedGuest,[ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL') +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop'){throw 'R335_REQUIRES_NATIVE_WINDOWS_NETFRAMEWORK'} +if([string]::IsNullOrWhiteSpace($SetupPath)) { + $SetupPath=Join-Path $PSScriptRoot '_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +} +if(!(Test-Path -LiteralPath $SetupPath -PathType Leaf)){throw 'R335_EXACT_COMPILED_SETUP_NOT_PRESENT'} +$setup=(Resolve-Path -LiteralPath $SetupPath).Path +$assembly=[Reflection.Assembly]::LoadFrom($setup) +$resource=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$resource){throw 'R335_PRODUCTION_HELPER_RESOURCE_MISSING'} +$reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) +try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} +$helperPath=(Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\installer\TBME_Setup_Elevated_Helper.ps1')).Path +$production=[IO.File]::ReadAllText($helperPath,[Text.Encoding]::UTF8) +$normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $normalize $production) -cne (& $normalize $embedded)){throw 'R335_COMPILED_PE_HELPER_SOURCE_NOT_EXACT'} +$cls=[regex]::Matches($production,'(?s)Add-Type -ReferencedAssemblies @\(''System.dll'',''System.Core.dll''\) -TypeDefinition @''\r?\n(.*?)\r?\n''@') +if($cls.Count -ne 1 -or !$cls[0].Groups[1].Value.Contains('public sealed class TBMEScopedUserLogLease : IDisposable')){throw 'R335_REAL_PRODUCTION_HANDLE_CLASS_NOT_UNIQUE'} +Add-Type -ReferencedAssemblies @('System.dll','System.Core.dll') -TypeDefinition $cls[0].Groups[1].Value -ErrorAction Stop +if($null -eq ('TBMEScopedUserLogLease' -as [type])){throw 'R335_PRODUCTION_HANDLE_CLASS_NOT_LOADED'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function CanRename([string]$path){ + $to=$path+'_moved' + try{ + [IO.Directory]::Move($path,$to) + [IO.Directory]::Move($to,$path) + return $true + }catch [IO.IOException]{return $false} + catch [UnauthorizedAccessException]{return $false} +} +function Attempt-FileRename([string]$file){ + $out=$file+'_renamed' + try{[IO.File]::Move($file,$out);[IO.File]::Move($out,$file);return $true} + catch [IO.IOException]{return $false} + catch [UnauthorizedAccessException]{return $false} +} +function Refuse([string]$name,[scriptblock]$action,[string]$reason){ + $message='' + try{& $action|Out-Null;throw 'R335_FAIL_OPEN'} + catch{ + $ex=$_.Exception + while($ex.InnerException){$ex=$ex.InnerException} + $message=$ex.Message + } + if($message -eq 'R335_FAIL_OPEN' -or $message -notmatch $reason){ + throw ('R335_REFUSAL_WRONG_'+$name+' ERROR='+$message) + } + Pass $name +} +Pass 'REAL_COMPILED_SETUP_EMBEDS_EXACT_PRODUCTION_HANDLE_SOURCE' +Pass 'PRODUCTION_LOG_GUARD_CLASS_LOADED_FROM_EMBEDDED_HELPER' +# R335 regression of the full trusted PE-helper transport, not just its C# +# handle type. It must preserve the literal here-string while compacting all +# other safe lines and stay within the Windows ShellExecute command limit. +$builder=$assembly.GetType('SetupProgram',$true).GetMethod( + 'BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') +if(!$builder){throw 'R335_EXACT_ELEVATED_PROCESS_BUILDER_NOT_PRESENT'} +$args=New-Object 'object[]' 4 +$args[0]='Install' +$args[1]='C:\R335_TEST_ONLY\payload & literal' +$args[2]='C:\R335_TEST_ONLY\AppData\Local\TaskbarMonitorEnhanced' +$args[3]='R335\USER_TEST_ONLY' +$psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) +$system=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +if(!$psi.FileName.Equals($system,[StringComparison]::OrdinalIgnoreCase) -or + $psi.Verb -cne 'runas' -or $psi.UseShellExecute -ne $true){ + throw 'R335_HELPER_PROCESS_UAC_AND_SYSTEM_EXE_NO_LONGER_PINNED' +} +Pass 'BUILD_ELEVATED_HELPER_PRESERVES_SYSTEM32_RUNAS_NO_LAUNCH' +$argline=$psi.Arguments +$inline='-NoProfile -NonInteractive -WindowStyle Hidden -Command "& { ' +$tail=' }"' +$encoded='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' +if($argline.StartsWith($encoded,[StringComparison]::Ordinal)){ + $bootstrap=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($argline.Substring($encoded.Length))) +}elseif($argline.StartsWith($inline,[StringComparison]::Ordinal) -and + $argline.EndsWith($tail,[StringComparison]::Ordinal) -and + $argline.Length -le 30000){ + $bootstrap=$argline.Substring($inline.Length,$argline.Length-$inline.Length-$tail.Length) +}else{throw 'R335_TRANSPORT_NOT_BOUNDED_INLINE_OR_UTF16_ENCODED'} +if($bootstrap.Contains('R335_TEST_ONLY') -or $argline.Contains('R335_TEST_ONLY')){ + throw 'R335_UNTRUSTED_PATH_REACHED_COMMAND_TEXT_OUTSIDE_BASE64' +} +Pass 'TRUSTED_HELPER_BOOTSTRAP_BOUNDED_AND_UNTRUSTED_PATHS_ENCODED' +$parts=[regex]::Matches($bootstrap,'\$blob=\[Convert\]::FromBase64String\(''([A-Za-z0-9+/=]+)''\)') +if($parts.Count -ne 1){throw 'R335_TRANSPORT_COMPRESSED_PE_HELPER_SOURCE_MISSING'} +$packed=[Convert]::FromBase64String($parts[0].Groups[1].Value) +$stream=[IO.MemoryStream]::new($packed) +$gzip=[IO.Compression.GZipStream]::new($stream,[IO.Compression.CompressionMode]::Decompress) +$textReader=[IO.StreamReader]::new($gzip,[Text.Encoding]::UTF8) +try{$trusted=$textReader.ReadToEnd()} +finally{$textReader.Dispose();$gzip.Dispose();$stream.Dispose()} +$tok=$null;$parseErr=$null +[Management.Automation.Language.Parser]::ParseInput($trusted,[ref]$tok,[ref]$parseErr)|Out-Null +if(@($parseErr).Count -ne 0){throw ('R335_TRANSPORTED_PE_HELPER_POWERSHELL_PARSE_ERRORS_'+@($parseErr).Count)} +Pass 'COMPRESSED_TRUSTED_HELPER_FULL_SCRIPT_PS51_PARSE_PASS' +$literal=$cls[0].Value.Replace(([string][char]13+[string][char]10),[string][char]10) +if(!$trusted.Contains($literal) -or + !$trusted.Contains('public sealed class TBMEScopedUserLogLease : IDisposable')){ + throw 'R335_TRUSTED_CSHARP_HERE_LITERAL_WAS_COMPACTED_OR_CORRUPTED' +} +Pass 'TRUSTED_NATIVE_CSHARP_HERE_LITERAL_PRESERVED_BYTE_IDENTICAL' +$base=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r335_loglease_'+[Guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($base)|Out-Null +$profile=Join-Path $base 'profile & literal dollar' +$local=Join-Path $profile 'AppData\Local' +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$logs=Join-Path $app 'Logs' +$file=Join-Path $logs 'sensor_install.log' +$external=Join-Path $base 'foreign-outside' +[IO.Directory]::CreateDirectory($logs)|Out-Null +[IO.Directory]::CreateDirectory($external)|Out-Null +$canary=Join-Path $external 'NO_WRITE.txt' +[IO.File]::WriteAllText($canary,'R335_FOREIGN_CANARY') +[IO.File]::WriteAllText($file,'PREEXISTING_LOG') +$lease=$null +try{ + if(!(CanRename $logs) -or !(CanRename $app) -or !(Attempt-FileRename $file)){ + throw 'R335_SANDBOX_BASELINE_RENAME_EXPECTED_PERMITTED' + } + Pass 'BASELINE_UNGUARDED_DIR_AND_LEAF_RENAME_ALLOWED' + $lease=[TBMEScopedUserLogLease]::new($profile,$app,$logs,$file) + Pass 'OPENED_ALL_FIVE_NO_REPARSE_COMPONENT_HANDLES_AND_EXACT_FILE' + if(CanRename $logs){throw 'R335_LOGS_RENAME_ALLOWED_WHILE_PINNED'} + Pass 'GUARD_BLOCKS_LOGS_FOLDER_RENAME' + if(CanRename $app){throw 'R335_APPROOT_RENAME_ALLOWED_WHILE_PINNED'} + Pass 'GUARD_BLOCKS_APPROOT_FOLDER_RENAME' + if(CanRename $local){throw 'R335_LOCAL_PARENT_RENAME_ALLOWED_WHILE_PINNED'} + Pass 'GUARD_BLOCKS_LOCAL_PARENT_RENAME' + if(Attempt-FileRename $file){throw 'R335_LEAF_RENAME_ALLOWED_WHILE_PINNED'} + Pass 'GUARD_BLOCKS_EXACT_LOG_FILE_RENAME' + $lease.Append('R335_APPEND_IS_CANARY_ONLY') + # The FileStream holding WRITE access intentionally denies external reads + # when their share mode denies writes. Inspect the bytes after lease disposal. + $win=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $child='param([string]$p);try{[IO.Directory]::Move($p,($p+''_actor''));[IO.Directory]::Move(($p+''_actor''),$p);exit 61}catch [IO.IOException]{exit 0}catch [UnauthorizedAccessException]{exit 0}' + $actor=Join-Path $base 'sandbox-actor.ps1' + [IO.File]::WriteAllText($actor,$child,[Text.UTF8Encoding]::new($false)) + & $win -NoProfile -NonInteractive -File $actor -p $logs + if($LASTEXITCODE -ne 0){throw ('R335_SEPARATE_PROCESS_RENAME_NOT_DENIED_'+$LASTEXITCODE)} + Pass 'SEPARATE_UNELEVATED_PROCESS_RENAME_BLOCKED_DURING_GUARD' + $lease.Dispose();$lease=$null + if(![IO.File]::ReadAllText($file).Contains('R335_APPEND_IS_CANARY_ONLY')){ + throw 'R335_GUARDED_LOG_WRITE_NOT_PERSISTED' + } + Pass 'EXACT_PINNED_FILESTREAM_WRITE_SUCCEEDED' + if(!(CanRename $logs) -or !(CanRename $app) -or !(Attempt-FileRename $file)){ + throw 'R335_FOLDER_OR_FILE_RENAME_STILL_BLOCKED_AFTER_DISPOSAL' + } + Pass 'RENAMES_REENABLED_AFTER_ALL_HANDLES_CLOSED' + $retired=$logs+'_clean' + [IO.Directory]::Move($logs,$retired) + $link=$false + try{ + New-Item -ItemType Junction -Path $logs -Target $external -ErrorAction Stop|Out-Null + $link=$true + Refuse 'LOGS_JUNCTION_REJECTED_BEFORE_OPEN_FILE' {[TBMEScopedUserLogLease]::new($profile,$app,$logs,$file)} 'SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED' + }finally{ + if($link -and [IO.Directory]::Exists($logs)){[IO.Directory]::Delete($logs)} + [IO.Directory]::Move($retired,$logs) + } + [IO.File]::Delete($file) + $link=$false + try{ + New-Item -ItemType Junction -Path $file -Target $external -ErrorAction Stop|Out-Null + $link=$true + Refuse 'LOG_FILE_REPARSE_LINK_REJECTED_BEFORE_WRITE' {[TBMEScopedUserLogLease]::new($profile,$app,$logs,$file)} 'SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED|SENSOR_LOG_HANDLE_CREATEFILE_FAILED_WIN32_5' + }finally{ + if($link -and [IO.Directory]::Exists($file)){[IO.Directory]::Delete($file)} + [IO.File]::WriteAllText($file,'RESTORED_LOG') + } + Refuse 'FOREIGN_APP_ROOT_LEXICALLY_REFUSED' {[TBMEScopedUserLogLease]::new($profile,$external,$logs,$file)} 'SENSOR_LOG_HANDLE_CANONICAL_LOG_PATH_MISMATCH' + if([IO.File]::ReadAllText($canary) -cne 'R335_FOREIGN_CANARY'){ + throw 'R335_OUTSIDE_CANARY_MODIFIED' + } + Pass 'OUTSIDE_FOREIGN_CANARY_UNMODIFIED' + $resultFile=Join-Path $logs 'sensor_install_result.json' + $resultLease=[TBMEScopedUserLogLease]::new($profile,$app,$logs,$resultFile) + try{$resultLease.Overwrite('{"Status":"R335_OK","SensorHealthy":false}'+[Environment]::NewLine)} + finally{$resultLease.Dispose()} + $result=Get-Content -LiteralPath $resultFile -Raw|ConvertFrom-Json + if($result.Status -cne 'R335_OK' -or $result.SensorHealthy -ne $false){ + throw 'R335_UTF8_BOM_RESULT_JSON_NOT_VALID' + } + Pass 'GUARDED_RESULT_JSON_UTF8_BOM_AND_SCHEMA_VALID' + $resultLease=[TBMEScopedUserLogLease]::new($profile,$app,$logs,$resultFile) + try{$resultLease.Overwrite('{"Status":"R335_FINAL"}'+[Environment]::NewLine)} + finally{$resultLease.Dispose()} + $bytes=[IO.File]::ReadAllBytes($resultFile) + $text=[Text.Encoding]::UTF8.GetString($bytes) + if($text.Contains('R335_OK') -or !$text.Contains('R335_FINAL') -or + $bytes[0] -ne 239 -or $bytes[1] -ne 187 -or $bytes[2] -ne 191){ + throw 'R335_BOM_RESULT_OVERWRITE_LEAKED_PREVIOUS_JSON' + } + Pass 'GUARDED_RESULT_OVERWRITE_TRUNCATES_AND_RETAINS_UTF8_BOM' + [IO.File]::Delete($resultFile) + $linked=$false + try{ + New-Item -ItemType Junction -Path $resultFile -Target $external -ErrorAction Stop|Out-Null + $linked=$true + Refuse 'RESULT_JSON_REPARSE_LEAF_REFUSED' {[TBMEScopedUserLogLease]::new($profile,$app,$logs,$resultFile)} 'SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED|SENSOR_LOG_HANDLE_CREATEFILE_FAILED_WIN32_5' + }finally{ + if($linked -and [IO.Directory]::Exists($resultFile)){[IO.Directory]::Delete($resultFile)} + } + if([IO.File]::ReadAllText($canary) -cne 'R335_FOREIGN_CANARY'){ + throw 'R335_RESULT_REPARSE_WROTE_OUTSIDE_CANARY' + } + Pass 'RESULT_JSON_REPARSE_EXTERNAL_CANARY_INTACT' + if($checks.Count -ne 23){throw ('R335_EXPECTED_19_CHECKS_ACTUAL_'+$checks.Count)} + Write-Output ('R335_DISPOSABLE_USER_LOG_HANDLE_PROBE=23/23_PASS') + if($HostedGuest){ + if($HostLabel -cnotin @('A','B') -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or $env:GITHUB_RUN_ID -cnotmatch '^\d+$'){ + throw 'R335_TEST_REQUIRES_DISPOSABLE_GITHUB_WINDOWS' + } + $dir=Join-Path $env:RUNNER_TEMP ('R335_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path $dir){throw 'R335_GUEST_RAW_RECEIPT_EXISTS'} + [IO.Directory]::CreateDirectory($dir)|Out-Null + $j=[ordered]@{Schema=1;Revision='R335';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA; + Status='PASS_SCOPED_NONDELETE_HANDLE_LOGS_APPROOT_AND_FILE'; + Checks=@($checks.ToArray());RealElevatedHelperLaunched=$false; + ActualProgramFilesModified=$false;AtomicCrossUserUacProven=$false; + ForeignCanaryPreserved=$true;SecurityFinalAccepted=$false} + $receipt=Join-Path $dir 'R335_ATOMIC_LOG_HANDLE_PROBE.json' + [IO.File]::WriteAllText($receipt,($j|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + Write-Output ('R335_HOSTED_RAW_RECEIPT_SHA256='+(Get-FileHash $receipt -Algorithm SHA256).Hash) + } +}finally{ + if($lease){$lease.Dispose()} + if([IO.Directory]::Exists($base) -and [IO.Path]::GetFileName($base).StartsWith('tbme_r335_loglease_',[StringComparison]::Ordinal)){ + $links=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue| + Where-Object {($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}) + if($links.Count -ne 0){throw 'R335_SANDBOX_REPARSE_REFUSE_CLEANUP'} + [IO.Directory]::Delete($base,$true) + } +} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 6517d98f..3f7510fc 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -115,10 +115,120 @@ function Assert-SensorPrivilegedBoundary( (Join-Path $brokerRoot 'TaskbarMonitorSensorSupervisor.exe') ) foreach($path in $candidates){Assert-SensorBoundaryNoReparse $path} + $script:ValidatedSensorProfileRoot=$profile return $sid } $validatedSensorSid=Assert-SensorPrivilegedBoundary $Mode $PayloadDir $AppRoot $UserId +# R335: native in-memory handle guards pin canonical per-user profile +# ancestry and exact file handles across log/result writes. Refuse reparse +# points by handle, never create a user-writable script or change UAC policy. +Add-Type -ReferencedAssemblies @('System.dll','System.Core.dll') -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public sealed class TBMEScopedUserLogLease : IDisposable { + const uint FILE_LIST_DIRECTORY=0x0001; + const uint GENERIC_READ=0x80000000; + const uint GENERIC_WRITE=0x40000000; + const uint FILE_SHARE_READ=1,FILE_SHARE_WRITE=2; + const uint OPEN_EXISTING=3, OPEN_ALWAYS=4; + const uint FILE_FLAG_BACKUP_SEMANTICS=0x02000000; + const uint FILE_FLAG_OPEN_REPARSE_POINT=0x00200000; + const uint FILE_ATTRIBUTE_DIRECTORY=0x10; + const uint FILE_ATTRIBUTE_REPARSE_POINT=0x400; + [DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] + static extern SafeFileHandle OpenNative(string name,uint access,uint share,IntPtr sec,uint disposition,uint flags,IntPtr template); + [StructLayout(LayoutKind.Sequential)] + public struct TagInfo { public uint FileAttributes; public uint ReparseTag; } + [DllImport("kernel32.dll",SetLastError=true)] + static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind,out TagInfo data,uint size); + readonly List guard=new List(); + FileStream stream; + bool disposed; + static SafeFileHandle OpenChecked(string path,uint access,uint share,uint disposition,uint flags,bool isDirectory){ + SafeFileHandle handle=OpenNative(path,access,share,IntPtr.Zero,disposition,flags,IntPtr.Zero); + if(handle.IsInvalid){ + int e=Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new IOException("SENSOR_LOG_HANDLE_CREATEFILE_FAILED_WIN32_"+e+" "+Path.GetFileName(path)); + } + TagInfo info; + if(!GetFileInformationByHandleEx(handle,9,out info,(uint)Marshal.SizeOf(typeof(TagInfo)))){ + int e=Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new IOException("SENSOR_LOG_HANDLE_ATTRIBUTE_TAG_QUERY_FAILED_WIN32_"+e); + } + if((info.FileAttributes&FILE_ATTRIBUTE_REPARSE_POINT)!=0 || + ((info.FileAttributes&FILE_ATTRIBUTE_DIRECTORY)!=0)!=isDirectory){ + handle.Dispose(); + throw new IOException("SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED"); + } + return handle; + } + public TBMEScopedUserLogLease(string profile,string appRoot,string logDir,string exactFile){ + if(String.IsNullOrWhiteSpace(profile) || String.IsNullOrWhiteSpace(appRoot) || + String.IsNullOrWhiteSpace(logDir) || String.IsNullOrWhiteSpace(exactFile)) + throw new IOException("SENSOR_LOG_HANDLE_EMPTY_LOG_BOUNDARY"); + string p=Path.GetFullPath(profile); + string appdata=Path.Combine(p,"AppData"); + string local=Path.Combine(appdata,"Local"); + string expected=Path.GetFullPath(Path.Combine(local,"TaskbarMonitorEnhanced")); + string logs=Path.GetFullPath(Path.Combine(expected,"Logs")); + string file=Path.GetFullPath(exactFile); + string log=Path.GetFullPath(Path.Combine(logs,"sensor_install.log")); + string result=Path.GetFullPath(Path.Combine(logs,"sensor_install_result.json")); + if(!String.Equals(Path.GetFullPath(appRoot),expected,StringComparison.OrdinalIgnoreCase) || + !String.Equals(Path.GetFullPath(logDir),logs,StringComparison.OrdinalIgnoreCase) || + !(String.Equals(file,log,StringComparison.OrdinalIgnoreCase) || + String.Equals(file,result,StringComparison.OrdinalIgnoreCase))){ + throw new IOException("SENSOR_LOG_HANDLE_CANONICAL_LOG_PATH_MISMATCH"); + } + try{ + foreach(string dir in new string[]{p,appdata,local,expected,logs}){ + guard.Add(OpenChecked(dir,FILE_LIST_DIRECTORY,FILE_SHARE_READ|FILE_SHARE_WRITE, + OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OPEN_REPARSE_POINT,true)); + } + SafeFileHandle h=OpenChecked(file,GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ, + OPEN_ALWAYS,FILE_FLAG_OPEN_REPARSE_POINT,false); + try{ + stream=new FileStream(h,FileAccess.ReadWrite); + stream.Seek(0,SeekOrigin.End); + }catch{h.Dispose();throw;} + }catch{Dispose();throw;} + } + public void Append(string text){ + if(disposed || stream==null)throw new ObjectDisposedException("TBMEScopedUserLogLease"); + if(stream.Length==0){ + byte[] bom=new System.Text.UTF8Encoding(true).GetPreamble(); + stream.Write(bom,0,bom.Length); + } + byte[] data=System.Text.Encoding.UTF8.GetBytes(text); + stream.Write(data,0,data.Length); + stream.Flush(true); + } + public void Overwrite(string text){ + if(disposed || stream==null)throw new ObjectDisposedException("TBMEScopedUserLogLease"); + stream.SetLength(0);stream.Position=0; + byte[] bom=new System.Text.UTF8Encoding(true).GetPreamble(); + stream.Write(bom,0,bom.Length); + byte[] data=System.Text.Encoding.UTF8.GetBytes(text); + stream.Write(data,0,data.Length); + stream.Flush(true); + } + public void Dispose(){ + if(disposed)return; + disposed=true; + if(stream!=null){stream.Dispose();stream=null;} + for(int i=guard.Count-1;i>=0;i--)guard[i].Dispose(); + guard.Clear(); + } +} +'@ + $TaskName='TaskbarMonitorEnhanced Sensor Broker' $BrokerRoot=Join-Path $env:ProgramFiles 'TaskbarMonitorEnhanced\SensorBroker' $SensorOwnerMarker=Join-Path $BrokerRoot '.tbme-owner.sid' @@ -178,8 +288,9 @@ function Assert-SensorUserLogSink([string]$Target){ function Write-Log([string]$Message){ Assert-SensorUserLogSink $LogPath - $line=(Get-Date).ToString('o')+' '+$Message - $line | Add-Content -LiteralPath $LogPath -Encoding UTF8 + $line=(Get-Date).ToString('o')+' '+$Message+[Environment]::NewLine + $lease=[TBMEScopedUserLogLease]::new($script:ValidatedSensorProfileRoot,$AppRoot,$LogsRoot,$LogPath) + try{$lease.Append($line)}finally{$lease.Dispose()} } function Write-Result( [string]$Status, @@ -191,7 +302,7 @@ function Write-Result( [bool]$SensorHealthy ){ Assert-SensorUserLogSink $ResultPath - [ordered]@{ + $json=[ordered]@{ Status=$Status Message=$Message PawnIOStatus=$PawnStatus @@ -210,7 +321,9 @@ function Write-Result( PreviousSensorLayerPresent=$PreviousSensorLayerPresent Time=(Get-Date).ToString('o') LogPath=$LogPath - } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + } | ConvertTo-Json -Depth 5 + $lease=[TBMEScopedUserLogLease]::new($script:ValidatedSensorProfileRoot,$AppRoot,$LogsRoot,$ResultPath) + try{$lease.Overwrite($json+[Environment]::NewLine)}finally{$lease.Dispose()} } function Get-PawnIOVersion { foreach($key in @( diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 60051565..144e1b7f 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -263,26 +263,52 @@ static string Base64Utf8Argument(string value) return Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); } - // R295: compact only transport text, not the readable embedded .ps1 source. - // Refuse here-strings and continuation tokens that need verbatim whitespace. + // R335: only compact trusted code OUTSIDE its exact native Add-Type + // here-string. Preserve every byte/line inside the C# literal so the + // unchanged OS-PowerShell helper retains syntactically valid source. + // No caller-controlled text is interpolated into this trusted resource. static string CompactTrustedHelperSource(string source) { - if(String.IsNullOrWhiteSpace(source) || - source.IndexOf("@'",StringComparison.Ordinal)>=0 || - source.IndexOf("@\"",StringComparison.Ordinal)>=0) - throw new InvalidDataException("Trusted sensor helper contains unsupported multiline text."); + if(String.IsNullOrWhiteSpace(source)) + throw new InvalidDataException("Trusted sensor helper source unavailable."); + const string nativeOpen="Add-Type -ReferencedAssemblies @('System.dll','System.Core.dll') -TypeDefinition @'"; + int opened=0,closed=0; + bool inNative=false; StringBuilder compact=new StringBuilder(); foreach(string raw in source.Replace("\r\n","\n").Split('\n')) { string line=raw.Trim(); + if(inNative) + { + // PS 5.1 requires the closing delimiter at column zero. + compact.Append(raw).Append('\n'); + if(String.Equals(raw,"'@",StringComparison.Ordinal)) + { + inNative=false; + closed++; + } + continue; + } if(line.Length==0 || line.StartsWith("#",StringComparison.Ordinal))continue; - if(line.EndsWith("`",StringComparison.Ordinal)) + if(line.EndsWith("@'",StringComparison.Ordinal) || + line.EndsWith("@\"",StringComparison.Ordinal)) + { + if(!String.Equals(line,nativeOpen,StringComparison.Ordinal) || opened!=0) + throw new InvalidDataException("Unexpected trusted sensor helper here-string."); + opened++; + inNative=true; + } + if(String.Equals(line,"'@",StringComparison.Ordinal) || + String.Equals(line,"\"@",StringComparison.Ordinal)) + throw new InvalidDataException("Unexpected trusted sensor helper here-string terminator."); + if(line[line.Length-1]==(char)0x60) throw new InvalidDataException("Trusted sensor helper cannot compact continuation lines."); compact.Append(line).Append('\n'); } + if(inNative || opened!=closed || opened>1) + throw new InvalidDataException("Trusted native helper literal not balanced."); return compact.ToString(); } - static string BuildElevatedHelperCommand(string mode,string payload,string appRoot,string user) { if(mode!="Install" && mode!="Uninstall") From bf5bdb6412444c8d6296270543e11fcde16cb67d Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 01:24:59 +0330 Subject: [PATCH 093/135] fix(sensor): keep native log lease compatible with PowerShell 5.1 and 7 --- build/Test-R328-HelperLogBoundary-Native.ps1 | 4 ++-- build/Test-R335-AtomicUserLogSink-Native.ps1 | 4 ++-- installer/TBME_Setup_Elevated_Helper.ps1 | 16 +++++++++------- installer/TaskbarMonitorEnhanced_Setup.cs | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/build/Test-R328-HelperLogBoundary-Native.ps1 b/build/Test-R328-HelperLogBoundary-Native.ps1 index 36fa636f..5e412b28 100644 --- a/build/Test-R328-HelperLogBoundary-Native.ps1 +++ b/build/Test-R328-HelperLogBoundary-Native.ps1 @@ -27,9 +27,9 @@ Pass 'EXACT_SETUP_PE_EMBEDS_TRUSTED_HELPER_SOURCE' $tokens=$null;$parseErrors=$null $ast=[Management.Automation.Language.Parser]::ParseFile($helper,[ref]$tokens,[ref]$parseErrors) if(@($parseErrors).Count -ne 0){throw 'R328_TRUSTED_HELPER_AST_PARSE_ERRORS'} -$native=[regex]::Matches($source,'(?s)Add-Type -ReferencedAssemblies @\(''System.dll'',''System.Core.dll''\) -TypeDefinition @''\r?\n(.*?)\r?\n''@') +$native=[regex]::Matches($source,'(?s)Add-Type -TypeDefinition @''\r?\n(.*?)\r?\n''@') if($native.Count -ne 1){throw 'R335_NATIVE_CLASS_SOURCE_NOT_UNIQUE'} -Add-Type -ReferencedAssemblies @('System.dll','System.Core.dll') -TypeDefinition $native[0].Groups[1].Value +Add-Type -TypeDefinition $native[0].Groups[1].Value if($null -eq ('TBMEScopedUserLogLease' -as [type])){throw 'R335_EXPECTED_PRODUCTION_CLASS_NOT_COMPILED'} foreach($fn in @('Assert-SensorBoundaryNoReparse','Assert-SensorUserLogSink','Write-Log','Write-Result')){ $definition=@($ast.FindAll({param($x) $x -is [Management.Automation.Language.FunctionDefinitionAst]},$true)| diff --git a/build/Test-R335-AtomicUserLogSink-Native.ps1 b/build/Test-R335-AtomicUserLogSink-Native.ps1 index 55923398..57779899 100644 --- a/build/Test-R335-AtomicUserLogSink-Native.ps1 +++ b/build/Test-R335-AtomicUserLogSink-Native.ps1 @@ -17,9 +17,9 @@ $helperPath=(Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\installer\TB $production=[IO.File]::ReadAllText($helperPath,[Text.Encoding]::UTF8) $normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} if((& $normalize $production) -cne (& $normalize $embedded)){throw 'R335_COMPILED_PE_HELPER_SOURCE_NOT_EXACT'} -$cls=[regex]::Matches($production,'(?s)Add-Type -ReferencedAssemblies @\(''System.dll'',''System.Core.dll''\) -TypeDefinition @''\r?\n(.*?)\r?\n''@') +$cls=[regex]::Matches($production,'(?s)Add-Type -TypeDefinition @''\r?\n(.*?)\r?\n''@') if($cls.Count -ne 1 -or !$cls[0].Groups[1].Value.Contains('public sealed class TBMEScopedUserLogLease : IDisposable')){throw 'R335_REAL_PRODUCTION_HANDLE_CLASS_NOT_UNIQUE'} -Add-Type -ReferencedAssemblies @('System.dll','System.Core.dll') -TypeDefinition $cls[0].Groups[1].Value -ErrorAction Stop +Add-Type -TypeDefinition $cls[0].Groups[1].Value -ErrorAction Stop if($null -eq ('TBMEScopedUserLogLease' -as [type])){throw 'R335_PRODUCTION_HANDLE_CLASS_NOT_LOADED'} $checks=[Collections.Generic.List[string]]::new() function Pass([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 3f7510fc..5073f13a 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -123,9 +123,9 @@ $validatedSensorSid=Assert-SensorPrivilegedBoundary $Mode $PayloadDir $AppRoot $ # R335: native in-memory handle guards pin canonical per-user profile # ancestry and exact file handles across log/result writes. Refuse reparse # points by handle, never create a user-writable script or change UAC policy. -Add-Type -ReferencedAssemblies @('System.dll','System.Core.dll') -TypeDefinition @' +Add-Type -TypeDefinition @' using System; -using System.Collections.Generic; + using System.IO; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; @@ -146,7 +146,8 @@ public sealed class TBMEScopedUserLogLease : IDisposable { public struct TagInfo { public uint FileAttributes; public uint ReparseTag; } [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind,out TagInfo data,uint size); - readonly List guard=new List(); + readonly SafeFileHandle[] guard=new SafeFileHandle[5]; + int guardCount=0; FileStream stream; bool disposed; static SafeFileHandle OpenChecked(string path,uint access,uint share,uint disposition,uint flags,bool isDirectory){ @@ -189,8 +190,9 @@ public sealed class TBMEScopedUserLogLease : IDisposable { } try{ foreach(string dir in new string[]{p,appdata,local,expected,logs}){ - guard.Add(OpenChecked(dir,FILE_LIST_DIRECTORY,FILE_SHARE_READ|FILE_SHARE_WRITE, - OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OPEN_REPARSE_POINT,true)); + SafeFileHandle parent=OpenChecked(dir,FILE_LIST_DIRECTORY,FILE_SHARE_READ|FILE_SHARE_WRITE, + OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OPEN_REPARSE_POINT,true); + guard[guardCount++]=parent; } SafeFileHandle h=OpenChecked(file,GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ, OPEN_ALWAYS,FILE_FLAG_OPEN_REPARSE_POINT,false); @@ -223,8 +225,8 @@ public sealed class TBMEScopedUserLogLease : IDisposable { if(disposed)return; disposed=true; if(stream!=null){stream.Dispose();stream=null;} - for(int i=guard.Count-1;i>=0;i--)guard[i].Dispose(); - guard.Clear(); + for(int i=guardCount-1;i>=0;i--)if(guard[i]!=null)guard[i].Dispose(); + guardCount=0; } } '@ diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 144e1b7f..d04b69c9 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -271,7 +271,7 @@ static string CompactTrustedHelperSource(string source) { if(String.IsNullOrWhiteSpace(source)) throw new InvalidDataException("Trusted sensor helper source unavailable."); - const string nativeOpen="Add-Type -ReferencedAssemblies @('System.dll','System.Core.dll') -TypeDefinition @'"; + const string nativeOpen="Add-Type -TypeDefinition @'"; int opened=0,closed=0; bool inNative=false; StringBuilder compact=new StringBuilder(); From fa57ae2c89304974f4b91d5a9ca7c3d5b06f57e1 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 01:31:41 +0330 Subject: [PATCH 094/135] test(sensor): align isolated R85 profile mock with native log lease --- build/Test-PawnIO-External-Degrade.ps1 | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/build/Test-PawnIO-External-Degrade.ps1 b/build/Test-PawnIO-External-Degrade.ps1 index b572b09a..38cd1d00 100644 --- a/build/Test-PawnIO-External-Degrade.ps1 +++ b/build/Test-PawnIO-External-Degrade.ps1 @@ -11,7 +11,8 @@ $Root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $Helper=Join-Path $Root 'installer\TBME_Setup_Elevated_Helper.ps1' $Base=Join-Path $Root 'artifacts\_r85_external_degrade_test' $Payload=Join-Path $Base 'payload' -$App=Join-Path $Base 'local_app' +$Profile=Join-Path $Base 'isolated-profile' +$App=Join-Path $Profile 'AppData\Local\TaskbarMonitorEnhanced' $Mock=Join-Path $Base 'helper_mocked_pawnio_discovery.ps1' $Result=Join-Path $App 'Logs\sensor_install_result.json' $Log=Join-Path $App 'Logs\sensor_install.log' @@ -36,6 +37,7 @@ $preflight=$source.IndexOf('$validatedSensorSid=Assert-SensorPrivilegedBoundary' $logWrite=$source.IndexOf('New-Item -ItemType Directory -Force -Path $LogsRoot',[StringComparison]::Ordinal) if($preflight -lt 0 -or $logWrite -le $preflight){throw 'R85_UNMODIFIED_PRODUCTION_GUARD_MISSING'} $escapeApp=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($App))) +$escapeProfile=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($Profile))) $escapePayload=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($Payload))) $escapeUser=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([Security.Principal.WindowsIdentity]::GetCurrent().Name)) $escapeSid=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) @@ -44,18 +46,21 @@ function Assert-SensorPrivilegedBoundary([string]$RequestedMode,[string]$Request if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows'){throw 'R85_MOCK_ONLY_HOSTED_DISPOSABLE'} $app=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__APP__')) + $profile=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PROFILE__')) $payload=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PAYLOAD__')) $user=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__USER__')) $sid=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__SID__')) if($RequestedMode -cne 'Install' -or $RequestedUser -cne $user -or -not [string]::Equals([IO.Path]::GetFullPath($RequestedAppRoot),$app,[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($app,[IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local\TaskbarMonitorEnhanced')),[StringComparison]::OrdinalIgnoreCase) -or -not [string]::Equals([IO.Path]::GetFullPath($RequestedPayload),$payload,[StringComparison]::OrdinalIgnoreCase)){ throw 'R85_MOCK_REJECTS_OUTSIDE_OWNED_FIXTURE' } + $script:ValidatedSensorProfileRoot=$profile return $sid } '@ -$mockBoundary=$mockBoundary.Replace('__APP__',$escapeApp).Replace('__PAYLOAD__',$escapePayload).Replace('__USER__',$escapeUser).Replace('__SID__',$escapeSid) +$mockBoundary=$mockBoundary.Replace('__APP__',$escapeApp).Replace('__PROFILE__',$escapeProfile).Replace('__PAYLOAD__',$escapePayload).Replace('__USER__',$escapeUser).Replace('__SID__',$escapeSid) $mocked=$source foreach($fn in @($funcs|Sort-Object {$_.Extent.StartOffset} -Descending)){ $replacement=if($fn.Name -ceq 'Get-PawnIOVersion'){"function Get-PawnIOVersion { return '' }"}else{$mockBoundary} @@ -88,8 +93,8 @@ try { $files=@(Get-ChildItem -LiteralPath $Base -Recurse -File) $allowed=@( 'payload\TaskbarMonitorSensorBroker.exe','payload\TaskbarMonitorSensorSupervisor.exe', - 'local_app\SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll', - 'local_app\Logs\sensor_install_result.json','local_app\Logs\sensor_install.log', + 'isolated-profile\AppData\Local\TaskbarMonitorEnhanced\SensorBackend\LibreHardwareMonitor-0.9.6\LibreHardwareMonitorLib.dll', + 'isolated-profile\AppData\Local\TaskbarMonitorEnhanced\Logs\sensor_install_result.json','isolated-profile\AppData\Local\TaskbarMonitorEnhanced\Logs\sensor_install.log', 'helper_mocked_pawnio_discovery.ps1') if($files.Count -ne $allowed.Count -or @($files|Where-Object {$_.FullName.Substring($Base.Length+1) -notin $allowed}).Count -ne 0){throw 'R85_UNKNOWN_FIXTURE_FILES'} Write-Output 'R85_MISSING_PAWNIO_DEGRADED_FAIL_CLOSED_NO_DRIVER_INSTALL_NO_ROLLBACK_NO_OS_CHANGE=PASS' From 3fd0b1be8464c62b7876c95e2ebba58a0fab0c69 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 02:48:35 +0330 Subject: [PATCH 095/135] fix(sensor): prune only empty first-install rollback directories (R337) --- .../audit-r337-first-install-recovery.yml | 87 ++++++++++ ...est-R337-FirstInstallColdOrphan-Native.ps1 | 150 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 22 +++ 3 files changed, 259 insertions(+) create mode 100644 .github/workflows/audit-r337-first-install-recovery.yml create mode 100644 build/Test-R337-FirstInstallColdOrphan-Native.ps1 diff --git a/.github/workflows/audit-r337-first-install-recovery.yml b/.github/workflows/audit-r337-first-install-recovery.yml new file mode 100644 index 00000000..5ed7b352 --- /dev/null +++ b/.github/workflows/audit-r337-first-install-recovery.yml @@ -0,0 +1,87 @@ +name: R337 first-sensor-install cold recovery differential (disposable) +on: + push: + branches: [audit/r337-orphan-broker-recovery] + paths: + - installer/TBME_Setup_Elevated_Helper.ps1 + - build/Test-R337-FirstInstallColdOrphan-Native.ps1 + - .github/workflows/audit-r337-first-install-recovery.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + first-install-cold-recovery: + name: R337 ${{ matrix.variant }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + variant: [baseline, candidate] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact test harness branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + path: harness + persist-credentials: false + - name: Checkout exact prior source or current candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ matrix.variant == 'baseline' && 'fa57ae2c89304974f4b91d5a9ca7c3d5b06f57e1' || github.sha }} + path: src + persist-credentials: false + - name: Require hosted Windows and exact pinned source + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $expected="${{ matrix.variant == 'baseline' && 'fa57ae2c89304974f4b91d5a9ca7c3d5b06f57e1' || github.sha }}" + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + (git -C src rev-parse HEAD).Trim() -cne $expected -or + (git -C harness rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R337_NON_DISPOSABLE_OR_WRONG_CHECKOUT_REF' + } + if((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System').ConsentPromptBehaviorAdmin -ne 0){ + 'R337_UAC_POLICY_NOT_ZERO_DO_NOT_ASSUME_INTERACTIVE_UAC_TESTED' + } + 'R337_SOURCE='+$expected + - name: Install pinned SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Compile exact source with embedded real Windows PowerShell Helper + shell: pwsh + working-directory: src + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R337_SOURCE_COMPILATION_FAILED'} + if(!(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R337_EXACT_SETUP_EXE_MISSING' + } + - name: Execute sandboxed PS5.1 first-install cold replay differential + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path harness/build/Test-R337-FirstInstallColdOrphan-Native.ps1).Path + $product=(Resolve-Path src).Path + $setup=(Resolve-Path src/build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostLabel '${{ matrix.host }}' -Variant '${{ matrix.variant }}' -ProductRoot $product -SetupPath $setup + if($LASTEXITCODE -ne 0){throw 'R337_HOSTED_NATIVE_COLD_RECOVERY_NEGATIVE_OR_REGRESSION'} + - name: Retain typed test evidence without public security alert upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R337-${{ matrix.variant }}-${{ matrix.host }}-${{ github.run_id }} + path: ${{ runner.temp }}/R337_*/R337_FIRST_INSTALL_COLD_RECOVERY.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R337-FirstInstallColdOrphan-Native.ps1 b/build/Test-R337-FirstInstallColdOrphan-Native.ps1 new file mode 100644 index 00000000..9fc22133 --- /dev/null +++ b/build/Test-R337-FirstInstallColdOrphan-Native.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('baseline','candidate')][string]$Variant, + [Parameter(Mandatory=$true)][string]$ProductRoot, + [Parameter(Mandatory=$true)][string]$SetupPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)){ + throw 'R337_DISPOSABLE_GITHUB_WINDOWS_ONLY' +} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +$root=Join-Path $env:RUNNER_TEMP ('R337_'+$Variant+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +if(Test-Path -LiteralPath $root){throw 'R337_COLLIDING_TEST_ROOT'} +[void][IO.Directory]::CreateDirectory($root) +$receipt=Join-Path $root 'R337_FIRST_INSTALL_COLD_RECOVERY.json' +$checks=[Collections.Generic.List[string]]::new() +$failure='' +$remaining=$null +$outside=Join-Path $root 'OUTSIDE_CANARY.txt' +$canary='R337_OUTSIDE_CANARY_NOT_MODIFIED' +[IO.File]::WriteAllText($outside,$canary) +$source=(Resolve-Path (Join-Path $ProductRoot 'installer\TBME_Setup_Elevated_Helper.ps1')).Path +$process=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +try{ + if(-not [IO.File]::Exists($SetupPath) -or + -not [string]::Equals([IO.Path]::GetFullPath($process), + ([IO.Path]::GetFullPath((Get-Process -Id $PID).Path)), + [StringComparison]::OrdinalIgnoreCase)){ + throw 'R337_EXACT_SYSTEM_POWERSHELL_51_OR_COMPILED_SETUP_MISSING' + } + $pe=[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($SetupPath)) + $rs=$pe.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$rs){throw 'R337_COMPILED_PE_SENSOR_RESOURCE_MISSING'} + $reader=[IO.StreamReader]::new($rs,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + $src=[IO.File]::ReadAllText($source) + if((& $normalize $embedded) -cne (& $normalize $src)){ + throw 'R337_COMPILED_PE_SCRIPT_NOT_EXACT' + } + $checks.Add('COMPILED_SETUP_EMBEDS_EXACT_PRODUCTION_SENSOR_HELPER') + $tok=$null;$errors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($source,[ref]$tok,[ref]$errors) + if(@($errors).Count){throw 'R337_PRODUCTION_PS51_SYNTAX_INVALID'} + $names=@( + 'Assert-SensorBoundaryNoReparse','Get-SensorJournalHash','Assert-SensorJournalRelative', + 'Assert-SensorJournalTrustedAcl','Write-SensorJournalUnique','Assert-SensorDurableJournal', + 'New-SensorDurableJournal','Get-SensorJournalTerminal','Write-SensorJournalTerminal', + 'Restore-SensorJournalOldFilesOnly','Assert-SensorRetiredJournalSafe', + 'Finish-SensorRetiredJournal','Finish-SensorTerminalJournal', + 'Resume-SensorCompletedJournal','Resolve-SensorPriorTaskPrincipalSid', + 'Assert-SensorTrustedOldTaskXml','Test-SensorOwnedTaskRegistration', + 'Restore-SensorJournalPriorLayerAndTask','Resume-SensorPendingJournal' + ) + $defs=@($ast.FindAll({param($n) + $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -in $names + },$true)) + if($defs.Count -ne $names.Count){throw ('R337_PRODUCTION_FUNCTION_COUNT_'+$defs.Count)} + . ([scriptblock]::Create(($defs|ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + $checks.Add('EXECUTING_REAL_PS51_FUNCTIONS_EXTRACTED_FROM_COMPILED_HELPER') + $sid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R337_HOSTED_INTERACTIVE_ACCOUNT_SID_INVALID'} + $name='TBME-R314-Disposable-'+$env:GITHUB_RUN_ID+'-'+$HostLabel + if(@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue | + Where-Object {$null -ne $_}).Count){throw 'R337_TASK_COLLISION'} + $checks.Add('NO_EXISTING_DISPOSABLE_TASK_OR_USER_SERVICE_CHANGE') + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins) + $acl.SetAccessRuleProtection($true,$false) + foreach($s in @($admins,$system)){ + $ace=[Security.AccessControl.FileSystemAccessRule]::new( + $s,[Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + [void]$acl.AddAccessRule($ace) + } + $trusted=Join-Path $root 'trusted' + [void][IO.Directory]::CreateDirectory($trusted,$acl) + Assert-SensorJournalTrustedAcl $trusted $true + $journal=Join-Path $trusted 'snapshot' + $live=Join-Path $root 'synthetic-broker' + $userData=Join-Path $root 'synthetic-app' + [void][IO.Directory]::CreateDirectory($userData) + $new=[string[]]@('TaskbarMonitorSensorBroker.exe','.tbme-owner.sid','vendor\source\lib.dll') + # Original protected tree DID NOT EXIST. A mid-install crash leaves an + # ownership marker and nested new bytes which must be removed on recovery. + New-SensorDurableJournal $journal $sid ([object[]]@()) '' $new $false + $checks.Add('READY_FLUSHED_BEFORE_FIRST_INSTALL_MUTATION_AND_OLD_ROOT_ABSENT') + [void][IO.Directory]::CreateDirectory($live,$acl) + [void][IO.Directory]::CreateDirectory((Join-Path $live 'vendor\source')) + [IO.File]::WriteAllText((Join-Path $live 'TaskbarMonitorSensorBroker.exe'),'NEW_TEST_ONLY') + [IO.File]::WriteAllText((Join-Path $live '.tbme-owner.sid'),$sid) + [IO.File]::WriteAllText((Join-Path $live 'vendor\source\lib.dll'),'NEW_TEST_ONLY') + if((Get-SensorJournalTerminal $journal $sid) -cne 'PREPARED'){throw 'R337_MISSING_DURABLE_READY'} + $checks.Add('SYNTHETIC_CRASH_STATE_HAS_PARTIAL_NEW_PROTECTED_FILES_AND_OWNER_MARKER') + $result=Resume-SensorPendingJournal $journal $sid $live $userData $name + if($result -cne 'PREVIOUS_LAYER_RESTORED'){throw ('R337_COLD_RECOVERY_NOT_ACCEPTED_'+$result)} + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired')){ + throw 'R337_TERMINAL_JOURNAL_NOT_FULLY_RETIRED' + } + $checks.Add('PRODUCTION_COLD_REPLAY_AND_DURABLE_RETIRE_SUCCEEDED') + foreach($rel in $new){ + if([IO.File]::Exists((Join-Path $live $rel))){throw ('R337_EXPECTED_REMOVED_NEW_FILE_'+$rel)} + } + $checks.Add('ALL_KNOWN_NEW_FILES_REMOVED_AFTER_ROLLBACK') + $remaining=[IO.Directory]::Exists($live) + if($Variant -ceq 'baseline'){ + if(!$remaining){throw 'R337_BASELINE_EXPECTED_ORPHAN_NOT_REPRODUCED'} + $checks.Add('BASELINE_BROKER_DIRECTORY_ORPHAN_REPRODUCED') + }else{ + if($remaining){throw 'R337_REPAIRED_BROKER_DIRECTORY_STILL_ORPHANED'} + $checks.Add('FIRST_INSTALL_RECOVERY_LEAVES_NO_UNOWNED_BROKER_ROOT') + } + if([IO.File]::ReadAllText($outside) -cne $canary){throw 'R337_OUTSIDE_CANARY_CHANGED'} + $checks.Add('UNRELATED_CANARY_BYTE_EXACT_UNMODIFIED') + if(@(Get-ScheduledTask -TaskPath '\' -TaskName $name -ErrorAction SilentlyContinue | + Where-Object {$null -ne $_}).Count){throw 'R337_UNEXPECTED_REGISTERED_TASK'} + $checks.Add('NEVER_REGISTERED_OR_MODIFIED_SCHEDULED_TASK') +}catch{ + $failure=$_.Exception.ToString() +}finally{ + $receiptData=[ordered]@{ + Schema=1;Revision='R337';Host=$HostLabel;Variant=$Variant + SourceCommit=(git rev-parse HEAD).Trim() + SetupSHA256=if(Test-Path -LiteralPath $SetupPath){Hash $SetupPath}else{'MISSING'} + Status=if(!$failure){if($Variant -ceq 'baseline'){'PASS_REPRODUCED_EMPTY_BROKER_ORPHAN'}else{'PASS_RECOVERY_LEAVES_NO_EMPTY_BROKER_ROOT'}}else{'FAIL'} + ActualElevatedUacPerformed=$false + RealProgramFilesWritten=$false + ActualScheduledTaskChanged=$false + ActualOwnerComputerTouched=$false + OldRootPresent=$false + EmptyBrokerRootRemained=$remaining + Checks=@($checks) + Failure=$failure + } + [IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + Write-Output ('R337_RECEIPT='+$receipt) + Write-Output ('R337_STATUS='+$receiptData.Status) +} +if($failure){throw ('R337_FIRST_INSTALL_COLD_RECOVERY_FAILED '+$failure)} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 5073f13a..079c92e7 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -792,6 +792,28 @@ function Restore-SensorJournalOldFilesOnly( throw 'SENSOR_JOURNAL_REPLAY_FINAL_SHA_FAILED' } } + # R337: a first protected-sensor installation has NO previous BrokerRoot. + # A killed helper may leave only newly added nested files and directories. + # After validated snapshot replay and allowlisted file deletion, remove + # ONLY now-empty known directories, deepest first, using nonrecursive + # Directory.Delete. Refuse any surprise member or reparse point. Keeping + # an empty unowned BrokerRoot would block the next same-owner repair. + if(-not [bool]$manifest.OldRootPresent){ + if($prior.Count -ne 0){ + throw 'SENSOR_JOURNAL_ABSENT_OLD_ROOT_HAS_SNAPSHOT_FILES' + } + foreach($relDir in @($dirs | Sort-Object Length -Descending)){ + $emptyDir=Join-Path $LiveRoot $relDir + Assert-SensorBoundaryNoReparse $emptyDir + if([IO.Directory]::Exists($emptyDir)){ + [IO.Directory]::Delete($emptyDir,$false) + } + } + Assert-SensorBoundaryNoReparse $LiveRoot + if([IO.Directory]::Exists($LiveRoot)){ + [IO.Directory]::Delete($LiveRoot,$false) + } + } return [Text.Encoding]::UTF8.GetString($xml) } From 997c6f349fa64cfe6385113a9ede46d35479cc7a Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 02:52:34 +0330 Subject: [PATCH 096/135] test(r337): pin split-checkout source for native receipt (F01) --- build/Test-R337-FirstInstallColdOrphan-Native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-R337-FirstInstallColdOrphan-Native.ps1 b/build/Test-R337-FirstInstallColdOrphan-Native.ps1 index 9fc22133..64b31c59 100644 --- a/build/Test-R337-FirstInstallColdOrphan-Native.ps1 +++ b/build/Test-R337-FirstInstallColdOrphan-Native.ps1 @@ -130,7 +130,7 @@ try{ }finally{ $receiptData=[ordered]@{ Schema=1;Revision='R337';Host=$HostLabel;Variant=$Variant - SourceCommit=(git rev-parse HEAD).Trim() + SourceCommit=(git -C $ProductRoot rev-parse HEAD).Trim() SetupSHA256=if(Test-Path -LiteralPath $SetupPath){Hash $SetupPath}else{'MISSING'} Status=if(!$failure){if($Variant -ceq 'baseline'){'PASS_REPRODUCED_EMPTY_BROKER_ORPHAN'}else{'PASS_RECOVERY_LEAVES_NO_EMPTY_BROKER_ROOT'}}else{'FAIL'} ActualElevatedUacPerformed=$false From edf2610c20789028050b3ea76b1bee01c77d07bf Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 03:30:00 +0330 Subject: [PATCH 097/135] fix(sensor): pin privileged Supervisor broker and output arguments (R338) --- .../audit-r338-supervisor-argv-trust.yml | 87 ++++++++ ...Test-R338-SupervisorTrustedArgs-Native.ps1 | 208 ++++++++++++++++++ src/sensors/TaskbarMonitorSensorSupervisor.cs | 97 +++++++- 3 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/audit-r338-supervisor-argv-trust.yml create mode 100644 build/Test-R338-SupervisorTrustedArgs-Native.ps1 diff --git a/.github/workflows/audit-r338-supervisor-argv-trust.yml b/.github/workflows/audit-r338-supervisor-argv-trust.yml new file mode 100644 index 00000000..ca00788d --- /dev/null +++ b/.github/workflows/audit-r338-supervisor-argv-trust.yml @@ -0,0 +1,87 @@ +name: R338 Supervisor trusted argv binding (disposable Windows) +on: + push: + branches: [audit/r338-supervisor-argv-trust] + paths: + - src/sensors/TaskbarMonitorSensorSupervisor.cs + - build/Test-R338-SupervisorTrustedArgs-Native.ps1 + - .github/workflows/audit-r338-supervisor-argv-trust.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + native-argv-contract: + name: R338 ${{ matrix.variant }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + variant: [baseline, candidate] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact test harness revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + path: harness + persist-credentials: false + - name: Checkout exact baseline or candidate product source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || github.sha }} + path: src + persist-credentials: false + - name: Require disposable Windows exact source and no preexisting product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $expected="${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || github.sha }}" + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID' -or + (git -C src rev-parse HEAD).Trim() -cne $expected -or + (git -C harness rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R338_NOT_PINNED_DISPOSABLE_WINDOWS' + } + $actual=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if(Test-Path -LiteralPath $actual){throw 'R338_HOSTED_EXISTING_LIVE_INSTALL_REFUSE'} + 'R338_RUNNER_DISPOSABLE_AND_SOURCE_BOUND='+$expected + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Compile actual supervisor and all bundled .NET Framework projects + shell: pwsh + working-directory: src + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Supervisor/TaskbarMonitorSensorSupervisor.exe -PathType Leaf)){ + throw 'R338_EXACT_COMPILED_SUPERVISOR_MISSING' + } + 'R338_SUPERVISOR_AND_PRODUCT_FULL_BUILD_PASS' + - name: Execute original compiled PE parser and native junction negatives (no launch) + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $ps=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path harness/build/Test-R338-SupervisorTrustedArgs-Native.ps1).Path + $source=(Resolve-Path src).Path + $exe=(Resolve-Path src/build/_out/Supervisor/TaskbarMonitorSensorSupervisor.exe).Path + & $ps -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostedGuest -HostLabel '${{ matrix.host }}' -Variant '${{ matrix.variant }}' -SourceRoot $source -SupervisorPath $exe + if($LASTEXITCODE -ne 0){throw 'R338_SUPERVISOR_ARG_PATH_NATIVE_GUEST_NOT_ACCEPTED'} + - name: Retain raw typed guest receipt without security alert upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R338-${{ matrix.variant }}-${{ matrix.host }}-${{ github.run_id }} + path: ${{ runner.temp }}/R338_*/R338_SUPERVISOR_ARG_TRUST.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R338-SupervisorTrustedArgs-Native.ps1 b/build/Test-R338-SupervisorTrustedArgs-Native.ps1 new file mode 100644 index 00000000..444ae49d --- /dev/null +++ b/build/Test-R338-SupervisorTrustedArgs-Native.ps1 @@ -0,0 +1,208 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('baseline','candidate')][string]$Variant, + [Parameter(Mandatory=$true)][string]$SupervisorPath, + [Parameter(Mandatory=$true)][string]$SourceRoot, + [ValidateSet('LOCAL','A','B')][string]$HostLabel='LOCAL', + [switch]$HostedGuest +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop'){throw 'R338_WINDOWS_POWERSHELL_51_REQUIRED'} +if($HostedGuest -and ($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $HostLabel -eq 'LOCAL' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID')){ + throw 'R338_GUEST_RUNNER_IDENTITY_FAIL_CLOSED' +} +function FileHash([string]$file) { + return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash +} +$expectedCommit=if($Variant -ceq 'baseline'){ + '997c6f349fa64cfe6385113a9ede46d35479cc7a' +}else{ + # Candidate must match a pinned workflow checkout; caller validates its SHA. + (git -C $SourceRoot rev-parse HEAD).Trim() +} +if((git -C $SourceRoot rev-parse HEAD).Trim() -cne $expectedCommit){ + throw 'R338_EXACT_SOURCE_HEAD_MISMATCH' +} +if(!(Test-Path -LiteralPath $SupervisorPath -PathType Leaf)){ + throw 'R338_COMPILED_SUPERVISOR_MISSING' +} +$assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path -LiteralPath $SupervisorPath).Path) +$type=$assembly.GetType('TaskbarMonitorSensorSupervisor',$true) +$flags=[Reflection.BindingFlags]'Static,NonPublic' +$parse=$type.GetMethod('ParseArgs',$flags) +$trusted=$type.GetMethod('TrustedWorkerPaths',$flags) +$brokerField=$type.GetField('BrokerPath',$flags) +if(!$parse -or !$brokerField){throw 'R338_REAL_COMPILED_ENTRYPOINT_MISSING'} +$checks=[Collections.Generic.List[string]]::new() +function Pass([string]$label) { + $script:checks.Add($label) + Write-Output ('PASS '+$label) +} +function Invoke-Parse([string[]]$items){ + $values=New-Object 'object[]' 1 + $values[0]=$items + return [bool]$parse.Invoke($null,$values) +} +function Invoke-Trusted([string[]]$parts){ + $values=New-Object 'object[]' 6 + for($i=0;$i -lt $values.Length;$i++){$values[$i]=$parts[$i]} + return [bool]$trusted.Invoke($null,$values) +} +$testRoot=Join-Path ([IO.Path]::GetTempPath()) ('tbme_r338_'+$HostLabel+'_'+[Guid]::NewGuid().ToString('N')) +$reportRoot=if($HostedGuest){Join-Path $env:RUNNER_TEMP ('R338_'+$HostLabel+'_'+$Variant+'_'+$env:GITHUB_RUN_ID)}else{''} +if($HostedGuest){ + if([IO.Directory]::Exists($reportRoot)){throw 'R338_DUPLICATE_HOSTED_RECEIPT_ROOT'} + [void][IO.Directory]::CreateDirectory($reportRoot) +} +$receipt=if($HostedGuest){Join-Path $reportRoot 'R338_SUPERVISOR_ARG_TRUST.json'}else{''} +$failure='' +$canaryHash='' +$junctionCreated=$false +$brokerDir='' +$canary='' +try { + [void][IO.Directory]::CreateDirectory($testRoot) + $program=Join-Path $testRoot 'Program Files' + $brokerDir=Join-Path $program 'TaskbarMonitorEnhanced\SensorBroker' + $profile=Join-Path $testRoot 'Profile' + $local=Join-Path $profile 'AppData\Local' + $app=Join-Path $local 'TaskbarMonitorEnhanced' + $foreign=Join-Path $testRoot 'foreign-untrusted' + [void][IO.Directory]::CreateDirectory($brokerDir) + [void][IO.Directory]::CreateDirectory($app) + [void][IO.Directory]::CreateDirectory($foreign) + $broker=Join-Path $brokerDir 'TaskbarMonitorSensorBroker.exe' + $output=Join-Path $app 'cpu_temp_broker.json' + $canary=Join-Path $foreign 'CANARY_NEVER_EXECUTED.txt' + [IO.File]::WriteAllText($broker,'R338_MOCK_EXE_NEVER_EXECUTED',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($canary,'R338_UNRELATED_FOREIGN_CANARY',[Text.UTF8Encoding]::new($false)) + $canaryHash=FileHash $canary + $evil=Join-Path $foreign 'foreign.exe' + $evilOutput=Join-Path $foreign 'rogue.json' + if($Variant -ceq 'baseline'){ + if($trusted){throw 'R338_BASELINE_UNEXPECTED_TRUSTED_FUNCTION'} + if(!(Invoke-Parse ([string[]]@('--broker',$evil,'--output',$evilOutput)))){ + throw 'R338_OLD_PARSER_DID_NOT_ACCEPT_ARBITRARY_PATHS' + } + Pass 'BASELINE_COMPILED_SUPERVISOR_ACCEPTS_FOREIGN_EXE_AND_OUTPUT_WITHOUT_EXECUTING' + if(!(Invoke-Parse ([string[]]@('--broker',$evil,'--output',$evilOutput,'--ignored','x')))){ + throw 'R338_OLD_PARSER_DID_NOT_IGNORE_EXTRA_FLAGS' + } + Pass 'BASELINE_ACCEPTS_UNEXPECTED_EXTRA_FLAGS' + }else{ + if(!$trusted){throw 'R338_TRUSTED_FUNCTION_NOT_COMPILED'} + Pass 'CANDIDATE_COMPILED_SUPERVISOR_EXPOSES_TRUST_CHECK' + $argsGood=[string[]]@($broker,$output,$brokerDir,$program,$profile,$local) + if(!(Invoke-Trusted $argsGood)){throw 'R338_CANONICAL_SANDBOX_POSITIVE_REFUSED'} + Pass 'SANDBOX_REAL_PATHS_CANONICAL_KNOWNFOLDERS_ACCEPTED' + $beforeField=[string]$brokerField.GetValue($null) + $badArgs=[string[]]@('--broker',$evil,'--output',$evilOutput) + if(Invoke-Parse $badArgs){throw 'R338_FOREIGN_PROCESS_START_PATH_ACCEPTED'} + if([string]$brokerField.GetValue($null) -cne $beforeField){ + throw 'R338_INVALID_PROCESS_ARGUMENT_PARTIALLY_MUTATED_GLOBAL_PATH' + } + Pass 'COMPILLED_PRODUCTION_PARSE_REFUSES_FOREIGN_EXECUTABLE_PRELAUNCH' + $malformed=@( + [pscustomobject]@{Argv=[string[]]@('--broker',$evil,'--output',$output)}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output',$evilOutput)}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--broker',$evil)}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output',$output,'--ignored','x')}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output')}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output',($output+'";Write-Host INJECTED'))}, + [pscustomobject]@{Argv=[string[]]@('--broker',$broker,'--output',('X'*4097))} + ) + foreach($case in $malformed){ + if(Invoke-Parse $case.Argv){throw 'R338_BAD_REAL_PROCESS_ARG_ACCEPTED'} + } + Pass 'REAL_PARSER_NEGATIVES_FOREIGN_DUPLICATE_UNKNOWN_TRUNCATED_HOSTILE_OVERLONG' + if(Invoke-Trusted ([string[]]@($evil,$output,$brokerDir,$program,$profile,$local))){ + throw 'R338_TRUST_ALLOW_FOREIGN_BROKER' + } + Pass 'TRUST_CHECK_REFUSES_FOREIGN_BROKER_PATH' + if(Invoke-Trusted ([string[]]@($broker,$evilOutput,$brokerDir,$program,$profile,$local))){ + throw 'R338_TRUST_ALLOW_FOREIGN_OUTPUT' + } + Pass 'TRUST_CHECK_REFUSES_FOREIGN_TELEMETRY_OUTPUT' + if(Invoke-Trusted ([string[]]@($broker,$output,$foreign,$program,$profile,$local))){ + throw 'R338_TRUST_ALLOW_FOREIGN_IMAGE_DIRECTORY' + } + Pass 'TRUST_CHECK_REFUSES_IMAGE_OUTSIDE_PROGRAMFILES_SENSOR_ROOT' + if(Invoke-Trusted ([string[]]@($broker,$output,$brokerDir,$program,$profile,$foreign))){ + throw 'R338_TRUST_ALLOW_NONLOCAL_PROFILE_DIRECTORY' + } + Pass 'TRUST_CHECK_REFUSES_NONCANONICAL_PROFILE_LOCALAPPDATA' + $escapedOutput=Join-Path $foreign 'escape\cpu_temp_broker.json' + if(Invoke-Trusted ([string[]]@($broker,$escapedOutput,$brokerDir,$program,$profile,$local))){ + throw 'R338_TRUST_ALLOW_PARENT_ESCAPE' + } + Pass 'TRUST_CHECK_REFUSES_OUTPUT_PARENT_ESCAPE' + $brokerBackup=$brokerDir+'_original' + [IO.Directory]::Move($brokerDir,$brokerBackup) + $outsideBroker=Join-Path $foreign 'TaskbarMonitorSensorBroker.exe' + [IO.File]::WriteAllText($outsideBroker,'R338_FOREIGN_EXE_MARKER',[Text.UTF8Encoding]::new($false)) + try { + New-Item -ItemType Junction -Path $brokerDir -Target $foreign -ErrorAction Stop|Out-Null + $junctionCreated=$true + if(Invoke-Trusted ([string[]]@($broker,$output,$brokerDir,$program,$profile,$local))){ + throw 'R338_PROTECTED_BROKER_REPARSE_NOT_REJECTED' + } + Pass 'TRUST_CHECK_REJECTS_REAL_WINDOWS_BROKER_JUNCTION' + } finally { + if($junctionCreated -and [IO.Directory]::Exists($brokerDir)){ + $att=[IO.File]::GetAttributes($brokerDir) + if(($att -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R338_BROKER_LINK_CHANGED_BEFORE_CLEANUP' + } + [IO.Directory]::Delete($brokerDir,$false) + $junctionCreated=$false + } + [IO.Directory]::Move($brokerBackup,$brokerDir) + } + if(!(Invoke-Trusted $argsGood)){throw 'R338_POSITIVE_NOT_RECOVERED_AFTER_JUNCTION'} + Pass 'VALID_PATHS_REACCEPTED_AFTER_REAL_JUNCTION_REMOVAL' + } + if((FileHash $canary) -cne $canaryHash){throw 'R338_FOREIGN_CANARY_MODIFIED'} + Pass 'FOREIGN_CANARY_BYTE_IDENTICAL_NO_PROCESS_STARTED' +} catch { + $failure=$_.Exception.ToString() +} finally { + $status=if(!$failure){if($Variant -ceq 'baseline'){ + 'PASS_REPRODUCED_OLD_SUPERVISOR_ARG_ACCEPTANCE' + }else{ + 'PASS_CANDIDATE_SUPERVISOR_TRUST_BOUNDARY' + }}else{'FAIL'} + if($HostedGuest){ + $result=[ordered]@{ + Schema=1;Revision='R338';Host=$HostLabel;Variant=$Variant; + SourceCommit=(git -C $SourceRoot rev-parse HEAD).Trim() + SupervisorSHA256=FileHash $SupervisorPath + Status=$status + ActualOriginalElevatedSupervisorStarted=$false + ActualUacConsentPromptTested=$false + ProtectedProgramFilesModified=$false + OriginalOwnerHostTouched=$false + AnyBrokerExeStarted=$false + Checks=@($checks) + Failure=$failure + } + [IO.File]::WriteAllText($receipt,($result|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + Write-Output ('R338_RECEIPT='+$receipt) + } + if($junctionCreated -and [IO.Directory]::Exists($brokerDir)){ + if(([IO.File]::GetAttributes($brokerDir) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($brokerDir,$false) + $junctionCreated=$false + } + } + # No actual system files, protected tasks, drivers or service are modified. + if([IO.Directory]::Exists($testRoot) -and !$junctionCreated){ + [IO.Directory]::Delete($testRoot,$true) + } + Write-Output ('R338_STATUS='+$status+' CHECKS='+$checks.Count) +} +if($failure){throw ('R338_SUPERVISOR_TRUST_NEGATIVE_FAILED '+$failure)} diff --git a/src/sensors/TaskbarMonitorSensorSupervisor.cs b/src/sensors/TaskbarMonitorSensorSupervisor.cs index 8ed1d932..e3a6b81a 100644 --- a/src/sensors/TaskbarMonitorSensorSupervisor.cs +++ b/src/sensors/TaskbarMonitorSensorSupervisor.cs @@ -449,18 +449,99 @@ static void WriteState(string reason) catch{} } + // R338: Supervisor may run at high integrity. CLI-supplied --broker + // must never choose an executable; only the adjacent protected Broker is + // allowed. Output must be the exact interactive user's canonical root. + // Path checks are fail-closed, not a claim of atomic rename protection. + static bool NoReparseComponents(string fullPath) + { + string cursor=Path.GetFullPath(fullPath); + while(!String.IsNullOrEmpty(cursor)) + { + try + { + if((File.GetAttributes(cursor)&FileAttributes.ReparsePoint)!=0) + return false; + } + catch(FileNotFoundException){} + catch(DirectoryNotFoundException){} + string parent=Path.GetDirectoryName(cursor); + if(String.IsNullOrEmpty(parent)||String.Equals(parent,cursor,StringComparison.OrdinalIgnoreCase)) + break; + cursor=parent; + } + return true; + } + + // Separate deterministic function for exact compiled-PE native negative + // tests. Production supplies OS known folders; tests pass isolated paths. + static bool TrustedWorkerPaths(string broker,string output,string executableDirectory, + string programFiles,string profile,string localAppData) + { + try + { + if(String.IsNullOrWhiteSpace(broker)||String.IsNullOrWhiteSpace(output)|| + String.IsNullOrWhiteSpace(executableDirectory)||String.IsNullOrWhiteSpace(programFiles)|| + String.IsNullOrWhiteSpace(profile)||String.IsNullOrWhiteSpace(localAppData)|| + broker.Length>2048||output.Length>2048|| + !Path.IsPathRooted(broker)||!Path.IsPathRooted(output)) + return false; + string pf=Path.GetFullPath(programFiles).TrimEnd(Path.DirectorySeparatorChar); + string user=Path.GetFullPath(profile).TrimEnd(Path.DirectorySeparatorChar); + string local=Path.GetFullPath(localAppData).TrimEnd(Path.DirectorySeparatorChar); + string dir=Path.GetFullPath(executableDirectory).TrimEnd(Path.DirectorySeparatorChar); + if(pf.StartsWith(@"\\",StringComparison.Ordinal)|| + user.StartsWith(@"\\",StringComparison.Ordinal)|| + local.StartsWith(@"\\",StringComparison.Ordinal)|| + dir.StartsWith(@"\\",StringComparison.Ordinal)) + return false; + string expectedLocal=Path.Combine(user,"AppData","Local"); + if(!String.Equals(local,expectedLocal,StringComparison.OrdinalIgnoreCase)) + return false; + string protectedDir=Path.Combine(pf,"TaskbarMonitorEnhanced","SensorBroker"); + string expectedBroker=Path.Combine(protectedDir,"TaskbarMonitorSensorBroker.exe"); + string expectedOutput=Path.Combine(local,"TaskbarMonitorEnhanced","cpu_temp_broker.json"); + if(!String.Equals(dir,protectedDir,StringComparison.OrdinalIgnoreCase)|| + !String.Equals(Path.GetFullPath(broker),expectedBroker,StringComparison.OrdinalIgnoreCase)|| + !String.Equals(Path.GetFullPath(output),expectedOutput,StringComparison.OrdinalIgnoreCase)|| + !File.Exists(expectedBroker)|| + !Directory.Exists(Path.GetDirectoryName(expectedOutput))) + return false; + return NoReparseComponents(expectedBroker)&&NoReparseComponents(expectedOutput); + } + catch{return false;} + } + static bool ParseArgs(string[] args) { - string legacyOutput=""; - for(int i=0;i Date: Mon, 19 Oct 2026 03:33:52 +0330 Subject: [PATCH 098/135] test(codeql): compare exact R337 and R338 compiled Supervisor findings --- .../audit-r338-manual-csharp-codeql.yml | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/audit-r338-manual-csharp-codeql.yml diff --git a/.github/workflows/audit-r338-manual-csharp-codeql.yml b/.github/workflows/audit-r338-manual-csharp-codeql.yml new file mode 100644 index 00000000..7adbbec9 --- /dev/null +++ b/.github/workflows/audit-r338-manual-csharp-codeql.yml @@ -0,0 +1,132 @@ +name: R338 Manual compiled CSharp CodeQL evidence + +on: + push: + branches: + - audit/r338-supervisor-argv-trust + paths: + - .github/workflows/audit-r338-manual-csharp-codeql.yml + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + manual-compiled-csharp: + name: Manual compiled CSharp ${{ matrix.variant }} on Windows + runs-on: windows-2025 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + variant: [baseline, candidate] + steps: + - name: Checkout exact reviewed product source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || 'edf2610c20789028050b3ea76b1bee01c77d07bf' }} + persist-credentials: false + - name: Assert disposable Windows and exact committed comparison source + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $expected="${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || 'edf2610c20789028050b3ea76b1bee01c77d07bf' }}" + if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:COMPUTERNAME -match 'EMAD-PC' -or + (git rev-parse HEAD).Trim() -cne $expected) { + throw 'R338_EXPECTED_DISPOSABLE_EXACT_PRODUCT_COMMIT_NOT_CHECKED_OUT' + } + 'R338_PINNED_WINDOWS_COMPILE_SOURCE='+$expected + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Initialize CodeQL for actual compiled CSharp + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: manual + config: | + threat-models: local + - name: Build exact four .NET Framework projects under CodeQL tracer + shell: pwsh + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -File ./build/Build.ps1 + if($LASTEXITCODE -ne 0){throw 'R338_MANUAL_CODEQL_PRIVATE_REPRO_BUILD_FAILED'} + $required=@( + 'build/_out/App/TaskbarMonitorEnhanced.exe', + 'build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe', + 'build/_out/Broker/TaskbarMonitorSensorBroker.exe', + 'build/_out/Supervisor/TaskbarMonitorSensorSupervisor.exe' + ) + foreach($p in $required){if(!(Test-Path -LiteralPath $p -PathType Leaf)){throw ('R338_EXPECTED_COMPILED_EXE_MISSING_'+$p)}} + 'R338_FOUR_REAL_COMPILED_CSHARP_PE=PASS' + - name: Analyze compiled sources without publishing security alerts + uses: github/codeql-action/analyze@v4 + with: + upload: never + output: codeql-results + - name: Verify raw compiled-source coverage and report diagnostics + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $sarifs=@(Get-ChildItem -LiteralPath codeql-results -Recurse -File -Filter '*.sarif') + if($sarifs.Count -ne 1){throw ('R338_MANUAL_CODEQL_EXPECTED_ONE_CSHARP_SARIF_GOT_'+$sarifs.Count)} + $sarif=Get-Content $sarifs[0].FullName -Raw|ConvertFrom-Json -Depth 100 + if(@($sarif.runs).Count -ne 1){throw 'R338_COMPILED_SARIF_RUN_COUNT_NOT_ONE'} + $run=$sarif.runs[0] + $artifacts=@($run.artifacts) + $files=@($artifacts|ForEach-Object {$_.location.uri}) + $setupRefs=@($files|Where-Object {$_ -like '*TaskbarMonitorEnhanced_Setup.cs*'}).Count + $appRefs=@($files|Where-Object {$_ -like '*TaskbarMonitorEnhanced.cs*'}).Count + $brokerRefs=@($files|Where-Object {$_ -like '*TaskbarMonitorSensorBroker.cs*'}).Count + $errors=0 + foreach($invocation in @($run.invocations)){ + $errors+=@($invocation.toolExecutionNotifications|Where-Object {$_.message.text -match 'Error CS[0-9]{4}'}).Count + } + if($setupRefs -lt 1 -or $appRefs -lt 1 -or $brokerRefs -lt 1){ + throw ('R338_MANUAL_CODEQL_COMPILED_SOURCE_COVERAGE_INCOMPLETE:Setup='+$setupRefs+' App='+$appRefs+' Broker='+$brokerRefs) + } + $findings=@($run.results) + $setup=@($findings|Where-Object {$_.locations[0].physicalLocation.artifactLocation.uri -like '*TaskbarMonitorEnhanced_Setup.cs*'}) + $supervisor=@($findings|Where-Object {$_.locations[0].physicalLocation.artifactLocation.uri -like '*TaskbarMonitorSensorSupervisor.cs*'}) + $expected="${{ matrix.variant == 'baseline' && '997c6f349fa64cfe6385113a9ede46d35479cc7a' || 'edf2610c20789028050b3ea76b1bee01c77d07bf' }}" + if((git rev-parse HEAD).Trim() -cne $expected){throw 'R338_MANUAL_RESULTS_NOT_AT_CHECKED_OUT_SHA'} + $summary=[ordered]@{ + Schema=1 + Revision='R338' + SourceCommit=$expected + AnalysisWorkflowCommit='${{ github.sha }}' + Variant='${{ matrix.variant }}' + Status='SCOPED_MANUAL_BUILD_CSHARP_CODEQL_RAW_RESULTS' + AnalyzerBuildMode='manual' + ThreatModel='remote_and_local' + FourRealFrameworkProjectsBuilt=$true + CompiledSetupSourceCount=$setupRefs + CompiledMainSourceCount=$appRefs + CompiledBrokerSourceCount=$brokerRefs + CompilerErrorNotifications=$errors + TotalFindings=$findings.Count + SetupFindings=$setup.Count + SetupPath=@($setup|Where-Object ruleId -eq 'cs/path-injection').Count + SetupCommand=@($setup|Where-Object ruleId -eq 'cs/command-line-injection').Count + SupervisorCommand=@($supervisor|Where-Object ruleId -eq 'cs/command-line-injection').Count + WholeSecurityAccepted=$false + RealElevatedUacHelperCrashTested=$false + ProducedWithoutPublicCodeScanningUpload=$true + } + $outfile='r338-manual-codeql-summary.json' + [IO.File]::WriteAllText($outfile,($summary|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + 'R338_MANUAL_CSHARP_VARIANT=${{ matrix.variant }} SETUP='+$setup.Count+' ALL='+$findings.Count+' COMPILER_NOTIFICATIONS='+$errors + - name: Retain compact raw SARIF and scoped summary + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R338-manual-csharp-${{ matrix.variant }}-${{ github.run_id }} + path: | + codeql-results + r338-manual-codeql-summary.json + retention-days: 7 + if-no-files-found: warn From 0d8eb1d6fb591d5e052d7791b68de91e2de53d2c Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 10:23:17 +0330 Subject: [PATCH 099/135] test(security): real second SID protected ProgramFiles race witness (R339) --- ...r339-cross-user-protected-programfiles.yml | 87 +++++ ...-ProtectedProgramFiles-TwoUsers-Native.ps1 | 298 ++++++++++++++++++ build/Test-R339-SecondAccount-Actor.ps1 | 150 +++++++++ 3 files changed, 535 insertions(+) create mode 100644 .github/workflows/audit-r339-cross-user-protected-programfiles.yml create mode 100644 build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 create mode 100644 build/Test-R339-SecondAccount-Actor.ps1 diff --git a/.github/workflows/audit-r339-cross-user-protected-programfiles.yml b/.github/workflows/audit-r339-cross-user-protected-programfiles.yml new file mode 100644 index 00000000..3f2d3934 --- /dev/null +++ b/.github/workflows/audit-r339-cross-user-protected-programfiles.yml @@ -0,0 +1,87 @@ +name: R339 genuine second Windows SID vs protected ProgramFiles (disposable) +on: + push: + branches: [audit/r339-two-user-protected-broker] + paths: + - build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 + - build/Test-R339-SecondAccount-Actor.ps1 + - .github/workflows/audit-r339-cross-user-protected-programfiles.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + cross-user-protected-programfiles: + name: R339 native Windows ${{ matrix.host }} real two users + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact test-only source with no credential persistence + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Require only disposable hosted Windows exact SHA before native account or protected root mutation + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r339-two-user-protected-broker' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R339_REFUSE_NON_DISPOSABLE_OR_NONPINNED_HOST' + } + $program=[Environment]::GetFolderPath('ProgramFiles') + $app=Join-Path $program 'TaskbarMonitorEnhanced' + $local=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $local)){ + throw 'R339_REAL_PRODUCT_INSTALL_OR_PREEXISTING_APP_ON_GUEST' + } + $token=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$token.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R339_ALREADY_ADMIN_TOKEN_REQUIRED_NOT_REAL_UAC' + } + 'R339_EXACT_DISPOSABLE_HOST_AND_NO_EXISTING_PRODUCT_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Compile actual candidate Supervisor and Broker (never launch either) + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Supervisor/TaskbarMonitorSensorSupervisor.exe) -or + !(Test-Path -LiteralPath build/_out/Broker/TaskbarMonitorSensorBroker.exe)){ + throw 'R339_COMPILED_PROTECTED_BINARIES_ABSENT' + } + 'R339_CANDIDATE_ALL_FOUR_COMPONENTS_BUILD_SUCCESS' + - name: Run two genuine local Windows accounts against admin-owned protected ProgramFiles + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $runner=(Resolve-Path build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $runner -HostLabel '${{ matrix.host }}' -CandidateRoot $source + if($LASTEXITCODE -ne 0){throw 'R339_REAL_SECOND_USER_PROTECTED_PROGRAMFILES_SECURITY_GATE_FAILED'} + - name: Retain typed two-user guest evidence without public scanner upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R339-${{ matrix.host }}-${{ github.run_id }} + path: ${{ runner.temp }}/R339_*/R339_PROTECTED_PROGRAMFILES_TWO_USERS.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 new file mode 100644 index 00000000..d03c3c86 --- /dev/null +++ b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 @@ -0,0 +1,298 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$CandidateRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R339 native cross-account DACL/TOCTOU check is destructive ONLY to uniquely +# owned test fixtures on two short-lived github-hosted Windows VM runners. +# It does NOT test interactive UAC, the original Helper, or the owner's PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r339-two-user-protected-broker' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$'){ + throw 'R339_REFUSE_NON_DISPOSABLE_NATIVE_WINDOWS' +} +$root=(Resolve-Path -LiteralPath $CandidateRoot).Path +if((git -C $root rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R339_PRODUCT_SOURCE_NOT_EXACT_RUNNING_SHA' +} +$principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R339_ALREADY_ELEVATED_HOSTED_ADMIN_REQUIRED_NO_AUTO_UAC' +} +$parentSid=[string]([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) +$program=[IO.Path]::GetFullPath([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) +$protectedRoot=Join-Path $program 'TaskbarMonitorEnhanced' +$brokerDir=Join-Path $protectedRoot 'SensorBroker' +$exe=Join-Path $brokerDir 'TaskbarMonitorSensorBroker.exe' +$supervisor=Join-Path $brokerDir 'TaskbarMonitorSensorSupervisor.exe' +$marker=Join-Path $brokerDir '.tbme-owner.sid' +$app=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'TaskbarMonitorEnhanced' +$cpu=Join-Path $app 'cpu_temp_broker.json' +$name='TBME339'+$HostLabel+$env:GITHUB_RUN_ID.Substring($env:GITHUB_RUN_ID.Length-7) +$shared=Join-Path $env:ProgramData ('TBME_R339_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$results=Join-Path $shared 'Results' +$authority=Join-Path $shared 'R339_AUTHORITY.json' +$workerPath=Join-Path $shared 'Test-R339-SecondAccount-Actor.ps1' +$childReceipt=Join-Path $results 'R339_B_RECEIPT.json' +$childReady=Join-Path $results 'R339_ACTOR_READY' +$out=Join-Path $env:RUNNER_TEMP ('R339_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $out 'R339_PROTECTED_PROGRAMFILES_TWO_USERS.json' +$inputBroker=Join-Path $root 'build\_out\Broker\TaskbarMonitorSensorBroker.exe' +$inputSupervisor=Join-Path $root 'build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe' +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$sourceActor=Join-Path $PSScriptRoot 'Test-R339-SecondAccount-Actor.ps1' +$ownerCreated=$false;$sharedCreated=$false;$appCreated=$false;$accountCreated=$false +$actorSid='';$child=$null;$childPid=0;$actorRead=$false;$rootRemoved=$false +$accountRemoved=$false;$sharedRemoved=$false;$appRemoved=$false +$denials=0;$concurrentChecks=0;$totalChecks=0;$failure='';$phase='PRECONDITION' +$checks=[Collections.Generic.List[string]]::new() +$initial=@{};$outsideCanary='' +function Hash([string]$path){return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash} +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function NewOwnedDirectory([string]$path,[bool]$writeForUsers){ + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){if($writeForUsers){[Security.AccessControl.FileSystemRights]::Modify}else{[Security.AccessControl.FileSystemRights]::ReadAndExecute}}else{[Security.AccessControl.FileSystemRights]::FullControl} + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $sid,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow) + [void]$acl.AddAccessRule($rule) + } + [void][IO.Directory]::CreateDirectory($path,$acl) +} +function AssertNoReparse([string]$path){ + if(([IO.File]::GetAttributes($path) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw ('R339_DISPOSABLE_FIXTURE_BECAME_REPARSE '+[IO.Path]::GetFileName($path)) + } +} +function TryRemoveProtectedFixture(){ + if(!$script:ownerCreated -or ![IO.Directory]::Exists($protectedRoot)){return $false} + AssertNoReparse $protectedRoot + AssertNoReparse $brokerDir + $entries=@([IO.Directory]::GetFileSystemEntries($protectedRoot)) + if($entries.Count -ne 1 -or $entries[0] -cne $brokerDir){throw 'R339_UNEXPECTED_PROTECTED_ROOT_MEMBER_KEEP_FOR_GUEST_DISPOSAL'} + $files=@([IO.Directory]::GetFileSystemEntries($brokerDir)) + if($files.Count -ne 3 -or @($files|Where-Object {$_ -notin @($exe,$supervisor,$marker)}).Count){ + throw 'R339_UNEXPECTED_BROKER_FOLDER_MEMBER_KEEP_FOR_GUEST_DISPOSAL' + } + foreach($file in @($exe,$supervisor,$marker)){ + AssertNoReparse $file + if($file -eq $exe -and (Hash $file) -cne $initial.Broker){throw 'R339_BROKER_MUTATED_KEEP_EVIDENCE'} + if($file -eq $supervisor -and (Hash $file) -cne $initial.Supervisor){throw 'R339_SUPERVISOR_MUTATED_KEEP_EVIDENCE'} + } + foreach($file in @($exe,$supervisor,$marker)){[IO.File]::Delete($file)} + [IO.Directory]::Delete($brokerDir,$false) + [IO.Directory]::Delete($protectedRoot,$false) + return !$([IO.Directory]::Exists($protectedRoot)) +} +try{ + foreach($entry in @($protectedRoot,$app,$shared,$out)){ + if(Test-Path -LiteralPath $entry){throw ('R339_DISPOSABLE_FIXTURE_NAME_PREEXISTS '+[IO.Path]::GetFileName($entry))} + } + if((Get-LocalUser -Name $name -ErrorAction SilentlyContinue) -or + @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue).Count){ + throw 'R339_PREEXISTING_ACCOUNT_OR_REAL_PRODUCT_PROCESS' + } + if(@(Get-ScheduledTask -TaskPath '\' -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -ErrorAction SilentlyContinue |Where-Object {$null -ne $_}).Count){ + throw 'R339_PREEXISTING_PRODUCT_SCHEDULED_TASK' + } + foreach($file in @($inputBroker,$inputSupervisor,$sourceActor,$native)){ + if(![IO.File]::Exists($file)){throw 'R339_CANDIDATE_SOURCE_OR_SYSTEM_EXECUTABLE_MISSING'} + } + [void][IO.Directory]::CreateDirectory($out) + Check 'DISPOSABLE_RUNNER_FRESH_PRODUCT_ACCOUNT_AND_SCHEDULER_PRECONDITIONS' + $phase='CREATE_EXACT_NEW_PROTECTED_PROGRAMFILES_FIXTURE' + NewOwnedDirectory $protectedRoot $false + $ownerCreated=$true + [void][IO.Directory]::CreateDirectory($brokerDir) + [IO.File]::Copy($inputBroker,$exe) + [IO.File]::Copy($inputSupervisor,$supervisor) + [IO.File]::WriteAllText($marker,$parentSid,[Text.UTF8Encoding]::new($false)) + $initial=@{Broker=Hash $exe;Supervisor=Hash $supervisor;Owner=Hash $marker} + if($initial.Broker -cne (Hash $inputBroker) -or $initial.Supervisor -cne (Hash $inputSupervisor)){ + throw 'R339_PRIVATE_COMPILED_SOURCE_BYTES_NOT_EXACT' + } + $dirAcl=Get-Acl -LiteralPath $brokerDir + $exeAcl=Get-Acl -LiteralPath $exe + if([string]$dirAcl.Owner -notmatch '(?i)Administrators' -or [string]$exeAcl.Owner -notmatch '(?i)Administrators'){ + throw 'R339_PROTECTED_FIXTURE_NOT_ADMINISTRATOR_OWNED' + } + foreach($acl in @($dirAcl,$exeAcl)){ + foreach($ace in @($acl.Access|Where-Object{$_.IdentityReference.Value -match '(?i)BUILTIN\\Users'})){ + if($ace.FileSystemRights.ToString() -match '(?i)Write|Modify|FullControl|Delete|ChangePermissions|TakeOwnership'){ + throw 'R339_USERS_HAVE_WRITE_ON_PROTECTED_FIXTURE' + } + } + } + Check 'REAL_PROGRAMFILES_PROTECTED_ACL_NO_OTHER_USERS_WRITE_AND_COMPILED_BINARY_BYTES_PINNED' + $phase='PREPARE_SCOPED_OTHER_ACCOUNT_SCRATCH' + NewOwnedDirectory $shared $false + $sharedCreated=$true + NewOwnedDirectory $results $true + [IO.File]::Copy($sourceActor,$workerPath) + if((Hash $workerPath) -cne (Hash $sourceActor)){throw 'R339_WORKER_COPY_CHANGED_BEFORE_CREDENTIAL_RUN'} + [void][IO.Directory]::CreateDirectory($app) + $appCreated=$true + $outsideCanary=Join-Path $out 'FOREIGN_CANARY.txt' + [IO.File]::WriteAllText($outsideCanary,'R339_OUTSIDE_CANARY_UNCHANGED',[Text.UTF8Encoding]::new($false)) + $initial.Outside=Hash $outsideCanary + Check 'ONLY_WHITELISTED_PROGRAMFILES_AND_PROGRAMDATA_FIXTURES_CREATED' + $phase='CREATE_ACTUAL_OTHER_LOCAL_WINDOWS_SID' + $bytes=[byte[]]::new(32) + $rng=[Security.Cryptography.RandomNumberGenerator]::Create() + try{$rng.GetBytes($bytes)}finally{$rng.Dispose()} + $secret=[Convert]::ToBase64String($bytes)+'A2!' + $secure=ConvertTo-SecureString $secret -AsPlainText -Force + $credential=[Management.Automation.PSCredential]::new(($env:COMPUTERNAME+'\'+$name),$secure) + $secret=$null + $account=New-LocalUser -Name $name -Password $secure -AccountNeverExpires -Description 'R339 disposable hosted VM cross-account ACL witness' + $accountCreated=$true + $actorSid=[string]$account.SID.Value + if($actorSid -cnotmatch '^S-1-5-21-' -or $actorSid -ceq $parentSid){throw 'R339_SECOND_WINDOWS_ACCOUNT_NOT_DISTINCT'} + Check 'REAL_OTHER_LOCAL_NONADMIN_SID_CREATED_ON_DISPOSABLE_HOST' + $authorityData=[ordered]@{ + Revision='R339';SourceCommit=$env:GITHUB_SHA;HostLabel=$HostLabel + HostComputerName=$env:COMPUTERNAME;ParentSid=$parentSid + ExpectedActorSid=$actorSid;ProtectedBrokerPath=$exe + ExpectedBrokerSHA256=$initial.Broker;ExpectedSupervisorSHA256=$initial.Supervisor + } + [IO.File]::WriteAllText($authority,($authorityData|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + $phase='COMPILLED_PRODUCTION_TRUST_PATH_BEFORE_ACTOR' + $assembly=[Reflection.Assembly]::LoadFrom($inputSupervisor) + $type=$assembly.GetType('TaskbarMonitorSensorSupervisor',$true) + $method=$type.GetMethod('TrustedWorkerPaths',[Reflection.BindingFlags]'NonPublic,Static') + if(!$method){throw 'R339_PRODUCTION_SOURCE_TRUST_METHOD_MISSING'} + $args=New-Object 'object[]' 6 + $values=[string[]]@($exe,$cpu,$brokerDir,$program,[Environment]::GetFolderPath('UserProfile'), + [Environment]::GetFolderPath('LocalApplicationData')) + for($i=0;$i -lt $values.Length;$i++){$args[$i]=$values[$i]} + if(!([bool]$method.Invoke($null,$args))){throw 'R339_PRODUCTION_SUPERVISOR_PROTECTED_KNOWN_PATH_NOT_ACCEPTED'} + Check 'EXACT_COMPILED_PRODUCTION_TRUST_GUARD_ACCEPTS_OWN_PROTECTED_FIXTURE' + $phase='START_ACTUAL_OTHER_NONADMIN_ACCOUNT' + $arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$workerPath+'" -AuthorityPath "'+$authority+'" -ReceiptPath "'+$childReceipt+'"' + $child=Start-Process -FilePath $native -ArgumentList $arguments -Credential $credential -LoadUserProfile -PassThru -WindowStyle Hidden -WorkingDirectory $shared -ErrorAction Stop + if(!$child){throw 'R339_DIFFERENT_USER_PROCESS_FAILED_TO_START'} + $childPid=$child.Id + $started=[Diagnostics.Stopwatch]::StartNew() + while(![IO.File]::Exists($childReady) -and !$child.HasExited -and $started.Elapsed.TotalSeconds -lt 25){ + Start-Sleep -Milliseconds 100 + } + if(![IO.File]::Exists($childReady)){throw 'R339_OTHER_LOCAL_ACTOR_DID_NOT_REACH_MUTATION_CHECKPOINT'} + Check 'OTHER_SID_ACTOR_REACHED_CONCURRENT_MUTATION_CHECKPOINT' + $phase='ADMIN_PRODUCTION_GUARD_WHILE_OTHER_SID_ATTEMPTS_MUTATION' + while(!$child.HasExited -and $concurrentChecks -lt 100){ + if(!([bool]$method.Invoke($null,$args))){throw 'R339_TRUST_GUARD_LOST_VALID_PROTECTED_PATH_DURING_ATTACK'} + if((Hash $exe) -cne $initial.Broker -or (Hash $supervisor) -cne $initial.Supervisor){ + throw 'R339_BINARY_DRIFT_DURING_OTHER_NONADMIN_SID_ATTACK' + } + $concurrentChecks++ + Start-Sleep -Milliseconds 30 + } + if(!$child.WaitForExit(25000)){throw 'R339_SECOND_USER_ATTACK_PROCESS_TIMEOUT'} + if($child.ExitCode -ne 0){throw ('R339_SECOND_USER_RETURNED_NONZERO_'+$child.ExitCode)} + if($concurrentChecks -lt 1){throw 'R339_NO_CONCURRENT_OWNER_GUARD_WITNESS'} + Check 'ADMIN_REAL_COMPILED_SUPERVISOR_TRUST_READS_DURING_ACTUAL_OTHER_USER_ATTACK' + $phase='VERIFY_OTHER_USER_TYPED_RECEIPT_AND_BOUNDARIES' + if(![IO.File]::Exists($childReceipt)){throw 'R339_OTHER_ACCOUNT_RECEIPT_NOT_RETURNED'} + $guest=Get-Content -LiteralPath $childReceipt -Raw|ConvertFrom-Json + if($guest.Status -cne 'PASS_SECOND_REAL_LOCAL_SID_DENIED_420_PROTECTED_MUTATIONS' -or + $guest.ActorSid -cne $actorSid -or + $guest.ParentSid -cne $parentSid -or + $guest.ActualDifferentLocalAccount -ne $true -or + $guest.ActorIsAdmin -ne $false -or + $guest.CanReadProtectedBroker -ne $true -or + $guest.SourceCommit -cne $env:GITHUB_SHA -or + $guest.ActualUacPromptTested -ne $false -or + @($guest.Checks).Count -ne 3 -or + @($guest.DeniedByOperation.PSObject.Properties.Value |Where-Object{$_ -ne 60}).Count -gt 0){ + throw ('R339_DIFFERENT_TOKEN_RECEIPT_NEGATIVE_FAIL '+($guest|ConvertTo-Json -Depth 3 -Compress)) + } + $actorRead=$true + $denials=420 + if((Hash $exe) -cne $initial.Broker -or (Hash $supervisor) -cne $initial.Supervisor -or + (Hash $marker) -cne $initial.Owner -or + (Hash $outsideCanary) -cne $initial.Outside){ + throw 'R339_PROTECTED_FILES_OR_EXTERNAL_CANARY_CHANGED_BY_OTHER_SID' + } + Check '420_OTHER_SID_PROTECTED_RENAME_OVERWRITE_DELETE_LINK_OPERATIONS_DENIED_AND_HASHES_STABLE' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($child){ + try{if(!$child.HasExited){$child.Kill();[void]$child.WaitForExit(8000)}}catch{} + $child.Dispose() + } + if($accountCreated){ + try{ + $accountNow=Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if($accountNow -and [string]$accountNow.SID.Value -ceq $actorSid){ + Disable-LocalUser -Name $name -ErrorAction Stop + Remove-LocalUser -Name $name -ErrorAction Stop + $accountRemoved=($null -eq (Get-LocalUser -Name $name -ErrorAction SilentlyContinue)) + } + }catch{ $failure+= ' ACCOUNT_CLEANUP_FAILED='+$_.Exception.Message} + } + if($ownerCreated){ + try{$rootRemoved=TryRemoveProtectedFixture} + catch{$failure+=' PROTECTED_FIXTURE_CLEANUP_REFUSED='+$_.Exception.Message} + } + if($appCreated){ + try{ + if([IO.Directory]::Exists($app) -and @([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + $appRemoved=(![IO.Directory]::Exists($app)) + }catch{$failure+=' DISPOSABLE_LOCALAPP_CLEANUP_REFUSED='+$_.Exception.Message} + } + if($sharedCreated){ + try{ + foreach($f in @($childReady,$childReceipt,$authority,$workerPath)){ + if([IO.File]::Exists($f)){[IO.File]::Delete($f)} + } + if([IO.Directory]::Exists($results)){[IO.Directory]::Delete($results,$false)} + if([IO.Directory]::Exists($shared)){[IO.Directory]::Delete($shared,$false)} + $sharedRemoved=(![IO.Directory]::Exists($shared)) + }catch{$failure+=' DISPOSABLE_SHARE_CLEANUP_REFUSED='+$_.Exception.Message} + } + if(!$accountRemoved -or !$rootRemoved -or !$sharedRemoved -or !$appRemoved){ + $failure+=' INCOMPLETE_SCOPED_TEST_FIXTURE_CLEANUP' + } + $status=if(!$failure -and $actorRead -and $denials -eq 420 -and $checks.Count -eq 8 -and $concurrentChecks -ge 1){ + 'PASS_REAL_NONADMIN_SECOND_SID_PROTECTED_PROGRAMFILES_CONCURRENT_REFUSAL' + }else{'FAIL_CLOSED_DISPOSABLE_WINDOWS_ONLY'} + $data=[ordered]@{ + Schema=1;Revision='R339';Status=$status;SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + HostType='github-hosted-disposable-Windows' + ActorAAlreadyAdmin=$true;ActorBSID=$actorSid;ActorASID=$parentSid + RealDistinctSecondNonAdminAccount=$actorRead;DeniedMutationCount=$denials;AttemptedOperationKinds=7 + ActualProductionTrustGuardConcurrentChecks=$concurrentChecks + ProtectedBrokerSHA256=$initial.Broker;ProtectedSupervisorSHA256=$initial.Supervisor + WindowsProtectedProgramFilesFixtureWasCreated=$ownerCreated + WindowsProtectedProgramFilesFixtureRemoved=$rootRemoved + DisposableSecondAccountRemoved=$accountRemoved;DisposableProgramDataRemoved=$sharedRemoved + DisposableProfileAppRootRemoved=$appRemoved + TrueInteractiveUacConsentTested=$false + OriginalElevatedSensorHelperStartedOrKilled=$false + OriginalOwnerComputerTouched=$false;OriginalReleaseOrDriversModified=$false + Checks=@($checks);Failure=$failure + RecordedUtc=[DateTime]::UtcNow.ToString('o') + } + if([IO.Directory]::Exists($out)){ + [IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ('R339_RECEIPT='+$receipt) + } + Write-Output ('R339_RESULT='+$status+' CHECKS='+$checks.Count+' DENIALS='+$denials+' CONCURRENT='+$concurrentChecks) +} +if($failure){throw ('R339_CROSS_ACCOUNT_NATIVE_REFUSAL_NOT_ACCEPTED '+$failure)} diff --git a/build/Test-R339-SecondAccount-Actor.ps1 b/build/Test-R339-SecondAccount-Actor.ps1 new file mode 100644 index 00000000..8c324d69 --- /dev/null +++ b/build/Test-R339-SecondAccount-Actor.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$AuthorityPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R339 only runs as the actual newly-created *nonadmin* SID on an isolated +# GitHub-hosted Windows runner. It never executes a SensorBroker or installer. +if($env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R339_SECOND_ACCOUNT_REFUSE_KNOWN_OWNER_MACHINE' +} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +$principal=[Security.Principal.WindowsPrincipal]::new($identity) +if($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R339_SECOND_ACCOUNT_MUST_NOT_BE_ADMIN' +} +$sid=[string]$identity.User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){ + throw 'R339_NONADMIN_SID_EXPECTED' +} +$program=[IO.Path]::GetFullPath([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) +$expectedProgram=Join-Path $program 'TaskbarMonitorEnhanced' +$expectedBrokerDir=Join-Path $expectedProgram 'SensorBroker' +$expectedExe=Join-Path $expectedBrokerDir 'TaskbarMonitorSensorBroker.exe' +$expectedSupervisor=Join-Path $expectedBrokerDir 'TaskbarMonitorSensorSupervisor.exe' +$allowedSharedRoot=[IO.Path]::GetFullPath((Join-Path $env:ProgramData 'R339_' )) +$authority=[IO.Path]::GetFullPath($AuthorityPath) +$parentDir=[IO.Path]::GetDirectoryName($authority) +$resultsDir=Join-Path $parentDir 'Results' +$expectedReceipt=Join-Path $resultsDir 'R339_B_RECEIPT.json' +if($parentDir -cnotmatch '[/\\]TBME_R339_[AB]_\d{5,20}$' -or + ![string]::Equals($authority,(Join-Path $parentDir 'R339_AUTHORITY.json'),[StringComparison]::OrdinalIgnoreCase) -or + ![string]::Equals([IO.Path]::GetFullPath($ReceiptPath),$expectedReceipt,[StringComparison]::OrdinalIgnoreCase) -or + -not $parentDir.StartsWith(($env:ProgramData.TrimEnd('\')+'\'),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R339_SECOND_ACCOUNT_AUTHORITY_OUTSIDE_OWNED_PROGRAMDATA' +} +if(!(Test-Path -LiteralPath $authority -PathType Leaf) -or + !(Test-Path -LiteralPath $expectedExe -PathType Leaf) -or + !(Test-Path -LiteralPath $expectedSupervisor -PathType Leaf)){ + throw 'R339_SECOND_ACCOUNT_AUTHORITY_OR_PROTECTED_FILES_ABSENT' +} +$j=Get-Content -LiteralPath $authority -Raw|ConvertFrom-Json +if($j.Revision -cne 'R339' -or $j.ExpectedActorSid -cne $sid -or + $j.HostComputerName -cne $env:COMPUTERNAME -or + $j.ProtectedBrokerPath -cne $expectedExe -or + $j.SourceCommit -cnotmatch '^[0-9a-f]{40}$'){ + throw 'R339_SECOND_ACCOUNT_AUTHORITY_NOT_BOUND_TO_TOKEN_AND_ROOT' +} +if(Test-Path -LiteralPath $expectedReceipt){throw 'R339_ACTOR_RECEIPT_PREEXISTS'} +$denials=[ordered]@{ + ProtectedRootRename=0;BrokerFolderRename=0; + BrokerExecutableOverwrite=0;BrokerExecutableDelete=0; + UnexpectedChildDirectory=0;UnexpectedJunction=0; + SupervisorExecutableOverwrite=0 +} +$failure='' +$canReadProtected=$false +$checks=[Collections.Generic.List[string]]::new() +$beforeExe='' +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function RefuseMutation([string]$label,[scriptblock]$action){ + try{ + & $action | Out-Null + throw ('R339_ACTOR_WRITE_SUCCEEDED_'+$label) + }catch{ + if($_.Exception.Message.StartsWith('R339_ACTOR_WRITE_SUCCEEDED_',[StringComparison]::Ordinal)){ + throw + } + $reason=$_.Exception + $allowed=$false + while($reason){ + if($reason -is [UnauthorizedAccessException] -or + $reason -is [Security.SecurityException] -or + $reason.HResult -eq -2147024891 -or + ($reason -is [IO.IOException] -and $reason.Message -match '(?i)denied|permission|access|privilege')){ + $allowed=$true;break + } + $reason=$reason.InnerException + } + if(!$allowed){ + throw ('R339_UNEXPECTED_MUTATION_FAILURE_TYPE_'+$label+' '+$_.Exception.Message) + } + $script:denials[$label]=[int]$script:denials[$label]+1 + } +} +try{ + if((Hash $expectedExe) -cne [string]$j.ExpectedBrokerSHA256 -or + (Hash $expectedSupervisor) -cne [string]$j.ExpectedSupervisorSHA256){ + throw 'R339_ACTOR_CANNOT_READ_EXACT_PROTECTED_FILES' + } + $canReadProtected=$true + $beforeExe=Hash $expectedExe + $checks.Add('ACTUAL_OTHER_LOCAL_WINDOWS_SID_NONADMIN_READS_PROTECTED_BINARY') + # This checkpoint lets the owning high-integrity test actor begin its + # in-memory production guard reads before the attempts start. + [IO.File]::WriteAllText((Join-Path $resultsDir 'R339_ACTOR_READY'),'SECOND_USER_BOUND',[Text.UTF8Encoding]::new($false)) + Start-Sleep -Milliseconds 450 + for($i=0;$i -lt 60;$i++){ + RefuseMutation 'ProtectedRootRename' { + [IO.Directory]::Move($expectedProgram,($expectedProgram+'_foreign')) + } + RefuseMutation 'BrokerFolderRename' { + [IO.Directory]::Move($expectedBrokerDir,($expectedBrokerDir+'_foreign')) + } + RefuseMutation 'BrokerExecutableOverwrite' { + [IO.File]::WriteAllText($expectedExe,'UNAUTHORIZED_R339_WRITE') + } + RefuseMutation 'BrokerExecutableDelete' { + [IO.File]::Delete($expectedExe) + } + RefuseMutation 'UnexpectedChildDirectory' { + [void][IO.Directory]::CreateDirectory((Join-Path $expectedBrokerDir 'R339_UNAUTHORISED_SUBDIR')) + } + RefuseMutation 'UnexpectedJunction' { + New-Item -ItemType Junction -Path (Join-Path $expectedBrokerDir 'R339_UNAUTHORISED_LINK') -Target $resultsDir -ErrorAction Stop | Out-Null + } + RefuseMutation 'SupervisorExecutableOverwrite' { + [IO.File]::WriteAllText($expectedSupervisor,'UNAUTHORIZED_R339_SUPERVISOR_WRITE') + } + if($i%10 -eq 0){Start-Sleep -Milliseconds 15} + } + if(@($denials.Values |Where-Object{$_ -ne 60}).Count){ + throw 'R339_B_ACTOR_NOT_ALL_MUTATIONS_ACCESS_DENIED' + } + $checks.Add('420_ACTUAL_NONADMIN_MUTATIONS_ALL_ACCESS_DENIED') + if((Hash $expectedExe) -cne $beforeExe -or + (Hash $expectedSupervisor) -cne [string]$j.ExpectedSupervisorSHA256){ + throw 'R339_ACTOR_PROTECTED_BYTES_CHANGED' + } + $checks.Add('OTHER_ACCOUNT_PROTECTED_EXE_BYTES_STILL_PINNED') +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message +}finally{ + $status=if(!$failure -and $checks.Count -eq 3){'PASS_SECOND_REAL_LOCAL_SID_DENIED_420_PROTECTED_MUTATIONS'}else{'FAIL_SECOND_ACCOUNT'} + $out=[ordered]@{ + Schema=1;Revision='R339';Status=$status + SourceCommit=$j.SourceCommit;Host=$j.HostLabel + ActorSid=$sid;ActorIsAdmin=$false;ParentSid=$j.ParentSid + ActualDifferentLocalAccount=($sid -cne $j.ParentSid) + CanReadProtectedBroker=$canReadProtected;DeniedByOperation=$denials + ProtectedBrokerHashBefore=$beforeExe;ExpectedBrokerSHA256=$j.ExpectedBrokerSHA256 + Iterations=60;Checks=@($checks);Failure=$failure + ActualUacPromptTested=$false;ActualOriginalHelperStarted=$false + OwnerMachineTouched=$false + } + [IO.File]::WriteAllText($expectedReceipt,($out|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) + Write-Output ('R339_OTHER_USER_STATUS='+$status) +} +if($failure){throw ('R339_ACTOR_MUTATION_REFUSAL_FAILED '+$failure)} From 2b4352e34579686d4b8303552082721848ffe8c6 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 10:25:29 +0330 Subject: [PATCH 100/135] test(r339): preserve typed failure receipt before fixture setup (F01) --- build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 index d03c3c86..737c518b 100644 --- a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 +++ b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 @@ -54,7 +54,7 @@ $actorSid='';$child=$null;$childPid=0;$actorRead=$false;$rootRemoved=$false $accountRemoved=$false;$sharedRemoved=$false;$appRemoved=$false $denials=0;$concurrentChecks=0;$totalChecks=0;$failure='';$phase='PRECONDITION' $checks=[Collections.Generic.List[string]]::new() -$initial=@{};$outsideCanary='' +$initial=@{Broker="NOT_STARTED";Supervisor="NOT_STARTED";Owner="NOT_STARTED";Outside="NOT_STARTED"};$outsideCanary='' function Hash([string]$path){return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash} function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} function NewOwnedDirectory([string]$path,[bool]$writeForUsers){ From 0199fb6897062d514402b374e89ed51dd490f978 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 10:29:13 +0330 Subject: [PATCH 101/135] test(r339): distinguish build-only LocalAppData scratch from installed product (F02) --- ...-ProtectedProgramFiles-TwoUsers-Native.ps1 | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 index 737c518b..d35e36ad 100644 --- a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 +++ b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 @@ -98,8 +98,21 @@ function TryRemoveProtectedFixture(){ return !$([IO.Directory]::Exists($protectedRoot)) } try{ - foreach($entry in @($protectedRoot,$app,$shared,$out)){ - if(Test-Path -LiteralPath $entry){throw ('R339_DISPOSABLE_FIXTURE_NAME_PREEXISTS '+[IO.Path]::GetFileName($entry))} + # The exact runner was verified empty immediately before Build.ps1. Its + # selftests may create an innocuous LocalAppData scratch directory. Do not + # treat that directory as an installation, nor delete preexisting bytes. + foreach($entry in @($protectedRoot,$shared,$out)){ + if(Test-Path -LiteralPath $entry){throw ('R339_PROTECTED_OR_RECEIPT_FIXTURE_PREEXISTS '+$entry)} + } + if(Test-Path -LiteralPath $app){ + if(!(Test-Path -LiteralPath $app -PathType Container) -or + @(Get-ChildItem -LiteralPath $app -Force -File -ErrorAction Stop | + Where-Object {$_.Name -in @('TaskbarMonitorEnhanced.exe','Uninstall.exe','install_state.json','config.json')}).Count -gt 0 -or + (Test-Path -LiteralPath 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TaskbarMonitorEnhanced')){ + throw 'R339_BUILD_CREATED_OR_EXISTING_REAL_INSTALLATION_REFUSED' + } + $appRemoved=$true # No new app root was created, preserve all prior scratch. + $checks.Add('PREEXISTING_BUILD_SELFTEST_LOCALAPPDATA_ROOT_PRESERVED') } if((Get-LocalUser -Name $name -ErrorAction SilentlyContinue) -or @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue).Count){ @@ -143,8 +156,10 @@ try{ NewOwnedDirectory $results $true [IO.File]::Copy($sourceActor,$workerPath) if((Hash $workerPath) -cne (Hash $sourceActor)){throw 'R339_WORKER_COPY_CHANGED_BEFORE_CREDENTIAL_RUN'} - [void][IO.Directory]::CreateDirectory($app) - $appCreated=$true + if(-not [IO.Directory]::Exists($app)){ + [void][IO.Directory]::CreateDirectory($app) + $appCreated=$true + } $outsideCanary=Join-Path $out 'FOREIGN_CANARY.txt' [IO.File]::WriteAllText($outsideCanary,'R339_OUTSIDE_CANARY_UNCHANGED',[Text.UTF8Encoding]::new($false)) $initial.Outside=Hash $outsideCanary @@ -266,10 +281,11 @@ try{ $sharedRemoved=(![IO.Directory]::Exists($shared)) }catch{$failure+=' DISPOSABLE_SHARE_CLEANUP_REFUSED='+$_.Exception.Message} } - if(!$accountRemoved -or !$rootRemoved -or !$sharedRemoved -or !$appRemoved){ - $failure+=' INCOMPLETE_SCOPED_TEST_FIXTURE_CLEANUP' - } - $status=if(!$failure -and $actorRead -and $denials -eq 420 -and $checks.Count -eq 8 -and $concurrentChecks -ge 1){ + if($accountCreated -and !$accountRemoved){$failure+=' OTHER_SID_ACCOUNT_CLEANUP_INCOMPLETE'} + if($ownerCreated -and !$rootRemoved){$failure+=' PROTECTED_TEST_ROOT_CLEANUP_INCOMPLETE'} + if($sharedCreated -and !$sharedRemoved){$failure+=' PROGRAMDATA_FIXTURE_CLEANUP_INCOMPLETE'} + if($appCreated -and !$appRemoved){$failure+=' CREATED_USER_APP_ROOT_CLEANUP_INCOMPLETE'} + $status=if(!$failure -and $actorRead -and $denials -eq 420 -and $checks.Count -eq $(if($appCreated){8}else{9}) -and $concurrentChecks -ge 1){ 'PASS_REAL_NONADMIN_SECOND_SID_PROTECTED_PROGRAMFILES_CONCURRENT_REFUSAL' }else{'FAIL_CLOSED_DISPOSABLE_WINDOWS_ONLY'} $data=[ordered]@{ @@ -280,6 +296,7 @@ try{ ActualProductionTrustGuardConcurrentChecks=$concurrentChecks ProtectedBrokerSHA256=$initial.Broker;ProtectedSupervisorSHA256=$initial.Supervisor WindowsProtectedProgramFilesFixtureWasCreated=$ownerCreated + PreExistingBuildOnlyLocalAppRootRetained=(!$appCreated -and [IO.Directory]::Exists($app)) WindowsProtectedProgramFilesFixtureRemoved=$rootRemoved DisposableSecondAccountRemoved=$accountRemoved;DisposableProgramDataRemoved=$sharedRemoved DisposableProfileAppRootRemoved=$appRemoved From 0cbd79275d73cd299dfeea789b09f9e841f2d840 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 10:31:24 +0330 Subject: [PATCH 102/135] test(r339): use supported Windows local-account description (F03) --- build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 index d35e36ad..a2c486df 100644 --- a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 +++ b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 @@ -172,7 +172,7 @@ try{ $secure=ConvertTo-SecureString $secret -AsPlainText -Force $credential=[Management.Automation.PSCredential]::new(($env:COMPUTERNAME+'\'+$name),$secure) $secret=$null - $account=New-LocalUser -Name $name -Password $secure -AccountNeverExpires -Description 'R339 disposable hosted VM cross-account ACL witness' + $account=New-LocalUser -Name $name -Password $secure -AccountNeverExpires -Description 'R339 disposable Windows ACL test' $accountCreated=$true $actorSid=[string]$account.SID.Value if($actorSid -cnotmatch '^S-1-5-21-' -or $actorSid -ceq $parentSid){throw 'R339_SECOND_WINDOWS_ACCOUNT_NOT_DISTINCT'} From 78f60a99c6bf0c5ba63848245196756d521b765d Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 10:35:49 +0330 Subject: [PATCH 103/135] test(r339): reconcile nullable second-account exit with validated SID receipt (F04) --- ...-ProtectedProgramFiles-TwoUsers-Native.ps1 | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 index a2c486df..a4d5da34 100644 --- a/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 +++ b/build/Test-R339-ProtectedProgramFiles-TwoUsers-Native.ps1 @@ -52,7 +52,7 @@ $sourceActor=Join-Path $PSScriptRoot 'Test-R339-SecondAccount-Actor.ps1' $ownerCreated=$false;$sharedCreated=$false;$appCreated=$false;$accountCreated=$false $actorSid='';$child=$null;$childPid=0;$actorRead=$false;$rootRemoved=$false $accountRemoved=$false;$sharedRemoved=$false;$appRemoved=$false -$denials=0;$concurrentChecks=0;$totalChecks=0;$failure='';$phase='PRECONDITION' +$denials=0;$concurrentChecks=0;$totalChecks=0;$failure='';$phase='PRECONDITION';$actorReceiptSha="NOT_ACCEPTED";$exitProvided=$false;$observedExit=$null $checks=[Collections.Generic.List[string]]::new() $initial=@{Broker="NOT_STARTED";Supervisor="NOT_STARTED";Owner="NOT_STARTED";Outside="NOT_STARTED"};$outsideCanary='' function Hash([string]$path){return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash} @@ -216,7 +216,16 @@ try{ Start-Sleep -Milliseconds 30 } if(!$child.WaitForExit(25000)){throw 'R339_SECOND_USER_ATTACK_PROCESS_TIMEOUT'} - if($child.ExitCode -ne 0){throw ('R339_SECOND_USER_RETURNED_NONZERO_'+$child.ExitCode)} + # Native Windows Start-Process -Credential/-LoadUserProfile can yield a + # Process wrapper with a null ExitCode even after WaitForExit succeeds. + # A nonzero provided ExitCode is fatal. Missing code requires successful + # WaitForExit AND independently verified exact-SID typed guest receipt, + # 420 actual operation refusals and unchanged protected binary hashes. + $observedExit=$child.ExitCode + $exitProvided=($null -ne $observedExit) + if($exitProvided -and [int]$observedExit -ne 0){ + throw ('R339_SECOND_USER_RETURNED_NONZERO_'+$observedExit) + } if($concurrentChecks -lt 1){throw 'R339_NO_CONCURRENT_OWNER_GUARD_WITNESS'} Check 'ADMIN_REAL_COMPILED_SUPERVISOR_TRUST_READS_DURING_ACTUAL_OTHER_USER_ATTACK' $phase='VERIFY_OTHER_USER_TYPED_RECEIPT_AND_BOUNDARIES' @@ -235,6 +244,7 @@ try{ throw ('R339_DIFFERENT_TOKEN_RECEIPT_NEGATIVE_FAIL '+($guest|ConvertTo-Json -Depth 3 -Compress)) } $actorRead=$true + $actorReceiptSha=Hash $childReceipt $denials=420 if((Hash $exe) -cne $initial.Broker -or (Hash $supervisor) -cne $initial.Supervisor -or (Hash $marker) -cne $initial.Owner -or @@ -294,12 +304,16 @@ try{ ActorAAlreadyAdmin=$true;ActorBSID=$actorSid;ActorASID=$parentSid RealDistinctSecondNonAdminAccount=$actorRead;DeniedMutationCount=$denials;AttemptedOperationKinds=7 ActualProductionTrustGuardConcurrentChecks=$concurrentChecks + OtherAccountNativeProcessExitCodeProvided=$exitProvided + OtherAccountNativeProcessExitCode=$observedExit + OtherAccountReceiptSha256=$actorReceiptSha ProtectedBrokerSHA256=$initial.Broker;ProtectedSupervisorSHA256=$initial.Supervisor WindowsProtectedProgramFilesFixtureWasCreated=$ownerCreated PreExistingBuildOnlyLocalAppRootRetained=(!$appCreated -and [IO.Directory]::Exists($app)) WindowsProtectedProgramFilesFixtureRemoved=$rootRemoved DisposableSecondAccountRemoved=$accountRemoved;DisposableProgramDataRemoved=$sharedRemoved - DisposableProfileAppRootRemoved=$appRemoved + DisposableProfileAppRootCreatedByTest=$appCreated + DisposableProfileAppRootRemovedIfCreated=($appCreated -and $appRemoved) TrueInteractiveUacConsentTested=$false OriginalElevatedSensorHelperStartedOrKilled=$false OriginalOwnerComputerTouched=$false;OriginalReleaseOrDriversModified=$false From 0239013069df4d13e3c62137d5e53347e8ec72cb Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 11:00:41 +0330 Subject: [PATCH 104/135] test(security): execute original PE-embedded RunAs helper on disposable Windows (R340) --- ...it-r340-real-original-helper-uninstall.yml | 87 +++++ ...0-OriginalHelperActualUninstall-Native.ps1 | 317 ++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 .github/workflows/audit-r340-real-original-helper-uninstall.yml create mode 100644 build/Test-R340-OriginalHelperActualUninstall-Native.ps1 diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml new file mode 100644 index 00000000..4e79df20 --- /dev/null +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -0,0 +1,87 @@ +name: R340 full original embedded sensor Helper uninstall (hosted Windows only) +on: + push: + branches: [audit/r340-real-helper-uninstall-death] + paths: + - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 + - .github/workflows/audit-r340-real-original-helper-uninstall.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + original-helper-uninstall: + name: R340 actual PE-embedded Helper ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact revision without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse any non-disposable host, prior product, task or wrong ref + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r340-real-helper-uninstall-death' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R340_REFUSE_WRONG_GUEST_OR_WRONG_SOURCE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe' + if((Test-Path -LiteralPath $root) -or (Test-Path -LiteralPath $main) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R340_ALREADY_INSTALLED_PRODUCT_OR_SENSOR_TASK_GUEST_REFUSED' + } + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R340_NOT_ALREADY_ADMIN_NEVER_DISABLE_UAC' + } + 'R340_DISPOSABLE_ADMIN_TOKEN_AND_NO_PRODUCT_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build exact reviewed product including production PE-embedded Helper + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R340_PRODUCT_EXACT_SETUP_NOT_COMPILED' + } + 'R340_ALL_FOUR_FRAMEWORK_PRODUCT_COMPONENTS_BUILD_SUCCESS' + - name: Launch exact unmodified RunAs production Helper on owned disabled test task + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R340-OriginalHelperActualUninstall-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){ + throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' + } + - name: Retain typed exact original Helper execution proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R340-${{ matrix.host }}-${{ github.run_id }} + path: ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 new file mode 100644 index 00000000..ebef225a --- /dev/null +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -0,0 +1,317 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R340 does NOT exercise real interactive UAC or install any driver. +# Run only on fresh hosted disposable Windows (the hosted runner already owns +# an administrator token), never on the maintainer desktop or existing sensor. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r340-real-helper-uninstall-death' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + ![string]::Equals((git -C $SourceRoot rev-parse HEAD).Trim(),$env:GITHUB_SHA,[StringComparison]::Ordinal)){ + throw 'R340_REFUSE_NONDISPOSABLE_OR_NONPINNED_WINDOWS_HOST' +} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($identity) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R340_HOSTED_ALREADY_ADMIN_TOKEN_REQUIRED_NO_UAC_BYPASS' +} +$sid=[string]$identity.User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R340_NONINTERACTIVE_TOKEN_SID_REFUSED'} +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$brokerFile=Join-Path $SourceRoot 'build\_out\Broker\TaskbarMonitorSensorBroker.exe' +$supervisorFile=Join-Path $SourceRoot 'build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe' +$helperSource=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$program=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$profile=[Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) +$local=[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) +$temp=Join-Path $profile 'AppData\Local\Temp' +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$logs=Join-Path $app 'Logs' +$log=Join-Path $logs 'sensor_install.log' +$resultPath=Join-Path $logs 'sensor_install_result.json' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$broker=Join-Path $protected 'SensorBroker' +$task='TaskbarMonitorEnhanced Sensor Broker' +$owner=Join-Path $broker '.tbme-owner.sid' +$mainOwner=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$journal=Join-Path $protected '.tbme-sensor-rollback' +$rid=[guid]::NewGuid().ToString('N') +$payload=Join-Path $temp ('tbme_uninstall_'+$rid) +$receiptsRoot=Join-Path $env:RUNNER_TEMP ('R340_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptsRoot 'R340_REAL_PROTECTED_UNINSTALL_HELPER.json' +$outside=Join-Path $receiptsRoot 'OUTSIDE_CANARY.txt' +$checks=[Collections.Generic.List[string]]::new() +$createdRoot=$false;$createdTask=$false;$createdPayload=$false;$brokerRemoved=$false;$ownerHash='' +$createdApp=$false;$createdLogs=$false +$helperExit=$null;$helperStatus='';$failure='';$phase='PRECONDITION' +$outsideHash='';$beforeB='';$beforeS='';$exportedXmlHash='' +$taskRemoved=$false;$protectedRemoved=$false;$payloadRemoved=$false +$logsRemoved=$false;$appRemoved=$false;$childPid=0 +$nativeRunas=$false;$usesRealUnmodifiedResource=$false +function Hash([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} +function Check([string]$check){$script:checks.Add($check);Write-Output ('PASS '+$check)} +function TaskExists(){ + return (@(Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction SilentlyContinue| + Where-Object{$null -ne $_}).Count -ne 0) +} +function NoReparse([string]$file){ + if(([IO.File]::GetAttributes($file) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw ('R340_REPARSE_IN_OWNED_FIXTURE '+[IO.Path]::GetFileName($file)) + } +} +function RemoveOwnedProtected(){ + if(!$createdRoot){return !$([IO.Directory]::Exists($protected))} + if(![IO.Directory]::Exists($protected)){return $true} + NoReparse $protected + $members=@([IO.Directory]::GetFileSystemEntries($protected)) + if($members.Count -eq 0){ + [IO.Directory]::Delete($protected,$false) + return !$([IO.Directory]::Exists($protected)) + } + if($members.Count -ne 1 -or $members[0] -cne $broker){ + throw 'R340_UNKNOWN_PROGRAMFILES_CHILD_NO_CLEANUP' + } + NoReparse $broker + $items=@([IO.Directory]::GetFileSystemEntries($broker)) + $expected=@((Join-Path $broker 'TaskbarMonitorSensorBroker.exe'), + (Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe'),$owner) + if(@($items|Where-Object{$_ -notin $expected}).Count){ + throw 'R340_FOREIGN_MEMBER_PRESERVE_PROTECTED' + } + foreach($file in $items){ + NoReparse $file + $h=Hash $file + if($file -ceq $owner){if($h -cne $script:ownerHash){throw 'R340_MARKER_NOT_OURS'}} + elseif($file -like '*SensorBroker.exe'){if($h -cne $beforeB){throw 'R340_BROKER_NOT_ORIGINAL'}} + elseif($file -like '*SensorSupervisor.exe'){if($h -cne $beforeS){throw 'R340_SUPERVISOR_NOT_ORIGINAL'}} + } + foreach($file in $items){[IO.File]::Delete($file)} + [IO.Directory]::Delete($broker,$false) + [IO.Directory]::Delete($protected,$false) + return !$([IO.Directory]::Exists($protected)) +} +try{ + $phase='VERIFY_FRESH_GUEST_AND_SOURCE' + foreach($f in @($setup,$brokerFile,$supervisorFile,$helperSource)){if(![IO.File]::Exists($f)){throw 'R340_PRIVATE_SOURCE_FILE_MISSING'}} + if([IO.Directory]::Exists($protected) -or [IO.Directory]::Exists($payload) -or + [IO.Directory]::Exists($receiptsRoot) -or [IO.File]::Exists($mainOwner) -or + [IO.File]::Exists($log) -or [IO.File]::Exists($resultPath) -or + (TaskExists) -or @(Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker,TaskbarMonitorEnhanced -ErrorAction SilentlyContinue).Count){ + throw 'R340_PREEXISTING_REAL_PRODUCT_OR_OWNED_FIXTURE_COLLISION' + } + $preexistingApp=[IO.Directory]::Exists($app) + $preexistingLog=[IO.Directory]::Exists($logs) + [void][IO.Directory]::CreateDirectory($receiptsRoot) + [IO.File]::WriteAllText($outside,'R340_UNRELATED_CANARY',[Text.UTF8Encoding]::new($false)) + $outsideHash=Hash $outside + Check 'FRESH_GITHUB_HOSTED_ADMIN_ORIGINAL_PRODUCT_TASK_AND_JOURNAL_ABSENT' + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired')){ + throw 'R340_PENDING_REAL_PROTECTED_SENSOR_JOURNAL_PREEXISTS' + } + $asm=[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($setup)) + $stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R340_SETUP_EMBEDDED_UNMODIFIED_HELPER_MISSING'} + $reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($helperSource)))){ + throw 'R340_UNMODIFIED_HELPER_RESOURCE_MISMATCH' + } + $usesRealUnmodifiedResource=$true + Check 'PRIVATE_EXACT_COMPILED_SETUP_EMBEDS_REAL_UNMODIFIED_HELPER' + $phase='CREATE_BOUNDED_DISPOSABLE_PROTECTED_FIXTURE' + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($s in @($admins,$system,$users)){ + $rights=if($s -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $s,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($protected,$acl) + $createdRoot=$true + [void][IO.Directory]::CreateDirectory($broker) + $protectedB=Join-Path $broker 'TaskbarMonitorSensorBroker.exe' + $protectedS=Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe' + [IO.File]::Copy($brokerFile,$protectedB) + [IO.File]::Copy($supervisorFile,$protectedS) + [IO.File]::WriteAllText($owner,$sid,[Text.Encoding]::ASCII) + $ownerHash=Hash $owner + $beforeB=Hash $protectedB;$beforeS=Hash $protectedS + if($beforeB -cne (Hash $brokerFile) -or $beforeS -cne (Hash $supervisorFile)){ + throw 'R340_PROTECTED_BINARIES_NOT_EXACT_PRIVATE_SETUP' + } + $programAcl=Get-Acl -LiteralPath $broker + if([string]$programAcl.Owner -notmatch '(?i)Administrators'){ + throw 'R340_PROTECTED_ACL_OWNER_NOT_ADMIN' + } + Check 'REAL_PROGRAMFILES_FIXTURE_ADMIN_OWNED_EXACT_BINARIES_AND_SID_MARKER' + $phase='CREATE_OWNER_DISABLED_NEVER_EXECUTED_TASK' + $argsText='--broker "'+$protectedB+'" --output "'+(Join-Path $app 'cpu_temp_broker.json')+'"' + $xml=@" + +false$sid +$sidInteractiveTokenHighestAvailable +falsefalse +$protectedS$argsText + +"@ + Register-ScheduledTask -TaskPath '\' -TaskName $task -Xml $xml -ErrorAction Stop|Out-Null + $createdTask=$true + $readTask=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop + if($readTask.Settings.Enabled -ne $false -or + $readTask.Principal.UserId -cne $sid -or + $readTask.Actions[0].Execute -cne $protectedS){ + throw 'R340_DISABLED_TASK_NOT_EXACT_REVIEW_OWNER' + } + $exportedXmlHash=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string](Export-ScheduledTask -TaskPath '\' -TaskName $task))) + Check 'EXACT_GLOBAL_SENSOR_TASK_REGISTERED_DISABLED_WITHOUT_EXECUTION' + $phase='PREPARE_CANONICAL_USER_TEMP_AND_LOGS' + if(![IO.Directory]::Exists($app)){[void][IO.Directory]::CreateDirectory($app);$createdApp=$true} + if(![IO.Directory]::Exists($logs)){[void][IO.Directory]::CreateDirectory($logs);$createdLogs=$true} + [void][IO.Directory]::CreateDirectory($payload);$createdPayload=$true + Check 'EXACT_USER_PROFILE_LOCALAPP_TEMP_PAYLOAD_AND_LOGS_PREPARED' + $phase='LAUNCH_UNMODIFIED_SETUP_PE_RUNAS_HELPER' + $t=$asm.GetType('SetupProgram',$true) + $method=$t.GetMethod('BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'Static,NonPublic') + if(!$method){throw 'R340_SETUP_PE_RUNAS_HELPER_FACTORY_NOT_PRESENT'} + $arg=New-Object 'object[]' 4 + $arg[0]='Uninstall';$arg[1]=$payload;$arg[2]=$app;$arg[3]=$sid + $psi=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$arg) + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + if(!$psi.UseShellExecute -or $psi.Verb -cne 'runas' -or + ![string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments -notmatch '(?i)-EncodedCommand|-Command "' -or + $psi.WorkingDirectory -cne [IO.Path]::GetDirectoryName($native)){ + throw 'R340_PE_RUNAS_EXECUTABLE_OR_TRANSPORT_NOT_VERIFIED' + } + $nativeRunas=$true + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R340_ACTUAL_ORIGINAL_RUNAS_HELPER_START_RETURNED_NULL'} + try{ + $childPid=$child.Id + if(!$child.WaitForExit(55000)){ + if(!$child.HasExited){$child.Kill();[void]$child.WaitForExit(6000)} + throw 'R340_ACTUAL_HELPER_PROCESS_TIMEOUT' + } + $helperExit=$child.ExitCode + }finally{$child.Dispose()} + if([int]$helperExit -ne 0){throw ('R340_ORIGINAL_HELPER_NONZERO_EXIT_'+$helperExit)} + Check 'ACTUAL_COMPRESSED_PE_EMBEDDED_HELPER_PROCESS_RUNAS_WITHOUT_DRIVER_EXITS_ZERO' + $phase='VERIFY_REAL_PROTECTED_UNINSTALL_AND_WITNESS' + $taskRemoved=!(TaskExists) + $brokerRemoved=![IO.Directory]::Exists($broker) + if(!$taskRemoved -or !$brokerRemoved){ + throw 'R340_BROKER_ROOT_OR_EXACT_GLOBAL_TASK_NOT_UNINSTALLED' + } + Check 'ACTUAL_HELPER_REMOVES_OWNED_GLOBAL_TASK_AND_PROTECTED_BROKER_SUBFOLDER' + if(![IO.File]::Exists($resultPath) -or ![IO.File]::Exists($log)){throw 'R340_REAL_HELPER_USER_STATUS_OR_LOG_NOT_WRITTEN'} + $result=Get-Content -LiteralPath $resultPath -Raw|ConvertFrom-Json + $helperStatus=[string]$result.Status + $body=[IO.File]::ReadAllText($log) + if($helperStatus -cne 'UNINSTALLED' -or + $result.RollbackPerformed -ne $false -or + $body -notmatch 'BEGIN mode=Uninstall' -or + $body -notmatch 'UNINSTALL protected sensor layer removed'){ + throw ('R340_ORIGINAL_HELPER_UNINSTALL_RESULT_BAD_'+$helperStatus) + } + Check 'REAL_UNMODIFIED_HELPER_WRITES_EXPECTED_UNINSTALL_RESULT_AND_NATIVE_LOG_LEASE' + if((Hash $outside) -cne $outsideHash){throw 'R340_EXTERNAL_CANARY_CHANGED'} + Check 'OUTSIDE_CANARY_UNCHANGED_AND_NO_PAWNIO_INSTALL_OR_SENSOR_PROCESS_START' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Only the caller-owned fixtures are candidates for cleanup. Never force + # delete unknown shared task/root contents or preexisting user AppData files. + if($createdTask -and (TaskExists)){ + try{ + $current=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop + if($current.Settings.Enabled -eq $false -and + $current.Principal.UserId -ceq $sid -and + [IO.Path]::GetFullPath($current.Actions[0].Execute) -ceq [IO.Path]::GetFullPath((Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe'))){ + Unregister-ScheduledTask -TaskPath '\' -TaskName $task -Confirm:$false -ErrorAction Stop + } + }catch{$failure+=' TASK_CLEANUP_FAILED='+$_.Exception.Message} + } + $taskRemoved=!(TaskExists) + if($createdRoot){ + try{$protectedRemoved=RemoveOwnedProtected} + catch{$failure+=' PROTECTED_CLEANUP_REFUSED='+$_.Exception.Message} + } + if($createdPayload){ + try{ + if([IO.Directory]::Exists($payload) -and @([IO.Directory]::GetFileSystemEntries($payload)).Count -eq 0){ + [IO.Directory]::Delete($payload,$false) + } + $payloadRemoved=(![IO.Directory]::Exists($payload)) + }catch{$failure+=' PAYLOAD_CLEANUP_REFUSED='+$_.Exception.Message} + } + if([IO.Directory]::Exists($logs)){ + try{ + foreach($f in @($log,$resultPath)){ + if([IO.File]::Exists($f)){NoReparse $f;[IO.File]::Delete($f)} + } + if($createdLogs -and @([IO.Directory]::GetFileSystemEntries($logs)).Count -eq 0){ + [IO.Directory]::Delete($logs,$false) + } + $logsRemoved=(!$createdLogs -or ![IO.Directory]::Exists($logs)) + }catch{$failure+=' LOG_CLEANUP_REFUSED='+$_.Exception.Message} + }else{$logsRemoved=$true} + if($createdApp){ + try{ + if([IO.Directory]::Exists($app) -and @([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + $appRemoved=(![IO.Directory]::Exists($app)) + }catch{$failure+=' APP_CLEANUP_REFUSED='+$_.Exception.Message} + }else{$appRemoved=$true} + if($createdTask -and !$taskRemoved){$failure+=' GLOBAL_TASK_RESIDUE'} + if($createdRoot -and !$protectedRemoved){$failure+=' GLOBAL_PROTECTED_ROOT_RESIDUE'} + if($createdPayload -and !$payloadRemoved){$failure+=' TEMP_PAYLOAD_RESIDUE'} + if(!$logsRemoved -or !$appRemoved){$failure+=' LOCALAPP_OWNED_RESIDUE'} + $status=if(!$failure -and $checks.Count -eq 9 -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ + 'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER' + }else{'FAIL_R340_NOT_ACCEPTED'} + $out=[ordered]@{ + Schema=1;Revision='R340';Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + SetupSHA256=if([IO.File]::Exists($setup)){Hash $setup}else{'MISSING'} + CandidateBrokerSHA256=$beforeB;CandidateSupervisorSHA256=$beforeS + RealEmbeddedHelperBytesMatchSource=$usesRealUnmodifiedResource + RealProductionRunasFactory=$nativeRunas + RunasHost='WindowsPowerShell5.1SystemDirectory' + AlreadyAdminRunner=$true;TrueInteractiveUacDialogObserved=$false + OriginalHelperProcessStarted=($childPid -gt 0);OriginalHelperPid=$childPid + OriginalHelperExitCode=$helperExit;OriginalHelperResultStatus=$helperStatus + ProtectedProgramFilesCreated=$createdRoot;ProtectedProgramFilesRemoved=$protectedRemoved + OriginalHelperBrokerSubfolderRemoved=$brokerRemoved + GlobalDisabledTaskCreated=$createdTask;GlobalDisabledTaskRemoved=$taskRemoved + ScopedUserPayloadRemoved=$payloadRemoved + ScopedTestLogsRemoved=$logsRemoved + UserProfileExistingAppPreserved=!$createdApp + ActualHelperDeliberateKillTested=$false + AnyDriverInstallationTriggered=$false + OriginalOwnerMachineTouched=$false + Checks=@($checks);Failure=$failure + } + if([IO.Directory]::Exists($receiptsRoot)){ + [IO.File]::WriteAllText($receipt,($out|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + Write-Output ('R340_NATIVE_RECEIPT='+$receipt) + } + Write-Output ('R340_STATUS='+$status+' CHECKS='+$checks.Count+' TASK_REMOVED='+$taskRemoved+' ROOT_REMOVED='+$protectedRemoved) +} +if($failure){throw ('R340_REAL_HELPER_UNINSTALL_NOT_ACCEPTED '+$failure)} From 20cf5fe25e9e1bfa7a95d19672c192a7edc16c59 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 11:06:30 +0330 Subject: [PATCH 105/135] test(r340): normalize native Task Scheduler principal SID on readback (F01) --- ...t-R340-OriginalHelperActualUninstall-Native.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index ebef225a..1ae68bde 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -64,6 +64,12 @@ function TaskExists(){ return (@(Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction SilentlyContinue| Where-Object{$null -ne $_}).Count -ne 0) } +function ResolveTaskSid([string]$principal){ + if([string]::IsNullOrWhiteSpace($principal)){throw 'R340_TASK_PRINCIPAL_EMPTY'} + if($principal -match '^S-1-5-(?:\d+-)+\d+$'){return $principal} + return [string]([Security.Principal.NTAccount]::new($principal)).Translate( + [Security.Principal.SecurityIdentifier]).Value +} function NoReparse([string]$file){ if(([IO.File]::GetAttributes($file) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ throw ('R340_REPARSE_IN_OWNED_FIXTURE '+[IO.Path]::GetFileName($file)) @@ -173,8 +179,8 @@ try{ $createdTask=$true $readTask=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop if($readTask.Settings.Enabled -ne $false -or - $readTask.Principal.UserId -cne $sid -or - $readTask.Actions[0].Execute -cne $protectedS){ + (ResolveTaskSid ([string]$readTask.Principal.UserId)) -cne $sid -or + -not [string]::Equals([string]$readTask.Actions[0].Execute,$protectedS,[StringComparison]::OrdinalIgnoreCase)){ throw 'R340_DISABLED_TASK_NOT_EXACT_REVIEW_OWNER' } $exportedXmlHash=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string](Export-ScheduledTask -TaskPath '\' -TaskName $task))) @@ -241,8 +247,8 @@ try{ try{ $current=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop if($current.Settings.Enabled -eq $false -and - $current.Principal.UserId -ceq $sid -and - [IO.Path]::GetFullPath($current.Actions[0].Execute) -ceq [IO.Path]::GetFullPath((Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe'))){ + (ResolveTaskSid ([string]$current.Principal.UserId)) -ceq $sid -and + [string]::Equals([IO.Path]::GetFullPath([string]$current.Actions[0].Execute),[IO.Path]::GetFullPath((Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe')),[StringComparison]::OrdinalIgnoreCase)){ Unregister-ScheduledTask -TaskPath '\' -TaskName $task -Confirm:$false -ErrorAction Stop } }catch{$failure+=' TASK_CLEANUP_FAILED='+$_.Exception.Message} From f2e3f31761005cef7b54404b332fb40b2ceaee49 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 11:09:11 +0330 Subject: [PATCH 106/135] test(r340): unwrap native PS5.1 reflection arguments (F02) --- build/Test-R340-OriginalHelperActualUninstall-Native.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index 1ae68bde..0e2c22b9 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -194,8 +194,12 @@ try{ $t=$asm.GetType('SetupProgram',$true) $method=$t.GetMethod('BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'Static,NonPublic') if(!$method){throw 'R340_SETUP_PE_RUNAS_HELPER_FACTORY_NOT_PRESENT'} + # R340-F02: unwrap PSObject arguments for private .NET Framework reflection. + $actualArgs=[string[]]@('Uninstall',$payload,$app,$sid) $arg=New-Object 'object[]' 4 - $arg[0]='Uninstall';$arg[1]=$payload;$arg[2]=$app;$arg[3]=$sid + for($i=0;$i -lt $actualArgs.Length;$i++){ + $arg[$i]=$actualArgs[$i].PSObject.BaseObject + } $psi=[Diagnostics.ProcessStartInfo]$method.Invoke($null,$arg) $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' if(!$psi.UseShellExecute -or $psi.Verb -cne 'runas' -or From b2f675f79acdb663827cc94b5ddf13f46492bcf0 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 11:18:39 +0330 Subject: [PATCH 107/135] test(r341): kill exact original Helper after BEGIN and verify pre-task retry --- ...it-r340-real-original-helper-uninstall.yml | 12 ++-- ...0-OriginalHelperActualUninstall-Native.ps1 | 66 +++++++++++++++++-- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index 4e79df20..16846bad 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -1,4 +1,4 @@ -name: R340 full original embedded sensor Helper uninstall (hosted Windows only) +name: R340 normal and R341 terminated original Helper (hosted Windows) on: push: branches: [audit/r340-real-helper-uninstall-death] @@ -11,12 +11,14 @@ permissions: actions: read jobs: original-helper-uninstall: - name: R340 actual PE-embedded Helper ${{ matrix.host }} + name: R340-R341 ${{ matrix.mode }} exact PE Helper ${{ matrix.host }} runs-on: ${{ matrix.os }} timeout-minutes: 35 strategy: fail-fast: false matrix: + mode: [nominal, killed] + host: [A, B] include: - host: A os: windows-2025 @@ -73,7 +75,9 @@ jobs: $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' $script=(Resolve-Path build/Test-R340-OriginalHelperActualUninstall-Native.ps1).Path $source=(Resolve-Path .).Path - & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + $nativeArgs=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$script,'-HostLabel','${{ matrix.host }}','-SourceRoot',$source) + if('${{ matrix.mode }}' -ceq 'killed'){$nativeArgs+='-KillBeforeMutation'} + & $native @nativeArgs if($LASTEXITCODE -ne 0){ throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' } @@ -81,7 +85,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: R340-${{ matrix.host }}-${{ github.run_id }} + name: R340-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} path: ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json retention-days: 7 if-no-files-found: warn diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index 0e2c22b9..d43070ee 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -1,7 +1,8 @@ [CmdletBinding()] param( [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, - [Parameter(Mandatory=$true)][string]$SourceRoot + [Parameter(Mandatory=$true)][string]$SourceRoot, + [switch]$KillBeforeMutation ) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' @@ -58,6 +59,7 @@ $outsideHash='';$beforeB='';$beforeS='';$exportedXmlHash='' $taskRemoved=$false;$protectedRemoved=$false;$payloadRemoved=$false $logsRemoved=$false;$appRemoved=$false;$childPid=0 $nativeRunas=$false;$usesRealUnmodifiedResource=$false +$deliberateKill=$false;$killedFirstPid=0;$originalTaskIntactAtKill=$false function Hash([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} function Check([string]$check){$script:checks.Add($check);Write-Output ('PASS '+$check)} function TaskExists(){ @@ -211,7 +213,55 @@ try{ } $nativeRunas=$true $child=[Diagnostics.Process]::Start($psi) - if(!$child){throw 'R340_ACTUAL_ORIGINAL_RUNAS_HELPER_START_RETURNED_NULL'} + if(!$child){throw 'R341_ORIGINAL_EMBEDDED_HELPER_START_RETURNED_NULL'} + if($KillBeforeMutation){ + $phase='KILL_EXACT_ORIGINAL_HELPER_AFTER_BEGIN_PRE_SHARED_MUTATION' + $first=$child + $killedFirstPid=$first.Id + try{ + $timer=[Diagnostics.Stopwatch]::StartNew() + $beginObserved=$false + while(!$first.HasExited -and $timer.ElapsedMilliseconds -lt 30000){ + if([IO.File]::Exists($log)){ + try{ + if([IO.File]::ReadAllText($log).Contains('BEGIN mode=Uninstall')){ + $beginObserved=$true + if(!$first.HasExited){ + $first.Kill() + if(!$first.WaitForExit(10000)){ + throw 'R341_EXACT_HELPER_KILL_DID_NOT_COMPLETE' + } + $deliberateKill=$true + } + break + } + }catch [IO.IOException]{ + # Retry only transient lease contention, never swallow mutation. + } + } + Start-Sleep -Milliseconds 5 + } + if(!$beginObserved -or !$deliberateKill){ + throw 'R341_UNMODIFIED_HELPER_EXITED_BEFORE_VERIFIED_KILL' + } + if(!(TaskExists) -or + ![IO.File]::Exists($protectedB) -or ![IO.File]::Exists($protectedS) -or + ![IO.File]::Exists($owner) -or + (Hash $protectedB) -cne $beforeB -or + (Hash $protectedS) -cne $beforeS -or + (Hash $owner) -cne $ownerHash){ + throw 'R341_KILL_WAS_AFTER_SHARED_TASK_OR_PROTECTED_FILES_MUTATION' + } + $originalTaskIntactAtKill=$true + }finally{ + try{if(!$first.HasExited){$first.Kill();[void]$first.WaitForExit(8000)}}catch{} + $first.Dispose() + } + Check 'ACTUAL_UNMODIFIED_HELPER_KILLED_AFTER_BEGIN_BEFORE_PROTECTED_MUTATION' + $phase='RELAUNCH_IDENTICAL_ORIGINAL_HELPER_FOR_BOUNDED_RECOVERY' + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R341_SECOND_IDENTICAL_UNMODIFIED_HELPER_NOT_STARTED'} + } try{ $childPid=$child.Id if(!$child.WaitForExit(55000)){ @@ -293,11 +343,11 @@ try{ if($createdRoot -and !$protectedRemoved){$failure+=' GLOBAL_PROTECTED_ROOT_RESIDUE'} if($createdPayload -and !$payloadRemoved){$failure+=' TEMP_PAYLOAD_RESIDUE'} if(!$logsRemoved -or !$appRemoved){$failure+=' LOCALAPP_OWNED_RESIDUE'} - $status=if(!$failure -and $checks.Count -eq 9 -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ - 'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER' + $status=if(!$failure -and $checks.Count -eq (9+[int]$KillBeforeMutation) -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ + if($KillBeforeMutation){'PASS_KILLED_ORIGINAL_EMBEDDED_HELPER_BEFORE_SHARED_MUTATION_THEN_RECOVERED'}else{'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER'} }else{'FAIL_R340_NOT_ACCEPTED'} $out=[ordered]@{ - Schema=1;Revision='R340';Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Schema=1;Revision=$(if($KillBeforeMutation){'R341'}else{'R340'});Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA SetupSHA256=if([IO.File]::Exists($setup)){Hash $setup}else{'MISSING'} CandidateBrokerSHA256=$beforeB;CandidateSupervisorSHA256=$beforeS RealEmbeddedHelperBytesMatchSource=$usesRealUnmodifiedResource @@ -312,7 +362,11 @@ try{ ScopedUserPayloadRemoved=$payloadRemoved ScopedTestLogsRemoved=$logsRemoved UserProfileExistingAppPreserved=!$createdApp - ActualHelperDeliberateKillTested=$false + ActualHelperDeliberateKillTested=$deliberateKill + DeliberateKillRequested=[bool]$KillBeforeMutation + FirstKilledOriginalHelperPid=$killedFirstPid + OriginalTaskAndBrokerUnchangedAtKill=$originalTaskIntactAtKill + RecoveryUsesIdenticalOriginalRunasProcessInfo=[bool]($deliberateKill -and $nativeRunas) AnyDriverInstallationTriggered=$false OriginalOwnerMachineTouched=$false Checks=@($checks);Failure=$failure From b992c4e13770a5a08c281c5f433bb13edf45a9b3 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 11:21:50 +0330 Subject: [PATCH 108/135] test(r341): use explicit 9/10 typed check count instead of SwitchParameter cast --- build/Test-R340-OriginalHelperActualUninstall-Native.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index d43070ee..d78b20d2 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -343,7 +343,9 @@ try{ if($createdRoot -and !$protectedRemoved){$failure+=' GLOBAL_PROTECTED_ROOT_RESIDUE'} if($createdPayload -and !$payloadRemoved){$failure+=' TEMP_PAYLOAD_RESIDUE'} if(!$logsRemoved -or !$appRemoved){$failure+=' LOCALAPP_OWNED_RESIDUE'} - $status=if(!$failure -and $checks.Count -eq (9+[int]$KillBeforeMutation) -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ + $neededChecks=9 + if($KillBeforeMutation){$neededChecks=10} + $status=if(!$failure -and $checks.Count -eq $neededChecks -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ if($KillBeforeMutation){'PASS_KILLED_ORIGINAL_EMBEDDED_HELPER_BEFORE_SHARED_MUTATION_THEN_RECOVERED'}else{'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER'} }else{'FAIL_R340_NOT_ACCEPTED'} $out=[ordered]@{ From 9148acd11bb554cbbce8334c5529b1c34b7f6efc Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 11:38:24 +0330 Subject: [PATCH 109/135] test(r342): witness original Helper death after actual protected task unregistration --- ...it-r340-real-original-helper-uninstall.yml | 7 +- ...0-OriginalHelperActualUninstall-Native.ps1 | 93 +++++++++++++++++-- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index 16846bad..6b593130 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -1,4 +1,4 @@ -name: R340 normal and R341 terminated original Helper (hosted Windows) +name: R340-R342 nominal early and posttask original Helper (hosted Windows) on: push: branches: [audit/r340-real-helper-uninstall-death] @@ -11,13 +11,13 @@ permissions: actions: read jobs: original-helper-uninstall: - name: R340-R341 ${{ matrix.mode }} exact PE Helper ${{ matrix.host }} + name: R340-R342 ${{ matrix.mode }} exact PE Helper ${{ matrix.host }} runs-on: ${{ matrix.os }} timeout-minutes: 35 strategy: fail-fast: false matrix: - mode: [nominal, killed] + mode: [nominal, killed, posttask] host: [A, B] include: - host: A @@ -77,6 +77,7 @@ jobs: $source=(Resolve-Path .).Path $nativeArgs=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$script,'-HostLabel','${{ matrix.host }}','-SourceRoot',$source) if('${{ matrix.mode }}' -ceq 'killed'){$nativeArgs+='-KillBeforeMutation'} + if('${{ matrix.mode }}' -ceq 'posttask'){$nativeArgs+='-KillAfterTaskRemoval'} & $native @nativeArgs if($LASTEXITCODE -ne 0){ throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index d78b20d2..b530407a 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -2,13 +2,15 @@ param( [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, [Parameter(Mandatory=$true)][string]$SourceRoot, - [switch]$KillBeforeMutation + [switch]$KillBeforeMutation, + [switch]$KillAfterTaskRemoval ) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' # R340 does NOT exercise real interactive UAC or install any driver. # Run only on fresh hosted disposable Windows (the hosted runner already owns # an administrator token), never on the maintainer desktop or existing sensor. +if($KillAfterTaskRemoval -and $KillBeforeMutation){throw 'R342_DEATH_CHECKPOINTS_MUTUALLY_EXCLUSIVE'} if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or @@ -60,6 +62,7 @@ $taskRemoved=$false;$protectedRemoved=$false;$payloadRemoved=$false $logsRemoved=$false;$appRemoved=$false;$childPid=0 $nativeRunas=$false;$usesRealUnmodifiedResource=$false $deliberateKill=$false;$killedFirstPid=0;$originalTaskIntactAtKill=$false +$deliberatePostTaskKill=$false;$taskRemovedAtKill=$false;$resultAbsentAtKill=$false;$postKilledPid=0 function Hash([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} function Check([string]$check){$script:checks.Add($check);Write-Output ('PASS '+$check)} function TaskExists(){ @@ -212,8 +215,78 @@ try{ throw 'R340_PE_RUNAS_EXECUTABLE_OR_TRANSPORT_NOT_VERIFIED' } $nativeRunas=$true + # R342: bounded, in-memory high-priority witness of exact owned Task + # Scheduler's physical entry removal, *not* a process-name-based kill. + if($KillAfterTaskRemoval){ + $taskFile=Join-Path $env:windir 'System32\Tasks\TaskbarMonitorEnhanced Sensor Broker' + if(![IO.File]::Exists($taskFile)){ + throw 'R342_DISABLED_GLOBAL_TASK_BACKING_FILE_NOT_PRESENT' + } + Add-Type -TypeDefinition @' +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +public sealed class R342TaskFileRemovalKill { + public volatile bool Deleted, Killed, ResultExistedAtKill; + public volatile string Error=""; + private Thread observer; + public void Start(Process child,string taskFile,string resultFile) { + if(child==null||!File.Exists(taskFile) || String.IsNullOrWhiteSpace(resultFile)) + throw new IOException("R342_WATCH_OWNER_TASK_NOT_PRESENT"); + observer=new Thread(()=>{ + Stopwatch sw=Stopwatch.StartNew(); + while(sw.ElapsedMilliseconds<25000){ + try{ + if(child.HasExited){Error="R342_ORIGINAL_EXIT_BEFORE_TASK_DELETE";return;} + if(!File.Exists(taskFile)){ + Deleted=true; + ResultExistedAtKill=File.Exists(resultFile); + if(ResultExistedAtKill){Error="R342_RESULT_ALREADY_EXISTS_BEFORE_KILL";return;} + child.Kill(); + Killed=child.WaitForExit(9000); + if(!Killed)Error="R342_KILL_NOT_COMPLETED"; + return; + } + }catch(Exception ex){Error=ex.GetType().Name+": "+ex.Message;return;} + Thread.SpinWait(256); + } + Error="R342_TASK_FILE_DELETE_NOT_OBSERVED"; + }); + observer.IsBackground=true; + observer.Priority=ThreadPriority.Highest; + observer.Start(); + } + public bool Wait(int timeoutMs){return observer!=null&&observer.Join(timeoutMs);} +} +'@ + } $child=[Diagnostics.Process]::Start($psi) if(!$child){throw 'R341_ORIGINAL_EMBEDDED_HELPER_START_RETURNED_NULL'} + if($KillAfterTaskRemoval){ + $phase='KILL_EXACT_ORIGINAL_HELPER_AFTER_SCHEDULER_TASK_MUTATION' + $first=$child;$postKilledPid=$first.Id + try{ + $killer=[R342TaskFileRemovalKill]::new() + $killer.Start($first,$taskFile,$resultPath) + if(!$killer.Wait(39000)){throw 'R342_BOUNDED_TASK_DELETE_WATCHER_TIMEOUT'} + if(!$killer.Deleted -or !$killer.Killed -or $killer.ResultExistedAtKill -or $killer.Error){ + throw ('R342_TASK_DELETE_KILL_NOT_ACCEPTABLE_'+$killer.Error) + } + if(TaskExists){throw 'R342_KILLED_BEFORE_REAL_TASK_UNREGISTER'} + if([IO.File]::Exists($resultPath)){throw 'R342_ORIGINAL_HELPER_RESULT_ALREADY_PUBLISHED'} + $deliberatePostTaskKill=$true + $taskRemovedAtKill=$true + $resultAbsentAtKill=$true + }finally{ + try{if(!$first.HasExited){$first.Kill();[void]$first.WaitForExit(8000)}}catch{} + $first.Dispose() + } + Check 'ACTUAL_ORIGINAL_HELPER_KILLED_AFTER_TASK_MUTATION_BEFORE_RESULT' + $phase='RETRY_SAME_PE_PRODUCTION_HELPER_AFTER_GLOBAL_TASK_MUTATION' + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R342_IDENTICAL_ORIGINAL_HELPER_RETRY_NOT_STARTED'} + } if($KillBeforeMutation){ $phase='KILL_EXACT_ORIGINAL_HELPER_AFTER_BEGIN_PRE_SHARED_MUTATION' $first=$child @@ -344,12 +417,14 @@ try{ if($createdPayload -and !$payloadRemoved){$failure+=' TEMP_PAYLOAD_RESIDUE'} if(!$logsRemoved -or !$appRemoved){$failure+=' LOCALAPP_OWNED_RESIDUE'} $neededChecks=9 - if($KillBeforeMutation){$neededChecks=10} + if($KillBeforeMutation -or $KillAfterTaskRemoval){$neededChecks=10} $status=if(!$failure -and $checks.Count -eq $neededChecks -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ - if($KillBeforeMutation){'PASS_KILLED_ORIGINAL_EMBEDDED_HELPER_BEFORE_SHARED_MUTATION_THEN_RECOVERED'}else{'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER'} + if($KillAfterTaskRemoval){'PASS_KILLED_ORIGINAL_HELPER_AFTER_TASK_UNREGISTER_BEFORE_RESULT_THEN_RETRIED'} + elseif($KillBeforeMutation){'PASS_KILLED_ORIGINAL_EMBEDDED_HELPER_BEFORE_SHARED_MUTATION_THEN_RECOVERED'} + else{'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER'} }else{'FAIL_R340_NOT_ACCEPTED'} $out=[ordered]@{ - Schema=1;Revision=$(if($KillBeforeMutation){'R341'}else{'R340'});Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Schema=1;Revision=$(if($KillAfterTaskRemoval){'R342'}elseif($KillBeforeMutation){'R341'}else{'R340'});Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA SetupSHA256=if([IO.File]::Exists($setup)){Hash $setup}else{'MISSING'} CandidateBrokerSHA256=$beforeB;CandidateSupervisorSHA256=$beforeS RealEmbeddedHelperBytesMatchSource=$usesRealUnmodifiedResource @@ -364,11 +439,15 @@ try{ ScopedUserPayloadRemoved=$payloadRemoved ScopedTestLogsRemoved=$logsRemoved UserProfileExistingAppPreserved=!$createdApp - ActualHelperDeliberateKillTested=$deliberateKill - DeliberateKillRequested=[bool]$KillBeforeMutation + ActualHelperDeliberateKillTested=[bool]($deliberateKill -or $deliberatePostTaskKill) + DeliberateKillRequested=[bool]($KillBeforeMutation -or $KillAfterTaskRemoval) FirstKilledOriginalHelperPid=$killedFirstPid OriginalTaskAndBrokerUnchangedAtKill=$originalTaskIntactAtKill - RecoveryUsesIdenticalOriginalRunasProcessInfo=[bool]($deliberateKill -and $nativeRunas) + RecoveryUsesIdenticalOriginalRunasProcessInfo=[bool](($deliberateKill -or $deliberatePostTaskKill) -and $nativeRunas) + ActualHelperKilledAfterProtectedTaskMutation=$deliberatePostTaskKill + OriginalTaskUnregisteredAtKill=$taskRemovedAtKill + ResultAbsentAtPostTaskKill=$resultAbsentAtKill + PostTaskKilledOriginalHelperPid=$postKilledPid AnyDriverInstallationTriggered=$false OriginalOwnerMachineTouched=$false Checks=@($checks);Failure=$failure From a6f742d986a1ad727895625e74c35a9e0c0c9380 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 12:00:59 +0330 Subject: [PATCH 110/135] test(r343): witness original Helper death after protected Broker executable deletion --- ...it-r340-real-original-helper-uninstall.yml | 7 +- ...0-OriginalHelperActualUninstall-Native.ps1 | 64 +++++++++++++++---- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index 6b593130..fa007a47 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -1,4 +1,4 @@ -name: R340-R342 nominal early and posttask original Helper (hosted Windows) +name: R340-R343 original Helper nominal, early, post-task, post-file (hosted Windows) on: push: branches: [audit/r340-real-helper-uninstall-death] @@ -11,13 +11,13 @@ permissions: actions: read jobs: original-helper-uninstall: - name: R340-R342 ${{ matrix.mode }} exact PE Helper ${{ matrix.host }} + name: R340-R343 ${{ matrix.mode }} exact PE Helper ${{ matrix.host }} runs-on: ${{ matrix.os }} timeout-minutes: 35 strategy: fail-fast: false matrix: - mode: [nominal, killed, posttask] + mode: [nominal, killed, posttask, postfile] host: [A, B] include: - host: A @@ -78,6 +78,7 @@ jobs: $nativeArgs=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$script,'-HostLabel','${{ matrix.host }}','-SourceRoot',$source) if('${{ matrix.mode }}' -ceq 'killed'){$nativeArgs+='-KillBeforeMutation'} if('${{ matrix.mode }}' -ceq 'posttask'){$nativeArgs+='-KillAfterTaskRemoval'} + if('${{ matrix.mode }}' -ceq 'postfile'){$nativeArgs+='-KillAfterBrokerRemoval'} & $native @nativeArgs if($LASTEXITCODE -ne 0){ throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index b530407a..7abc24a3 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -3,14 +3,19 @@ param( [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, [Parameter(Mandatory=$true)][string]$SourceRoot, [switch]$KillBeforeMutation, - [switch]$KillAfterTaskRemoval + [switch]$KillAfterTaskRemoval, + [switch]$KillAfterBrokerRemoval ) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' # R340 does NOT exercise real interactive UAC or install any driver. # Run only on fresh hosted disposable Windows (the hosted runner already owns # an administrator token), never on the maintainer desktop or existing sensor. -if($KillAfterTaskRemoval -and $KillBeforeMutation){throw 'R342_DEATH_CHECKPOINTS_MUTUALLY_EXCLUSIVE'} +if(($KillBeforeMutation -and $KillAfterTaskRemoval) -or + ($KillBeforeMutation -and $KillAfterBrokerRemoval) -or + ($KillAfterTaskRemoval -and $KillAfterBrokerRemoval)){ + throw 'R343_DEATH_CHECKPOINTS_MUTUALLY_EXCLUSIVE' +} if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or @@ -63,6 +68,7 @@ $logsRemoved=$false;$appRemoved=$false;$childPid=0 $nativeRunas=$false;$usesRealUnmodifiedResource=$false $deliberateKill=$false;$killedFirstPid=0;$originalTaskIntactAtKill=$false $deliberatePostTaskKill=$false;$taskRemovedAtKill=$false;$resultAbsentAtKill=$false;$postKilledPid=0 +$deliberatePostFileKill=$false;$fileRemovedAtKill=$false;$taskGoneAtFileKill=$false;$protectedRootPresentAtFileKill=$null;$ownerMarkerPresentAtFileKill=$null;$fileKilledPid=0 function Hash([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} function Check([string]$check){$script:checks.Add($check);Write-Output ('PASS '+$check)} function TaskExists(){ @@ -217,10 +223,12 @@ try{ $nativeRunas=$true # R342: bounded, in-memory high-priority witness of exact owned Task # Scheduler's physical entry removal, *not* a process-name-based kill. - if($KillAfterTaskRemoval){ - $taskFile=Join-Path $env:windir 'System32\Tasks\TaskbarMonitorEnhanced Sensor Broker' + if($KillAfterTaskRemoval -or $KillAfterBrokerRemoval){ + $taskFile=if($KillAfterTaskRemoval){ + Join-Path $env:windir 'System32\Tasks\TaskbarMonitorEnhanced Sensor Broker' + }else{$protectedB} if(![IO.File]::Exists($taskFile)){ - throw 'R342_DISABLED_GLOBAL_TASK_BACKING_FILE_NOT_PRESENT' + throw 'R343_EXPECTED_REAL_PROTECTED_WATCH_FILE_NOT_PRESENT' } Add-Type -TypeDefinition @' using System; @@ -335,6 +343,33 @@ public sealed class R342TaskFileRemovalKill { $child=[Diagnostics.Process]::Start($psi) if(!$child){throw 'R341_SECOND_IDENTICAL_UNMODIFIED_HELPER_NOT_STARTED'} } + if($KillAfterBrokerRemoval){ + $phase='KILL_EXACT_ORIGINAL_HELPER_AFTER_PROTECTED_BROKER_EXE_DELETE' + $first=$child;$fileKilledPid=$first.Id + try{ + $watcher=[R342TaskFileRemovalKill]::new() + $watcher.Start($first,$taskFile,$resultPath) + if(!$watcher.Wait(39000)){throw 'R343_BOUNDED_FILE_DELETE_WATCHER_TIMEOUT'} + if(!$watcher.Deleted -or !$watcher.Killed -or $watcher.ResultExistedAtKill -or $watcher.Error){ + throw ('R343_REAL_BROKER_EXECUTABLE_DELETE_KILL_NOT_ACCEPTED_'+$watcher.Error) + } + $fileRemovedAtKill=(![IO.File]::Exists($protectedB)) + $taskGoneAtFileKill=(!(TaskExists)) + $protectedRootPresentAtFileKill=[IO.Directory]::Exists($broker) + $ownerMarkerPresentAtFileKill=[IO.File]::Exists($owner) + if(!$fileRemovedAtKill -or !$taskGoneAtFileKill -or [IO.File]::Exists($resultPath)){ + throw 'R343_ORIGINAL_HELPER_NOT_STOPPED_AFTER_BROKER_FILE_DELETE_BEFORE_RESULT' + } + $deliberatePostFileKill=$true + }finally{ + try{if(!$first.HasExited){$first.Kill();[void]$first.WaitForExit(8000)}}catch{} + $first.Dispose() + } + Check 'ORIGINAL_HELPER_KILLED_AFTER_ACTUAL_BROKER_EXECUTABLE_DELETE_BEFORE_RESULT' + $phase='RETRY_UNMODIFIED_PRODUCTION_HELPER_AFTER_PROTECTED_FILE_MUTATION' + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R343_SECOND_ORIGINAL_HELPER_PROCESS_FAILED_TO_START'} + } try{ $childPid=$child.Id if(!$child.WaitForExit(55000)){ @@ -417,14 +452,15 @@ public sealed class R342TaskFileRemovalKill { if($createdPayload -and !$payloadRemoved){$failure+=' TEMP_PAYLOAD_RESIDUE'} if(!$logsRemoved -or !$appRemoved){$failure+=' LOCALAPP_OWNED_RESIDUE'} $neededChecks=9 - if($KillBeforeMutation -or $KillAfterTaskRemoval){$neededChecks=10} + if($KillBeforeMutation -or $KillAfterTaskRemoval -or $KillAfterBrokerRemoval){$neededChecks=10} $status=if(!$failure -and $checks.Count -eq $neededChecks -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ - if($KillAfterTaskRemoval){'PASS_KILLED_ORIGINAL_HELPER_AFTER_TASK_UNREGISTER_BEFORE_RESULT_THEN_RETRIED'} + if($KillAfterBrokerRemoval){'PASS_KILLED_ORIGINAL_HELPER_AFTER_BROKER_EXE_DELETE_BEFORE_RESULT_THEN_RETRIED'} + elseif($KillAfterTaskRemoval){'PASS_KILLED_ORIGINAL_HELPER_AFTER_TASK_UNREGISTER_BEFORE_RESULT_THEN_RETRIED'} elseif($KillBeforeMutation){'PASS_KILLED_ORIGINAL_EMBEDDED_HELPER_BEFORE_SHARED_MUTATION_THEN_RECOVERED'} else{'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER'} }else{'FAIL_R340_NOT_ACCEPTED'} $out=[ordered]@{ - Schema=1;Revision=$(if($KillAfterTaskRemoval){'R342'}elseif($KillBeforeMutation){'R341'}else{'R340'});Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Schema=1;Revision=$(if($KillAfterBrokerRemoval){'R343'}elseif($KillAfterTaskRemoval){'R342'}elseif($KillBeforeMutation){'R341'}else{'R340'});Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA SetupSHA256=if([IO.File]::Exists($setup)){Hash $setup}else{'MISSING'} CandidateBrokerSHA256=$beforeB;CandidateSupervisorSHA256=$beforeS RealEmbeddedHelperBytesMatchSource=$usesRealUnmodifiedResource @@ -439,11 +475,17 @@ public sealed class R342TaskFileRemovalKill { ScopedUserPayloadRemoved=$payloadRemoved ScopedTestLogsRemoved=$logsRemoved UserProfileExistingAppPreserved=!$createdApp - ActualHelperDeliberateKillTested=[bool]($deliberateKill -or $deliberatePostTaskKill) - DeliberateKillRequested=[bool]($KillBeforeMutation -or $KillAfterTaskRemoval) + ActualHelperDeliberateKillTested=[bool]($deliberateKill -or $deliberatePostTaskKill -or $deliberatePostFileKill) + DeliberateKillRequested=[bool]($KillBeforeMutation -or $KillAfterTaskRemoval -or $KillAfterBrokerRemoval) FirstKilledOriginalHelperPid=$killedFirstPid OriginalTaskAndBrokerUnchangedAtKill=$originalTaskIntactAtKill - RecoveryUsesIdenticalOriginalRunasProcessInfo=[bool](($deliberateKill -or $deliberatePostTaskKill) -and $nativeRunas) + RecoveryUsesIdenticalOriginalRunasProcessInfo=[bool](($deliberateKill -or $deliberatePostTaskKill -or $deliberatePostFileKill) -and $nativeRunas) + ActualHelperKilledAfterBrokerExecutableDeletion=$deliberatePostFileKill + ExactProtectedBrokerExecutableAbsentAtKill=$fileRemovedAtKill + TaskAlreadyUnregisteredAtFileKill=$taskGoneAtFileKill + ProtectedBrokerRootPresentAtFileKill=$protectedRootPresentAtFileKill + OwnerMarkerPresentAtFileKill=$ownerMarkerPresentAtFileKill + PostFileKilledOriginalHelperPid=$fileKilledPid ActualHelperKilledAfterProtectedTaskMutation=$deliberatePostTaskKill OriginalTaskUnregisteredAtKill=$taskRemovedAtKill ResultAbsentAtPostTaskKill=$resultAbsentAtKill From 0da25339e7a4aaafc37116d928441a959b286e83 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 12:16:42 +0330 Subject: [PATCH 111/135] fix(sensor): journal protected uninstall before mutation and authenticate markerless rollback (R344) --- ...it-r340-real-original-helper-uninstall.yml | 8 +- ...0-OriginalHelperActualUninstall-Native.ps1 | 76 ++++++++++++++++- installer/TBME_Setup_Elevated_Helper.ps1 | 81 +++++++++++++++++-- 3 files changed, 151 insertions(+), 14 deletions(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index fa007a47..809acb99 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -1,7 +1,7 @@ -name: R340-R343 original Helper nominal, early, post-task, post-file (hosted Windows) +name: R344 authenticated protected uninstall journal original Helper (hosted Windows) on: push: - branches: [audit/r340-real-helper-uninstall-death] + branches: [audit/r344-uninstall-prepared-journal-guard] paths: - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 - .github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -11,7 +11,7 @@ permissions: actions: read jobs: original-helper-uninstall: - name: R340-R343 ${{ matrix.mode }} exact PE Helper ${{ matrix.host }} + name: R344 ${{ matrix.mode }} exact PE Helper ${{ matrix.host }} runs-on: ${{ matrix.os }} timeout-minutes: 35 strategy: @@ -36,7 +36,7 @@ jobs: $ErrorActionPreference='Stop' if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or - $env:GITHUB_REF -cne 'refs/heads/audit/r340-real-helper-uninstall-death' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r344-uninstall-prepared-journal-guard' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index 7abc24a3..02f79b35 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -21,7 +21,7 @@ if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or - $env:GITHUB_REF -cne 'refs/heads/audit/r340-real-helper-uninstall-death' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r344-uninstall-prepared-journal-guard' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or @@ -69,6 +69,7 @@ $nativeRunas=$false;$usesRealUnmodifiedResource=$false $deliberateKill=$false;$killedFirstPid=0;$originalTaskIntactAtKill=$false $deliberatePostTaskKill=$false;$taskRemovedAtKill=$false;$resultAbsentAtKill=$false;$postKilledPid=0 $deliberatePostFileKill=$false;$fileRemovedAtKill=$false;$taskGoneAtFileKill=$false;$protectedRootPresentAtFileKill=$null;$ownerMarkerPresentAtFileKill=$null;$fileKilledPid=0 +$journalColdRestored=$false;$recoveryRunPid=0;$finalCleanupRunPid=0;$recoveryTaskXmlExact=$false;$recoveryTaskTrustMatch=$false;$firstHelperStatus='';$oldOwnedBrokerHashesRestored=$false function Hash([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} function Check([string]$check){$script:checks.Add($check);Write-Output ('PASS '+$check)} function TaskExists(){ @@ -380,6 +381,66 @@ public sealed class R342TaskFileRemovalKill { }finally{$child.Dispose()} if([int]$helperExit -ne 0){throw ('R340_ORIGINAL_HELPER_NONZERO_EXIT_'+$helperExit)} Check 'ACTUAL_COMPRESSED_PE_EMBEDDED_HELPER_PROCESS_RUNAS_WITHOUT_DRIVER_EXITS_ZERO' + # R344: The exact original Helper must cold-restore the hash-bound old + # protected Broker + disabled global task after interruption under PREPARED. + # The first new Helper intentionally exits DEGRADED_PREVIOUS_LAYER_RESTORED; + # only a THIRD identical original Helper run may finish requested Uninstall. + if(($KillBeforeMutation -or $KillAfterTaskRemoval -or $KillAfterBrokerRemoval) -and + [IO.File]::Exists($resultPath)){ + $intermediate=Get-Content -LiteralPath $resultPath -Raw|ConvertFrom-Json + $firstHelperStatus=[string]$intermediate.Status + if($firstHelperStatus -ceq 'DEGRADED_PREVIOUS_LAYER_RESTORED'){ + $phase='VERIFY_HASHED_PREPARED_UNINSTALL_COLD_RESTORATION' + if(!(TaskExists) -or + ![IO.File]::Exists($protectedB) -or + ![IO.File]::Exists($protectedS) -or + ![IO.File]::Exists($owner) -or + (Hash $protectedB) -cne $beforeB -or + (Hash $protectedS) -cne $beforeS -or + (Hash $owner) -cne $ownerHash){ + throw 'R344_ORIGINAL_HELPER_COLD_REPLAY_PRIOR_OWNER_FILES_MISMATCH' + } + $oldOwnedBrokerHashesRestored=$true + $restored=Get-ScheduledTask -TaskName $task -TaskPath '\' -ErrorAction Stop + $recoveryTaskTrustMatch=($restored.Settings.Enabled -eq $false -and + (ResolveTaskSid ([string]$restored.Principal.UserId)) -ceq $sid -and + @($restored.Actions).Count -eq 1 -and + [string]::Equals([string]$restored.Actions[0].Execute,$protectedS,[StringComparison]::OrdinalIgnoreCase) -and + [string]::Equals([string]$restored.Actions[0].Arguments,$argsText,[StringComparison]::OrdinalIgnoreCase)) + if(!$recoveryTaskTrustMatch){throw 'R344_PRIOR_SCHEDULED_TASK_ACTION_PRINCIPAL_NOT_RESTORED'} + $priorXml=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($exportedXmlHash)) + $newXml=[string](Export-ScheduledTask -TaskPath '\' -TaskName $task -ErrorAction Stop) + $recoveryTaskXmlExact=[string]::Equals($priorXml,$newXml,[StringComparison]::Ordinal) + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired')){ + throw 'R344_PREPARED_JOURNAL_NOT_RETIRED_AFTER_COLD_REPLAY' + } + $journalColdRestored=$true + $recoveryRunPid=$childPid + Check 'VERIFIED_PRIOR_BROKER_MARKER_AND_TASK_COLD_RESTORED_FROM_REAL_PREPARED_JOURNAL' + $phase='THIRD_IDENTICAL_PE_HELPER_FINISHES_REQUESTED_UNINSTALL' + $finalChild=[Diagnostics.Process]::Start($psi) + if(!$finalChild){throw 'R344_THIRD_ORIGINAL_HELPER_NOT_STARTED'} + try{ + $finalCleanupRunPid=$finalChild.Id + if($finalCleanupRunPid -eq $recoveryRunPid -or + $finalCleanupRunPid -eq $fileKilledPid -or + $finalCleanupRunPid -eq $postKilledPid){ + throw 'R344_FINAL_ORIGINAL_HELPER_PID_COLLISION' + } + if(!$finalChild.WaitForExit(55000)){ + if(!$finalChild.HasExited){$finalChild.Kill();[void]$finalChild.WaitForExit(6000)} + throw 'R344_FINAL_UNINSTALL_TIMEOUT' + } + if($finalChild.ExitCode -ne 0){throw ('R344_FINAL_UNINSTALL_NONZERO_'+$finalChild.ExitCode)} + }finally{$finalChild.Dispose()} + $childPid=$finalCleanupRunPid;$helperExit=0 + }elseif($KillAfterTaskRemoval -or $KillAfterBrokerRemoval){ + throw ('R344_TORN_UNINSTALL_DID_NOT_COLD_RESTORE_'+$firstHelperStatus) + } + } + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired')){ + throw 'R344_TERMINAL_UNINSTALL_JOURNAL_RESIDUE' + } $phase='VERIFY_REAL_PROTECTED_UNINSTALL_AND_WITNESS' $taskRemoved=!(TaskExists) $brokerRemoved=![IO.Directory]::Exists($broker) @@ -453,14 +514,15 @@ public sealed class R342TaskFileRemovalKill { if(!$logsRemoved -or !$appRemoved){$failure+=' LOCALAPP_OWNED_RESIDUE'} $neededChecks=9 if($KillBeforeMutation -or $KillAfterTaskRemoval -or $KillAfterBrokerRemoval){$neededChecks=10} + if($journalColdRestored){$neededChecks++} $status=if(!$failure -and $checks.Count -eq $neededChecks -and $taskRemoved -and $brokerRemoved -and $protectedRemoved){ - if($KillAfterBrokerRemoval){'PASS_KILLED_ORIGINAL_HELPER_AFTER_BROKER_EXE_DELETE_BEFORE_RESULT_THEN_RETRIED'} + if($KillAfterBrokerRemoval){'PASS_PREPARED_UNINSTALL_JOURNAL_RESTORED_AFTER_BROKER_FILE_DELETE_THEN_UNINSTALLED'} elseif($KillAfterTaskRemoval){'PASS_KILLED_ORIGINAL_HELPER_AFTER_TASK_UNREGISTER_BEFORE_RESULT_THEN_RETRIED'} elseif($KillBeforeMutation){'PASS_KILLED_ORIGINAL_EMBEDDED_HELPER_BEFORE_SHARED_MUTATION_THEN_RECOVERED'} else{'PASS_REAL_UNMODIFIED_EMBEDDED_HELPER_RUNAS_UNINSTALL_PROTECTED_LAYER'} }else{'FAIL_R340_NOT_ACCEPTED'} $out=[ordered]@{ - Schema=1;Revision=$(if($KillAfterBrokerRemoval){'R343'}elseif($KillAfterTaskRemoval){'R342'}elseif($KillBeforeMutation){'R341'}else{'R340'});Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Schema=1;Revision=$(if($KillAfterBrokerRemoval){'R344'}elseif($KillAfterTaskRemoval){'R342'}elseif($KillBeforeMutation){'R341'}else{'R340'});Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA SetupSHA256=if([IO.File]::Exists($setup)){Hash $setup}else{'MISSING'} CandidateBrokerSHA256=$beforeB;CandidateSupervisorSHA256=$beforeS RealEmbeddedHelperBytesMatchSource=$usesRealUnmodifiedResource @@ -486,6 +548,14 @@ public sealed class R342TaskFileRemovalKill { ProtectedBrokerRootPresentAtFileKill=$protectedRootPresentAtFileKill OwnerMarkerPresentAtFileKill=$ownerMarkerPresentAtFileKill PostFileKilledOriginalHelperPid=$fileKilledPid + OriginalPreparedJournalColdRecoveryObserved=$journalColdRestored + OriginalHelperColdRecoveryFirstStatus=$firstHelperStatus + ColdRecoveryHelperPid=$recoveryRunPid + FinalOriginalHelperUninstallPid=$finalCleanupRunPid + RestoredOwnerMarkerAndProtectedBinarySHAExact=$oldOwnedBrokerHashesRestored + RestoredDisabledTaskPrincipalAndActionExact=$recoveryTaskTrustMatch + RestoredTaskExportedXmlByteExact=$recoveryTaskXmlExact + NoTerminalJournalResidueAfterFinal=$true ActualHelperKilledAfterProtectedTaskMutation=$deliberatePostTaskKill OriginalTaskUnregisteredAtKill=$taskRemovedAtKill ResultAbsentAtPostTaskKill=$resultAbsentAtKill diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 079c92e7..23d6deb6 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -1387,13 +1387,37 @@ try{ Preserve-OtherUserSensorLayer 'ANOTHER_ACCOUNT_OWNS_GLOBAL_SCHEDULED_TASK' } }elseif(Test-Path -LiteralPath $BrokerRoot){ - # R303: an aborted same-user install may leave an existing protected - # BrokerRoot after task removal. Refuse absent/other-user ownership. + # R344: Uninstall can be killed after deleting the owner marker but + # before removing BrokerRoot (R343 native A/B reproduced this). Never + # trust a markerless tree merely because the caller requests Uninstall. + # Only an admin-owned, fully hashed PREPARED journal with the old owner + # marker itself among its verified snapshots proves this same-account + # partial deletion. Other modes/foreign roots continue to fail closed. Assert-SensorBoundaryNoReparse $SensorOwnerMarker if(-not (Test-Path -LiteralPath $SensorOwnerMarker -PathType Leaf)){ - Preserve-OtherUserSensorLayer 'UNOWNED_SHARED_PROGRAMFILES_BROKER_WITHOUT_TASK' - } - if([IO.File]::ReadAllText($SensorOwnerMarker,[Text.Encoding]::ASCII).Trim() -cne $expectedUserSid){ + $trustedPartialUninstall=$false + if($Mode -ceq 'Uninstall' -and + [IO.Directory]::Exists($SensorJournalRoot)){ + $pending=Assert-SensorDurableJournal $SensorJournalRoot $expectedUserSid + $newList=@($pending.ExpectedNewFiles|Where-Object{$null -ne $_}) + $ownerRecords=@($pending.Files|Where-Object{ + [string]$_.Rel -ceq '.tbme-owner.sid' + }) + if([bool]$pending.OldRootPresent -and $newList.Count -eq 0 -and + $ownerRecords.Count -eq 1 -and + (Get-SensorJournalTerminal $SensorJournalRoot $expectedUserSid) -ceq 'PREPARED'){ + $savedMarker=Join-Path (Join-Path $SensorJournalRoot 'files') '.tbme-owner.sid' + Assert-SensorJournalTrustedAcl $savedMarker $false + if([IO.File]::ReadAllText($savedMarker,[Text.Encoding]::ASCII).Trim() -ceq $expectedUserSid){ + $trustedPartialUninstall=$true + } + } + } + if(-not $trustedPartialUninstall){ + Preserve-OtherUserSensorLayer 'UNOWNED_SHARED_PROGRAMFILES_BROKER_WITHOUT_TASK' + } + Write-Log 'R344_SAME_OWNER_PREPARED_UNINSTALL_SNAPSHOT_ACCEPTED' + }elseif([IO.File]::ReadAllText($SensorOwnerMarker,[Text.Encoding]::ASCII).Trim() -cne $expectedUserSid){ Preserve-OtherUserSensorLayer 'PROTECTED_SENSOR_OWNER_MARKER_MISMATCH' } Write-Log 'PARTIAL_PROTECTED_SENSOR_BROKER_SAME_OWNER_ACCEPTED' @@ -1433,10 +1457,53 @@ try{ if($Mode -eq 'Uninstall'){ try{ + # R344: authenticated write-ahead snapshot before the FIRST task or + # protected Broker deletion. A torn uninstall must restore the exact + # previous owner task/files, not silently leave a markerless folder. + $oldRootPresent=[IO.Directory]::Exists($BrokerRoot) + $oldTaskPresent=@(Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue | + Where-Object{$null -ne $_}).Count -ne 0 + if($oldRootPresent -or $oldTaskPresent){ + if(-not(Capture-PreviousSensorLayer)){ + throw 'R344_UNINSTALL_PRIOR_OWNER_SNAPSHOT_NOT_CAPTURED' + } + $snapshotParent=[IO.Path]::GetDirectoryName($SensorJournalRoot) + Assert-SensorBoundaryNoReparse $snapshotParent + if(-not [IO.Directory]::Exists($snapshotParent)){ + [void][IO.Directory]::CreateDirectory($snapshotParent) + } + Assert-SensorJournalTrustedAcl $snapshotParent $false + # Empty expected-new set distinguishes an uninstall snapshot + # from the existing install PREPARED journals. Hashes and SID are + # checked during markerless cold recovery before any mutation. + New-SensorDurableJournal $SensorJournalRoot $validatedSensorSid $RollbackFiles $RollbackTaskXmlText ([string[]]@()) $oldRootPresent + Write-Log 'R344_DURABLE_PREPARED_BEFORE_UNINSTALL_TASK_FILE_DELETION' + } Get-Process TaskbarMonitorSensorSupervisor,TaskbarMonitorSensorBroker -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $BrokerRoot -Recurse -Force -ErrorAction SilentlyContinue + $liveTasks=@(Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue | + Where-Object{$null -ne $_}) + if($liveTasks.Count -gt 1){throw 'R344_UNINSTALL_MULTIPLE_GLOBAL_TASKS'} + if($liveTasks.Count -eq 1){ + Unregister-ScheduledTask -TaskPath '\' -TaskName $TaskName -Confirm:$false -ErrorAction Stop + } + Assert-SensorBoundaryNoReparse $BrokerRoot + if([IO.Directory]::Exists($BrokerRoot)){ + Remove-Item -LiteralPath $BrokerRoot -Recurse -Force -ErrorAction Stop + } + if([IO.Directory]::Exists($BrokerRoot) -or + @((Get-ScheduledTask -TaskPath '\' -TaskName $TaskName -ErrorAction SilentlyContinue | + Where-Object{$null -ne $_})).Count){ + throw 'R344_PROTECTED_UNINSTALL_POSTCONDITION_FAILED' + } + if([IO.Directory]::Exists($SensorJournalRoot)){ + Write-SensorJournalTerminal $SensorJournalRoot $validatedSensorSid 'COMMITTED' + Finish-SensorTerminalJournal $SensorJournalRoot $validatedSensorSid + } + if([IO.Directory]::Exists($SensorJournalRoot) -or + [IO.Directory]::Exists($SensorJournalRoot+'-retired')){ + throw 'R344_UNINSTALL_TERMINAL_JOURNAL_RESIDUE' + } Write-Log 'UNINSTALL protected sensor layer removed' Write-Result 'UNINSTALLED' 'Protected sensor layer removed.' 'NOT_APPLICABLE' $null $false $false $false exit 0 From 54d54f05bbc711d9cdab49fc838449553bcb2957 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 12:21:42 +0330 Subject: [PATCH 112/135] test(r344): capture original Helper warning and bound nominal guest diagnostic (F01) --- ...it-r340-real-original-helper-uninstall.yml | 2 +- ...0-OriginalHelperActualUninstall-Native.ps1 | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index 809acb99..c4cd623d 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - mode: [nominal, killed, posttask, postfile] + mode: [nominal] host: [A, B] include: - host: A diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index 02f79b35..465d902d 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -464,6 +464,30 @@ public sealed class R342TaskFileRemovalKill { }catch{ $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase }finally{ + # R344-F01 diagnostic snapshot is read-only and taken BEFORE cleanup. + # The nominal test must expose the actual Helper Status/Message and log + # source for an early fail-closed unwind; no profile secrets are copied. + $helperDiagnosticStatus='MISSING';$helperDiagnosticMessage='' + $helperDiagnosticLogTail='' + try{ + if([IO.File]::Exists($resultPath)){ + $diagnostic=Get-Content -LiteralPath $resultPath -Raw -ErrorAction Stop|ConvertFrom-Json + $helperDiagnosticStatus=[string]$diagnostic.Status + $helperDiagnosticMessage=[string]$diagnostic.Message + } + if([IO.File]::Exists($log)){ + $lines=@(Get-Content -LiteralPath $log -Tail 9 -ErrorAction Stop) + $helperDiagnosticLogTail=($lines -join ' | ') + if($helperDiagnosticLogTail.Length -gt 1800){ + $helperDiagnosticLogTail=$helperDiagnosticLogTail.Substring($helperDiagnosticLogTail.Length-1800) + } + } + }catch{$helperDiagnosticMessage='DIAGNOSTIC_READ_FAILED_'+$_.Exception.GetType().Name} + if($failure){ + Write-Output ('R344_DIAGNOSTIC_HELPER_STATUS='+$helperDiagnosticStatus) + Write-Output ('R344_DIAGNOSTIC_HELPER_MESSAGE='+$helperDiagnosticMessage) + Write-Output ('R344_DIAGNOSTIC_LOGTAIL='+$helperDiagnosticLogTail) + } # Only the caller-owned fixtures are candidates for cleanup. Never force # delete unknown shared task/root contents or preexisting user AppData files. if($createdTask -and (TaskExists)){ @@ -531,6 +555,9 @@ public sealed class R342TaskFileRemovalKill { AlreadyAdminRunner=$true;TrueInteractiveUacDialogObserved=$false OriginalHelperProcessStarted=($childPid -gt 0);OriginalHelperPid=$childPid OriginalHelperExitCode=$helperExit;OriginalHelperResultStatus=$helperStatus + PreCleanupHelperResultStatus=$helperDiagnosticStatus + PreCleanupHelperResultMessage=$helperDiagnosticMessage + PreCleanupHelperLogTail=$helperDiagnosticLogTail ProtectedProgramFilesCreated=$createdRoot;ProtectedProgramFilesRemoved=$protectedRemoved OriginalHelperBrokerSubfolderRemoved=$brokerRemoved GlobalDisabledTaskCreated=$createdTask;GlobalDisabledTaskRemoved=$taskRemoved From 362d7051e9cb16e3303b8ae7a715b1e26a2b021a Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 12:35:26 +0330 Subject: [PATCH 113/135] fix(sensor): trace R344 rollback capture origin and scope R85 install guard (F02) --- build/Test-PawnIO-External-Degrade.ps1 | 16 ++++++++++++++-- installer/TBME_Setup_Elevated_Helper.ps1 | 7 +++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/build/Test-PawnIO-External-Degrade.ps1 b/build/Test-PawnIO-External-Degrade.ps1 index 38cd1d00..8ac629f3 100644 --- a/build/Test-PawnIO-External-Degrade.ps1 +++ b/build/Test-PawnIO-External-Degrade.ps1 @@ -20,8 +20,20 @@ if(Test-Path -LiteralPath $Base){throw 'R85 GUEST_FIXTURE_COLLISION_NO_USER_DATA if(!(Test-Path -LiteralPath $Helper -PathType Leaf)){throw 'R85 HELPER MISSING'} $source=Get-Content -LiteralPath $Helper -Raw $guardAt=$source.IndexOf('PAWNIO_UPSTREAM_EXTERNAL_INSTALL_REQUIRED',[StringComparison]::Ordinal) -$captureAt=$source.IndexOf('if(-not(Capture-PreviousSensorLayer))',[StringComparison]::Ordinal) -if($guardAt -lt 0 -or $captureAt -le $guardAt){throw 'R85 EARLY DEGRADED GUARD NOT BEFORE SENSOR MUTATION'} +# R344 adds a separate *Uninstall-only* PREPARED snapshot before PawnIO +# discovery. The R85 regression is for Install: bind this static check to +# the Install-specific capture following the completed Uninstall branch. +$uninstallBegin=$source.IndexOf('if($Mode -eq ''Uninstall'')',[StringComparison]::Ordinal) +$uninstallEnd=$source.IndexOf('# R294: one exclusive read',$uninstallBegin,[StringComparison]::Ordinal) +$captureAt=$source.IndexOf('if(-not(Capture-PreviousSensorLayer))',$uninstallEnd,[StringComparison]::Ordinal) +if($uninstallBegin -lt 0 -or $uninstallEnd -le $uninstallBegin -or + $guardAt -lt $uninstallEnd -or $captureAt -le $guardAt){ + throw 'R85 INSTALL_DEGRADED_GUARD_NOT_BEFORE_INSTALL_SENSOR_MUTATION' +} +if($source.Substring($uninstallBegin,$uninstallEnd-$uninstallBegin).IndexOf( + 'if(-not(Capture-PreviousSensorLayer))',[StringComparison]::Ordinal) -lt 0){ + throw 'R85 R344_UNINSTALL_JOURNAL_CAPTURE_MISSING' +} $tok=$null;$errs=$null $ast=[Management.Automation.Language.Parser]::ParseInput($source,[ref]$tok,[ref]$errs) if($errs.Count -ne 0){throw 'R85 HELPER HAS PARSER ERRORS'} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 23d6deb6..97fc1038 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -1308,7 +1308,8 @@ function Capture-PreviousSensorLayer { $task=Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if($task){ $script:RollbackTaskXmlText=[string](Export-ScheduledTask -TaskName $TaskName) - if($RollbackTaskXmlText.Length -gt 262144){throw 'ROLLBACK_XML_TOO_LARGE'} + if([string]::IsNullOrWhiteSpace([string]$script:RollbackTaskXmlText) -or + ([string]$script:RollbackTaskXmlText).Length -gt 262144){throw 'ROLLBACK_XML_MISSING_OR_TOO_LARGE'} $script:PreviousSensorLayerPresent=$true } $script:RollbackCaptured=$true @@ -1316,7 +1317,9 @@ function Capture-PreviousSensorLayer { return $true }catch{ Clear-SensorRollbackBuffer - Write-Log ('ROLLBACK_CAPTURE_FAIL '+$_.Exception.ToString()) + Write-Log ('ROLLBACK_CAPTURE_FAIL '+$_.Exception.ToString()+ + ' POSITION='+[string]$_.InvocationInfo.PositionMessage+ + ' STACK='+[string]$_.ScriptStackTrace) return $false } } From ec8b7049b2dd9aaf6769031adf523a10959e29c4 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 12:44:53 +0330 Subject: [PATCH 114/135] fix(sensor): distinguish read-only NTFS ACEs from mutations in journal trust (R344 F03) --- ...it-r340-real-original-helper-uninstall.yml | 14 +- ...-R344-JournalAcl-MutationRights-Native.ps1 | 138 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 5 +- 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 build/Test-R344-JournalAcl-MutationRights-Native.ps1 diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index c4cd623d..f74219b0 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -4,6 +4,7 @@ on: branches: [audit/r344-uninstall-prepared-journal-guard] paths: - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 + - build/Test-R344-JournalAcl-MutationRights-Native.ps1 - .github/workflows/audit-r340-real-original-helper-uninstall.yml workflow_dispatch: permissions: @@ -68,6 +69,15 @@ jobs: throw 'R340_PRODUCT_EXACT_SETUP_NOT_COMPILED' } 'R340_ALL_FOUR_FRAMEWORK_PRODUCT_COMPONENTS_BUILD_SUCCESS' + - name: Validate real PE-embedded journal ACL reject/allow semantics + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-JournalAcl-MutationRights-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ACTUAL_ACL_BIT_NEGATIVE_FAILED'} - name: Launch exact unmodified RunAs production Helper on owned disabled test task shell: pwsh run: | @@ -88,6 +98,8 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: R340-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} - path: ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json + path: | + ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json + ${{ runner.temp }}/R344Acl_*/R344_JOURNAL_ACL_MASK.json retention-days: 7 if-no-files-found: warn diff --git a/build/Test-R344-JournalAcl-MutationRights-Native.ps1 b/build/Test-R344-JournalAcl-MutationRights-Native.ps1 new file mode 100644 index 00000000..f3c73618 --- /dev/null +++ b/build/Test-R344-JournalAcl-MutationRights-Native.ps1 @@ -0,0 +1,138 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r344-uninstall-prepared-journal-guard' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$'){ + throw 'R344_ACL_TEST_REFUSES_NON_DISPOSABLE_WINDOWS' +} +if((git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R344_ACL_GUEST_EXACT_COMMIT_MISMATCH' +} +$principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R344_ACL_TEST_REQUIRES_EXISTING_ADMIN_TOKEN_NO_UAC' +} +$realScript=(Resolve-Path -LiteralPath (Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1')).Path +$setup=(Resolve-Path -LiteralPath (Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe')).Path +$raw=[IO.File]::ReadAllText($realScript) +$asm=[Reflection.Assembly]::LoadFrom($setup) +$stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') +if(!$stream){throw 'R344_ACL_COMPILED_REAL_PE_HELPER_ABSENT'} +$reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) +try{$fromSetup=$reader.ReadToEnd()}finally{$reader.Dispose()} +$norm={param($text) $text.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} +if((& $norm $raw) -cne (& $norm $fromSetup)){ + throw 'R344_ACL_SOURCE_NOT_EXACT_COMPILED_PE_HELPER' +} +$tok=$null;$err=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($realScript,[ref]$tok,[ref]$err) +if(@($err).Count){throw 'R344_ACL_EMBEDDED_HELPER_PS51_AST_INVALID'} +$names=@('Assert-SensorBoundaryNoReparse','Assert-SensorJournalTrustedAcl') +$defs=@($ast.FindAll({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('Assert-SensorBoundaryNoReparse','Assert-SensorJournalTrustedAcl') +},$true)) +if($defs.Count -ne 2){throw 'R344_ACL_REAL_PRODUCTION_FUNCTION_MISSING'} +. ([scriptblock]::Create(($defs|ForEach-Object{$_.Extent.Text}) -join [Environment]::NewLine)) +$container=Join-Path $env:RUNNER_TEMP ('R344Acl_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$target=Join-Path $container 'test-root' +$receipt=Join-Path $container 'R344_JOURNAL_ACL_MASK.json' +if(Test-Path -LiteralPath $container){throw 'R344_ACL_UNIQUE_TEST_ROOT_COLLISION'} +$checks=[Collections.Generic.List[string]]::new() +$failure='' +$clean=$false +$realAcl=$null +function Pass([string]$label){$script:checks.Add($label);Write-Output ('PASS '+$label)} +function GuardedExpectReject([string]$expected){ + $message='' + try{ + Assert-SensorJournalTrustedAcl $target $true + throw 'R344_ACL_DANGEROUS_ALLOW_WRITE_ACCEPTED' + }catch{ + $message=$_.Exception.Message + } + if($message -cne $expected){throw ('R344_ACL_WRONG_REJECTION_'+$message)} +} +try{ + [void][IO.Directory]::CreateDirectory($container) + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $base=[Security.AccessControl.DirectorySecurity]::new() + $base.SetOwner($admins);$base.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $sid,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + [void]$base.AddAccessRule($rule) + } + [void][IO.Directory]::CreateDirectory($target,$base) + $realAcl=Get-Acl -LiteralPath $target + if($realAcl.Owner -notmatch '(?i)Administrators' -or !$realAcl.AreAccessRulesProtected){ + throw 'R344_ACL_GUEST_DOES_NOT_HAVE_EXPECTED_ADMIN_OWNER' + } + Pass 'REAL_PE_EMBEDDED_HELPER_AND_PROTECTED_ADMIN_DACL_EXACT' + Assert-SensorJournalTrustedAcl $target $true + Pass 'BUILTIN_USERS_READ_EXECUTE_ALLOWED_NOT_MISCLASSIFIED_AS_WRITE' + $rights=@([Security.AccessControl.FileSystemRights]::Modify, + [Security.AccessControl.FileSystemRights]::WriteData, + [Security.AccessControl.FileSystemRights]::Delete, + [Security.AccessControl.FileSystemRights]::ChangePermissions) + $labels=@('MODIFY','WRITE_DATA','DELETE','CHANGE_PERMISSIONS') + for($i=0;$i -lt $rights.Count;$i++){ + $candidate=Get-Acl -LiteralPath $target + $rule=[Security.AccessControl.FileSystemAccessRule]::new( + $users,$rights[$i],[Security.AccessControl.AccessControlType]::Allow) + [void]$candidate.AddAccessRule($rule) + Set-Acl -LiteralPath $target -AclObject $candidate -ErrorAction Stop + try{GuardedExpectReject 'SENSOR_JOURNAL_UNTRUSTED_WRITE_ACE'} + finally{Set-Acl -LiteralPath $target -AclObject $realAcl -ErrorAction Stop} + Assert-SensorJournalTrustedAcl $target $true + Pass ('BUILTIN_USERS_'+$labels[$i]+'_REJECTED_WITH_SAME_DACL_RESTORED') + } +}catch{$failure=$_.Exception.ToString()} +finally{ + try{ + if([IO.Directory]::Exists($target)){ + Assert-SensorBoundaryNoReparse $target + if(@([IO.Directory]::GetFileSystemEntries($target)).Count -ne 0){ + throw 'R344_ACL_TEST_ROOT_HAS_UNEXPECTED_MEMBERS' + } + if($realAcl){Set-Acl -LiteralPath $target -AclObject $realAcl -ErrorAction Stop} + [IO.Directory]::Delete($target,$false) + } + $clean=(![IO.Directory]::Exists($target)) + }catch{$failure+=' CLEANUP_FAIL '+$_.Exception.ToString()} + $expected=6 + $status=if(!$failure -and $checks.Count -eq $expected -and $clean){'PASS_REAL_COMPILED_JOURNAL_ACL_MASK_NTFS'}else{'FAIL_R344_JOURNAL_ACL_GUEST'} + $data=[ordered]@{ + Schema=1;Revision='R344';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + ExactPEEmbeddedRealProductionFunction=$true + UsersReadAndExecuteAllowed=($checks.Contains('BUILTIN_USERS_READ_EXECUTE_ALLOWED_NOT_MISCLASSIFIED_AS_WRITE')) + FourUntrustedMutationACEsRejected=($checks.Count -eq $expected) + OriginalAdminOwnedFixtureRemoved=$clean + RealOwnerComputerTouched=$false;UacPromptTested=$false + Checks=@($checks);Failure=$failure + } + if([IO.Directory]::Exists($container)){ + [IO.File]::WriteAllText($receipt,($data|ConvertTo-Json -Depth 6), + [Text.UTF8Encoding]::new($false)) + Write-Output ('R344_JOURNAL_ACL_RECEIPT='+$receipt) + } + Write-Output ('R344_JOURNAL_ACL_STATUS='+$status) +} +if($failure){throw ('R344_NATIVE_COMPILED_ACL_NEGATIVE_FAILED '+$failure)} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 97fc1038..c1b5a120 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -434,8 +434,11 @@ function Assert-SensorJournalTrustedAcl([string]$Path,[bool]$RequireProtected){ if($RequireProtected -and -not $acl.AreAccessRulesProtected){ throw 'SENSOR_JOURNAL_UNPROTECTED_DACL' } + # R344-F03: FileSystemRights.Modify includes ReadAndExecute. + # Bitwise testing the composite Modify value wrongly treated benign + # BUILTIN Users ReadAndExecute as a write ACE. Check only mutation + # capabilities; never loosen the trusted owner or SID allowlist. $write=[Security.AccessControl.FileSystemRights]::Write -bor - [Security.AccessControl.FileSystemRights]::Modify -bor [Security.AccessControl.FileSystemRights]::Delete -bor [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [Security.AccessControl.FileSystemRights]::ChangePermissions -bor From 8c4da94159e005ae52da087f4e4cdb61ab67ab10 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 12:49:27 +0330 Subject: [PATCH 115/135] test(r344): expand accepted ACL and nominal proof to all four original Helper modes --- .github/workflows/audit-r340-real-original-helper-uninstall.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index f74219b0..73e29015 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - mode: [nominal] + mode: [nominal, killed, posttask, postfile] host: [A, B] include: - host: A From 86492c50ed2a475a9a316dc77dfc55800e6f8567 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 12:57:55 +0330 Subject: [PATCH 116/135] test(r344): diagnose protected journal capture scope on post-mutation native guests --- ...udit-r340-real-original-helper-uninstall.yml | 2 +- installer/TBME_Setup_Elevated_Helper.ps1 | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index 73e29015..e583b9c4 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - mode: [nominal, killed, posttask, postfile] + mode: [posttask, postfile] host: [A, B] include: - host: A diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index c1b5a120..274d05bb 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -759,7 +759,8 @@ function Restore-SensorJournalOldFilesOnly( } $pending.Add($full) }elseif(-not $known.Contains($rel)){ - throw 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' + throw ('SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE_REL='+$rel+ + ' SAVED='+$prior.Count+' EXPECTED='+$known.Count) } } } @@ -1316,6 +1317,12 @@ function Capture-PreviousSensorLayer { $script:PreviousSensorLayerPresent=$true } $script:RollbackCaptured=$true + Write-Log ('R344_DIAG_CAPTURE rootExists='+[IO.Directory]::Exists($BrokerRoot)+ + ' scriptRows='+@($script:RollbackFiles).Count+ + ' unscopedRows='+@($RollbackFiles).Count+ + ' scriptPrevious='+[bool]$script:PreviousSensorLayerPresent+ + ' unscopedPrevious='+[bool]$PreviousSensorLayerPresent+ + ' xmlLen='+([string]$script:RollbackTaskXmlText).Length) Write-Log ("ROLLBACK_CAPTURE_PASS previous="+$PreviousSensorLayerPresent) return $true }catch{ @@ -1409,6 +1416,10 @@ try{ $ownerRecords=@($pending.Files|Where-Object{ [string]$_.Rel -ceq '.tbme-owner.sid' }) + Write-Log ('R344_DIAG_MARKERLESS oldRoot='+[bool]$pending.OldRootPresent+ + ' snapshots='+@($pending.Files).Count+ + ' newFiles='+$newList.Count+ + ' markerRows='+$ownerRecords.Count) if([bool]$pending.OldRootPresent -and $newList.Count -eq 0 -and $ownerRecords.Count -eq 1 -and (Get-SensorJournalTerminal $SensorJournalRoot $expectedUserSid) -ceq 'PREPARED'){ @@ -1473,6 +1484,10 @@ if($Mode -eq 'Uninstall'){ if(-not(Capture-PreviousSensorLayer)){ throw 'R344_UNINSTALL_PRIOR_OWNER_SNAPSHOT_NOT_CAPTURED' } + Write-Log ('R344_DIAG_BEFORE_DURABLE rootPresent='+$oldRootPresent+ + ' scriptRows='+@($script:RollbackFiles).Count+ + ' unscopedRows='+@($RollbackFiles).Count+ + ' taskXmlLen='+([string]$RollbackTaskXmlText).Length) $snapshotParent=[IO.Path]::GetDirectoryName($SensorJournalRoot) Assert-SensorBoundaryNoReparse $snapshotParent if(-not [IO.Directory]::Exists($snapshotParent)){ From a00ce084dd2a3bd6f2332fb93db1e0a08f9592d5 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 13:02:05 +0330 Subject: [PATCH 117/135] fix(sensor): unify embedded Helper script state to preserve PREPARED snapshots (R344 F06) --- ...udit-r340-real-original-helper-uninstall.yml | 1 + installer/TBME_Setup_Elevated_Helper.ps1 | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index e583b9c4..e095bac2 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -5,6 +5,7 @@ on: paths: - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 - build/Test-R344-JournalAcl-MutationRights-Native.ps1 + - installer/TBME_Setup_Elevated_Helper.ps1 - .github/workflows/audit-r340-real-original-helper-uninstall.yml workflow_dispatch: permissions: diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 274d05bb..a7932a7e 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -252,13 +252,16 @@ $CpuTransportHealthy=$false $GpuTransportHealthy=$false $StorageTransportHealthy=$false $CpuDataAvailable=$false -$RollbackPerformed=$false -$RollbackSucceeded=$false -$PreviousSensorLayerPresent=$false -$ActiveArchitecture='R21_PROCESS_ISOLATED' -$RollbackFiles=@() -$RollbackTaskXmlText=$null -$RollbackCaptured=$false +# R344-F06: Setup executes the embedded Helper inside a nested scriptblock. +# Later functions write $script: state; initialize those same variables in +# script scope too, otherwise local bootstrap fields shadow the snapshots. +$script:RollbackPerformed=$false +$script:RollbackSucceeded=$false +$script:PreviousSensorLayerPresent=$false +$script:ActiveArchitecture='R21_PROCESS_ISOLATED' +$script:RollbackFiles=@() +$script:RollbackTaskXmlText=$null +$script:RollbackCaptured=$false # Fail closed when a user-writable profile log path was replaced after the # initial elevated token/boundary verification. Write-time inspection is From 0fb74041bb09947a7dae5a29f68db4afa40be835 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 13:30:10 +0330 Subject: [PATCH 118/135] test(r344): revalidate every original Helper death mode on fixed journal state (F07) --- .github/workflows/audit-r340-real-original-helper-uninstall.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index e095bac2..5c2fcc95 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - mode: [posttask, postfile] + mode: [nominal, killed, posttask, postfile] host: [A, B] include: - host: A From 09315d25e5a17a12d9dcc59d63dbb8db64d3f99b Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 13:41:11 +0330 Subject: [PATCH 119/135] test(security): prove original Helper rejects unowned Broker without journal; trim debug (R344 F08) --- ...it-r340-real-original-helper-uninstall.yml | 12 + ...est-R344-UnownedSensorNoJournal-Native.ps1 | 245 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 21 +- 3 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 build/Test-R344-UnownedSensorNoJournal-Native.ps1 diff --git a/.github/workflows/audit-r340-real-original-helper-uninstall.yml b/.github/workflows/audit-r340-real-original-helper-uninstall.yml index 5c2fcc95..09c544e9 100644 --- a/.github/workflows/audit-r340-real-original-helper-uninstall.yml +++ b/.github/workflows/audit-r340-real-original-helper-uninstall.yml @@ -5,6 +5,7 @@ on: paths: - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 - build/Test-R344-JournalAcl-MutationRights-Native.ps1 + - build/Test-R344-UnownedSensorNoJournal-Native.ps1 - installer/TBME_Setup_Elevated_Helper.ps1 - .github/workflows/audit-r340-real-original-helper-uninstall.yml workflow_dispatch: @@ -79,6 +80,16 @@ jobs: $source=(Resolve-Path .).Path & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source if($LASTEXITCODE -ne 0){throw 'R344_ACTUAL_ACL_BIT_NEGATIVE_FAILED'} + - name: Refuse unowned or foreign protected Broker without a real journal + if: matrix.mode == 'nominal' + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-UnownedSensorNoJournal-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ORIGINAL_HELPER_UNOWNED_WITHOUT_JOURNAL_REFUSAL_FAILED'} - name: Launch exact unmodified RunAs production Helper on owned disabled test task shell: pwsh run: | @@ -102,5 +113,6 @@ jobs: path: | ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json ${{ runner.temp }}/R344Acl_*/R344_JOURNAL_ACL_MASK.json + ${{ runner.temp }}/R344Neg_*/R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json retention-days: 7 if-no-files-found: warn diff --git a/build/Test-R344-UnownedSensorNoJournal-Native.ps1 b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 new file mode 100644 index 00000000..8c66ce47 --- /dev/null +++ b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 @@ -0,0 +1,245 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# Native security-negative witness; tests exact PE embedded production Helper +# only on fresh temporary hosted Windows. Never run on owner's installed PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r344-uninstall-prepared-journal-guard' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^\d{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R344_NEGATIVE_REQUIRES_PINNED_DISPOSABLE_GUEST' +} +$token=[Security.Principal.WindowsIdentity]::GetCurrent() +$principal=[Security.Principal.WindowsPrincipal]::new($token) +if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R344_NEGATIVE_REQUIRES_EXISTING_ADMIN_TOKEN' +} +$sid=[string]$token.User.Value +if($sid -cnotmatch '^S-1-5-21-(?:\d+-){3}\d+$'){throw 'R344_NEGATIVE_INTERACTIVE_TOKEN_SID_MISSING'} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$root=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $root 'SensorBroker' +$brokerExe=Join-Path $broker 'TaskbarMonitorSensorBroker.exe' +$supervisorExe=Join-Path $broker 'TaskbarMonitorSensorSupervisor.exe' +$ownerMarker=Join-Path $broker '.tbme-owner.sid' +$journal=Join-Path $root '.tbme-sensor-rollback' +$task='TaskbarMonitorEnhanced Sensor Broker' +$app=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'TaskbarMonitorEnhanced' +$logs=Join-Path $app 'Logs' +$log=Join-Path $logs 'sensor_install.log' +$result=Join-Path $logs 'sensor_install_result.json' +$temp=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) 'AppData\Local\Temp' +$payload=Join-Path $temp ('tbme_neg_'+[guid]::NewGuid().ToString('N')) +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R344Neg_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$privateBroker=Join-Path $SourceRoot 'build\_out\Broker\TaskbarMonitorSensorBroker.exe' +$privateSupervisor=Join-Path $SourceRoot 'build\_out\Supervisor\TaskbarMonitorSensorSupervisor.exe' +$helperSource=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$checks=[Collections.Generic.List[string]]::new() +$failure='';$phase='PRECONDITION' +$rootCreated=$false;$payloadCreated=$false;$appCreated=$false;$logsCreated=$false +$rootRemoved=$false;$payloadRemoved=$false;$logsRemoved=$false;$appRemoved=$false +$originalBrokerHash='';$originalSupervisorHash='';$script:startupCount=0 +$realPE=$false;$canary='';$canaryHash='' +function SHA([string]$f){return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash} +function Note([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function TaskExists(){ + return @((Get-ScheduledTask -TaskPath '\' -TaskName $task -ErrorAction SilentlyContinue |Where-Object {$null -ne $_})).Count -gt 0 +} +function RefuseReparse([string]$path){ + if(([IO.File]::GetAttributes($path) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R344_NEGATIVE_FIXTURE_REPARSE_NOT_OWNED' + } +} +function RunOriginalHelper([Diagnostics.ProcessStartInfo]$psi,[string]$reason){ + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R344_NEGATIVE_ORIGINAL_HELPER_NOT_STARTED'} + try{ + if(!$child.WaitForExit(45000)){ + if(!$child.HasExited){$child.Kill();[void]$child.WaitForExit(5000)} + throw 'R344_NEGATIVE_ORIGINAL_HELPER_TIMEOUT' + } + if($child.ExitCode -ne 0){throw ('R344_NEGATIVE_HELPER_EXIT_NONZERO_'+$child.ExitCode)} + }finally{$child.Dispose()} + $script:startupCount++ + if(![IO.File]::Exists($result) -or ![IO.File]::Exists($log)){ + throw 'R344_NEGATIVE_MISSING_REAL_HELPER_STATUS_OR_LOG' + } + $status=Get-Content -LiteralPath $result -Raw|ConvertFrom-Json + if($status.Status -cne 'DEGRADED_OTHER_USER_SENSOR_LAYER_PRESERVED' -or + $status.Message -cnotmatch [Regex]::Escape($reason)){ + throw ('R344_NEGATIVE_FOERGIGN_OWNER_GUARD_FAIL_'+$status.Status+'_'+$status.Message) + } + if(!(Select-String -LiteralPath $log -SimpleMatch ('CROSS_USER_SENSOR_SAFETY_REFUSAL reason='+$reason) -Quiet)){ + throw 'R344_NEGATIVE_REAL_HELPER_NOT_LOGGING_EXPECTED_REFUSAL' + } + if([IO.Directory]::Exists($journal) -or [IO.Directory]::Exists($journal+'-retired') -or + (TaskExists) -or (SHA $brokerExe) -cne $originalBrokerHash -or + (SHA $supervisorExe) -cne $originalSupervisorHash -or (SHA $canary) -cne $canaryHash){ + throw 'R344_NEGATIVE_PROTECTED_EXECUTABLE_OR_CANARY_MUTATED' + } + Note ('EXACT_PRODUCTION_HELPER_REFUSES_'+$reason) +} +try{ + foreach($f in @($setup,$privateBroker,$privateSupervisor,$helperSource,$native)){ + if(![IO.File]::Exists($f)){throw 'R344_NEGATIVE_EXACT_PRIVATE_BUILD_FILE_MISSING'} + } + if([IO.Directory]::Exists($root) -or [IO.Directory]::Exists($receiptRoot) -or + [IO.Directory]::Exists($payload) -or [IO.File]::Exists($log) -or + [IO.File]::Exists($result) -or [IO.File]::Exists((Join-Path $app 'TaskbarMonitorEnhanced.exe')) -or + (TaskExists) -or @(Get-Process TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count){ + throw 'R344_NEGATIVE_PREEXISTING_INSTALL_OR_FIXTURE_COLLISION' + } + [void][IO.Directory]::CreateDirectory($receiptRoot) + $canary=Join-Path $receiptRoot 'CANARY.txt' + [IO.File]::WriteAllText($canary,'R344_OUTSIDE_CANARY',[Text.UTF8Encoding]::new($false)) + $canaryHash=SHA $canary + $asm=[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($setup)) + $resource=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$resource){throw 'R344_NEGATIVE_PE_NO_EMBEDDED_PRODUCTION_HELPER'} + $reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $norm={param($text) $text.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $norm $embedded) -cne (& $norm ([IO.File]::ReadAllText($helperSource)))){ + throw 'R344_NEGATIVE_PRIVATE_SETUP_EMBEDDED_SOURCE_NOT_EXACT' + } + $realPE=$true + Note 'ORIGINAL_PE_EMBEDDED_HELPER_SHA_BOUND_WITH_DISPOSABLE_TOKEN' + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($party in @($admins,$system,$users)){ + $rights=if($party -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $party,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($root,$acl) + $rootCreated=$true + [void][IO.Directory]::CreateDirectory($broker) + [IO.File]::Copy($privateBroker,$brokerExe) + [IO.File]::Copy($privateSupervisor,$supervisorExe) + $originalBrokerHash=SHA $brokerExe + $originalSupervisorHash=SHA $supervisorExe + if($originalBrokerHash -cne (SHA $privateBroker) -or + $originalSupervisorHash -cne (SHA $privateSupervisor)){throw 'R344_NEGATIVE_PRIVATE_EXECUTABLE_COPY_NOT_EXACT'} + if(![IO.Directory]::Exists($app)){ + [void][IO.Directory]::CreateDirectory($app);$appCreated=$true + } + if(![IO.Directory]::Exists($logs)){ + [void][IO.Directory]::CreateDirectory($logs);$logsCreated=$true + } + [void][IO.Directory]::CreateDirectory($payload);$payloadCreated=$true + $type=$asm.GetType('SetupProgram',$true) + $factory=$type.GetMethod('BuildElevatedSensorProcessInfo',[Reflection.BindingFlags]'NonPublic,Static') + if(!$factory){throw 'R344_NEGATIVE_PRODUCTION_RUNAS_FACTORY_MISSING'} + $actual=[string[]]@('Uninstall',$payload,$app,$sid) + $values=New-Object 'object[]' 4 + for($i=0;$i -lt 4;$i++){$values[$i]=$actual[$i].PSObject.BaseObject} + $psi=[Diagnostics.ProcessStartInfo]$factory.Invoke($null,$values) + if(!$psi.UseShellExecute -or $psi.Verb -cne 'runas' -or + -not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R344_NEGATIVE_NOT_ORIGINAL_SYSTEM32_RUNAS_PE_HELPER' + } + Note 'BOUNDED_ADMIN_OWNED_MARKERLESS_PROGRAMFILES_BROKER_CREATED_WITHOUT_JOURNAL_OR_TASK' + $phase='ORIGINAL_HELPER_REFUSES_MISSING_OWNER_WITHOUT_DURABLE_JOURNAL' + RunOriginalHelper $psi 'UNOWNED_SHARED_PROGRAMFILES_BROKER_WITHOUT_TASK' + if([IO.File]::Exists($ownerMarker)){throw 'R344_NEGATIVE_HELPER_CREATED_MISSING_OWNER_MARKER'} + Note 'ABSENT_JOURNAL_DID_NOT_FABRICATE_SENSOR_OWNER_MARKER' + $phase='ORIGINAL_HELPER_REFUSES_FOREIGN_MARKER_WITHOUT_DURABLE_JOURNAL' + [IO.File]::WriteAllText($ownerMarker,'S-1-5-21-0-0-0-1',[Text.Encoding]::ASCII) + $markerHash=SHA $ownerMarker + RunOriginalHelper $psi 'PROTECTED_SENSOR_OWNER_MARKER_MISMATCH' + if((SHA $ownerMarker) -cne $markerHash){throw 'R344_NEGATIVE_FOREIGN_SID_MARKER_CHANGED'} + Note 'OTHER_ACCOUNT_MARKER_BYTE_EXACT_AND_NO_UNTRUSTED_SENSOR_CLEANUP' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($rootCreated -and [IO.Directory]::Exists($root)){ + try{ + RefuseReparse $root;RefuseReparse $broker + $parentMembers=@([IO.Directory]::GetFileSystemEntries($root)) + $expectedBroker=$broker + if($parentMembers.Count -ne 1 -or $parentMembers[0] -cne $expectedBroker){ + throw 'R344_NEGATIVE_UNEXPECTED_PROTECTED_ROOT_CHILD_REFUSE_CLEANUP' + } + $children=@([IO.Directory]::GetFileSystemEntries($broker)) + $allowed=@($brokerExe,$supervisorExe,$ownerMarker) + if(@($children|Where-Object{$_ -notin $allowed}).Count){ + throw 'R344_NEGATIVE_UNEXPECTED_PROTECTED_FILE_REFUSE_CLEANUP' + } + foreach($f in $children){RefuseReparse $f} + if((SHA $brokerExe) -cne $originalBrokerHash -or + (SHA $supervisorExe) -cne $originalSupervisorHash){ + throw 'R344_NEGATIVE_EXACT_PROTECTED_EXECUTABLE_HASH_CHANGED_REFUSE_CLEANUP' + } + foreach($f in $children){[IO.File]::Delete($f)} + [IO.Directory]::Delete($broker,$false) + [IO.Directory]::Delete($root,$false) + }catch{$failure+=' PROTECTED_CLEANUP_REFUSED='+$_.Exception.Message} + } + $rootRemoved=(![IO.Directory]::Exists($root)) + if($payloadCreated){ + try{ + if([IO.Directory]::Exists($payload) -and @([IO.Directory]::GetFileSystemEntries($payload)).Count -eq 0){ + [IO.Directory]::Delete($payload,$false) + } + }catch{$failure+=' PAYLOAD_CLEANUP_REFUSED='+$_.Exception.Message} + } + $payloadRemoved=(![IO.Directory]::Exists($payload)) + if([IO.Directory]::Exists($logs)){ + try{ + foreach($f in @($log,$result)){if([IO.File]::Exists($f)){RefuseReparse $f;[IO.File]::Delete($f)}} + if($logsCreated -and @([IO.Directory]::GetFileSystemEntries($logs)).Count -eq 0){ + [IO.Directory]::Delete($logs,$false) + } + }catch{$failure+=' LOG_CLEANUP_REFUSED='+$_.Exception.Message} + } + $logsRemoved=(!$logsCreated -or ![IO.Directory]::Exists($logs)) + if($appCreated -and [IO.Directory]::Exists($app)){ + try{ + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){[IO.Directory]::Delete($app,$false)} + }catch{$failure+=' APP_CLEANUP_REFUSED='+$_.Exception.Message} + } + $appRemoved=(!$appCreated -or ![IO.Directory]::Exists($app)) + if($rootCreated -and !$rootRemoved){$failure+=' OWN_PROTECTED_ROOT_NOT_REMOVED'} + if($payloadCreated -and !$payloadRemoved){$failure+=' OWN_TEMP_PAYLOAD_NOT_REMOVED'} + if(!$logsRemoved -or !$appRemoved){$failure+=' OWN_TEST_PROFILE_LOG_REMAINS'} + $status=if(!$failure -and $checks.Count -eq 6 -and $startupCount -eq 2 -and $rootRemoved){ + 'PASS_REAL_HELPER_REFUSES_UNOWNED_AND_FOREIGN_BROKER_WITHOUT_JOURNAL' + }else{'FAIL_R344_REAL_SECURITY_NEGATIVE'} + $out=[ordered]@{ + Schema=1;Revision='R344';Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + Status=$status;SetupSHA256=if([IO.File]::Exists($setup)){SHA $setup}else{'MISSING'} + ActualFullPEEmbeddedOriginalHelper=$realPE;RealOriginalHelperInvocations=$startupCount + NoTrustedPreparedJournalPresent=$true;NoScheduledTaskEverRegistered=$true + ForeignAndAbsentOwnerMarkerRefused=($checks.Count -eq 6) + ProtectedOriginalBrokerSHA256=$originalBrokerHash + ProtectedOriginalSupervisorSHA256=$originalSupervisorHash + ExactOwnedProtectedRootRemoved=$rootRemoved + OriginalOwnerComputerTouched=$false + ActualInteractiveUACPromptObserved=$false + ActualProtectedDriverInstalled=$false + Checks=@($checks);Failure=$failure + } + if([IO.Directory]::Exists($receiptRoot)){ + [IO.File]::WriteAllText($receipt,($out|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) + Write-Output ('R344_NEGATIVE_RECEIPT='+$receipt) + } + Write-Output ('R344_NEGATIVE_STATUS='+$status) +} +if($failure){throw ('R344_SECURITY_NEGATIVE_NOT_ACCEPTED '+$failure)} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index a7932a7e..bb93c0d2 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -762,8 +762,7 @@ function Restore-SensorJournalOldFilesOnly( } $pending.Add($full) }elseif(-not $known.Contains($rel)){ - throw ('SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE_REL='+$rel+ - ' SAVED='+$prior.Count+' EXPECTED='+$known.Count) + throw 'SENSOR_JOURNAL_REPLAY_UNKNOWN_FILE' } } } @@ -1320,19 +1319,11 @@ function Capture-PreviousSensorLayer { $script:PreviousSensorLayerPresent=$true } $script:RollbackCaptured=$true - Write-Log ('R344_DIAG_CAPTURE rootExists='+[IO.Directory]::Exists($BrokerRoot)+ - ' scriptRows='+@($script:RollbackFiles).Count+ - ' unscopedRows='+@($RollbackFiles).Count+ - ' scriptPrevious='+[bool]$script:PreviousSensorLayerPresent+ - ' unscopedPrevious='+[bool]$PreviousSensorLayerPresent+ - ' xmlLen='+([string]$script:RollbackTaskXmlText).Length) Write-Log ("ROLLBACK_CAPTURE_PASS previous="+$PreviousSensorLayerPresent) return $true }catch{ Clear-SensorRollbackBuffer - Write-Log ('ROLLBACK_CAPTURE_FAIL '+$_.Exception.ToString()+ - ' POSITION='+[string]$_.InvocationInfo.PositionMessage+ - ' STACK='+[string]$_.ScriptStackTrace) + Write-Log ('ROLLBACK_CAPTURE_FAIL '+$_.Exception.ToString()) return $false } } @@ -1419,10 +1410,6 @@ try{ $ownerRecords=@($pending.Files|Where-Object{ [string]$_.Rel -ceq '.tbme-owner.sid' }) - Write-Log ('R344_DIAG_MARKERLESS oldRoot='+[bool]$pending.OldRootPresent+ - ' snapshots='+@($pending.Files).Count+ - ' newFiles='+$newList.Count+ - ' markerRows='+$ownerRecords.Count) if([bool]$pending.OldRootPresent -and $newList.Count -eq 0 -and $ownerRecords.Count -eq 1 -and (Get-SensorJournalTerminal $SensorJournalRoot $expectedUserSid) -ceq 'PREPARED'){ @@ -1487,10 +1474,6 @@ if($Mode -eq 'Uninstall'){ if(-not(Capture-PreviousSensorLayer)){ throw 'R344_UNINSTALL_PRIOR_OWNER_SNAPSHOT_NOT_CAPTURED' } - Write-Log ('R344_DIAG_BEFORE_DURABLE rootPresent='+$oldRootPresent+ - ' scriptRows='+@($script:RollbackFiles).Count+ - ' unscopedRows='+@($RollbackFiles).Count+ - ' taskXmlLen='+([string]$RollbackTaskXmlText).Length) $snapshotParent=[IO.Path]::GetDirectoryName($SensorJournalRoot) Assert-SensorBoundaryNoReparse $snapshotParent if(-not [IO.Directory]::Exists($snapshotParent)){ From ce33b95b859845d865e5bb90db5120fdf0ed129f Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 13:44:13 +0330 Subject: [PATCH 120/135] test(r344): use official private uninstall Temp prefix in foreign-owner negative --- build/Test-R344-UnownedSensorNoJournal-Native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-R344-UnownedSensorNoJournal-Native.ps1 b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 index 8c66ce47..4b09cb04 100644 --- a/build/Test-R344-UnownedSensorNoJournal-Native.ps1 +++ b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 @@ -39,7 +39,7 @@ $logs=Join-Path $app 'Logs' $log=Join-Path $logs 'sensor_install.log' $result=Join-Path $logs 'sensor_install_result.json' $temp=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) 'AppData\Local\Temp' -$payload=Join-Path $temp ('tbme_neg_'+[guid]::NewGuid().ToString('N')) +$payload=Join-Path $temp ('tbme_uninstall_'+[guid]::NewGuid().ToString('N')) $receiptRoot=Join-Path $env:RUNNER_TEMP ('R344Neg_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) $receipt=Join-Path $receiptRoot 'R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json' $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' From a0fd11a1e446d5894c0472657ad7b16f732594ff Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 15:11:34 +0330 Subject: [PATCH 121/135] fix(sensor): pin protected ProgramFiles Broker directory identity during cold replay (R347) --- .../audit-r347-original-helper-regression.yml | 118 ++++++++++++ .../audit-r347-protected-broker-lease.yml | 88 +++++++++ ...0-OriginalHelperActualUninstall-Native.ps1 | 2 +- ...-R344-JournalAcl-MutationRights-Native.ps1 | 2 +- ...est-R344-UnownedSensorNoJournal-Native.ps1 | 2 +- build/Test-R347-OtherAdminRename-Actor.ps1 | 67 +++++++ ...t-R347-PrivilegedDirectoryLease-Native.ps1 | 172 ++++++++++++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 83 +++++++++ 8 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/audit-r347-original-helper-regression.yml create mode 100644 .github/workflows/audit-r347-protected-broker-lease.yml create mode 100644 build/Test-R347-OtherAdminRename-Actor.ps1 create mode 100644 build/Test-R347-PrivilegedDirectoryLease-Native.ps1 diff --git a/.github/workflows/audit-r347-original-helper-regression.yml b/.github/workflows/audit-r347-original-helper-regression.yml new file mode 100644 index 00000000..17170a8e --- /dev/null +++ b/.github/workflows/audit-r347-original-helper-regression.yml @@ -0,0 +1,118 @@ +name: R347 full original Helper regression against broker directory leases (Windows) +on: + push: + branches: [audit/r347-protected-broker-handle-lease] + paths: + - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 + - build/Test-R344-JournalAcl-MutationRights-Native.ps1 + - build/Test-R344-UnownedSensorNoJournal-Native.ps1 + - installer/TBME_Setup_Elevated_Helper.ps1 + - .github/workflows/audit-r347-original-helper-regression.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + original-helper-uninstall: + name: R347 regression ${{ matrix.mode }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [nominal, killed, posttask, postfile] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact revision without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse any non-disposable host, prior product, task or wrong ref + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R340_REFUSE_WRONG_GUEST_OR_WRONG_SOURCE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe' + if((Test-Path -LiteralPath $root) -or (Test-Path -LiteralPath $main) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R340_ALREADY_INSTALLED_PRODUCT_OR_SENSOR_TASK_GUEST_REFUSED' + } + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R340_NOT_ALREADY_ADMIN_NEVER_DISABLE_UAC' + } + 'R340_DISPOSABLE_ADMIN_TOKEN_AND_NO_PRODUCT_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build exact reviewed product including production PE-embedded Helper + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R340_PRODUCT_EXACT_SETUP_NOT_COMPILED' + } + 'R340_ALL_FOUR_FRAMEWORK_PRODUCT_COMPONENTS_BUILD_SUCCESS' + - name: Validate real PE-embedded journal ACL reject/allow semantics + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-JournalAcl-MutationRights-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ACTUAL_ACL_BIT_NEGATIVE_FAILED'} + - name: Refuse unowned or foreign protected Broker without a real journal + if: matrix.mode == 'nominal' + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-UnownedSensorNoJournal-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ORIGINAL_HELPER_UNOWNED_WITHOUT_JOURNAL_REFUSAL_FAILED'} + - name: Launch exact unmodified RunAs production Helper on owned disabled test task + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R340-OriginalHelperActualUninstall-Native.ps1).Path + $source=(Resolve-Path .).Path + $nativeArgs=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$script,'-HostLabel','${{ matrix.host }}','-SourceRoot',$source) + if('${{ matrix.mode }}' -ceq 'killed'){$nativeArgs+='-KillBeforeMutation'} + if('${{ matrix.mode }}' -ceq 'posttask'){$nativeArgs+='-KillAfterTaskRemoval'} + if('${{ matrix.mode }}' -ceq 'postfile'){$nativeArgs+='-KillAfterBrokerRemoval'} + & $native @nativeArgs + if($LASTEXITCODE -ne 0){ + throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' + } + - name: Retain typed exact original Helper execution proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R347-regression-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json + ${{ runner.temp }}/R344Acl_*/R344_JOURNAL_ACL_MASK.json + ${{ runner.temp }}/R344Neg_*/R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r347-protected-broker-lease.yml b/.github/workflows/audit-r347-protected-broker-lease.yml new file mode 100644 index 00000000..79ad4cde --- /dev/null +++ b/.github/workflows/audit-r347-protected-broker-lease.yml @@ -0,0 +1,88 @@ +name: R347 real privileged process rename vs protected Broker handle lease (disposable Windows) +on: + push: + branches: [audit/r347-protected-broker-handle-lease] + paths: + - installer/TBME_Setup_Elevated_Helper.ps1 + - build/Test-R347-PrivilegedDirectoryLease-Native.ps1 + - build/Test-R347-OtherAdminRename-Actor.ps1 + - .github/workflows/audit-r347-protected-broker-lease.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + privileged-concurrent-rename: + name: R347 real two privileged process handles ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact candidate without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Enforce disposable already-admin Windows and no preexisting product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R347_HOST_OR_COMMIT_NOT_DISPOSABLE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $root) -or + (Test-Path -LiteralPath (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}).Count){ + throw 'R347_HOST_ALREADY_HAS_REAL_PRODUCT' + } + $isAdmin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(-not $isAdmin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R347_NO_EXISTING_ADMIN_TOKEN' + } + 'R347_DISPOSABLE_NO_PRODUCT_PRECONDITIONS_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build all four original Framework projects with embedded lease + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R347_COMPILED_ORIGINAL_SETUP_MISSING' + } + 'R347_EXACT_COMPILED_PRODUCT_BUILD_PASS' + - name: Real other admin process rename and junction refusal on private test-only protected root + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path build/Test-R347-PrivilegedDirectoryLease-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R347_TRUE_PRIVILEGED_DIRECTORY_LEASE_NOT_ACCEPTED'} + - name: Retain raw typed privileged Windows proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R347-${{ matrix.host }}-${{ github.run_id }} + path: ${{ runner.temp }}/R347_*/R347_PINNED_DIRECTORY_LEASE.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index 465d902d..34ae8eba 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -21,7 +21,7 @@ if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or - $env:GITHUB_REF -cne 'refs/heads/audit/r344-uninstall-prepared-journal-guard' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or diff --git a/build/Test-R344-JournalAcl-MutationRights-Native.ps1 b/build/Test-R344-JournalAcl-MutationRights-Native.ps1 index f3c73618..2ab9a98c 100644 --- a/build/Test-R344-JournalAcl-MutationRights-Native.ps1 +++ b/build/Test-R344-JournalAcl-MutationRights-Native.ps1 @@ -10,7 +10,7 @@ if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or - $env:GITHUB_REF -cne 'refs/heads/audit/r344-uninstall-prepared-journal-guard' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$'){ diff --git a/build/Test-R344-UnownedSensorNoJournal-Native.ps1 b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 index 4b09cb04..e91d4b71 100644 --- a/build/Test-R344-UnownedSensorNoJournal-Native.ps1 +++ b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 @@ -12,7 +12,7 @@ if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or - $env:GITHUB_REF -cne 'refs/heads/audit/r344-uninstall-prepared-journal-guard' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or $env:GITHUB_RUN_ID -cnotmatch '^\d{5,20}$' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or diff --git a/build/Test-R347-OtherAdminRename-Actor.ps1 b/build/Test-R347-OtherAdminRename-Actor.ps1 new file mode 100644 index 00000000..67bfcae7 --- /dev/null +++ b/build/Test-R347-OtherAdminRename-Actor.ps1 @@ -0,0 +1,67 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$BrokerPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R347_ADMIN_ACTOR_REFUSES_NONHOSTED_WINDOWS' +} +$admin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(-not $admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R347_ADMIN_ACTOR_REQUIRES_ALREADY_ADMIN_TOKEN' +} +$pf=[IO.Path]::GetFullPath([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) +$root=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $root 'SensorBroker' +if(-not [string]::Equals([IO.Path]::GetFullPath($BrokerPath),$broker,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R347_ACTOR_BROKER_NOT_EXACT_PROTECTED_PATH' +} +$receipt=[IO.Path]::GetFullPath($ReceiptPath) +if($receipt -notlike ([IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\R347_*')){ + throw 'R347_ACTOR_RECEIPT_OUTSIDE_RUNNER_TEMP' +} +if([IO.File]::Exists($receipt)){throw 'R347_OTHER_ADMIN_RECEIPT_ALREADY_EXISTS'} +$attempts=80;$d1=0;$d2=0;$failure='' +function TryRename([string]$which,[string]$old,[string]$dest){ + try{ + [IO.Directory]::Move($old,$dest) + throw ('R347_UNEXPECTED_PRIVILEGED_DIRECTORY_RENAME_SUCCEEDED_'+$which) + }catch{ + if($_.Exception.Message.StartsWith('R347_UNEXPECTED_',[StringComparison]::Ordinal)){throw} + $e=$_.Exception + if(-not ($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException])){ + throw ('R347_BAD_PRIVILEGED_RENAME_FAILURE_TYPE_'+$which+' '+$e.GetType().Name) + } + if([IO.Directory]::Exists($dest) -or -not [IO.Directory]::Exists($old)){ + throw ('R347_PROTECTED_PATH_CHANGED_DURING_DENIAL_'+$which) + } + if($which -eq 'Broker'){$script:d1++}else{$script:d2++} + } +} +try{ + for($i=0;$i -lt $attempts;$i++){ + TryRename 'Broker' $broker ($broker+'_R347_unexpected') + TryRename 'Product' $root ($root+'_R347_unexpected') + Start-Sleep -Milliseconds 5 + } +}catch{$failure=$_.Exception.ToString()} +$status=if(-not $failure -and $d1 -eq $attempts -and $d2 -eq $attempts){ + 'PASS_ADMIN_RENAME_DENIED_DURING_PINNED_ANCESTOR_HANDLES' +}else{'FAIL_R347_ADMIN_RENAME_ACTOR'} +[IO.File]::WriteAllText($receipt,([ordered]@{ + Schema=1;Revision='R347';Status=$status + Host=$env:COMPUTERNAME;SourceCommit=$env:GITHUB_SHA + ActorPid=$PID;ActorAlreadyAdmin=$true + AttemptsPerPath=$attempts;BrokerRenameDenied=$d1;ProductRenameDenied=$d2 + ProgramFilesProtectedPath=$broker;Failure=$failure +}|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) +Write-Output ('R347_ACTOR_STATUS='+$status+' ATTEMPTS='+($d1+$d2)) +if($failure){throw ('R347_ACTOR_FAILED '+$failure)} diff --git a/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 b/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 new file mode 100644 index 00000000..c426fcda --- /dev/null +++ b/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 @@ -0,0 +1,172 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R347_DISPOSABLE_HOST_EXACT_SOURCE_REQUIRED' +} +$actor=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(-not $actor.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R347_ALREADY_ADMIN_WINDOWS_TOKEN_REQUIRED' +} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$root=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $root 'SensorBroker' +$private=Join-Path $env:RUNNER_TEMP ('R347_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $private 'R347_PINNED_DIRECTORY_LEASE.json' +$otherReceipt=Join-Path $private 'OTHER_ADMIN_RENAME_RECEIPT.json' +$outsideCanary=Join-Path $private 'OUTSIDE_CANARY.txt' +$sentinel=Join-Path $broker 'R347_KNOWN.txt' +$backup=Join-Path $root 'SensorBroker_R347_backup' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$src=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$other=Join-Path $SourceRoot 'build\Test-R347-OtherAdminRename-Actor.ps1' +$ps=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$checks=[Collections.Generic.List[string]]::new() +$lease=$null;$child=$null;$created=$false;$linked=$false +$failure='';$denials=0;$concurrent=0;$actorPid=0;$released=$false;$rejectLink=$false;$clean=$false +$canarySha='';$actorSha='' +function Hash([string]$f){return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash} +function Pass([string]$s){$script:checks.Add($s);Write-Output ('PASS '+$s)} +try{ + if([IO.Directory]::Exists($root) -or [IO.Directory]::Exists($private) -or + @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count){ + throw 'R347_PREEXISTING_PRODUCT_OR_FIXTURE_REFUSE' + } + foreach($f in @($src,$setup,$other,$ps)){if(-not [IO.File]::Exists($f)){throw 'R347_SOURCE_FILE_NOT_FOUND'}} + $asm=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $resource=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$resource){throw 'R347_EMBEDDED_PRODUCTION_HELPER_ABSENT'} + $reader=[IO.StreamReader]::new($resource,[Text.Encoding]::UTF8) + try{$body=$reader.ReadToEnd()}finally{$reader.Dispose()} + $normalize={param($v) $v.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $body) -cne (& $normalize ([IO.File]::ReadAllText($src)))){ + throw 'R347_ORIGINAL_SETUP_EMBEDDED_HELPER_NOT_SOURCE_EXACT' + } + $at=$body.IndexOf('public sealed class TBMEProtectedBrokerLease',[StringComparison]::Ordinal) + if($at -lt 0){throw 'R347_NATIVE_CLASS_NOT_EMBEDDED'} + $st=$body.LastIndexOf("Add-Type -TypeDefinition @'",$at,[StringComparison]::Ordinal) + $from=$body.IndexOf([string][char]10,$st,[StringComparison]::Ordinal) + $end=$body.IndexOf(([string][char]10+"'@"),$at,[StringComparison]::Ordinal) + if($st -lt 0 -or $from -lt 0 -or $end -le $at -or $end-$from -gt 13000){ + throw 'R347_EMBEDDED_CSHARP_BOUNDS_BAD' + } + Add-Type -TypeDefinition $body.Substring($from+1,$end-$from-1) -ErrorAction Stop + Pass 'EXACT_REAL_PE_EMBEDDED_PROTECTED_LEASE_COMPILED' + [void][IO.Directory]::CreateDirectory($private) + [IO.File]::WriteAllText($outsideCanary,'R347_OUTSIDE_CANARY_UNTOUCHED',[Text.UTF8Encoding]::new($false)) + $canarySha=Hash $outsideCanary + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $sid,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($root,$acl);$created=$true + [void][IO.Directory]::CreateDirectory($broker) + [IO.File]::WriteAllText($sentinel,'R347_KNOWN_MEMBER',[Text.UTF8Encoding]::new($false)) + if((Get-Acl $root).Owner -notmatch '(?i)Administrators'){throw 'R347_PROTECTED_OWNER_NOT_ADMIN'} + Pass 'EXACT_ADMIN_OWNED_PROGRAMFILES_TEST_ROOT_CREATED' + $lease=[TBMEProtectedBrokerLease]::new($broker) + if(-not $lease.IsPinned){throw 'R347_THREE_DIRECTORY_HANDLES_NOT_OPEN'} + Pass 'REAL_PROGRAMFILES_PRODUCT_AND_BROKER_DIR_HANDLE_LEASE_ACTIVE' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$ps + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$other+'" -BrokerPath "'+$broker+'" -ReceiptPath "'+$otherReceipt+'"' + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R347_DIFFERENT_ADMIN_PROCESS_START_FAILED'} + $actorPid=$child.Id + $watch=[Diagnostics.Stopwatch]::StartNew() + while(-not $child.HasExited -and $watch.ElapsedMilliseconds -lt 25000){ + if(-not $lease.IsPinned -or -not [IO.File]::Exists($sentinel)){ + throw 'R347_PROTECTED_ROOT_CHANGED_WHILE_HANDLE_PINNED' + } + $concurrent++;Start-Sleep -Milliseconds 4 + } + if(-not $child.WaitForExit(8000) -or $child.ExitCode -ne 0){throw 'R347_OTHER_ADMIN_PROCESS_NOT_SUCCESSFUL'} + $a=Get-Content -LiteralPath $otherReceipt -Raw|ConvertFrom-Json + if($a.Status -cne 'PASS_ADMIN_RENAME_DENIED_DURING_PINNED_ANCESTOR_HANDLES' -or + $a.ActorPid -ne $actorPid -or $a.ActorAlreadyAdmin -ne $true -or + $a.BrokerRenameDenied -ne 80 -or $a.ProductRenameDenied -ne 80 -or + $a.SourceCommit -cne $env:GITHUB_SHA -or $concurrent -lt 5){ + throw 'R347_OTHER_ADMIN_TYPED_PROOF_INVALID' + } + $actorSha=Hash $otherReceipt;$denials=160 + Pass 'OTHER_ADMIN_PID_RENAME_160_OF_160_REFUSED_DURING_LEASE' + Pass 'ORIGINAL_PINNED_BROKER_PATH_STABLE_WHILE_ATTACKER_ACTIVE' + $lease.Dispose();$lease=$null + [IO.Directory]::Move($broker,$backup) + if(-not [IO.Directory]::Exists($backup) -or [IO.Directory]::Exists($broker)){throw 'R347_POST_LEASE_MOVE_NOT_SUCCESSFUL'} + [IO.Directory]::Move($backup,$broker);$released=$true + Pass 'SAME_ADMIN_RENAME_SUCCEEDS_AFTER_LEASE_RELEASE' + [IO.Directory]::Move($broker,$backup) + New-Item -ItemType Junction -Path $broker -Target $private -ErrorAction Stop|Out-Null + $linked=$true + $message='' + try{$attempt=[TBMEProtectedBrokerLease]::new($broker);try{throw 'R347_UNSAFE_LINK_ACCEPTED'}finally{$attempt.Dispose()}} + catch{$message=$_.Exception.Message} + if($message -cne 'SENSOR_BROKER_DIRECTORY_LEASE_REPARSE_REFUSED'){throw ('R347_JUNCTION_REJECTION_WRONG_'+$message)} + $rejectLink=$true + if((Hash $outsideCanary) -cne $canarySha){throw 'R347_OUTSIDE_CANARY_CHANGED'} + Pass 'REAL_NTFS_JUNCTION_REJECTED_BY_HANDLE_REPARSE_TAG' + if(([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -eq 0){throw 'R347_JUNCTION_CHANGED_BEFORE_SAFE_DELETION'} + [IO.Directory]::Delete($broker,$false);$linked=$false + [IO.Directory]::Move($backup,$broker) + if((Hash $outsideCanary) -cne $canarySha){throw 'R347_OUTSIDE_SHA_CHANGED'} + Pass 'OUTSIDE_CANARY_SHA256_UNCHANGED' +}catch{$failure=$_.Exception.ToString()} +finally{ + if($child){try{if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(3000)}}catch{};$child.Dispose()} + if($lease){$lease.Dispose()} + if($linked -and [IO.Directory]::Exists($broker)){ + try{if(([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0){[IO.Directory]::Delete($broker,$false)}}catch{$failure+=' LINK_CLEANUP_FAILED'} + } + if([IO.Directory]::Exists($backup) -and -not [IO.Directory]::Exists($broker)){try{[IO.Directory]::Move($backup,$broker)}catch{$failure+=' RESTORE_BACKUP_FAILED'}} + if($created -and [IO.Directory]::Exists($root)){ + try{ + if(([IO.File]::GetAttributes($root) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [IO.Directory]::Exists($broker) -or + ([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @([IO.Directory]::GetFileSystemEntries($root)).Count -ne 1 -or + @([IO.Directory]::GetFileSystemEntries($broker)).Count -ne 1 -or + [IO.File]::ReadAllText($sentinel) -cne 'R347_KNOWN_MEMBER'){ + throw 'R347_UNKNOWN_FIXTURE_MEMBER_NO_CLEANUP' + } + [IO.File]::Delete($sentinel);[IO.Directory]::Delete($broker,$false);[IO.Directory]::Delete($root,$false) + $clean=(![IO.Directory]::Exists($root)) + }catch{$failure+=' PROTECTED_FIXTURE_CLEANUP_FAILED '+$_.Exception.Message} + } + $status=if(-not $failure -and $checks.Count -eq 8 -and $denials -eq 160 -and + $concurrent -ge 5 -and $released -and $rejectLink -and $clean){ + 'PASS_CONCURRENT_ALREADY_ADMIN_DIRECTORY_RENAME_DENIED' + }else{'FAIL_R347_DIRECTORY_LEASE'} + if([IO.Directory]::Exists($private)){ + $obj=[ordered]@{ + Schema=1;Revision='R347';Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + ExactOriginalSetupPEHelper=$true;RealOtherAdminPid=$actorPid + DeniedAdminRenames=$denials;ConcurrentPinnedChecks=$concurrent + RenameAfterDisposePass=$released;ReparseJunctionRefused=$rejectLink + OutsideCanarySHA256=$canarySha;OtherAdminRawReceiptSHA256=$actorSha + CreatedAdminProtectedRootRemoved=$clean;LeafFileConcurrentRaceProven=$false + InteractiveUACNotTested=$true;ActualOriginalHelperWasNotLaunched=$true + OwnerComputerTouched=$false;Checks=@($checks);Failure=$failure + } + [IO.File]::WriteAllText($receipt,($obj|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) + Write-Output ('R347_TYPED_RECEIPT='+$receipt) + } + Write-Output ('R347_STATUS='+$status+' CHECKS='+$checks.Count+' DENIED='+$denials) +} +if($failure){throw ('R347_NATIVE_PRIVILEGED_RENAME_GUARD_FAILED '+$failure)} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index bb93c0d2..ee1ea79b 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -229,6 +229,74 @@ public sealed class TBMEScopedUserLogLease : IDisposable { guardCount=0; } } + + // R347: protected directory ancestor leases prohibit concurrent renames. +public sealed class TBMEProtectedBrokerLease : IDisposable { + const uint FILE_READ_ATTRIBUTES=0x00000080; + const uint SHARE_READ=1, SHARE_WRITE=2, OPEN_EXISTING=3; + const uint BACKUP_SEMANTICS=0x02000000, OPEN_REPARSE_POINT=0x00200000; + const uint ATTR_DIRECTORY=0x10, ATTR_REPARSE=0x400; + [DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] + static extern SafeFileHandle OpenNative(string path,uint access,uint share,IntPtr sec, + uint creation,uint flags,IntPtr template); + [StructLayout(LayoutKind.Sequential)] + struct FILE_ATTRIBUTE_TAG_INFO { public uint Attributes; public uint Tag; } + [DllImport("kernel32.dll",SetLastError=true)] + static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind, + out FILE_ATTRIBUTE_TAG_INFO data,uint size); + readonly SafeFileHandle[] parents=new SafeFileHandle[3]; + int count; + bool disposed; + + static SafeFileHandle OpenExactDir(string path) { + SafeFileHandle h=OpenNative(path,FILE_READ_ATTRIBUTES, + SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING, + BACKUP_SEMANTICS|OPEN_REPARSE_POINT,IntPtr.Zero); + if(h.IsInvalid) { + int error=Marshal.GetLastWin32Error(); + h.Dispose(); + throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_OPEN_FAILED_WIN32_"+error); + } + FILE_ATTRIBUTE_TAG_INFO info; + if(!GetFileInformationByHandleEx(h,9,out info, + (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO)))) { + int error=Marshal.GetLastWin32Error(); h.Dispose(); + throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_TAG_FAILED_WIN32_"+error); + } + if((info.Attributes&ATTR_DIRECTORY)==0||(info.Attributes&ATTR_REPARSE)!=0) { + h.Dispose(); + throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_REPARSE_REFUSED"); + } + return h; + } + + public TBMEProtectedBrokerLease(string liveBroker) { + string program=Path.GetFullPath(Environment.GetFolderPath( + Environment.SpecialFolder.ProgramFiles)).TrimEnd(Path.DirectorySeparatorChar); + string root=Path.Combine(program,"TaskbarMonitorEnhanced"); + string broker=Path.Combine(root,"SensorBroker"); + if(String.IsNullOrWhiteSpace(liveBroker) || + !String.Equals(Path.GetFullPath(liveBroker).TrimEnd(Path.DirectorySeparatorChar), + broker,StringComparison.OrdinalIgnoreCase)) { + throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_NOT_EXACT_SYSTEM_PROGRAMFILES"); + } + try { + parents[count++]=OpenExactDir(program); + parents[count++]=OpenExactDir(root); + // If a killed Helper removed the old Broker directory, the pinned + // parent prevents its replacement while recreating this fixed child. + if(!Directory.Exists(broker))Directory.CreateDirectory(broker); + parents[count++]=OpenExactDir(broker); + }catch {Dispose();throw;} + } + public bool IsPinned {get{return !disposed&&count==3;}} + public void Dispose() { + if(disposed)return; + disposed=true; + for(int i=count-1;i>=0;i--)if(parents[i]!=null)parents[i].Dispose(); + count=0; + } +} '@ $TaskName='TaskbarMonitorEnhanced Sensor Broker' @@ -1231,6 +1299,18 @@ function Restore-SensorJournalPriorLayerAndTask( } # Reject a foreign or altered live task BEFORE any files are changed. $previousTaskPresent=Test-SensorOwnedTaskRegistration $TaskName $ExpectedSid $LiveBrokerRoot $TrustedAppRoot + # R347: the original old protected tree is known from the validated + # journal. Pin ProgramFiles/product/Broker directory handles across both + # file replay and Task XML restoration; no DELETE sharing is granted. + # R337 first-install journals are intentionally excluded because the + # expected old BrokerRoot is absent and must be deleted on rollback. + $brokerDirectoryLease=$null + if($TaskName -ceq 'TaskbarMonitorEnhanced Sensor Broker' -and + [bool]$record.OldRootPresent){ + $brokerDirectoryLease=[TBMEProtectedBrokerLease]::new($LiveBrokerRoot) + Assert-SensorJournalTrustedAcl $LiveBrokerRoot $false + } + try { # R309 prevalidates every saved file/hash, XML and all current live # member names before the first synthetic file write. Retrying after a # crash is safe for the same allowlisted bytes and expected task. @@ -1260,6 +1340,9 @@ function Restore-SensorJournalPriorLayerAndTask( if([IO.Directory]::Exists($Journal) -or [IO.Directory]::Exists($Journal+'-retired')){ throw 'SENSOR_COLD_ROLLBACK_JOURNAL_RESIDUE' } + }finally{ + if($null -ne $brokerDirectoryLease){$brokerDirectoryLease.Dispose()} + } } # R315: verified cold replay is now callable from the trusted PE entrypoint From 55436a576744c95330d3f3b15d2654f3b3e85c06 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 15:19:34 +0330 Subject: [PATCH 122/135] fix(sensor): request directory-list rights to enforce no-share-delete guard (R347 F01) --- .github/workflows/audit-r347-protected-broker-lease.yml | 4 +++- installer/TBME_Setup_Elevated_Helper.ps1 | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/audit-r347-protected-broker-lease.yml b/.github/workflows/audit-r347-protected-broker-lease.yml index 79ad4cde..280d56d3 100644 --- a/.github/workflows/audit-r347-protected-broker-lease.yml +++ b/.github/workflows/audit-r347-protected-broker-lease.yml @@ -83,6 +83,8 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: R347-${{ matrix.host }}-${{ github.run_id }} - path: ${{ runner.temp }}/R347_*/R347_PINNED_DIRECTORY_LEASE.json + path: | + ${{ runner.temp }}/R347_*/R347_PINNED_DIRECTORY_LEASE.json + ${{ runner.temp }}/R347_*/OTHER_ADMIN_RENAME_RECEIPT.json retention-days: 7 if-no-files-found: warn diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index ee1ea79b..1a151771 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -232,7 +232,10 @@ public sealed class TBMEScopedUserLogLease : IDisposable { // R347: protected directory ancestor leases prohibit concurrent renames. public sealed class TBMEProtectedBrokerLease : IDisposable { - const uint FILE_READ_ATTRIBUTES=0x00000080; + // FILE_READ_ATTRIBUTES is exempt from CreateFile share-mode checks; + // request actual directory listing access so no FILE_SHARE_DELETE + // blocks rename/delete by a concurrent privileged process. + const uint FILE_LIST_DIRECTORY=0x00000001; const uint SHARE_READ=1, SHARE_WRITE=2, OPEN_EXISTING=3; const uint BACKUP_SEMANTICS=0x02000000, OPEN_REPARSE_POINT=0x00200000; const uint ATTR_DIRECTORY=0x10, ATTR_REPARSE=0x400; @@ -249,7 +252,7 @@ public sealed class TBMEProtectedBrokerLease : IDisposable { bool disposed; static SafeFileHandle OpenExactDir(string path) { - SafeFileHandle h=OpenNative(path,FILE_READ_ATTRIBUTES, + SafeFileHandle h=OpenNative(path,FILE_LIST_DIRECTORY, SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING, BACKUP_SEMANTICS|OPEN_REPARSE_POINT,IntPtr.Zero); if(h.IsInvalid) { From edf7e0901fabdc44473bda23a6db73eef29a38b2 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 15:23:39 +0330 Subject: [PATCH 123/135] test(r347): unwrap only actual PS5.1 native rename-sharing denials (F02) --- build/Test-R347-OtherAdminRename-Actor.ps1 | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/build/Test-R347-OtherAdminRename-Actor.ps1 b/build/Test-R347-OtherAdminRename-Actor.ps1 index 67bfcae7..602ff48e 100644 --- a/build/Test-R347-OtherAdminRename-Actor.ps1 +++ b/build/Test-R347-OtherAdminRename-Actor.ps1 @@ -36,9 +36,19 @@ function TryRename([string]$which,[string]$old,[string]$dest){ throw ('R347_UNEXPECTED_PRIVILEGED_DIRECTORY_RENAME_SUCCEEDED_'+$which) }catch{ if($_.Exception.Message.StartsWith('R347_UNEXPECTED_',[StringComparison]::Ordinal)){throw} + # .NET static Directory.Move under Windows PowerShell 5.1 wraps the + # underlying Win32 IOException in MethodInvocationException. Only unwrap + # a proven sharing/access denial; reject all unrelated native failures. $e=$_.Exception - if(-not ($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException])){ - throw ('R347_BAD_PRIVILEGED_RENAME_FAILURE_TYPE_'+$which+' '+$e.GetType().Name) + while($e -is [Management.Automation.MethodInvocationException] -and $e.InnerException){ + $e=$e.InnerException + } + $allowedHResult=($e.HResult -in @(-2147024864,-2147024891,-2147024863)) + $allowedMessage=($e.Message -match '(?i)sharing violation|being used by another process|access (is )?denied') + if(-not (($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException]) -and + ($allowedHResult -or $allowedMessage))){ + throw ('R347_UNEXPECTED_RENAME_FAILURE_CAUSE_'+$which+ + '_'+$e.GetType().Name+'_HRESULT_'+$e.HResult+'_MSG_'+$e.Message) } if([IO.Directory]::Exists($dest) -or -not [IO.Directory]::Exists($old)){ throw ('R347_PROTECTED_PATH_CHANGED_DURING_DENIAL_'+$which) From 2e246134ddf5a3efcfa66900d6d97fc19167643f Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 15:26:29 +0330 Subject: [PATCH 124/135] test(r347): unwrap original .NET junction refusal from PS5.1 invocation (F03) --- build/Test-R347-PrivilegedDirectoryLease-Native.ps1 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 b/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 index c426fcda..2b2207d9 100644 --- a/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 +++ b/build/Test-R347-PrivilegedDirectoryLease-Native.ps1 @@ -117,7 +117,15 @@ try{ $linked=$true $message='' try{$attempt=[TBMEProtectedBrokerLease]::new($broker);try{throw 'R347_UNSAFE_LINK_ACCEPTED'}finally{$attempt.Dispose()}} - catch{$message=$_.Exception.Message} + catch{ + $cause=$_.Exception + while($cause.InnerException -and + ($cause -is [Management.Automation.MethodInvocationException] -or + $cause -is [Reflection.TargetInvocationException])){ + $cause=$cause.InnerException + } + $message=$cause.Message + } if($message -cne 'SENSOR_BROKER_DIRECTORY_LEASE_REPARSE_REFUSED'){throw ('R347_JUNCTION_REJECTION_WRONG_'+$message)} $rejectLink=$true if((Hash $outsideCanary) -cne $canarySha){throw 'R347_OUTSIDE_CANARY_CHANGED'} From 688ae532e9101a2b6ce45edebbc3499ded80b24a Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 15:55:22 +0330 Subject: [PATCH 125/135] test(r348): witness privileged leaf overwrite despite protected directory leases --- .../audit-r348-privileged-leaf-negative.yml | 90 +++++++++ build/Test-R348-PrivilegedLeaf-Actor.ps1 | 63 ++++++ build/Test-R348-PrivilegedLeaf-Native.ps1 | 179 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 .github/workflows/audit-r348-privileged-leaf-negative.yml create mode 100644 build/Test-R348-PrivilegedLeaf-Actor.ps1 create mode 100644 build/Test-R348-PrivilegedLeaf-Native.ps1 diff --git a/.github/workflows/audit-r348-privileged-leaf-negative.yml b/.github/workflows/audit-r348-privileged-leaf-negative.yml new file mode 100644 index 00000000..cdbcf727 --- /dev/null +++ b/.github/workflows/audit-r348-privileged-leaf-negative.yml @@ -0,0 +1,90 @@ +name: R348 privileged leaf overwrite negative characterization (disposable Windows) +on: + push: + branches: [audit/r348-privileged-leaf-race-negative] + paths: + - installer/TBME_Setup_Elevated_Helper.ps1 + - build/Test-R348-PrivilegedLeaf-Native.ps1 + - build/Test-R348-PrivilegedLeaf-Actor.ps1 + - .github/workflows/audit-r348-privileged-leaf-negative.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + privileged-leaf-negative-characterization: + name: R348 privileged leaf overwrite ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact candidate without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Enforce disposable already-admin Windows and no preexisting product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r348-privileged-leaf-race-negative' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R348_HOST_OR_COMMIT_NOT_DISPOSABLE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $root) -or + (Test-Path -LiteralPath (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}).Count){ + throw 'R348_HOST_ALREADY_HAS_REAL_PRODUCT' + } + $isAdmin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(-not $isAdmin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R348_NO_EXISTING_ADMIN_TOKEN' + } + 'R348_DISPOSABLE_NO_PRODUCT_PRECONDITIONS_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build all four original Framework projects with embedded lease + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R348_COMPILED_ORIGINAL_SETUP_MISSING' + } + 'R348_EXACT_COMPILED_PRODUCT_BUILD_PASS' + - name: Observe Administrator child leaf overwrite while original directory handles pinned + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path build/Test-R348-PrivilegedLeaf-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R348_NEGATIVE_LEAF_DIAGNOSTIC_NOT_COMPLETED'} + - name: Retain raw typed privileged Windows proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R348-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R348_*/R348_PRIVILEGED_LEAF_NEGATIVE.json + ${{ runner.temp }}/R348_*/R348_ACTOR_RECEIPT.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R348-PrivilegedLeaf-Actor.ps1 b/build/Test-R348-PrivilegedLeaf-Actor.ps1 new file mode 100644 index 00000000..99858311 --- /dev/null +++ b/build/Test-R348-PrivilegedLeaf-Actor.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$AuthorityPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r348-privileged-leaf-race-negative' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R348_CHILD_ONLY_DISPOSABLE_HOSTED_WINDOWS' +} +$admin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R348_CHILD_ALREADY_ADMIN_REQUIRED_NO_ELEVATION_ATTEMPT' +} +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +$authority=[IO.Path]::GetFullPath($AuthorityPath) +$receipt=[IO.Path]::GetFullPath($ReceiptPath) +if(-not $authority.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or + -not $receipt.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R348_CHILD_OUTPUT_NOT_EXACT_DISPOSABLE_TEMP' +} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$leaf=Join-Path $pf 'TaskbarMonitorEnhanced\SensorBroker\R348_KNOWN_LEAF.txt' +$a=Get-Content -LiteralPath $authority -Raw|ConvertFrom-Json +if($a.Schema -ne 1 -or $a.Revision -cne 'R348' -or + $a.SourceCommit -cne $env:GITHUB_SHA -or + $a.Guest -cne $env:COMPUTERNAME -or + $a.ExpectedLeafPath -cne $leaf -or + $a.OriginalSha256 -cnotmatch '^[0-9A-F]{64}$' -or + [string]::IsNullOrWhiteSpace([string]$a.Payload) -or + $a.Payload.Length -gt 128 -or + $a.Payload -cnotmatch '^R348_ACTOR_WRITE_[0-9]+_[AB]$'){ + throw 'R348_CHILD_AUTHORITY_NOT_BOUND_TO_GUEST_SOURCE_LEAF' +} +if(-not [IO.File]::Exists($leaf) -or + (Get-FileHash -LiteralPath $leaf -Algorithm SHA256).Hash -cne $a.OriginalSha256){ + throw 'R348_CHILD_EXACT_OWNER_LEAF_OR_OLD_HASH_WRONG' +} +if([IO.File]::Exists($receipt)){throw 'R348_CHILD_RECEIPT_ALREADY_EXISTS'} +$failure='';$writeSucceeded=$false +try{ + [IO.File]::WriteAllText($leaf,[string]$a.Payload,[Text.UTF8Encoding]::new($false)) + $writeSucceeded=[IO.File]::ReadAllText($leaf) -ceq [string]$a.Payload + if(-not $writeSucceeded){throw 'R348_CHILD_LEAF_WRITE_READBACK_UNEXPECTED'} +}catch{$failure=$_.Exception.GetType().Name+': '+$_.Exception.Message} +$status=if(!$failure -and $writeSucceeded){'OBSERVED_PRIVILEGED_LEAF_WRITE_WHILE_DIRECTORY_LEASE_HELD'}else{'NO_LEAF_WRITE_OBSERVED_OR_ACTOR_FAILED'} +$out=[ordered]@{ + Schema=1;Revision='R348';SourceCommit=$env:GITHUB_SHA;Status=$status + Guest=$env:COMPUTERNAME;ActorPid=$PID;ActorAlreadyAdmin=$true + OriginalSHA256=$a.OriginalSha256;LeafWriteSucceeded=$writeSucceeded + AfterSHA256=$(if([IO.File]::Exists($leaf)){(Get-FileHash $leaf -Algorithm SHA256).Hash}else{'MISSING'}) + OwnerComputerTouched=$false;OriginalHelperStarted=$false + Failure=$failure +} +[IO.File]::WriteAllText($receipt,($out|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R348_CHILD_STATUS='+$status +if($failure){throw ('R348_CHILD_TEST_OPERATION_FAILED_'+$failure)} diff --git a/build/Test-R348-PrivilegedLeaf-Native.ps1 b/build/Test-R348-PrivilegedLeaf-Native.ps1 new file mode 100644 index 00000000..b496ec08 --- /dev/null +++ b/build/Test-R348-PrivilegedLeaf-Native.ps1 @@ -0,0 +1,179 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r348-privileged-leaf-race-negative' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R348_GUEST_ONLY_HOSTED_EXACT_REVISION' +} +$who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R348_REQUIRE_EXISTING_ADMIN_NO_UAC_BYPASS' +} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$product=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $product 'SensorBroker' +$leaf=Join-Path $broker 'R348_KNOWN_LEAF.txt' +$runRoot=Join-Path $env:RUNNER_TEMP ('R348_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$authority=Join-Path $runRoot 'R348_AUTHORITY.json' +$actorReceipt=Join-Path $runRoot 'R348_ACTOR_RECEIPT.json' +$receipt=Join-Path $runRoot 'R348_PRIVILEGED_LEAF_NEGATIVE.json' +$outside=Join-Path $runRoot 'OUTSIDE_CANARY.txt' +$source=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$actorFile=Join-Path $SourceRoot 'build\Test-R348-PrivilegedLeaf-Actor.ps1' +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$oldText='R348_BASELINE_'+$env:GITHUB_RUN_ID+'_'+$HostLabel +$newText='R348_ACTOR_WRITE_'+$env:GITHUB_RUN_ID+'_'+$HostLabel +$lease=$null;$child=$null;$protectedOwned=$false +$checks=[Collections.Generic.List[string]]::new() +$failure='';$observedGap=$false;$clean=$false;$childPid=0;$oldSha='';$newSha='';$actorSha='';$canarySha='' +function Hash([string]$f){return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash} +function Pass([string]$msg){$script:checks.Add($msg);Write-Output ('PASS '+$msg)} +try{ + if([IO.Directory]::Exists($product) -or [IO.Directory]::Exists($runRoot) -or + @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count){ + throw 'R348_EXISTING_PRODUCT_OR_GUEST_TEST_ROOT' + } + foreach($f in @($source,$setup,$actorFile,$native)){ + if(-not [IO.File]::Exists($f)){throw 'R348_REVIEW_SOURCE_FILE_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $stream=$assembly.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R348_COMPILED_ORIGINAL_HELPER_NOT_PRESENT'} + $reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $norm={param($v) $v.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $norm $embedded) -cne (& $norm ([IO.File]::ReadAllText($source)))){ + throw 'R348_COMPILED_PRODUCT_NOT_IDENTICAL_TO_HELPER_SOURCE' + } + $loc=$embedded.IndexOf('public sealed class TBMEProtectedBrokerLease',[StringComparison]::Ordinal) + $start=$embedded.LastIndexOf("Add-Type -TypeDefinition @'",$loc,[StringComparison]::Ordinal) + $first=$embedded.IndexOf([string][char]10,$start,[StringComparison]::Ordinal) + $last=$embedded.IndexOf(([string][char]10+"'@"),$loc,[StringComparison]::Ordinal) + if($loc -lt 0 -or $start -lt 0 -or $first -lt 0 -or $last -le $loc -or $last-$first -gt 14000){ + throw 'R348_COMPILED_LEASE_EXTRACTION_BOUNDS' + } + Add-Type -TypeDefinition $embedded.Substring($first+1,$last-$first-1) -ErrorAction Stop + if($null -eq ('TBMEProtectedBrokerLease' -as [type])){throw 'R348_COMPILED_LEASE_TYPE_NOT_AVAILABLE'} + Pass 'REAL_ORIGINAL_SETUP_PE_EMBEDDED_DIRECTORY_LEASE_CLASS' + [void][IO.Directory]::CreateDirectory($runRoot) + [IO.File]::WriteAllText($outside,'R348_OUTSIDE_CANARY',[Text.UTF8Encoding]::new($false)) + $canarySha=Hash $outside + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $sid,$rights,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($product,$acl) + $protectedOwned=$true + [void][IO.Directory]::CreateDirectory($broker) + [IO.File]::WriteAllText($leaf,$oldText,[Text.UTF8Encoding]::new($false)) + $oldSha=Hash $leaf + Pass 'TEST_ONLY_ADMIN_OWNED_PROTECTED_ROOT_EXACT_LEAF_CREATED' + $lease=[TBMEProtectedBrokerLease]::new($broker) + if(-not $lease.IsPinned){throw 'R348_EXPECT_THREE_OPEN_PROGRAMFILES_DIRECTORY_HANDLES'} + Pass 'REAL_R347_DIRECTORY_LEASE_HELD_ACROSS_OTHER_ADMIN_WRITE' + $auth=[ordered]@{ + Schema=1;Revision='R348';SourceCommit=$env:GITHUB_SHA;Guest=$env:COMPUTERNAME + ExpectedLeafPath=$leaf;OriginalSHA256=$oldSha;Payload=$newText + } + [IO.File]::WriteAllText($authority,($auth|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorFile+'" -AuthorityPath "'+$authority+'" -ReceiptPath "'+$actorReceipt+'"' + $psi.UseShellExecute=$false;$psi.CreateNoWindow=$true + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R348_DIFFERENT_ADMIN_CHILD_NOT_STARTED'} + $childPid=$child.Id + if(-not $child.WaitForExit(18000)){throw 'R348_DIFFERENT_ADMIN_CHILD_TIMEOUT'} + if($child.ExitCode -ne 0){throw ('R348_DIFFERENT_ADMIN_CHILD_FAILED_EXIT_'+$child.ExitCode)} + if(-not [IO.File]::Exists($actorReceipt)){throw 'R348_CHILD_PROOF_NOT_FOUND'} + $typed=Get-Content $actorReceipt -Raw|ConvertFrom-Json + $actorSha=Hash $actorReceipt + $newSha=Hash $leaf + if($typed.SourceCommit -cne $env:GITHUB_SHA -or + $typed.Revision -cne 'R348' -or + $typed.ActorPid -ne $childPid -or + $typed.Status -cne 'OBSERVED_PRIVILEGED_LEAF_WRITE_WHILE_DIRECTORY_LEASE_HELD' -or + $typed.LeafWriteSucceeded -ne $true -or + $typed.OriginalSHA256 -cne $oldSha -or + $typed.AfterSHA256 -cne $newSha -or + [IO.File]::ReadAllText($leaf) -cne $newText -or + -not $lease.IsPinned -or + $newSha -ceq $oldSha){ + throw 'R348_REAL_PRIVILEGED_LEAF_WRITE_GAP_NOT_WITNESSED' + } + $observedGap=$true + Pass 'DIFFERENT_ADMIN_PID_CHANGED_LEAF_WHILE_DIRECTORY_HANDLE_STILL_PINNED' + if(-not [IO.Directory]::Exists($broker) -or + -not [IO.Directory]::Exists($product) -or + ([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R348_DIRECTORY_IDENTITY_NO_LONGER_STABLE' + } + Pass 'DIRECTORY_ANCESTRY_STABLE_WHILE_LEAF_BYTES_CHANGED' + if((Hash $outside) -cne $canarySha){throw 'R348_OUTSIDE_CANARY_MODIFIED'} + Pass 'OUTSIDE_CANARY_UNCHANGED_BY_LEAF_WRITE' +}catch{$failure=$_.Exception.ToString()} +finally{ + if($child){ + try{if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(5000)}}catch{} + $child.Dispose() + } + if($lease){$lease.Dispose()} + if($protectedOwned -and [IO.Directory]::Exists($product)){ + try{ + if(([IO.File]::GetAttributes($product) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [IO.Directory]::Exists($broker) -or + ([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @([IO.Directory]::GetFileSystemEntries($product)).Count -ne 1 -or + @([IO.Directory]::GetFileSystemEntries($broker)).Count -ne 1 -or + -not [IO.File]::Exists($leaf)){ + throw 'R348_UNEXPECTED_PROTECTED_ROOT_MEMBER' + } + $actual=[IO.File]::ReadAllText($leaf) + if($actual -cne $newText -and $actual -cne $oldText){ + throw 'R348_PROTECTED_TEST_LEAF_UNKNOWN_BYTES_PRESERVED' + } + [IO.File]::Delete($leaf) + [IO.Directory]::Delete($broker,$false) + [IO.Directory]::Delete($product,$false) + $clean=(-not [IO.Directory]::Exists($product)) + }catch{$failure+=' TEST_ONLY_CLEANUP_REFUSED_'+$_.Exception.Message} + } + $status=if(-not $failure -and $checks.Count -eq 6 -and $observedGap -and $clean){ + 'NEGATIVE_CONFIRMED_DIRECTORY_LEASE_DOES_NOT_PROTECT_PRIVILEGED_LEAF_OVERWRITE' + }else{'FAIL_R348_DIAGNOSTIC_INCONCLUSIVE'} + if([IO.Directory]::Exists($runRoot)){ + [IO.File]::WriteAllText($receipt,([ordered]@{ + Schema=1;Revision='R348';Status=$status;SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + RealExactPEEmbeddedLeaseLoaded=$true;AdminActorPid=$childPid;ActorReceiptSHA256=$actorSha + DirectoryHandlesStayedPinned=$observedGap;PrivilegedLeafWriteWasAllowed=$observedGap + LeafSHA256Before=$oldSha;LeafSHA256After=$newSha + OutsideCanarySHA256=$canarySha;TemporaryProtectedFixtureRemoved=$clean + UserInstallationOrGlassTouched=$false;RealUACPromptTested=$false + SecurityGatePassed=$false;OriginalHelperExecuted=$false + Checks=@($checks);Failure=$failure + }|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Write-Output ('R348_RAW_NEGATIVE_PROOF='+$receipt) + } + Write-Output ('R348_DIAGNOSTIC_STATUS='+$status+' CHECKS='+$checks.Count) +} +if($failure){throw ('R348_NEGATIVE_LEAF_GAP_TEST_FAILED_'+$failure)} From 70720eed7cc7bd9ef18bf8d8e98764539da6381e Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 16:30:28 +0330 Subject: [PATCH 126/135] fix(sensor): use no-reparse per-leaf native locks during authenticated PREPARED replay (R349) --- .../audit-r349-original-helper-regression.yml | 118 +++++ .../audit-r349-privileged-leaf-native.yml | 90 ++++ ...0-OriginalHelperActualUninstall-Native.ps1 | 2 +- ...-R344-JournalAcl-MutationRights-Native.ps1 | 2 +- ...est-R344-UnownedSensorNoJournal-Native.ps1 | 2 +- build/Test-R349-PrivilegedLeaf-Actor.ps1 | 67 +++ build/Test-R349-PrivilegedLeaf-Native.ps1 | 232 +++++++++ installer/TBME_Setup_Elevated_Helper.ps1 | 474 +++++++++++------- 8 files changed, 810 insertions(+), 177 deletions(-) create mode 100644 .github/workflows/audit-r349-original-helper-regression.yml create mode 100644 .github/workflows/audit-r349-privileged-leaf-native.yml create mode 100644 build/Test-R349-PrivilegedLeaf-Actor.ps1 create mode 100644 build/Test-R349-PrivilegedLeaf-Native.ps1 diff --git a/.github/workflows/audit-r349-original-helper-regression.yml b/.github/workflows/audit-r349-original-helper-regression.yml new file mode 100644 index 00000000..50dca4c3 --- /dev/null +++ b/.github/workflows/audit-r349-original-helper-regression.yml @@ -0,0 +1,118 @@ +name: R349 full original Helper regression against broker directory leases (Windows) +on: + push: + branches: [audit/r349-pinned-leaf-replay-handles] + paths: + - build/Test-R340-OriginalHelperActualUninstall-Native.ps1 + - build/Test-R344-JournalAcl-MutationRights-Native.ps1 + - build/Test-R344-UnownedSensorNoJournal-Native.ps1 + - installer/TBME_Setup_Elevated_Helper.ps1 + - .github/workflows/audit-r349-original-helper-regression.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + original-helper-uninstall: + name: R349 regression ${{ matrix.mode }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [nominal, killed, posttask, postfile] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact revision without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse any non-disposable host, prior product, task or wrong ref + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R340_REFUSE_WRONG_GUEST_OR_WRONG_SOURCE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $main=Join-Path $app 'TaskbarMonitorEnhanced.exe' + if((Test-Path -LiteralPath $root) -or (Test-Path -LiteralPath $main) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R340_ALREADY_INSTALLED_PRODUCT_OR_SENSOR_TASK_GUEST_REFUSED' + } + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R340_NOT_ALREADY_ADMIN_NEVER_DISABLE_UAC' + } + 'R340_DISPOSABLE_ADMIN_TOKEN_AND_NO_PRODUCT_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build exact reviewed product including production PE-embedded Helper + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R340_PRODUCT_EXACT_SETUP_NOT_COMPILED' + } + 'R340_ALL_FOUR_FRAMEWORK_PRODUCT_COMPONENTS_BUILD_SUCCESS' + - name: Validate real PE-embedded journal ACL reject/allow semantics + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-JournalAcl-MutationRights-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ACTUAL_ACL_BIT_NEGATIVE_FAILED'} + - name: Refuse unowned or foreign protected Broker without a real journal + if: matrix.mode == 'nominal' + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R344-UnownedSensorNoJournal-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R344_ORIGINAL_HELPER_UNOWNED_WITHOUT_JOURNAL_REFUSAL_FAILED'} + - name: Launch exact unmodified RunAs production Helper on owned disabled test task + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R340-OriginalHelperActualUninstall-Native.ps1).Path + $source=(Resolve-Path .).Path + $nativeArgs=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$script,'-HostLabel','${{ matrix.host }}','-SourceRoot',$source) + if('${{ matrix.mode }}' -ceq 'killed'){$nativeArgs+='-KillBeforeMutation'} + if('${{ matrix.mode }}' -ceq 'posttask'){$nativeArgs+='-KillAfterTaskRemoval'} + if('${{ matrix.mode }}' -ceq 'postfile'){$nativeArgs+='-KillAfterBrokerRemoval'} + & $native @nativeArgs + if($LASTEXITCODE -ne 0){ + throw 'R340_ORIGINAL_ELEVATED_HELPER_PROTECTED_UNINSTALL_FAILED' + } + - name: Retain typed exact original Helper execution proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R349-regression-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R340_*/R340_REAL_PROTECTED_UNINSTALL_HELPER.json + ${{ runner.temp }}/R344Acl_*/R344_JOURNAL_ACL_MASK.json + ${{ runner.temp }}/R344Neg_*/R344_UNOWNED_FOREIGN_WITHOUT_JOURNAL.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r349-privileged-leaf-native.yml b/.github/workflows/audit-r349-privileged-leaf-native.yml new file mode 100644 index 00000000..2fb9e8d6 --- /dev/null +++ b/.github/workflows/audit-r349-privileged-leaf-native.yml @@ -0,0 +1,90 @@ +name: R349 privileged Broker leaf overwrite and preopened DELETE denial (disposable Windows) +on: + push: + branches: [audit/r349-pinned-leaf-replay-handles] + paths: + - installer/TBME_Setup_Elevated_Helper.ps1 + - build/Test-R349-PrivilegedLeaf-Native.ps1 + - build/Test-R349-PrivilegedLeaf-Actor.ps1 + - .github/workflows/audit-r349-privileged-leaf-native.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + privileged-leaf-negative-characterization: + name: R349 protected leaf lock ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact candidate without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Enforce disposable already-admin Windows and no preexisting product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R349_HOST_OR_COMMIT_NOT_DISPOSABLE' + } + $root=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $root) -or + (Test-Path -LiteralPath (Join-Path $app 'TaskbarMonitorEnhanced.exe')) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue | Where-Object {$null -ne $_}).Count){ + throw 'R349_HOST_ALREADY_HAS_REAL_PRODUCT' + } + $isAdmin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(-not $isAdmin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R349_NO_EXISTING_ADMIN_TOKEN' + } + 'R349_DISPOSABLE_NO_PRODUCT_PRECONDITIONS_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build all four original Framework projects with embedded lease + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R349_COMPILED_ORIGINAL_SETUP_MISSING' + } + 'R349_EXACT_COMPILED_PRODUCT_BUILD_PASS' + - name: Verify second Administrator leaf overwrite and prior DELETE handles blocked + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $test=(Resolve-Path build/Test-R349-PrivilegedLeaf-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $test -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R349_NATIVE_LEAF_SHARING_ACCEPTANCE_NOT_PASSED'} + - name: Retain raw typed privileged Windows proof + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R349-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R349_*/R349_PINNED_LEAF_NATIVE.json + ${{ runner.temp }}/R349_*/R349_ACTOR_RECEIPT.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 index 34ae8eba..0a29b737 100644 --- a/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 +++ b/build/Test-R340-OriginalHelperActualUninstall-Native.ps1 @@ -21,7 +21,7 @@ if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or - $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or diff --git a/build/Test-R344-JournalAcl-MutationRights-Native.ps1 b/build/Test-R344-JournalAcl-MutationRights-Native.ps1 index 2ab9a98c..34074108 100644 --- a/build/Test-R344-JournalAcl-MutationRights-Native.ps1 +++ b/build/Test-R344-JournalAcl-MutationRights-Native.ps1 @@ -10,7 +10,7 @@ if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or - $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$'){ diff --git a/build/Test-R344-UnownedSensorNoJournal-Native.ps1 b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 index e91d4b71..507eac8f 100644 --- a/build/Test-R344-UnownedSensorNoJournal-Native.ps1 +++ b/build/Test-R344-UnownedSensorNoJournal-Native.ps1 @@ -12,7 +12,7 @@ if($PSVersionTable.PSEdition -cne 'Desktop' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or - $env:GITHUB_REF -cne 'refs/heads/audit/r347-protected-broker-handle-lease' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or $env:GITHUB_RUN_ID -cnotmatch '^\d{5,20}$' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or diff --git a/build/Test-R349-PrivilegedLeaf-Actor.ps1 b/build/Test-R349-PrivilegedLeaf-Actor.ps1 new file mode 100644 index 00000000..4eab68d0 --- /dev/null +++ b/build/Test-R349-PrivilegedLeaf-Actor.ps1 @@ -0,0 +1,67 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$AuthorityPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R349_REFUSE_NONDISPOSABLE_WINDOWS' +} +$who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'R349_CHILD_ALREADY_ADMIN_REQUIRED'} +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +$auth=[IO.Path]::GetFullPath($AuthorityPath) +$receipt=[IO.Path]::GetFullPath($ReceiptPath) +if(-not $auth.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or + -not $receipt.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or + [IO.File]::Exists($receipt)){ + throw 'R349_ACTOR_AUTHORITY_OUTSIDE_TEMP_OR_RECEIPT_EXISTS' +} +$a=Get-Content -LiteralPath $auth -Raw|ConvertFrom-Json +$leaf=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) 'TaskbarMonitorEnhanced\SensorBroker\R349_KNOWN_LEAF.txt' +if($a.Schema -ne 1 -or $a.Revision -cne 'R349' -or $a.SourceCommit -cne $env:GITHUB_SHA -or + $a.Guest -cne $env:COMPUTERNAME -or $a.ExpectedLeafPath -cne $leaf -or + $a.Payload -cnotmatch '^R349_PRIVILEGED_WRITE_[0-9]+_[AB]$' -or + $a.OriginalSha256 -cnotmatch '^[0-9A-F]{64}$'){ + throw 'R349_ACTOR_AUTHORITY_NOT_EXACT' +} +# The parent owns a READ/WRITE file handle with only FILE_SHARE_READ; +# a separate path-based Get-FileHash may itself be refused because its +# FileShare mode does not permit the parent's WRITE access. The original SHA +# was already established before pinning and passed in typed guest authority. +if(-not [IO.File]::Exists($leaf)){ + throw 'R349_EXPECTED_EXACT_TEST_LEAF_ABSENT' +} +$writeDenied=$false;$why='';$unexpected='' +try{ + [IO.File]::WriteAllText($leaf,[string]$a.Payload,[Text.UTF8Encoding]::new($false)) + $unexpected='R349_PRIVILEGED_LEAF_OVERWRITE_SUCCEEDED_WHILE_PINNED' +}catch{ + $e=$_.Exception + while($e.InnerException -and + ($e -is [Management.Automation.MethodInvocationException] -or + $e -is [Reflection.TargetInvocationException])){$e=$e.InnerException} + $why=$e.GetType().Name+' HRESULT='+$e.HResult + $sharing=$e.HResult -in @(-2147024864,-2147024891) + if(($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException]) -and $sharing){ + $writeDenied=$true + }else{$unexpected='R349_WRONG_FAILURE_CAUSE_'+$why} +} +$status=if($writeDenied -and !$unexpected){ + 'PASS_PRIVILEGED_WRITE_OPEN_REFUSED_BY_PINNED_LEAF' +}else{'FAIL_R349_LEAF_WRITE_NOT_EXCLUSIVELY_PINNED'} +$out=[ordered]@{ + Schema=1;Revision='R349';Status=$status;SourceCommit=$env:GITHUB_SHA + ActorPid=$PID;Guest=$env:COMPUTERNAME;AlreadyAdmin=$true + WriteDeniedByRealWindowsSharing=$writeDenied;NativeError=$why + OriginalSHA256=$a.OriginalSha256 + # Cannot reopen the leaf for reading while it is pinned. Parent checks + # SHA from its already-open handle, then releases the lease. + OwnerComputerTouched=$false;Failure=$unexpected +} +[IO.File]::WriteAllText($receipt,($out|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) +'R349_CHILD_RESULT='+$status +if($unexpected){throw $unexpected} diff --git a/build/Test-R349-PrivilegedLeaf-Native.ps1 b/build/Test-R349-PrivilegedLeaf-Native.ps1 new file mode 100644 index 00000000..6b038394 --- /dev/null +++ b/build/Test-R349-PrivilegedLeaf-Native.ps1 @@ -0,0 +1,232 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r349-pinned-leaf-replay-handles' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R349_REFUSE_NONDISPOSABLE_OR_WRONG_GIT_SOURCE' +} +$admin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R349_GUEST_ALREADY_ADMIN_REQUIRED_NO_ELEVATION' +} +$pf=[Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) +$protected=Join-Path $pf 'TaskbarMonitorEnhanced' +$broker=Join-Path $protected 'SensorBroker' +$leafName='R349_KNOWN_LEAF.txt' +$leaf=Join-Path $broker $leafName +$root=Join-Path $env:RUNNER_TEMP ('R349_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $root 'R349_PINNED_LEAF_NATIVE.json' +$actorReceipt=Join-Path $root 'R349_ACTOR_RECEIPT.json' +$authority=Join-Path $root 'R349_AUTHORITY.json' +$canary=Join-Path $root 'OUTSIDE_CANARY.txt' +$source=Join-Path $SourceRoot 'installer\TBME_Setup_Elevated_Helper.ps1' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$actorFile=Join-Path $SourceRoot 'build\Test-R349-PrivilegedLeaf-Actor.ps1' +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$oldText='R349_ORIGINAL_BYTES_'+$env:GITHUB_RUN_ID+'_'+$HostLabel +$newText='R349_PRIVILEGED_WRITE_'+$env:GITHUB_RUN_ID+'_'+$HostLabel +$lease=$null;$child=$null;$priorDelete=$null;$secondLease=$null +$created=$false;$cleanupOk=$false;$failure='';$actorPid=0 +$checks=[Collections.Generic.List[string]]::new() +$beforeHash='';$afterHash='';$actorHash='';$canaryHash='' +$denied=$false;$preopenDeleteRefused=$false;$postReleaseWriteAllowed=$false +function Hash([string]$file){(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Cause([Exception]$error){ + while($error.InnerException -and + ($error -is [Management.Automation.MethodInvocationException] -or + $error -is [Reflection.TargetInvocationException])){$error=$error.InnerException} + return $error +} +function Sharing([Exception]$error){ + $e=Cause $error + return ($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException]) -and + ($e.HResult -in @(-2147024864,-2147024891)) +} +try{ + if([IO.Directory]::Exists($protected) -or [IO.Directory]::Exists($root) -or + @(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count){ + throw 'R349_PREEXISTING_PROGRAMFILES_PRODUCT_OR_FIXTURE_COLLISION' + } + foreach($f in @($source,$setup,$actorFile,$native)){if(![IO.File]::Exists($f)){throw 'R349_SOURCE_PREREQUISITE_MISSING'}} + $asm=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $stream=$asm.GetManifestResourceStream('Payload.TBME_Setup_Elevated_Helper.ps1') + if(!$stream){throw 'R349_REAL_SETUP_PE_EMBEDDED_HELPER_MISSING'} + $reader=[IO.StreamReader]::new($stream,[Text.Encoding]::UTF8) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $normalize={param($x) $x.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize ([IO.File]::ReadAllText($source)))){ + throw 'R349_REAL_PE_EMBEDDED_HELPER_AND_SOURCE_DIFFER' + } + $at=$embedded.IndexOf('public sealed class TBMEProtectedBrokerLease',[StringComparison]::Ordinal) + $header=$embedded.LastIndexOf("Add-Type -TypeDefinition @'",$at,[StringComparison]::Ordinal) + $first=$embedded.IndexOf([string][char]10,$header,[StringComparison]::Ordinal) + $last=$embedded.IndexOf(([string][char]10+"'@"),$at,[StringComparison]::Ordinal) + if($at -lt 0 -or $header -lt 0 -or $first -lt 0 -or $last -le $at -or + $last-$first -gt 22000){throw 'R349_BOUND_REAL_CSHARP_EXTRACTION_REFUSED'} + Add-Type -TypeDefinition $embedded.Substring($first+1,$last-$first-1) -ErrorAction Stop + if($null -eq ('TBMEProtectedBrokerLease' -as [type])){throw 'R349_REAL_PRODUCT_LEAF_LEASE_NOT_COMPILED'} + Check 'EXACT_ORIGINAL_SETUP_EMBEDS_NEW_NATIVE_LEAF_LOCK_CLASS' + [void][IO.Directory]::CreateDirectory($root) + [IO.File]::WriteAllText($canary,'R349_OUTSIDE_CANARY',[Text.UTF8Encoding]::new($false)) + $canaryHash=Hash $canary + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $users=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($admins);$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @($admins,$system,$users)){ + $rights=if($sid -eq $users){[Security.AccessControl.FileSystemRights]::ReadAndExecute}else{[Security.AccessControl.FileSystemRights]::FullControl} + [void]$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid,$rights, + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) + } + [void][IO.Directory]::CreateDirectory($protected,$acl);$created=$true + [void][IO.Directory]::CreateDirectory($broker) + [IO.File]::WriteAllText($leaf,$oldText,[Text.UTF8Encoding]::new($false)) + $beforeHash=Hash $leaf + if((Get-Acl $protected).Owner -notmatch '(?i)Administrators'){throw 'R349_PROTECTED_OWNER_NOT_ADMIN'} + Check 'FRESH_ADMIN_OWNED_PROGRAMFILES_AND_KNOWN_LEAF_SHA_CREATED' + $proof=[ordered]@{ + Schema=1;Revision='R349';SourceCommit=$env:GITHUB_SHA;Guest=$env:COMPUTERNAME + ExpectedLeafPath=$leaf;OriginalSHA256=$beforeHash;Payload=$newText + } + [IO.File]::WriteAllText($authority,($proof|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false)) + $lease=[TBMEProtectedBrokerLease]::new($broker) + $streamLeaf=$lease.OpenReplayLeaf($leafName) + if(-not $lease.IsPinned -or $null -eq $streamLeaf){throw 'R349_LEAF_NOT_PINNED'} + $bytes=[byte[]]$lease.ReadReplayLeaf($leafName) + $actual=[Text.Encoding]::UTF8.GetString($bytes) + if($actual -cne $oldText){throw 'R349_ORIGINAL_BYTES_CHANGED_BEFORE_CHILD'} + Check 'EXACT_PROTECTED_LEAF_HANDLE_PINNED_AND_OLD_BYTES_STABLE' + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorFile+'" -AuthorityPath "'+$authority+'" -ReceiptPath "'+$actorReceipt+'"' + $psi.CreateNoWindow=$true;$psi.UseShellExecute=$false + $child=[Diagnostics.Process]::Start($psi) + if(!$child){throw 'R349_ACTOR_PROCESS_NOT_CREATED'} + $actorPid=$child.Id + if(-not $child.WaitForExit(23000)){throw 'R349_ACTOR_PROCESS_TIMEOUT'} + if($child.ExitCode -ne 0){throw ('R349_ACTOR_PROCESS_NONZERO_'+$child.ExitCode)} + if(-not [IO.File]::Exists($actorReceipt)){throw 'R349_CHILD_TYPED_RECEIPT_NOT_WRITTEN'} + $a=Get-Content -LiteralPath $actorReceipt -Raw|ConvertFrom-Json + $actorHash=Hash $actorReceipt + if($a.Status -cne 'PASS_PRIVILEGED_WRITE_OPEN_REFUSED_BY_PINNED_LEAF' -or + $a.ActorPid -ne $actorPid -or $a.SourceCommit -cne $env:GITHUB_SHA -or + $a.WriteDeniedByRealWindowsSharing -ne $true -or + $a.AlreadyAdmin -ne $true -or + ![string]::IsNullOrWhiteSpace([string]$a.Failure)){ + throw 'R349_SECOND_PRIVILEGED_PROCESS_DID_NOT_PROVE_SHARING_DENIAL' + } + $denied=$true + Check 'DISTINCT_ALREADY_ADMIN_PID_WRITE_OPEN_REJECTED_BY_REAL_NTFS_SHARING' + $bytes=[byte[]]$lease.ReadReplayLeaf($leafName) + if([Text.Encoding]::UTF8.GetString($bytes) -cne $oldText -or + !$lease.IsPinned -or [IO.Directory]::Exists($broker+'_renamed')){ + throw 'R349_PINNED_LEAF_OR_PARENT_CHANGED_DURING_OTHER_ADMIN_WRITE' + } + Check 'REAL_PRODUCTION_LEAF_HANDLE_READBACK_UNCHANGED_DURING_ATTACK' + $lease.Dispose();$lease=$null + # A preopened DELETE-capable handle with broad sharing must block a + # later exclusive leaf lease, even if the directory is pinned. This is + # a fail-closed precondition, not a successful recovery transaction. + Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class R349PreopenedDelete { + [DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] + static extern SafeFileHandle Open(string p,uint a,uint s,IntPtr x,uint c,uint f,IntPtr t); + public static SafeFileHandle Hold(string p) { + SafeFileHandle h=Open(p,0x00010000,7,IntPtr.Zero,3,0x00200000,IntPtr.Zero); + if(h.IsInvalid){int error=Marshal.GetLastWin32Error();h.Dispose();throw new IOException("R349_PREOPEN_DELETE_FAILED_WIN32_"+error);} + return h; + } +} +'@ + $priorDelete=[R349PreopenedDelete]::Hold($leaf) + $secondLease=[TBMEProtectedBrokerLease]::new($broker) + try{ + $null=$secondLease.OpenReplayLeaf($leafName) + throw 'R349_LEAF_LEASE_UNSAFELY_OPENED_WITH_PREEXISTING_DELETE_HANDLE' + }catch{ + if($_.Exception.Message -like 'R349_LEAF_LEASE_UNSAFELY*'){throw} + if(-not (Sharing $_.Exception)){ + throw ('R349_EXPECTED_SHARING_FAIL_FOR_PREOPENED_DELETE_'+(Cause $_.Exception).Message) + } + $preopenDeleteRefused=$true + } + $secondLease.Dispose();$secondLease=$null + $priorDelete.Dispose();$priorDelete=$null + Check 'REAL_PREOPENED_DELETE_ACCESS_HANDLE_BLOCKED_NEW_EXCLUSIVE_LEAF_LOCK' + [IO.File]::WriteAllText($leaf,$newText,[Text.UTF8Encoding]::new($false)) + $afterHash=Hash $leaf + if($afterHash -ceq $beforeHash -or [IO.File]::ReadAllText($leaf) -cne $newText){ + throw 'R349_OWNER_WRITE_AFTER_LEASE_RELEASE_NOT_POSSIBLE' + } + $postReleaseWriteAllowed=$true + Check 'WRITING_KNOWN_TEST_LEAF_ALLOWED_AFTER_EXCLUSIVE_HANDLE_RELEASE' + if((Hash $canary) -cne $canaryHash){throw 'R349_OUTSIDE_CANARY_CHANGED'} + Check 'OUTSIDE_CANARY_UNTOUCHED' +}catch{$failure=$_.Exception.ToString()} +finally{ + if($child){try{if(-not $child.HasExited){$child.Kill();[void]$child.WaitForExit(6000)}}catch{};$child.Dispose()} + if($lease){$lease.Dispose()} + if($secondLease){$secondLease.Dispose()} + if($priorDelete){$priorDelete.Dispose()} + if($created -and [IO.Directory]::Exists($protected)){ + try{ + if(([IO.File]::GetAttributes($protected) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [IO.Directory]::Exists($broker) -or + ([IO.File]::GetAttributes($broker) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @([IO.Directory]::GetFileSystemEntries($protected)).Count -ne 1 -or + @([IO.Directory]::GetFileSystemEntries($broker)).Count -ne 1 -or + -not [IO.File]::Exists($leaf)){ + throw 'R349_UNEXPECTED_PROTECTED_ROOT_OR_MEMBER_KEEP_FOR_GUEST' + } + $current=[IO.File]::ReadAllText($leaf) + if($current -cne $oldText -and $current -cne $newText){ + throw 'R349_UNEXPECTED_LEAF_BYTES_KEEP_FOR_GUEST' + } + [IO.File]::Delete($leaf) + [IO.Directory]::Delete($broker,$false) + [IO.Directory]::Delete($protected,$false) + $cleanupOk=(![IO.Directory]::Exists($protected)) + }catch{$failure+=' R349_PROTECTED_CLEANUP_REFUSED_'+$_.Exception.Message} + } + $status=if(!$failure -and $checks.Count -eq 8 -and $denied -and + $preopenDeleteRefused -and $postReleaseWriteAllowed -and $cleanupOk){ + 'PASS_R349_PRIVILEGED_LEAF_WRITE_AND_PREOPEN_DELETE_DENIED_BY_NATIVE_HANDLES' + }else{'FAIL_R349_NATIVE_LEAF_SHARING_ACCEPTANCE'} + if([IO.Directory]::Exists($root)){ + [IO.File]::WriteAllText($receipt,([ordered]@{ + Schema=1;Revision='R349';Status=$status;Host=$HostLabel;SourceCommit=$env:GITHUB_SHA + RealUnmodifiedSetupEmbeddedCSharp=$true;AlreadyAdminRunner=$true;DistinctOtherAdminPid=$actorPid + OriginalLeafSHA256=$beforeHash;AfterReleaseSHA256=$afterHash + OtherAdminWriteRefused=$denied;PreopenedDeleteHandleRefused=$preopenDeleteRefused + WriteAfterReleaseAllowed=$postReleaseWriteAllowed;ActualNativeLeaseHeld=$true + ChildReceiptSHA256=$actorHash;OutsideCanarySHA256=$canaryHash + CleanupOwnedProgramFilesFixture=$cleanupOk + RealInteractiveUacConsentTested=$false;OriginalFullHelperExecuted=$false + ActualFullJournalReplayWithConcurrentLeafWriterTested=$false + SecurityEntireProductAccepted=$false;OwnerInstallationChanged=$false + Checks=@($checks);Failure=$failure + }|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) + Write-Output ('R349_PINNED_LEAF_RECEIPT='+$receipt) + } + Write-Output ('R349_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count) +} +if($failure){throw ('R349_REPLAY_LEAF_GUARD_NOT_ACCEPTED '+$failure)} diff --git a/installer/TBME_Setup_Elevated_Helper.ps1 b/installer/TBME_Setup_Elevated_Helper.ps1 index 1a151771..a3a967c8 100644 --- a/installer/TBME_Setup_Elevated_Helper.ps1 +++ b/installer/TBME_Setup_Elevated_Helper.ps1 @@ -125,180 +125,279 @@ $validatedSensorSid=Assert-SensorPrivilegedBoundary $Mode $PayloadDir $AppRoot $ # points by handle, never create a user-writable script or change UAC policy. Add-Type -TypeDefinition @' using System; - using System.IO; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; - public sealed class TBMEScopedUserLogLease : IDisposable { - const uint FILE_LIST_DIRECTORY=0x0001; - const uint GENERIC_READ=0x80000000; - const uint GENERIC_WRITE=0x40000000; - const uint FILE_SHARE_READ=1,FILE_SHARE_WRITE=2; - const uint OPEN_EXISTING=3, OPEN_ALWAYS=4; - const uint FILE_FLAG_BACKUP_SEMANTICS=0x02000000; - const uint FILE_FLAG_OPEN_REPARSE_POINT=0x00200000; - const uint FILE_ATTRIBUTE_DIRECTORY=0x10; - const uint FILE_ATTRIBUTE_REPARSE_POINT=0x400; - [DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] - static extern SafeFileHandle OpenNative(string name,uint access,uint share,IntPtr sec,uint disposition,uint flags,IntPtr template); - [StructLayout(LayoutKind.Sequential)] - public struct TagInfo { public uint FileAttributes; public uint ReparseTag; } - [DllImport("kernel32.dll",SetLastError=true)] - static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind,out TagInfo data,uint size); - readonly SafeFileHandle[] guard=new SafeFileHandle[5]; - int guardCount=0; - FileStream stream; - bool disposed; - static SafeFileHandle OpenChecked(string path,uint access,uint share,uint disposition,uint flags,bool isDirectory){ - SafeFileHandle handle=OpenNative(path,access,share,IntPtr.Zero,disposition,flags,IntPtr.Zero); - if(handle.IsInvalid){ - int e=Marshal.GetLastWin32Error(); - handle.Dispose(); - throw new IOException("SENSOR_LOG_HANDLE_CREATEFILE_FAILED_WIN32_"+e+" "+Path.GetFileName(path)); - } - TagInfo info; - if(!GetFileInformationByHandleEx(handle,9,out info,(uint)Marshal.SizeOf(typeof(TagInfo)))){ - int e=Marshal.GetLastWin32Error(); - handle.Dispose(); - throw new IOException("SENSOR_LOG_HANDLE_ATTRIBUTE_TAG_QUERY_FAILED_WIN32_"+e); - } - if((info.FileAttributes&FILE_ATTRIBUTE_REPARSE_POINT)!=0 || - ((info.FileAttributes&FILE_ATTRIBUTE_DIRECTORY)!=0)!=isDirectory){ - handle.Dispose(); - throw new IOException("SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED"); - } - return handle; - } - public TBMEScopedUserLogLease(string profile,string appRoot,string logDir,string exactFile){ - if(String.IsNullOrWhiteSpace(profile) || String.IsNullOrWhiteSpace(appRoot) || - String.IsNullOrWhiteSpace(logDir) || String.IsNullOrWhiteSpace(exactFile)) - throw new IOException("SENSOR_LOG_HANDLE_EMPTY_LOG_BOUNDARY"); - string p=Path.GetFullPath(profile); - string appdata=Path.Combine(p,"AppData"); - string local=Path.Combine(appdata,"Local"); - string expected=Path.GetFullPath(Path.Combine(local,"TaskbarMonitorEnhanced")); - string logs=Path.GetFullPath(Path.Combine(expected,"Logs")); - string file=Path.GetFullPath(exactFile); - string log=Path.GetFullPath(Path.Combine(logs,"sensor_install.log")); - string result=Path.GetFullPath(Path.Combine(logs,"sensor_install_result.json")); - if(!String.Equals(Path.GetFullPath(appRoot),expected,StringComparison.OrdinalIgnoreCase) || - !String.Equals(Path.GetFullPath(logDir),logs,StringComparison.OrdinalIgnoreCase) || - !(String.Equals(file,log,StringComparison.OrdinalIgnoreCase) || - String.Equals(file,result,StringComparison.OrdinalIgnoreCase))){ - throw new IOException("SENSOR_LOG_HANDLE_CANONICAL_LOG_PATH_MISMATCH"); - } - try{ - foreach(string dir in new string[]{p,appdata,local,expected,logs}){ - SafeFileHandle parent=OpenChecked(dir,FILE_LIST_DIRECTORY,FILE_SHARE_READ|FILE_SHARE_WRITE, - OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OPEN_REPARSE_POINT,true); - guard[guardCount++]=parent; - } - SafeFileHandle h=OpenChecked(file,GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ, - OPEN_ALWAYS,FILE_FLAG_OPEN_REPARSE_POINT,false); - try{ - stream=new FileStream(h,FileAccess.ReadWrite); - stream.Seek(0,SeekOrigin.End); - }catch{h.Dispose();throw;} - }catch{Dispose();throw;} - } - public void Append(string text){ - if(disposed || stream==null)throw new ObjectDisposedException("TBMEScopedUserLogLease"); - if(stream.Length==0){ - byte[] bom=new System.Text.UTF8Encoding(true).GetPreamble(); - stream.Write(bom,0,bom.Length); - } - byte[] data=System.Text.Encoding.UTF8.GetBytes(text); - stream.Write(data,0,data.Length); - stream.Flush(true); - } - public void Overwrite(string text){ - if(disposed || stream==null)throw new ObjectDisposedException("TBMEScopedUserLogLease"); - stream.SetLength(0);stream.Position=0; - byte[] bom=new System.Text.UTF8Encoding(true).GetPreamble(); - stream.Write(bom,0,bom.Length); - byte[] data=System.Text.Encoding.UTF8.GetBytes(text); - stream.Write(data,0,data.Length); - stream.Flush(true); - } - public void Dispose(){ - if(disposed)return; - disposed=true; - if(stream!=null){stream.Dispose();stream=null;} - for(int i=guardCount-1;i>=0;i--)if(guard[i]!=null)guard[i].Dispose(); - guardCount=0; - } +const uint FILE_LIST_DIRECTORY=0x0001; +const uint GENERIC_READ=0x80000000; +const uint GENERIC_WRITE=0x40000000; +const uint FILE_SHARE_READ=1,FILE_SHARE_WRITE=2; +const uint OPEN_EXISTING=3, OPEN_ALWAYS=4; +const uint FILE_FLAG_BACKUP_SEMANTICS=0x02000000; +const uint FILE_FLAG_OPEN_REPARSE_POINT=0x00200000; +const uint FILE_ATTRIBUTE_DIRECTORY=0x10; +const uint FILE_ATTRIBUTE_REPARSE_POINT=0x400; +[DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] +static extern SafeFileHandle OpenNative(string name,uint access,uint share,IntPtr sec,uint disposition,uint flags,IntPtr template); +[StructLayout(LayoutKind.Sequential)] +public struct TagInfo { public uint FileAttributes; public uint ReparseTag; } +[DllImport("kernel32.dll",SetLastError=true)] +static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind,out TagInfo data,uint size); +readonly SafeFileHandle[] guard=new SafeFileHandle[5]; +int guardCount=0; +FileStream stream; +bool disposed; +static SafeFileHandle OpenChecked(string path,uint access,uint share,uint disposition,uint flags,bool isDirectory){ +SafeFileHandle handle=OpenNative(path,access,share,IntPtr.Zero,disposition,flags,IntPtr.Zero); +if(handle.IsInvalid){ +int e=Marshal.GetLastWin32Error(); +handle.Dispose(); +throw new IOException("SENSOR_LOG_HANDLE_CREATEFILE_FAILED_WIN32_"+e+" "+Path.GetFileName(path)); +} +TagInfo info; +if(!GetFileInformationByHandleEx(handle,9,out info,(uint)Marshal.SizeOf(typeof(TagInfo)))){ +int e=Marshal.GetLastWin32Error(); +handle.Dispose(); +throw new IOException("SENSOR_LOG_HANDLE_ATTRIBUTE_TAG_QUERY_FAILED_WIN32_"+e); +} +if((info.FileAttributes&FILE_ATTRIBUTE_REPARSE_POINT)!=0 || +((info.FileAttributes&FILE_ATTRIBUTE_DIRECTORY)!=0)!=isDirectory){ +handle.Dispose(); +throw new IOException("SENSOR_LOG_HANDLE_REPARSE_OR_COMPONENT_TYPE_REFUSED"); +} +return handle; +} +public TBMEScopedUserLogLease(string profile,string appRoot,string logDir,string exactFile){ +if(String.IsNullOrWhiteSpace(profile) || String.IsNullOrWhiteSpace(appRoot) || +String.IsNullOrWhiteSpace(logDir) || String.IsNullOrWhiteSpace(exactFile)) +throw new IOException("SENSOR_LOG_HANDLE_EMPTY_LOG_BOUNDARY"); +string p=Path.GetFullPath(profile); +string appdata=Path.Combine(p,"AppData"); +string local=Path.Combine(appdata,"Local"); +string expected=Path.GetFullPath(Path.Combine(local,"TaskbarMonitorEnhanced")); +string logs=Path.GetFullPath(Path.Combine(expected,"Logs")); +string file=Path.GetFullPath(exactFile); +string log=Path.GetFullPath(Path.Combine(logs,"sensor_install.log")); +string result=Path.GetFullPath(Path.Combine(logs,"sensor_install_result.json")); +if(!String.Equals(Path.GetFullPath(appRoot),expected,StringComparison.OrdinalIgnoreCase) || +!String.Equals(Path.GetFullPath(logDir),logs,StringComparison.OrdinalIgnoreCase) || +!(String.Equals(file,log,StringComparison.OrdinalIgnoreCase) || +String.Equals(file,result,StringComparison.OrdinalIgnoreCase))){ +throw new IOException("SENSOR_LOG_HANDLE_CANONICAL_LOG_PATH_MISMATCH"); +} +try{ +foreach(string dir in new string[]{p,appdata,local,expected,logs}){ +SafeFileHandle parent=OpenChecked(dir,FILE_LIST_DIRECTORY,FILE_SHARE_READ|FILE_SHARE_WRITE, +OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OPEN_REPARSE_POINT,true); +guard[guardCount++]=parent; +} +SafeFileHandle h=OpenChecked(file,GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ, +OPEN_ALWAYS,FILE_FLAG_OPEN_REPARSE_POINT,false); +try{ +stream=new FileStream(h,FileAccess.ReadWrite); +stream.Seek(0,SeekOrigin.End); +}catch{h.Dispose();throw;} +}catch{Dispose();throw;} +} +public void Append(string text){ +if(disposed || stream==null)throw new ObjectDisposedException("TBMEScopedUserLogLease"); +if(stream.Length==0){ +byte[] bom=new System.Text.UTF8Encoding(true).GetPreamble(); +stream.Write(bom,0,bom.Length); +} +byte[] data=System.Text.Encoding.UTF8.GetBytes(text); +stream.Write(data,0,data.Length); +stream.Flush(true); +} +public void Overwrite(string text){ +if(disposed || stream==null)throw new ObjectDisposedException("TBMEScopedUserLogLease"); +stream.SetLength(0);stream.Position=0; +byte[] bom=new System.Text.UTF8Encoding(true).GetPreamble(); +stream.Write(bom,0,bom.Length); +byte[] data=System.Text.Encoding.UTF8.GetBytes(text); +stream.Write(data,0,data.Length); +stream.Flush(true); +} +public void Dispose(){ +if(disposed)return; +disposed=true; +if(stream!=null){stream.Dispose();stream=null;} +for(int i=guardCount-1;i>=0;i--)if(guard[i]!=null)guard[i].Dispose(); +guardCount=0; +} } - - // R347: protected directory ancestor leases prohibit concurrent renames. public sealed class TBMEProtectedBrokerLease : IDisposable { - // FILE_READ_ATTRIBUTES is exempt from CreateFile share-mode checks; - // request actual directory listing access so no FILE_SHARE_DELETE - // blocks rename/delete by a concurrent privileged process. - const uint FILE_LIST_DIRECTORY=0x00000001; - const uint SHARE_READ=1, SHARE_WRITE=2, OPEN_EXISTING=3; - const uint BACKUP_SEMANTICS=0x02000000, OPEN_REPARSE_POINT=0x00200000; - const uint ATTR_DIRECTORY=0x10, ATTR_REPARSE=0x400; - [DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] - static extern SafeFileHandle OpenNative(string path,uint access,uint share,IntPtr sec, - uint creation,uint flags,IntPtr template); - [StructLayout(LayoutKind.Sequential)] - struct FILE_ATTRIBUTE_TAG_INFO { public uint Attributes; public uint Tag; } - [DllImport("kernel32.dll",SetLastError=true)] - static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind, - out FILE_ATTRIBUTE_TAG_INFO data,uint size); - readonly SafeFileHandle[] parents=new SafeFileHandle[3]; - int count; - bool disposed; - - static SafeFileHandle OpenExactDir(string path) { - SafeFileHandle h=OpenNative(path,FILE_LIST_DIRECTORY, - SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING, - BACKUP_SEMANTICS|OPEN_REPARSE_POINT,IntPtr.Zero); - if(h.IsInvalid) { - int error=Marshal.GetLastWin32Error(); - h.Dispose(); - throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_OPEN_FAILED_WIN32_"+error); - } - FILE_ATTRIBUTE_TAG_INFO info; - if(!GetFileInformationByHandleEx(h,9,out info, - (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO)))) { - int error=Marshal.GetLastWin32Error(); h.Dispose(); - throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_TAG_FAILED_WIN32_"+error); - } - if((info.Attributes&ATTR_DIRECTORY)==0||(info.Attributes&ATTR_REPARSE)!=0) { - h.Dispose(); - throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_REPARSE_REFUSED"); - } - return h; - } - - public TBMEProtectedBrokerLease(string liveBroker) { - string program=Path.GetFullPath(Environment.GetFolderPath( - Environment.SpecialFolder.ProgramFiles)).TrimEnd(Path.DirectorySeparatorChar); - string root=Path.Combine(program,"TaskbarMonitorEnhanced"); - string broker=Path.Combine(root,"SensorBroker"); - if(String.IsNullOrWhiteSpace(liveBroker) || - !String.Equals(Path.GetFullPath(liveBroker).TrimEnd(Path.DirectorySeparatorChar), - broker,StringComparison.OrdinalIgnoreCase)) { - throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_NOT_EXACT_SYSTEM_PROGRAMFILES"); - } - try { - parents[count++]=OpenExactDir(program); - parents[count++]=OpenExactDir(root); - // If a killed Helper removed the old Broker directory, the pinned - // parent prevents its replacement while recreating this fixed child. - if(!Directory.Exists(broker))Directory.CreateDirectory(broker); - parents[count++]=OpenExactDir(broker); - }catch {Dispose();throw;} - } - public bool IsPinned {get{return !disposed&&count==3;}} - public void Dispose() { - if(disposed)return; - disposed=true; - for(int i=count-1;i>=0;i--)if(parents[i]!=null)parents[i].Dispose(); - count=0; - } +const uint FILE_LIST_DIRECTORY=0x00000001; +const uint SHARE_READ=1, SHARE_WRITE=2, OPEN_EXISTING=3; +const uint BACKUP_SEMANTICS=0x02000000, OPEN_REPARSE_POINT=0x00200000; +const uint ATTR_DIRECTORY=0x10, ATTR_REPARSE=0x400; +[DllImport("kernel32.dll",EntryPoint="CreateFileW",SetLastError=true,CharSet=CharSet.Unicode)] +static extern SafeFileHandle OpenNative(string path,uint access,uint share,IntPtr sec, +uint creation,uint flags,IntPtr template); +[StructLayout(LayoutKind.Sequential)] +struct FILE_ATTRIBUTE_TAG_INFO { public uint Attributes; public uint Tag; } +[DllImport("kernel32.dll",SetLastError=true)] +static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind, +out FILE_ATTRIBUTE_TAG_INFO data,uint size); +readonly SafeFileHandle[] parents=new SafeFileHandle[3]; +int count; +bool disposed; +static SafeFileHandle OpenExactDir(string path) { +SafeFileHandle h=OpenNative(path,FILE_LIST_DIRECTORY, +SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING, +BACKUP_SEMANTICS|OPEN_REPARSE_POINT,IntPtr.Zero); +if(h.IsInvalid) { +int error=Marshal.GetLastWin32Error(); +h.Dispose(); +throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_OPEN_FAILED_WIN32_"+error); +} +FILE_ATTRIBUTE_TAG_INFO info; +if(!GetFileInformationByHandleEx(h,9,out info, +(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO)))) { +int error=Marshal.GetLastWin32Error(); h.Dispose(); +throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_TAG_FAILED_WIN32_"+error); +} +if((info.Attributes&ATTR_DIRECTORY)==0||(info.Attributes&ATTR_REPARSE)!=0) { +h.Dispose(); +throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_REPARSE_REFUSED"); +} +return h; +} +public TBMEProtectedBrokerLease(string liveBroker) { +string program=Path.GetFullPath(Environment.GetFolderPath( +Environment.SpecialFolder.ProgramFiles)).TrimEnd(Path.DirectorySeparatorChar); +string root=Path.Combine(program,"TaskbarMonitorEnhanced"); +string broker=Path.Combine(root,"SensorBroker"); +if(String.IsNullOrWhiteSpace(liveBroker) || +!String.Equals(Path.GetFullPath(liveBroker).TrimEnd(Path.DirectorySeparatorChar), +broker,StringComparison.OrdinalIgnoreCase)) { +throw new IOException("SENSOR_BROKER_DIRECTORY_LEASE_NOT_EXACT_SYSTEM_PROGRAMFILES"); +} +try { +parents[count++]=OpenExactDir(program); +parents[count++]=OpenExactDir(root); +if(!Directory.Exists(broker))Directory.CreateDirectory(broker); +parents[count++]=OpenExactDir(broker); +canonicalBroker=broker; +}catch {Dispose();throw;} +} +const uint GENERIC_READ=0x80000000, GENERIC_WRITE=0x40000000; +const uint OPEN_ALWAYS=4, ATTR_REPARSE_POINT=0x400; +[StructLayout(LayoutKind.Sequential)] +struct FILE_TIME_PAIR { public uint Low; public uint High; } +[StructLayout(LayoutKind.Sequential)] +struct FILE_HANDLE_INFO { +public uint FileAttributes; +public FILE_TIME_PAIR Created, Accessed, Modified; +public uint VolumeSerial, SizeHigh, SizeLow, NumberOfLinks, IndexHigh, IndexLow; +} +[DllImport("kernel32.dll",SetLastError=true)] +static extern bool GetFileInformationByHandle(SafeFileHandle h,out FILE_HANDLE_INFO info); +[DllImport("kernel32.dll",EntryPoint="GetFinalPathNameByHandleW", +SetLastError=true,CharSet=CharSet.Unicode)] +static extern uint FinalNative(SafeFileHandle h,System.Text.StringBuilder path, +uint capacity,uint flags); +readonly System.Collections.Generic.Dictionary leaves= +new System.Collections.Generic.Dictionary( +StringComparer.OrdinalIgnoreCase); +readonly System.Collections.Generic.List childDirectories= +new System.Collections.Generic.List(); +string canonicalBroker; +public FileStream OpenReplayLeaf(string relative) { +if(!IsPinned||String.IsNullOrWhiteSpace(relative)){ +throw new IOException("SENSOR_REPLAY_LEAF_LEASE_NOT_PINNED"); +} +relative=relative.Replace('/',Path.DirectorySeparatorChar); +string[] parts=relative.Split(Path.DirectorySeparatorChar); +if(parts.Length==0||parts.Length>8){ +throw new IOException("SENSOR_REPLAY_LEAF_TOO_DEEP"); +} +foreach(string part in parts) { +if(part=="."||part==".."|| +!System.Text.RegularExpressions.Regex.IsMatch(part,@"^[A-Za-z0-9_.-]+$")){ +throw new IOException("SENSOR_REPLAY_LEAF_BAD_COMPONENT"); +} +} +string path=Path.GetFullPath(Path.Combine(canonicalBroker,relative)); +if(!path.StartsWith(canonicalBroker+Path.DirectorySeparatorChar, +StringComparison.OrdinalIgnoreCase)){ +throw new IOException("SENSOR_REPLAY_LEAF_OUTSIDE_FIXED_BROKER"); +} +FileStream already; +if(leaves.TryGetValue(relative,out already)){return already;} +string parent=canonicalBroker; +for(int i=0;i=buffer.Capacity|| +!String.Equals(buffer.ToString(),"\\\\?\\"+path, +StringComparison.OrdinalIgnoreCase)){ +throw new IOException("SENSOR_REPLAY_LEAF_FINAL_PATH_IDENTITY_MISMATCH"); +} +FileStream fs=new FileStream(h,FileAccess.ReadWrite); +leaves.Add(relative,fs); +return fs; +}catch{h.Dispose();throw;} +} +public byte[] ReadReplayLeaf(string relative) { +FileStream fs=OpenReplayLeaf(relative); +if(fs.Length>16777216){throw new IOException("SENSOR_REPLAY_LEAF_SIZE_LIMIT");} +byte[] result=new byte[(int)fs.Length]; +fs.Position=0; +int pos=0; +while(pos16777216){ +throw new IOException("SENSOR_REPLAY_LEAF_DATA_BOUND"); +} +FileStream fs=OpenReplayLeaf(relative); +fs.Position=0; +fs.SetLength(oldBytes.Length); +fs.Write(oldBytes,0,oldBytes.Length); +fs.Flush(true); +return ReadReplayLeaf(relative); +} +public bool IsPinned {get{return !disposed&&count==3;}} +public void Dispose() { +if(disposed)return; +disposed=true; +foreach(FileStream fs in leaves.Values)fs.Dispose(); +leaves.Clear(); +for(int i=childDirectories.Count-1;i>=0;i--)childDirectories[i].Dispose(); +childDirectories.Clear(); +for(int i=count-1;i>=0;i--)if(parents[i]!=null)parents[i].Dispose(); +count=0; +} } '@ @@ -746,7 +845,8 @@ function Write-SensorJournalTerminal([string]$Root,[string]$Sid,[string]$Phase){ } } function Restore-SensorJournalOldFilesOnly( - [string]$Root,[string]$Sid,[string]$LiveRoot + [string]$Root,[string]$Sid,[string]$LiveRoot, + [object]$PinnedBrokerLease=$null ){ $state=Get-SensorJournalTerminal $Root $Sid if($state -cne 'PREPARED' -and $state -cne 'ROLLED_BACK'){ @@ -838,6 +938,19 @@ function Restore-SensorJournalOldFilesOnly( } } } + # R349: acquire native READ/WRITE, NO SHARE_WRITE/DELETE, no-reparse + # handles for every original leaf BEFORE the first rollback write. The + # lease stays open across original Task restoration and journal terminal + # cleanup in the caller. Any privileged pre-opened writer or DELETE handle + # causes a fail-closed error while PREPARED remains intact. + if($null -ne $PinnedBrokerLease){ + if(-not $PinnedBrokerLease.IsPinned){ + throw 'SENSOR_REPLAY_LEAF_BROKER_PARENT_LEASE_LOST' + } + foreach($entry in $prior){ + $null=$PinnedBrokerLease.OpenReplayLeaf([string]$entry.Rel) + } + } # Stage validated originals back into the live synthetic tree. # A process death mid-copy leaves READY and intact old snapshots, so a # subsequent same-account invocation can resume. This isn't an atomic @@ -851,8 +964,14 @@ function Restore-SensorJournalOldFilesOnly( Assert-SensorBoundaryNoReparse $dest [void][IO.Directory]::CreateDirectory($parent) Assert-SensorBoundaryNoReparse $dest - [IO.File]::WriteAllBytes($dest,[byte[]]$entry.Bytes) - if((Get-SensorJournalHash ([IO.File]::ReadAllBytes($dest))) -cne $entry.Sha){ + if($null -ne $PinnedBrokerLease){ + $verifiedBytes=[byte[]]$PinnedBrokerLease.ReplaceReplayLeaf( + [string]$entry.Rel,[byte[]]$entry.Bytes) + }else{ + [IO.File]::WriteAllBytes($dest,[byte[]]$entry.Bytes) + $verifiedBytes=[IO.File]::ReadAllBytes($dest) + } + if((Get-SensorJournalHash $verifiedBytes) -cne $entry.Sha){ throw 'SENSOR_JOURNAL_REPLAY_WRITE_SHA_FAILED' } } @@ -864,8 +983,15 @@ function Restore-SensorJournalOldFilesOnly( } foreach($entry in $prior){ $dest=Join-Path $LiveRoot $entry.Rel - if(-not [IO.File]::Exists($dest) -or - (Get-SensorJournalHash ([IO.File]::ReadAllBytes($dest))) -cne $entry.Sha){ + if($null -ne $PinnedBrokerLease){ + $bytes=[byte[]]$PinnedBrokerLease.ReadReplayLeaf([string]$entry.Rel) + }else{ + if(-not [IO.File]::Exists($dest)){ + throw 'SENSOR_JOURNAL_REPLAY_FINAL_MISSING_FILE' + } + $bytes=[IO.File]::ReadAllBytes($dest) + } + if((Get-SensorJournalHash $bytes) -cne $entry.Sha){ throw 'SENSOR_JOURNAL_REPLAY_FINAL_SHA_FAILED' } } @@ -1317,7 +1443,7 @@ function Restore-SensorJournalPriorLayerAndTask( # R309 prevalidates every saved file/hash, XML and all current live # member names before the first synthetic file write. Retrying after a # crash is safe for the same allowlisted bytes and expected task. - $decoded=Restore-SensorJournalOldFilesOnly $Journal $ExpectedSid $LiveBrokerRoot + $decoded=Restore-SensorJournalOldFilesOnly $Journal $ExpectedSid $LiveBrokerRoot $brokerDirectoryLease if(-not [string]::Equals($decoded,$oldXml,[StringComparison]::Ordinal)){ throw 'SENSOR_COLD_REPLAY_XML_DATA_CHANGED' } From 46c896f123a98aa7fa234bd4b266c8003ebb621e Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Mon, 19 Oct 2026 16:33:39 +0330 Subject: [PATCH 127/135] test(r349): accept only genuine Win32 sharing violation 32 on preopened delete --- build/Test-R349-PrivilegedLeaf-Native.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build/Test-R349-PrivilegedLeaf-Native.ps1 b/build/Test-R349-PrivilegedLeaf-Native.ps1 index 6b038394..5c3278d1 100644 --- a/build/Test-R349-PrivilegedLeaf-Native.ps1 +++ b/build/Test-R349-PrivilegedLeaf-Native.ps1 @@ -52,8 +52,13 @@ function Cause([Exception]$error){ } function Sharing([Exception]$error){ $e=Cause $error + # The unchanged production native guard deliberately throws a fresh + # IOException carrying GetLastError=32 in its exact message; its HResult + # is the generic managed IOException code, not the source Win32 HResult. + # Restrict acceptance to this precise native sharing violation only. return ($e -is [IO.IOException] -or $e -is [UnauthorizedAccessException]) -and - ($e.HResult -in @(-2147024864,-2147024891)) + (($e.HResult -in @(-2147024864,-2147024891)) -or + $e.Message -ceq 'SENSOR_REPLAY_LEAF_EXCLUSIVE_OPEN_FAILED_WIN32_32') } try{ if([IO.Directory]::Exists($protected) -or [IO.Directory]::Exists($root) -or From 1aa2e02a2bf07a84d913481bc51ca936f386a097 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Tue, 20 Oct 2026 01:07:23 +0330 Subject: [PATCH 128/135] fix(security): pin deferred cleanup directory identities through deletion --- .../audit-r354-real-deferred-cleanup.yml | 100 +++++ .../audit-r355-compiled-directory-pins.yml | 100 +++++ ...st-R354-OriginalDeferredCleanup-Native.ps1 | 344 ++++++++++++++++++ .../Test-R355-CompiledDirectoryPin-Native.ps1 | 152 ++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 65 ++++ 5 files changed, 761 insertions(+) create mode 100644 .github/workflows/audit-r354-real-deferred-cleanup.yml create mode 100644 .github/workflows/audit-r355-compiled-directory-pins.yml create mode 100644 build/Test-R354-OriginalDeferredCleanup-Native.ps1 create mode 100644 build/Test-R355-CompiledDirectoryPin-Native.ps1 diff --git a/.github/workflows/audit-r354-real-deferred-cleanup.yml b/.github/workflows/audit-r354-real-deferred-cleanup.yml new file mode 100644 index 00000000..7111dddf --- /dev/null +++ b/.github/workflows/audit-r354-real-deferred-cleanup.yml @@ -0,0 +1,100 @@ +name: R354 real production Setup deferred cleanup (disposable Windows) +on: + push: + branches: [audit/r350-original-helper-preopened-delete-recovery] + paths: + - build/Test-R354-OriginalDeferredCleanup-Native.ps1 + - installer/TaskbarMonitorEnhanced_Setup.cs + - build/Build.ps1 + - .github/workflows/audit-r354-real-deferred-cleanup.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + real-deferred-cleanup: + name: R354 ${{ matrix.mode }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [Nominal, RootJunction, ChildJunction] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact head without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-hosted or preexisting real product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R354_REFUSE_WRONG_SOURCE_OR_NONDISPOSABLE_RUNNER' + } + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $protected=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $protected) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R354_EXISTING_OWNER_INSTALLATION_OR_SENSOR_REFUSED' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_ALREADY_ADMIN_HOSTED_RUNNER_REQUIRED' + } + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(Test-Path -LiteralPath $marker){throw 'R354_PREBUILD_MARKER_PREEXISTS'} + $proof=[ordered]@{ + Schema=1;Revision='R354';SourceCommit=$env:GITHUB_SHA + Host=$env:COMPUTERNAME + AppRootAbsentBeforeBuild=$true;ProtectedRootAbsentBeforeBuild=$true + TaskAbsentBeforeBuild=$true + } + [IO.File]::WriteAllText($marker,($proof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + 'R354_FRESH_DISPOSABLE_WINDOWS_GUEST_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build unmodified real production Setup PE and four components + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R354_REAL_PRODUCT_SETUP_BUILD_FAILED' + } + - name: Run exact genuine deferred cleanup process only on disposable profile + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R354-OriginalDeferredCleanup-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -Mode '${{ matrix.mode }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){ + throw 'R354_ORIGINAL_COMPILED_CLEANUP_GUEST_FAILED' + } + - name: Retain real original Setup cleanup typed receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R354-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R354_*/R354_ORIGINAL_DEFERRED_CLEANUP.json + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/audit-r355-compiled-directory-pins.yml b/.github/workflows/audit-r355-compiled-directory-pins.yml new file mode 100644 index 00000000..514ff8e9 --- /dev/null +++ b/.github/workflows/audit-r355-compiled-directory-pins.yml @@ -0,0 +1,100 @@ +name: R355 compiled Setup directory identity pins (disposable Windows) +on: + push: + branches: [audit/r350-original-helper-preopened-delete-recovery] + paths: + - build/Test-R355-CompiledDirectoryPin-Native.ps1 + - installer/TaskbarMonitorEnhanced_Setup.cs + - build/Build.ps1 + - .github/workflows/audit-r355-compiled-directory-pins.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + real-deferred-cleanup: + name: R355 exact native lease ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [NativeLease] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact head without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-hosted or preexisting real product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R354_REFUSE_WRONG_SOURCE_OR_NONDISPOSABLE_RUNNER' + } + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $protected=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $protected) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R354_EXISTING_OWNER_INSTALLATION_OR_SENSOR_REFUSED' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_ALREADY_ADMIN_HOSTED_RUNNER_REQUIRED' + } + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(Test-Path -LiteralPath $marker){throw 'R354_PREBUILD_MARKER_PREEXISTS'} + $proof=[ordered]@{ + Schema=1;Revision='R354';SourceCommit=$env:GITHUB_SHA + Host=$env:COMPUTERNAME + AppRootAbsentBeforeBuild=$true;ProtectedRootAbsentBeforeBuild=$true + TaskAbsentBeforeBuild=$true + } + [IO.File]::WriteAllText($marker,($proof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + 'R354_FRESH_DISPOSABLE_WINDOWS_GUEST_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build unmodified real production Setup PE and four components + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R354_REAL_PRODUCT_SETUP_BUILD_FAILED' + } + - name: Run compiled Setup native directory pin and independent rename actor on disposable runner + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R355-CompiledDirectoryPin-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){ + throw 'R355_NATIVE_COMPRESSED_PE_PIN_GUEST_FAILED' + } + - name: Retain real original Setup cleanup typed receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R355-native-lease-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R355_DIRECTORY_PIN_*/R355_PRODUCTION_PIN_RECEIPT.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R354-OriginalDeferredCleanup-Native.ps1 b/build/Test-R354-OriginalDeferredCleanup-Native.ps1 new file mode 100644 index 00000000..2c9eaeea --- /dev/null +++ b/build/Test-R354-OriginalDeferredCleanup-Native.ps1 @@ -0,0 +1,344 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('Nominal','RootJunction','ChildJunction')][string]$Mode, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R354: compile/build the original Setup, launch ONLY its own generated +# unelevated deferred-cleanup command on an already clean, disposable +# GitHub-hosted Windows user profile. Never invoke this on the owner PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R354_REFUSE_NONDISPOSABLE_OR_UNPINNED_HOST' +} +$who=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($who) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_GITHUB_RUNNER_ALREADY_ADMIN_REQUIRED' +} +if($HostLabel -cne 'A' -and $HostLabel -cne 'B'){throw 'R354_INVALID_MATRIX_HOST'} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$program=[Environment]::GetFolderPath('ProgramFiles') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R354_'+$Mode+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R354_ORIGINAL_DEFERRED_CLEANUP.json' +$external=Join-Path $receiptRoot 'R354_OUTSIDE_CANARY_ROOT' +$canary=Join-Path $external 'NEVER_DELETE_OUTSIDE.txt' +$fixture=Join-Path $app 'synthetic-owner-file.txt' +$child=Join-Path $app 'malicious-child-junction' +$checks=[Collections.Generic.List[string]]::new() +$exitCode=-1;$stderr='';$failure='';$phase='PRECONDITION' +$setup='';$sourceExact=$false;$fixtureCreated=$false;$outsideSha='' +$canaryUnchanged=$false;$reparseRefused=$false;$nominalDeleted=$false +$testCleaned=$false;$parentPid=0;$parentTicks=0;$cleanupPid=0 +$compiledEmbeddedSource=$false;$decodedTransport=$false;$nativeOriginalProcessLaunched=$false +$spillover='';$spilloverMoved=$false;$spilloverRestored=$false +$spillBefore='';$spillAfter='';$syntheticGone=$false +function FingerprintGuestApp([string]$base){ + if(![IO.Directory]::Exists($base)){throw 'R354_SPILL_NOT_DIRECTORY'} + if(([IO.File]::GetAttributes($base) -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_ROOT_REPARSE'} + $members=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction Stop) + if($members.Count -gt 300){throw 'R354_SPILL_TOO_MANY_ENTRIES'} + $lines=[Collections.Generic.List[string]]::new() + [long]$total=0 + foreach($item in $members){ + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_MEMBER_REPARSE'} + $rel=$item.FullName.Substring($base.TrimEnd('\').Length+1) + if($item.PSIsContainer){$lines.Add('D|'+$rel)} + else{ + if($item.Length -gt 16777216){throw 'R354_SPILL_OVERSIZE_FILE'} + $total+=$item.Length + if($total -gt 67108864){throw 'R354_SPILL_TOO_MANY_BYTES'} + $lines.Add('F|'+$rel+'|'+$item.Length+'|'+(Hash $item.FullName)) + } + } + $digest=[Security.Cryptography.SHA256]::Create() + try{ + $joined=[string]::Join([string][char]10,[string[]]@($lines|Sort-Object -CaseSensitive)) + return ([BitConverter]::ToString($digest.ComputeHash([Text.Encoding]::UTF8.GetBytes($joined)))).Replace('-','') + }finally{$digest.Dispose()} +} + +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function IsTaskPresent(){ + return @((Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_})).Count -ne 0 +} +# This receipt path is RUNNER_TEMP on a verified hosted disposable Windows +# runner, not any owner installation; it must exist even when preflight fails. +if([IO.Directory]::Exists($receiptRoot)){throw 'R354_OWNED_RECEIPT_ROOT_PREEXISTS'} +[IO.Directory]::CreateDirectory($receiptRoot)|Out-Null +$fresh=[ordered]@{AppDir=$false;ProtectedDir=$false;MainFile=$false;SensorTask=$false;SensorProcessCount=0} +try{ + $phase='VERIFY_FRESH_GITHUB_HOSTED_CLEAN_PROFILE' + $fresh.AppDir=[IO.Directory]::Exists($app) + $fresh.ProtectedDir=[IO.Directory]::Exists($protected) + $fresh.MainFile=[IO.File]::Exists($main) + $fresh.SensorTask=[bool](IsTaskPresent) + $fresh.SensorProcessCount=@(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count + Write-Output ('R354_NATIVE_GUEST_FRESHNESS='+($fresh|ConvertTo-Json -Compress)) + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(![IO.File]::Exists($marker)){throw 'R354_PREBUILD_FRESHNESS_PROOF_MISSING'} + $pre=Get-Content -LiteralPath $marker -Raw|ConvertFrom-Json + if($pre.Schema -ne 1 -or $pre.Revision -cne 'R354' -or + $pre.SourceCommit -cne $env:GITHUB_SHA -or + $pre.Host -cne $env:COMPUTERNAME -or + $pre.AppRootAbsentBeforeBuild -ne $true -or + $pre.ProtectedRootAbsentBeforeBuild -ne $true -or + $pre.TaskAbsentBeforeBuild -ne $true){ + throw 'R354_BAD_PREBUILD_FRESHNESS_PROOF' + } + if($fresh.ProtectedDir -or $fresh.MainFile -or + $fresh.SensorTask -or $fresh.SensorProcessCount -gt 0){ + throw 'R354_POSTBUILD_REAL_PRODUCT_OR_SENSOR_REFUSED' + } + if(-not [string]::Equals([IO.Path]::GetFullPath($local), + [IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local')),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_NONCANONICAL_LOCALAPPDATA_REFUSED' + } + [IO.Directory]::CreateDirectory($external)|Out-Null + [IO.File]::WriteAllText($canary,'R354_OUTSIDE_CANARY_DO_NOT_TOUCH',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash $canary + Check 'REAL_GITHUB_HOSTED_WINDOWS_CLEAN_APP_TASK_AND_SENSORS_ABSENT' + Check 'EXPECTED_OWNER_APP_ROOT_IS_CANONICAL_KNOWNFOLDER_NOT_ENV_OVERRIDE' + $phase='ISOLATE_KNOWN_BUILD_ONLY_APPROOT' + if($fresh.AppDir){ + $spillover=Join-Path $local ('TaskbarMonitorEnhanced_R354_BUILD_SNAPSHOT_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $spillover){throw 'R354_SPILL_PREEXISTS'} + $spillBefore=FingerprintGuestApp $app + [IO.Directory]::Move($app,$spillover) + $spilloverMoved=$true + if([IO.Directory]::Exists($app) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_MOVE_SHA_MISMATCH' + } + } + Check 'PREBUILD_FRESH_GUEST_BUILD_ONLY_APPDATA_ATOMICALLY_ISOLATED' + $phase='REFLECT_EXACT_COMPILED_PRODUCTION_SETUP_PRIVATE_FACTORY' + $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $source=Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + foreach($p in @($setup,$source,$native)){ + if(![IO.File]::Exists($p)){throw 'R354_BUILD_OR_TRUSTED_WINDOWS_HOST_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $reader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $expectedSource=[IO.File]::ReadAllText($source) + $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize $expectedSource)){ + throw 'R354_ORIGINAL_PE_SETUP_SOURCE_HASH_MISMATCH' + } + $sourceExact=$true;$compiledEmbeddedSource=$true + Check 'REAL_ORIGINAL_SETUP_PE_EMBEDS_EXACT_REVIEWED_SETUP_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $appProp=$type.GetProperty('AppRoot',$flags) + if(!$builder -or !$appProp){throw 'R354_EXACT_PE_CLEANUP_FACTORY_MISSING'} + $known=[string]$appProp.GetValue($null,$null) + if(-not [string]::Equals($known,$app,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_SETUP_KNOWN_APP_ROOT_CHANGED' + } + Check 'PRODUCTION_SETUP_KNOWN_ROOT_AND_REAL_FACTORY_VERIFIED' + $phase='USE_REAL_EXITED_PARENT_IDENTITY_FOR_DEFERRED_CLEANUP' + $sacStart=[Diagnostics.ProcessStartInfo]::new() + $sacStart.FileName=$native + $sacStart.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $sacStart.UseShellExecute=$false;$sacStart.CreateNoWindow=$true + $parent=[Diagnostics.Process]::Start($sacStart) + if(!$parent){throw 'R354_TEST_PARENT_PROCESS_MISSING'} + try{ + $parentPid=$parent.Id + $parentTicks=$parent.StartTime.ToUniversalTime().Ticks + if(!$parent.WaitForExit(10000)){throw 'R354_REAL_PARENT_DID_NOT_EXIT'} + }finally{$parent.Dispose()} + if($parentPid -le 0 -or $parentTicks -le 0){ + throw 'R354_INVALID_REAL_EXITED_PARENT_IDENTITY' + } + $args=[object[]]::new(3) + $args[0]=([string]$known).PSObject.BaseObject + $args[1]=([int]$parentPid).PSObject.BaseObject + $args[2]=([long]$parentTicks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) + if(-not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + !$psi.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ',[StringComparison]::Ordinal) -or + $psi.Arguments.Contains($app) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ')){ + throw 'R354_REAL_SETUP_PROCESS_FACTORY_NOT_BOUND_TO_TRUSTED_HOST' + } + $decoded=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String( + $psi.Arguments.Substring('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '.Length))) + $tokens=$null;$parseErrors=$null + [Management.Automation.Language.Parser]::ParseInput($decoded,[ref]$tokens,[ref]$parseErrors)|Out-Null + if(@($parseErrors).Count -ne 0 -or + !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' + } + $decodedTransport=$true + Check 'REAL_EXITED_PARENT_ID_AND_UTF16_ONLY_ORIGINAL_SETUP_COMMAND_TRANSPORT' + $phase='CREATE_DISPOSABLE_CANARY_AND_OPTIONAL_JUNCTION' + if($Mode -ceq 'RootJunction'){ + if(Test-Path -LiteralPath $app){throw 'R354_FRESH_ROOT_ALREADY_OCCUPIED'} + New-Item -ItemType Junction -Path $app -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_ROOT_JUNCTION_NOT_ACTUALLY_REPARSE' + } + $fixtureCreated=$true + }else{ + [IO.Directory]::CreateDirectory($app)|Out-Null + [IO.File]::WriteAllText($fixture,'R354_TEST_USER_DATA_ONLY',[Text.UTF8Encoding]::new($false)) + $fixtureCreated=$true + if($Mode -ceq 'ChildJunction'){ + New-Item -ItemType Junction -Path $child -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_CHILD_JUNCTION_NOT_ACTUALLY_REPARSE' + } + } + } + Check 'ONLY_FRESH_GUEST_OWNED_SYNTHETIC_ROOT_WITH_EXTERNAL_CANARY_PREPARED' + $phase='START_REAL_UNMODIFIED_COMPILED_SETUP_CLEANUP_PROCESS' + $psi.RedirectStandardError=$true + $psi.RedirectStandardOutput=$true + $psi.CreateNoWindow=$true + $cleanup=[Diagnostics.Process]::Start($psi) + if(!$cleanup){throw 'R354_REAL_CLEANUP_PROCESS_NOT_STARTED'} + try{ + $cleanupPid=$cleanup.Id + if(!$cleanup.WaitForExit(40000)){ + if(!$cleanup.HasExited){$cleanup.Kill();[void]$cleanup.WaitForExit(8000)} + throw 'R354_REAL_CLEANUP_PROCESS_TIMEOUT' + } + $exitCode=[int]$cleanup.ExitCode + $stderr=[string]$cleanup.StandardError.ReadToEnd() + $stdout=[string]$cleanup.StandardOutput.ReadToEnd() + }finally{$cleanup.Dispose()} + $nativeOriginalProcessLaunched=$true + Check 'REAL_COMPILED_SETUP_GENERATED_CLEANUP_EXECUTED_AS_SEPARATE_NATIVE_PROCESS' + $phase='VERIFY_OUTCOME_EXACT_NATIVE_PROCESS_NONFOLLOWING' + if($Mode -ceq 'Nominal'){ + if($exitCode -ne 0 -or [IO.Directory]::Exists($app) -or + $stderr.Contains('TBME_CLEANUP_')){ + throw 'R354_NOMINAL_NATIVE_CLEANUP_NOT_SUCCESSFUL' + } + $nominalDeleted=$true + Check 'ORIGINAL_CLEANUP_NOMINAL_REMOVES_OWNED_TEST_ROOT_AND_NOTHING_ELSE' + }else{ + $expected=if($Mode -ceq 'RootJunction'){'TBME_CLEANUP_ANCESTOR_REPARSE'}else{'TBME_CLEANUP_CHILD_REPARSE'} + if($exitCode -eq 0 -or !$stderr.Contains($expected)){ + throw ('R354_ORIGINAL_NATIVE_CLEANUP_JUNCTION_WAS_NOT_REFUSED_'+$Mode+'_exit_'+$exitCode+'_stderr_'+$stderr.Substring(0,[Math]::Min(320,$stderr.Length))) + } + if($Mode -ceq 'ChildJunction' -and ![IO.File]::Exists($fixture)){ + throw 'R354_OWNER_TEST_PAYLOAD_DELETED_BEFORE_REFUSAL' + } + $junction=if($Mode -ceq 'RootJunction'){$app}else{$child} + if(([IO.File]::GetAttributes($junction) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_JUNCTION_NOT_PRESERVED_ON_REFUSAL' + } + $reparseRefused=$true + Check ('ORIGINAL_CLEANUP_NATIVE_'+$Mode.ToUpperInvariant()+'_JUNCTION_REFUSED_PRE_DELETE') + } + if((Hash $canary) -cne $outsideSha){throw 'R354_OUTSIDE_CANARY_WAS_MODIFIED'} + $canaryUnchanged=$true + Check 'EXTERNAL_CANARY_SHA_UNCHANGED_AFTER_ORIGINAL_CLEANUP_PROCESS' + if([IO.Directory]::Exists($protected) -or (IsTaskPresent)){ + throw 'R354_REAL_SENSOR_INSTALLATION_OR_SCHEDULED_TASK_TRIGGERED' + } + Check 'NO_PROTECTED_SENSOR_OR_DRIVER_SETUP_EVER_INVOKED' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Remove ONLY exact owned synthetic test fixture created on this hosted runner, + # and only after confirming the external canary has not changed. + try{ + if($fixtureCreated -and (Hash $canary) -ceq $outsideSha){ + if($Mode -ceq 'RootJunction' -and [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($app,$false) + } + if($Mode -ceq 'ChildJunction' -and [IO.Directory]::Exists($child) -and + ([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($child,$false) + } + if($Mode -cne 'RootJunction' -and [IO.Directory]::Exists($app)){ + if([IO.File]::Exists($fixture) -and (Hash $fixture) -ceq (( + [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R354_TEST_USER_DATA_ONLY')))).Replace('-',''))){ + [IO.File]::Delete($fixture) + } + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + } + } + if([IO.File]::Exists($canary) -and (Hash $canary) -ceq $outsideSha -and + @([IO.Directory]::GetFileSystemEntries($external)).Count -eq 1){ + [IO.File]::Delete($canary) + [IO.Directory]::Delete($external,$false) + } + $syntheticGone=(![IO.Directory]::Exists($app)) + if($spilloverMoved -and $syntheticGone){ + if(![IO.Directory]::Exists($spillover) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_CHANGED_BEFORE_RESTORE' + } + [IO.Directory]::Move($spillover,$app) + $spillAfter=FingerprintGuestApp $app + $spilloverRestored=($spillAfter -ceq $spillBefore -and ![IO.Directory]::Exists($spillover)) + if(!$spilloverRestored){throw 'R354_BUILD_APPDATA_NOT_EXACTLY_RESTORED'} + } + $testCleaned=($syntheticGone -and + (!$spilloverMoved -or $spilloverRestored) -and + ![IO.Directory]::Exists($external) -and ![IO.Directory]::Exists($protected) -and !(IsTaskPresent)) + }catch{$failure+=' R354_TEST_FIXTURE_CLEANUP_FAILED='+$_.Exception.Message} + $accepted=(!$failure -and $checks.Count -eq 11 -and $testCleaned -and + $nativeOriginalProcessLaunched -and $canaryUnchanged -and + ($nominalDeleted -or $reparseRefused)) + $status=if($accepted){'PASS_REAL_ORIGINAL_SETUP_DEFERRED_CLEANUP_'+$Mode.ToUpperInvariant()+'_SCOPED'}else{'FAIL_R354_NOT_ACCEPTED'} + $receiptData=[ordered]@{ + Schema=1;Revision='R354';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;Mode=$Mode + SetupSHA256=$(if($setup -and [IO.File]::Exists($setup)){Hash $setup}else{'MISSING'}) + RealSetupSourceEmbeddedExact=$sourceExact;SourceUnchanged=$compiledEmbeddedSource + TrustedProcessInfoTransport=$decodedTransport + RealExitedParentPid=$parentPid;OriginalNativeCleanupPid=$cleanupPid + ActualOriginalSetupGeneratedCleanupExecuted=$nativeOriginalProcessLaunched + CleanupNativeExitCode=$exitCode + ExpectedJunctionRefusalObserved=$reparseRefused;NormalSyntheticUninstallRemoved=$nominalDeleted + ExternalCanarySHA256=$outsideSha;CanaryUnchanged=$canaryUnchanged + ScopedTestCleanupComplete=$testCleaned + PreBuildGuestAppAbsent=$true + BuildGeneratedAppRootSeen=$fresh.AppDir + BuildGeneratedRootBackupRestored=$spilloverRestored + BuildGeneratedRootShaBefore=$spillBefore + BuildGeneratedRootShaAfter=$spillAfter + SyntheticFixtureRootFullyRemoved=$syntheticGone + GuestFreshness=$fresh + UserOwnerComputerTouched=$false;DriverOrBrokerSetupExecuted=$false + InteractiveUacTested=$false;ConcurrentPathSwapTested=$false + Checks=@($checks);Failure=$failure + } + try{[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false))}catch{$failure+=' RECEIPT_WRITE_FAILED'} + Write-Output ('R354_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count+' EXIT='+$exitCode) + if($failure){Write-Output ('R354_FAILURE='+$failure)} +} +if($failure){throw ('R354_REAL_DEFERRED_CLEANUP_FAILED '+$failure)} diff --git a/build/Test-R355-CompiledDirectoryPin-Native.ps1 b/build/Test-R355-CompiledDirectoryPin-Native.ps1 new file mode 100644 index 00000000..1750ae5d --- /dev/null +++ b/build/Test-R355-CompiledDirectoryPin-Native.ps1 @@ -0,0 +1,152 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$SourceRoot) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R355_ONLY_FRESH_DISPOSABLE_GITHUB_WINDOWS' +} +$root=Join-Path $env:RUNNER_TEMP ('R355_DIRECTORY_PIN_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$testDir=Join-Path $root 'owner-test-only' +$moved=Join-Path $root 'owner-moved' +$link=Join-Path $root 'owner-junction' +$receipt=Join-Path $root 'R355_PRODUCTION_PIN_RECEIPT.json' +$setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' +$checks=[Collections.Generic.List[string]]::new() +$lease=$null;$started=$false;$canary='';$sha='';$deniedCode=-1;$releasedCode=-1 +function Pass([string]$x){$script:checks.Add($x);Write-Output ('PASS '+$x)} +function Hash([string]$f){return (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash} +if([IO.Directory]::Exists($root) -or ![IO.File]::Exists($setup) -or + [IO.Directory]::Exists((Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced'))){ + throw 'R355_GUEST_SCOPE_NOT_FRESH_OR_SETUP_MISSING' +} +[IO.Directory]::CreateDirectory($root)|Out-Null +try{ + $assembly=[Reflection.Assembly]::LoadFrom($setup) + $sourceReader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$sourceReader.ReadToEnd()}finally{$sourceReader.Dispose()} + $text=[IO.File]::ReadAllText((Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs')) + $norm={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $norm $embedded) -cne (& $norm $text)){throw 'R355_ORIGINAL_PE_EMBEDDED_SOURCE_NOT_EXACT'} + Pass 'COMPILED_PRODUCTION_SETUP_PE_EMBEDS_EXACT_NEW_DEFENSIVE_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $build=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $app=$type.GetProperty('AppRoot',$flags) + if(!$build -or !$app){throw 'R355_ORIGINAL_FACTORY_ABSENT'} + $known=[string]$app.GetValue($null,$null) + $proc=[Diagnostics.Process]::GetCurrentProcess() + try{$pidSelf=$proc.Id;$ticks=$proc.StartTime.ToUniversalTime().Ticks}finally{$proc.Dispose()} + $nativeArgs=[object[]]::new(3) + $nativeArgs[0]=([string]$known).PSObject.BaseObject + $nativeArgs[1]=([int]$pidSelf).PSObject.BaseObject + $nativeArgs[2]=([long]$ticks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$build.Invoke($null,$nativeArgs) + $prefix='-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ' + if(!$psi.Arguments.StartsWith($prefix,[StringComparison]::Ordinal) -or + $psi.Arguments.Length -gt 22080 -or + $psi.UseShellExecute -ne $false -or + ![string]::Equals($psi.FileName, + (Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe'), + [StringComparison]::OrdinalIgnoreCase)){ + throw 'R355_PRODUCTION_CLEANUP_COMMAND_CHANGED_INSECURELY' + } + Pass 'REAL_PRODUCTION_DEFERRED_PROCESSINFO_HOST_AND_ENCODEDCOMMAND_BOUNDS' + $code=[Text.Encoding]::Unicode.GetString( + [Convert]::FromBase64String($psi.Arguments.Substring($prefix.Length))) + $tokens=$null;$parseErr=$null + [Management.Automation.Language.Parser]::ParseInput($code,[ref]$tokens,[ref]$parseErr)|Out-Null + if(@($parseErr).Count){throw 'R355_REAL_ENCODEDCOMMAND_POWERSHELL51_PARSE_FAILED'} + $m=[regex]::Matches($code,"(?ms)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@") + if($m.Count -ne 1){throw 'R355_REAL_NATIVE_CLASS_NOT_UNIQUE_IN_PE'} + Add-Type -TypeDefinition $m[0].Groups[1].Value -ErrorAction Stop + Pass 'ACTUAL_COMPILED_DEFERRED_SCRIPT_PS51_AND_NATIVE_CSHARP_COMPILE_PASS' + [IO.Directory]::CreateDirectory($testDir)|Out-Null + $canary=Join-Path $testDir 'R355_TEST_ONLY_CANARY.txt' + [IO.File]::WriteAllText($canary,'R355_TEST_PRIVATE_ONLY',[Text.UTF8Encoding]::new($false)) + $sha=Hash $canary + $lease=[TBMEDeferredDirectoryLeaseSet]::new() + $lease.Pin($testDir) + $lease.Pin($testDir) + $pathBlob=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($testDir)) + $moveBlob=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($moved)) + $actorCode='$a=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('''+$pathBlob+'''));'+ + '$b=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('''+$moveBlob+'''));'+ + 'try{[IO.Directory]::Move($a,$b);[IO.Directory]::Move($b,$a);exit 61}'+ + 'catch [IO.IOException]{exit 32}catch [UnauthorizedAccessException]{exit 5}' + $actorTokens=$null;$actorErrors=$null + [Management.Automation.Language.Parser]::ParseInput($actorCode,[ref]$actorTokens,[ref]$actorErrors)|Out-Null + if(@($actorErrors).Count){throw 'R355_ACTOR_CODE_GENERATION_PARSE_FAILED'} + $enc=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($actorCode)) + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $actor1=[Diagnostics.Process]::Start($native,('-NoProfile -NonInteractive -EncodedCommand '+$enc)) + if(!$actor1){throw 'R355_SEPARATE_PROCESS_NOT_STARTED'} + try{ + if(!$actor1.WaitForExit(16000)){throw 'R355_SEPARATE_ACTOR_TIMEOUT'} + $deniedCode=$actor1.ExitCode + }finally{$actor1.Dispose()} + if($deniedCode -ne 32 -and $deniedCode -ne 5){throw ('R355_RENAME_NOT_DENIED_WHILE_PINN_'+$deniedCode)} + if([IO.Directory]::Exists($moved) -or (Hash $canary) -cne $sha){ + throw 'R355_ACTOR_CHANGED_PINNED_ROOT_OR_CANARY' + } + Pass 'SEPARATE_NATIVE_WINDOWS_PROCESS_RENAME_REFUSED_DURING_DIRECTORY_LEASE' + $lease.Release($testDir) + $actor2=[Diagnostics.Process]::Start($native,('-NoProfile -NonInteractive -EncodedCommand '+$enc)) + if(!$actor2){throw 'R355_SECOND_ACTOR_NOT_STARTED'} + try{ + if(!$actor2.WaitForExit(16000)){throw 'R355_SECOND_ACTOR_TIMEOUT'} + $releasedCode=$actor2.ExitCode + }finally{$actor2.Dispose()} + if($releasedCode -ne 61 -or ![IO.Directory]::Exists($testDir) -or (Hash $canary) -cne $sha){ + throw ('R355_DIRECTORY_RENAME_NOT_ALLOWED_AFTER_LEASE_RELEASE_'+$releasedCode) + } + Pass 'SAME_ACTOR_RENAME_ALLOWED_AND_RESTORED_AFTER_LEASE_RELEASE' + New-Item -ItemType Junction -Path $link -Target $testDir -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($link) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R355_TEST_JUNCTION_CREATION_NOT_GENUINE' + } + $denied=$false + try{$lease.Pin($link)}catch [IO.IOException]{$denied=$true} + if(!$denied -or (Hash $canary) -cne $sha){throw 'R355_LEASE_FAILOPEN_ON_JUNCTION'} + Pass 'SAME_PRODUCTION_LEASE_CLASS_REFUSED_REAL_TEST_JUNCTION' + $started=$true +}finally{ + if($lease){$lease.Dispose()} + if([IO.Directory]::Exists($link) -and + ([IO.File]::GetAttributes($link) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($link,$false) + } + if([IO.File]::Exists($canary) -and [IO.File]::ReadAllText($canary) -ceq 'R355_TEST_PRIVATE_ONLY'){ + [IO.File]::Delete($canary) + } + if([IO.Directory]::Exists($testDir) -and + @([IO.Directory]::GetFileSystemEntries($testDir)).Count -eq 0){ + [IO.Directory]::Delete($testDir,$false) + } +} +if(!$started -or $checks.Count -ne 7 -or [IO.Directory]::Exists($testDir) -or + [IO.Directory]::Exists($moved) -or [IO.Directory]::Exists($link)){ + throw 'R355_BOUNDED_COMPILED_NATIVE_PROOF_NOT_ACCEPTED' +} +$record=[ordered]@{ + Schema=1;Revision='R355';Status='PASS_SCOPED_ORIGINAL_COMPILED_PE_DIRECTORY_PIN_NATIVE' + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + SetupSHA256=Hash $setup;Tests=@($checks) + ParentPid=$PID;SeparateNativeActorDeniedCode=$deniedCode + SeparateNativeActorAllowedCode=$releasedCode + ExactCompiledProductionNativeClassUsed=$true + ExternalTestCanaryUntouched=$true + OwnerAppOrSensorModified=$false + ActualConcurrentUninstallRaceTested=$false + InteractiveUacTested=$false + WholeSecurityAccepted=$false +} +[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) +'R355_COMPILED_NATIVE_LEASE_PASS_HOST='+$HostLabel+' CHECKS='+$checks.Count diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index d04b69c9..57a7555e 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1162,6 +1162,63 @@ static ProcessStartInfo BuildDeferredUninstallCleanupProcessInfo(string root,int # Parent exited between process lookup and StartTime/WaitForExit. } if(-not [IO.Directory]::Exists($expectedRoot)){exit 0} + +# R355: acquire trusted directory handles without FILE_SHARE_DELETE before +# reading inventory or deleting names, so an attacker cannot rename pinned +# profile/LocalAppData/owned-tree ancestors while this cleanup is working. +Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Text; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public sealed class TBMEDeferredDirectoryLeaseSet : IDisposable { + const uint LIST=1,SHARE_READ_WRITE=3,OPEN_EXISTING=3; + const uint BACKUP=0x02000000,OPEN_REPARSE=0x00200000,DIR=0x10,REPARSE=0x400; + [StructLayout(LayoutKind.Sequential)] + struct TAG {public uint Attributes;public uint ReparseTag;} + [DllImport(""kernel32.dll"",CharSet=CharSet.Unicode,EntryPoint=""CreateFileW"",SetLastError=true)] + static extern SafeFileHandle Create(string p,uint access,uint sharing,IntPtr sec,uint create,uint flags,IntPtr template); + [DllImport(""kernel32.dll"",SetLastError=true)] + static extern bool GetFileInformationByHandleEx(SafeFileHandle file,int kind,out TAG info,uint size); + [DllImport(""kernel32.dll"",CharSet=CharSet.Unicode,EntryPoint=""GetFinalPathNameByHandleW"",SetLastError=true)] + static extern uint Final(SafeFileHandle file,StringBuilder path,uint size,uint flags); + readonly Dictionary pins=new Dictionary(StringComparer.OrdinalIgnoreCase); + public void Pin(string path){ + if(String.IsNullOrWhiteSpace(path))throw new IOException(""R355_PIN_EMPTY_DIRECTORY""); + string expected=Path.GetFullPath(path).TrimEnd('\\'); + if(pins.ContainsKey(expected))return; + SafeFileHandle h=Create(expected,LIST,SHARE_READ_WRITE,IntPtr.Zero,OPEN_EXISTING,BACKUP|OPEN_REPARSE,IntPtr.Zero); + if(h.IsInvalid){int e=Marshal.GetLastWin32Error();h.Dispose();throw new IOException(""R355_PIN_CREATEFILE_WIN32_""+e);} + try{ + TAG t; + if(!GetFileInformationByHandleEx(h,9,out t,(uint)Marshal.SizeOf(typeof(TAG)))) + throw new IOException(""R355_PIN_TAG_WIN32_""+Marshal.GetLastWin32Error()); + if((t.Attributes&DIR)==0||(t.Attributes&REPARSE)!=0) + throw new IOException(""R355_PIN_NON_DIRECTORY_OR_REPARSE""); + StringBuilder b=new StringBuilder(4096); + uint n=Final(h,b,(uint)b.Capacity,0); + if(n==0||n>=b.Capacity)throw new IOException(""R355_PIN_FINAL_PATH_WIN32_""+Marshal.GetLastWin32Error()); + string actual=b.ToString(); + if(actual.StartsWith(@""\\?\"",StringComparison.Ordinal))actual=actual.Substring(4); + if(!String.Equals(actual.TrimEnd('\\'),expected,StringComparison.OrdinalIgnoreCase)) + throw new IOException(""R355_PIN_REDIRECTED_DIRECTORY""); + pins.Add(expected,h); + }catch{h.Dispose();throw;} + } + public void Release(string path){ + string p=Path.GetFullPath(path).TrimEnd('\\'); + SafeFileHandle h; + if(!pins.TryGetValue(p,out h))throw new IOException(""R355_PIN_RELEASE_UNKNOWN_DIRECTORY""); + pins.Remove(p);h.Dispose(); + } + public void Dispose(){ + foreach(SafeFileHandle h in pins.Values)h.Dispose(); + pins.Clear(); + } +} +'@ # Reject every ancestor and descendant reparse point before deletion; # this is a path-based check, not proof against concurrent swaps. $cursor=$expectedRoot @@ -1180,6 +1237,11 @@ static ProcessStartInfo BuildDeferredUninstallCleanupProcessInfo(string root,int $dirs=[Collections.Generic.List[string]]::new() $files=[Collections.Generic.List[string]]::new() $stack.Add($expectedRoot) +$lease=[TBMEDeferredDirectoryLeaseSet]::new() +try{ +foreach($pinPath in @($profile,(Join-Path $profile 'AppData'),$local,$expectedRoot)){ + $lease.Pin($pinPath) +} $members=0 while($stack.Count -gt 0){ $dir=$stack[$stack.Count-1] @@ -1189,6 +1251,7 @@ static ProcessStartInfo BuildDeferredUninstallCleanupProcessInfo(string root,int ($attrs -band [IO.FileAttributes]::Directory) -eq 0){ throw 'TBME_CLEANUP_CHANGED_DIRECTORY' } + $lease.Pin($dir) $dirs.Add($dir) foreach($entry in [IO.Directory]::GetFileSystemEntries($dir)){ $members++ @@ -1220,12 +1283,14 @@ static ProcessStartInfo BuildDeferredUninstallCleanupProcessInfo(string root,int [IO.File]::Delete($file) } foreach($dir in ($dirs|Sort-Object Length -Descending)){ + $lease.Release($dir) $attrs=[IO.File]::GetAttributes($dir) if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ throw 'TBME_CLEANUP_CHANGED_DIRECTORY' } [IO.Directory]::Delete($dir,$false) } +}finally{$lease.Dispose()} "; script=script.Replace("__ROOT_B64__",rootBlob) .Replace("__OWNER_SID__",sid) From 1f7acd4012633cb6df7e635990edbb8238628ef7 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Tue, 20 Oct 2026 01:08:29 +0330 Subject: [PATCH 129/135] ci(r355): route disposable pin and cleanup regressions to review branch --- .github/workflows/audit-r354-real-deferred-cleanup.yml | 2 +- .github/workflows/audit-r355-compiled-directory-pins.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/audit-r354-real-deferred-cleanup.yml b/.github/workflows/audit-r354-real-deferred-cleanup.yml index 7111dddf..a65cea18 100644 --- a/.github/workflows/audit-r354-real-deferred-cleanup.yml +++ b/.github/workflows/audit-r354-real-deferred-cleanup.yml @@ -1,7 +1,7 @@ name: R354 real production Setup deferred cleanup (disposable Windows) on: push: - branches: [audit/r350-original-helper-preopened-delete-recovery] + branches: [audit/r355-deferred-cleanup-directory-pins] paths: - build/Test-R354-OriginalDeferredCleanup-Native.ps1 - installer/TaskbarMonitorEnhanced_Setup.cs diff --git a/.github/workflows/audit-r355-compiled-directory-pins.yml b/.github/workflows/audit-r355-compiled-directory-pins.yml index 514ff8e9..2027aa1d 100644 --- a/.github/workflows/audit-r355-compiled-directory-pins.yml +++ b/.github/workflows/audit-r355-compiled-directory-pins.yml @@ -1,7 +1,7 @@ name: R355 compiled Setup directory identity pins (disposable Windows) on: push: - branches: [audit/r350-original-helper-preopened-delete-recovery] + branches: [audit/r355-deferred-cleanup-directory-pins] paths: - build/Test-R355-CompiledDirectoryPin-Native.ps1 - installer/TaskbarMonitorEnhanced_Setup.cs From b19fc70f085f129d7519bffa3f4b2856c7a45a82 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Tue, 20 Oct 2026 01:11:19 +0330 Subject: [PATCH 130/135] test(r355): require exact six native lease witness checks --- build/Test-R355-CompiledDirectoryPin-Native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Test-R355-CompiledDirectoryPin-Native.ps1 b/build/Test-R355-CompiledDirectoryPin-Native.ps1 index 1750ae5d..ed8c2f15 100644 --- a/build/Test-R355-CompiledDirectoryPin-Native.ps1 +++ b/build/Test-R355-CompiledDirectoryPin-Native.ps1 @@ -131,7 +131,7 @@ try{ [IO.Directory]::Delete($testDir,$false) } } -if(!$started -or $checks.Count -ne 7 -or [IO.Directory]::Exists($testDir) -or +if(!$started -or $checks.Count -ne 6 -or [IO.Directory]::Exists($testDir) -or [IO.Directory]::Exists($moved) -or [IO.Directory]::Exists($link)){ throw 'R355_BOUNDED_COMPILED_NATIVE_PROOF_NOT_ACCEPTED' } From 8695cd7c8afdb1906645c0948ef750f6b2d3a4ab Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Tue, 20 Oct 2026 01:29:55 +0330 Subject: [PATCH 131/135] test(r355): race original Setup cleanup with independent live directory rename --- ...355-original-cleanup-concurrent-rename.yml | 102 ++++ ...355-RealCleanupConcurrentRename-Native.ps1 | 472 ++++++++++++++++++ ...est-R355-RealCleanupRenameActor-Native.ps1 | 151 ++++++ 3 files changed, 725 insertions(+) create mode 100644 .github/workflows/audit-r355-original-cleanup-concurrent-rename.yml create mode 100644 build/Test-R355-RealCleanupConcurrentRename-Native.ps1 create mode 100644 build/Test-R355-RealCleanupRenameActor-Native.ps1 diff --git a/.github/workflows/audit-r355-original-cleanup-concurrent-rename.yml b/.github/workflows/audit-r355-original-cleanup-concurrent-rename.yml new file mode 100644 index 00000000..b3e15040 --- /dev/null +++ b/.github/workflows/audit-r355-original-cleanup-concurrent-rename.yml @@ -0,0 +1,102 @@ +name: R355 original compiled Setup cleanup vs independent live root rename (disposable Windows) +on: + push: + branches: [audit/r355-deferred-cleanup-directory-pins] + paths: + - build/Test-R355-RealCleanupConcurrentRename-Native.ps1 + - build/Test-R355-RealCleanupRenameActor-Native.ps1 + - installer/TaskbarMonitorEnhanced_Setup.cs + - build/Build.ps1 + - .github/workflows/audit-r355-original-cleanup-concurrent-rename.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + real-deferred-cleanup: + name: R355 live original ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [LiveRace] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout exact head without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-hosted or preexisting real product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R354_REFUSE_WRONG_SOURCE_OR_NONDISPOSABLE_RUNNER' + } + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $protected=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $protected) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R354_EXISTING_OWNER_INSTALLATION_OR_SENSOR_REFUSED' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_ALREADY_ADMIN_HOSTED_RUNNER_REQUIRED' + } + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(Test-Path -LiteralPath $marker){throw 'R354_PREBUILD_MARKER_PREEXISTS'} + $proof=[ordered]@{ + Schema=1;Revision='R354';SourceCommit=$env:GITHUB_SHA + Host=$env:COMPUTERNAME + AppRootAbsentBeforeBuild=$true;ProtectedRootAbsentBeforeBuild=$true + TaskAbsentBeforeBuild=$true + } + [IO.File]::WriteAllText($marker,($proof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + 'R354_FRESH_DISPOSABLE_WINDOWS_GUEST_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build unmodified real production Setup PE and four components + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R354_REAL_PRODUCT_SETUP_BUILD_FAILED' + } + - name: Run original compiled Cleanup process with separate native live rename actor + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R355-RealCleanupConcurrentRename-Native.ps1).Path + $source=(Resolve-Path .).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -Mode '${{ matrix.mode }}' -SourceRoot $source + if($LASTEXITCODE -ne 0){ + throw 'R355_FULL_ORIGINAL_LIVE_RENAME_CLEANUP_GUEST_FAILED' + } + - name: Retain real original Setup cleanup typed receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R355-full-live-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R354_*/R354_ORIGINAL_DEFERRED_CLEANUP.json + ${{ runner.temp }}/R354_*/R355_LIVE_RENAME_ACTOR.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R355-RealCleanupConcurrentRename-Native.ps1 b/build/Test-R355-RealCleanupConcurrentRename-Native.ps1 new file mode 100644 index 00000000..fc43ef1e --- /dev/null +++ b/build/Test-R355-RealCleanupConcurrentRename-Native.ps1 @@ -0,0 +1,472 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('LiveRace')][string]$Mode, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R354: compile/build the original Setup, launch ONLY its own generated +# unelevated deferred-cleanup command on an already clean, disposable +# GitHub-hosted Windows user profile. Never invoke this on the owner PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R354_REFUSE_NONDISPOSABLE_OR_UNPINNED_HOST' +} +$who=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($who) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_GITHUB_RUNNER_ALREADY_ADMIN_REQUIRED' +} +if($HostLabel -cne 'A' -and $HostLabel -cne 'B'){throw 'R354_INVALID_MATRIX_HOST'} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$program=[Environment]::GetFolderPath('ProgramFiles') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R354_'+$Mode+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R354_ORIGINAL_DEFERRED_CLEANUP.json' +$external=Join-Path $receiptRoot 'R354_OUTSIDE_CANARY_ROOT' +$canary=Join-Path $external 'NEVER_DELETE_OUTSIDE.txt' +$fixture=Join-Path $app 'synthetic-owner-file.txt' +$child=Join-Path $app 'malicious-child-junction' +$checks=[Collections.Generic.List[string]]::new() +$exitCode=-1;$stderr='';$failure='';$phase='PRECONDITION' +$setup='';$sourceExact=$false;$fixtureCreated=$false;$outsideSha='' +$canaryUnchanged=$false;$reparseRefused=$false;$nominalDeleted=$false +$testCleaned=$false;$parentPid=0;$parentTicks=0;$cleanupPid=0 +$compiledEmbeddedSource=$false;$decodedTransport=$false;$nativeOriginalProcessLaunched=$false +$actorProcess=$null;$actorPid=0;$actorReceipt='';$actorReady='';$actorSignal='';$actorProofSha='';$actorDenied=$false;$actorAttempts=0;$actorObservedDeleted=-1;$actorExit=-1;$stageDir='';$raceMoved='';$filesCount=4000 +$spillover='';$spilloverMoved=$false;$spilloverRestored=$false +$spillBefore='';$spillAfter='';$syntheticGone=$false +function FingerprintGuestApp([string]$base){ + if(![IO.Directory]::Exists($base)){throw 'R354_SPILL_NOT_DIRECTORY'} + if(([IO.File]::GetAttributes($base) -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_ROOT_REPARSE'} + $members=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction Stop) + if($members.Count -gt 300){throw 'R354_SPILL_TOO_MANY_ENTRIES'} + $lines=[Collections.Generic.List[string]]::new() + [long]$total=0 + foreach($item in $members){ + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_MEMBER_REPARSE'} + $rel=$item.FullName.Substring($base.TrimEnd('\').Length+1) + if($item.PSIsContainer){$lines.Add('D|'+$rel)} + else{ + if($item.Length -gt 16777216){throw 'R354_SPILL_OVERSIZE_FILE'} + $total+=$item.Length + if($total -gt 67108864){throw 'R354_SPILL_TOO_MANY_BYTES'} + $lines.Add('F|'+$rel+'|'+$item.Length+'|'+(Hash $item.FullName)) + } + } + $digest=[Security.Cryptography.SHA256]::Create() + try{ + $joined=[string]::Join([string][char]10,[string[]]@($lines|Sort-Object -CaseSensitive)) + return ([BitConverter]::ToString($digest.ComputeHash([Text.Encoding]::UTF8.GetBytes($joined)))).Replace('-','') + }finally{$digest.Dispose()} +} + +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function IsTaskPresent(){ + return @((Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_})).Count -ne 0 +} +# This receipt path is RUNNER_TEMP on a verified hosted disposable Windows +# runner, not any owner installation; it must exist even when preflight fails. +if([IO.Directory]::Exists($receiptRoot)){throw 'R354_OWNED_RECEIPT_ROOT_PREEXISTS'} +[IO.Directory]::CreateDirectory($receiptRoot)|Out-Null +$fresh=[ordered]@{AppDir=$false;ProtectedDir=$false;MainFile=$false;SensorTask=$false;SensorProcessCount=0} +try{ + $phase='VERIFY_FRESH_GITHUB_HOSTED_CLEAN_PROFILE' + $fresh.AppDir=[IO.Directory]::Exists($app) + $fresh.ProtectedDir=[IO.Directory]::Exists($protected) + $fresh.MainFile=[IO.File]::Exists($main) + $fresh.SensorTask=[bool](IsTaskPresent) + $fresh.SensorProcessCount=@(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count + Write-Output ('R354_NATIVE_GUEST_FRESHNESS='+($fresh|ConvertTo-Json -Compress)) + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(![IO.File]::Exists($marker)){throw 'R354_PREBUILD_FRESHNESS_PROOF_MISSING'} + $pre=Get-Content -LiteralPath $marker -Raw|ConvertFrom-Json + if($pre.Schema -ne 1 -or $pre.Revision -cne 'R354' -or + $pre.SourceCommit -cne $env:GITHUB_SHA -or + $pre.Host -cne $env:COMPUTERNAME -or + $pre.AppRootAbsentBeforeBuild -ne $true -or + $pre.ProtectedRootAbsentBeforeBuild -ne $true -or + $pre.TaskAbsentBeforeBuild -ne $true){ + throw 'R354_BAD_PREBUILD_FRESHNESS_PROOF' + } + if($fresh.ProtectedDir -or $fresh.MainFile -or + $fresh.SensorTask -or $fresh.SensorProcessCount -gt 0){ + throw 'R354_POSTBUILD_REAL_PRODUCT_OR_SENSOR_REFUSED' + } + if(-not [string]::Equals([IO.Path]::GetFullPath($local), + [IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local')),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_NONCANONICAL_LOCALAPPDATA_REFUSED' + } + [IO.Directory]::CreateDirectory($external)|Out-Null + [IO.File]::WriteAllText($canary,'R354_OUTSIDE_CANARY_DO_NOT_TOUCH',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash $canary + Check 'REAL_GITHUB_HOSTED_WINDOWS_CLEAN_APP_TASK_AND_SENSORS_ABSENT' + Check 'EXPECTED_OWNER_APP_ROOT_IS_CANONICAL_KNOWNFOLDER_NOT_ENV_OVERRIDE' + $phase='ISOLATE_KNOWN_BUILD_ONLY_APPROOT' + if($fresh.AppDir){ + $spillover=Join-Path $local ('TaskbarMonitorEnhanced_R354_BUILD_SNAPSHOT_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $spillover){throw 'R354_SPILL_PREEXISTS'} + $spillBefore=FingerprintGuestApp $app + [IO.Directory]::Move($app,$spillover) + $spilloverMoved=$true + if([IO.Directory]::Exists($app) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_MOVE_SHA_MISMATCH' + } + } + Check 'PREBUILD_FRESH_GUEST_BUILD_ONLY_APPDATA_ATOMICALLY_ISOLATED' + $phase='REFLECT_EXACT_COMPILED_PRODUCTION_SETUP_PRIVATE_FACTORY' + $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $source=Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + foreach($p in @($setup,$source,$native)){ + if(![IO.File]::Exists($p)){throw 'R354_BUILD_OR_TRUSTED_WINDOWS_HOST_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $reader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $expectedSource=[IO.File]::ReadAllText($source) + $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize $expectedSource)){ + throw 'R354_ORIGINAL_PE_SETUP_SOURCE_HASH_MISMATCH' + } + $sourceExact=$true;$compiledEmbeddedSource=$true + Check 'REAL_ORIGINAL_SETUP_PE_EMBEDS_EXACT_REVIEWED_SETUP_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $appProp=$type.GetProperty('AppRoot',$flags) + if(!$builder -or !$appProp){throw 'R354_EXACT_PE_CLEANUP_FACTORY_MISSING'} + $known=[string]$appProp.GetValue($null,$null) + if(-not [string]::Equals($known,$app,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_SETUP_KNOWN_APP_ROOT_CHANGED' + } + Check 'PRODUCTION_SETUP_KNOWN_ROOT_AND_REAL_FACTORY_VERIFIED' + $phase='USE_REAL_EXITED_PARENT_IDENTITY_FOR_DEFERRED_CLEANUP' + $sacStart=[Diagnostics.ProcessStartInfo]::new() + $sacStart.FileName=$native + $sacStart.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $sacStart.UseShellExecute=$false;$sacStart.CreateNoWindow=$true + $parent=[Diagnostics.Process]::Start($sacStart) + if(!$parent){throw 'R354_TEST_PARENT_PROCESS_MISSING'} + try{ + $parentPid=$parent.Id + $parentTicks=$parent.StartTime.ToUniversalTime().Ticks + if(!$parent.WaitForExit(10000)){throw 'R354_REAL_PARENT_DID_NOT_EXIT'} + }finally{$parent.Dispose()} + if($parentPid -le 0 -or $parentTicks -le 0){ + throw 'R354_INVALID_REAL_EXITED_PARENT_IDENTITY' + } + $args=[object[]]::new(3) + $args[0]=([string]$known).PSObject.BaseObject + $args[1]=([int]$parentPid).PSObject.BaseObject + $args[2]=([long]$parentTicks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) + if(-not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + !$psi.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ',[StringComparison]::Ordinal) -or + $psi.Arguments.Contains($app) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ')){ + throw 'R354_REAL_SETUP_PROCESS_FACTORY_NOT_BOUND_TO_TRUSTED_HOST' + } + $decoded=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String( + $psi.Arguments.Substring('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '.Length))) + $tokens=$null;$parseErrors=$null + [Management.Automation.Language.Parser]::ParseInput($decoded,[ref]$tokens,[ref]$parseErrors)|Out-Null + if(@($parseErrors).Count -ne 0 -or + !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' + } + $decodedTransport=$true + Check 'REAL_EXITED_PARENT_ID_AND_UTF16_ONLY_ORIGINAL_SETUP_COMMAND_TRANSPORT' + $phase='CREATE_DISPOSABLE_CANARY_AND_OPTIONAL_JUNCTION' + if($Mode -ceq 'RootJunction'){ + if(Test-Path -LiteralPath $app){throw 'R354_FRESH_ROOT_ALREADY_OCCUPIED'} + New-Item -ItemType Junction -Path $app -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_ROOT_JUNCTION_NOT_ACTUALLY_REPARSE' + } + $fixtureCreated=$true + }else{ + [IO.Directory]::CreateDirectory($app)|Out-Null + [IO.File]::WriteAllText($fixture,'R354_TEST_USER_DATA_ONLY',[Text.UTF8Encoding]::new($false)) + $fixtureCreated=$true + if($Mode -ceq 'LiveRace'){ + $phase='R355_CREATE_BOUNDED_ONLY_HOSTED_TEST_FILES_FOR_REAL_DELETION' + $stageDir=Join-Path $app 'delete_stage' + [IO.Directory]::CreateDirectory($stageDir)|Out-Null + for($i=0;$i -lt $filesCount;$i++){ + [IO.File]::WriteAllText((Join-Path $stageDir ('{0:d5}.bin' -f $i)), + 'R355_ONLY_TEST_DATA',[Text.UTF8Encoding]::new($false)) + } + $check=@([IO.Directory]::GetFiles($stageDir,'*.bin')) + if($check.Count -ne $filesCount -or + ![IO.File]::Exists((Join-Path $stageDir '00000.bin'))){ + throw 'R355_REAL_CLEANUP_FIXTURE_FILES_INCOMPLETE' + } + Check 'BOUNDED_FOUR_THOUSAND_SYNTHETIC_TEST_FILES_ONLY_IN_HOSTED_APPROOT' + } + if($Mode -ceq 'ChildJunction'){ + New-Item -ItemType Junction -Path $child -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_CHILD_JUNCTION_NOT_ACTUALLY_REPARSE' + } + } + } + Check 'ONLY_FRESH_GUEST_OWNED_SYNTHETIC_ROOT_WITH_EXTERNAL_CANARY_PREPARED' + if($Mode -ceq 'LiveRace'){ + $phase='R355_PRESTART_INDEPENDENT_NATIVE_RENAME_ACTOR' + $actorFile=Join-Path $SourceRoot 'build\Test-R355-RealCleanupRenameActor-Native.ps1' + if(![IO.File]::Exists($actorFile)){throw 'R355_RACE_ACTOR_FILE_MISSING'} + $actorReceipt=Join-Path $receiptRoot 'R355_LIVE_RENAME_ACTOR.json' + $actorReady=Join-Path $receiptRoot 'R355_LIVE_RENAME_READY.txt' + $actorSignal=Join-Path $receiptRoot 'R355_LIVE_RENAME_SIGNAL.json' + $raceMoved=$app+'_R355_RACE_'+$HostLabel+'_'+$env:GITHUB_RUN_ID + foreach($path in @($actorReceipt,$actorReady,$actorSignal,$raceMoved)){ + if(Test-Path -LiteralPath $path){throw 'R355_PREEXISTING_RACE_CONTROL_OR_MOVED_TARGET'} + } + foreach($path in @($actorFile,$app,$raceMoved,$actorReceipt,$actorReady,$actorSignal)){ + if($path.Contains('"')){throw 'R355_DISALLOWED_QUOTE_IN_TRUSTED_NATIVE_PATH'} + } + $actorPsi=[Diagnostics.ProcessStartInfo]::new() + $actorPsi.FileName=$native + $actorPsi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorFile+'" -HostLabel "'+$HostLabel+'" -AppRoot "'+$app+'" -MovedRoot "'+$raceMoved+'" -ReadyPath "'+$actorReady+'" -SignalPath "'+$actorSignal+'" -ReceiptPath "'+$actorReceipt+'"' + $actorPsi.CreateNoWindow=$true;$actorPsi.UseShellExecute=$false + $actorProcess=[Diagnostics.Process]::Start($actorPsi) + if(!$actorProcess){throw 'R355_ACTOR_PROCESS_FAILED_TO_START'} + $actorPid=$actorProcess.Id + if($actorPid -le 0 -or $actorPid -eq $PID){throw 'R355_ACTOR_NOT_INDEPENDENT_PID'} + $wait=[Diagnostics.Stopwatch]::StartNew() + while(![IO.File]::Exists($actorReady) -and !$actorProcess.HasExited -and $wait.ElapsedMilliseconds -lt 25000){ + Start-Sleep -Milliseconds 5 + } + if(![IO.File]::Exists($actorReady) -or + [IO.File]::ReadAllText($actorReady) -cne ('R355_REAL_NATIVE_ACTOR_READY_'+$env:GITHUB_SHA)){ + throw 'R355_ACTOR_PREHELPER_RENAME_PREFLIGHT_NOT_PROVEN' + } + Check 'SEPARATE_WINDOWS_RENAME_ACTOR_PREHELPER_ORIGINAL_ROOT_MOVE_AND_RESTORE_READY' + } + $phase='START_REAL_UNMODIFIED_COMPILED_SETUP_CLEANUP_PROCESS' + $psi.RedirectStandardError=$true + $psi.RedirectStandardOutput=$true + $psi.CreateNoWindow=$true + $cleanup=[Diagnostics.Process]::Start($psi) + if(!$cleanup){throw 'R354_REAL_CLEANUP_PROCESS_NOT_STARTED'} + try{ + $cleanupPid=$cleanup.Id + if($Mode -ceq 'LiveRace'){ + $signalInfo=[ordered]@{SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;CleanupPid=$cleanupPid} + [IO.File]::WriteAllText($actorSignal,($signalInfo|ConvertTo-Json -Depth 5), + [Text.UTF8Encoding]::new($false)) + } + if(!$cleanup.WaitForExit(70000)){ + if(!$cleanup.HasExited){$cleanup.Kill();[void]$cleanup.WaitForExit(8000)} + throw 'R354_REAL_CLEANUP_PROCESS_TIMEOUT' + } + $exitCode=[int]$cleanup.ExitCode + $stderr=[string]$cleanup.StandardError.ReadToEnd() + $stdout=[string]$cleanup.StandardOutput.ReadToEnd() + }finally{$cleanup.Dispose()} + $nativeOriginalProcessLaunched=$true + Check 'REAL_COMPILED_SETUP_GENERATED_CLEANUP_EXECUTED_AS_SEPARATE_NATIVE_PROCESS' + if($Mode -ceq 'LiveRace'){ + $phase='R355_VERIFY_REAL_COMPETITOR_OVERLAPPED_ORIGINAL_CLEANUP_DELETION' + if($null -eq $actorProcess){throw 'R355_ACTOR_LOST_BEFORE_WITNESS'} + if(!$actorProcess.WaitForExit(15000)){ + if(!$actorProcess.HasExited){$actorProcess.Kill();[void]$actorProcess.WaitForExit(5000)} + throw 'R355_ACTOR_COMPLETION_TIMED_OUT' + } + $actorExit=$actorProcess.ExitCode + if($actorExit -ne 0 -or -not [IO.File]::Exists($actorReceipt)){ + throw ('R355_ACTOR_EXIT_NONZERO_OR_RECEIPT_MISSING_'+$actorExit) + } + $actorEvidence=Get-Content -LiteralPath $actorReceipt -Raw|ConvertFrom-Json + $actorProofSha=Hash $actorReceipt + if($actorEvidence.Status -cne 'PASS_REAL_NATIVE_ORIGINAL_CLEANUP_DIRECTORY_RENAME_DENIED_ON_LIVE_DELETION' -or + $actorEvidence.SourceCommit -cne $env:GITHUB_SHA -or + $actorEvidence.Host -cne $HostLabel -or + $actorEvidence.ActorPid -ne $actorPid -or + $actorEvidence.OriginalCleanupPid -ne $cleanupPid -or + $actorEvidence.NativeSharingViolationWin32 -ne 32 -or + $actorEvidence.RealRenameDeniedWhileCleanupAlive -ne $true -or + $actorEvidence.FirstSyntheticFileAlreadyDeletedBeforeProbe -lt 0 -or + $actorEvidence.PreHelperRenameSucceededAndRestored -ne $true -or + $actorEvidence.RealRenameAllowedDuringCleanup -ne $false -or + ![string]::IsNullOrEmpty([string]$actorEvidence.Failure)){ + throw 'R355_UNTRUSTED_ACTOR_REPORT_OR_UNPROVEN_LIVE_OVERLAP' + } + $actorDenied=$true + $actorAttempts=[int]$actorEvidence.RenameAttempts + $actorObservedDeleted=[int]$actorEvidence.FirstSyntheticFileAlreadyDeletedBeforeProbe + Check 'ACTUAL_ORIGINAL_COMPILED_CLEANUP_DENIED_CONCURRENT_ROOT_RENAME_AFTER_FIRST_DELETION' + } + $phase='VERIFY_OUTCOME_EXACT_NATIVE_PROCESS_NONFOLLOWING' + if($Mode -ceq 'Nominal' -or $Mode -ceq 'LiveRace'){ + if($exitCode -ne 0 -or [IO.Directory]::Exists($app) -or + $stderr.Contains('TBME_CLEANUP_')){ + throw 'R354_NOMINAL_NATIVE_CLEANUP_NOT_SUCCESSFUL' + } + $nominalDeleted=$true + Check 'ORIGINAL_CLEANUP_NOMINAL_REMOVES_OWNED_TEST_ROOT_AND_NOTHING_ELSE' + }else{ + $expected=if($Mode -ceq 'RootJunction'){'TBME_CLEANUP_ANCESTOR_REPARSE'}else{'TBME_CLEANUP_CHILD_REPARSE'} + if($exitCode -eq 0 -or !$stderr.Contains($expected)){ + throw ('R354_ORIGINAL_NATIVE_CLEANUP_JUNCTION_WAS_NOT_REFUSED_'+$Mode+'_exit_'+$exitCode+'_stderr_'+$stderr.Substring(0,[Math]::Min(320,$stderr.Length))) + } + if($Mode -ceq 'ChildJunction' -and ![IO.File]::Exists($fixture)){ + throw 'R354_OWNER_TEST_PAYLOAD_DELETED_BEFORE_REFUSAL' + } + $junction=if($Mode -ceq 'RootJunction'){$app}else{$child} + if(([IO.File]::GetAttributes($junction) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_JUNCTION_NOT_PRESERVED_ON_REFUSAL' + } + $reparseRefused=$true + Check ('ORIGINAL_CLEANUP_NATIVE_'+$Mode.ToUpperInvariant()+'_JUNCTION_REFUSED_PRE_DELETE') + } + if((Hash $canary) -cne $outsideSha){throw 'R354_OUTSIDE_CANARY_WAS_MODIFIED'} + $canaryUnchanged=$true + Check 'EXTERNAL_CANARY_SHA_UNCHANGED_AFTER_ORIGINAL_CLEANUP_PROCESS' + if([IO.Directory]::Exists($protected) -or (IsTaskPresent)){ + throw 'R354_REAL_SENSOR_INSTALLATION_OR_SCHEDULED_TASK_TRIGGERED' + } + Check 'NO_PROTECTED_SENSOR_OR_DRIVER_SETUP_EVER_INVOKED' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($null -ne $actorProcess){ + try{ + if(!$actorProcess.HasExited){$actorProcess.Kill();[void]$actorProcess.WaitForExit(5000)} + }catch{$failure+=' R355_ACTOR_CHILD_TERMINATION_FAILED'} + try{$actorProcess.Dispose()}catch{$failure+=' R355_ACTOR_DISPOSE_FAILED'} + $actorProcess=$null + } + if($Mode -ceq 'LiveRace' -and $fixtureCreated){ + # No recursive delete; only files with this exact disposable test name and bytes. + try{ + if([IO.Directory]::Exists($raceMoved) -and -not [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($raceMoved) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + [IO.Directory]::Move($raceMoved,$app) + } + if([IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and + [IO.Directory]::Exists($stageDir) -and + ([IO.File]::GetAttributes($stageDir) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + $members=@([IO.Directory]::GetFileSystemEntries($stageDir)) + if($members.Count -le $filesCount){ + foreach($member in $members){ + $name=[IO.Path]::GetFileName($member) + if($name -cnotmatch '^[0-9]{5}\.bin$' -or + ([IO.File]::GetAttributes($member) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [IO.File]::ReadAllText($member) -cne 'R355_ONLY_TEST_DATA'){ + throw 'R355_UNKNOWN_FIXTURE_MEMBER_CLEANUP_REFUSED' + } + [IO.File]::Delete($member) + } + if(@([IO.Directory]::GetFileSystemEntries($stageDir)).Count -eq 0){ + [IO.Directory]::Delete($stageDir,$false) + } + }else{throw 'R355_TOO_MANY_FIXTURE_MEMBERS_NO_CLEANUP'} + } + }catch{$failure+=' R355_BOUNDED_STAGE_FIXTURE_RECOVERY_FAILED='+$_.Exception.Message} + } + # Remove ONLY exact owned synthetic test fixture created on this hosted runner, + # and only after confirming the external canary has not changed. + try{ + if($fixtureCreated -and (Hash $canary) -ceq $outsideSha){ + if($Mode -ceq 'RootJunction' -and [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($app,$false) + } + if($Mode -ceq 'ChildJunction' -and [IO.Directory]::Exists($child) -and + ([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($child,$false) + } + if($Mode -cne 'RootJunction' -and [IO.Directory]::Exists($app)){ + if([IO.File]::Exists($fixture) -and (Hash $fixture) -ceq (( + [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R354_TEST_USER_DATA_ONLY')))).Replace('-',''))){ + [IO.File]::Delete($fixture) + } + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + } + } + if([IO.File]::Exists($canary) -and (Hash $canary) -ceq $outsideSha -and + @([IO.Directory]::GetFileSystemEntries($external)).Count -eq 1){ + [IO.File]::Delete($canary) + [IO.Directory]::Delete($external,$false) + } + $syntheticGone=(![IO.Directory]::Exists($app)) + if($spilloverMoved -and $syntheticGone){ + if(![IO.Directory]::Exists($spillover) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_CHANGED_BEFORE_RESTORE' + } + [IO.Directory]::Move($spillover,$app) + $spillAfter=FingerprintGuestApp $app + $spilloverRestored=($spillAfter -ceq $spillBefore -and ![IO.Directory]::Exists($spillover)) + if(!$spilloverRestored){throw 'R354_BUILD_APPDATA_NOT_EXACTLY_RESTORED'} + } + $testCleaned=($syntheticGone -and + (!$spilloverMoved -or $spilloverRestored) -and + ![IO.Directory]::Exists($external) -and ![IO.Directory]::Exists($protected) -and !(IsTaskPresent)) + }catch{$failure+=' R354_TEST_FIXTURE_CLEANUP_FAILED='+$_.Exception.Message} + $accepted=(!$failure -and $checks.Count -eq 14 -and $testCleaned -and + $actorDenied -and $actorProofSha -cne '' -and + $nativeOriginalProcessLaunched -and $canaryUnchanged -and + ($nominalDeleted -or $reparseRefused)) + $status=if($accepted){'PASS_R355_ORIGINAL_PE_REAL_CLEANUP_CONCURRENT_ROOT_RENAME_DENIED'}else{'FAIL_R355_LIVE_REAL_CLEANUP_UNPROVEN'} + $receiptData=[ordered]@{ + Schema=1;Revision='R355';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;Mode=$Mode + SetupSHA256=$(if($setup -and [IO.File]::Exists($setup)){Hash $setup}else{'MISSING'}) + RealSetupSourceEmbeddedExact=$sourceExact;SourceUnchanged=$compiledEmbeddedSource + TrustedProcessInfoTransport=$decodedTransport + RealExitedParentPid=$parentPid;OriginalNativeCleanupPid=$cleanupPid + ActualOriginalSetupGeneratedCleanupExecuted=$nativeOriginalProcessLaunched + CleanupNativeExitCode=$exitCode + ExpectedJunctionRefusalObserved=$reparseRefused;NormalSyntheticUninstallRemoved=$nominalDeleted + ExternalCanarySHA256=$outsideSha;CanaryUnchanged=$canaryUnchanged + ScopedTestCleanupComplete=$testCleaned + R355OnlySyntheticOwnedFilesCreated=$filesCount + PreBuildGuestAppAbsent=$true + BuildGeneratedAppRootSeen=$fresh.AppDir + BuildGeneratedRootBackupRestored=$spilloverRestored + BuildGeneratedRootShaBefore=$spillBefore + BuildGeneratedRootShaAfter=$spillAfter + SyntheticFixtureRootFullyRemoved=$syntheticGone + GuestFreshness=$fresh + UserOwnerComputerTouched=$false;DriverOrBrokerSetupExecuted=$false + InteractiveUacTested=$false;ConcurrentPathSwapTested=$true + SeparateWindowsActorPid=$actorPid;SeparateActorReceiptSHA256=$actorProofSha + RealConcurrentRenameRefusedDuringOriginalCleanup=$actorDenied + RealConcurrentRenameAttempts=$actorAttempts + FirstDeletedOwnedFileWitnessIndex=$actorObservedDeleted + ActorExitCode=$actorExit + NoForeignOrOwnerFilesAltered=$canaryUnchanged + Checks=@($checks);Failure=$failure + } + try{[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false))}catch{$failure+=' RECEIPT_WRITE_FAILED'} + Write-Output ('R354_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count+' EXIT='+$exitCode) + if($failure){Write-Output ('R354_FAILURE='+$failure)} +} +if($failure){throw ('R354_REAL_DEFERRED_CLEANUP_FAILED '+$failure)} diff --git a/build/Test-R355-RealCleanupRenameActor-Native.ps1 b/build/Test-R355-RealCleanupRenameActor-Native.ps1 new file mode 100644 index 00000000..e6c62867 --- /dev/null +++ b/build/Test-R355-RealCleanupRenameActor-Native.ps1 @@ -0,0 +1,151 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$AppRoot, + [Parameter(Mandatory=$true)][string]$MovedRoot, + [Parameter(Mandatory=$true)][string]$ReadyPath, + [Parameter(Mandatory=$true)][string]$SignalPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# A separate real Windows process in an isolated GitHub-hosted runner. +# ONLY renames exact synthetic per-user application root; no junction, +# driver, real installation or user deletion. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r355-deferred-cleanup-directory-pins' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R355_REAL_CLEANUP_ACTOR_DISPOSABLE_ONLY' +} +$expected=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$canonical=[IO.Path]::GetFullPath($expected) +$expectedMoved=$canonical+'_R355_RACE_'+$HostLabel+'_'+$env:GITHUB_RUN_ID +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +if(-not [string]::Equals($canonical,[IO.Path]::GetFullPath($AppRoot),[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($expectedMoved,[IO.Path]::GetFullPath($MovedRoot),[StringComparison]::OrdinalIgnoreCase) -or + @($ReadyPath,$SignalPath,$ReceiptPath|Where-Object{ + -not [IO.Path]::GetFullPath($_).StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) + }).Count -gt 0){ + throw 'R355_ACTOR_UNTRUSTED_PATH_IDENTITY' +} +$actorPid=$PID;$cleanupPid=0;$preflightMoved=$false +$firstMissing=-1;$renameDenied=$false;$renameAllowed=$false +$hresult=0;$attempts=0;$failure='';$phase='PREFLIGHT';$helperAliveAtDenied=$false +$stage=Join-Path $canonical 'delete_stage' +$movedPath=$expectedMoved +try{ + foreach($p in @($ReadyPath,$SignalPath,$ReceiptPath,$movedPath)){ + if([IO.File]::Exists($p) -or [IO.Directory]::Exists($p)){throw 'R355_ACTOR_CONTROL_OR_MOVED_PREEXISTS'} + } + if(-not [IO.Directory]::Exists($canonical) -or + ([IO.File]::GetAttributes($canonical) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R355_ACTOR_FRESH_SYNTHETIC_ROOT_NOT_NORMAL_DIRECTORY' + } + for($i=0;$i -lt 12;$i++){ + if(-not [IO.File]::Exists((Join-Path $stage ('{0:d5}.bin' -f $i)))){ + throw 'R355_ACTOR_SYNTHETIC_WITNESS_FILE_MISSING' + } + } + # Native same-parent rename must be allowed without Helper's directory lease. + [IO.Directory]::Move($canonical,$movedPath) + if(-not [IO.Directory]::Exists($movedPath)){throw 'R355_PREHELPER_MOVE_NOT_REAL'} + [IO.Directory]::Move($movedPath,$canonical) + if(-not [IO.Directory]::Exists($canonical)){throw 'R355_PREHELPER_MOVE_NOT_RESTORED'} + $preflightMoved=$true + [IO.File]::WriteAllText($ReadyPath,'R355_REAL_NATIVE_ACTOR_READY_'+$env:GITHUB_SHA,[Text.UTF8Encoding]::new($false)) + $phase='WAIT_FOR_NATIVE_ORIGINAL_CLEANUP_PID' + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not [IO.File]::Exists($SignalPath) -and $clock.ElapsedMilliseconds -lt 24000){ + Start-Sleep -Milliseconds 2 + } + if(-not [IO.File]::Exists($SignalPath)){throw 'R355_ACTOR_CLEANUP_SIGNAL_TIMEOUT'} + $signal=Get-Content -LiteralPath $SignalPath -Raw|ConvertFrom-Json + if($signal.SourceCommit -cne $env:GITHUB_SHA -or + $signal.Host -cne $HostLabel -or + $signal.CleanupPid -le 0 -or $signal.CleanupPid -eq $PID){ + throw 'R355_ACTOR_CLEANUP_PID_IDENTITY_UNTRUSTED' + } + $cleanupPid=[int]$signal.CleanupPid + $phase='WAIT_FOR_WITNESSED_FIRST_REAL_SYNTHETIC_FILE_DELETION' + $clock.Restart() + while($clock.ElapsedMilliseconds -lt 27000){ + $live=Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue + if(!$live){break} + for($i=0;$i -lt 12;$i++){ + if(-not [IO.File]::Exists((Join-Path $stage ('{0:d5}.bin' -f $i)))){ + $firstMissing=$i;break + } + } + if($firstMissing -ge 0){break} + Start-Sleep -Milliseconds 1 + } + if($firstMissing -lt 0){throw 'R355_ACTOR_NO_REAL_DELETION_WITNESS_DURING_LIVE_CHILD'} + $phase='ATTEMPT_REAL_SAME_PARENT_DIRECTORY_RENAME_DURING_NATIVE_DELETION' + $clock.Restart() + while($clock.ElapsedMilliseconds -lt 12000){ + $child=Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue + if(!$child){break} + $attempts++ + try{ + [IO.Directory]::Move($canonical,$movedPath) + $renameAllowed=$true + # Strict test cleanup: only reverse our own exact moved root. + if([IO.Directory]::Exists($movedPath) -and -not [IO.Directory]::Exists($canonical)){ + [IO.Directory]::Move($movedPath,$canonical) + } + break + }catch [IO.IOException]{ + $hresult=$_.Exception.HResult -band 65535 + if($hresult -eq 32 -and $null -ne (Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue)){ + $helperAliveAtDenied=$true;$renameDenied=$true;break + } + }catch [UnauthorizedAccessException]{ + $hresult=$_.Exception.HResult -band 65535 + } + Start-Sleep -Milliseconds 1 + } + if($renameAllowed){throw 'R355_REAL_ORIGINAL_CLEANUP_DIRECTORY_RENAME_ALLOWED_WHILE_CHILD_ACTIVE'} + if(-not $renameDenied -or -not $helperAliveAtDenied){ + throw ('R355_REAL_ORIGINAL_CLEANUP_LEASE_DENIAL_NOT_WITNESSED attempts='+$attempts+' win32='+$hresult) + } +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Never delete synthetic app bytes. If actor's rename got through, restore name + # only if the exact sibling is still present, and fail instead of following links. + try{ + if([IO.Directory]::Exists($movedPath) -and + -not [IO.Directory]::Exists($canonical) -and + ([IO.File]::GetAttributes($movedPath) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + [IO.Directory]::Move($movedPath,$canonical) + } + }catch{$failure+=' ACTOR_RESTORE_FAILED='+$_.Exception.Message} + $status=if(-not $failure -and $renameDenied -and $preflightMoved -and + $helperAliveAtDenied -and $firstMissing -ge 0 -and + $attempts -gt 0 -and $hresult -eq 32){ + 'PASS_REAL_NATIVE_ORIGINAL_CLEANUP_DIRECTORY_RENAME_DENIED_ON_LIVE_DELETION' + }else{'FAIL_R355_LIVE_CLEANUP_RENAME_NOT_PROVEN'} + $receipt=[ordered]@{ + Schema=1;Revision='R355';Status=$status;SourceCommit=$env:GITHUB_SHA + Host=$HostLabel;ActorPid=$actorPid;OriginalCleanupPid=$cleanupPid + SeparateNativeProcess=$true;PreHelperRenameSucceededAndRestored=$preflightMoved + FirstSyntheticFileAlreadyDeletedBeforeProbe=$firstMissing + RenameAttempts=$attempts;RealRenameAllowedDuringCleanup=$renameAllowed + NativeSharingViolationWin32=$hresult;RealRenameDeniedWhileCleanupAlive=$renameDenied + OriginalCleanupAliveAtDenial=$helperAliveAtDenied + OutsideCanaryTouched=$false;OwnerMachineTouched=$false + InteractiveUACObserved=$false;Failure=$failure + } + try{ + [IO.File]::WriteAllText($ReceiptPath,($receipt|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + }catch{Write-Output ('R355_ACTOR_RECEIPT_WRITE_FAILURE='+$_.Exception.Message)} + Write-Output ('R355_ACTOR_LIVE_RENAME_STATUS='+$status+' tries='+$attempts+' code='+$hresult) +} +if($failure){throw ('R355_ACTOR_FAILED '+$failure)} From 17d09e3491434841f899706154e7a1478dc91ba5 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Tue, 20 Oct 2026 01:49:52 +0330 Subject: [PATCH 132/135] test(r356): probe Win32 pinned-handle directory disposition vs separate rename actor --- ...356-handle-bound-directory-disposition.yml | 67 ++++++ ...-R356-NativePinnedDirectoryDisposition.ps1 | 202 ++++++++++++++++++ build/Test-R356-RenameActor-Native.ps1 | 63 ++++++ 3 files changed, 332 insertions(+) create mode 100644 .github/workflows/audit-r356-handle-bound-directory-disposition.yml create mode 100644 build/Test-R356-NativePinnedDirectoryDisposition.ps1 create mode 100644 build/Test-R356-RenameActor-Native.ps1 diff --git a/.github/workflows/audit-r356-handle-bound-directory-disposition.yml b/.github/workflows/audit-r356-handle-bound-directory-disposition.yml new file mode 100644 index 00000000..cb12fcbf --- /dev/null +++ b/.github/workflows/audit-r356-handle-bound-directory-disposition.yml @@ -0,0 +1,67 @@ +name: R356 native handle-bound directory deletion feasibility (disposable Windows) +on: + push: + branches: [audit/r356-handle-bound-directory-disposition] + paths: + - build/Test-R356-NativePinnedDirectoryDisposition.ps1 + - build/Test-R356-RenameActor-Native.ps1 + - .github/workflows/audit-r356-handle-bound-directory-disposition.yml + workflow_dispatch: +permissions: + contents: read +jobs: + native-handle-disposition: + name: R356 handle disposition ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 18 + strategy: + fail-fast: false + matrix: + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout pinned source without retaining credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-disposable runner or mismatched source + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R356_GUEST_OR_SHA_GUARD_FAILED' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R356_EXPECT_HOSTED_ADMIN_RUNNER' + } + 'R356_DISPOSABLE_RUNNER_ONLY=PASS' + - name: Run independent Windows process rename and pinned handle disposition + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + $script=(Resolve-Path build/Test-R356-NativePinnedDirectoryDisposition.ps1).Path + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' + if($LASTEXITCODE -ne 0){ + throw 'R356_HANDLE_DISPOSITION_NATIVE_PROTOTYPE_FAILED' + } + - name: Retain typed original Windows native result, even on failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R356-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R356_*/R356_DIRECTORY_DISPOSITION_NATIVE.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R356-NativePinnedDirectoryDisposition.ps1 b/build/Test-R356-NativePinnedDirectoryDisposition.ps1 new file mode 100644 index 00000000..99c07cd6 --- /dev/null +++ b/build/Test-R356-NativePinnedDirectoryDisposition.ps1 @@ -0,0 +1,202 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R356 native feasibility probe only. NO production Setup/Helper mutation. +# Runs on disposable, already-admin GitHub-hosted Windows jobs only. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git -C $PSScriptRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R356_REFUSE_NON_DISPOSABLE_UNPINNED_SOURCE' +} +$admin=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R356_ALREADY_ADMIN_GITHUB_WINDOWS_REQUIRED' +} +$native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' +$actorScript=Join-Path $PSScriptRoot 'Test-R356-RenameActor-Native.ps1' +if(![IO.File]::Exists($native) -or ![IO.File]::Exists($actorScript)){throw 'R356_REQUIRED_NATIVE_SOURCES_MISSING'} +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP) +$testRoot=Join-Path $temp ('R356_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +if([IO.Directory]::Exists($testRoot) -or [IO.File]::Exists($testRoot)){throw 'R356_FIXTURE_PREEXISTS'} +[void][IO.Directory]::CreateDirectory($testRoot) +$target=Join-Path $testRoot 'delete-only-this-directory' +$outside=Join-Path $testRoot 'outside' +$canary=Join-Path $outside 'EXTERNAL_UNTOUCHED.txt' +$leaf=Join-Path $target 'test-leaf.txt' +$receipt=Join-Path $testRoot 'R356_DIRECTORY_DISPOSITION_NATIVE.json' +$checks=[Collections.Generic.List[string]]::new() +$parentPin=$null;$childPin=$null +$exitFailure='';$phase='START' +$preActorPid=0;$inActorPid=0;$nonemptyError=-1;$dispositionCode=-1;$preAllowed=$false;$inDenied=$false;$unlinkDone=$false;$outsideIntact=$false;$cleanupDone=$false +function Check([string]$label){[void]$script:checks.Add($label);Write-Output ('PASS '+$label)} +function H([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} +function Invoke-Actor([string]$which,[string]$path,[string]$externalMarker){ + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($path)) + $e=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($externalMarker)) + $psi=[Diagnostics.ProcessStartInfo]::new() + $psi.FileName=$native + $psi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorScript+'" -Phase '+$which+' -PathB64 '+$encoded+' -CanaryB64 '+$e + $psi.UseShellExecute=$false + $psi.CreateNoWindow=$true + $psi.RedirectStandardOutput=$true + $psi.RedirectStandardError=$true + $proc=[Diagnostics.Process]::Start($psi) + if(!$proc){throw ('R356_NATIVE_ACTOR_START_FAILED_'+$which)} + try{ + if(!$proc.WaitForExit(20000)){ + if(!$proc.HasExited){$proc.Kill();[void]$proc.WaitForExit(4000)} + throw ('R356_NATIVE_ACTOR_TIMEOUT_'+$which) + } + $out=$proc.StandardOutput.ReadToEnd() + $err=$proc.StandardError.ReadToEnd() + if($proc.ExitCode -ne 0){throw ('R356_NATIVE_ACTOR_FAILED_'+$which+'_'+$err+'_'+$out)} + $json=$out|ConvertFrom-Json + if($json.Revision -cne 'R356' -or $json.SourceCommit -cne $env:GITHUB_SHA -or + $json.Phase -cne $which -or $json.Pid -le 0 -or $json.CanaryUnchanged -ne $true){ + throw ('R356_NATIVE_ACTOR_RECEIPT_INVALID_'+$which) + } + return $json + }finally{$proc.Dispose()} +} +try{ + $phase='CREATE_SYNTHETIC_TEMP_ONLY' + [void][IO.Directory]::CreateDirectory($target) + [void][IO.Directory]::CreateDirectory($outside) + [IO.File]::WriteAllText($canary,'R356_OUTSIDE_CANARY_NEVER_CHANGE',[Text.UTF8Encoding]::new($false)) + $outsideSha=H $canary + [IO.File]::WriteAllText($leaf,'R356_TEMP_LEAF_ONLY',[Text.UTF8Encoding]::new($false)) + Check 'ONLY_FRESH_HOSTED_RUNNER_TEMP_SYNTHETIC_FILES' + $phase='COMPILE_NATIVE_DISPOSITION_CLASS' + Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Text; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public sealed class R356NativeDirectoryLease : IDisposable { + const uint LIST=1,DELETE=0x00010000,SHARE_READ_WRITE=3,EXISTING=3, BACKUP=0x02000000,REPARSE=0x00200000; + const uint DIRECTORY=0x10,TAG_REPARSE=0x400; + [StructLayout(LayoutKind.Sequential)] private struct ATTRTAG {public uint Attributes; public uint ReparseTag;} + [StructLayout(LayoutKind.Sequential)] private struct DISPOSITION {public byte DeleteFile;} + [DllImport("kernel32.dll",EntryPoint="CreateFileW",CharSet=CharSet.Unicode,SetLastError=true)] + static extern SafeFileHandle Open(string path,uint access,uint sharing,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll",EntryPoint="GetFileInformationByHandleEx",SetLastError=true)] + static extern bool Info(SafeFileHandle handle,int cls,out ATTRTAG tag,uint len); + [DllImport("kernel32.dll",EntryPoint="GetFinalPathNameByHandleW",CharSet=CharSet.Unicode,SetLastError=true)] + static extern uint Final(SafeFileHandle handle,StringBuilder text,uint chars,uint flags); + [DllImport("kernel32.dll",EntryPoint="SetFileInformationByHandle",SetLastError=true)] + static extern bool Set(SafeFileHandle handle,int cls,ref DISPOSITION info,uint len); + readonly SafeFileHandle handle; + public R356NativeDirectoryLease(string path,bool writableDeletion){ + string exact=Path.GetFullPath(path).TrimEnd('\\'); + SafeFileHandle h=Open(exact,LIST | (writableDeletion?DELETE:0),SHARE_READ_WRITE,IntPtr.Zero,EXISTING,BACKUP|REPARSE,IntPtr.Zero); + if(h.IsInvalid){int code=Marshal.GetLastWin32Error();h.Dispose();throw new IOException("R356_CREATE_WIN32_"+code);} + try{ + ATTRTAG tag; + if(!Info(h,9,out tag,(uint)Marshal.SizeOf(typeof(ATTRTAG))))throw new IOException("R356_INFO_WIN32_"+Marshal.GetLastWin32Error()); + if((tag.Attributes&DIRECTORY)==0 || (tag.Attributes&TAG_REPARSE)!=0)throw new IOException("R356_NON_DIRECTORY_OR_REPARSE"); + StringBuilder full=new StringBuilder(4096); + uint n=Final(h,full,(uint)full.Capacity,0); + if(n==0 || n>=full.Capacity)throw new IOException("R356_FINAL_WIN32_"+Marshal.GetLastWin32Error()); + string resolved=full.ToString(); + if(resolved.StartsWith(@"\\?\",StringComparison.Ordinal))resolved=resolved.Substring(4); + if(!String.Equals(resolved.TrimEnd('\\'),exact,StringComparison.OrdinalIgnoreCase)) + throw new IOException("R356_REDIRECTED_DIR_IDENTITY"); + handle=h; + }catch{h.Dispose();throw;} + } + public int MarkDeleteByExactHandle(){ + DISPOSITION flags=new DISPOSITION();flags.DeleteFile=1; + if(Set(handle,4,ref flags,(uint)Marshal.SizeOf(typeof(DISPOSITION))))return 0; + return Marshal.GetLastWin32Error(); + } + public void Dispose(){handle.Dispose();} +} +'@ + Check 'MICROSOFT_SUPPORTED_HANDLE_DISPOSITION_NATIVE_CSHARP_COMPILES' + $phase='ACTOR_BEFORE_PIN' + $pre=Invoke-Actor 'Before' $target $canary + if($pre.RenameAllowed -ne $true -or $pre.Win32Error -ne 0){throw 'R356_PRE_PIN_RENAME_WAS_NOT_ALLOWED_AND_RESTORED'} + $preActorPid=[int]$pre.Pid;$preAllowed=$true + Check 'INDEPENDENT_ACTOR_CAN_RENAME_EXACT_DIRECTORY_BEFORE_PIN' + $phase='OPEN_NO_SHARE_DELETE_PARENT_AND_CHILD' + $parentPin=[R356NativeDirectoryLease]::new($testRoot,$false) + $childPin=[R356NativeDirectoryLease]::new($target,$true) + Check 'NATIVE_LIST_AND_DELETE_ACCESS_HELD_WITHOUT_FILE_SHARE_DELETE' + $phase='ACTOR_DURING_PIN' + $during=Invoke-Actor 'During' $target $canary + $inActorPid=[int]$during.Pid + if($during.RenameAllowed -ne $false -or $during.Win32Error -ne 32 -or + $inActorPid -eq $PID -or $inActorPid -eq $preActorPid){throw 'R356_SECOND_ACTOR_RENAME_NOT_DENIED_WIN32_32'} + $inDenied=$true + Check 'INDEPENDENT_ACTOR_RENAME_DENIED_WHILE_EXACT_DELETE_ACCESS_HANDLE_PINNED' + $phase='REFUSE_NONEMPTY_HANDLE_DELETE' + $nonemptyError=$childPin.MarkDeleteByExactHandle() + if($nonemptyError -ne 145){throw ('R356_NONEMPTY_DIRECTORY_UNEXPECTED_DELETE_'+$nonemptyError)} + if(![IO.File]::Exists($leaf)){throw 'R356_UNEXPECTED_NONEMPTY_FILE_LOSS'} + Check 'HANDLE_DELETE_REJECTED_NONEMPTY_DIRECTORY_WIN32_145' + $phase='DELETE_ONLY_SYNTHETIC_LEAF_WITH_HANDLE_STILL_PINNED' + if((H $leaf) -cne ([BitConverter]::ToString(([Security.Cryptography.SHA256]::Create()).ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R356_TEMP_LEAF_ONLY')))).Replace('-','')){ + throw 'R356_SYNTHETIC_LEAF_CHANGED' + } + [IO.File]::Delete($leaf) + if([IO.File]::Exists($leaf) -or @([IO.Directory]::GetFileSystemEntries($target)).Count -ne 0){ + throw 'R356_TARGET_NOT_VERIFIED_EMPTY' + } + Check 'KNOWN_LEAF_REMOVED_AND_PINNED_DIRECTORY_VERIFIED_EMPTY' + $phase='SET_DISPOSITION_INFO_ON_STILL_OPEN_EXACT_HANDLE' + $dispositionCode=$childPin.MarkDeleteByExactHandle() + if($dispositionCode -ne 0){throw ('R356_NATIVE_DISPOSITION_FAILED_WIN32_'+$dispositionCode)} + $childPin.Dispose();$childPin=$null + for($i=0;$i -lt 30 -and [IO.Directory]::Exists($target);$i++){Start-Sleep -Milliseconds 50} + if([IO.Directory]::Exists($target)){throw 'R356_HANDLE_DELETE_DID_NOT_REMOVE_EMPTY_DIRECTORY'} + $unlinkDone=$true + Check 'SET_FILE_INFORMATION_BY_HANDLE_DELETES_ONLY_PINNED_EMPTY_DIRECTORY' + $phase='OUTSIDE_CANARY_AND_PARENT_CLEANUP' + if((H $canary) -cne $outsideSha){throw 'R356_EXTERNAL_CANARY_CHANGED'} + $outsideIntact=$true + Check 'OUTSIDE_CANARY_SHA_UNMODIFIED_DURING_NATIVE_DELETE' + $parentPin.Dispose();$parentPin=$null + if(@([IO.Directory]::GetFileSystemEntries($outside)).Count -ne 1 -or (H $canary) -cne $outsideSha){ + throw 'R356_OUTSIDE_CANARY_MEMBERS_CHANGED' + } + [IO.File]::Delete($canary) + [IO.Directory]::Delete($outside,$false) + if(@([IO.Directory]::GetFileSystemEntries($testRoot)).Count -ne 0){throw 'R356_UNEXPECTED_TEST_FIXTURE_RESIDUE'} + $cleanupDone=$true + Check 'ALL_TEST_OWNED_FILES_REMOVED_WITHOUT_RECURSIVE_DELETE' +}catch{ + $exitFailure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($null -ne $childPin){try{$childPin.Dispose()}catch{$exitFailure+=' CHILD_HANDLE_DISPOSE_FAILED'}} + if($null -ne $parentPin){try{$parentPin.Dispose()}catch{$exitFailure+=' PARENT_HANDLE_DISPOSE_FAILED'}} + $ok=(!$exitFailure -and $preAllowed -and $inDenied -and $nonemptyError -eq 145 -and + $dispositionCode -eq 0 -and $unlinkDone -and $outsideIntact -and $cleanupDone -and $checks.Count -eq 10) + $record=[ordered]@{ + Schema=1;Revision='R356';SourceCommit=$env:GITHUB_SHA;Host=$HostLabel + Status=$(if($ok){'PASS_PINNED_EXACT_HANDLE_DISPOSITION_EMPTY_DIRECTORY_NO_PATH_RACE'}else{'FAIL_R356_PROTOTYPE'}) + OriginalProductImplementationChanged=$false + RealOSNativeSetFileInformationByHandleInvoked=$true + IndependentActorBeforePid=$preActorPid;IndependentActorDuringPid=$inActorPid + BeforePinRenameAllowed=$preAllowed;DuringPinRenameDenied=$inDenied + DuringNativeRenameWin32=$(if($inDenied){32}else{-1}) + NonEmptyDirectoryDeleteWin32=$nonemptyError;EmptyDirectoryDispositionWin32=$dispositionCode + DeletedExactPinnedDirectory=$unlinkDone;OutsideCanaryIntact=$outsideIntact + OwnedTemporaryCleanupComplete=$cleanupDone;NoOwnerMachineTouched=$true + NoUACShown=$true;FullNTFSRaceSecurityProven=$false + Checks=@($checks);Failure=$exitFailure + } + try{[IO.File]::WriteAllText($receipt,($record|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false))} + catch{Write-Output ('R356_RECEIPT_WRITE_FAILURE '+$_.Exception.Message)} + Write-Output ('R356_NATIVE_PROTOTYPE_STATUS='+$record.Status+' CHECKS='+$checks.Count+' ERR='+$exitFailure) +} +if($exitFailure){throw ('R356_NATIVE_DISPOSITION_PROTOTYPE_FAILED '+$exitFailure)} diff --git a/build/Test-R356-RenameActor-Native.ps1 b/build/Test-R356-RenameActor-Native.ps1 new file mode 100644 index 00000000..0104d7ed --- /dev/null +++ b/build/Test-R356-RenameActor-Native.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('Before','During')][string]$Phase, + [Parameter(Mandatory=$true)][string]$PathB64, + [Parameter(Mandatory=$true)][string]$CanaryB64 +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$'){ + throw 'R356_RENAME_ACTOR_GUEST_ONLY' +} +$path=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($PathB64)) +$canary=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($CanaryB64)) +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +foreach($p in @($path,$canary)){ + if(![IO.Path]::GetFullPath($p).StartsWith($temp,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R356_RENAME_ACTOR_PATH_ESCAPED_RUNNER_TEMP' + } +} +if($path -cnotmatch '\\R356_[AB]_[0-9]{5,20}\\delete-only-this-directory$' -or + -not [IO.Directory]::Exists($path) -or -not [IO.File]::Exists($canary)){ + throw 'R356_RENAME_ACTOR_FIXED_SYNTHETIC_FIXTURE_MISSING' +} +$sha=(Get-FileHash -LiteralPath $canary -Algorithm SHA256).Hash +$renamed=$path+'_moved' +if([IO.Directory]::Exists($renamed) -or [IO.File]::Exists($renamed)){ + throw 'R356_RENAME_ACTOR_DESTINATION_ALREADY_OCCUPIED' +} +$allowed=$false;$win32=-1 +try{ + [IO.Directory]::Move($path,$renamed) + [IO.Directory]::Move($renamed,$path) + $allowed=$true;$win32=0 +}catch [IO.IOException]{ + $win32=$_.Exception.HResult -band 65535 + if([IO.Directory]::Exists($renamed) -and -not [IO.Directory]::Exists($path)){ + try{[IO.Directory]::Move($renamed,$path)}catch{throw 'R356_ACTOR_RESTORE_OF_RENAMED_DIRECTORY_FAILED'} + } +}catch [UnauthorizedAccessException]{ + $win32=$_.Exception.HResult -band 65535 +} +if((Get-FileHash -LiteralPath $canary -Algorithm SHA256).Hash -cne $sha){ + throw 'R356_ACTOR_OUTSIDE_CANARY_CHANGED' +} +if(-not [IO.Directory]::Exists($path)){ + throw 'R356_ACTOR_ORIGINAL_PATH_NOT_PRESENT_AFTER_PROBE' +} +if($Phase -ceq 'Before' -and (-not $allowed -or $win32 -ne 0)){ + throw ('R356_ACTOR_BEFORE_MUST_SUCCEED_WIN32_'+$win32) +} +if($Phase -ceq 'During' -and ($allowed -or $win32 -ne 32)){ + throw ('R356_ACTOR_DURING_MUST_DENY_NATIVE_SHARE32_GOT_'+$win32) +} +[ordered]@{ + Revision='R356';SourceCommit=$env:GITHUB_SHA;Phase=$Phase + Pid=$PID;RenameAllowed=$allowed;Win32Error=$win32;CanaryUnchanged=$true +}|ConvertTo-Json -Compress From 1fc3f672e69c6a92db4a3792022b5e4c38c50047 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Tue, 20 Oct 2026 01:51:49 +0330 Subject: [PATCH 133/135] test(r356): avoid PowerShell h history alias in native proof helper --- build/Test-R356-NativePinnedDirectoryDisposition.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build/Test-R356-NativePinnedDirectoryDisposition.ps1 b/build/Test-R356-NativePinnedDirectoryDisposition.ps1 index 99c07cd6..0c723ccb 100644 --- a/build/Test-R356-NativePinnedDirectoryDisposition.ps1 +++ b/build/Test-R356-NativePinnedDirectoryDisposition.ps1 @@ -37,7 +37,7 @@ $parentPin=$null;$childPin=$null $exitFailure='';$phase='START' $preActorPid=0;$inActorPid=0;$nonemptyError=-1;$dispositionCode=-1;$preAllowed=$false;$inDenied=$false;$unlinkDone=$false;$outsideIntact=$false;$cleanupDone=$false function Check([string]$label){[void]$script:checks.Add($label);Write-Output ('PASS '+$label)} -function H([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} +function Hash-R356File([string]$file){return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash} function Invoke-Actor([string]$which,[string]$path,[string]$externalMarker){ $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($path)) $e=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($externalMarker)) @@ -71,7 +71,7 @@ try{ [void][IO.Directory]::CreateDirectory($target) [void][IO.Directory]::CreateDirectory($outside) [IO.File]::WriteAllText($canary,'R356_OUTSIDE_CANARY_NEVER_CHANGE',[Text.UTF8Encoding]::new($false)) - $outsideSha=H $canary + $outsideSha=Hash-R356File $canary [IO.File]::WriteAllText($leaf,'R356_TEMP_LEAF_ONLY',[Text.UTF8Encoding]::new($false)) Check 'ONLY_FRESH_HOSTED_RUNNER_TEMP_SYNTHETIC_FILES' $phase='COMPILE_NATIVE_DISPOSITION_CLASS' @@ -144,7 +144,7 @@ public sealed class R356NativeDirectoryLease : IDisposable { if(![IO.File]::Exists($leaf)){throw 'R356_UNEXPECTED_NONEMPTY_FILE_LOSS'} Check 'HANDLE_DELETE_REJECTED_NONEMPTY_DIRECTORY_WIN32_145' $phase='DELETE_ONLY_SYNTHETIC_LEAF_WITH_HANDLE_STILL_PINNED' - if((H $leaf) -cne ([BitConverter]::ToString(([Security.Cryptography.SHA256]::Create()).ComputeHash( + if((Hash-R356File $leaf) -cne ([BitConverter]::ToString(([Security.Cryptography.SHA256]::Create()).ComputeHash( [Text.Encoding]::UTF8.GetBytes('R356_TEMP_LEAF_ONLY')))).Replace('-','')){ throw 'R356_SYNTHETIC_LEAF_CHANGED' } @@ -162,11 +162,11 @@ public sealed class R356NativeDirectoryLease : IDisposable { $unlinkDone=$true Check 'SET_FILE_INFORMATION_BY_HANDLE_DELETES_ONLY_PINNED_EMPTY_DIRECTORY' $phase='OUTSIDE_CANARY_AND_PARENT_CLEANUP' - if((H $canary) -cne $outsideSha){throw 'R356_EXTERNAL_CANARY_CHANGED'} + if((Hash-R356File $canary) -cne $outsideSha){throw 'R356_EXTERNAL_CANARY_CHANGED'} $outsideIntact=$true Check 'OUTSIDE_CANARY_SHA_UNMODIFIED_DURING_NATIVE_DELETE' $parentPin.Dispose();$parentPin=$null - if(@([IO.Directory]::GetFileSystemEntries($outside)).Count -ne 1 -or (H $canary) -cne $outsideSha){ + if(@([IO.Directory]::GetFileSystemEntries($outside)).Count -ne 1 -or (Hash-R356File $canary) -cne $outsideSha){ throw 'R356_OUTSIDE_CANARY_MEMBERS_CHANGED' } [IO.File]::Delete($canary) From 8451c6ca1e46eb95de864dfcaeae4494429ae860 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Tue, 20 Oct 2026 02:04:00 +0330 Subject: [PATCH 134/135] fix(security): delete exact empty cleanup directories through pinned native handles --- ...inal-setup-directory-handle-regression.yml | 105 ++++ ...inalConcurrentRename-Regression-Native.ps1 | 472 ++++++++++++++++++ ...6-OriginalConcurrentRenameActor-Native.ps1 | 151 ++++++ ...ginalDeferredCleanup-Regression-Native.ps1 | 344 +++++++++++++ installer/TaskbarMonitorEnhanced_Setup.cs | 45 +- 5 files changed, 1097 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/audit-r356-original-setup-directory-handle-regression.yml create mode 100644 build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 create mode 100644 build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 create mode 100644 build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 diff --git a/.github/workflows/audit-r356-original-setup-directory-handle-regression.yml b/.github/workflows/audit-r356-original-setup-directory-handle-regression.yml new file mode 100644 index 00000000..1b1b2ba3 --- /dev/null +++ b/.github/workflows/audit-r356-original-setup-directory-handle-regression.yml @@ -0,0 +1,105 @@ +name: R356 original PE Setup deletion by pinned handles (disposable Windows) +on: + push: + branches: [audit/r356-handle-bound-directory-disposition] + paths: + - installer/TaskbarMonitorEnhanced_Setup.cs + - build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 + - build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 + - build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 + - .github/workflows/audit-r356-original-setup-directory-handle-regression.yml + workflow_dispatch: +permissions: + contents: read + actions: read +jobs: + original-setup: + name: R356 original Setup ${{ matrix.mode }} ${{ matrix.host }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + mode: [Nominal, RootJunction, ChildJunction, LiveRace] + host: [A, B] + include: + - host: A + os: windows-2025 + - host: B + os: windows-2022 + steps: + - name: Checkout immutable head without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse non-disposable runner and preexisting installed product + shell: pwsh + run: | + $ErrorActionPreference='Stop' + if($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + (git rev-parse HEAD).Trim() -cne '${{ github.sha }}'){ + throw 'R356_ONLY_EXACT_DISPOSABLE_WINDOWS_GUEST' + } + $app=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' + $protected=Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'TaskbarMonitorEnhanced' + if((Test-Path -LiteralPath $app) -or (Test-Path -LiteralPath $protected) -or + @(Get-ScheduledTask -TaskName 'TaskbarMonitorEnhanced Sensor Broker' -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_}).Count){ + throw 'R356_REJECT_PREEXISTING_USER_INSTALLATION_OR_SCHEDULED_TASK' + } + $who=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if(!$who.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R356_RUNNER_ALREADY_ADMIN_REQUIRED' + } + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(Test-Path -LiteralPath $marker){throw 'R356_MARKER_PREEXISTS'} + $proof=[ordered]@{ + Schema=1;Revision='R354';SourceCommit=$env:GITHUB_SHA + Host=$env:COMPUTERNAME + AppRootAbsentBeforeBuild=$true;ProtectedRootAbsentBeforeBuild=$true + TaskAbsentBeforeBuild=$true + } + [IO.File]::WriteAllText($marker,($proof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false)) + 'R356_HOSTED_PREBUILD_SANDBOX_GUARD_PASS' + - name: Install pinned .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.400 + - name: Build modified private Setup and four components + shell: pwsh + run: | + $ErrorActionPreference='Stop' + ./build/Build.ps1 + if($LASTEXITCODE -ne 0 -or + !(Test-Path -LiteralPath build/_out/Setup/TaskbarMonitorEnhanced_Setup_1.7.8.exe)){ + throw 'R356_PRIVATE_SETUP_BUILD_FAILED' + } + - name: Run actual production-source Setup generated deferred cleanup + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $mode='${{ matrix.mode }}' + $source=(Resolve-Path .).Path + $script=if($mode -ceq 'LiveRace'){ + (Resolve-Path build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1).Path + }else{ + (Resolve-Path build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1).Path + } + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + & $native -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $script -HostLabel '${{ matrix.host }}' -Mode $mode -SourceRoot $source + if($LASTEXITCODE -ne 0){throw 'R356_ORIGINAL_NATIVE_DEFERRED_CLEANUP_REGRESSION_FAILED'} + - name: Retain SHA-auditable original PE guest receipts on success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: R356-prod-${{ matrix.mode }}-${{ matrix.host }}-${{ github.run_id }} + path: | + ${{ runner.temp }}/R354_*/R354_ORIGINAL_DEFERRED_CLEANUP.json + ${{ runner.temp }}/R354_*/R355_LIVE_RENAME_ACTOR.json + retention-days: 7 + if-no-files-found: warn diff --git a/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 b/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 new file mode 100644 index 00000000..3f82ef85 --- /dev/null +++ b/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 @@ -0,0 +1,472 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('LiveRace')][string]$Mode, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R354: compile/build the original Setup, launch ONLY its own generated +# unelevated deferred-cleanup command on an already clean, disposable +# GitHub-hosted Windows user profile. Never invoke this on the owner PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R354_REFUSE_NONDISPOSABLE_OR_UNPINNED_HOST' +} +$who=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($who) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_GITHUB_RUNNER_ALREADY_ADMIN_REQUIRED' +} +if($HostLabel -cne 'A' -and $HostLabel -cne 'B'){throw 'R354_INVALID_MATRIX_HOST'} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$program=[Environment]::GetFolderPath('ProgramFiles') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R354_'+$Mode+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R354_ORIGINAL_DEFERRED_CLEANUP.json' +$external=Join-Path $receiptRoot 'R354_OUTSIDE_CANARY_ROOT' +$canary=Join-Path $external 'NEVER_DELETE_OUTSIDE.txt' +$fixture=Join-Path $app 'synthetic-owner-file.txt' +$child=Join-Path $app 'malicious-child-junction' +$checks=[Collections.Generic.List[string]]::new() +$exitCode=-1;$stderr='';$failure='';$phase='PRECONDITION' +$setup='';$sourceExact=$false;$fixtureCreated=$false;$outsideSha='' +$canaryUnchanged=$false;$reparseRefused=$false;$nominalDeleted=$false +$testCleaned=$false;$parentPid=0;$parentTicks=0;$cleanupPid=0 +$compiledEmbeddedSource=$false;$decodedTransport=$false;$nativeOriginalProcessLaunched=$false +$actorProcess=$null;$actorPid=0;$actorReceipt='';$actorReady='';$actorSignal='';$actorProofSha='';$actorDenied=$false;$actorAttempts=0;$actorObservedDeleted=-1;$actorExit=-1;$stageDir='';$raceMoved='';$filesCount=4000 +$spillover='';$spilloverMoved=$false;$spilloverRestored=$false +$spillBefore='';$spillAfter='';$syntheticGone=$false +function FingerprintGuestApp([string]$base){ + if(![IO.Directory]::Exists($base)){throw 'R354_SPILL_NOT_DIRECTORY'} + if(([IO.File]::GetAttributes($base) -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_ROOT_REPARSE'} + $members=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction Stop) + if($members.Count -gt 300){throw 'R354_SPILL_TOO_MANY_ENTRIES'} + $lines=[Collections.Generic.List[string]]::new() + [long]$total=0 + foreach($item in $members){ + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_MEMBER_REPARSE'} + $rel=$item.FullName.Substring($base.TrimEnd('\').Length+1) + if($item.PSIsContainer){$lines.Add('D|'+$rel)} + else{ + if($item.Length -gt 16777216){throw 'R354_SPILL_OVERSIZE_FILE'} + $total+=$item.Length + if($total -gt 67108864){throw 'R354_SPILL_TOO_MANY_BYTES'} + $lines.Add('F|'+$rel+'|'+$item.Length+'|'+(Hash $item.FullName)) + } + } + $digest=[Security.Cryptography.SHA256]::Create() + try{ + $joined=[string]::Join([string][char]10,[string[]]@($lines|Sort-Object -CaseSensitive)) + return ([BitConverter]::ToString($digest.ComputeHash([Text.Encoding]::UTF8.GetBytes($joined)))).Replace('-','') + }finally{$digest.Dispose()} +} + +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function IsTaskPresent(){ + return @((Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_})).Count -ne 0 +} +# This receipt path is RUNNER_TEMP on a verified hosted disposable Windows +# runner, not any owner installation; it must exist even when preflight fails. +if([IO.Directory]::Exists($receiptRoot)){throw 'R354_OWNED_RECEIPT_ROOT_PREEXISTS'} +[IO.Directory]::CreateDirectory($receiptRoot)|Out-Null +$fresh=[ordered]@{AppDir=$false;ProtectedDir=$false;MainFile=$false;SensorTask=$false;SensorProcessCount=0} +try{ + $phase='VERIFY_FRESH_GITHUB_HOSTED_CLEAN_PROFILE' + $fresh.AppDir=[IO.Directory]::Exists($app) + $fresh.ProtectedDir=[IO.Directory]::Exists($protected) + $fresh.MainFile=[IO.File]::Exists($main) + $fresh.SensorTask=[bool](IsTaskPresent) + $fresh.SensorProcessCount=@(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count + Write-Output ('R354_NATIVE_GUEST_FRESHNESS='+($fresh|ConvertTo-Json -Compress)) + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(![IO.File]::Exists($marker)){throw 'R354_PREBUILD_FRESHNESS_PROOF_MISSING'} + $pre=Get-Content -LiteralPath $marker -Raw|ConvertFrom-Json + if($pre.Schema -ne 1 -or $pre.Revision -cne 'R354' -or + $pre.SourceCommit -cne $env:GITHUB_SHA -or + $pre.Host -cne $env:COMPUTERNAME -or + $pre.AppRootAbsentBeforeBuild -ne $true -or + $pre.ProtectedRootAbsentBeforeBuild -ne $true -or + $pre.TaskAbsentBeforeBuild -ne $true){ + throw 'R354_BAD_PREBUILD_FRESHNESS_PROOF' + } + if($fresh.ProtectedDir -or $fresh.MainFile -or + $fresh.SensorTask -or $fresh.SensorProcessCount -gt 0){ + throw 'R354_POSTBUILD_REAL_PRODUCT_OR_SENSOR_REFUSED' + } + if(-not [string]::Equals([IO.Path]::GetFullPath($local), + [IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local')),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_NONCANONICAL_LOCALAPPDATA_REFUSED' + } + [IO.Directory]::CreateDirectory($external)|Out-Null + [IO.File]::WriteAllText($canary,'R354_OUTSIDE_CANARY_DO_NOT_TOUCH',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash $canary + Check 'REAL_GITHUB_HOSTED_WINDOWS_CLEAN_APP_TASK_AND_SENSORS_ABSENT' + Check 'EXPECTED_OWNER_APP_ROOT_IS_CANONICAL_KNOWNFOLDER_NOT_ENV_OVERRIDE' + $phase='ISOLATE_KNOWN_BUILD_ONLY_APPROOT' + if($fresh.AppDir){ + $spillover=Join-Path $local ('TaskbarMonitorEnhanced_R354_BUILD_SNAPSHOT_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $spillover){throw 'R354_SPILL_PREEXISTS'} + $spillBefore=FingerprintGuestApp $app + [IO.Directory]::Move($app,$spillover) + $spilloverMoved=$true + if([IO.Directory]::Exists($app) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_MOVE_SHA_MISMATCH' + } + } + Check 'PREBUILD_FRESH_GUEST_BUILD_ONLY_APPDATA_ATOMICALLY_ISOLATED' + $phase='REFLECT_EXACT_COMPILED_PRODUCTION_SETUP_PRIVATE_FACTORY' + $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $source=Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + foreach($p in @($setup,$source,$native)){ + if(![IO.File]::Exists($p)){throw 'R354_BUILD_OR_TRUSTED_WINDOWS_HOST_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $reader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $expectedSource=[IO.File]::ReadAllText($source) + $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize $expectedSource)){ + throw 'R354_ORIGINAL_PE_SETUP_SOURCE_HASH_MISMATCH' + } + $sourceExact=$true;$compiledEmbeddedSource=$true + Check 'REAL_ORIGINAL_SETUP_PE_EMBEDS_EXACT_REVIEWED_SETUP_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $appProp=$type.GetProperty('AppRoot',$flags) + if(!$builder -or !$appProp){throw 'R354_EXACT_PE_CLEANUP_FACTORY_MISSING'} + $known=[string]$appProp.GetValue($null,$null) + if(-not [string]::Equals($known,$app,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_SETUP_KNOWN_APP_ROOT_CHANGED' + } + Check 'PRODUCTION_SETUP_KNOWN_ROOT_AND_REAL_FACTORY_VERIFIED' + $phase='USE_REAL_EXITED_PARENT_IDENTITY_FOR_DEFERRED_CLEANUP' + $sacStart=[Diagnostics.ProcessStartInfo]::new() + $sacStart.FileName=$native + $sacStart.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $sacStart.UseShellExecute=$false;$sacStart.CreateNoWindow=$true + $parent=[Diagnostics.Process]::Start($sacStart) + if(!$parent){throw 'R354_TEST_PARENT_PROCESS_MISSING'} + try{ + $parentPid=$parent.Id + $parentTicks=$parent.StartTime.ToUniversalTime().Ticks + if(!$parent.WaitForExit(10000)){throw 'R354_REAL_PARENT_DID_NOT_EXIT'} + }finally{$parent.Dispose()} + if($parentPid -le 0 -or $parentTicks -le 0){ + throw 'R354_INVALID_REAL_EXITED_PARENT_IDENTITY' + } + $args=[object[]]::new(3) + $args[0]=([string]$known).PSObject.BaseObject + $args[1]=([int]$parentPid).PSObject.BaseObject + $args[2]=([long]$parentTicks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) + if(-not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + !$psi.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ',[StringComparison]::Ordinal) -or + $psi.Arguments.Contains($app) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ')){ + throw 'R354_REAL_SETUP_PROCESS_FACTORY_NOT_BOUND_TO_TRUSTED_HOST' + } + $decoded=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String( + $psi.Arguments.Substring('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '.Length))) + $tokens=$null;$parseErrors=$null + [Management.Automation.Language.Parser]::ParseInput($decoded,[ref]$tokens,[ref]$parseErrors)|Out-Null + if(@($parseErrors).Count -ne 0 -or + !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' + } + $decodedTransport=$true + Check 'REAL_EXITED_PARENT_ID_AND_UTF16_ONLY_ORIGINAL_SETUP_COMMAND_TRANSPORT' + $phase='CREATE_DISPOSABLE_CANARY_AND_OPTIONAL_JUNCTION' + if($Mode -ceq 'RootJunction'){ + if(Test-Path -LiteralPath $app){throw 'R354_FRESH_ROOT_ALREADY_OCCUPIED'} + New-Item -ItemType Junction -Path $app -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_ROOT_JUNCTION_NOT_ACTUALLY_REPARSE' + } + $fixtureCreated=$true + }else{ + [IO.Directory]::CreateDirectory($app)|Out-Null + [IO.File]::WriteAllText($fixture,'R354_TEST_USER_DATA_ONLY',[Text.UTF8Encoding]::new($false)) + $fixtureCreated=$true + if($Mode -ceq 'LiveRace'){ + $phase='R355_CREATE_BOUNDED_ONLY_HOSTED_TEST_FILES_FOR_REAL_DELETION' + $stageDir=Join-Path $app 'delete_stage' + [IO.Directory]::CreateDirectory($stageDir)|Out-Null + for($i=0;$i -lt $filesCount;$i++){ + [IO.File]::WriteAllText((Join-Path $stageDir ('{0:d5}.bin' -f $i)), + 'R355_ONLY_TEST_DATA',[Text.UTF8Encoding]::new($false)) + } + $check=@([IO.Directory]::GetFiles($stageDir,'*.bin')) + if($check.Count -ne $filesCount -or + ![IO.File]::Exists((Join-Path $stageDir '00000.bin'))){ + throw 'R355_REAL_CLEANUP_FIXTURE_FILES_INCOMPLETE' + } + Check 'BOUNDED_FOUR_THOUSAND_SYNTHETIC_TEST_FILES_ONLY_IN_HOSTED_APPROOT' + } + if($Mode -ceq 'ChildJunction'){ + New-Item -ItemType Junction -Path $child -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_CHILD_JUNCTION_NOT_ACTUALLY_REPARSE' + } + } + } + Check 'ONLY_FRESH_GUEST_OWNED_SYNTHETIC_ROOT_WITH_EXTERNAL_CANARY_PREPARED' + if($Mode -ceq 'LiveRace'){ + $phase='R355_PRESTART_INDEPENDENT_NATIVE_RENAME_ACTOR' + $actorFile=Join-Path $SourceRoot 'build\Test-R356-OriginalConcurrentRenameActor-Native.ps1' + if(![IO.File]::Exists($actorFile)){throw 'R355_RACE_ACTOR_FILE_MISSING'} + $actorReceipt=Join-Path $receiptRoot 'R355_LIVE_RENAME_ACTOR.json' + $actorReady=Join-Path $receiptRoot 'R355_LIVE_RENAME_READY.txt' + $actorSignal=Join-Path $receiptRoot 'R355_LIVE_RENAME_SIGNAL.json' + $raceMoved=$app+'_R355_RACE_'+$HostLabel+'_'+$env:GITHUB_RUN_ID + foreach($path in @($actorReceipt,$actorReady,$actorSignal,$raceMoved)){ + if(Test-Path -LiteralPath $path){throw 'R355_PREEXISTING_RACE_CONTROL_OR_MOVED_TARGET'} + } + foreach($path in @($actorFile,$app,$raceMoved,$actorReceipt,$actorReady,$actorSignal)){ + if($path.Contains('"')){throw 'R355_DISALLOWED_QUOTE_IN_TRUSTED_NATIVE_PATH'} + } + $actorPsi=[Diagnostics.ProcessStartInfo]::new() + $actorPsi.FileName=$native + $actorPsi.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$actorFile+'" -HostLabel "'+$HostLabel+'" -AppRoot "'+$app+'" -MovedRoot "'+$raceMoved+'" -ReadyPath "'+$actorReady+'" -SignalPath "'+$actorSignal+'" -ReceiptPath "'+$actorReceipt+'"' + $actorPsi.CreateNoWindow=$true;$actorPsi.UseShellExecute=$false + $actorProcess=[Diagnostics.Process]::Start($actorPsi) + if(!$actorProcess){throw 'R355_ACTOR_PROCESS_FAILED_TO_START'} + $actorPid=$actorProcess.Id + if($actorPid -le 0 -or $actorPid -eq $PID){throw 'R355_ACTOR_NOT_INDEPENDENT_PID'} + $wait=[Diagnostics.Stopwatch]::StartNew() + while(![IO.File]::Exists($actorReady) -and !$actorProcess.HasExited -and $wait.ElapsedMilliseconds -lt 25000){ + Start-Sleep -Milliseconds 5 + } + if(![IO.File]::Exists($actorReady) -or + [IO.File]::ReadAllText($actorReady) -cne ('R355_REAL_NATIVE_ACTOR_READY_'+$env:GITHUB_SHA)){ + throw 'R355_ACTOR_PREHELPER_RENAME_PREFLIGHT_NOT_PROVEN' + } + Check 'SEPARATE_WINDOWS_RENAME_ACTOR_PREHELPER_ORIGINAL_ROOT_MOVE_AND_RESTORE_READY' + } + $phase='START_REAL_UNMODIFIED_COMPILED_SETUP_CLEANUP_PROCESS' + $psi.RedirectStandardError=$true + $psi.RedirectStandardOutput=$true + $psi.CreateNoWindow=$true + $cleanup=[Diagnostics.Process]::Start($psi) + if(!$cleanup){throw 'R354_REAL_CLEANUP_PROCESS_NOT_STARTED'} + try{ + $cleanupPid=$cleanup.Id + if($Mode -ceq 'LiveRace'){ + $signalInfo=[ordered]@{SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;CleanupPid=$cleanupPid} + [IO.File]::WriteAllText($actorSignal,($signalInfo|ConvertTo-Json -Depth 5), + [Text.UTF8Encoding]::new($false)) + } + if(!$cleanup.WaitForExit(70000)){ + if(!$cleanup.HasExited){$cleanup.Kill();[void]$cleanup.WaitForExit(8000)} + throw 'R354_REAL_CLEANUP_PROCESS_TIMEOUT' + } + $exitCode=[int]$cleanup.ExitCode + $stderr=[string]$cleanup.StandardError.ReadToEnd() + $stdout=[string]$cleanup.StandardOutput.ReadToEnd() + }finally{$cleanup.Dispose()} + $nativeOriginalProcessLaunched=$true + Check 'REAL_COMPILED_SETUP_GENERATED_CLEANUP_EXECUTED_AS_SEPARATE_NATIVE_PROCESS' + if($Mode -ceq 'LiveRace'){ + $phase='R355_VERIFY_REAL_COMPETITOR_OVERLAPPED_ORIGINAL_CLEANUP_DELETION' + if($null -eq $actorProcess){throw 'R355_ACTOR_LOST_BEFORE_WITNESS'} + if(!$actorProcess.WaitForExit(15000)){ + if(!$actorProcess.HasExited){$actorProcess.Kill();[void]$actorProcess.WaitForExit(5000)} + throw 'R355_ACTOR_COMPLETION_TIMED_OUT' + } + $actorExit=$actorProcess.ExitCode + if($actorExit -ne 0 -or -not [IO.File]::Exists($actorReceipt)){ + throw ('R355_ACTOR_EXIT_NONZERO_OR_RECEIPT_MISSING_'+$actorExit) + } + $actorEvidence=Get-Content -LiteralPath $actorReceipt -Raw|ConvertFrom-Json + $actorProofSha=Hash $actorReceipt + if($actorEvidence.Status -cne 'PASS_REAL_NATIVE_ORIGINAL_CLEANUP_DIRECTORY_RENAME_DENIED_ON_LIVE_DELETION' -or + $actorEvidence.SourceCommit -cne $env:GITHUB_SHA -or + $actorEvidence.Host -cne $HostLabel -or + $actorEvidence.ActorPid -ne $actorPid -or + $actorEvidence.OriginalCleanupPid -ne $cleanupPid -or + $actorEvidence.NativeSharingViolationWin32 -ne 32 -or + $actorEvidence.RealRenameDeniedWhileCleanupAlive -ne $true -or + $actorEvidence.FirstSyntheticFileAlreadyDeletedBeforeProbe -lt 0 -or + $actorEvidence.PreHelperRenameSucceededAndRestored -ne $true -or + $actorEvidence.RealRenameAllowedDuringCleanup -ne $false -or + ![string]::IsNullOrEmpty([string]$actorEvidence.Failure)){ + throw 'R355_UNTRUSTED_ACTOR_REPORT_OR_UNPROVEN_LIVE_OVERLAP' + } + $actorDenied=$true + $actorAttempts=[int]$actorEvidence.RenameAttempts + $actorObservedDeleted=[int]$actorEvidence.FirstSyntheticFileAlreadyDeletedBeforeProbe + Check 'ACTUAL_ORIGINAL_COMPILED_CLEANUP_DENIED_CONCURRENT_ROOT_RENAME_AFTER_FIRST_DELETION' + } + $phase='VERIFY_OUTCOME_EXACT_NATIVE_PROCESS_NONFOLLOWING' + if($Mode -ceq 'Nominal' -or $Mode -ceq 'LiveRace'){ + if($exitCode -ne 0 -or [IO.Directory]::Exists($app) -or + $stderr.Contains('TBME_CLEANUP_')){ + throw 'R354_NOMINAL_NATIVE_CLEANUP_NOT_SUCCESSFUL' + } + $nominalDeleted=$true + Check 'ORIGINAL_CLEANUP_NOMINAL_REMOVES_OWNED_TEST_ROOT_AND_NOTHING_ELSE' + }else{ + $expected=if($Mode -ceq 'RootJunction'){'TBME_CLEANUP_ANCESTOR_REPARSE'}else{'TBME_CLEANUP_CHILD_REPARSE'} + if($exitCode -eq 0 -or !$stderr.Contains($expected)){ + throw ('R354_ORIGINAL_NATIVE_CLEANUP_JUNCTION_WAS_NOT_REFUSED_'+$Mode+'_exit_'+$exitCode+'_stderr_'+$stderr.Substring(0,[Math]::Min(320,$stderr.Length))) + } + if($Mode -ceq 'ChildJunction' -and ![IO.File]::Exists($fixture)){ + throw 'R354_OWNER_TEST_PAYLOAD_DELETED_BEFORE_REFUSAL' + } + $junction=if($Mode -ceq 'RootJunction'){$app}else{$child} + if(([IO.File]::GetAttributes($junction) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_JUNCTION_NOT_PRESERVED_ON_REFUSAL' + } + $reparseRefused=$true + Check ('ORIGINAL_CLEANUP_NATIVE_'+$Mode.ToUpperInvariant()+'_JUNCTION_REFUSED_PRE_DELETE') + } + if((Hash $canary) -cne $outsideSha){throw 'R354_OUTSIDE_CANARY_WAS_MODIFIED'} + $canaryUnchanged=$true + Check 'EXTERNAL_CANARY_SHA_UNCHANGED_AFTER_ORIGINAL_CLEANUP_PROCESS' + if([IO.Directory]::Exists($protected) -or (IsTaskPresent)){ + throw 'R354_REAL_SENSOR_INSTALLATION_OR_SCHEDULED_TASK_TRIGGERED' + } + Check 'NO_PROTECTED_SENSOR_OR_DRIVER_SETUP_EVER_INVOKED' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + if($null -ne $actorProcess){ + try{ + if(!$actorProcess.HasExited){$actorProcess.Kill();[void]$actorProcess.WaitForExit(5000)} + }catch{$failure+=' R355_ACTOR_CHILD_TERMINATION_FAILED'} + try{$actorProcess.Dispose()}catch{$failure+=' R355_ACTOR_DISPOSE_FAILED'} + $actorProcess=$null + } + if($Mode -ceq 'LiveRace' -and $fixtureCreated){ + # No recursive delete; only files with this exact disposable test name and bytes. + try{ + if([IO.Directory]::Exists($raceMoved) -and -not [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($raceMoved) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + [IO.Directory]::Move($raceMoved,$app) + } + if([IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and + [IO.Directory]::Exists($stageDir) -and + ([IO.File]::GetAttributes($stageDir) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + $members=@([IO.Directory]::GetFileSystemEntries($stageDir)) + if($members.Count -le $filesCount){ + foreach($member in $members){ + $name=[IO.Path]::GetFileName($member) + if($name -cnotmatch '^[0-9]{5}\.bin$' -or + ([IO.File]::GetAttributes($member) -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [IO.File]::ReadAllText($member) -cne 'R355_ONLY_TEST_DATA'){ + throw 'R355_UNKNOWN_FIXTURE_MEMBER_CLEANUP_REFUSED' + } + [IO.File]::Delete($member) + } + if(@([IO.Directory]::GetFileSystemEntries($stageDir)).Count -eq 0){ + [IO.Directory]::Delete($stageDir,$false) + } + }else{throw 'R355_TOO_MANY_FIXTURE_MEMBERS_NO_CLEANUP'} + } + }catch{$failure+=' R355_BOUNDED_STAGE_FIXTURE_RECOVERY_FAILED='+$_.Exception.Message} + } + # Remove ONLY exact owned synthetic test fixture created on this hosted runner, + # and only after confirming the external canary has not changed. + try{ + if($fixtureCreated -and (Hash $canary) -ceq $outsideSha){ + if($Mode -ceq 'RootJunction' -and [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($app,$false) + } + if($Mode -ceq 'ChildJunction' -and [IO.Directory]::Exists($child) -and + ([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($child,$false) + } + if($Mode -cne 'RootJunction' -and [IO.Directory]::Exists($app)){ + if([IO.File]::Exists($fixture) -and (Hash $fixture) -ceq (( + [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R354_TEST_USER_DATA_ONLY')))).Replace('-',''))){ + [IO.File]::Delete($fixture) + } + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + } + } + if([IO.File]::Exists($canary) -and (Hash $canary) -ceq $outsideSha -and + @([IO.Directory]::GetFileSystemEntries($external)).Count -eq 1){ + [IO.File]::Delete($canary) + [IO.Directory]::Delete($external,$false) + } + $syntheticGone=(![IO.Directory]::Exists($app)) + if($spilloverMoved -and $syntheticGone){ + if(![IO.Directory]::Exists($spillover) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_CHANGED_BEFORE_RESTORE' + } + [IO.Directory]::Move($spillover,$app) + $spillAfter=FingerprintGuestApp $app + $spilloverRestored=($spillAfter -ceq $spillBefore -and ![IO.Directory]::Exists($spillover)) + if(!$spilloverRestored){throw 'R354_BUILD_APPDATA_NOT_EXACTLY_RESTORED'} + } + $testCleaned=($syntheticGone -and + (!$spilloverMoved -or $spilloverRestored) -and + ![IO.Directory]::Exists($external) -and ![IO.Directory]::Exists($protected) -and !(IsTaskPresent)) + }catch{$failure+=' R354_TEST_FIXTURE_CLEANUP_FAILED='+$_.Exception.Message} + $accepted=(!$failure -and $checks.Count -eq 14 -and $testCleaned -and + $actorDenied -and $actorProofSha -cne '' -and + $nativeOriginalProcessLaunched -and $canaryUnchanged -and + ($nominalDeleted -or $reparseRefused)) + $status=if($accepted){'PASS_R355_ORIGINAL_PE_REAL_CLEANUP_CONCURRENT_ROOT_RENAME_DENIED'}else{'FAIL_R355_LIVE_REAL_CLEANUP_UNPROVEN'} + $receiptData=[ordered]@{ + Schema=1;Revision='R355';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;Mode=$Mode + SetupSHA256=$(if($setup -and [IO.File]::Exists($setup)){Hash $setup}else{'MISSING'}) + RealSetupSourceEmbeddedExact=$sourceExact;SourceUnchanged=$compiledEmbeddedSource + TrustedProcessInfoTransport=$decodedTransport + RealExitedParentPid=$parentPid;OriginalNativeCleanupPid=$cleanupPid + ActualOriginalSetupGeneratedCleanupExecuted=$nativeOriginalProcessLaunched + CleanupNativeExitCode=$exitCode + ExpectedJunctionRefusalObserved=$reparseRefused;NormalSyntheticUninstallRemoved=$nominalDeleted + ExternalCanarySHA256=$outsideSha;CanaryUnchanged=$canaryUnchanged + ScopedTestCleanupComplete=$testCleaned + R355OnlySyntheticOwnedFilesCreated=$filesCount + PreBuildGuestAppAbsent=$true + BuildGeneratedAppRootSeen=$fresh.AppDir + BuildGeneratedRootBackupRestored=$spilloverRestored + BuildGeneratedRootShaBefore=$spillBefore + BuildGeneratedRootShaAfter=$spillAfter + SyntheticFixtureRootFullyRemoved=$syntheticGone + GuestFreshness=$fresh + UserOwnerComputerTouched=$false;DriverOrBrokerSetupExecuted=$false + InteractiveUacTested=$false;ConcurrentPathSwapTested=$true + SeparateWindowsActorPid=$actorPid;SeparateActorReceiptSHA256=$actorProofSha + RealConcurrentRenameRefusedDuringOriginalCleanup=$actorDenied + RealConcurrentRenameAttempts=$actorAttempts + FirstDeletedOwnedFileWitnessIndex=$actorObservedDeleted + ActorExitCode=$actorExit + NoForeignOrOwnerFilesAltered=$canaryUnchanged + Checks=@($checks);Failure=$failure + } + try{[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false))}catch{$failure+=' RECEIPT_WRITE_FAILED'} + Write-Output ('R354_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count+' EXIT='+$exitCode) + if($failure){Write-Output ('R354_FAILURE='+$failure)} +} +if($failure){throw ('R354_REAL_DEFERRED_CLEANUP_FAILED '+$failure)} diff --git a/build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 b/build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 new file mode 100644 index 00000000..50a167ef --- /dev/null +++ b/build/Test-R356-OriginalConcurrentRenameActor-Native.ps1 @@ -0,0 +1,151 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][string]$AppRoot, + [Parameter(Mandatory=$true)][string]$MovedRoot, + [Parameter(Mandatory=$true)][string]$ReadyPath, + [Parameter(Mandatory=$true)][string]$SignalPath, + [Parameter(Mandatory=$true)][string]$ReceiptPath +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# A separate real Windows process in an isolated GitHub-hosted runner. +# ONLY renames exact synthetic per-user application root; no junction, +# driver, real installation or user deletion. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP'){ + throw 'R355_REAL_CLEANUP_ACTOR_DISPOSABLE_ONLY' +} +$expected=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TaskbarMonitorEnhanced' +$canonical=[IO.Path]::GetFullPath($expected) +$expectedMoved=$canonical+'_R355_RACE_'+$HostLabel+'_'+$env:GITHUB_RUN_ID +$temp=[IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')+'\' +if(-not [string]::Equals($canonical,[IO.Path]::GetFullPath($AppRoot),[StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($expectedMoved,[IO.Path]::GetFullPath($MovedRoot),[StringComparison]::OrdinalIgnoreCase) -or + @($ReadyPath,$SignalPath,$ReceiptPath|Where-Object{ + -not [IO.Path]::GetFullPath($_).StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) + }).Count -gt 0){ + throw 'R355_ACTOR_UNTRUSTED_PATH_IDENTITY' +} +$actorPid=$PID;$cleanupPid=0;$preflightMoved=$false +$firstMissing=-1;$renameDenied=$false;$renameAllowed=$false +$hresult=0;$attempts=0;$failure='';$phase='PREFLIGHT';$helperAliveAtDenied=$false +$stage=Join-Path $canonical 'delete_stage' +$movedPath=$expectedMoved +try{ + foreach($p in @($ReadyPath,$SignalPath,$ReceiptPath,$movedPath)){ + if([IO.File]::Exists($p) -or [IO.Directory]::Exists($p)){throw 'R355_ACTOR_CONTROL_OR_MOVED_PREEXISTS'} + } + if(-not [IO.Directory]::Exists($canonical) -or + ([IO.File]::GetAttributes($canonical) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + throw 'R355_ACTOR_FRESH_SYNTHETIC_ROOT_NOT_NORMAL_DIRECTORY' + } + for($i=0;$i -lt 12;$i++){ + if(-not [IO.File]::Exists((Join-Path $stage ('{0:d5}.bin' -f $i)))){ + throw 'R355_ACTOR_SYNTHETIC_WITNESS_FILE_MISSING' + } + } + # Native same-parent rename must be allowed without Helper's directory lease. + [IO.Directory]::Move($canonical,$movedPath) + if(-not [IO.Directory]::Exists($movedPath)){throw 'R355_PREHELPER_MOVE_NOT_REAL'} + [IO.Directory]::Move($movedPath,$canonical) + if(-not [IO.Directory]::Exists($canonical)){throw 'R355_PREHELPER_MOVE_NOT_RESTORED'} + $preflightMoved=$true + [IO.File]::WriteAllText($ReadyPath,'R355_REAL_NATIVE_ACTOR_READY_'+$env:GITHUB_SHA,[Text.UTF8Encoding]::new($false)) + $phase='WAIT_FOR_NATIVE_ORIGINAL_CLEANUP_PID' + $clock=[Diagnostics.Stopwatch]::StartNew() + while(-not [IO.File]::Exists($SignalPath) -and $clock.ElapsedMilliseconds -lt 24000){ + Start-Sleep -Milliseconds 2 + } + if(-not [IO.File]::Exists($SignalPath)){throw 'R355_ACTOR_CLEANUP_SIGNAL_TIMEOUT'} + $signal=Get-Content -LiteralPath $SignalPath -Raw|ConvertFrom-Json + if($signal.SourceCommit -cne $env:GITHUB_SHA -or + $signal.Host -cne $HostLabel -or + $signal.CleanupPid -le 0 -or $signal.CleanupPid -eq $PID){ + throw 'R355_ACTOR_CLEANUP_PID_IDENTITY_UNTRUSTED' + } + $cleanupPid=[int]$signal.CleanupPid + $phase='WAIT_FOR_WITNESSED_FIRST_REAL_SYNTHETIC_FILE_DELETION' + $clock.Restart() + while($clock.ElapsedMilliseconds -lt 27000){ + $live=Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue + if(!$live){break} + for($i=0;$i -lt 12;$i++){ + if(-not [IO.File]::Exists((Join-Path $stage ('{0:d5}.bin' -f $i)))){ + $firstMissing=$i;break + } + } + if($firstMissing -ge 0){break} + Start-Sleep -Milliseconds 1 + } + if($firstMissing -lt 0){throw 'R355_ACTOR_NO_REAL_DELETION_WITNESS_DURING_LIVE_CHILD'} + $phase='ATTEMPT_REAL_SAME_PARENT_DIRECTORY_RENAME_DURING_NATIVE_DELETION' + $clock.Restart() + while($clock.ElapsedMilliseconds -lt 12000){ + $child=Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue + if(!$child){break} + $attempts++ + try{ + [IO.Directory]::Move($canonical,$movedPath) + $renameAllowed=$true + # Strict test cleanup: only reverse our own exact moved root. + if([IO.Directory]::Exists($movedPath) -and -not [IO.Directory]::Exists($canonical)){ + [IO.Directory]::Move($movedPath,$canonical) + } + break + }catch [IO.IOException]{ + $hresult=$_.Exception.HResult -band 65535 + if($hresult -eq 32 -and $null -ne (Get-Process -Id $cleanupPid -ErrorAction SilentlyContinue)){ + $helperAliveAtDenied=$true;$renameDenied=$true;break + } + }catch [UnauthorizedAccessException]{ + $hresult=$_.Exception.HResult -band 65535 + } + Start-Sleep -Milliseconds 1 + } + if($renameAllowed){throw 'R355_REAL_ORIGINAL_CLEANUP_DIRECTORY_RENAME_ALLOWED_WHILE_CHILD_ACTIVE'} + if(-not $renameDenied -or -not $helperAliveAtDenied){ + throw ('R355_REAL_ORIGINAL_CLEANUP_LEASE_DENIAL_NOT_WITNESSED attempts='+$attempts+' win32='+$hresult) + } +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Never delete synthetic app bytes. If actor's rename got through, restore name + # only if the exact sibling is still present, and fail instead of following links. + try{ + if([IO.Directory]::Exists($movedPath) -and + -not [IO.Directory]::Exists($canonical) -and + ([IO.File]::GetAttributes($movedPath) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + [IO.Directory]::Move($movedPath,$canonical) + } + }catch{$failure+=' ACTOR_RESTORE_FAILED='+$_.Exception.Message} + $status=if(-not $failure -and $renameDenied -and $preflightMoved -and + $helperAliveAtDenied -and $firstMissing -ge 0 -and + $attempts -gt 0 -and $hresult -eq 32){ + 'PASS_REAL_NATIVE_ORIGINAL_CLEANUP_DIRECTORY_RENAME_DENIED_ON_LIVE_DELETION' + }else{'FAIL_R355_LIVE_CLEANUP_RENAME_NOT_PROVEN'} + $receipt=[ordered]@{ + Schema=1;Revision='R355';Status=$status;SourceCommit=$env:GITHUB_SHA + Host=$HostLabel;ActorPid=$actorPid;OriginalCleanupPid=$cleanupPid + SeparateNativeProcess=$true;PreHelperRenameSucceededAndRestored=$preflightMoved + FirstSyntheticFileAlreadyDeletedBeforeProbe=$firstMissing + RenameAttempts=$attempts;RealRenameAllowedDuringCleanup=$renameAllowed + NativeSharingViolationWin32=$hresult;RealRenameDeniedWhileCleanupAlive=$renameDenied + OriginalCleanupAliveAtDenial=$helperAliveAtDenied + OutsideCanaryTouched=$false;OwnerMachineTouched=$false + InteractiveUACObserved=$false;Failure=$failure + } + try{ + [IO.File]::WriteAllText($ReceiptPath,($receipt|ConvertTo-Json -Depth 7), + [Text.UTF8Encoding]::new($false)) + }catch{Write-Output ('R355_ACTOR_RECEIPT_WRITE_FAILURE='+$_.Exception.Message)} + Write-Output ('R355_ACTOR_LIVE_RENAME_STATUS='+$status+' tries='+$attempts+' code='+$hresult) +} +if($failure){throw ('R355_ACTOR_FAILED '+$failure)} diff --git a/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 b/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 new file mode 100644 index 00000000..5a0e5fa0 --- /dev/null +++ b/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 @@ -0,0 +1,344 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('A','B')][string]$HostLabel, + [Parameter(Mandatory=$true)][ValidateSet('Nominal','RootJunction','ChildJunction')][string]$Mode, + [Parameter(Mandatory=$true)][string]$SourceRoot +) +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +# R354: compile/build the original Setup, launch ONLY its own generated +# unelevated deferred-cleanup command on an already clean, disposable +# GitHub-hosted Windows user profile. Never invoke this on the owner PC. +if($PSVersionTable.PSEdition -cne 'Desktop' -or + $env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + $env:GITHUB_REPOSITORY -cne 'GOD13emad/TaskbarMonitorEnhanced' -or + $env:GITHUB_REF -cne 'refs/heads/audit/r356-handle-bound-directory-disposition' -or + $env:COMPUTERNAME -match '(?i)EMAD-PC|ALIEMAD|SAEID|LAPTOP' -or + $env:GITHUB_RUN_ID -cnotmatch '^[0-9]{5,20}$' -or + $env:GITHUB_SHA -cnotmatch '^[a-f0-9]{40}$' -or + (git -C $SourceRoot rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){ + throw 'R354_REFUSE_NONDISPOSABLE_OR_UNPINNED_HOST' +} +$who=[Security.Principal.WindowsIdentity]::GetCurrent() +$admin=[Security.Principal.WindowsPrincipal]::new($who) +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){ + throw 'R354_GITHUB_RUNNER_ALREADY_ADMIN_REQUIRED' +} +if($HostLabel -cne 'A' -and $HostLabel -cne 'B'){throw 'R354_INVALID_MATRIX_HOST'} +$profile=[Environment]::GetFolderPath('UserProfile') +$local=[Environment]::GetFolderPath('LocalApplicationData') +$program=[Environment]::GetFolderPath('ProgramFiles') +$app=Join-Path $local 'TaskbarMonitorEnhanced' +$main=Join-Path $app 'TaskbarMonitorEnhanced.exe' +$protected=Join-Path $program 'TaskbarMonitorEnhanced' +$taskName='TaskbarMonitorEnhanced Sensor Broker' +$receiptRoot=Join-Path $env:RUNNER_TEMP ('R354_'+$Mode+'_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) +$receipt=Join-Path $receiptRoot 'R354_ORIGINAL_DEFERRED_CLEANUP.json' +$external=Join-Path $receiptRoot 'R354_OUTSIDE_CANARY_ROOT' +$canary=Join-Path $external 'NEVER_DELETE_OUTSIDE.txt' +$fixture=Join-Path $app 'synthetic-owner-file.txt' +$child=Join-Path $app 'malicious-child-junction' +$checks=[Collections.Generic.List[string]]::new() +$exitCode=-1;$stderr='';$failure='';$phase='PRECONDITION' +$setup='';$sourceExact=$false;$fixtureCreated=$false;$outsideSha='' +$canaryUnchanged=$false;$reparseRefused=$false;$nominalDeleted=$false +$testCleaned=$false;$parentPid=0;$parentTicks=0;$cleanupPid=0 +$compiledEmbeddedSource=$false;$decodedTransport=$false;$nativeOriginalProcessLaunched=$false +$spillover='';$spilloverMoved=$false;$spilloverRestored=$false +$spillBefore='';$spillAfter='';$syntheticGone=$false +function FingerprintGuestApp([string]$base){ + if(![IO.Directory]::Exists($base)){throw 'R354_SPILL_NOT_DIRECTORY'} + if(([IO.File]::GetAttributes($base) -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_ROOT_REPARSE'} + $members=@(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction Stop) + if($members.Count -gt 300){throw 'R354_SPILL_TOO_MANY_ENTRIES'} + $lines=[Collections.Generic.List[string]]::new() + [long]$total=0 + foreach($item in $members){ + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'R354_SPILL_MEMBER_REPARSE'} + $rel=$item.FullName.Substring($base.TrimEnd('\').Length+1) + if($item.PSIsContainer){$lines.Add('D|'+$rel)} + else{ + if($item.Length -gt 16777216){throw 'R354_SPILL_OVERSIZE_FILE'} + $total+=$item.Length + if($total -gt 67108864){throw 'R354_SPILL_TOO_MANY_BYTES'} + $lines.Add('F|'+$rel+'|'+$item.Length+'|'+(Hash $item.FullName)) + } + } + $digest=[Security.Cryptography.SHA256]::Create() + try{ + $joined=[string]::Join([string][char]10,[string[]]@($lines|Sort-Object -CaseSensitive)) + return ([BitConverter]::ToString($digest.ComputeHash([Text.Encoding]::UTF8.GetBytes($joined)))).Replace('-','') + }finally{$digest.Dispose()} +} + +function Check([string]$name){$script:checks.Add($name);Write-Output ('PASS '+$name)} +function Hash([string]$p){return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash} +function IsTaskPresent(){ + return @((Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue|Where-Object {$null -ne $_})).Count -ne 0 +} +# This receipt path is RUNNER_TEMP on a verified hosted disposable Windows +# runner, not any owner installation; it must exist even when preflight fails. +if([IO.Directory]::Exists($receiptRoot)){throw 'R354_OWNED_RECEIPT_ROOT_PREEXISTS'} +[IO.Directory]::CreateDirectory($receiptRoot)|Out-Null +$fresh=[ordered]@{AppDir=$false;ProtectedDir=$false;MainFile=$false;SensorTask=$false;SensorProcessCount=0} +try{ + $phase='VERIFY_FRESH_GITHUB_HOSTED_CLEAN_PROFILE' + $fresh.AppDir=[IO.Directory]::Exists($app) + $fresh.ProtectedDir=[IO.Directory]::Exists($protected) + $fresh.MainFile=[IO.File]::Exists($main) + $fresh.SensorTask=[bool](IsTaskPresent) + $fresh.SensorProcessCount=@(Get-Process TaskbarMonitorEnhanced,TaskbarMonitorSensorBroker,TaskbarMonitorSensorSupervisor -ErrorAction SilentlyContinue).Count + Write-Output ('R354_NATIVE_GUEST_FRESHNESS='+($fresh|ConvertTo-Json -Compress)) + $marker=Join-Path $env:RUNNER_TEMP 'R354_PREBUILD_FRESH_GUEST.json' + if(![IO.File]::Exists($marker)){throw 'R354_PREBUILD_FRESHNESS_PROOF_MISSING'} + $pre=Get-Content -LiteralPath $marker -Raw|ConvertFrom-Json + if($pre.Schema -ne 1 -or $pre.Revision -cne 'R354' -or + $pre.SourceCommit -cne $env:GITHUB_SHA -or + $pre.Host -cne $env:COMPUTERNAME -or + $pre.AppRootAbsentBeforeBuild -ne $true -or + $pre.ProtectedRootAbsentBeforeBuild -ne $true -or + $pre.TaskAbsentBeforeBuild -ne $true){ + throw 'R354_BAD_PREBUILD_FRESHNESS_PROOF' + } + if($fresh.ProtectedDir -or $fresh.MainFile -or + $fresh.SensorTask -or $fresh.SensorProcessCount -gt 0){ + throw 'R354_POSTBUILD_REAL_PRODUCT_OR_SENSOR_REFUSED' + } + if(-not [string]::Equals([IO.Path]::GetFullPath($local), + [IO.Path]::GetFullPath((Join-Path $profile 'AppData\Local')),[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_NONCANONICAL_LOCALAPPDATA_REFUSED' + } + [IO.Directory]::CreateDirectory($external)|Out-Null + [IO.File]::WriteAllText($canary,'R354_OUTSIDE_CANARY_DO_NOT_TOUCH',[Text.UTF8Encoding]::new($false)) + $outsideSha=Hash $canary + Check 'REAL_GITHUB_HOSTED_WINDOWS_CLEAN_APP_TASK_AND_SENSORS_ABSENT' + Check 'EXPECTED_OWNER_APP_ROOT_IS_CANONICAL_KNOWNFOLDER_NOT_ENV_OVERRIDE' + $phase='ISOLATE_KNOWN_BUILD_ONLY_APPROOT' + if($fresh.AppDir){ + $spillover=Join-Path $local ('TaskbarMonitorEnhanced_R354_BUILD_SNAPSHOT_'+$HostLabel+'_'+$env:GITHUB_RUN_ID) + if(Test-Path -LiteralPath $spillover){throw 'R354_SPILL_PREEXISTS'} + $spillBefore=FingerprintGuestApp $app + [IO.Directory]::Move($app,$spillover) + $spilloverMoved=$true + if([IO.Directory]::Exists($app) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_MOVE_SHA_MISMATCH' + } + } + Check 'PREBUILD_FRESH_GUEST_BUILD_ONLY_APPDATA_ATOMICALLY_ISOLATED' + $phase='REFLECT_EXACT_COMPILED_PRODUCTION_SETUP_PRIVATE_FACTORY' + $setup=Join-Path $SourceRoot 'build\_out\Setup\TaskbarMonitorEnhanced_Setup_1.7.8.exe' + $source=Join-Path $SourceRoot 'installer\TaskbarMonitorEnhanced_Setup.cs' + $native=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' + foreach($p in @($setup,$source,$native)){ + if(![IO.File]::Exists($p)){throw 'R354_BUILD_OR_TRUSTED_WINDOWS_HOST_MISSING'} + } + $assembly=[Reflection.Assembly]::LoadFrom((Resolve-Path $setup).Path) + $reader=[IO.StreamReader]::new($assembly.GetManifestResourceStream('Payload.TaskbarMonitorEnhanced_Setup.cs')) + try{$embedded=$reader.ReadToEnd()}finally{$reader.Dispose()} + $expectedSource=[IO.File]::ReadAllText($source) + $normalize={param([string]$s) $s.Replace(([string][char]13+[string][char]10),[string][char]10).TrimStart([char]0xFEFF).TrimEnd()} + if((& $normalize $embedded) -cne (& $normalize $expectedSource)){ + throw 'R354_ORIGINAL_PE_SETUP_SOURCE_HASH_MISMATCH' + } + $sourceExact=$true;$compiledEmbeddedSource=$true + Check 'REAL_ORIGINAL_SETUP_PE_EMBEDS_EXACT_REVIEWED_SETUP_SOURCE' + $type=$assembly.GetType('SetupProgram',$true) + $flags=[Reflection.BindingFlags]'Static,NonPublic' + $builder=$type.GetMethod('BuildDeferredUninstallCleanupProcessInfo',$flags) + $appProp=$type.GetProperty('AppRoot',$flags) + if(!$builder -or !$appProp){throw 'R354_EXACT_PE_CLEANUP_FACTORY_MISSING'} + $known=[string]$appProp.GetValue($null,$null) + if(-not [string]::Equals($known,$app,[StringComparison]::OrdinalIgnoreCase)){ + throw 'R354_SETUP_KNOWN_APP_ROOT_CHANGED' + } + Check 'PRODUCTION_SETUP_KNOWN_ROOT_AND_REAL_FACTORY_VERIFIED' + $phase='USE_REAL_EXITED_PARENT_IDENTITY_FOR_DEFERRED_CLEANUP' + $sacStart=[Diagnostics.ProcessStartInfo]::new() + $sacStart.FileName=$native + $sacStart.Arguments='-NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $sacStart.UseShellExecute=$false;$sacStart.CreateNoWindow=$true + $parent=[Diagnostics.Process]::Start($sacStart) + if(!$parent){throw 'R354_TEST_PARENT_PROCESS_MISSING'} + try{ + $parentPid=$parent.Id + $parentTicks=$parent.StartTime.ToUniversalTime().Ticks + if(!$parent.WaitForExit(10000)){throw 'R354_REAL_PARENT_DID_NOT_EXIT'} + }finally{$parent.Dispose()} + if($parentPid -le 0 -or $parentTicks -le 0){ + throw 'R354_INVALID_REAL_EXITED_PARENT_IDENTITY' + } + $args=[object[]]::new(3) + $args[0]=([string]$known).PSObject.BaseObject + $args[1]=([int]$parentPid).PSObject.BaseObject + $args[2]=([long]$parentTicks).PSObject.BaseObject + $psi=[Diagnostics.ProcessStartInfo]$builder.Invoke($null,$args) + if(-not [string]::Equals($psi.FileName,$native,[StringComparison]::OrdinalIgnoreCase) -or + $psi.UseShellExecute -ne $false -or + !$psi.Arguments.StartsWith('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ',[StringComparison]::Ordinal) -or + $psi.Arguments.Contains($app) -or + $psi.Arguments.Contains(' -File ') -or + $psi.Arguments.Contains(' -Command ')){ + throw 'R354_REAL_SETUP_PROCESS_FACTORY_NOT_BOUND_TO_TRUSTED_HOST' + } + $decoded=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String( + $psi.Arguments.Substring('-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '.Length))) + $tokens=$null;$parseErrors=$null + [Management.Automation.Language.Parser]::ParseInput($decoded,[ref]$tokens,[ref]$parseErrors)|Out-Null + if(@($parseErrors).Count -ne 0 -or + !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or + !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or + !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' + } + $decodedTransport=$true + Check 'REAL_EXITED_PARENT_ID_AND_UTF16_ONLY_ORIGINAL_SETUP_COMMAND_TRANSPORT' + $phase='CREATE_DISPOSABLE_CANARY_AND_OPTIONAL_JUNCTION' + if($Mode -ceq 'RootJunction'){ + if(Test-Path -LiteralPath $app){throw 'R354_FRESH_ROOT_ALREADY_OCCUPIED'} + New-Item -ItemType Junction -Path $app -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_ROOT_JUNCTION_NOT_ACTUALLY_REPARSE' + } + $fixtureCreated=$true + }else{ + [IO.Directory]::CreateDirectory($app)|Out-Null + [IO.File]::WriteAllText($fixture,'R354_TEST_USER_DATA_ONLY',[Text.UTF8Encoding]::new($false)) + $fixtureCreated=$true + if($Mode -ceq 'ChildJunction'){ + New-Item -ItemType Junction -Path $child -Target $external -ErrorAction Stop|Out-Null + if(([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_CHILD_JUNCTION_NOT_ACTUALLY_REPARSE' + } + } + } + Check 'ONLY_FRESH_GUEST_OWNED_SYNTHETIC_ROOT_WITH_EXTERNAL_CANARY_PREPARED' + $phase='START_REAL_UNMODIFIED_COMPILED_SETUP_CLEANUP_PROCESS' + $psi.RedirectStandardError=$true + $psi.RedirectStandardOutput=$true + $psi.CreateNoWindow=$true + $cleanup=[Diagnostics.Process]::Start($psi) + if(!$cleanup){throw 'R354_REAL_CLEANUP_PROCESS_NOT_STARTED'} + try{ + $cleanupPid=$cleanup.Id + if(!$cleanup.WaitForExit(40000)){ + if(!$cleanup.HasExited){$cleanup.Kill();[void]$cleanup.WaitForExit(8000)} + throw 'R354_REAL_CLEANUP_PROCESS_TIMEOUT' + } + $exitCode=[int]$cleanup.ExitCode + $stderr=[string]$cleanup.StandardError.ReadToEnd() + $stdout=[string]$cleanup.StandardOutput.ReadToEnd() + }finally{$cleanup.Dispose()} + $nativeOriginalProcessLaunched=$true + Check 'REAL_COMPILED_SETUP_GENERATED_CLEANUP_EXECUTED_AS_SEPARATE_NATIVE_PROCESS' + $phase='VERIFY_OUTCOME_EXACT_NATIVE_PROCESS_NONFOLLOWING' + if($Mode -ceq 'Nominal'){ + if($exitCode -ne 0 -or [IO.Directory]::Exists($app) -or + $stderr.Contains('TBME_CLEANUP_')){ + throw 'R354_NOMINAL_NATIVE_CLEANUP_NOT_SUCCESSFUL' + } + $nominalDeleted=$true + Check 'ORIGINAL_CLEANUP_NOMINAL_REMOVES_OWNED_TEST_ROOT_AND_NOTHING_ELSE' + }else{ + $expected=if($Mode -ceq 'RootJunction'){'TBME_CLEANUP_ANCESTOR_REPARSE'}else{'TBME_CLEANUP_CHILD_REPARSE'} + if($exitCode -eq 0 -or !$stderr.Contains($expected)){ + throw ('R354_ORIGINAL_NATIVE_CLEANUP_JUNCTION_WAS_NOT_REFUSED_'+$Mode+'_exit_'+$exitCode+'_stderr_'+$stderr.Substring(0,[Math]::Min(320,$stderr.Length))) + } + if($Mode -ceq 'ChildJunction' -and ![IO.File]::Exists($fixture)){ + throw 'R354_OWNER_TEST_PAYLOAD_DELETED_BEFORE_REFUSAL' + } + $junction=if($Mode -ceq 'RootJunction'){$app}else{$child} + if(([IO.File]::GetAttributes($junction) -band [IO.FileAttributes]::ReparsePoint) -eq 0){ + throw 'R354_JUNCTION_NOT_PRESERVED_ON_REFUSAL' + } + $reparseRefused=$true + Check ('ORIGINAL_CLEANUP_NATIVE_'+$Mode.ToUpperInvariant()+'_JUNCTION_REFUSED_PRE_DELETE') + } + if((Hash $canary) -cne $outsideSha){throw 'R354_OUTSIDE_CANARY_WAS_MODIFIED'} + $canaryUnchanged=$true + Check 'EXTERNAL_CANARY_SHA_UNCHANGED_AFTER_ORIGINAL_CLEANUP_PROCESS' + if([IO.Directory]::Exists($protected) -or (IsTaskPresent)){ + throw 'R354_REAL_SENSOR_INSTALLATION_OR_SCHEDULED_TASK_TRIGGERED' + } + Check 'NO_PROTECTED_SENSOR_OR_DRIVER_SETUP_EVER_INVOKED' +}catch{ + $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message+' PHASE='+$phase +}finally{ + # Remove ONLY exact owned synthetic test fixture created on this hosted runner, + # and only after confirming the external canary has not changed. + try{ + if($fixtureCreated -and (Hash $canary) -ceq $outsideSha){ + if($Mode -ceq 'RootJunction' -and [IO.Directory]::Exists($app) -and + ([IO.File]::GetAttributes($app) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($app,$false) + } + if($Mode -ceq 'ChildJunction' -and [IO.Directory]::Exists($child) -and + ([IO.File]::GetAttributes($child) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ + [IO.Directory]::Delete($child,$false) + } + if($Mode -cne 'RootJunction' -and [IO.Directory]::Exists($app)){ + if([IO.File]::Exists($fixture) -and (Hash $fixture) -ceq (( + [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash( + [Text.Encoding]::UTF8.GetBytes('R354_TEST_USER_DATA_ONLY')))).Replace('-',''))){ + [IO.File]::Delete($fixture) + } + if(@([IO.Directory]::GetFileSystemEntries($app)).Count -eq 0){ + [IO.Directory]::Delete($app,$false) + } + } + } + if([IO.File]::Exists($canary) -and (Hash $canary) -ceq $outsideSha -and + @([IO.Directory]::GetFileSystemEntries($external)).Count -eq 1){ + [IO.File]::Delete($canary) + [IO.Directory]::Delete($external,$false) + } + $syntheticGone=(![IO.Directory]::Exists($app)) + if($spilloverMoved -and $syntheticGone){ + if(![IO.Directory]::Exists($spillover) -or + (FingerprintGuestApp $spillover) -cne $spillBefore){ + throw 'R354_SPILL_CHANGED_BEFORE_RESTORE' + } + [IO.Directory]::Move($spillover,$app) + $spillAfter=FingerprintGuestApp $app + $spilloverRestored=($spillAfter -ceq $spillBefore -and ![IO.Directory]::Exists($spillover)) + if(!$spilloverRestored){throw 'R354_BUILD_APPDATA_NOT_EXACTLY_RESTORED'} + } + $testCleaned=($syntheticGone -and + (!$spilloverMoved -or $spilloverRestored) -and + ![IO.Directory]::Exists($external) -and ![IO.Directory]::Exists($protected) -and !(IsTaskPresent)) + }catch{$failure+=' R354_TEST_FIXTURE_CLEANUP_FAILED='+$_.Exception.Message} + $accepted=(!$failure -and $checks.Count -eq 11 -and $testCleaned -and + $nativeOriginalProcessLaunched -and $canaryUnchanged -and + ($nominalDeleted -or $reparseRefused)) + $status=if($accepted){'PASS_REAL_ORIGINAL_SETUP_DEFERRED_CLEANUP_'+$Mode.ToUpperInvariant()+'_SCOPED'}else{'FAIL_R354_NOT_ACCEPTED'} + $receiptData=[ordered]@{ + Schema=1;Revision='R354';Status=$status + SourceCommit=$env:GITHUB_SHA;Host=$HostLabel;Mode=$Mode + SetupSHA256=$(if($setup -and [IO.File]::Exists($setup)){Hash $setup}else{'MISSING'}) + RealSetupSourceEmbeddedExact=$sourceExact;SourceUnchanged=$compiledEmbeddedSource + TrustedProcessInfoTransport=$decodedTransport + RealExitedParentPid=$parentPid;OriginalNativeCleanupPid=$cleanupPid + ActualOriginalSetupGeneratedCleanupExecuted=$nativeOriginalProcessLaunched + CleanupNativeExitCode=$exitCode + ExpectedJunctionRefusalObserved=$reparseRefused;NormalSyntheticUninstallRemoved=$nominalDeleted + ExternalCanarySHA256=$outsideSha;CanaryUnchanged=$canaryUnchanged + ScopedTestCleanupComplete=$testCleaned + PreBuildGuestAppAbsent=$true + BuildGeneratedAppRootSeen=$fresh.AppDir + BuildGeneratedRootBackupRestored=$spilloverRestored + BuildGeneratedRootShaBefore=$spillBefore + BuildGeneratedRootShaAfter=$spillAfter + SyntheticFixtureRootFullyRemoved=$syntheticGone + GuestFreshness=$fresh + UserOwnerComputerTouched=$false;DriverOrBrokerSetupExecuted=$false + InteractiveUacTested=$false;ConcurrentPathSwapTested=$false + Checks=@($checks);Failure=$failure + } + try{[IO.File]::WriteAllText($receipt,($receiptData|ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false))}catch{$failure+=' RECEIPT_WRITE_FAILED'} + Write-Output ('R354_NATIVE_STATUS='+$status+' CHECKS='+$checks.Count+' EXIT='+$exitCode) + if($failure){Write-Output ('R354_FAILURE='+$failure)} +} +if($failure){throw ('R354_REAL_DEFERRED_CLEANUP_FAILED '+$failure)} diff --git a/installer/TaskbarMonitorEnhanced_Setup.cs b/installer/TaskbarMonitorEnhanced_Setup.cs index 57a7555e..2ab64162 100644 --- a/installer/TaskbarMonitorEnhanced_Setup.cs +++ b/installer/TaskbarMonitorEnhanced_Setup.cs @@ -1163,9 +1163,7 @@ static ProcessStartInfo BuildDeferredUninstallCleanupProcessInfo(string root,int } if(-not [IO.Directory]::Exists($expectedRoot)){exit 0} -# R355: acquire trusted directory handles without FILE_SHARE_DELETE before -# reading inventory or deleting names, so an attacker cannot rename pinned -# profile/LocalAppData/owned-tree ancestors while this cleanup is working. +# Pin exact native directory handles while removing known owned entries. Add-Type -TypeDefinition @' using System; using System.IO; @@ -1174,10 +1172,13 @@ static ProcessStartInfo BuildDeferredUninstallCleanupProcessInfo(string root,int using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; public sealed class TBMEDeferredDirectoryLeaseSet : IDisposable { - const uint LIST=1,SHARE_READ_WRITE=3,OPEN_EXISTING=3; + const uint LIST=1,DELETE=65536,SHARE_READ_WRITE=3,OPEN_EXISTING=3; const uint BACKUP=0x02000000,OPEN_REPARSE=0x00200000,DIR=0x10,REPARSE=0x400; [StructLayout(LayoutKind.Sequential)] struct TAG {public uint Attributes;public uint ReparseTag;} + [StructLayout(LayoutKind.Sequential)]struct DISP {public byte DeleteFile;} + [DllImport(""kernel32.dll"",SetLastError=true)] + static extern bool SetFileInformationByHandle(SafeFileHandle h,int cls,ref DISP info,uint size); [DllImport(""kernel32.dll"",CharSet=CharSet.Unicode,EntryPoint=""CreateFileW"",SetLastError=true)] static extern SafeFileHandle Create(string p,uint access,uint sharing,IntPtr sec,uint create,uint flags,IntPtr template); [DllImport(""kernel32.dll"",SetLastError=true)] @@ -1185,11 +1186,12 @@ struct TAG {public uint Attributes;public uint ReparseTag;} [DllImport(""kernel32.dll"",CharSet=CharSet.Unicode,EntryPoint=""GetFinalPathNameByHandleW"",SetLastError=true)] static extern uint Final(SafeFileHandle file,StringBuilder path,uint size,uint flags); readonly Dictionary pins=new Dictionary(StringComparer.OrdinalIgnoreCase); - public void Pin(string path){ + public void Pin(string path){Pin(path,false);} + public void Pin(string path,bool canDelete){ if(String.IsNullOrWhiteSpace(path))throw new IOException(""R355_PIN_EMPTY_DIRECTORY""); string expected=Path.GetFullPath(path).TrimEnd('\\'); if(pins.ContainsKey(expected))return; - SafeFileHandle h=Create(expected,LIST,SHARE_READ_WRITE,IntPtr.Zero,OPEN_EXISTING,BACKUP|OPEN_REPARSE,IntPtr.Zero); + SafeFileHandle h=Create(expected,LIST|(canDelete?DELETE:0),SHARE_READ_WRITE,IntPtr.Zero,OPEN_EXISTING,BACKUP|OPEN_REPARSE,IntPtr.Zero); if(h.IsInvalid){int e=Marshal.GetLastWin32Error();h.Dispose();throw new IOException(""R355_PIN_CREATEFILE_WIN32_""+e);} try{ TAG t; @@ -1213,14 +1215,22 @@ public void Release(string path){ if(!pins.TryGetValue(p,out h))throw new IOException(""R355_PIN_RELEASE_UNKNOWN_DIRECTORY""); pins.Remove(p);h.Dispose(); } + public void DeletePinned(string path){ + string p=Path.GetFullPath(path).TrimEnd('\\'); + SafeFileHandle h; + if(!pins.TryGetValue(p,out h))throw new IOException(""R356_PIN_NOT_FOUND""); + DISP d=new DISP();d.DeleteFile=1; + if(!SetFileInformationByHandle(h,4,ref d,(uint)Marshal.SizeOf(typeof(DISP)))) + throw new IOException(""R356_PIN_DELETE_WIN32_""+Marshal.GetLastWin32Error()); + pins.Remove(p);h.Dispose(); + } public void Dispose(){ foreach(SafeFileHandle h in pins.Values)h.Dispose(); pins.Clear(); } } '@ -# Reject every ancestor and descendant reparse point before deletion; -# this is a path-based check, not proof against concurrent swaps. +# Reject reparse points before inventory. $cursor=$expectedRoot while(-not [string]::IsNullOrEmpty($cursor)){ if(([IO.File]::GetAttributes($cursor) -band [IO.FileAttributes]::ReparsePoint) -ne 0){ @@ -1230,8 +1240,7 @@ public void Dispose(){ if([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor){break} $cursor=$parent } -# Stage an exact non-following deletion inventory. Never ask .NET to -# recursively delete a directory that might acquire a new junction. +# Inventory exact user-owned entries only. $prefix=[IO.Path]::GetFullPath($expectedRoot).TrimEnd('\')+'\' $stack=[Collections.Generic.List[string]]::new() $dirs=[Collections.Generic.List[string]]::new() @@ -1240,7 +1249,7 @@ public void Dispose(){ $lease=[TBMEDeferredDirectoryLeaseSet]::new() try{ foreach($pinPath in @($profile,(Join-Path $profile 'AppData'),$local,$expectedRoot)){ - $lease.Pin($pinPath) + $lease.Pin($pinPath,($pinPath -ceq $expectedRoot)) } $members=0 while($stack.Count -gt 0){ @@ -1251,7 +1260,7 @@ public void Dispose(){ ($attrs -band [IO.FileAttributes]::Directory) -eq 0){ throw 'TBME_CLEANUP_CHANGED_DIRECTORY' } - $lease.Pin($dir) + $lease.Pin($dir,$true) $dirs.Add($dir) foreach($entry in [IO.Directory]::GetFileSystemEntries($dir)){ $members++ @@ -1271,9 +1280,7 @@ public void Dispose(){ } } } -# Explicit leaf deletes, followed by empty directories deepest first. -# Every unexpected new member makes Directory.Delete(...,false) fail -# rather than following it. Native handle-bound race proof remains open. +# Delete known files and then delete pinned empty directories by handle. foreach($file in $files){ $attrs=[IO.File]::GetAttributes($file) if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or @@ -1283,12 +1290,10 @@ public void Dispose(){ [IO.File]::Delete($file) } foreach($dir in ($dirs|Sort-Object Length -Descending)){ - $lease.Release($dir) - $attrs=[IO.File]::GetAttributes($dir) - if(($attrs -band [IO.FileAttributes]::ReparsePoint) -ne 0){ - throw 'TBME_CLEANUP_CHANGED_DIRECTORY' + if(@([IO.Directory]::GetFileSystemEntries($dir)).Count -ne 0){ + throw 'TBME_CLEANUP_UNEXPECTED_MEMBER' } - [IO.Directory]::Delete($dir,$false) + $lease.DeletePinned($dir) } }finally{$lease.Dispose()} "; From f3a841856c6b7eeeec41beb464fc22af88e3aef8 Mon Sep 17 00:00:00 2001 From: TBME Audit Date: Tue, 20 Oct 2026 02:08:18 +0330 Subject: [PATCH 135/135] test(r356): require pinned-handle disposition in CSharp command regressions --- build/Test-R334-CommandSinkContracts-Native.ps1 | 5 ++++- .../Test-R356-OriginalConcurrentRename-Regression-Native.ps1 | 5 ++++- .../Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/build/Test-R334-CommandSinkContracts-Native.ps1 b/build/Test-R334-CommandSinkContracts-Native.ps1 index 46c6c7b3..1882b833 100644 --- a/build/Test-R334-CommandSinkContracts-Native.ps1 +++ b/build/Test-R334-CommandSinkContracts-Native.ps1 @@ -192,7 +192,10 @@ Pass 'CLEANUP_ROOT_TRANSPORTED_ONLY_AS_UTF8_BASE64_DATA' if(!$decoded.Contains('TBME_CLEANUP_WRONG_EFFECTIVE_TOKEN') -or !$decoded.Contains('TBME_CLEANUP_ROOT_NOT_CANONICAL') -or !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or - !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + !$decoded.Contains('$lease.DeletePinned($dir)') -or + !$decoded.Contains('SetFileInformationByHandle') -or + $decoded.Contains('$lease.Release($dir)') -or + $decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ throw 'R334_CLEANUP_OWNER_SID_REPARSE_NONRECURSIVE_GUARDS_MISSING' } Pass 'CLEANUP_TOKEN_CANONICAL_ROOT_AND_NONRECURSIVE_GUARDS' diff --git a/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 b/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 index 3f82ef85..3e52ee8f 100644 --- a/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 +++ b/build/Test-R356-OriginalConcurrentRename-Regression-Native.ps1 @@ -191,7 +191,10 @@ try{ if(@($parseErrors).Count -ne 0 -or !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or - !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + !$decoded.Contains('$lease.DeletePinned($dir)') -or + !$decoded.Contains('SetFileInformationByHandle') -or + $decoded.Contains('$lease.Release($dir)') -or + $decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' } $decodedTransport=$true diff --git a/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 b/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 index 5a0e5fa0..4e22a9bc 100644 --- a/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 +++ b/build/Test-R356-OriginalDeferredCleanup-Regression-Native.ps1 @@ -190,7 +190,10 @@ try{ if(@($parseErrors).Count -ne 0 -or !$decoded.Contains('TBME_CLEANUP_ANCESTOR_REPARSE') -or !$decoded.Contains('TBME_CLEANUP_CHILD_REPARSE') -or - !$decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ + !$decoded.Contains('$lease.DeletePinned($dir)') -or + !$decoded.Contains('SetFileInformationByHandle') -or + $decoded.Contains('$lease.Release($dir)') -or + $decoded.Contains('[IO.Directory]::Delete($dir,$false)')){ throw 'R354_ORIGINAL_PE_CLEANUP_COMMAND_INCORRECT' } $decodedTransport=$true