diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index 5bcc87e..bb91d55 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -1,5 +1,18 @@ # Infrastructure current state +## Windows ACL SID resolution — 2026-11-03 + +- Read-only PowerShell 5.1 inspection found that display-name conversion of + ACL entries can throw `IdentityNotMappedException` even though the ACL itself + is readable. The installer and external-receiver guards now enumerate access + rules directly as `SecurityIdentifier` values and read owners as SIDs; unknown + principals remain visible to the existing fail-closed policy. Regression + coverage exercises synthetic unmapped SIDs through the actual installer guard + functions. These repository changes are pending review; no Windows ACL, + installer, task, VPS, or production state was changed. The native PowerShell + harness still requires execution on Windows before this behavior is confirmed + in that runtime. + ## External deployment runtime binding — 2026-11-03 - PR #59 is merged to `main`; it contains the root-only read-only status diff --git a/infra/backup/receive_external.py b/infra/backup/receive_external.py index 5bce695..65ef909 100644 --- a/infra/backup/receive_external.py +++ b/infra/backup/receive_external.py @@ -83,8 +83,9 @@ $allowed = @($owner, 'S-1-5-18', 'S-1-5-32-544') $aclOwner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($aclOwner -notin $allowed) { exit 6 } $ownerAllowed = $false -foreach ($rule in $acl.Access) { - $sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value +$rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]) +foreach ($rule in $rules) { + $sid = $rule.IdentityReference.Value if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $sid -notin $allowed) { exit 2 } if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { exit 3 } if ($sid -eq $owner) { $ownerAllowed = $true } diff --git a/infra/backup/test_receive_external.py b/infra/backup/test_receive_external.py index 229e138..4a1566b 100644 --- a/infra/backup/test_receive_external.py +++ b/infra/backup/test_receive_external.py @@ -203,6 +203,9 @@ class ExternalReceiverTests(unittest.TestCase): self.assertIn("System.IO.DriveInfo", run_mock.call_args.args[0][-1]) self.assertNotIn("Get-Volume", run_mock.call_args.args[0][-1]) self.assertIn("$acl.GetOwner(", run_mock.call_args.args[0][-1]) + self.assertIn("$acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])", run_mock.call_args.args[0][-1]) + self.assertIn("$rule.IdentityReference.Value", run_mock.call_args.args[0][-1]) + self.assertNotIn(".Translate(", run_mock.call_args.args[0][-1]) self.assertIn("$aclOwner -notin $allowed", run_mock.call_args.args[0][-1]) def test_private_acl_rejects_an_unapproved_acl_owner(self): diff --git a/infra/deploy/install_windows_release_client.ps1 b/infra/deploy/install_windows_release_client.ps1 index 6491035..f754a97 100644 --- a/infra/deploy/install_windows_release_client.ps1 +++ b/infra/deploy/install_windows_release_client.ps1 @@ -50,7 +50,8 @@ function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) { $item = Get-Item -LiteralPath $Path -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing reparse path: $Path" } $acl = Get-Acl -LiteralPath $Path - $ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + $rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]) $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' if ($AllowOwnerWrite) { $approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value) @@ -60,24 +61,24 @@ function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) { if ($ownerSid -notin $approvedOwners) { throw "Protected path has an unapproved owner: $Path" } if ($AllowOwnerWrite) { $allowed = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value) - foreach ($rule in $acl.Access) { - $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + foreach ($rule in $rules) { + $sid = $rule.IdentityReference.Value if ($rule.AccessControlType -ne 'Allow' -or $sid -notin $allowed -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { throw "Private state path ACL must grant only full control to owner/SYSTEM/Administrators: $Path" } } - $ownerHasFullControl = @($acl.Access | Where-Object { + $ownerHasFullControl = @($rules | Where-Object { $_.AccessControlType -eq 'Allow' -and - $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -eq $identity.User.Value -and + $_.IdentityReference.Value -eq $identity.User.Value -and ($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl }).Count -gt 0 if (-not $ownerHasFullControl) { throw "Task owner needs full control on protected state path: $Path" } } else { - $writableUntrusted = @($acl.Access | Where-Object { + $writableUntrusted = @($rules | Where-Object { $_.AccessControlType -eq 'Allow' -and (([int]$_.FileSystemRights -band [int]$mutationRights) -ne 0) -and - $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller) + $_.IdentityReference.Value -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller) }) if ($writableUntrusted.Count -gt 0) { throw "Protected path grants write access outside SYSTEM/Administrators: $Path" } } @@ -102,13 +103,14 @@ function Assert-TrustedPath([string]$Path, [bool]$ParentsOnly = $false) { throw "Protected path contains a reparse point: $($current.FullName)" } $acl = Get-Acl -LiteralPath $current.FullName - $ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value if ($ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) { throw "Protected path has an untrusted owner: $($current.FullName)" } $checkedRights = if ($isLeaf) { $mutationRights } else { $pathReplacementRights } - foreach ($rule in $acl.Access) { - $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + $rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]) + foreach ($rule in $rules) { + $sid = $rule.IdentityReference.Value if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue } if ($rule.AccessControlType -eq 'Allow' -and (([int]$rule.FileSystemRights -band [int]$checkedRights) -ne 0) -and diff --git a/infra/deploy/test_windows_release_bundle_mode.ps1 b/infra/deploy/test_windows_release_bundle_mode.ps1 index 3d7a685..d21a418 100644 --- a/infra/deploy/test_windows_release_bundle_mode.ps1 +++ b/infra/deploy/test_windows_release_bundle_mode.ps1 @@ -17,8 +17,7 @@ if (($temporaryRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne throw 'Temporary fixture root must not be a reparse point.' } $temporaryRootCreated = $temporaryRootItem.CreationTimeUtc -$temporaryAclOwner = (Get-Acl -LiteralPath $temporaryRoot).Owner -$temporaryOwnerSid = ([Security.Principal.NTAccount]$temporaryAclOwner).Translate( +$temporaryOwnerSid = (Get-Acl -LiteralPath $temporaryRoot).GetOwner( [Security.Principal.SecurityIdentifier]).Value $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value if ($temporaryOwnerSid -cne $currentSid) { throw 'Temporary fixture root is not owned by the current user.' } @@ -44,8 +43,7 @@ function Get-VerifiedFixtureRoot([string]$Candidate) { $item.CreationTimeUtc -ne $temporaryRootCreated) { throw 'Fixture root was replaced, moved, or resolved outside its original identity.' } - $ownerName = (Get-Acl -LiteralPath $candidatePath).Owner - $ownerSid = ([Security.Principal.NTAccount]$ownerName).Translate( + $ownerSid = (Get-Acl -LiteralPath $candidatePath).GetOwner( [Security.Principal.SecurityIdentifier]).Value if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) { throw 'Fixture root owner changed.' @@ -63,8 +61,7 @@ function Get-VerifiedFixtureEntry([string]$Path, [string]$ResolvedRoot) { if (-not $resolved.StartsWith($ResolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Fixture entry resolved outside its verified temporary root.' } - $ownerName = (Get-Acl -LiteralPath $resolved).Owner - $ownerSid = ([Security.Principal.NTAccount]$ownerName).Translate( + $ownerSid = (Get-Acl -LiteralPath $resolved).GetOwner( [Security.Principal.SecurityIdentifier]).Value if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) { throw 'Fixture entry is not owned by the current user.' @@ -104,6 +101,88 @@ try { if ($null -eq $validatorAst) { throw 'Installer bundle-mode validator was not found.' } . ([scriptblock]::Create($validatorAst.Extent.Text)) + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $mutationRights = [System.Security.AccessControl.FileSystemRights]( + [int][System.Security.AccessControl.FileSystemRights]::WriteData -bor + [int][System.Security.AccessControl.FileSystemRights]::AppendData -bor + [int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor + [int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor + [int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [int][System.Security.AccessControl.FileSystemRights]::Delete -bor + [int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [int][System.Security.AccessControl.FileSystemRights]::TakeOwnership + ) + $pathReplacementRights = [System.Security.AccessControl.FileSystemRights]( + [int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [int][System.Security.AccessControl.FileSystemRights]::Delete -bor + [int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [int][System.Security.AccessControl.FileSystemRights]::TakeOwnership + ) + foreach ($guardName in @('Assert-ProtectedPath', 'Assert-TrustedPath')) { + $guardAst = $installerAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $guardName + }, $true) + if ($null -eq $guardAst) { throw "Installer ACL guard was not found: $guardName" } + . ([scriptblock]::Create($guardAst.Extent.Text)) + } + + $unknownSid = [Security.Principal.SecurityIdentifier]::new( + 'S-1-5-21-3141592653-2718281828-1618033988-987654') + $unmapped = $false + try { $null = $unknownSid.Translate([Security.Principal.NTAccount]) } + catch [Security.Principal.IdentityNotMappedException] { $unmapped = $true } + if (-not $unmapped) { throw 'The synthetic ACL SID unexpectedly resolves to an NTAccount.' } + + $savedMockAcl = $script:akyldashAclFixture + function Get-Acl([string]$LiteralPath) { return $script:akyldashAclFixture } + try { + $sidSystem = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $sidAdmins = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $privateFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new() + $privateFixtureAcl.SetAccessRuleProtection($true, $false) + $privateFixtureAcl.SetOwner($identity.User) + foreach ($sid in @($sidSystem, $sidAdmins, $identity.User)) { + $null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.AccessControlType]::Allow)) + } + $null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + $unknownSid, [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.AccessControlType]::Allow)) + $script:akyldashAclFixture = $privateFixtureAcl + $privateRejected = $false + try { Assert-ProtectedPath -Path $temporaryRoot -AllowOwnerWrite $true } + catch { + if ($_.Exception.Message -notlike 'Private state path ACL must grant only full control*') { throw } + $privateRejected = $true + } + if (-not $privateRejected) { throw 'Unmapped SID was not rejected by the protected-path guard.' } + + $trustedFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new() + $trustedFixtureAcl.SetAccessRuleProtection($true, $false) + $trustedFixtureAcl.SetOwner($sidSystem) + $null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + $sidSystem, [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.AccessControlType]::Allow)) + $null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + $unknownSid, [System.Security.AccessControl.FileSystemRights]::WriteData, + [System.Security.AccessControl.AccessControlType]::Allow)) + $script:akyldashAclFixture = $trustedFixtureAcl + $trustedRejected = $false + try { Assert-TrustedPath -Path $temporaryRoot } + catch { + if ($_.Exception.Message -notlike 'Protected path grants replacement/write access*') { throw } + $trustedRejected = $true + } + if (-not $trustedRejected) { throw 'Unmapped SID was not rejected by the trusted-path guard.' } + } finally { + Remove-Item Function:\Get-Acl -ErrorAction SilentlyContinue + if ($null -eq $savedMockAcl) { Remove-Variable -Name akyldashAclFixture -Scope Script -ErrorAction SilentlyContinue } + else { $script:akyldashAclFixture = $savedMockAcl } + } + Add-Type -AssemblyName System.IO.Compression $stream = [IO.File]::OpenRead($bundlePath) try { diff --git a/infra/deploy/test_windows_release_launcher.py b/infra/deploy/test_windows_release_launcher.py index cebdc12..ab28840 100644 --- a/infra/deploy/test_windows_release_launcher.py +++ b/infra/deploy/test_windows_release_launcher.py @@ -113,6 +113,10 @@ class WindowsReleaseBootstrapTests(unittest.TestCase): script.index("$bundleStream = [IO.File]::Open")) self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script) self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)", script) + self.assertIn("$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value", script) + self.assertGreaterEqual(script.count("$acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])"), 2) + self.assertIn("$rule.IdentityReference.Value", script) + self.assertNotIn(".Translate(", script) self.assertIn("Assert-TrustedPath $appRoot", script) self.assertIn("Assert-TrustedPath $programData -ParentsOnly $true", script) self.assertIn("Set-TrustedCodeOwner $app", script) @@ -120,6 +124,15 @@ class WindowsReleaseBootstrapTests(unittest.TestCase): for name in release_bundle.FILES: self.assertIn(f"files/{name}", script) + def test_windows_acl_harness_exercises_unmapped_sid_with_real_installer_guards(self): + harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8") + self.assertIn("Assert-ProtectedPath", harness) + self.assertIn("Assert-TrustedPath", harness) + self.assertIn("IdentityNotMappedException", harness) + self.assertIn("$unknownSid", harness) + self.assertIn("$guardAst.Extent.Text", harness) + self.assertIn("Unmapped SID was not rejected by the protected-path guard", harness) + def test_release_bundle_uses_exact_commit_objects_not_dirty_worktree_or_git_hooks(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory)