From 508258539ca09102984359f56d076f31e4413b13 Mon Sep 17 00:00:00 2001 From: JonasBK Date: Thu, 23 Apr 2026 13:11:20 +0200 Subject: [PATCH 1/7] collect custom deny aces --- src/CommonLib/Enums/DirectoryPaths.cs | 3 +- src/CommonLib/Enums/LDAPProperties.cs | 1 + src/CommonLib/LdapUtils.cs | 5 +- src/CommonLib/Processors/ACLProcessor.cs | 178 ++++++++++++++++++ .../Processors/LdapPropertyProcessor.cs | 50 ++++- src/CommonLib/WellKnownPrincipal.cs | 4 +- test/unit/ACLProcessorTest.cs | 170 ++++++++++++++++- test/unit/LdapPropertyTests.cs | 50 +++-- 8 files changed, 428 insertions(+), 33 deletions(-) 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/Enums/LDAPProperties.cs b/src/CommonLib/Enums/LDAPProperties.cs index 0bf6b726e..89ba25462 100644 --- a/src/CommonLib/Enums/LDAPProperties.cs +++ b/src/CommonLib/Enums/LDAPProperties.cs @@ -96,5 +96,6 @@ public static class LDAPProperties public const string LockOutObservationWindow = "lockoutobservationwindow"; public const string PrincipalName = "msds-principalname"; public const string GroupType = "grouptype"; + public const string CustomDenyAces = "customdenyaces"; } } diff --git a/src/CommonLib/LdapUtils.cs b/src/CommonLib/LdapUtils.cs index 9db6d7ec0..98ec52692 100644 --- a/src/CommonLib/LdapUtils.cs +++ b/src/CommonLib/LdapUtils.cs @@ -881,7 +881,8 @@ 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 var sidValue && + (sidValue == WellKnownPrincipal.EveryoneSid || sidValue == "S-1-5-11")) { return await GetWellKnownPrincipal(sid.Value, computerDomain); } @@ -1418,4 +1419,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..59a44e5b3 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 @@ -881,6 +890,175 @@ or Label.NTAuthStore } } + public Task GetCustomDenyAces(ResolvedSearchResult result, IDirectoryObject searchResult) { + if (!searchResult.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var descriptor)) { + return Task.FromResult(Array.Empty()); + } + + searchResult.TryGetDistinguishedName(out var distinguishedName); + return GetCustomDenyAces( + descriptor, + result.Domain, + result.ObjectType, + distinguishedName, + searchResult.IsMSA() || searchResult.IsGMSA(), + result.DisplayName); + } + + public async Task GetCustomDenyAces(byte[] ntSecurityDescriptor, string objectDomain, + Label objectType, string distinguishedName = null, bool isMSA = false, string objectName = "") { + if (ntSecurityDescriptor == null) { + return Array.Empty(); + } + + RawSecurityDescriptor descriptor; + try { + descriptor = new RawSecurityDescriptor(ntSecurityDescriptor, 0); + } + catch (OverflowException) { + _log.LogWarning( + "Security descriptor on object {Name} exceeds maximum allowable length. Unable to process custom deny ACEs", + objectName); + return Array.Empty(); + } + + if (descriptor.DiscretionaryAcl == null || descriptor.DiscretionaryAcl.Count == 0) { + return Array.Empty(); + } + + var results = new List(); + + // Walk the raw DACL so we can preserve deny ACE ordering and serialize each ACE back to SDDL verbatim. + 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; + } + + var sddl = SerializeAceToSddl(ace); + if (!string.IsNullOrWhiteSpace(sddl)) { + results.Add(sddl); + } + } + + return results.Count == 0 ? Array.Empty() : results.ToArray(); + } + + public async Task AddCustomDenyAcesProperty(Dictionary props, byte[] ntSecurityDescriptor, + string objectDomain, Label objectType, string distinguishedName = null, bool isMSA = false, + string objectName = "") { + var customDenyAces = await GetCustomDenyAces(ntSecurityDescriptor, objectDomain, objectType, + distinguishedName, isMSA, objectName); + + if (customDenyAces.Length > 0) { + props[LDAPProperties.CustomDenyAces] = customDenyAces; + } + } + + 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.HasFlag(ActiveDirectoryRights.Delete) && + rights.HasFlag(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); + } + + private static string SerializeAceToSddl(GenericAce ace) { + // Rehydrate the ACE inside a one-entry DACL and let the framework emit the canonical ACE SDDL for us. + var acl = new RawAcl(ace is ObjectAce ? GenericAcl.AclRevisionDS : GenericAcl.AclRevision, 1); + acl.InsertAce(0, CloneAce(ace)); + + var descriptor = new RawSecurityDescriptor(ControlFlags.DiscretionaryAclPresent, null, null, null, acl); + var sddl = descriptor.GetSddlForm(AccessControlSections.Access); + + return sddl.StartsWith("D:", StringComparison.OrdinalIgnoreCase) ? sddl.Substring(2) : sddl; + } + + private static GenericAce CloneAce(GenericAce ace) { + var buffer = new byte[ace.BinaryLength]; + ace.GetBinaryForm(buffer, 0); + return GenericAce.CreateFromBinaryForm(buffer, 0); + } + /// /// Helper function to use commonlib types and pass to ProcessGMSAReaders diff --git a/src/CommonLib/Processors/LdapPropertyProcessor.cs b/src/CommonLib/Processors/LdapPropertyProcessor.cs index 14cd0e6f4..a722222d1 100644 --- a/src/CommonLib/Processors/LdapPropertyProcessor.cs +++ b/src/CommonLib/Processors/LdapPropertyProcessor.cs @@ -8,6 +8,7 @@ using System.Security.Principal; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using SharpHoundCommonLib.DirectoryObjects; using SharpHoundCommonLib.Enums; using SharpHoundCommonLib.LDAPQueries; using SharpHoundCommonLib.OutputTypes; @@ -37,10 +38,12 @@ static LdapPropertyProcessor() { private readonly ILdapUtils _utils; private readonly ILogger _log; + private readonly ACLProcessor _aclProcessor; public LdapPropertyProcessor(ILdapUtils utils, ILogger log = null) { _utils = utils; _log = log ?? Logging.LogProvider.CreateLogger(nameof(LdapPropertyProcessor)); + _aclProcessor = new ACLProcessor(utils, _log); } private static Dictionary GetCommonProps(IDirectoryObject entry) { @@ -78,6 +81,7 @@ private static Dictionary GetCommonProps(IDirectoryObject entry) /// public async Task> ReadDomainProperties(IDirectoryObject entry, string domain) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, domain, Label.Domain); if (entry.TryGetProperty(LDAPProperties.ExpirePasswordsOnSmartCardOnlyAccounts, out var expirePassword) && bool.TryParse(expirePassword, out var expirePasswordBool)) { @@ -178,8 +182,9 @@ public static string FunctionalLevelToString(int level) { /// /// /// - public static Dictionary ReadGPOProperties(IDirectoryObject entry) { + public async Task> ReadGPOProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.GPO); entry.TryGetProperty(LDAPProperties.GPCFileSYSPath, out var path); props.Add("gpcpath", path.ToUpper()); entry.TryGetProperty(LDAPProperties.Flags, out var flags); @@ -192,8 +197,9 @@ public static Dictionary ReadGPOProperties(IDirectoryObject entr /// /// /// - public static Dictionary ReadOUProperties(IDirectoryObject entry) { + public async Task> ReadOUProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.OU); return props; } @@ -212,6 +218,7 @@ public async Task ReadGroupPropertiesAsync(IDirectoryObject ent { var groupProperties = new GroupProperties(); var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, domain, Label.Group); entry.TryGetLongProperty(LDAPProperties.AdminCount, out var ac); props.Add("admincount", ac != 0); entry.TryGetLongProperty(LDAPProperties.GroupType, out var groupType); @@ -230,8 +237,10 @@ public async Task ReadGroupPropertiesAsync(IDirectoryObject ent /// /// /// - public static Dictionary ReadContainerProperties(IDirectoryObject entry) { + public async Task> ReadContainerProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + var objectType = entry.GetLabel(out var label) ? label : Label.Container; + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), objectType); return props; } @@ -249,6 +258,7 @@ public Task public async Task ReadUserProperties(IDirectoryObject entry, string domain) { var userProps = new UserProperties(); var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, domain, Label.User); if (entry.TryGetLongProperty(LDAPProperties.UserAccountControl, out var uac)) { var uacFlags = (UacFlags)uac; @@ -364,6 +374,7 @@ public Task ReadComputerProperties(IDirectoryObject entry, public async Task ReadComputerProperties(IDirectoryObject entry, string domain) { var compProps = new ComputerProperties(); var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, domain, Label.Computer); var flags = (UacFlags)0; if (entry.TryGetLongProperty(LDAPProperties.UserAccountControl, out var uac)) { @@ -468,8 +479,9 @@ await SendComputerStatus(new CSVComputerStatus { /// /// /// Returns a dictionary with the common properties of the RootCA - public static Dictionary ReadRootCAProperties(IDirectoryObject entry) { + public async Task> ReadRootCAProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.RootCA); // Certificate if (entry.TryGetByteProperty(LDAPProperties.CACertificate, out var rawCertificate) && HasBytes(rawCertificate)) { @@ -489,8 +501,9 @@ public static Dictionary ReadRootCAProperties(IDirectoryObject e /// /// /// Returns a dictionary with the common properties and the crosscertificatepair property of the AICA - public static Dictionary ReadAIACAProperties(IDirectoryObject entry) { + public async Task> ReadAIACAProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.AIACA); entry.TryGetByteArrayProperty(LDAPProperties.CrossCertificatePair, out var crossCertificatePair); var hasCrossCertificatePair = crossCertificatePair.Length > 0; @@ -515,8 +528,9 @@ public static Dictionary ReadAIACAProperties(IDirectoryObject en /// /// /// Returns a dictionary with the common properties and the caname, hostname, and flags properties of the EnterpriseCA - public static Dictionary ReadEnterpriseCAProperties(IDirectoryObject entry) { + public async Task> ReadEnterpriseCAProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.EnterpriseCA); if (entry.TryGetLongProperty("flags", out var flags)) props.Add("flags", (PKICertificateAuthorityFlags)flags); props.Add("caname", entry.GetProperty(LDAPProperties.Name)); @@ -540,8 +554,9 @@ public static Dictionary ReadEnterpriseCAProperties(IDirectoryOb /// /// /// Returns a dictionary with the common properties of the NTAuthStore - public static Dictionary ReadNTAuthStoreProperties(IDirectoryObject entry) { + public async Task> ReadNTAuthStoreProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.NTAuthStore); return props; } @@ -550,8 +565,9 @@ public static Dictionary ReadNTAuthStoreProperties(IDirectoryObj /// /// /// Returns a dictionary associated with the CertTemplate properties that were read - public static Dictionary ReadCertTemplateProperties(IDirectoryObject entry) { + public async Task> ReadCertTemplateProperties(IDirectoryObject entry) { var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.CertTemplate); props.Add("validityperiod", ConvertPKIPeriod(entry.GetByteProperty(LDAPProperties.PKIExpirationPeriod))); props.Add("renewalperiod", ConvertPKIPeriod(entry.GetByteProperty(LDAPProperties.PKIOverlappedPeriod))); @@ -636,6 +652,7 @@ public static Dictionary ReadCertTemplateProperties(IDirectoryOb public async Task ReadIssuancePolicyProperties(IDirectoryObject entry) { var ret = new IssuancePolicyProperties(); var props = GetCommonProps(entry); + await AddCustomDenyAceProperty(props, entry, GetEntryDomain(entry), Label.IssuancePolicy); props.Add("displayname", entry.GetProperty(LDAPProperties.DisplayName)); props.Add("certtemplateoid", entry.GetProperty(LDAPProperties.CertTemplateOID)); @@ -650,6 +667,23 @@ public async Task ReadIssuancePolicyProperties(IDirect return ret; } + private async Task AddCustomDenyAceProperty(Dictionary props, IDirectoryObject entry, + string domain, Label objectType) { + if (!entry.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var ntSecurityDescriptor)) { + return; + } + + entry.TryGetDistinguishedName(out var distinguishedName); + await _aclProcessor.AddCustomDenyAcesProperty(props, ntSecurityDescriptor, domain, objectType, + distinguishedName, entry.IsMSA() || entry.IsGMSA(), distinguishedName ?? string.Empty); + } + + private static string GetEntryDomain(IDirectoryObject entry) { + return entry.TryGetDistinguishedName(out var distinguishedName) + ? Helpers.DistinguishedNameToDomain(distinguishedName) + : string.Empty; + } + /// /// Attempts to parse all LDAP attributes outside of the ones already collected and converts them to a human readable /// format using a best guess 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 f6907fd34..40f1f2b18 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; @@ -2135,5 +2138,170 @@ public async Task ACLProcessor_ProcessACL_EnterpriseCA_Enroll() Assert.False(actual.IsInherited); Assert.Equal(actual.RightName, expectedRightName); } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_EmitsQualifyingDenyAce() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2500", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Single(result); + Assert.Equal(SerializeAce(ace), result[0]); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_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.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(result); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_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.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(result); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_SkipsExchangeConfigurationPath() { + var ace = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2700", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.Container, + "CN=Mailbox Database,CN=Microsoft Exchange,CN=Services,CN=Configuration,DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(result); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_SkipsAccidentalDeletionProtection() { + var ace = CreateCommonDenyAce("S-1-1-0", + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.OU, "OU=TEST,DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(result); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_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.GetCustomDenyAces(CreateSecurityDescriptorBytes(msaAce), _testDomainName, + Label.User, "CN=TEST MSA,CN=Managed Service Accounts,DC=TESTLAB,DC=LOCAL", true); + var domainResult = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(domainAce), + _testDomainName, Label.Domain, "DC=TESTLAB,DC=LOCAL"); + + Assert.Empty(msaResult); + Assert.Empty(domainResult); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_EmitsMultipleQualifyingAces() { + 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.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace1, ace2), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Equal(2, result.Length); + Assert.Equal(SerializeAce(ace1), result[0]); + Assert.Equal(SerializeAce(ace2), result[1]); + } + + [Fact] + public async Task ACLProcessor_GetCustomDenyAces_PreservesDeterministicOrdering() { + var ace1 = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2901", + ActiveDirectoryRights.DeleteChild); + var ace2 = CreateCommonDenyAce("S-1-5-21-3130019616-2776909439-2417379446-2900", + ActiveDirectoryRights.Delete); + var processor = CreateCustomDenyAceProcessor(); + + var result = await processor.GetCustomDenyAces(CreateSecurityDescriptorBytes(ace1, ace2), _testDomainName, + Label.User, "CN=TEST USER,CN=USERS,DC=TESTLAB,DC=LOCAL"); + + Assert.Equal(new[] { SerializeAce(ace1), SerializeAce(ace2) }, result); + } + + [Fact] + public async Task ACLProcessor_AddCustomDenyAcesProperty_DoesNotEmitWhenEmpty() { + var props = new Dictionary(); + var ace = CreateCommonDenyAce("S-1-1-0", + ActiveDirectoryRights.Delete | ActiveDirectoryRights.DeleteTree); + var processor = CreateCustomDenyAceProcessor(); + + await processor.AddCustomDenyAcesProperty(props, CreateSecurityDescriptorBytes(ace), _testDomainName, + Label.OU, "OU=TEST,DC=TESTLAB,DC=LOCAL"); + + Assert.DoesNotContain("customdenyaces", props.Keys); + } + + 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 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) { + return new CommonAce(AceFlags.None, 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); + } + + private static string SerializeAce(GenericAce ace) { + var acl = new RawAcl(GenericAcl.AclRevisionDS, 1); + acl.InsertAce(0, ace); + var descriptor = new RawSecurityDescriptor(ControlFlags.DiscretionaryAclPresent, null, null, null, acl); + return descriptor.GetSddlForm(AccessControlSections.Access).Substring(2); + } } -} \ No newline at end of file +} diff --git a/test/unit/LdapPropertyTests.cs b/test/unit/LdapPropertyTests.cs index 6fe094509..18ae40118 100644 --- a/test/unit/LdapPropertyTests.cs +++ b/test/unit/LdapPropertyTests.cs @@ -82,7 +82,7 @@ public void LDAPPropertyProcessor_FunctionalLevelToString_TestFunctionalLevels() } [Fact] - public void LDAPPropertyProcessor_ReadGPOProperties_TestGoodData() + public async Task LDAPPropertyProcessor_ReadGPOProperties_TestGoodData() { var mock = new MockDirectoryObject( "CN\u003d{94DD0260-38B5-497E-8876-10E7A96E80D0},CN\u003dPolicies,CN\u003dSystem,DC\u003dtestlab,DC\u003dlocal", @@ -96,7 +96,8 @@ public void LDAPPropertyProcessor_ReadGPOProperties_TestGoodData() {"description", "Test"} }, "S-1-5-21-3130019616-2776909439-2417379446",""); - var test = LdapPropertyProcessor.ReadGPOProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadGPOProperties(mock); Assert.Contains("description", test.Keys); Assert.Equal("Test", test["description"] as string); @@ -106,7 +107,7 @@ public void LDAPPropertyProcessor_ReadGPOProperties_TestGoodData() } [Fact] - public void LDAPPropertyProcessor_ReadOUProperties_TestGoodData() + public async Task LDAPPropertyProcessor_ReadOUProperties_TestGoodData() { var mock = new MockDirectoryObject("OU\u003dTestOU,DC\u003dtestlab,DC\u003dlocal", new Dictionary @@ -114,7 +115,8 @@ public void LDAPPropertyProcessor_ReadOUProperties_TestGoodData() {"description", "Test"} },"", "2A374493-816A-4193-BEFD-D2F4132C6DCA"); - var test = LdapPropertyProcessor.ReadOUProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadOUProperties(mock); Assert.Contains("description", test.Keys); Assert.Equal("Test", test["description"] as string); } @@ -681,7 +683,7 @@ public async Task LDAPPropertyProcessor_ReadComputerProperties_TestDumpSMSAPassw } [Fact] - public void LDAPPropertyProcessor_ReadRootCAProperties() { + public async Task LDAPPropertyProcessor_ReadRootCAProperties() { var ecdsa = ECDsa.Create(); var req = new CertificateRequest("cn=foobar", ecdsa, HashAlgorithmName.SHA256); var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); @@ -699,7 +701,8 @@ public void LDAPPropertyProcessor_ReadRootCAProperties() { {LDAPProperties.CACertificate, bytes} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadRootCAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadRootCAProperties(mock); var keys = test.Keys; //These are not common properties @@ -718,7 +721,7 @@ public void LDAPPropertyProcessor_ReadRootCAProperties() { [Theory] [MemberData(nameof(EmptyCertBytes))] - public void LDAPPropertyProcessor_ReadRootCAProperties_NoCACertificate(byte[] CACertBytes) { + public async Task LDAPPropertyProcessor_ReadRootCAProperties_NoCACertificate(byte[] CACertBytes) { var mock = new MockDirectoryObject( "CN\u003dDUMPSTER-DC01-CA,CN\u003dAIA,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dDUMPSTER,DC\u003dFIRE", new Dictionary @@ -731,7 +734,8 @@ public void LDAPPropertyProcessor_ReadRootCAProperties_NoCACertificate(byte[] CA {LDAPProperties.CACertificate, CACertBytes} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadRootCAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadRootCAProperties(mock); var keys = test.Keys; //These are cert derived properties @@ -745,7 +749,7 @@ public void LDAPPropertyProcessor_ReadRootCAProperties_NoCACertificate(byte[] CA } [Fact] - public void LDAPPropertyProcessor_ReadAIACAProperties() { + public async Task LDAPPropertyProcessor_ReadAIACAProperties() { var ecdsa = ECDsa.Create(); var req = new CertificateRequest("cn=foobar", ecdsa, HashAlgorithmName.SHA256); var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); @@ -764,7 +768,8 @@ public void LDAPPropertyProcessor_ReadAIACAProperties() { {LDAPProperties.CACertificate, bytes} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadAIACAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadAIACAProperties(mock); var keys = test.Keys; //These are not common properties @@ -787,7 +792,7 @@ public void LDAPPropertyProcessor_ReadAIACAProperties() { [Theory] [MemberData(nameof(EmptyCertBytes))] - public void LDAPPropertyProcessor_ReadAIACAProperties_NoCACertificate(byte[] CACertBytes) { + public async Task LDAPPropertyProcessor_ReadAIACAProperties_NoCACertificate(byte[] CACertBytes) { var mock = new MockDirectoryObject( "CN\u003dDUMPSTER-DC01-CA,CN\u003dAIA,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dDUMPSTER,DC\u003dFIRE", new Dictionary @@ -801,7 +806,8 @@ public void LDAPPropertyProcessor_ReadAIACAProperties_NoCACertificate(byte[] CAC {LDAPProperties.CACertificate, CACertBytes} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadAIACAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadAIACAProperties(mock); var keys = test.Keys; //These are cert derived properties @@ -817,7 +823,7 @@ public void LDAPPropertyProcessor_ReadAIACAProperties_NoCACertificate(byte[] CAC } [Fact] - public void LDAPPropertyProcessor_ReadEnterpriseCAProperties() { + public async Task LDAPPropertyProcessor_ReadEnterpriseCAProperties() { var ecdsa = ECDsa.Create(); var req = new CertificateRequest("cn=foobar", ecdsa, HashAlgorithmName.SHA256); var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); @@ -836,7 +842,8 @@ public void LDAPPropertyProcessor_ReadEnterpriseCAProperties() { {"flags", 1} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadEnterpriseCAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadEnterpriseCAProperties(mock); var keys = test.Keys; //These are not common properties @@ -860,7 +867,7 @@ public void LDAPPropertyProcessor_ReadEnterpriseCAProperties() { [Theory] [MemberData(nameof(EmptyCertBytes))] - public void LDAPPropertyProcessor_ReadEnterpriseCAProperties_NoCACertificate(byte[] CACertBytes) { + public async Task LDAPPropertyProcessor_ReadEnterpriseCAProperties_NoCACertificate(byte[] CACertBytes) { var mock = new MockDirectoryObject( "CN\u003dDUMPSTER-DC01-CA,CN\u003dAIA,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dDUMPSTER,DC\u003dFIRE", new Dictionary @@ -874,7 +881,8 @@ public void LDAPPropertyProcessor_ReadEnterpriseCAProperties_NoCACertificate(byt {"flags", 1} }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadEnterpriseCAProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadEnterpriseCAProperties(mock); var keys = test.Keys; //These are cert derived properties @@ -899,7 +907,7 @@ public void LDAPPropertyProcessor_ReadEnterpriseCAProperties_NoCACertificate(byt }; [Fact] - public void LDAPPropertyProcessor_ReadNTAuthStoreProperties() + public async Task LDAPPropertyProcessor_ReadNTAuthStoreProperties() { var mock = new MockDirectoryObject("CN\u003dNTAUTHCERTIFICATES,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dDUMPSTER,DC\u003dFIRE", new Dictionary @@ -911,7 +919,8 @@ public void LDAPPropertyProcessor_ReadNTAuthStoreProperties() {"whencreated", 1683986131}, }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadNTAuthStoreProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadNTAuthStoreProperties(mock); var keys = test.Keys; //These are not common properties @@ -923,7 +932,7 @@ public void LDAPPropertyProcessor_ReadNTAuthStoreProperties() } [Fact] - public void LDAPPropertyProcessor_ReadCertTemplateProperties() + public async Task LDAPPropertyProcessor_ReadCertTemplateProperties() { var mock = new MockDirectoryObject("CN\u003dWORKSTATION,CN\u003dCERTIFICATE TEMPLATES,CN\u003dPUBLIC KEY SERVICES,CN\u003dSERVICES,CN\u003dCONFIGURATION,DC\u003dEXTERNAL,DC\u003dLOCAL", new Dictionary @@ -961,7 +970,8 @@ public void LDAPPropertyProcessor_ReadCertTemplateProperties() {LDAPProperties.PKIPrivateKeyFlag, 256}, }, "","2F9F3630-F46A-49BF-B186-6629994EBCF9"); - var test = LdapPropertyProcessor.ReadCertTemplateProperties(mock); + var processor = new LdapPropertyProcessor(new MockLdapUtils()); + var test = await processor.ReadCertTemplateProperties(mock); var keys = test.Keys; //These are not common properties From 6d2c4ad30b006dddc80a9a734ede435f6f80cf20 Mon Sep 17 00:00:00 2001 From: JonasBK Date: Thu, 23 Apr 2026 13:25:30 +0200 Subject: [PATCH 2/7] disable DocFX by default on non-Windows --- docfx/Docfx.csproj | 1 + 1 file changed, 1 insertion(+) 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 From 4490451cd4fc5b9ff60dd527dac0be5897967677 Mon Sep 17 00:00:00 2001 From: JonasBK Date: Thu, 23 Apr 2026 14:23:40 +0200 Subject: [PATCH 3/7] add SkipDenyAces flag --- src/CommonLib/ILdapUtils.cs | 6 +++- src/CommonLib/LdapConfig.cs | 3 +- src/CommonLib/LdapUtils.cs | 4 +++ .../Processors/LdapPropertyProcessor.cs | 4 +++ test/unit/Facades/MockLdapUtils.cs | 9 +++-- test/unit/LdapPropertyTests.cs | 36 +++++++++++++++++++ 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/CommonLib/ILdapUtils.cs b/src/CommonLib/ILdapUtils.cs index 753e53bd1..56faa21fc 100644 --- a/src/CommonLib/ILdapUtils.cs +++ b/src/CommonLib/ILdapUtils.cs @@ -157,6 +157,10 @@ IAsyncEnumerable> RangedRetrieval(string distinguishedName, /// The new ldap config void SetLdapConfig(LdapConfig config); /// + /// Gets the current ldap config for this utils instance + /// + LdapConfig GetLdapConfig(); + /// /// Tests if a LDAP connection can be made successfully to a domain /// /// The domain to test @@ -175,4 +179,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..81da1541a 100644 --- a/src/CommonLib/LdapConfig.cs +++ b/src/CommonLib/LdapConfig.cs @@ -13,6 +13,7 @@ public class LdapConfig public bool ForceSSL { get; set; } = false; public bool DisableSigning { get; set; } = false; public bool DisableCertVerification { get; set; } = false; + public bool SkipDenyAces { get; set; } = false; public AuthType AuthType { get; set; } = AuthType.Kerberos; public int MaxConcurrentQueries { get; set; } = 15; @@ -53,4 +54,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 98ec52692..6d0ee17ad 100644 --- a/src/CommonLib/LdapUtils.cs +++ b/src/CommonLib/LdapUtils.cs @@ -1077,6 +1077,10 @@ public void SetLdapConfig(LdapConfig config) { _connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner); } + public LdapConfig GetLdapConfig() { + return _ldapConfig; + } + public Task<(bool Success, string Message)> TestLdapConnection(string domain) { return _connectionPool.TestDomainConnection(domain, false); } diff --git a/src/CommonLib/Processors/LdapPropertyProcessor.cs b/src/CommonLib/Processors/LdapPropertyProcessor.cs index a722222d1..2f38fe19f 100644 --- a/src/CommonLib/Processors/LdapPropertyProcessor.cs +++ b/src/CommonLib/Processors/LdapPropertyProcessor.cs @@ -669,6 +669,10 @@ public async Task ReadIssuancePolicyProperties(IDirect private async Task AddCustomDenyAceProperty(Dictionary props, IDirectoryObject entry, string domain, Label objectType) { + if (_utils.GetLdapConfig().SkipDenyAces) { + return; + } + if (!entry.TryGetByteProperty(LDAPProperties.SecurityDescriptor, out var ntSecurityDescriptor)) { return; } diff --git a/test/unit/Facades/MockLdapUtils.cs b/test/unit/Facades/MockLdapUtils.cs index 1e5f1194d..e3b6a56e7 100644 --- a/test/unit/Facades/MockLdapUtils.cs +++ b/test/unit/Facades/MockLdapUtils.cs @@ -20,6 +20,7 @@ namespace CommonLibTest.Facades [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] public class MockLdapUtils : ILdapUtils { + private LdapConfig _ldapConfig = new(); private readonly ConcurrentDictionary _domainControllers = new(); private readonly Forest _forest; private readonly ConcurrentDictionary _seenWellKnownPrincipals = new(); @@ -1007,7 +1008,11 @@ public IAsyncEnumerable GetWellKnownPrincipalOutput() { } public void SetLdapConfig(LdapConfig config) { - throw new NotImplementedException(); + _ldapConfig = config; + } + + public LdapConfig GetLdapConfig() { + return _ldapConfig; } public Task<(bool Success, string Message)> TestLdapConnection(string domain) { @@ -1127,4 +1132,4 @@ public void Dispose() { return (true, "0"); } } -} \ No newline at end of file +} diff --git a/test/unit/LdapPropertyTests.cs b/test/unit/LdapPropertyTests.cs index 18ae40118..7862df516 100644 --- a/test/unit/LdapPropertyTests.cs +++ b/test/unit/LdapPropertyTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.DirectoryServices; using System.Runtime.Versioning; +using System.Security.AccessControl; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Security.Principal; @@ -30,6 +31,20 @@ public LdapPropertyTests(ITestOutputHelper testOutputHelper) _testOutputHelper = testOutputHelper; } + 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; + } + [Fact] public async void LDAPPropertyProcessor_ReadDomainProperties_TestGoodData() { @@ -121,6 +136,27 @@ public async Task LDAPPropertyProcessor_ReadOUProperties_TestGoodData() Assert.Equal("Test", test["description"] as string); } + [Fact] + public async Task LDAPPropertyProcessor_ReadOUProperties_SkipsCustomDenyAces_WhenLdapConfigRequestsIt() + { + var denyAce = new CommonAce(AceFlags.None, AceQualifier.AccessDenied, (int)ActiveDirectoryRights.Delete, + new SecurityIdentifier("S-1-5-21-3130019616-2776909439-2417379446-2500"), false, null); + var mock = new MockDirectoryObject("OU\u003dTestOU,DC\u003dtestlab,DC\u003dlocal", + new Dictionary + { + {LDAPProperties.SecurityDescriptor, CreateSecurityDescriptorBytes(denyAce)} + }, "", "2A374493-816A-4193-BEFD-D2F4132C6DCA"); + var ldapUtils = new MockLdapUtils(); + ldapUtils.SetLdapConfig(new LdapConfig { + SkipDenyAces = true + }); + + var processor = new LdapPropertyProcessor(ldapUtils); + var test = await processor.ReadOUProperties(mock); + + Assert.DoesNotContain(LDAPProperties.CustomDenyAces, test.Keys); + } + [Fact] public async Task LDAPPropertyProcessor_ReadGroupProperties_TestGoodData() { From fd53dac85f7d2f045f5cb1c336e40364752d9a1a Mon Sep 17 00:00:00 2001 From: JonasBK Date: Thu, 23 Apr 2026 14:44:13 +0200 Subject: [PATCH 4/7] mv customdenyaces out of LDAPProperties --- src/CommonLib/Enums/LDAPProperties.cs | 1 - src/CommonLib/Processors/ACLProcessor.cs | 2 +- test/unit/LdapPropertyTests.cs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/CommonLib/Enums/LDAPProperties.cs b/src/CommonLib/Enums/LDAPProperties.cs index 89ba25462..0bf6b726e 100644 --- a/src/CommonLib/Enums/LDAPProperties.cs +++ b/src/CommonLib/Enums/LDAPProperties.cs @@ -96,6 +96,5 @@ public static class LDAPProperties public const string LockOutObservationWindow = "lockoutobservationwindow"; public const string PrincipalName = "msds-principalname"; public const string GroupType = "grouptype"; - public const string CustomDenyAces = "customdenyaces"; } } diff --git a/src/CommonLib/Processors/ACLProcessor.cs b/src/CommonLib/Processors/ACLProcessor.cs index 59a44e5b3..08f9af12b 100644 --- a/src/CommonLib/Processors/ACLProcessor.cs +++ b/src/CommonLib/Processors/ACLProcessor.cs @@ -955,7 +955,7 @@ public async Task AddCustomDenyAcesProperty(Dictionary props, by distinguishedName, isMSA, objectName); if (customDenyAces.Length > 0) { - props[LDAPProperties.CustomDenyAces] = customDenyAces; + props["customdenyaces"] = customDenyAces; } } diff --git a/test/unit/LdapPropertyTests.cs b/test/unit/LdapPropertyTests.cs index 7862df516..5dfaa9d32 100644 --- a/test/unit/LdapPropertyTests.cs +++ b/test/unit/LdapPropertyTests.cs @@ -154,7 +154,7 @@ public async Task LDAPPropertyProcessor_ReadOUProperties_SkipsCustomDenyAces_Whe var processor = new LdapPropertyProcessor(ldapUtils); var test = await processor.ReadOUProperties(mock); - Assert.DoesNotContain(LDAPProperties.CustomDenyAces, test.Keys); + Assert.DoesNotContain("customdenyaces", test.Keys); } [Fact] From 686b7814a820bbb0ea066bc6c157ce59023a3927 Mon Sep 17 00:00:00 2001 From: JonasBK Date: Thu, 23 Apr 2026 18:07:53 +0200 Subject: [PATCH 5/7] coderabbit fixes --- src/CommonLib/ILdapUtils.cs | 4 ++-- src/CommonLib/LdapConfig.cs | 1 + src/CommonLib/LdapUtils.cs | 4 +--- src/CommonLib/Processors/ACLProcessor.cs | 2 +- src/CommonLib/Processors/LdapPropertyProcessor.cs | 2 +- test/unit/Facades/MockLdapUtils.cs | 4 +--- test/unit/LdapPropertyTests.cs | 5 +++++ 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/CommonLib/ILdapUtils.cs b/src/CommonLib/ILdapUtils.cs index 56faa21fc..2fc7a6b32 100644 --- a/src/CommonLib/ILdapUtils.cs +++ b/src/CommonLib/ILdapUtils.cs @@ -157,9 +157,9 @@ IAsyncEnumerable> RangedRetrieval(string distinguishedName, /// The new ldap config void SetLdapConfig(LdapConfig config); /// - /// Gets the current ldap config for this utils instance + /// Gets whether custom deny ACE collection is disabled for this utils instance /// - LdapConfig GetLdapConfig(); + bool SkipDenyAces { get; } /// /// Tests if a LDAP connection can be made successfully to a domain /// diff --git a/src/CommonLib/LdapConfig.cs b/src/CommonLib/LdapConfig.cs index 81da1541a..5242d02b2 100644 --- a/src/CommonLib/LdapConfig.cs +++ b/src/CommonLib/LdapConfig.cs @@ -42,6 +42,7 @@ public override string ToString() { sb.AppendLine($"LdapPort: {GetPort(false)}"); sb.AppendLine($"LdapSSLPort: {GetPort(true)}"); sb.AppendLine($"ForceSSL: {ForceSSL}"); + sb.AppendLine($"SkipDenyAces: {SkipDenyAces}"); sb.AppendLine($"AuthType: {AuthType.ToString()}"); sb.AppendLine($"MaxConcurrentQueries: {MaxConcurrentQueries}"); if (!string.IsNullOrWhiteSpace(Username)) { diff --git a/src/CommonLib/LdapUtils.cs b/src/CommonLib/LdapUtils.cs index 6d0ee17ad..cd416823d 100644 --- a/src/CommonLib/LdapUtils.cs +++ b/src/CommonLib/LdapUtils.cs @@ -1077,9 +1077,7 @@ public void SetLdapConfig(LdapConfig config) { _connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner); } - public LdapConfig GetLdapConfig() { - return _ldapConfig; - } + public bool SkipDenyAces => _ldapConfig.SkipDenyAces; public Task<(bool Success, string Message)> TestLdapConnection(string domain) { return _connectionPool.TestDomainConnection(domain, false); diff --git a/src/CommonLib/Processors/ACLProcessor.cs b/src/CommonLib/Processors/ACLProcessor.cs index 08f9af12b..44edeb9b4 100644 --- a/src/CommonLib/Processors/ACLProcessor.cs +++ b/src/CommonLib/Processors/ACLProcessor.cs @@ -915,7 +915,7 @@ public async Task GetCustomDenyAces(byte[] ntSecurityDescriptor, strin try { descriptor = new RawSecurityDescriptor(ntSecurityDescriptor, 0); } - catch (OverflowException) { + 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); diff --git a/src/CommonLib/Processors/LdapPropertyProcessor.cs b/src/CommonLib/Processors/LdapPropertyProcessor.cs index 2f38fe19f..2d1aac4d1 100644 --- a/src/CommonLib/Processors/LdapPropertyProcessor.cs +++ b/src/CommonLib/Processors/LdapPropertyProcessor.cs @@ -669,7 +669,7 @@ public async Task ReadIssuancePolicyProperties(IDirect private async Task AddCustomDenyAceProperty(Dictionary props, IDirectoryObject entry, string domain, Label objectType) { - if (_utils.GetLdapConfig().SkipDenyAces) { + if (_utils.SkipDenyAces) { return; } diff --git a/test/unit/Facades/MockLdapUtils.cs b/test/unit/Facades/MockLdapUtils.cs index e3b6a56e7..efe0c0518 100644 --- a/test/unit/Facades/MockLdapUtils.cs +++ b/test/unit/Facades/MockLdapUtils.cs @@ -1011,9 +1011,7 @@ public void SetLdapConfig(LdapConfig config) { _ldapConfig = config; } - public LdapConfig GetLdapConfig() { - return _ldapConfig; - } + public bool SkipDenyAces => _ldapConfig.SkipDenyAces; public Task<(bool Success, string Message)> TestLdapConnection(string domain) { throw new NotImplementedException(); diff --git a/test/unit/LdapPropertyTests.cs b/test/unit/LdapPropertyTests.cs index 5dfaa9d32..966a6cd93 100644 --- a/test/unit/LdapPropertyTests.cs +++ b/test/unit/LdapPropertyTests.cs @@ -146,6 +146,11 @@ public async Task LDAPPropertyProcessor_ReadOUProperties_SkipsCustomDenyAces_Whe { {LDAPProperties.SecurityDescriptor, CreateSecurityDescriptorBytes(denyAce)} }, "", "2A374493-816A-4193-BEFD-D2F4132C6DCA"); + + var baselineProcessor = new LdapPropertyProcessor(new MockLdapUtils()); + var baseline = await baselineProcessor.ReadOUProperties(mock); + Assert.Contains("customdenyaces", baseline.Keys); + var ldapUtils = new MockLdapUtils(); ldapUtils.SetLdapConfig(new LdapConfig { SkipDenyAces = true From 8fe085885511b74a271d009fb0f283bc8a93c045 Mon Sep 17 00:00:00 2001 From: JonasBK Date: Thu, 23 Apr 2026 18:15:48 +0200 Subject: [PATCH 6/7] fix Windows only code --- test/unit/LdapPropertyTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/unit/LdapPropertyTests.cs b/test/unit/LdapPropertyTests.cs index 966a6cd93..2f8372f16 100644 --- a/test/unit/LdapPropertyTests.cs +++ b/test/unit/LdapPropertyTests.cs @@ -31,6 +31,7 @@ public LdapPropertyTests(ITestOutputHelper testOutputHelper) _testOutputHelper = testOutputHelper; } + [SupportedOSPlatform("windows")] private static byte[] CreateSecurityDescriptorBytes(params GenericAce[] aces) { var acl = new RawAcl(GenericAcl.AclRevisionDS, aces.Length); @@ -136,7 +137,8 @@ public async Task LDAPPropertyProcessor_ReadOUProperties_TestGoodData() Assert.Equal("Test", test["description"] as string); } - [Fact] + [SupportedOSPlatform("windows")] + [WindowsOnlyFact] public async Task LDAPPropertyProcessor_ReadOUProperties_SkipsCustomDenyAces_WhenLdapConfigRequestsIt() { var denyAce = new CommonAce(AceFlags.None, AceQualifier.AccessDenied, (int)ActiveDirectoryRights.Delete, From 820d80d2ca977fd2031bd3824d5f835c6ea6c70c Mon Sep 17 00:00:00 2001 From: JonasBK Date: Thu, 23 Apr 2026 18:45:04 +0200 Subject: [PATCH 7/7] fix AddCustomDenyAceProperty --- src/CommonLib/Processors/LdapPropertyProcessor.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CommonLib/Processors/LdapPropertyProcessor.cs b/src/CommonLib/Processors/LdapPropertyProcessor.cs index 2d1aac4d1..11e11f84e 100644 --- a/src/CommonLib/Processors/LdapPropertyProcessor.cs +++ b/src/CommonLib/Processors/LdapPropertyProcessor.cs @@ -677,9 +677,12 @@ private async Task AddCustomDenyAceProperty(Dictionary props, ID return; } - entry.TryGetDistinguishedName(out var distinguishedName); + var distinguishedName = entry.TryGetDistinguishedName(out var dn) ? dn : string.Empty; + var objectName = entry.TryGetProperty(LDAPProperties.SAMAccountName, out var samAccountName) + ? samAccountName + : distinguishedName; await _aclProcessor.AddCustomDenyAcesProperty(props, ntSecurityDescriptor, domain, objectType, - distinguishedName, entry.IsMSA() || entry.IsGMSA(), distinguishedName ?? string.Empty); + distinguishedName, entry.IsMSA() || entry.IsGMSA(), objectName); } private static string GetEntryDomain(IDirectoryObject entry) {