diff --git a/src/lib/windows-owner-acl.ts b/src/lib/windows-owner-acl.ts index 4b1559dd618..b40954a02e5 100644 --- a/src/lib/windows-owner-acl.ts +++ b/src/lib/windows-owner-acl.ts @@ -19,7 +19,11 @@ const PRIVATE_DACL_FLAGS = 0x1004; // DiscretionaryAclPresent | DiscretionaryAcl const FULL_CONTROL = 2032127; // This script is constant. Literal paths are environment data, never PowerShell source. -// Enumerate the descriptor Get-Acl reports. GetAccessRules projects away unsupported ACE details, so the +// Read the descriptor through .NET (Owner, Group and Access, as Get-Acl does). Get-Acl is a module-autoloaded +// cmdlet: PowerShell 5.1 resolves it through the per-profile module analysis cache, and with a fresh or +// redirected LOCALAPPDATA that rebuild scans every PSModulePath module and can outlast the inspection budget +// (hosted Windows runners: the pairing CLI timed out at 30 s). Every call below is a .NET member, so nothing +// autoloads and no module on PSModulePath can shadow the read. GetAccessRules projects away unsupported ACE details, so the // ACEs are read from the serialized descriptor instead. .NET canonicalizes it first: compatible entries for the // same principal merge and entries that grant nothing drop, but another principal's entry is never folded into // the user's, and the owner is untouched. The policy is therefore the effective DACL, not the on-disk bytes. @@ -36,10 +40,13 @@ try { $administrators=[System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $enabled=$principal.IsInRole($administrators) [Console]::WriteLine(('U|{0}|{1}|{2}' -f $identity.User.Value,$identity.Owner.Value,$enabled)) + $sections=[System.Security.AccessControl.AccessControlSections]'Owner,Group,Access' for ($i=0; $i -lt $count; $i++) { $path=[Environment]::GetEnvironmentVariable(('OCX_ACL_PATH_{0}' -f $i)) if ([string]::IsNullOrEmpty($path)) { throw 'path' } - $acls=@(Get-Acl -LiteralPath $path) + if ([System.IO.Directory]::Exists($path)) { $acl=[System.Security.AccessControl.DirectorySecurity]::new($path, $sections) } + else { $acl=[System.Security.AccessControl.FileSecurity]::new($path, $sections) } + $acls=@($acl) if ($acls.Count -ne 1) { throw 'acl' } $bytes=$acls[0].GetSecurityDescriptorBinaryForm() $raw=[System.Security.AccessControl.RawSecurityDescriptor]::new([byte[]]$bytes, 0) diff --git a/tests/gui/gui-pair-delivery.test.ts b/tests/gui/gui-pair-delivery.test.ts index 53d107a6fe6..fbfd4eb5fd0 100644 --- a/tests/gui/gui-pair-delivery.test.ts +++ b/tests/gui/gui-pair-delivery.test.ts @@ -580,3 +580,18 @@ describe("Windows redemption owner and effective-DACL policy", () => { expect(s.pairingGrants.size).toBe(1); expect(existsSync(path)).toBe(false); }, 45_000); }); + +test("pairing ACL script reads descriptors through .NET, never through module-autoloaded cmdlets", () => { + // Autoloaded cmdlets resolve through the per-profile module analysis cache; a fresh or redirected + // LOCALAPPDATA rebuilds it by scanning every PSModulePath module and outlasted the 30 s budget on + // hosted Windows runners. A module earlier on PSModulePath could also shadow the cmdlet. + const source = readFileSync(repoPath("src/lib/windows-owner-acl.ts"), "utf8"); + const start = source.indexOf("const ACL_SCRIPT = String.raw`"); + const script = source.slice(start, source.indexOf("`;", start)); + expect(start).toBeGreaterThan(-1); + for (const cmdlet of ["Get-Acl", "ConvertTo-Json", "ForEach-Object", "New-Object", "Get-Item", "Select-Object", "Where-Object"]) { + expect(script).not.toContain(cmdlet); + } + expect(script).toContain("[System.Security.AccessControl.DirectorySecurity]::new($path, $sections)"); + expect(script).toContain("[System.Security.AccessControl.FileSecurity]::new($path, $sections)"); +});