fix: enumerate Windows ACL entries by SID #63

Merged
agent merged 2 commits from fix/windows-acl-sid-enumeration into main 2026-11-03 09:11:41 +00:00
7 changed files with 135 additions and 18 deletions

No files matched your search

+6
View File
@@ -10,6 +10,12 @@
auto-apply config и task выключены. Изолированный PowerShell 5.1 harness для
bundle-проверки завершился успешно на точном PR SHA; реальная установка,
регистрация задач, SSH и production не выполнялись и не активировались.
- Infrastructure: Windows release installer и external receiver читают владельцев
и ACL ACE напрямую как SID, без преобразования в имена учётных записей. Это
устраняет зависимость проверки от разрешения имён; неизвестные SID остаются
видимыми и отклоняются существующими fail-closed guard-политиками. Проверены
synthetic unmapped-SID fixtures и read-only ACL preflight; production ACL не
менялись.
- Документация: объединены продуктовый, UI- и deployment-планы; уточнены
внешняя backup-retention политика и обязательный owner-approved maintenance
window для cold backup. Передача корпуса требует атомарной публикации
+13
View File
@@ -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
+3 -2
View File
@@ -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 }
+3
View File
@@ -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):
+12 -10
View File
@@ -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
@@ -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 {
@@ -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)