From 0b92b69d53874efe8efef3ea9f1433742fa67393 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 11:53:22 +0000 Subject: [PATCH 1/4] fix: write only the sections of the security descriptor that a cmdlet changes The access and audit cmdlets wrote the owner of an item back with the entries they changed. For a DACL without the auto-inherit flag, Windows returns the owner and the group even when only the DACL is read, and the cmdlets wrote every section that the descriptor held. Without the Restore privilege, or on a file server that refuses the owner, the write failed with error 1307 (#34). - Add-NTFSAccess, Clear-NTFSAccess, Add-NTFSAudit, and Clear-NTFSAudit read only the DACL or the SACL. FileSystemSecurity2.Write(), Remove-NTFSAccess, and Remove-NTFSAudit write only the sections they read, which also fixes the access inheritance cmdlets. - Read together with the SACL, the inherited entries of such a DACL lose their inherited flag when the parent folder has no SACL, and the cmdlets stored them as explicit copies. Get-NTFSSecurityDescriptor now reads the DACL in a separate call. - Set-NTFSSecurityDescriptor writes only the sections that changed since they were read; an unchanged descriptor writes nothing (maintainer decision of 2026-10-06). - Clear-NTFSAudit writes nothing for an item without a SACL, and reports an error without the Security privilege (maintainer decision). The regression tests failed before and pass after the fix in Windows PowerShell 5.1 and PowerShell 7, elevated and as a basic user. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 26 ++- Docs/Cmdlets/Add-NTFSAccess.md | 2 + Docs/Cmdlets/Add-NTFSAudit.md | 2 + Docs/Cmdlets/Clear-NTFSAccess.md | 2 + Docs/Cmdlets/Clear-NTFSAudit.md | 4 +- Docs/Cmdlets/Get-NTFSSecurityDescriptor.md | 4 +- Docs/Cmdlets/Remove-NTFSAccess.md | 2 + Docs/Cmdlets/Set-NTFSSecurityDescriptor.md | 2 +- .../SetSecurityDescriptor.cs | 5 +- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 13 +- ...temAccessRule2.AddFileSystemAccessRules.cs | 3 +- ...AccessRule2.RemoveFileSystemAccessRules.cs | 10 +- ...essRule2.RemoveFileSystemAccessRulesAll.cs | 3 +- ...ystemAuditRule2.AddFileSystemAuditRules.cs | 3 +- ...temAuditRule2.RemoveFileSystemAuditRule.cs | 9 +- ...AuditRule2.RemoveFileSystemAuditRuleAll.cs | 10 +- Security2/FileSystem/FileSystemSecurity2.cs | 83 ++++++++- Tests/Access.Tests.ps1 | 110 ++++++++++++ Tests/Audit.Tests.ps1 | 121 +++++++++++++ Tests/Inheritance.Tests.ps1 | 45 ++++- Tests/SecurityDescriptor.Tests.ps1 | 160 ++++++++++++++++++ Tests/TestHelpers.Tests.ps1 | 31 ++++ Tests/TestHelpers.psm1 | 42 ++++- 23 files changed, 662 insertions(+), 30 deletions(-) create mode 100644 Tests/SecurityDescriptor.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e3cad46..8a0d1e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,13 +65,21 @@ The format is based on `Get-ChildItem`, which made the import fail in Windows PowerShell when another module had added a `Size` member; use `LengthOnDisk` ([#82](https://github.com/raandree/NTFSSecurity/issues/82)) +- Write only the sections of a security descriptor that changed since it + was read in `Set-NTFSSecurityDescriptor`, such as the DACL after + `Add-NTFSAccess -SecurityDescriptor`; a descriptor without changes writes + nothing. The cmdlet wrote every section that `Get-NTFSSecurityDescriptor` + had read, also an unchanged owner, which failed with error 1307 where the + account may not assign that owner + ([#34](https://github.com/raandree/NTFSSecurity/issues/34)) ### Deprecated - Deprecate NTFSSecurity as a whole: the project will be archived soon. Move to [WindowsAccessControl](https://github.com/raandree/WindowsAccessControl), - which is also on the PowerShell Gallery + which is also on the PowerShell Gallery. The description of NTFSSecurity + in the PowerShell Gallery says so as well - Deprecate the `-PassThur` alias of `Remove-Item2`; use `-PassThru` - Deprecate the `MACTripleDES` value of `Get-FileHash2 -Algorithm`: it uses a random key, so its result differs on every call; the cmdlet now warns @@ -209,5 +217,21 @@ The format is based on `Set-NTFSInheritance -AuditInheritanceEnabled`, which failed with "Access is denied" for a file or folder without audit entries, also in an elevated session with the Security privilege +- Fix `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, + `Add-NTFSAudit`, `Clear-NTFSAudit`, `Enable-NTFSAccessInheritance`, + `Disable-NTFSAccessInheritance`, and `Set-NTFSInheritance`, which wrote the + owner of an item back with the entries they changed; where the account may + not assign that owner, such as on some file servers, they failed with + "(1307) This security ID may not be assigned as the owner of this object". + They now write only the DACL or the SACL + ([#34](https://github.com/raandree/NTFSSecurity/issues/34)) +- Fix `Add-NTFSAccess`, `Add-NTFSAudit`, and `Clear-NTFSAudit`, which in an + elevated session could store the inherited access entries of an item as + explicit entries, and `Get-NTFSSecurityDescriptor`, which could return + them without their inherited flag, so that `Set-NTFSSecurityDescriptor` + stored them as explicit entries as well +- Fix `Clear-NTFSAudit`, which finished without an error but changed nothing + in a session without the Security privilege; it now writes an error, like + the other audit cmdlets [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Add-NTFSAccess.md b/Docs/Cmdlets/Add-NTFSAccess.md index b39a443..8d79939 100644 --- a/Docs/Cmdlets/Add-NTFSAccess.md +++ b/Docs/Cmdlets/Add-NTFSAccess.md @@ -290,6 +290,8 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `Priva If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. +In the `Path` parameter sets, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. In an elevated session, it could also store the inherited entries of the item as explicit entries. + ## RELATED LINKS [Get-NTFSAccess](Get-NTFSAccess.md) diff --git a/Docs/Cmdlets/Add-NTFSAudit.md b/Docs/Cmdlets/Add-NTFSAudit.md index ad280e6..5973097 100644 --- a/Docs/Cmdlets/Add-NTFSAudit.md +++ b/Docs/Cmdlets/Add-NTFSAudit.md @@ -290,6 +290,8 @@ Writing the SACL requires the Security privilege (`SeSecurityPrivilege`, "Manage If the security descriptor cannot be read or written because access is denied, the cmdlet takes ownership of the item, repeats the operation, and restores the previous owner. If the second attempt fails as well, the cmdlet restores the previous owner and writes an error. Before 5.0.0, the account that ran the cmdlet stayed the owner of the item in that case. +In the `Path` parameter sets, the cmdlet reads and writes only the SACL of the item and leaves its owner, its group, and its DACL as they are. Before 5.0.0, it also wrote the owner and the DACL back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. In an elevated session, it could also store the inherited access entries of the item as explicit entries. + `-Path` or `-SecurityDescriptor`, `-Account`, and `-AccessRights` are positional parameters at positions 1, 2, and 3, like in `Remove-NTFSAudit`. Before 5.0.0, `-Account` and `-AccessRights` were both declared at position 2, so a command that passed them by position failed. An audit entry alone does not create events. Windows writes the events to the security log only while the "Audit object access" policy, or the corresponding "Audit File System" advanced audit policy, is enabled for success, failure, or both. That policy is a Windows setting and is not managed by this module. diff --git a/Docs/Cmdlets/Clear-NTFSAccess.md b/Docs/Cmdlets/Clear-NTFSAccess.md index 62ff672..863c82c 100644 --- a/Docs/Cmdlets/Clear-NTFSAccess.md +++ b/Docs/Cmdlets/Clear-NTFSAccess.md @@ -145,6 +145,8 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `Priva If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. +In the `Path` parameter set, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. + ## RELATED LINKS [Add-NTFSAccess](Add-NTFSAccess.md) diff --git a/Docs/Cmdlets/Clear-NTFSAudit.md b/Docs/Cmdlets/Clear-NTFSAudit.md index 7ec4adf..7af9672 100644 --- a/Docs/Cmdlets/Clear-NTFSAudit.md +++ b/Docs/Cmdlets/Clear-NTFSAudit.md @@ -140,7 +140,9 @@ This cmdlet writes nothing to the pipeline. Use `Get-NTFSAudit` to check which a When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. -Reading and writing the SACL requires the Security privilege (`SeSecurityPrivilege`, "Manage auditing and security log"), so run this cmdlet in an elevated session of an account that holds that privilege. Without that privilege the cmdlet reads the security descriptor without its SACL, finds no audit entries to remove, and finishes without an error although nothing was changed. `-DisableInheritance` fails in that situation with a `ClearAclError` whose message states that a required privilege is not held by the client. +Reading and writing the SACL requires the Security privilege (`SeSecurityPrivilege`, "Manage auditing and security log"), so run this cmdlet in an elevated session of an account that holds that privilege. Without it, the cmdlet writes a non-terminating `ClearAclError` whose message states that a required privilege is not held by the client, and the item is left unchanged. Before 5.0.0, the cmdlet read the security descriptor without its SACL in that situation, found no audit entries to remove, and finished without an error although nothing was changed. + +In the `Path` parameter set, the cmdlet reads and writes only the SACL of the item and leaves its owner, its group, and its DACL as they are; it writes nothing for an item without a SACL. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. If the security descriptor cannot be read or written because access is denied, the cmdlet takes ownership of the item, repeats the operation, and restores the previous owner. If the second attempt fails as well, the cmdlet restores the previous owner and writes an error. Before 5.0.0, the account that ran the cmdlet stayed the owner of the item in that case. diff --git a/Docs/Cmdlets/Get-NTFSSecurityDescriptor.md b/Docs/Cmdlets/Get-NTFSSecurityDescriptor.md index 9228d41..7310a63 100644 --- a/Docs/Cmdlets/Get-NTFSSecurityDescriptor.md +++ b/Docs/Cmdlets/Get-NTFSSecurityDescriptor.md @@ -21,9 +21,9 @@ Get-NTFSSecurityDescriptor [[-Path] ] [] The `Get-NTFSSecurityDescriptor` cmdlet reads the security descriptor of a file or folder into memory and returns it as a `Security2.FileSystemSecurity2` object. A security descriptor holds the owner of an item, its primary group, the discretionary access control list (DACL) that grants or denies access, and the system access control list (SACL) that controls auditing. -The returned object is the starting point of the security descriptor workflow. Many cmdlets of the module accept it through a `-SecurityDescriptor` parameter and then change the copy in memory instead of the file system, among them `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Add-NTFSAudit`, `Remove-NTFSAudit`, `Clear-NTFSAudit`, `Set-NTFSInheritance`, `Enable-NTFSAccessInheritance`, `Disable-NTFSAccessInheritance`, and `Set-NTFSOwner`. Nothing reaches the disk until you pass the descriptor to `Set-NTFSSecurityDescriptor`, which makes it possible to collect several changes and apply them in a single write. Discard the variable to discard the changes. +The returned object is the starting point of the security descriptor workflow. Many cmdlets of the module accept it through a `-SecurityDescriptor` parameter and then change the copy in memory instead of the file system, among them `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Add-NTFSAudit`, `Remove-NTFSAudit`, `Clear-NTFSAudit`, `Set-NTFSInheritance`, `Enable-NTFSAccessInheritance`, `Disable-NTFSAccessInheritance`, and `Set-NTFSOwner`. Nothing reaches the disk until you pass the descriptor to `Set-NTFSSecurityDescriptor`, which makes it possible to collect several changes and apply them in a single write; that cmdlet writes only the sections that changed. Discard the variable to discard the changes. -The cmdlet reads all sections of the descriptor. When that fails, for example because the session may not read the SACL, it falls back to the access, owner, and group sections, and then to the access section alone. `-Path` accepts pipeline input by value and by property name through its `FullName` alias, so the output of `Get-ChildItem2`, `Get-Item2`, and `Get-ChildItem` binds to it. Relative paths are resolved against the current location, and when you omit `-Path` entirely, the cmdlet returns the descriptor of the current location. +The cmdlet reads all sections of the descriptor. When that fails, for example because the session may not read the SACL, it falls back to the access, owner, and group sections, and then to the access section alone. When the cmdlet reads the SACL, it reads the DACL in a separate call, so that the inherited access entries keep their inherited flag. Before 5.0.0, it read the DACL together with the SACL, and Windows could return the inherited entries without that flag; a descriptor written back then stored them as explicit entries. `-Path` accepts pipeline input by value and by property name through its `FullName` alias, so the output of `Get-ChildItem2`, `Get-Item2`, and `Get-ChildItem` binds to it. Relative paths are resolved against the current location, and when you omit `-Path` entirely, the cmdlet returns the descriptor of the current location. Every path is processed on its own. When a path does not exist, the cmdlet writes a non-terminating error and continues with the next one. When reading the descriptor fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, reads the descriptor, and restores the previous owner; if that fails as well, it writes a non-terminating error. diff --git a/Docs/Cmdlets/Remove-NTFSAccess.md b/Docs/Cmdlets/Remove-NTFSAccess.md index 56090b6..c82dfab 100644 --- a/Docs/Cmdlets/Remove-NTFSAccess.md +++ b/Docs/Cmdlets/Remove-NTFSAccess.md @@ -304,6 +304,8 @@ If the ACL of an item cannot be written because access is denied, the cmdlet tri Removing rights from an entry that does not exist is not an error; the cmdlet leaves the ACL unchanged. +In the `Path` parameter sets, the cmdlet writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. + An entry with a generic right, such as `GenericAll`, can be removed, for example by piping it from `Get-NTFSAccess`. Windows keeps generic rights in the inherit-only entries of folders. Before 5.0.0, the cmdlet failed for such an entry with the error "The value '269484032' is not valid for this usage of the type FileSystemRights". Before 5.0.0, the `-RemoveSpecific` switch was missing, although version 4.1 had introduced it. diff --git a/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md b/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md index 524a566..14fa9f3 100644 --- a/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md +++ b/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md @@ -21,7 +21,7 @@ Set-NTFSSecurityDescriptor [-SecurityDescriptor] [-PassT The `Set-NTFSSecurityDescriptor` cmdlet writes a `Security2.FileSystemSecurity2` object to the file system. It is the final step of the security descriptor workflow: `Get-NTFSSecurityDescriptor` reads a descriptor into memory, cmdlets such as `Add-NTFSAccess`, `Remove-NTFSAccess`, `Set-NTFSOwner`, and `Disable-NTFSAccessInheritance` change that copy through their `-SecurityDescriptor` parameter, and this cmdlet applies all of those changes in a single write. -Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path, and writing a descriptor that you did not change simply re-applies its current content. +Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own. diff --git a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs index 8d14e60..fb6a3bc 100644 --- a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs +++ b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs @@ -39,7 +39,8 @@ namespace NTFSSecurity { try { - sd.Write(); + // Only the changed sections, so that an unchanged owner, for example, isn't written back (#34) + sd.WriteChanges(); if (passThru) { @@ -55,7 +56,7 @@ namespace NTFSSecurity FileSystemOwner.SetOwner(sd.Item, System.Security.Principal.WindowsIdentity.GetCurrent().User); - sd.Write(); + sd.WriteChanges(); FileSystemOwner.SetOwner(sd.Item, previousOwner); } diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 5cd0dd8..87bb3ff 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -720,6 +720,7 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. + In the `Path` parameter sets, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. In an elevated session, it could also store the inherited entries of the item as explicit entries. @@ -1502,6 +1503,7 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. Writing the SACL requires the Security privilege (`SeSecurityPrivilege`, "Manage auditing and security log"), so run this cmdlet in an elevated session of an account that holds that privilege. Without it, the cmdlet writes a non-terminating `AddAceError` whose message states that a required privilege is not held by the client, and the item is left unchanged. If the security descriptor cannot be read or written because access is denied, the cmdlet takes ownership of the item, repeats the operation, and restores the previous owner. If the second attempt fails as well, the cmdlet restores the previous owner and writes an error. Before 5.0.0, the account that ran the cmdlet stayed the owner of the item in that case. + In the `Path` parameter sets, the cmdlet reads and writes only the SACL of the item and leaves its owner, its group, and its DACL as they are. Before 5.0.0, it also wrote the owner and the DACL back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. In an elevated session, it could also store the inherited access entries of the item as explicit entries. `-Path` or `-SecurityDescriptor`, `-Account`, and `-AccessRights` are positional parameters at positions 1, 2, and 3, like in `Remove-NTFSAudit`. Before 5.0.0, `-Account` and `-AccessRights` were both declared at position 2, so a command that passed them by position failed. An audit entry alone does not create events. Windows writes the events to the security log only while the "Audit object access" policy, or the corresponding "Audit File System" advanced audit policy, is enabled for success, failure, or both. That policy is a Windows setting and is not managed by this module. @@ -1709,6 +1711,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. + In the `Path` parameter set, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. @@ -1912,7 +1915,8 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. - Reading and writing the SACL requires the Security privilege (`SeSecurityPrivilege`, "Manage auditing and security log"), so run this cmdlet in an elevated session of an account that holds that privilege. Without that privilege the cmdlet reads the security descriptor without its SACL, finds no audit entries to remove, and finishes without an error although nothing was changed. `-DisableInheritance` fails in that situation with a `ClearAclError` whose message states that a required privilege is not held by the client. + Reading and writing the SACL requires the Security privilege (`SeSecurityPrivilege`, "Manage auditing and security log"), so run this cmdlet in an elevated session of an account that holds that privilege. Without it, the cmdlet writes a non-terminating `ClearAclError` whose message states that a required privilege is not held by the client, and the item is left unchanged. Before 5.0.0, the cmdlet read the security descriptor without its SACL in that situation, found no audit entries to remove, and finished without an error although nothing was changed. + In the `Path` parameter set, the cmdlet reads and writes only the SACL of the item and leaves its owner, its group, and its DACL as they are; it writes nothing for an item without a SACL. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. If the security descriptor cannot be read or written because access is denied, the cmdlet takes ownership of the item, repeats the operation, and restores the previous owner. If the second attempt fails as well, the cmdlet restores the previous owner and writes an error. Before 5.0.0, the account that ran the cmdlet stayed the owner of the item in that case. @@ -6238,8 +6242,8 @@ PS C:\> Get-NTFSOwner -SecurityDescriptor $sd The `Get-NTFSSecurityDescriptor` cmdlet reads the security descriptor of a file or folder into memory and returns it as a `Security2.FileSystemSecurity2` object. A security descriptor holds the owner of an item, its primary group, the discretionary access control list (DACL) that grants or denies access, and the system access control list (SACL) that controls auditing. - The returned object is the starting point of the security descriptor workflow. Many cmdlets of the module accept it through a `-SecurityDescriptor` parameter and then change the copy in memory instead of the file system, among them `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Add-NTFSAudit`, `Remove-NTFSAudit`, `Clear-NTFSAudit`, `Set-NTFSInheritance`, `Enable-NTFSAccessInheritance`, `Disable-NTFSAccessInheritance`, and `Set-NTFSOwner`. Nothing reaches the disk until you pass the descriptor to `Set-NTFSSecurityDescriptor`, which makes it possible to collect several changes and apply them in a single write. Discard the variable to discard the changes. - The cmdlet reads all sections of the descriptor. When that fails, for example because the session may not read the SACL, it falls back to the access, owner, and group sections, and then to the access section alone. `-Path` accepts pipeline input by value and by property name through its `FullName` alias, so the output of `Get-ChildItem2`, `Get-Item2`, and `Get-ChildItem` binds to it. Relative paths are resolved against the current location, and when you omit `-Path` entirely, the cmdlet returns the descriptor of the current location. + The returned object is the starting point of the security descriptor workflow. Many cmdlets of the module accept it through a `-SecurityDescriptor` parameter and then change the copy in memory instead of the file system, among them `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Add-NTFSAudit`, `Remove-NTFSAudit`, `Clear-NTFSAudit`, `Set-NTFSInheritance`, `Enable-NTFSAccessInheritance`, `Disable-NTFSAccessInheritance`, and `Set-NTFSOwner`. Nothing reaches the disk until you pass the descriptor to `Set-NTFSSecurityDescriptor`, which makes it possible to collect several changes and apply them in a single write; that cmdlet writes only the sections that changed. Discard the variable to discard the changes. + The cmdlet reads all sections of the descriptor. When that fails, for example because the session may not read the SACL, it falls back to the access, owner, and group sections, and then to the access section alone. When the cmdlet reads the SACL, it reads the DACL in a separate call, so that the inherited access entries keep their inherited flag. Before 5.0.0, it read the DACL together with the SACL, and Windows could return the inherited entries without that flag; a descriptor written back then stored them as explicit entries. `-Path` accepts pipeline input by value and by property name through its `FullName` alias, so the output of `Get-ChildItem2`, `Get-Item2`, and `Get-ChildItem` binds to it. Relative paths are resolved against the current location, and when you omit `-Path` entirely, the cmdlet returns the descriptor of the current location. Every path is processed on its own. When a path does not exist, the cmdlet writes a non-terminating error and continues with the next one. When reading the descriptor fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, reads the descriptor, and restores the previous owner; if that fails as well, it writes a non-terminating error. @@ -8416,6 +8420,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. Removing rights from an entry that does not exist is not an error; the cmdlet leaves the ACL unchanged. + In the `Path` parameter sets, the cmdlet writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. An entry with a generic right, such as `GenericAll`, can be removed, for example by piping it from `Get-NTFSAccess`. Windows keeps generic rights in the inherit-only entries of folders. Before 5.0.0, the cmdlet failed for such an entry with the error "The value '269484032' is not valid for this usage of the type FileSystemRights". Before 5.0.0, the `-RemoveSpecific` switch was missing, although version 4.1 had introduced it. A path that does not exist produces the non-terminating error `ReadFileError`, and the cmdlet continues with the next path. Before 5.0.0, the cmdlet also wrote a misleading `RemoveAceError` for that path, and with `-PassThru` it stopped with a `NullReferenceException`. @@ -9870,7 +9875,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd The `Set-NTFSSecurityDescriptor` cmdlet writes a `Security2.FileSystemSecurity2` object to the file system. It is the final step of the security descriptor workflow: `Get-NTFSSecurityDescriptor` reads a descriptor into memory, cmdlets such as `Add-NTFSAccess`, `Remove-NTFSAccess`, `Set-NTFSOwner`, and `Disable-NTFSAccessInheritance` change that copy through their `-SecurityDescriptor` parameter, and this cmdlet applies all of those changes in a single write. - Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path, and writing a descriptor that you did not change simply re-applies its current content. + Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own. When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner. If that fails as well, it writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege. diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.AddFileSystemAccessRules.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.AddFileSystemAccessRules.cs index 7a39392..cd8f067 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.AddFileSystemAccessRules.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.AddFileSystemAccessRules.cs @@ -32,7 +32,8 @@ namespace Security2 if (type == AccessControlType.Allow) rights = rights | FileSystemRights2.Synchronize; - var sd = new FileSystemSecurity2(item); + // Only the DACL, so that the owner isn't written back (#34) and the inherited entries keep their flag + var sd = new FileSystemSecurity2(item, AccessControlSections.Access); var ace = AddFileSystemAccessRule(sd, account, rights, type, inheritanceFlags, propagationFlags); diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRules.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRules.cs index 71d171e..6264399 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRules.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRules.cs @@ -62,7 +62,9 @@ namespace Security2 ace = (FileSystemAccessRule)sd.AccessRuleFactory(account, (int)rights, false, inheritanceFlags, propagationFlags, type); RemoveRule(sd, ace, removeSpecific); - file.SetAccessControl(sd); + // Only the DACL: Windows can return the owner with it, and writing that back fails for an owner that the + // user cannot assign (#34). + file.SetAccessControl(sd, AccessControlSections.Access); } else { @@ -73,7 +75,7 @@ namespace Security2 ace = (FileSystemAccessRule)sd.AccessRuleFactory(account, (int)rights, false, inheritanceFlags, propagationFlags, type); RemoveRule(sd, ace, removeSpecific); - directory.SetAccessControl(sd); + directory.SetAccessControl(sd, AccessControlSections.Access); } } @@ -122,7 +124,7 @@ namespace Security2 RemoveRule(sd, ace, removeSpecific); - file.SetAccessControl(sd); + file.SetAccessControl(sd, AccessControlSections.Access); } else { @@ -132,7 +134,7 @@ namespace Security2 RemoveRule(sd, ace, removeSpecific); - directory.SetAccessControl(sd); + directory.SetAccessControl(sd, AccessControlSections.Access); } } diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRulesAll.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRulesAll.cs index 0cb8cd3..bcc1c27 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRulesAll.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRulesAll.cs @@ -25,7 +25,8 @@ namespace Security2 public static void RemoveFileSystemAccessRuleAll(FileSystemInfo item, List accounts = null) { - var sd = new FileSystemSecurity2(item); + // Only the DACL, so that the owner isn't written back (#34) and the inherited entries keep their flag + var sd = new FileSystemSecurity2(item, AccessControlSections.Access); RemoveFileSystemAccessRuleAll(sd, accounts); diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.AddFileSystemAuditRules.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.AddFileSystemAuditRules.cs index 0bb350a..59e1b9b 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.AddFileSystemAuditRules.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.AddFileSystemAuditRules.cs @@ -26,7 +26,8 @@ namespace Security2 public static FileSystemAuditRule2 AddFileSystemAuditRule(FileSystemInfo item, IdentityReference2 account, FileSystemRights2 rights, AuditFlags type, InheritanceFlags inheritanceFlags, PropagationFlags propagationFlags) { - var sd = new FileSystemSecurity2(item); + // Only the SACL, so that neither the owner (#34) nor the DACL is written back + var sd = new FileSystemSecurity2(item, AccessControlSections.Audit); var ace = AddFileSystemAuditRule(sd, account, rights, type, inheritanceFlags, propagationFlags); diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRule.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRule.cs index 4896624..b833a9e 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRule.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRule.cs @@ -22,7 +22,8 @@ namespace Security2 else sd.RemoveAuditRule(ace); - file.SetAccessControl(sd); + // Only the SACL, so that no other section that Windows returns with it is written back (#34) + file.SetAccessControl(sd, AccessControlSections.Audit); } else { @@ -36,7 +37,7 @@ namespace Security2 else sd.RemoveAuditRule(ace); - directory.SetAccessControl(sd); + directory.SetAccessControl(sd, AccessControlSections.Audit); } } @@ -57,7 +58,7 @@ namespace Security2 sd.RemoveAuditRuleSpecific(ace); - file.SetAccessControl(sd); + file.SetAccessControl(sd, AccessControlSections.Audit); } else { @@ -67,7 +68,7 @@ namespace Security2 sd.RemoveAuditRuleSpecific(ace); - directory.SetAccessControl(sd); + directory.SetAccessControl(sd, AccessControlSections.Audit); } } diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRuleAll.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRuleAll.cs index 97edeea..ee721f0 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRuleAll.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRuleAll.cs @@ -25,11 +25,17 @@ namespace Security2 public static void RemoveFileSystemAuditRuleAll(FileSystemInfo item, List accounts = null) { - var sd = new FileSystemSecurity2(item); + // Only the SACL, so that neither the owner (#34) nor the DACL is written back + var sd = new FileSystemSecurity2(item, AccessControlSections.Audit); RemoveFileSystemAuditRuleAll(sd, accounts); - sd.Write(); + // An item without audit entries can have no SACL at all. Then there is nothing to remove, and Windows denies + // a write without any section: (5) Access is denied. + if (sd.HasSystemAcl) + { + sd.Write(); + } } } } diff --git a/Security2/FileSystem/FileSystemSecurity2.cs b/Security2/FileSystem/FileSystemSecurity2.cs index fda0c72..5d8f150 100644 --- a/Security2/FileSystem/FileSystemSecurity2.cs +++ b/Security2/FileSystem/FileSystemSecurity2.cs @@ -1,5 +1,6 @@ using Alphaleonis.Win32.Filesystem; using System; +using System.Collections.Generic; using System.Security.AccessControl; namespace Security2 @@ -13,6 +14,10 @@ namespace Security2 protected AccessControlSections sections; protected bool isFile = false; + // The SDDL form of each section as it was read or last written, so that WriteChanges writes only the + // sections that changed since. + private Dictionary sectionsAsRead; + public FileSystemInfo Item { get { return item; } @@ -43,6 +48,8 @@ namespace Security2 sd = ((DirectoryInfo)this.item).GetAccessControl(sections); } + + RememberSections(); } public FileSystemSecurity2(FileSystemInfo item) @@ -93,6 +100,19 @@ namespace Security2 } } } + + // Read together with the SACL, the inherited entries of a DACL without the auto-inherit flag lose their + // inherited flag when the parent folder has no SACL, and writing such a DACL back stores them as explicit + // entries. Read alone, the DACL keeps the flags. + if (HasAuditSection) + { + var accessSecurity = isFile + ? (FileSystemSecurity)((FileInfo)this.item).GetAccessControl(AccessControlSections.Access) + : ((DirectoryInfo)this.item).GetAccessControl(AccessControlSections.Access); + sd.SetSecurityDescriptorBinaryForm(accessSecurity.GetSecurityDescriptorBinaryForm(), AccessControlSections.Access); + } + + RememberSections(); } // Without the Security privilege, the security descriptor is read without its SACL. @@ -101,6 +121,39 @@ namespace Security2 get { return (sections & AccessControlSections.Audit) == AccessControlSections.Audit; } } + // An item without audit entries can have no SACL at all, also when the SACL was read. + internal bool HasSystemAcl + { + get { return new RawSecurityDescriptor(sd.GetSecurityDescriptorBinaryForm(), 0).SystemAcl != null; } + } + + // The sections that differ from the ones that were read or last written. + internal AccessControlSections ChangedSections + { + get + { + var changed = AccessControlSections.None; + foreach (var section in sectionsAsRead) + { + if (sd.GetSecurityDescriptorSddlForm(section.Key) != section.Value) + { + changed |= section.Key; + } + } + + return changed; + } + } + + private void RememberSections() + { + sectionsAsRead = new Dictionary(); + foreach (var section in new[] { AccessControlSections.Access, AccessControlSections.Audit, AccessControlSections.Owner, AccessControlSections.Group }) + { + sectionsAsRead[section] = sd.GetSecurityDescriptorSddlForm(section); + } + } + public FileSystemSecurity SecurityDescriptor { get @@ -109,16 +162,42 @@ namespace Security2 } } + // Writes the sections that the descriptor was read with. Windows can return the owner and the group with a DACL + // that is read alone, and writing them back fails for an owner that the user cannot assign (#34). public void Write() { if (isFile) { - ((FileInfo)item).SetAccessControl((FileSecurity)sd); + ((FileInfo)item).SetAccessControl((FileSecurity)sd, sections); } else { - ((DirectoryInfo)item).SetAccessControl((DirectorySecurity)sd); + ((DirectoryInfo)item).SetAccessControl((DirectorySecurity)sd, sections); + } + + RememberSections(); + } + + // Writes only the sections that changed since they were read or last written, so that, for example, an + // unchanged owner that the user cannot assign isn't written back (#34). Without a change, it writes nothing. + internal void WriteChanges() + { + var changedSections = ChangedSections; + if (changedSections == AccessControlSections.None) + { + return; + } + + if (isFile) + { + ((FileInfo)item).SetAccessControl((FileSecurity)sd, changedSections); } + else + { + ((DirectoryInfo)item).SetAccessControl((DirectorySecurity)sd, changedSections); + } + + RememberSections(); } public void Write(FileSystemInfo item) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index d89bd74..b0f6a43 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -10,6 +10,8 @@ BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force # With the Restore privilege, Windows may grant writing the DACL despite a deny entry. $canBypassWriteDeny = Test-PrivilegeHeld -Name 'SeRestorePrivilege' + # Assigning an owner other than the user or one of its groups needs the Restore privilege. + $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' } @@ -19,9 +21,20 @@ BeforeAll { Import-Module -Name $modulePath -Force -ErrorAction Stop $sandbox = New-TestSandbox -Name 'Access' Push-Location -LiteralPath $sandbox + + $privateData = (Get-Module -Name NTFSSecurity).PrivateData + $enablePrivileges = $privateData['EnablePrivileges'] + $sidType = [System.Security.Principal.SecurityIdentifier] + # An owner that the user can assign only with the Restore privilege + $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + + function Get-RestorePrivilegeState { + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState + } } AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges Pop-Location Remove-TestSandbox -Sandbox $sandbox Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue @@ -208,6 +221,30 @@ Describe 'Remove-NTFSAccess' { } } + Context 'When the item has an owner that the user cannot assign' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Before 5.0.0-rc3, the cmdlet read only the DACL, but wrote the owner that Windows returns with a DACL without + # the auto-inherit flag, which Windows refuses without the Restore privilege (#34). Any write of the DACL adds + # the flag, so the item keeps its DACL as created and has no explicit entry to remove. + It 'Should write no error and keep the owner' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveOtherOwner' + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + + Remove-NTFSAccess -Path $file -Account 'Everyone' -AccessRights ReadData -ErrorVariable removeErrors -ErrorAction SilentlyContinue + + $removeErrors | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $trustedInstaller + } + } + Context 'With a generic right' { # Before 5.0.0, removing an entry with a generic right such as GENERIC_ALL failed with "The value '269484032' is # not valid", because .NET rebuilds the rule and rejects generic rights (#17). Windows keeps generic rights in @@ -324,6 +361,52 @@ Describe 'Add-NTFSAccess' { $result | Should -BeNullOrEmpty } } + + Context 'When the item has an owner that the user cannot assign' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Before 5.0.0-rc3, the cmdlet wrote the unchanged owner back. Without the Restore privilege, Windows refuses + # an owner that the user cannot assign, like a file server that refuses the owner (#34): (1307) This security + # ID may not be assigned as the owner of this object. + It 'Should add the entry and keep the owner' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OtherOwner' + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + + Add-NTFSAccess -Path $file -Account 'Everyone' -AccessRights ReadData -ErrorVariable addErrors -ErrorAction SilentlyContinue + + $addErrors | Should -BeNullOrEmpty + $acl = Get-Acl -LiteralPath $file + $acl.GetOwner($sidType).Value | Should -Be $trustedInstaller + @($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | + Should -HaveCount 1 + } + } + + Context 'With inherited entries' { + # Before 5.0.0-rc3, the cmdlet read the DACL together with the SACL when the process held the Security + # privilege. When the folder has no SACL, Windows then returns the inherited entries of a DACL without the + # auto-inherit flag, such as that of a file in the temp folder of the user, without their inherited flag, and + # the cmdlet wrote them back as explicit copies. + It 'Should add one explicit entry and keep the inherited entries inherited' -Skip:(-not $holdsSecurityPrivilege) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Inherited' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + $inheritedCount | Should -BeGreaterThan 0 + + Add-NTFSAccess -Path $file -Account 'Everyone' -AccessRights ReadData + + $acl = Get-Acl -LiteralPath $file + @($acl.GetAccessRules($true, $false, $sidType)) | Should -HaveCount 1 + @($acl.GetAccessRules($false, $true, $sidType)) | Should -HaveCount $inheritedCount + } + } } Describe 'Security descriptor parameter sets' { @@ -377,4 +460,31 @@ Describe 'Clear-NTFSAccess' { $acl.Access | Should -BeNullOrEmpty } } + + Context 'When the item has an owner that the user cannot assign' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Before 5.0.0-rc3, the cmdlet wrote the unchanged owner back, which Windows refuses without the Restore + # privilege (#34). For a DACL without the auto-inherit flag, such as that of a new file in the temp folder of + # the user, Windows returns the owner even when only the DACL is read. Any write of the DACL adds the flag, so + # the item keeps its DACL as created. + It 'Should write no error and keep the owner' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearOtherOwner' + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + + Clear-NTFSAccess -Path $file -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -BeNullOrEmpty + $acl = Get-Acl -LiteralPath $file + $acl.GetOwner($sidType).Value | Should -Be $trustedInstaller + @($acl.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty + } + } } diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index 7345e13..b010a90 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -11,6 +11,8 @@ param () BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $canReadAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + # Assigning an owner other than the user or one of its groups needs the Restore privilege. + $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' } BeforeAll { @@ -19,9 +21,20 @@ BeforeAll { Import-Module -Name $modulePath -Force -ErrorAction Stop $sandbox = New-TestSandbox -Name 'Audit' Push-Location -LiteralPath $sandbox + + $privateData = (Get-Module -Name NTFSSecurity).PrivateData + $enablePrivileges = $privateData['EnablePrivileges'] + $sidType = [System.Security.Principal.SecurityIdentifier] + # An owner that the user can assign only with the Restore privilege + $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + + function Get-RestorePrivilegeState { + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState + } } AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges Pop-Location Remove-TestSandbox -Sandbox $sandbox Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue @@ -121,6 +134,48 @@ Describe 'Add-NTFSAudit' { $result | ForEach-Object -Process { $_.InheritanceEnabled | Should -BeFalse } } } + + Context 'When the item has an owner that the user cannot assign' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Before 5.0.0-rc3, the cmdlet wrote the unchanged owner back, which Windows refuses without the Restore + # privilege (#34). + It 'Should add the audit entry and keep the owner' -Skip:(-not ($canReadAudit -and $canAssignAnyOwner)) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OtherOwner' + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + + Add-NTFSAudit -Path $file -Account 'Everyone' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorVariable addErrors -ErrorAction SilentlyContinue + + $addErrors | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $trustedInstaller + @(Get-NTFSAudit -Path $file -ExcludeInherited) | Should -HaveCount 1 + } + } + + Context 'With inherited access entries' { + # Before 5.0.0-rc3, the cmdlet also read and wrote the DACL. Read together with the SACL, the inherited entries + # of a DACL without the auto-inherit flag lose their inherited flag when the folder has no SACL, and the cmdlet + # wrote them back as explicit copies. + It 'Should leave the access entries unchanged' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Inherited' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + $inheritedCount | Should -BeGreaterThan 0 + + Add-NTFSAudit -Path $file -Account 'Everyone' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + + $acl = Get-Acl -LiteralPath $file + @($acl.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty + @($acl.GetAccessRules($false, $true, $sidType)) | Should -HaveCount $inheritedCount + } + } } Describe 'Get-NTFSOrphanedAudit' { @@ -206,3 +261,69 @@ Describe 'Remove-NTFSAudit' { } } } + +Describe 'Clear-NTFSAudit' { + Context 'When the item has an owner that the user cannot assign' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Before 5.0.0-rc3, the cmdlet wrote the unchanged owner back, which Windows refuses without the Restore + # privilege (#34). + It 'Should remove the audit entries and keep the owner' -Skip:(-not ($canReadAudit -and $canAssignAnyOwner)) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearOtherOwner' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Add-NTFSAudit -Path $file -Account 'Everyone' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + + Clear-NTFSAudit -Path $file -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $trustedInstaller + @(Get-NTFSAudit -Path $file -ExcludeInherited) | Should -BeNullOrEmpty + } + } + + Context 'When the item has no SACL' { + # An item without audit entries can have no SACL at all, and Windows denies a write without any section. + # Before 5.0.0-rc3, the cmdlet also read and wrote the DACL, and in an elevated session it wrote the inherited + # access entries back as explicit copies. + It 'Should write no error and leave the access entries unchanged' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'NoSacl' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $audit = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList ( + (Get-Item2 -Path $file), [System.Security.AccessControl.AccessControlSections]::Audit + ) + $audit.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | Should -BeNullOrEmpty + $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + + Clear-NTFSAudit -Path $file -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -BeNullOrEmpty + $acl = Get-Acl -LiteralPath $file + @($acl.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty + @($acl.GetAccessRules($false, $true, $sidType)) | Should -HaveCount $inheritedCount + } + } + + Context 'Without the Security privilege' { + # Before 5.0.0-rc3, the cmdlet read the security descriptor without its SACL, found no audit entries to remove, + # and finished without an error although nothing was changed; it also wrote the DACL back. + It 'Should write an error and leave the item unchanged' -Skip:$canReadAudit { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'NoPrivilege' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $sddl = (Get-Acl -LiteralPath $file).Sddl + + Clear-NTFSAudit -Path $file -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -HaveCount 1 + $clearErrors[0].FullyQualifiedErrorId | Should -BeLike 'ClearAclError,*' + (Get-Acl -LiteralPath $file).Sddl | Should -BeExactly $sddl + } + } +} diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1 index ce489b7..102df95 100644 --- a/Tests/Inheritance.Tests.ps1 +++ b/Tests/Inheritance.Tests.ps1 @@ -10,6 +10,8 @@ param () BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $canChangeAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + # Assigning an owner other than the user or one of its groups needs the Restore privilege. + $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' } BeforeAll { @@ -18,9 +20,19 @@ BeforeAll { Import-Module -Name $modulePath -Force -ErrorAction Stop $sandbox = New-TestSandbox -Name 'Inheritance' Push-Location -LiteralPath $sandbox + + $privateData = (Get-Module -Name NTFSSecurity).PrivateData + $enablePrivileges = $privateData['EnablePrivileges'] + # An owner that the user can assign only with the Restore privilege + $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + + function Get-RestorePrivilegeState { + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState + } } AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges Pop-Location Remove-TestSandbox -Sandbox $sandbox Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue @@ -124,9 +136,7 @@ Describe 'Set-NTFSInheritance' { } # In memory, the kept entries stay marked as inherited; Windows stores them as explicit ones on write. - # The descriptor holds only the access entries. Windows marks the inherited entries of a DACL that isn't in - # the auto-inherit format, such as that of a file in the temp folder of the user, only when the SACL isn't - # read with it, and Get-NTFSSecurityDescriptor reads the SACL with the Security privilege. + # The descriptor holds only the access entries. It 'Should keep the inherited access entries of a security descriptor' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'KeepDescriptor' Assert-TestSandboxPath -Sandbox $sandbox -Path $file @@ -263,3 +273,32 @@ Describe 'Audit inheritance switches' { { & $Command @parameters -ErrorAction SilentlyContinue } | Should -Not -Throw } } + +Describe 'Access inheritance cmdlets' { + Context 'When the item has an owner that the user cannot assign' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Before 5.0.0-rc3, the cmdlets read only the DACL, but wrote the owner that Windows returns with a DACL without + # the auto-inherit flag, such as that of a new file in the temp folder of the user. Windows refuses that owner + # without the Restore privilege (#34). + It '<_> should write no error and keep the owner' -Skip:(-not $canAssignAnyOwner) -ForEach @( + 'Disable-NTFSAccessInheritance', 'Enable-NTFSAccessInheritance' + ) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OtherOwner' + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + + & $_ -Path $file -ErrorVariable inheritanceErrors -ErrorAction SilentlyContinue + + $inheritanceErrors | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $file).GetOwner([System.Security.Principal.SecurityIdentifier]).Value | + Should -Be $trustedInstaller + } + } +} diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 new file mode 100644 index 0000000..1426344 --- /dev/null +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -0,0 +1,160 @@ +<# + Tests Get-NTFSSecurityDescriptor and Set-NTFSSecurityDescriptor of the module built in NTFSSecurity\bin\Release on + files in a sandbox folder. Tests that need the Security or the Restore privilege skip without it; CI runs them + elevated. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + # Assigning an owner other than the user or one of its groups needs the Restore privilege. + $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' +} + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' + Import-Module -Name $modulePath -Force -ErrorAction Stop + $sandbox = New-TestSandbox -Name 'SecurityDescriptor' + Push-Location -LiteralPath $sandbox + + $privateData = (Get-Module -Name NTFSSecurity).PrivateData + $enablePrivileges = $privateData['EnablePrivileges'] + $sidType = [System.Security.Principal.SecurityIdentifier] + # An owner that the user can assign only with the Restore privilege + $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + + function Get-RestorePrivilegeState { + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState + } + + function Get-EveryoneRule ([string] $Path) { + (Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' } + } +} + +AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + Pop-Location + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-NTFSSecurityDescriptor' { + # Before 5.0.0-rc3, the cmdlet read the DACL together with the SACL when the process held the Security privilege. + # When the folder has no SACL, Windows then returns the inherited entries of a DACL without the auto-inherit flag, + # such as that of a file in the temp folder of the user, without their inherited flag. + It 'Should report the inherited access entries as inherited' -Skip:(-not $holdsSecurityPrivilege) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Inherited' + $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + $inheritedCount | Should -BeGreaterThan 0 + + $sd = Get-NTFSSecurityDescriptor -Path $file + + @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty + @($sd.SecurityDescriptor.GetAccessRules($false, $true, $sidType)) | Should -HaveCount $inheritedCount + } + + It 'Should read the owner and the audit entries with the access entries' -Skip:(-not $holdsSecurityPrivilege) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Sections' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Add-NTFSAudit -Path $file -Account 'Everyone' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + + $sd = Get-NTFSSecurityDescriptor -Path $file + + $sd.SecurityDescriptor.GetOwner($sidType).Value | Should -Be (Get-Acl -LiteralPath $file).GetOwner($sidType).Value + @($sd.SecurityDescriptor.GetAuditRules($true, $false, $sidType)) | Should -HaveCount 1 + } +} + +Describe 'Set-NTFSSecurityDescriptor' { + Context 'When a cmdlet added an access entry to the descriptor' { + It 'Should write the entry as the only explicit entry of the item' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddedEntry' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd + + @(Get-EveryoneRule -Path $file) | Should -HaveCount 1 + @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType)) | Should -HaveCount 1 + } + } + + Context 'When the item has an owner that the user cannot assign' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Before 5.0.0-rc3, the cmdlet wrote every section that Get-NTFSSecurityDescriptor read, also the unchanged + # owner, which Windows refuses without the Restore privilege, like a file server that refuses the owner (#34). + It 'Should write an added access entry and keep the owner' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OtherOwner' + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorVariable setErrors -ErrorAction SilentlyContinue + + $setErrors | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $trustedInstaller + @(Get-EveryoneRule -Path $file) | Should -HaveCount 1 + } + + It 'Should write an added audit entry and keep the owner' -Skip:(-not ($holdsSecurityPrivilege -and $canAssignAnyOwner)) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OtherOwnerAudit' + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + Get-RestorePrivilegeState | Should -Be 'Disabled' + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAudit -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorVariable setErrors -ErrorAction SilentlyContinue + + $setErrors | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $trustedInstaller + @(Get-NTFSAudit -Path $file -ExcludeInherited) | Should -HaveCount 1 + } + } + + Context 'When the descriptor has sections that it did not change' { + # Before 5.0.0-rc3, the cmdlet wrote every section that Get-NTFSSecurityDescriptor read, so it also undid the + # changes that were made to the item after it was read. + It 'Should not write back the access entries of an unchanged descriptor' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Unchanged' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + $acl = Get-Acl -LiteralPath $file + $acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( + (New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0'), + [System.Security.AccessControl.FileSystemRights]::ReadData, [System.Security.AccessControl.AccessControlType]::Allow + ))) + Set-Acl -LiteralPath $file -AclObject $acl + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd + + @(Get-EveryoneRule -Path $file) | Should -HaveCount 1 + } + + It 'Should write the owner when only the owner changed' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'NewOwner' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + $sd.SecurityDescriptor.SetOwner((New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $trustedInstaller)) + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd + + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $trustedInstaller + } + } +} diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 index c743ed4..fdf7598 100644 --- a/Tests/TestHelpers.Tests.ps1 +++ b/Tests/TestHelpers.Tests.ps1 @@ -7,6 +7,12 @@ )] param () +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + # Assigning an owner other than the user or one of its groups needs the Restore privilege. + $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' +} + BeforeAll { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force } @@ -130,6 +136,31 @@ Describe 'Test helpers' { } } + Context 'Set-TestOwner' { + BeforeAll { + $sandbox = New-TestSandbox -Name 'Helpers' + $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + } + + AfterAll { + Remove-TestSandbox -Sandbox $sandbox + } + + It 'Should make the account the owner of an item in the sandbox' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Owner' + + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + + (Get-Acl -LiteralPath $file).GetOwner([System.Security.Principal.SecurityIdentifier]).Value | + Should -Be $trustedInstaller + } + + It 'Should refuse an item outside the sandbox' { + { Set-TestOwner -Sandbox $sandbox -Path "$sandbox-Other\File.txt" -Sid $trustedInstaller } | + Should -Throw -ExpectedMessage 'Refusing to change*' + } + } + Context 'Test-IsElevated and Test-PrivilegeHeld' { It 'Should tell whether the process is elevated' { Test-IsElevated | Should -BeOfType [bool] diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index b47faaf..21e50c5 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -262,6 +262,46 @@ function Add-TestDenyRule { Set-Acl -LiteralPath $Path -AclObject $acl } +function Set-TestOwner { + <# + .SYNOPSIS + Makes an account the owner of an item in the sandbox, also an account that only the Restore privilege lets + the user assign. + .DESCRIPTION + Runs icacls, which enables the Restore privilege in its own process, so that the privileges of the test + process stay as they are. Remove-TestSandbox deletes the item through the rights on its folder. + .PARAMETER Sid + The SID of the new owner, such as that of NT SERVICE\TrustedInstaller. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only writes to sandboxes.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Sandbox, + + [Parameter(Mandatory)] + [string] + $Path, + + [Parameter(Mandatory)] + [ValidatePattern('^S-1-\d+(-\d+)+$')] + [string] + $Sid + ) + + Assert-TestSandboxPath -Sandbox $Sandbox -Path $Path + # icacls resolves a relative path against the working folder of the process, not the location of PowerShell. + $location = (Get-Location -PSProvider FileSystem).ProviderPath + $fullName = [IO.Path]::GetFullPath([IO.Path]::Combine($location, $Path)) + $output = & icacls.exe $fullName /setowner "*$Sid" /Q 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "icacls could not make '$Sid' the owner of '$fullName' (exit code $LASTEXITCODE): $output" + } +} + function Test-IsElevated { <# .SYNOPSIS @@ -303,4 +343,4 @@ function Test-PrivilegeHeld { } Export-ModuleMember -Function New-TestSandbox, Assert-TestSandboxPath, Remove-TestSandbox, New-TestSandboxItem, - Block-TestReadPermission, Block-TestWritePermission, Test-IsElevated, Test-PrivilegeHeld + Block-TestReadPermission, Block-TestWritePermission, Set-TestOwner, Test-IsElevated, Test-PrivilegeHeld From 63b23ce365cd663a7e64701175ba67ddccc69aef Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 11:59:23 +0000 Subject: [PATCH 2/4] docs: explain why a share root loses its inherited permissions over UNC Written through its UNC path, the DACL of a share root can't re-inherit from the parent folder on the server: Windows drops the inherited entries of a DACL in the auto-inherit format and stores the others as explicit copies. icacls and Set-Acl behave the same; a subfolder through the share and the local path keep them (#67). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Docs/FAQ.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Docs/FAQ.md b/Docs/FAQ.md index 8f31dc8..498f7d4 100644 --- a/Docs/FAQ.md +++ b/Docs/FAQ.md @@ -29,6 +29,17 @@ which Explorer adds for a path on a file share, and the account must be resolvable on the computer that runs the cmdlet. See [Get-NTFSEffectiveAccess](Cmdlets/Get-NTFSEffectiveAccess.md). +## The root of a share loses its inherited permissions over UNC + +When you change the permissions of the root folder of a share through its +UNC path, such as `\\server\share`, Windows can't reach the parent folder on +the server to inherit from. The root folder then loses its inherited +entries, or keeps them as explicit entries that no longer follow the parent +folder. `icacls` and `Set-Acl` behave the same way. Change the root folder +through its local path on the server, such as `D:\Shares\Data`, and use the +UNC path for the folders below the root. See +[#67](https://github.com/raandree/NTFSSecurity/issues/67). + ## Get-ChildItem2 -Recurse runs in a loop through junctions A junction can point to a folder above it. Use `-SkipMountPoints` and From ec4615be5e65fb2058574234d98da179fe7533f2 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 12:02:32 +0000 Subject: [PATCH 3/4] chore(release): prepare 5.0.0-rc3 - Set the prerelease label rc3, and add 5.0.0-rc2 to the versions that the PowerShell Gallery already has. - Extend the description of the manifest, which the PowerShell Gallery shows, with the archive note (maintainer decision of 2026-10-06, Decision 18). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- NTFSSecurity/NTFSSecurity.psd1 | 4 ++-- Tests/Repository.Tests.ps1 | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/NTFSSecurity/NTFSSecurity.psd1 b/NTFSSecurity/NTFSSecurity.psd1 index f32223b..85a7892 100644 --- a/NTFSSecurity/NTFSSecurity.psd1 +++ b/NTFSSecurity/NTFSSecurity.psd1 @@ -11,7 +11,7 @@ Copyright = '2018' - Description = 'PowerShell module for managing file and folder security on NTFS volumes' + Description = 'PowerShell module for managing file and folder security on NTFS volumes. NTFSSecurity will be archived; its successor is WindowsAccessControl.' PowerShellVersion = '5.1' @@ -102,7 +102,7 @@ ProjectUri = 'https://github.com/raandree/NTFSSecurity' ReleaseNotes = 'https://github.com/raandree/NTFSSecurity/blob/master/CHANGELOG.md' # Remove the prerelease label for the final release, see Docs/Contributing/05-Releasing.md - Prerelease = 'rc2' + Prerelease = 'rc3' } } } \ No newline at end of file diff --git a/Tests/Repository.Tests.ps1 b/Tests/Repository.Tests.ps1 index bf5c3b1..f8ed023 100644 --- a/Tests/Repository.Tests.ps1 +++ b/Tests/Repository.Tests.ps1 @@ -87,15 +87,17 @@ Describe 'Release metadata' { } } - # Before 5.0.0-rc2, the description said "Windows PowerShell Module", although the module supports PowerShell 7. + # Before 5.0.0-rc2, the description said "Windows PowerShell Module", although the module supports PowerShell 7. Since + # 5.0.0-rc3, it announces that the project will be archived, for the users who see only the PowerShell Gallery. It 'Should have the description that the PowerShell Gallery shows for the module' { - $manifest.Description | Should -BeExactly 'PowerShell module for managing file and folder security on NTFS volumes' + $manifest.Description | Should -BeExactly ('PowerShell module for managing file and folder security on NTFS volumes. ' + + 'NTFSSecurity will be archived; its successor is WindowsAccessControl.') } # The PowerShell Gallery doesn't accept a version twice. Add every published version to this list # (Docs/Contributing/05-Releasing.md). It 'Should not reuse a version that the PowerShell Gallery already has' { - $publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1' + $publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2' $publishedVersions | Should -Not -Contain $version } From 09476833720e338ad384dcfd3c2a0478cbfc32aa Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 12:36:44 +0000 Subject: [PATCH 4/4] fix: address the review of 5.0.0-rc3 - Set-NTFSSecurityDescriptor -Verbose names the sections that it writes, or says that it writes nothing for an unchanged descriptor; its page says "since it was read or last written" (review F-02). - The pages of Enable-NTFSAccessInheritance, Disable-NTFSAccessInheritance, and Set-NTFSInheritance get the #34 note, like the other fixed cmdlets (review F-06). - Set-TestOwner throws its own error when icacls fails, also when the caller uses -ErrorAction Stop in Windows PowerShell (review F-07). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 7 +++--- Docs/Cmdlets/Disable-NTFSAccessInheritance.md | 2 ++ Docs/Cmdlets/Enable-NTFSAccessInheritance.md | 2 ++ Docs/Cmdlets/Set-NTFSInheritance.md | 2 ++ Docs/Cmdlets/Set-NTFSSecurityDescriptor.md | 2 +- .../SetSecurityDescriptor.cs | 11 +++++++++ NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 5 +++- Tests/SecurityDescriptor.Tests.ps1 | 23 +++++++++++++++++++ Tests/TestHelpers.Tests.ps1 | 9 ++++++++ Tests/TestHelpers.psm1 | 3 +++ 10 files changed, 61 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0d1e7..dc58999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,9 +68,10 @@ The format is based on - Write only the sections of a security descriptor that changed since it was read in `Set-NTFSSecurityDescriptor`, such as the DACL after `Add-NTFSAccess -SecurityDescriptor`; a descriptor without changes writes - nothing. The cmdlet wrote every section that `Get-NTFSSecurityDescriptor` - had read, also an unchanged owner, which failed with error 1307 where the - account may not assign that owner + nothing, and `-Verbose` names the sections that the cmdlet writes. The + cmdlet wrote every section that `Get-NTFSSecurityDescriptor` had read, + also an unchanged owner, which failed with error 1307 where the account + may not assign that owner ([#34](https://github.com/raandree/NTFSSecurity/issues/34)) ### Deprecated diff --git a/Docs/Cmdlets/Disable-NTFSAccessInheritance.md b/Docs/Cmdlets/Disable-NTFSAccessInheritance.md index 0a8ca64..7a7b694 100644 --- a/Docs/Cmdlets/Disable-NTFSAccessInheritance.md +++ b/Docs/Cmdlets/Disable-NTFSAccessInheritance.md @@ -172,6 +172,8 @@ Before 5.0.0, the cmdlet enabled the privileges even when `EnablePrivileges` was Before 5.0.0, `-PassThru` returned the unchanged state of an item also when the change failed, and stopped the command when the item could not be read. +In the `Path` parameter set, the cmdlet writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. + ## RELATED LINKS [Enable-NTFSAccessInheritance](Enable-NTFSAccessInheritance.md) diff --git a/Docs/Cmdlets/Enable-NTFSAccessInheritance.md b/Docs/Cmdlets/Enable-NTFSAccessInheritance.md index 6f38c39..3448101 100644 --- a/Docs/Cmdlets/Enable-NTFSAccessInheritance.md +++ b/Docs/Cmdlets/Enable-NTFSAccessInheritance.md @@ -171,6 +171,8 @@ Before 5.0.0, the cmdlet enabled the privileges even when `EnablePrivileges` was Before 5.0.0, `-PassThru` returned the unchanged state of an item also when the change failed, and stopped the command when the item could not be read. +In the `Path` parameter set, the cmdlet writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. + ## RELATED LINKS [Disable-NTFSAccessInheritance](Disable-NTFSAccessInheritance.md) diff --git a/Docs/Cmdlets/Set-NTFSInheritance.md b/Docs/Cmdlets/Set-NTFSInheritance.md index e3667c6..cb4bfa1 100644 --- a/Docs/Cmdlets/Set-NTFSInheritance.md +++ b/Docs/Cmdlets/Set-NTFSInheritance.md @@ -198,6 +198,8 @@ Before 5.0.0, the cmdlet enabled the privileges even when `EnablePrivileges` was Before 5.0.0, `-PassThru` returned the unchanged state of an item also when the change failed, and stopped the command when the item could not be read. +In the `Path` parameter set, the cmdlet writes only the section that it changes, the DACL or the SACL, and leaves the owner and the group of the item as they are. Before 5.0.0, a change of the access inheritance could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. + ## RELATED LINKS [Get-NTFSInheritance](Get-NTFSInheritance.md) diff --git a/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md b/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md index 14fa9f3..f7c37e0 100644 --- a/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md +++ b/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md @@ -21,7 +21,7 @@ Set-NTFSSecurityDescriptor [-SecurityDescriptor] [-PassT The `Set-NTFSSecurityDescriptor` cmdlet writes a `Security2.FileSystemSecurity2` object to the file system. It is the final step of the security descriptor workflow: `Get-NTFSSecurityDescriptor` reads a descriptor into memory, cmdlets such as `Add-NTFSAccess`, `Remove-NTFSAccess`, `Set-NTFSOwner`, and `Disable-NTFSAccessInheritance` change that copy through their `-SecurityDescriptor` parameter, and this cmdlet applies all of those changes in a single write. -Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. +Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read or last written, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. With `-Verbose`, the cmdlet names the sections that it writes, or says that it writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own. diff --git a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs index fb6a3bc..1fa10f4 100644 --- a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs +++ b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs @@ -1,6 +1,7 @@ using Security2; using System; using System.Management.Automation; +using System.Security.AccessControl; namespace NTFSSecurity { @@ -40,6 +41,16 @@ namespace NTFSSecurity try { // Only the changed sections, so that an unchanged owner, for example, isn't written back (#34) + var changedSections = sd.ChangedSections; + if (changedSections == AccessControlSections.None) + { + WriteVerbose(string.Format("No section of the security descriptor of '{0}' changed since it was read or last written; nothing is written", sd.FullName)); + } + else + { + WriteVerbose(string.Format("Writing the changed sections of the security descriptor of '{0}': {1}", sd.FullName, changedSections)); + } + sd.WriteChanges(); if (passThru) diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 87bb3ff..64cb669 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -2427,6 +2427,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd A path that does not exist produces a non-terminating error and the cmdlet continues with the remaining paths. Before 5.0.0, the cmdlet enabled the privileges even when `EnablePrivileges` was `$false`, and left them enabled. Before 5.0.0, `-PassThru` returned the unchanged state of an item also when the change failed, and stopped the command when the item could not be read. + In the `Path` parameter set, the cmdlet writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. @@ -3044,6 +3045,7 @@ PS C:\> Get-Privileges | Where-Object { $_.Privilege -in 'Backup', 'Restore', A path that does not exist produces a non-terminating error and the cmdlet continues with the remaining paths. Before 5.0.0, the cmdlet enabled the privileges even when `EnablePrivileges` was `$false`, and left them enabled. Before 5.0.0, `-PassThru` returned the unchanged state of an item also when the change failed, and stopped the command when the item could not be read. + In the `Path` parameter set, the cmdlet writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. @@ -9557,6 +9559,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd Before 5.0.0, omitting `-AccessInheritanceEnabled` or `-AuditInheritanceEnabled` could fail with the error "Nullable object must have a value". Before 5.0.0, the cmdlet enabled the privileges even when `EnablePrivileges` was `$false`, and left them enabled. Before 5.0.0, `-PassThru` returned the unchanged state of an item also when the change failed, and stopped the command when the item could not be read. + In the `Path` parameter set, the cmdlet writes only the section that it changes, the DACL or the SACL, and leaves the owner and the group of the item as they are. Before 5.0.0, a change of the access inheritance could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. @@ -9875,7 +9878,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd The `Set-NTFSSecurityDescriptor` cmdlet writes a `Security2.FileSystemSecurity2` object to the file system. It is the final step of the security descriptor workflow: `Get-NTFSSecurityDescriptor` reads a descriptor into memory, cmdlets such as `Add-NTFSAccess`, `Remove-NTFSAccess`, `Set-NTFSOwner`, and `Disable-NTFSAccessInheritance` change that copy through their `-SecurityDescriptor` parameter, and this cmdlet applies all of those changes in a single write. - Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. + Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read or last written, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. With `-Verbose`, the cmdlet names the sections that it writes, or says that it writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own. When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner. If that fails as well, it writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege. diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index 1426344..d23d26d 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -157,4 +157,27 @@ Describe 'Set-NTFSSecurityDescriptor' { (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $trustedInstaller } } + + Context 'With -Verbose' { + It 'Should name the sections that it writes' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'VerboseChanged' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData + + $messages = Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -Verbose 4>&1 + + $messages.Message | Should -Contain "Writing the changed sections of the security descriptor of '$($sd.FullName)': Access" + } + + It 'Should say that it writes nothing for an unchanged descriptor' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'VerboseUnchanged' + $sd = Get-NTFSSecurityDescriptor -Path $file + + $messages = Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -Verbose 4>&1 + + $messages.Message | + Should -Contain "No section of the security descriptor of '$($sd.FullName)' changed since it was read or last written; nothing is written" + } + } } diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 index fdf7598..355dff4 100644 --- a/Tests/TestHelpers.Tests.ps1 +++ b/Tests/TestHelpers.Tests.ps1 @@ -159,6 +159,15 @@ Describe 'Test helpers' { { Set-TestOwner -Sandbox $sandbox -Path "$sandbox-Other\File.txt" -Sid $trustedInstaller } | Should -Throw -ExpectedMessage 'Refusing to change*' } + + # icacls reports a failure on stderr, which Windows PowerShell turns into a terminating error of its own when + # the caller uses -ErrorAction Stop. + It 'Should throw its own error when icacls fails, also with -ErrorAction Stop' { + $missing = Join-Path -Path $sandbox -ChildPath 'Missing.txt' + + { Set-TestOwner -Sandbox $sandbox -Path $missing -Sid $trustedInstaller -ErrorAction Stop } | + Should -Throw -ExpectedMessage 'icacls could not make*' + } } Context 'Test-IsElevated and Test-PrivilegeHeld' { diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index 21e50c5..4ccdfdb 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -293,6 +293,9 @@ function Set-TestOwner { ) Assert-TestSandboxPath -Sandbox $Sandbox -Path $Path + # icacls reports a failure on stderr, which Windows PowerShell turns into a terminating error when the caller uses + # ErrorAction Stop; the exit code decides instead. + $ErrorActionPreference = 'Continue' # icacls resolves a relative path against the working folder of the process, not the location of PowerShell. $location = (Get-Location -PSProvider FileSystem).ProviderPath $fullName = [IO.Path]::GetFullPath([IO.Path]::Combine($location, $Path))