Skip to content

0.74.0 - hardening: data loss, signing security, PS 5.1, CI honesty - #154

Merged
sanderdewit merged 7 commits into
mainfrom
release/0.74.0
Aug 3, 2026
Merged

0.74.0 - hardening: data loss, signing security, PS 5.1, CI honesty#154
sanderdewit merged 7 commits into
mainfrom
release/0.74.0

Conversation

@sanderdewit

Copy link
Copy Markdown
Owner

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-MsixPsf destroyed payload in PSF-free packages. The delete loop ran unconditionally with patterns as generic as config.json and *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 own resources\app\config.json simply lost it. Now gated on real PSF presence, with precise patterns.
  • Add-MsixVcRuntimeBundle packed 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.
  • -WhatIf was broken for all ~46 mutators: New-MsixWorkspace honoured the inherited $WhatIfPreference and returned an empty path.
  • -UnsignedOutputPath could destroy the artifact it promised to preserve — copy with -ErrorAction SilentlyContinue, unconditional "preserved" log, then the scratch deleted in finally.

Security (#147, #148)

  • Signing-toolchain hijack. Only three .exe files were verified, but SDK signtool.exe loads wintrust.dll / mssign32.dll / AppxSip.dll from its own directory — so genuine signed executables plus one planted DLL passed the check and ran attacker code inside signing. Every .exe/.dll in the root is now verified.
  • Discovery reached a user-writable directory (4-level parent walk, lexically highest wins → ~\Documents for a CurrentUser install). Now one level.
  • Fail-open branch when the verifier wasn't loaded → now throws.
  • PFX password written to the log file via the Exec: line, collected by CI artifact upload and support bundles → secret-bearing switches redacted.
  • SignerSignEx left 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+) made Set-MsixScriptSignature / Add-MsixStandardScript -Pfx unusable 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 PSF config.json. New _MsixWriteUtf8 makes it explicit and identical on both.
  • The 5.1 CI lane now resolves every command and parameter against real 5.1 metadata; PSSA's compatibility rules do not flag this class.

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-MsixLoaderSearchPathOverride wrote attribute LoaderSearchPath; UapManifestSchema_v6.xsd requires FolderPath.
  • Add-MsixLoaderSearchPathOverride declared the extension under Application; it must be under <Package>.
  • Add-MsixFirewallRule passed the caller's casing to a schema requiring lowercase, so -Direction In failed to parse.

Reliability + CI (#150, #151, #152)

  • Workspace leak behind ~46 mutators — 1,077 stale msix-* directories on one ordinary dev machine, oldest ~3 months. Also two helpers that leaked permanently because the path was never returned.
  • CI never provisioned PSF, so the PSF regression guards — including the OrderedDictionary.ContainsKey guard 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.
  • The coverage ratchet counted Get-Command calls and It titles as coverage, so the debt list read EMPTY while 12 mutators were never invoked. Replaced with AST CommandAst detection; six mutators gained real tests.
  • Test-MsixFixtureToolingAvailable returned $false on system-SDK hosts, silently disabling integration coverage.

New

  • PSGallery update notification on import. Silent in CI and non-interactive hosts, cached 24h, hard network timeout, every failure swallowed — an update notice must never slow or break Import-Module. Opt out with $env:MSIX_NO_UPDATE_CHECK = '1'. Measured: 273 ms live lookup, 60 ms fail on an unreachable endpoint.
  • Missing/mistyped package paths now fail loudly instead of returning an empty finding set that reads as "clean".

Validation

  • Full suite 745 passed / 0 failed / 1 skipped; local Integration lane 100/0/0.
  • PSSA clean across the repo at ParseError/Error/Warning.
  • 5.1 command/parameter scan clean under real Windows PowerShell 5.1.
  • The six data-loss guards were verified to fail against the pre-fix code (stash, run, restore) — they are genuine regression tests, not tautologies.
  • Private-key deletion and password redaction verified empirically.

🤖 Generated with Claude Code

Sander de Wit and others added 7 commits August 3, 2026 18:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CRITICAL: three paths silently destroy or corrupt the operator's package

1 participant