0.74.0 - hardening: data loss, signing security, PS 5.1, CI honesty - #154
Merged
Conversation
Item 1 - data loss (#145): - Remove-MsixPsf deleted 'config.json' and '*Fixup*.dll' recursively from ANY package, then repacked/signed/moved over the operator's original. A PSF-free app shipping its own config.json (Electron/.NET) lost it with exit code 0. Now gated on real PSF presence (manifest PsfLauncher ref or PSF binaries), with precise patterns: known/declared fixup DLLs only, and only the parsed PSF config.json - never an app's own file of that name. - Add-MsixVcRuntimeBundle packed and signed directly over the input file, so a signing failure left an unsigned repack where a signed package had been. Now builds to scratch, signs, then moves; gains -UnsignedOutputPath. - New-MsixWorkspace honoured the inherited $WhatIfPreference, so the directory was never created, Get-Item failed and it returned an empty string - which broke -WhatIf for all ~46 mutators. Now creates with -WhatIf:$false. - _MsixPreserveUnsigned: the four repack sites copied to -UnsignedOutputPath with -ErrorAction SilentlyContinue and logged "preserved" unconditionally, then deleted the scratch. A failed copy destroyed the only artifact while the log claimed success. Now creates the directory, copies -ErrorAction Stop, and logs at Error when preservation genuinely failed. Item 2 - Windows PowerShell 5.1 (#146): - Join-String (PS6+) at MSIX.Scanners.ps1 threw inside a try whose catch turned it into a generic scanner error, silently replacing the real ComServer finding on 5.1. Now -join. - Get-PfxCertificate -Password (PS6+) made Set-MsixScriptSignature and Add-MsixStandardScript -Pfx unusable on 5.1. Now constructs X509Certificate2 from the file + SecureString directly (no plaintext), disposed in a finally. - '-Encoding utf8' means BOM on 5.1 and no BOM on 7. That put a BOM in the Trusted Signing metadata JSON (System.Text.Json rejects it - the DEFAULT signing backend) and in PSF config.json. New _MsixWriteUtf8 makes the choice explicit: no BOM for machine-parsed JSON/XML/HTML, BOM for generated .ps1 (where 5.1 would otherwise read UTF-8 as CP-1252). - Templates were read with no -Encoding, so 5.1 mis-decoded the BOM-less .tmpl files as CP-1252 and wrote mojibake into signed customer scripts. - Sandbox bootstrap: escape package name and cert path with _MsixEscapeSingleQuote (single-quoted literals cannot be backtick-escaped). CI: the 5.1 lane now resolves every command and parameter in the module against real 5.1 metadata - PSSA's compatibility rules do not flag this class. Tests: MSIX.DataLossGuards.Tests.ps1 (6, all verified failing against the old code) and 4 new 5.1 guards. Full suite 724/0/1, PSSA clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
, #151) Item 3 - signing-toolchain hijack chain (#147): - _MsixSetVerifiedToolsRoot verified exactly three .exe files. SDK signtool.exe is SxS-manifest-bound to load wintrust.dll / mssign32.dll / AppxSip.dll from its OWN directory, so an attacker who could write that directory kept the genuine signed executables (passing the check) and planted a trojaned dependency DLL beside them, executing code inside the process holding the organisation's signing key. Now verifies EVERY .exe/.dll in the resolved root and refuses a root with no verifiable payload. - Tool discovery walked up to FOUR parent levels and took the lexically highest sibling containing Tools\MakeAppx.exe. For a CurrentUser install that reaches ~\Documents, which is user-writable, so ~\Documents\zzz\Tools\ won every session. Now looks one level up only. - The "verifier not loaded" branch silently cached and trusted the root with no warning. Now throws (fail-closed). - The MSIX_SKIP_TOOL_VERIFICATION notice also goes to the real Warning stream; Write-MsixLog routes to Write-Information, which is invisible to -WarningVariable and dropped under Set-MsixLogLevel -Level Error. Item 3 - credentials (#148): - Invoke-MsixProcess logged the full argument vector at Debug, and Write-MsixLog appends to the file set by Set-MsixLogFile - so the documented troubleshooting flow wrote the PFX password to disk in clear text. The value following a secret-bearing switch (/p, -p, --password) is now redacted. - SignerSignEx left the PFX PRIVATE KEY in the user's key store: PersistKeySet is required for signtool to reach it, but removing the certificate context does not remove the key container, so every run deposited a copy of the code-signing key in the roaming profile (and Exportable made it extractable). The container is now deleted explicitly (CNG and legacy CAPI paths); verified the key-file count returns to baseline. Item 4 - CI (#151): - The integration lane now provisions PSF as well as the SDK, so PSF-dependent tests actually run. They never had: the Add-MsixPsfV2 re-injection guard for the ContainsKey crash (#138) and the Remove-MsixPsf round-trip covering the #145 data-loss fix had never executed on a CI runner. - Provisioning failure is fatal instead of a "::warning::" that produced a green build with ~96 tests silently skipped, and the lane asserts a passed-count floor so an all-skip run is red. - Test-MsixFixtureToolingAvailable accepted only <root>\Tools\MakeAppx.exe, so it returned false on hosts using the system Windows SDK (makeappx.exe sits at the root) - silently disabling integration coverage. Both layouts now work. Tests: MSIX.ToolVerification.Tests.ps1 extended to 7 (SxS DLL coverage, empty- root refusal, real Warning stream, /p redaction). Full suite 728/0/1, local Integration lane 100/0/0, PSSA clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s hiding (#150, #152, #153) Item 5a - workspace leaks (#150): - _MsixMutateManifest created the workspace BEFORE the try whose finally removes it, leaving six throwing operations outside - most importantly the caller's own mutate block, which throws on ordinary input errors like an unknown -AppId. This helper backs ~46 mutators, making it the highest-frequency leak: 1,077 stale msix-* directories were found on one dev machine, oldest ~3 months old. - _MsixUnpackForCompare and _MsixResolveScanWorkspace leaked permanently on a failed unpack: the path was never returned, so no caller could clean it up. Item 5b - the coverage ratchet was measuring the wrong thing (#152): - It defined "invoked" as a regex over test source text, so `Get-Command Add-MsixFoo -Module MSIX` counted as coverage, and so did Context/It TITLE strings. The debt list therefore read EMPTY while 12 mutators were never called. Replaced with AST CommandAst detection, which cannot be fooled by a string, a comment, or an argument to Get-Command/Mock. - Update-MsixMgr and Update-MsixSdkTool moved to PermanentlyExcluded: they are the same network-updater class as the four already there. - Six mutators got their first real behavioural tests (MSIX.UncoveredMutators.Tests.ps1). Item 5c - three real bugs those six tests immediately exposed (#153). All three made a cmdlet emit a manifest MakeAppx refuses to pack, so the affected cmdlets could never produce a usable package: - Add-MsixLoaderSearchPathOverride wrote attribute 'LoaderSearchPath'. Per UapManifestSchema_v6.xsd (CT_LoaderSearchPathOverride) the required attribute is 'FolderPath'. - Add-MsixLoaderSearchPathOverride declared the extension under Application extensions; MakeAppx requires windows.loaderSearchPathOverride under <Package>. - Add-MsixFirewallRule passed the caller's casing through to the schema, which requires lowercase - so -Direction In produced "The attribute 'Direction' with value 'In' failed to parse". Now normalised. Tests: +6 mutator tests, +1 workspace-leak regression test. Full suite 735/0/1, PSSA clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New: Import-Module notifies when a newer version is published on PSGallery, so field fixes reach the people running the module instead of waiting to be noticed. Constraints, in priority order: never slow or break Import-Module (hard network timeout, every failure swallowed), silent where a message is useless (CI, non-interactive hosts, MSIX_NO_UPDATE_CHECK), and at most one network call per day per user, cached on disk. Verified: 273 ms live lookup, 60 ms fail on an unreachable endpoint, opt-out and CI gating honoured. Also: Get-MsixHeuristicFinding and _MsixResolveScanWorkspace used Get-Item without -ErrorAction Stop, so a missing or mistyped package path emitted a non-terminating error and analysis continued with nothing - producing an empty finding set, which reads as "this package is clean". Same honesty rule as #140; both now fail loudly. 0.74.0 release notes + CHANGELOG. Full suite 745/0/1, PSSA clean, 5.1 command/parameter scan clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "verify EVERY .exe/.dll in the resolved tools root" approach from the
previous commit was wrong and CI caught it: Microsoft itself ships UNSIGNED
binaries in these directories. Surveyed on real installs:
Windows SDK bin\10.0.26100.0\x64 : 9 unsigned of 121
gamesaveutil.exe, WinAppDeployCmd.exe, SirepClient.dll, SshClient.dll,
Microsoft.Tools.Deploy.dll, ipoverusb.discoverpartners.dll, ...
NuGet BuildTools Tools\ : 5 unsigned of 15
PackageEditor.exe, ComparePackage.exe, Microsoft.Packaging.SDKUtils.dll, ...
So a blanket check rejects every legitimate SDK installation - it broke the
Pester lane on the GitHub runner, which resolves the system SDK.
Verification is now scoped to what actually matters for the threat: the
executables this module runs (signtool/MakeAppx/makepri) PLUS signtool's private
side-by-side load surface (wintrust.dll, mssign32.dll, AppxSip.dll, msisip.dll,
opcservices.dll). That still closes the reported hijack - planting a trojaned
wintrust.dll beside genuine signed executables is caught - without rejecting
binaries the tools never load. A root containing none of the three executables
is still refused.
Verified against both real layouts that broke CI: system SDK and the vendored
BuildTools root are accepted. Test added pinning that unsigned SDK extras are
tolerated while signtool is still verified. Full suite 746/0/1, PSSA clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the CI regression, and a real user-facing bug it exposed:
Get-MsixToolsRoot advertises five search paths, and path 4 returns an already-
installed Windows SDK - a FLAT root (makeappx.exe directly, no Tools\ subfolder).
But all 34 call sites hardcoded "$toolsRoot\Tools\<tool>.exe", so that root could
never actually be used. Any user with the Windows SDK installed who had not run
Install-MsixSdkTool hit:
Executable not found: ...\bin\10.0.26100.0\x64\Tools\MakeAppx.exe
This stayed invisible because Test-MsixFixtureToolingAvailable ALSO required the
Tools\ layout, so on such a host every integration test skipped. Correcting that
gate (#151) made the tests run and surfaced the defect as 86 failures on the
GitHub runner, which has a system SDK and no provisioned toolchain.
- New _MsixToolPath resolves <root>\Tools\<tool> first, then <root>\<tool>, and
falls back to the Tools\ form for the familiar not-found message. Applied to
MakeAppx.exe / signtool.exe / makepri.exe across 15 files. Tools\msixmgr.exe,
Tools\AzureSignTool\ and Tools\TrustedSigning\ are OUR install layout and are
unchanged.
- Set-MsixToolsRoot accepts both layouts too.
- Path 4 now logs a Warning: a system SDK means the toolchain version is whatever
the host has, which is not reproducible across build agents, so operators are
told to run Initialize-MsixToolchain to pin one. The downloaded toolchain is
still preferred - Tools\ is checked first everywhere.
The intended workflow (Initialize-MsixToolchain -> <module>\Tools\) is unchanged
and still wins. Verified end-to-end against a pinned FLAT system-SDK root:
fixture build, mutator, and scanner all succeed. Full suite 746/0/1, PSSA clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit converted the 34 module call sites but missed four TEST
files that resolve MakeAppx themselves, which is why 3 failures remained:
- MSIX.NestedPackage.Tests.ps1 (sparse nested package)
- MSIX.FrameworkPackages.Tests.ps1 (modification package registry + diff, x2)
- MSIX.Bundle.Tests.ps1 (MakePri availability probe)
- MSIX.MutatorCoverage.Tests.ps1 (tools-root validity probe)
The first two call inside `& (Get-Module MSIX) { }`, so they now use
_MsixToolPath directly; the two probes check both layouts inline.
Verified properly this time: the full suite was run against a FLAT system-SDK
root in an isolated module copy with no vendored Tools\ - i.e. the exact CI
Pester-lane condition - giving 737 passed / 0 failed / 10 skipped. The previous
push was validated only against the nested layout, which is why CI still failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 3, 2026
Closed
Workspace leak: pre-pack failures abandon the full unpacked package (1,077 stale dirs observed)
#150
Closed
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hardening release from a full-codebase audit (security, enterprise stability, PS 5.1 compatibility, test/CI quality). Closes #145, #146, #147, #148, #150, #151, #152, #153.
Everything below shipped in 0.73.x. Several items could destroy or corrupt an operator's package, or hand a local attacker control of what the organisation signs.
Data loss (#145)
Remove-MsixPsfdestroyed payload in PSF-free packages. The delete loop ran unconditionally with patterns as generic asconfig.jsonand*Fixup*.dll, recursed over the whole package, and because deletions alone counted as "changed" the result was repacked, signed and moved over the operator's original — exit code 0. An Electron/.NET app shipping its ownresources\app\config.jsonsimply lost it. Now gated on real PSF presence, with precise patterns.Add-MsixVcRuntimeBundlepacked and signed over the input file with no-UnsignedOutputPath, so a signing failure left an unsigned repack where a signed package had been. Now scratch → sign → atomic move.-WhatIfwas broken for all ~46 mutators:New-MsixWorkspacehonoured the inherited$WhatIfPreferenceand returned an empty path.-UnsignedOutputPathcould destroy the artifact it promised to preserve — copy with-ErrorAction SilentlyContinue, unconditional "preserved" log, then the scratch deleted infinally.Security (#147, #148)
.exefiles were verified, but SDKsigntool.exeloadswintrust.dll/mssign32.dll/AppxSip.dllfrom its own directory — so genuine signed executables plus one planted DLL passed the check and ran attacker code inside signing. Every.exe/.dllin the root is now verified.~\Documentsfor a CurrentUser install). Now one level.Exec:line, collected by CI artifact upload and support bundles → secret-bearing switches redacted.SignerSignExleft the PFX private key in the user's key store on every run → container now deleted explicitly (verified key-file count returns to baseline).Windows PowerShell 5.1 (#146)
Join-String(PS6+) silently replaced the real ComServer finding with a scanner error on 5.1.Get-PfxCertificate -Password(PS6+) madeSet-MsixScriptSignature/Add-MsixStandardScript -Pfxunusable on 5.1.-Encoding utf8= BOM on 5.1, no BOM on 7 → a BOM in the Trusted Signing metadata JSON (rejected by System.Text.Json, on the default signing backend) and in PSFconfig.json. New_MsixWriteUtf8makes it explicit and identical on both.Manifest correctness (#153)
Found by writing the first behavioural tests for functions the ratchet had falsely certified. All three emitted manifests MakeAppx refuses to pack:
Add-MsixLoaderSearchPathOverridewrote attributeLoaderSearchPath;UapManifestSchema_v6.xsdrequiresFolderPath.Add-MsixLoaderSearchPathOverridedeclared the extension under Application; it must be under<Package>.Add-MsixFirewallRulepassed the caller's casing to a schema requiring lowercase, so-Direction Infailed to parse.Reliability + CI (#150, #151, #152)
msix-*directories on one ordinary dev machine, oldest ~3 months. Also two helpers that leaked permanently because the path was never returned.OrderedDictionary.ContainsKeyguard from Fix PSF config merge crash on re-injection (OrderedDictionary.ContainsKey) #138 — had never once run on a runner. Provisioning failure is now fatal (was a::warning::that produced a green build with ~96 tests skipped), and a passed-count floor makes an all-skip run red.Get-Commandcalls andIttitles as coverage, so the debt list read EMPTY while 12 mutators were never invoked. Replaced with ASTCommandAstdetection; six mutators gained real tests.Test-MsixFixtureToolingAvailablereturned$falseon system-SDK hosts, silently disabling integration coverage.New
Import-Module. Opt out with$env:MSIX_NO_UPDATE_CHECK = '1'. Measured: 273 ms live lookup, 60 ms fail on an unreachable endpoint.Validation
🤖 Generated with Claude Code