diff --git a/.codex b/.codex new file mode 100644 index 000000000..e69de29bb diff --git a/docfx/Docfx.csproj b/docfx/Docfx.csproj index e8d74dd37..6133e54fe 100644 --- a/docfx/Docfx.csproj +++ b/docfx/Docfx.csproj @@ -2,6 +2,7 @@ net5.0 false + false $([MSBuild]::NormalizeDirectory($(MSBuildThisFileDirectory), `..`, `docs`)) 8002 $(MSBuildThisFileDirectory)docfx.log diff --git a/src/CommonLib/Enums/DirectoryPaths.cs b/src/CommonLib/Enums/DirectoryPaths.cs index 878feef23..c7421afac 100644 --- a/src/CommonLib/Enums/DirectoryPaths.cs +++ b/src/CommonLib/Enums/DirectoryPaths.cs @@ -8,7 +8,8 @@ public static class DirectoryPaths public const string CertTemplateLocation = "CN=Certificate Templates,CN=Public Key Services,CN=Services"; public const string NTAuthStoreLocation = "CN=NTAuthCertificates,CN=Public Key Services,CN=Services"; public const string PKILocation = "CN=Public Key Services,CN=Services"; + public const string ExchangeLocation = "CN=Microsoft Exchange,CN=Services,CN=Configuration"; public const string ConfigLocation = "CN=Configuration"; public const string OIDContainerLocation = "CN=OID,CN=Public Key Services,CN=Services"; } -} \ No newline at end of file +} diff --git a/src/CommonLib/ILdapUtils.cs b/src/CommonLib/ILdapUtils.cs index 753e53bd1..9ddba7c55 100644 --- a/src/CommonLib/ILdapUtils.cs +++ b/src/CommonLib/ILdapUtils.cs @@ -175,4 +175,4 @@ IAsyncEnumerable> RangedRetrieval(string distinguishedName, /// void ResetUtils(); } -} \ No newline at end of file +} diff --git a/src/CommonLib/LdapConfig.cs b/src/CommonLib/LdapConfig.cs index 3f3e84e40..745a19b6e 100644 --- a/src/CommonLib/LdapConfig.cs +++ b/src/CommonLib/LdapConfig.cs @@ -53,4 +53,4 @@ public override string ToString() { return sb.ToString(); } } -} \ No newline at end of file +} diff --git a/src/CommonLib/LdapUtils.cs b/src/CommonLib/LdapUtils.cs index 0745af9e8..f637130ea 100644 --- a/src/CommonLib/LdapUtils.cs +++ b/src/CommonLib/LdapUtils.cs @@ -881,7 +881,7 @@ public ActiveDirectorySecurityDescriptor MakeSecurityDescriptor() { string computerDomainSid, string computerDomain) { if (!WellKnownPrincipal.GetWellKnownPrincipal(sid.Value, out var common)) return (false, null); //The "Everyone" and "Authenticated Users" principals are special and will be converted to the domain equivalent - if (sid.Value is "S-1-1-0" or "S-1-5-11") { + if (sid.Value is WellKnownPrincipal.EveryoneSid or "S-1-5-11") { return await GetWellKnownPrincipal(sid.Value, computerDomain); } @@ -1418,4 +1418,4 @@ private static string ComputeDisplayName(IDirectoryObject directoryObject, strin return displayName.ToUpper(); } } -} \ No newline at end of file +} diff --git a/src/CommonLib/Processors/ACLProcessor.cs b/src/CommonLib/Processors/ACLProcessor.cs index da3a615b4..b9a1c5ede 100644 --- a/src/CommonLib/Processors/ACLProcessor.cs +++ b/src/CommonLib/Processors/ACLProcessor.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging; using SharpHoundCommonLib.DirectoryObjects; using SharpHoundCommonLib.Enums; +using SharpHoundCommonLib.LDAPQueries; using SharpHoundCommonLib.OutputTypes; using System.Linq; @@ -20,7 +21,15 @@ public class ACLProcessor { private readonly ILogger _log; private readonly ILdapUtils _utils; private readonly ConcurrentHashSet _builtDomainCaches = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _exchangeTrusteeSidCache = new(StringComparer.OrdinalIgnoreCase); private readonly object _lock = new(); + // These Exchange principals commonly carry product-added deny ACEs that we intentionally suppress. + private static readonly HashSet ExchangeTrusteeNames = new(StringComparer.OrdinalIgnoreCase) { + "Exchange Windows Permissions", + "Exchange Trusted Subsystem", + "Exchange Servers", + "Organization Management" + }; static ACLProcessor() { //Create a dictionary with the base GUIDs of each object type @@ -48,6 +57,44 @@ public ACLProcessor(ILdapUtils utils, ILogger log = null) _log = log ?? Logging.LogProvider.CreateLogger("ACLProc"); } + public readonly struct CustomDenyAceCounts { + public CustomDenyAceCounts(int explicitCount, int inheritedCount) { + ExplicitCount = explicitCount; + InheritedCount = inheritedCount; + } + + public int ExplicitCount { get; } + public int InheritedCount { get; } + public int Total => ExplicitCount + InheritedCount; + } + + public sealed class ACLProcessingResult { + public ACLProcessingResult(ACE[] aces, CustomDenyAceCounts customDenyAceCounts) { + Aces = aces; + CustomDenyAceCounts = customDenyAceCounts; + } + + public ACE[] Aces { get; } + public CustomDenyAceCounts CustomDenyAceCounts { get; } + } + + private sealed class CustomDenyAceAccumulator { + private int _explicitCount; + private int _inheritedCount; + + public void Add(bool inherited) { + if (inherited) { + _inheritedCount++; + } else { + _explicitCount++; + } + } + + public CustomDenyAceCounts ToCounts() { + return new CustomDenyAceCounts(_explicitCount, _inheritedCount); + } + } + /// Represents a lightweight Access Control Entry (ACE) used to compute hash values /// for AdminSDHolder purposes internal class ACEForHashing { @@ -431,6 +478,24 @@ public IAsyncEnumerable ProcessACL(ResolvedSearchResult result, IDirectoryO return ProcessACL(descriptor, result.Domain, result.ObjectType, searchResult.HasLAPS(), checkForOwnerRights, result.DisplayName); } + /// + /// Processes the regular ACL edges and custom deny ACE counts in one ACL traversal. + /// + /// + /// Callers that do not want custom deny ACE counts should continue to call . + /// + public Task ProcessACLWithCustomDenyAces(ResolvedSearchResult result, + IDirectoryObject searchResult, bool checkForOwnerRights = true) { + if (!searchResult.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var descriptor)) { + return Task.FromResult(new ACLProcessingResult(Array.Empty(), new CustomDenyAceCounts())); + } + + searchResult.TryGetDistinguishedName(out var distinguishedName); + return ProcessACLWithCustomDenyAces(descriptor, result.Domain, result.ObjectType, searchResult.HasLAPS(), + checkForOwnerRights, distinguishedName, searchResult.IsMSA() || searchResult.IsGMSA(), + result.DisplayName); + } + /// /// Read's a raw ntSecurityDescriptor and processes the ACEs in the ACL, filtering out ACEs that /// BloodHound is not interested in as well as principals we don't care about @@ -447,8 +512,25 @@ public IAsyncEnumerable ProcessACL(byte[] ntSecurityDescriptor, string obje return ProcessACL(ntSecurityDescriptor, objectDomain, objectType, hasLaps, true, objectName); } - public async IAsyncEnumerable ProcessACL(byte[] ntSecurityDescriptor, string objectDomain, + public IAsyncEnumerable ProcessACL(byte[] ntSecurityDescriptor, string objectDomain, Label objectType, bool hasLaps, bool checkForOwnerRights, string objectName) { + return ProcessACLInternal(ntSecurityDescriptor, objectDomain, objectType, hasLaps, checkForOwnerRights, + objectName); + } + + public async Task ProcessACLWithCustomDenyAces(byte[] ntSecurityDescriptor, + string objectDomain, Label objectType, bool hasLaps, bool checkForOwnerRights = true, + string distinguishedName = null, bool isMSA = false, string objectName = "") { + var accumulator = new CustomDenyAceAccumulator(); + var aces = await ProcessACLInternal(ntSecurityDescriptor, objectDomain, objectType, hasLaps, + checkForOwnerRights, objectName, accumulator, distinguishedName, isMSA).ToArrayAsync(); + return new ACLProcessingResult(aces, accumulator.ToCounts()); + } + + private async IAsyncEnumerable ProcessACLInternal(byte[] ntSecurityDescriptor, string objectDomain, + Label objectType, bool hasLaps, bool checkForOwnerRights, string objectName, + CustomDenyAceAccumulator customDenyAceAccumulator = null, string distinguishedName = null, + bool isMSA = false) { await BuildGuidCache(objectDomain); if (ntSecurityDescriptor == null) { @@ -496,7 +578,19 @@ public async IAsyncEnumerable ProcessACL(byte[] ntSecurityDescriptor, strin bool isPermissionForOwnerRightsSid = false; bool isInheritedPermissionForOwnerRightsSid = false; - if (ace == null || ace.AccessControlType() == AccessControlType.Deny || !ace.IsAceInheritedFrom(BaseGuids[objectType])) { + if (ace == null) { + continue; + } + + if (ace.AccessControlType() == AccessControlType.Deny) { + if (customDenyAceAccumulator != null) { + await CountCustomDenyAce(ace, customDenyAceAccumulator, objectDomain, objectType, + distinguishedName, isMSA); + } + continue; + } + + if (!ace.IsAceInheritedFrom(BaseGuids[objectType])) { continue; } @@ -881,6 +975,164 @@ or Label.NTAuthStore } } + public Task GetCustomDenyAceCounts(ResolvedSearchResult result, + IDirectoryObject searchResult) { + if (!searchResult.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var descriptor)) { + return Task.FromResult(new CustomDenyAceCounts()); + } + + searchResult.TryGetDistinguishedName(out var distinguishedName); + return GetCustomDenyAceCounts( + descriptor, + result.Domain, + result.ObjectType, + distinguishedName, + searchResult.IsMSA() || searchResult.IsGMSA(), + result.DisplayName); + } + + public async Task GetCustomDenyAceCounts(byte[] ntSecurityDescriptor, + string objectDomain, Label objectType, string distinguishedName = null, bool isMSA = false, + string objectName = "") { + if (ntSecurityDescriptor == null) { + return new CustomDenyAceCounts(); + } + + RawSecurityDescriptor descriptor; + try { + descriptor = new RawSecurityDescriptor(ntSecurityDescriptor, 0); + } + catch (Exception e) when (e is OverflowException or ArgumentException) { + _log.LogWarning( + "Security descriptor on object {Name} exceeds maximum allowable length. Unable to process custom deny ACEs", + objectName); + return new CustomDenyAceCounts(); + } + + if (descriptor.DiscretionaryAcl == null || descriptor.DiscretionaryAcl.Count == 0) { + return new CustomDenyAceCounts(); + } + + var explicitCount = 0; + var inheritedCount = 0; + + foreach (GenericAce ace in descriptor.DiscretionaryAcl) { + if (!TryGetDenyAceData(ace, out var principalSid, out var rights, out var objectAceType)) { + continue; + } + + if (await ShouldExcludeCustomDenyAce(principalSid, rights, objectAceType, objectDomain, objectType, + distinguishedName, isMSA)) { + continue; + } + + if ((ace.AceFlags & AceFlags.Inherited) == AceFlags.Inherited) { + inheritedCount++; + } else { + explicitCount++; + } + } + + return new CustomDenyAceCounts(explicitCount, inheritedCount); + } + + private async Task CountCustomDenyAce(ActiveDirectoryRuleDescriptor ace, + CustomDenyAceAccumulator accumulator, string objectDomain, Label objectType, string distinguishedName, + bool isMSA) { + var principalSid = ace.IdentityReference(); + if (string.IsNullOrWhiteSpace(principalSid)) { + return; + } + + if (await ShouldExcludeCustomDenyAce(principalSid, ace.ActiveDirectoryRights(), ace.ObjectType(), + objectDomain, objectType, distinguishedName, isMSA)) { + return; + } + + accumulator.Add(ace.IsInherited()); + } + + private static bool TryGetDenyAceData(GenericAce ace, out string principalSid, out ActiveDirectoryRights rights, + out Guid objectAceType) { + principalSid = null; + rights = 0; + objectAceType = Guid.Empty; + + switch (ace) { + case CommonAce commonAce when commonAce.AceQualifier == AceQualifier.AccessDenied: + principalSid = commonAce.SecurityIdentifier?.Value; + rights = (ActiveDirectoryRights)commonAce.AccessMask; + return !string.IsNullOrWhiteSpace(principalSid); + case ObjectAce objectAce when objectAce.AceQualifier == AceQualifier.AccessDenied: + principalSid = objectAce.SecurityIdentifier?.Value; + rights = (ActiveDirectoryRights)objectAce.AccessMask; + objectAceType = objectAce.ObjectAceType; + return !string.IsNullOrWhiteSpace(principalSid); + default: + return false; + } + } + + private async Task ShouldExcludeCustomDenyAce(string principalSid, ActiveDirectoryRights rights, + Guid objectAceType, string objectDomain, Label objectType, string distinguishedName, bool isMSA) { + // Filter Exchange Deny ACEs + if (!string.IsNullOrWhiteSpace(distinguishedName) && + distinguishedName.IndexOf(DirectoryPaths.ExchangeLocation, StringComparison.OrdinalIgnoreCase) >= 0) { + return true; + } + + if (await IsExchangeTrustee(principalSid, objectDomain)) { + return true; + } + + // Filter default Everyone Deny ACEs + if (principalSid.Equals(WellKnownPrincipal.EveryoneSid, StringComparison.OrdinalIgnoreCase)) { + if ((objectType is Label.OU or Label.Container) && + rights == (ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree)) { + return true; + } + + if (isMSA && + rights.HasFlag(ActiveDirectoryRights.ExtendedRight) && + objectAceType.Equals(new Guid(ACEGuids.UserForceChangePassword))) { + return true; + } + + if (objectType == Label.Domain && rights.HasFlag(ActiveDirectoryRights.DeleteChild)) { + return true; + } + } + + return false; + } + + private async Task IsExchangeTrustee(string principalSid, string objectDomain) { + if (string.IsNullOrWhiteSpace(principalSid) || string.IsNullOrWhiteSpace(objectDomain)) { + return false; + } + + if (_exchangeTrusteeSidCache.TryGetValue(objectDomain, out var cachedSids)) { + return cachedSids.Contains(principalSid, StringComparer.OrdinalIgnoreCase); + } + + // Well-known principals never match the Exchange groups we are suppressing. + if (WellKnownPrincipal.GetWellKnownPrincipal(principalSid, out _)) { + return false; + } + + // Resolve the small fixed set of Exchange trustee names once per domain using the shared name -> ID cache path. + var resolvedSids = new List(); + foreach (var trusteeName in ExchangeTrusteeNames) { + if (await _utils.ResolveAccountName(trusteeName, objectDomain) is (true, var principal) && + !string.IsNullOrWhiteSpace(principal.ObjectIdentifier)) { + resolvedSids.Add(principal.ObjectIdentifier); + } + } + + var exchangeTrusteeSids = resolvedSids.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + _exchangeTrusteeSidCache.TryAdd(objectDomain, exchangeTrusteeSids); + return exchangeTrusteeSids.Contains(principalSid, StringComparer.OrdinalIgnoreCase); + } /// /// Helper function to use commonlib types and pass to ProcessGMSAReaders diff --git a/src/CommonLib/WellKnownPrincipal.cs b/src/CommonLib/WellKnownPrincipal.cs index 4185dc8bb..5c8f661ea 100644 --- a/src/CommonLib/WellKnownPrincipal.cs +++ b/src/CommonLib/WellKnownPrincipal.cs @@ -5,6 +5,8 @@ namespace SharpHoundCommonLib { public static class WellKnownPrincipal { + public const string EveryoneSid = "S-1-1-0"; + /// /// Gets the principal associated with a well known SID /// @@ -18,7 +20,7 @@ public static bool GetWellKnownPrincipal(string sid, out TypedPrincipal commonPr "S-1-0" => new TypedPrincipal("Null Authority", Label.User), "S-1-0-0" => new TypedPrincipal("Nobody", Label.User), "S-1-1" => new TypedPrincipal("World Authority", Label.User), - "S-1-1-0" => new TypedPrincipal("Everyone", Label.Group), + EveryoneSid => new TypedPrincipal("Everyone", Label.Group), "S-1-2" => new TypedPrincipal("Local Authority", Label.User), "S-1-2-0" => new TypedPrincipal("Local", Label.Group), "S-1-2-1" => new TypedPrincipal("Console Logon", Label.Group), diff --git a/test/unit/ACLProcessorTest.cs b/test/unit/ACLProcessorTest.cs index c9da64d55..b383c78fd 100644 --- a/test/unit/ACLProcessorTest.cs +++ b/test/unit/ACLProcessorTest.cs @@ -2,9 +2,11 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.DirectoryServices; +using System.Collections; using System.Linq; using System.Runtime.Versioning; using System.Security.AccessControl; +using System.Security.Principal; using System.Threading; using System.Threading.Tasks; using CommonLibTest.Facades; @@ -12,6 +14,7 @@ using Newtonsoft.Json; using SharpHoundCommonLib; using SharpHoundCommonLib.Enums; +using SharpHoundCommonLib.LDAPQueries; using SharpHoundCommonLib.OutputTypes; using SharpHoundCommonLib.Processors; using Xunit; @@ -2136,5 +2139,237 @@ public async Task ACLProcessor_ProcessACL_EnterpriseCA_Enroll() Assert.False(actual.IsInherited); Assert.Equal(actual.RightName, expectedRightName); } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_CountsQualifyingExplicitDenyAce() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2500", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(result, 1, 0); + } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_SkipsExchangeTrusteeDenyAce() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2600", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(("S-1-5-21-3130019616-2776909439-2417379446-2600", + "Exchange Windows Permissions")); + + var result = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(result, 0, 0); + } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_SkipsOrganizationManagementDenyAce() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2601", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(("S-1-5-21-3130019616-2776909439-2417379446-2601", + "Organization Management")); + + var result = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(result, 0, 0); + } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_SkipsExchangeConfigurationPath() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2700", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.Container, + "CN=Mailbox Database,CN=Microsoft Exchange,CN=Services,CN=Configuration,DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(result, 0, 0); + } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_SkipsAccidentalDeletionProtection() { + var ace = CreateCommonDenyAce("S-1-1-0", + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.OU, "OU=TEST,DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(result, 0, 0); + } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_CountsAccidentalDeletionProtectionWithAdditionalRights() { + var ace = CreateCommonDenyAce("S-1-1-0", + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree | ActiveDirectoryRights.WriteDacl); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.OU, "OU=TEST,DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(result, 1, 0); + } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_SkipsDefaultAdDenyPatterns() { + var msaAce = CreateObjectDenyAce("S-1-1-0", ActiveDirectoryRights.ExtendedRight, + new Guid(ACEGuids.UserForceChangePassword)); + var domainAce = CreateCommonDenyAce("S-1-1-0", ActiveDirectoryRights.DeleteChild); + var processor = CreateCustomDenyAceProcessor(); + + var msaResult = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(msaAce), + _testDomainName, Label.User, "CN=TEST MSA,CN=Managed Service Accounts,DC=TESTLAB,DC=LOCAL", true); + var domainResult = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(domainAce), + _testDomainName, Label.Domain, "DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(msaResult, 0, 0); + AssertCustomDenyAceCounts(domainResult, 0, 0); + } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_CountsMultipleExplicitDenyAces() { + var ace1 = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2800", + ActiveDirectoryRights.Delete); + var ace2 = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2801", + ActiveDirectoryRights.DeleteChild); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(ace1, ace2), + _testDomainName, Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(result, 2, 0); + } + + [WindowsOnlyFact] + public async Task ACLProcessor_GetCustomDenyAceCounts_SplitsExplicitAndInheritedDenyAces() { + var explicitAce = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-3000", + ActiveDirectoryRights.Delete); + var inheritedAce = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-3001", + ActiveDirectoryRights.DeleteChild, AceFlags.Inherited); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAceCounts(CreateSecurityDescriptorBytes(explicitAce, inheritedAce), + _testDomainName, Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + AssertCustomDenyAceCounts(result, 1, 1); + } + + [Fact] + public async Task ACLProcessor_ProcessACLWithCustomDenyAces_ReturnsRegularAcesAndDenyCounts() { + var denyRule = CreateRuleDescriptor("S-1-5-21-3130019616-2776909439-2417379446-3100", + AccessControlType.Deny, ActiveDirectoryRights.Delete); + var allowRule = CreateRuleDescriptor("S-1-5-21-3130019616-2776909439-2417379446-3101", + AccessControlType.Allow, ActiveDirectoryRights.WriteDacl); + allowRule.Setup(x => x.IsAceInheritedFrom(It.IsAny())).Returns(true); + + var processor = CreateCombinedAclProcessor(new[] { denyRule.Object, allowRule.Object }); + var result = await processor.ProcessACLWithCustomDenyAces(new byte[] { 1 }, _testDomainName, Label.User, + false); + + Assert.Single(result.Aces); + Assert.Equal(EdgeNames.WriteDacl, result.Aces[0].RightName); + AssertCustomDenyAceCounts(result.CustomDenyAceCounts, 1, 0); + } + + [Fact] + public async Task ACLProcessor_ProcessACLWithCustomDenyAces_DoesNotCountExcludedDenyAces() { + var denyRule = CreateRuleDescriptor(WellKnownPrincipal.EveryoneSid, AccessControlType.Deny, + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree); + + var processor = CreateCombinedAclProcessor(new[] { denyRule.Object }); + var result = await processor.ProcessACLWithCustomDenyAces(new byte[] { 1 }, _testDomainName, Label.OU, + false); + + Assert.Empty(result.Aces); + AssertCustomDenyAceCounts(result.CustomDenyAceCounts, 0, 0); + } + + [Fact] + public async Task ACLProcessor_ProcessACLWithCustomDenyAces_CountsAccidentalDeletionProtectionWithAdditionalRights() { + var denyRule = CreateRuleDescriptor(WellKnownPrincipal.EveryoneSid, AccessControlType.Deny, + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree | ActiveDirectoryRights.WriteDacl); + + var processor = CreateCombinedAclProcessor(new[] { denyRule.Object }); + var result = await processor.ProcessACLWithCustomDenyAces(new byte[] { 1 }, _testDomainName, Label.OU, + false); + + Assert.Empty(result.Aces); + AssertCustomDenyAceCounts(result.CustomDenyAceCounts, 1, 0); + } + + private ACLProcessor CreateCustomDenyAceProcessor(params (string Sid, string Name)[] principals) { + var mockLdapUtils = new Mock(MockBehavior.Strict); + mockLdapUtils.Setup(x => x.ResolveAccountName(It.IsAny(), It.IsAny())) + .ReturnsAsync((string name, string _) => { + var match = principals.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + return string.IsNullOrWhiteSpace(match.Sid) + ? (false, null) + : (true, new TypedPrincipal(match.Sid, Label.Group)); + }); + + return new ACLProcessor(mockLdapUtils.Object); + } + + private ACLProcessor CreateCombinedAclProcessor(IEnumerable rules) { + var mockLdapUtils = new Mock(); + var mockSecurityDescriptor = new Mock(MockBehavior.Loose, null); + mockSecurityDescriptor.Setup(x => x.GetOwner(It.IsAny())).Returns((string)null); + mockSecurityDescriptor.Setup(x => x.GetAccessRules(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(rules.ToList()); + mockLdapUtils.Setup(x => x.MakeSecurityDescriptor()).Returns(mockSecurityDescriptor.Object); + mockLdapUtils.Setup(x => x.PagedQuery(It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable.Empty>()); + mockLdapUtils.Setup(x => x.ResolveAccountName(It.IsAny(), It.IsAny())) + .ReturnsAsync((false, null)); + mockLdapUtils.Setup(x => x.ResolveIDAndType(It.IsAny(), It.IsAny())) + .ReturnsAsync((string sid, string _) => (true, new TypedPrincipal(sid, Label.User))); + return new ACLProcessor(mockLdapUtils.Object); + } + + private static Mock CreateRuleDescriptor(string sid, + AccessControlType accessControlType, ActiveDirectoryRights rights, bool inherited = false) { + var rule = new Mock(MockBehavior.Loose, null); + rule.Setup(x => x.IdentityReference()).Returns(sid); + rule.Setup(x => x.AccessControlType()).Returns(accessControlType); + rule.Setup(x => x.ActiveDirectoryRights()).Returns(rights); + rule.Setup(x => x.ObjectType()).Returns(Guid.Empty); + rule.Setup(x => x.IsInherited()).Returns(inherited); + return rule; + } + + private static void AssertCustomDenyAceCounts(ACLProcessor.CustomDenyAceCounts result, + int expectedExplicitCount, int expectedInheritedCount) { + Assert.Equal(expectedExplicitCount, result.ExplicitCount); + Assert.Equal(expectedInheritedCount, result.InheritedCount); + Assert.Equal(expectedExplicitCount + expectedInheritedCount, result.Total); + } + + private static byte[] CreateSecurityDescriptorBytes(params GenericAce[] aces) { + var acl = new RawAcl(GenericAcl.AclRevisionDS, aces.Length); + for (var i = 0; i < aces.Length; i++) { + acl.InsertAce(i, aces[i]); + } + + var descriptor = new RawSecurityDescriptor(ControlFlags.DiscretionaryAclPresent, null, null, null, acl); + var buffer = new byte[descriptor.BinaryLength]; + descriptor.GetBinaryForm(buffer, 0); + return buffer; + } + + private static CommonAce CreateCommonDenyAce(string sid, ActiveDirectoryRights rights, + AceFlags aceFlags = AceFlags.None) { + return new CommonAce(aceFlags, AceQualifier.AccessDenied, (int)rights, + new SecurityIdentifier(sid), false, null); + } + + private static ObjectAce CreateObjectDenyAce(string sid, ActiveDirectoryRights rights, Guid objectType) { + return new ObjectAce(AceFlags.None, AceQualifier.AccessDenied, (int)rights, + new SecurityIdentifier(sid), ObjectAceFlags.ObjectAceTypePresent, objectType, Guid.Empty, false, null); + } } -} \ No newline at end of file +} diff --git a/test/unit/Facades/MockLdapUtils.cs b/test/unit/Facades/MockLdapUtils.cs index f60608d16..7cc463eaa 100644 --- a/test/unit/Facades/MockLdapUtils.cs +++ b/test/unit/Facades/MockLdapUtils.cs @@ -1127,4 +1127,4 @@ public void Dispose() { return (true, "0"); } } -} \ No newline at end of file +}