Browse Source

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 <ai@example.com>
pull/112/head
Raimund Andree 5 days ago
parent
commit
0b92b69d53
  1. 26
      CHANGELOG.md
  2. 2
      Docs/Cmdlets/Add-NTFSAccess.md
  3. 2
      Docs/Cmdlets/Add-NTFSAudit.md
  4. 2
      Docs/Cmdlets/Clear-NTFSAccess.md
  5. 4
      Docs/Cmdlets/Clear-NTFSAudit.md
  6. 4
      Docs/Cmdlets/Get-NTFSSecurityDescriptor.md
  7. 2
      Docs/Cmdlets/Remove-NTFSAccess.md
  8. 2
      Docs/Cmdlets/Set-NTFSSecurityDescriptor.md
  9. 5
      NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs
  10. 13
      NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml
  11. 3
      Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.AddFileSystemAccessRules.cs
  12. 10
      Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRules.cs
  13. 3
      Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRulesAll.cs
  14. 3
      Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.AddFileSystemAuditRules.cs
  15. 9
      Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRule.cs
  16. 8
      Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRuleAll.cs
  17. 83
      Security2/FileSystem/FileSystemSecurity2.cs
  18. 110
      Tests/Access.Tests.ps1
  19. 121
      Tests/Audit.Tests.ps1
  20. 45
      Tests/Inheritance.Tests.ps1
  21. 160
      Tests/SecurityDescriptor.Tests.ps1
  22. 31
      Tests/TestHelpers.Tests.ps1
  23. 42
      Tests/TestHelpers.psm1

26
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

2
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)

2
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.

2
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)

4
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.

4
Docs/Cmdlets/Get-NTFSSecurityDescriptor.md

@ -21,9 +21,9 @@ Get-NTFSSecurityDescriptor [[-Path] <String[]>] [<CommonParameters>]
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.

2
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.

2
Docs/Cmdlets/Set-NTFSSecurityDescriptor.md

@ -21,7 +21,7 @@ Set-NTFSSecurityDescriptor [-SecurityDescriptor] <FileSystemSecurity2[]> [-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.

5
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);
}

13
NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml

@ -720,6 +720,7 @@
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@ -1502,6 +1503,7 @@
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>`-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.</maml:para>
<maml:para>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.</maml:para>
</maml:alert>
@ -1709,6 +1711,7 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@ -1912,7 +1915,8 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<maml:alertSet>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
</maml:alert>
</maml:alertSet>
@ -6238,8 +6242,8 @@ PS C:\&gt; Get-NTFSOwner -SecurityDescriptor $sd</dev:code>
</command:details>
<maml:description>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
</maml:description>
<command:syntax>
@ -8416,6 +8420,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>Removing rights from an entry that does not exist is not an error; the cmdlet leaves the ACL unchanged.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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".</maml:para>
<maml:para>Before 5.0.0, the `-RemoveSpecific` switch was missing, although version 4.1 had introduced it.</maml:para>
<maml:para>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`.</maml:para>
@ -9870,7 +9875,7 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
</command:details>
<maml:description>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
</maml:description>

3
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);

10
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);
}
}

3
Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.RemoveFileSystemAccessRulesAll.cs

@ -25,7 +25,8 @@ namespace Security2
public static void RemoveFileSystemAccessRuleAll(FileSystemInfo item, List<IdentityReference2> 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);

3
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);

9
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);
}
}

8
Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.RemoveFileSystemAuditRuleAll.cs

@ -25,11 +25,17 @@ namespace Security2
public static void RemoveFileSystemAuditRuleAll(FileSystemInfo item, List<IdentityReference2> 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);
// 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();
}
}
}
}

83
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<AccessControlSections, string> 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<AccessControlSections, string>();
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)

110
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
}
}
}

121
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
}
}
}

45
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
}
}
}

160
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
}
}
}

31
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]

42
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

Loading…
Cancel
Save