From 9eea35767fede01633b947fb9f49265d58556d6b Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Thu, 4 Jun 2026 14:28:47 +0200 Subject: [PATCH 01/17] fix 404 links --- queries/Collection health of CA Registry Data.yml | 2 +- queries/Collection health of DC Registry Data.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/queries/Collection health of CA Registry Data.yml b/queries/Collection health of CA Registry Data.yml index a50e48e..c4af24f 100644 --- a/queries/Collection health of CA Registry Data.yml +++ b/queries/Collection health of CA Registry Data.yml @@ -17,6 +17,6 @@ query: |- AND c.lastlogontimestamp > (datetime().epochseconds - (30 * 86400)) RETURN p revision: 2 -resources: https://bloodhound.specterops.io/collect-data/enterprise-collection/permissions#ca-registry +resources: https://bloodhound.specterops.io/collect-data/sharphound-data-permissions#ca-registry acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Collection health of DC Registry Data.yml b/queries/Collection health of DC Registry Data.yml index 12c3e65..3186341 100644 --- a/queries/Collection health of DC Registry Data.yml +++ b/queries/Collection health of DC Registry Data.yml @@ -12,6 +12,6 @@ query: |- AND c.lastlogontimestamp > (datetime().epochseconds - (30 * 86400)) RETURN p revision: 2 -resources: https://bloodhound.specterops.io/collect-data/enterprise-collection/permissions#dc-registry +resources: https://bloodhound.specterops.io/collect-data/sharphound-data-permissions#dc-registry acknowledgements: Martin Sohn Christensen, @martinsohndk From fb37b6f4ecf364b5f910fb0f3f92f8eb66d90c61 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 9 Jun 2026 10:36:27 +0200 Subject: [PATCH 02/17] Address query bugs Addresses https://github.com/SpecterOps/BloodHoundQueryLibrary/issues/63 https://github.com/SpecterOps/BloodHoundQueryLibrary/issues/62 https://github.com/SpecterOps/BloodHoundQueryLibrary/issues/61 https://github.com/SpecterOps/BloodHoundQueryLibrary/issues/60 https://github.com/SpecterOps/BloodHoundQueryLibrary/issues/59 https://github.com/SpecterOps/BloodHoundQueryLibrary/issues/58 https://github.com/SpecterOps/BloodHoundQueryLibrary/issues/57 --- docs/security-assessment-mapping.json | 6 +++--- .../Domains allowing unauthenticated NSPI RPC binds.yml | 4 ++-- ...allowing unauthenticated rootDSE searches and binds.yml | 4 ++-- ...ng privileged groups from AdminSDHolder protections.yml | 4 ++-- queries/Domains not verifying UPN and SPN uniqueness.yml | 4 ++-- queries/Domains with List Object mode enabled.yml | 4 ++-- ...Users which do not require password to authenticate.yml | 6 ++++-- ...n a trusted domain.yml => Users with logon scripts.yml} | 7 ++----- 8 files changed, 19 insertions(+), 20 deletions(-) rename queries/{Users with logon scripts stored in a trusted domain.yml => Users with logon scripts.yml} (50%) diff --git a/docs/security-assessment-mapping.json b/docs/security-assessment-mapping.json index 98182f9..652c0d5 100644 --- a/docs/security-assessment-mapping.json +++ b/docs/security-assessment-mapping.json @@ -2086,15 +2086,15 @@ { "bloodhound_query": { "guid": "8d94d3f3-3d53-4939-a206-3c0a4dd3f646", - "name": "Users with logon scripts stored in a trusted domain" + "name": "Users with logon scripts" }, "maps_to": [ { "source": "PingCastle", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "partial", + "mapping_scope_detail": "Query finds users with logon scripts no conditions.", "id": "T-ScriptOutOfDomain", "name": "[T]Check if login scripts may be located in a trusted domain" } diff --git a/queries/Domains allowing unauthenticated NSPI RPC binds.yml b/queries/Domains allowing unauthenticated NSPI RPC binds.yml index a41cf05..88c829b 100644 --- a/queries/Domains allowing unauthenticated NSPI RPC binds.yml +++ b/queries/Domains allowing unauthenticated NSPI RPC binds.yml @@ -6,9 +6,9 @@ category: Active Directory Hygiene description: Checks the fAllowAnonNSPI flag of dSHeuristics. query: |- MATCH (n:Domain) - WHERE n.dsheuristics =~ ".{7}[^0].*" + WHERE n.dsheuristics =~ "^.{7}[^0].*" RETURN n -revision: 1 +revision: 2 resources: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/e5899be4-862e-496f-9a38-33950617d2c5 acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Domains allowing unauthenticated rootDSE searches and binds.yml b/queries/Domains allowing unauthenticated rootDSE searches and binds.yml index 1c4281f..507300c 100644 --- a/queries/Domains allowing unauthenticated rootDSE searches and binds.yml +++ b/queries/Domains allowing unauthenticated rootDSE searches and binds.yml @@ -6,9 +6,9 @@ category: Active Directory Hygiene description: Checks the fLDAPBlockAnonOps flag of dSHeuristics. query: |- MATCH (n:Domain) - WHERE n.dsheuristics =~ ".{6}2.*" + WHERE n.dsheuristics =~ "^.{6}2.*" RETURN n -revision: 2 +revision: 3 resources: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/e5899be4-862e-496f-9a38-33950617d2c5 acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Domains exempting privileged groups from AdminSDHolder protections.yml b/queries/Domains exempting privileged groups from AdminSDHolder protections.yml index adff7a2..cb264ef 100644 --- a/queries/Domains exempting privileged groups from AdminSDHolder protections.yml +++ b/queries/Domains exempting privileged groups from AdminSDHolder protections.yml @@ -6,9 +6,9 @@ category: Active Directory Hygiene description: Checks the dwAdminSDExMask flag of dSHeuristics. query: |- MATCH (n:Domain) - WHERE n.dsheuristics =~ ".{15}[^0].*" + WHERE n.dsheuristics =~ "^.{15}[^0].*" RETURN n -revision: 1 +revision: 2 resources: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/e5899be4-862e-496f-9a38-33950617d2c5 acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Domains not verifying UPN and SPN uniqueness.yml b/queries/Domains not verifying UPN and SPN uniqueness.yml index f9dd093..a6a8bfa 100644 --- a/queries/Domains not verifying UPN and SPN uniqueness.yml +++ b/queries/Domains not verifying UPN and SPN uniqueness.yml @@ -6,9 +6,9 @@ category: Active Directory Hygiene description: Checks the DoNotVerifyUPNAndOrSPNUniqueness flag of dSHeuristics. query: |- MATCH (n:Domain) - WHERE n.dsheuristics =~ ".{20}[^0].*" + WHERE n.dsheuristics =~ "^.{20}[^0].*" RETURN n -revision: 1 +revision: 2 resources: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/e5899be4-862e-496f-9a38-33950617d2c5 acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Domains with List Object mode enabled.yml b/queries/Domains with List Object mode enabled.yml index 77f467d..c5eed3c 100644 --- a/queries/Domains with List Object mode enabled.yml +++ b/queries/Domains with List Object mode enabled.yml @@ -6,9 +6,9 @@ category: Domain Information description: Checks the fDoListObject flag of dSHeuristics. query: |- MATCH (n:Domain) - WHERE n.dsheuristics =~ ".{2}[^0].*" + WHERE n.dsheuristics =~ "^.{2}1.*" RETURN n -revision: 1 +revision: 2 resources: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/e5899be4-862e-496f-9a38-33950617d2c5 acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Users which do not require password to authenticate.yml b/queries/Users which do not require password to authenticate.yml index 617f746..1cd4824 100644 --- a/queries/Users which do not require password to authenticate.yml +++ b/queries/Users which do not require password to authenticate.yml @@ -7,9 +7,11 @@ description: query: |- MATCH (u:User) WHERE u.passwordnotreqd = true - RETURN u + AND u.enabled = true + // Exclude interdomain trust accounts via UAC bit (0x0800 = 2048) + AND NOT u.useraccountcontrol % 4096 < 2048 RETURN u LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Users with logon scripts stored in a trusted domain.yml b/queries/Users with logon scripts.yml similarity index 50% rename from queries/Users with logon scripts stored in a trusted domain.yml rename to queries/Users with logon scripts.yml index 77f0fcf..b64a192 100644 --- a/queries/Users with logon scripts stored in a trusted domain.yml +++ b/queries/Users with logon scripts.yml @@ -1,4 +1,4 @@ -name: Users with logon scripts stored in a trusted domain +name: Users with logon scripts guid: 8d94d3f3-3d53-4939-a206-3c0a4dd3f646 prebuilt: false platforms: Active Directory @@ -7,11 +7,8 @@ description: query: |- MATCH (n:User) WHERE n.logonscript IS NOT NULL - MATCH (d:Domain)<-[:SameForestTrust|CrossForestTrust]-(:Domain)-[:Contains*1..]->(n) - WITH n,last(split(d.name, '@')) AS domain - WHERE toUpper(n.logonscript) STARTS WITH ("\\\\" + domain + "\\") RETURN n -revision: 2 +revision: 3 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk From 3d786c9fde67824f1b4368df3b54cb3e29b4ffb4 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Mon, 13 Jul 2026 10:55:34 +0200 Subject: [PATCH 03/17] Add second blog post --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 36d390a..31044c5 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,12 @@ You can also easily bulk import all queries into BloodHound: - UI: Explore -> Cypher -> Import - API: [/api/v2/saved-queries/import](https://bloodhound.specterops.io/reference/cypher/import-one-or-more-cypher-queries) -For an introduction to the project, please read our blog post: +These blog posts give an in-depth introduction to the project: - [Introducing the BloodHound Query Library](https://specterops.io/blog/2025/06/17/introducing-the-bloodhound-query-library/) +- [What’s New in the BloodHound Query Library: BYOL, OpenGraph, Multi-Server, and More](https://specterops.io/blog/2026/04/15/whats-new-in-the-bloodhound-query-library-byol-opengraph-multi-server-and-more/) + + ## Deprecation Notice: `system_tags` Queries From 6235d6499a265b87ba99717d12ebbf73bef0ad17 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Mon, 13 Jul 2026 11:01:38 +0200 Subject: [PATCH 04/17] Create Map domain trusts and forest roots.yml --- .../Map domain trusts and forest roots.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 queries/Map domain trusts and forest roots.yml diff --git a/queries/Map domain trusts and forest roots.yml b/queries/Map domain trusts and forest roots.yml new file mode 100644 index 0000000..f6dadf6 --- /dev/null +++ b/queries/Map domain trusts and forest roots.yml @@ -0,0 +1,20 @@ +name: Map domain trusts and forest roots +guid: 4dabcb63-f521-47ae-ab19-63c728e3d0ec +prebuilt: false +platforms: Active Directory +category: Domain Information +description: Maps domain trusts while identifying each forest root (the domain holding the Configuration container) and highlighting the abusable trust boundaries between forests, where an attacker can traverse from one forest to another via TGT delegation abuse or SID history spoofing. +query: |- + MATCH (config:Container) + WHERE config.distinguishedname STARTS WITH 'CN=CONFIGURATION,DC' + MATCH p=(root:Domain)-[:Contains]->(config) + WHERE root.collected = 'true' + // Optionally also find any other domains in that forest + OPTIONAL MATCH t = (child:Domain)-[:SameForestTrust*..]->(root) + WHERE child.collected = 'true' + MATCH boundary=(:Domain)-[:AbuseTGTDelegation|SpoofSIDHistory]->(:Domain) + RETURN p,t,boundary + LIMIT 1000 +revision: 1 +resources: +acknowledgements: Jim Sykora, @JimSycurity From d3cdf0589726a739a848f5e45308db25d386dfe9 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 08:05:26 +0200 Subject: [PATCH 05/17] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 14ba8c1..b4aed42 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,4 @@ cython_debug/ # PyPI configuration file .pypirc +AGENTS.md From c0198c39a4a90dfe7f83d1ed1c4ec281ad1b94a1 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 08:06:28 +0200 Subject: [PATCH 06/17] Fix Syntax badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 31044c5..49cb02c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ alt="Slack"/> - Syntax check

From 28ca587bcf060cf2dea1ff7ada76694aad7c026f Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 08:41:41 +0200 Subject: [PATCH 07/17] Deprecation of COALESCE() Updates queries to use Tag_Tier_Zero and Tag_Owned labels exclusively, as noticed in the April blog. - Removes legacy system_tags compatibility checks and null handling. - Increments revisions for all affected queries. - Removes the completed deprecation notice from the README. - Updates the README query example. --- README.md | 22 +------------------ ...table Tier Zero users (DontReqPreAuth).yml | 4 ++-- ...d Tier Zero High Value principals - AD.yml | 4 ++-- ...d Tier Zero High Value principals - AZ.yml | 4 ++-- ... by Exchange privilege escalation risk.yml | 4 ++-- ...s with more than 50 Tier Zero accounts.yml | 4 ++-- ...r Zero principals inactive for 60 days.yml | 4 ++-- ... Value principals inactive for 60 days.yml | 4 ++-- ...cipals in Tier Zero High Value targets.yml | 4 ++-- ...members of Tier Zero High Value groups.yml | 4 ++-- queries/Locations of Owned objects - AD.yml | 4 ++-- queries/Locations of Owned objects - AZ.yml | 4 ++-- ...ations of Tier Zero High Value objects.yml | 4 ++-- ...ted groups within Tier Zero High Value.yml | 4 ++-- ...eDCOM privileges on Domain Controllers.yml | 4 ++-- ...r Zero account with 'Admin Count' flag.yml | 4 ++-- ...er Zero account with excessive control.yml | 4 ++-- ... account with unconstrained delegation.yml | 4 ++-- ...with SID History of Tier Zero accounts.yml | 6 ++--- ... resource-based constrained delegation.yml | 4 ++-- ... Zero owners of Tier Zero Entra groups.yml | 6 ++--- ...cessor rights (no prerequisites check).yml | 4 ++-- ...ssor rights (with prerequisites check).yml | 4 ++-- ...link control over Tier Zero containers.yml | 6 ++--- ... with access to enabled gMSA passwords.yml | 8 +++---- ...incipals with control of AdminSDHolder.yml | 4 ++-- ...ero users that can read LAPS passwords.yml | 4 ++-- ... Users to Tier Zero High Value targets.yml | 4 ++-- ...ow Credentials on Tier Zero principals.yml | 4 ++-- ...m Owned Azure Users to Azure Keyvaults.yml | 4 ++-- ...hs from Owned Azure Users to Azure VMs.yml | 4 ++-- ...ations to Tier Zero High Value targets.yml | 4 ++-- ... Users to Tier Zero High Value targets.yml | 4 ++-- ... Users to Tier Zero High Value targets.yml | 4 ++-- ... paths from Owned objects to Tier Zero.yml | 4 ++-- queries/Shortest paths from Owned objects.yml | 4 ++-- ...m non-Tier Zero computers to Tier Zero.yml | 6 ++--- ...from non-Tier Zero groups to Tier Zero.yml | 6 ++--- ...rom non-Tier Zero objects to Tier Zero.yml | 6 ++--- ...n-Tier Zero user accounts to Tier Zero.yml | 6 ++--- ... paths to Tier Zero High Value targets.yml | 4 ++-- ... principals synchronized with Entra ID.yml | 4 ++-- ... Azure roles with more than 10 members.yml | 4 ++-- ...tra ID principals synchronized with AD.yml | 4 ++-- ...ot requiring smart card authentication.yml | 4 ++-- ...ero High Value external Entra ID users.yml | 4 ++-- ...alue users with non-expiring passwords.yml | 4 ++-- ...OU containing Non-Tier Zero principals.yml | 6 ++--- ...Denied RODC Password Replication Group.yml | 6 ++--- ...er Zero accounts that can be delegated.yml | 4 ++-- ...ters at risk of constrained delegation.yml | 4 ++-- ... resource-based constrained delegation.yml | 4 ++-- ...ters not requiring inbound SMB signing.yml | 4 ++-- ... than the default maximum password age.yml | 4 ++-- ...o computers with the WebClient running.yml | 4 ++-- ...ers with unsupported operating systems.yml | 4 ++-- ...ro objects created in the past 10 days.yml | 4 ++-- ...Zero principals not owned by Tier Zero.yml | 6 ++--- ...ipals without AdminSDHolder protection.yml | 4 ++-- ...ro users not member of Protected Users.yml | 4 ++-- queries/Tier Zero users with email.yml | 4 ++-- ...h passwords not rotated in over 1 year.yml | 4 ++-- queries/Uncommon permission on containers.yml | 4 ++-- 63 files changed, 137 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 49cb02c..0f11766 100644 --- a/README.md +++ b/README.md @@ -34,26 +34,6 @@ These blog posts give an in-depth introduction to the project: - [Introducing the BloodHound Query Library](https://specterops.io/blog/2025/06/17/introducing-the-bloodhound-query-library/) - [What’s New in the BloodHound Query Library: BYOL, OpenGraph, Multi-Server, and More](https://specterops.io/blog/2026/04/15/whats-new-in-the-bloodhound-query-library-byol-opengraph-multi-server-and-more/) - - -## Deprecation Notice: `system_tags` Queries - -Queries in the library currently use two methods to scope nodes to Tier Zero and Owned, supporting both old and new versions of BloodHound. At the end of July 2026, all queries will be updated to use the newer simpler method. - -Old versions require scoping with a node property and null handling: - -```cypher -WHERE COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0' -``` - -New versions can use node labels directly: - -```cypher -WHERE (n:Tag_Tier_Zero) -``` - -The simpler label-based approach was introduced with [Privilege Zones](https://specterops.io/privilege-zones/), which became generally available in [v2026.03.23](https://bloodhound.specterops.io/resources/release-notes/2026-03-23). Upgrade your BloodHound version to ensure queries from the library continue to work. - ## Overview The library contains queries that demonstrate BloodHound's versatility beyond traditional attack path analysis. This includes: @@ -126,7 +106,7 @@ $queries[0] | BHInvoke ```powershell Name : Tier Zero / High Value external Entra ID users Query : MATCH (n:AZUser) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.name CONTAINS '#EXT#@' RETURN n LIMIT 100 diff --git a/queries/AS-REP Roastable Tier Zero users (DontReqPreAuth).yml b/queries/AS-REP Roastable Tier Zero users (DontReqPreAuth).yml index bc11168..3ed51fd 100644 --- a/queries/AS-REP Roastable Tier Zero users (DontReqPreAuth).yml +++ b/queries/AS-REP Roastable Tier Zero users (DontReqPreAuth).yml @@ -6,10 +6,10 @@ category: Active Directory Hygiene description: query: |- MATCH (n:Base) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.dontreqpreauth = true RETURN n -revision: 1 +revision: 2 resources: https://attack.mitre.org/techniques/T1558/004/ acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Disabled Tier Zero High Value principals - AD.yml b/queries/Disabled Tier Zero High Value principals - AD.yml index ae835e0..f7e9755 100644 --- a/queries/Disabled Tier Zero High Value principals - AD.yml +++ b/queries/Disabled Tier Zero High Value principals - AD.yml @@ -6,13 +6,13 @@ category: Active Directory Hygiene description: query: |- MATCH (n:Base) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.enabled = false AND NOT n.objectid ENDS WITH '-502' // Removes false positive, KRBTGT AND NOT n.objectid ENDS WITH '-500' // Removes false positive, built-in Administrator RETURN n LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Disabled Tier Zero High Value principals - AZ.yml b/queries/Disabled Tier Zero High Value principals - AZ.yml index 2f81497..8ae1911 100644 --- a/queries/Disabled Tier Zero High Value principals - AZ.yml +++ b/queries/Disabled Tier Zero High Value principals - AZ.yml @@ -6,11 +6,11 @@ category: Azure Hygiene description: query: |- MATCH (n:AZBase) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.enabled = false RETURN n LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Domains affected by Exchange privilege escalation risk.yml b/queries/Domains affected by Exchange privilege escalation risk.yml index 5cd948a..e43ad7c 100644 --- a/queries/Domains affected by Exchange privilege escalation risk.yml +++ b/queries/Domains affected by Exchange privilege escalation risk.yml @@ -7,9 +7,9 @@ description: query: |- MATCH p=(n:Group)-[r:WriteDacl|ForceChangePassword|AddMember]->(m:Base) WHERE n.name STARTS WITH "EXCHANGE " - AND ((m:Tag_Tier_Zero) OR COALESCE(m.system_tags, '') CONTAINS 'admin_tier_0') + AND (m:Tag_Tier_Zero) RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Domains with more than 50 Tier Zero accounts.yml b/queries/Domains with more than 50 Tier Zero accounts.yml index fafb656..c63a283 100644 --- a/queries/Domains with more than 50 Tier Zero accounts.yml +++ b/queries/Domains with more than 50 Tier Zero accounts.yml @@ -6,11 +6,11 @@ category: Active Directory Hygiene description: query: |- MATCH (d:Domain)-[:Contains*1..]->(n:Base) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) WITH d, COUNT(n) AS adminCount WHERE adminCount > 50 RETURN d -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Enabled Entra Tier Zero principals inactive for 60 days.yml b/queries/Enabled Entra Tier Zero principals inactive for 60 days.yml index 2f14e18..514541f 100644 --- a/queries/Enabled Entra Tier Zero principals inactive for 60 days.yml +++ b/queries/Enabled Entra Tier Zero principals inactive for 60 days.yml @@ -6,7 +6,7 @@ category: Azure Hygiene description: Enabled Entra ID Tier Zero principals with no sign-in activity in 60 days, or that have never signed in. Stale privileged accounts should be reviewed and removed to reduce the attack surface. query: |- MATCH (n:AZBase) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.enabled = true AND ( // never signed in AND created more than 60+ days @@ -19,6 +19,6 @@ query: |- OR n.lastsuccessfulsignindatetime < tostring(datetime() - duration('P60D')) ) RETURN n -revision: 1 +revision: 2 resources: https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#4-configure-recurring-access-reviews-to-revoke-unneeded-permissions-over-time acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Enabled Tier Zero High Value principals inactive for 60 days.yml b/queries/Enabled Tier Zero High Value principals inactive for 60 days.yml index f7a4275..7d1ed81 100644 --- a/queries/Enabled Tier Zero High Value principals inactive for 60 days.yml +++ b/queries/Enabled Tier Zero High Value principals inactive for 60 days.yml @@ -7,7 +7,7 @@ description: query: |- WITH 60 as inactive_days MATCH (n:Base) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.enabled = true AND n.lastlogontimestamp < (datetime().epochseconds - (inactive_days * 86400)) // Replicated value AND n.lastlogon < (datetime().epochseconds - (inactive_days * 86400)) // Non-replicated value @@ -16,7 +16,7 @@ query: |- AND NOT n.objectid ENDS WITH '-500' // Removes false positive, built-in Administrator AND NOT n.name STARTS WITH 'AZUREADSSOACC.' // Removes false positive, Entra Seamless SSO RETURN n -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Foreign principals in Tier Zero High Value targets.yml b/queries/Foreign principals in Tier Zero High Value targets.yml index e33cc7a..33a3607 100644 --- a/queries/Foreign principals in Tier Zero High Value targets.yml +++ b/queries/Foreign principals in Tier Zero High Value targets.yml @@ -6,12 +6,12 @@ category: Azure Hygiene description: query: |- MATCH (n:AZServicePrincipal) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND NOT toUpper(n.appownerorganizationid) = toUpper(n.tenantid) AND n.appownerorganizationid CONTAINS '-' RETURN n LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Kerberoastable members of Tier Zero High Value groups.yml b/queries/Kerberoastable members of Tier Zero High Value groups.yml index 3b1f2ac..413c5d3 100644 --- a/queries/Kerberoastable members of Tier Zero High Value groups.yml +++ b/queries/Kerberoastable members of Tier Zero High Value groups.yml @@ -6,14 +6,14 @@ category: Kerberos Interaction description: query: |- MATCH (u:User) - WHERE ((u:Tag_Tier_Zero) OR COALESCE(u.system_tags, '') CONTAINS 'admin_tier_0') AND u.hasspn=true + WHERE (u:Tag_Tier_Zero) AND u.hasspn=true AND u.enabled = true AND NOT u.objectid ENDS WITH '-502' AND NOT COALESCE(u.gmsa, false) = true AND NOT COALESCE(u.msa, false) = true RETURN u LIMIT 100 -revision: 2 +revision: 3 resources: https://attack.mitre.org/techniques/T1558/003/ acknowledgements: diff --git a/queries/Locations of Owned objects - AD.yml b/queries/Locations of Owned objects - AD.yml index 4a12f81..a744c24 100644 --- a/queries/Locations of Owned objects - AD.yml +++ b/queries/Locations of Owned objects - AD.yml @@ -6,10 +6,10 @@ category: Domain Information description: query: |- MATCH p = (t:Base)<-[:Contains*1..]-(:Domain) - WHERE ((t:Tag_Owned) OR COALESCE(t.system_tags, '') CONTAINS 'owned') + WHERE (t:Tag_Owned) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Locations of Owned objects - AZ.yml b/queries/Locations of Owned objects - AZ.yml index b758a9b..334e0b7 100644 --- a/queries/Locations of Owned objects - AZ.yml +++ b/queries/Locations of Owned objects - AZ.yml @@ -6,10 +6,10 @@ category: General description: query: |- MATCH p = (t:AZBase)<-[:AZContains*1..]-(:AZTenant) - WHERE ((t:Tag_Owned) OR COALESCE(t.system_tags, '') CONTAINS 'owned') + WHERE (t:Tag_Owned) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Locations of Tier Zero High Value objects.yml b/queries/Locations of Tier Zero High Value objects.yml index 2b0febb..5cbc2b8 100644 --- a/queries/Locations of Tier Zero High Value objects.yml +++ b/queries/Locations of Tier Zero High Value objects.yml @@ -6,10 +6,10 @@ category: Domain Information description: query: |- MATCH p = (t:Base)<-[:Contains*1..]-(:Domain) - WHERE ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Nested groups within Tier Zero High Value.yml b/queries/Nested groups within Tier Zero High Value.yml index 7e16b7b..1b3f6fc 100644 --- a/queries/Nested groups within Tier Zero High Value.yml +++ b/queries/Nested groups within Tier Zero High Value.yml @@ -6,12 +6,12 @@ category: Active Directory Hygiene description: query: |- MATCH p=(t:Group)<-[:MemberOf*..]-(s:Group) - WHERE ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (t:Tag_Tier_Zero) AND NOT s.objectid ENDS WITH '-512' // Domain Admins AND NOT s.objectid ENDS WITH '-519' // Enterprise Admins RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers.yml b/queries/Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers.yml index 97343fe..5b5ede8 100644 --- a/queries/Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers.yml +++ b/queries/Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers.yml @@ -6,14 +6,14 @@ category: Dangerous Privileges description: query: |- MATCH p1=(n:Base)-[:ExecuteDCOM]->(c:Computer) - WHERE NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE NOT (n:Tag_Tier_Zero) AND c.isdc MATCH p2=(n)-[:MemberOf]->(g:Group) WHERE g.objectid ENDS WITH "S-1-5-32-562" // Distributed COM Users OR g.objectid ENDS WITH "S-1-5-32-559" // Performance Log Users RETURN p1,p2 LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Non-Tier Zero account with 'Admin Count' flag.yml b/queries/Non-Tier Zero account with 'Admin Count' flag.yml index 94121ad..7ec96b7 100644 --- a/queries/Non-Tier Zero account with 'Admin Count' flag.yml +++ b/queries/Non-Tier Zero account with 'Admin Count' flag.yml @@ -8,7 +8,7 @@ query: |- MATCH (n:Base) WHERE (n:User OR n:Computer) AND n.admincount = true - AND NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (n:Tag_Tier_Zero) AND NOT n.objectid ENDS WITH '-502' // KRBTGT user AND NOT n.objectid ENDS WITH '-500' // Administrator user OPTIONAL MATCH (n)-[:MemberOf]->(g:Group) @@ -28,7 +28,7 @@ query: |- WITH n, g WHERE g IS NULL RETURN n -revision: 2 +revision: 3 resources: - https://learn.microsoft.com/en-us/windows/win32/adschema/a-admincount - https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory#protected-groups diff --git a/queries/Non-Tier Zero account with excessive control.yml b/queries/Non-Tier Zero account with excessive control.yml index 533dbff..03ff561 100644 --- a/queries/Non-Tier Zero account with excessive control.yml +++ b/queries/Non-Tier Zero account with excessive control.yml @@ -7,11 +7,11 @@ description: Returns non-Tier Zero principals with >= 1000 direct rights to othe query: |- MATCH (n:Base)-[r:AD_ATTACK_PATHS]->(m:Base) WHERE NOT r:MemberOf - AND NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (n:Tag_Tier_Zero) WITH n, COLLECT(DISTINCT(m)) AS endNodes WHERE SIZE(endNodes) >= 1000 RETURN n -revision: 5 +revision: 6 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero account with unconstrained delegation.yml b/queries/Non-Tier Zero account with unconstrained delegation.yml index 8d4b613..392b8ed 100644 --- a/queries/Non-Tier Zero account with unconstrained delegation.yml +++ b/queries/Non-Tier Zero account with unconstrained delegation.yml @@ -10,11 +10,11 @@ query: |- // The query excludes all Tier Zero objects by default // Exclude only DCs by removing the line below and uncomment the 'NOT n.isdc' line after - AND NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (n:Tag_Tier_Zero) //AND NOT n.isdc = true RETURN n -revision: 3 +revision: 4 resources: acknowledgements: - Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero accounts with SID History of Tier Zero accounts.yml b/queries/Non-Tier Zero accounts with SID History of Tier Zero accounts.yml index 95b9bb9..a7e3fc3 100644 --- a/queries/Non-Tier Zero accounts with SID History of Tier Zero accounts.yml +++ b/queries/Non-Tier Zero accounts with SID History of Tier Zero accounts.yml @@ -6,10 +6,10 @@ category: Dangerous Privileges description: query: |- MATCH p=(n:Base)-[:HasSIDHistory]->(m:Base) - WHERE ((m:Tag_Tier_Zero) OR COALESCE(m.system_tags, '') CONTAINS 'admin_tier_0') - AND NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (m:Tag_Tier_Zero) + AND NOT (n:Tag_Tier_Zero) RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero computers with inbound resource-based constrained delegation.yml b/queries/Non-Tier Zero computers with inbound resource-based constrained delegation.yml index 6669501..8200b15 100644 --- a/queries/Non-Tier Zero computers with inbound resource-based constrained delegation.yml +++ b/queries/Non-Tier Zero computers with inbound resource-based constrained delegation.yml @@ -6,8 +6,8 @@ category: Dangerous Privileges description: Computers outside of Tier Zero where another principal has been granted the ability to impersonate users via resource-based constrained delegation (RBCD). query: |- MATCH p = (m:Base)-[:AllowedToAct]->(n:Computer) - WHERE NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE NOT (n:Tag_Tier_Zero) RETURN p -revision: 1 +revision: 2 resources: https://learn.microsoft.com/en-us/windows-server/security/kerberos/kerberos-constrained-delegation-overview acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero owners of Tier Zero Entra groups.yml b/queries/Non-Tier Zero owners of Tier Zero Entra groups.yml index 40d4b5c..c3f1b7b 100644 --- a/queries/Non-Tier Zero owners of Tier Zero Entra groups.yml +++ b/queries/Non-Tier Zero owners of Tier Zero Entra groups.yml @@ -6,9 +6,9 @@ category: Dangerous Privileges description: query: |- MATCH p=(n:AZBase)-[:AZOwns]->(g:AZGroup) - WHERE NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') - AND ((g:Tag_Tier_Zero) OR COALESCE(g.system_tags, '') CONTAINS 'admin_tier_0') + WHERE NOT (n:Tag_Tier_Zero) + AND (g:Tag_Tier_Zero) RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero principals with BadSuccessor rights (no prerequisites check).yml b/queries/Non-Tier Zero principals with BadSuccessor rights (no prerequisites check).yml index 29039d6..627e352 100644 --- a/queries/Non-Tier Zero principals with BadSuccessor rights (no prerequisites check).yml +++ b/queries/Non-Tier Zero principals with BadSuccessor rights (no prerequisites check).yml @@ -8,9 +8,9 @@ query: |- // Find OU control MATCH p = (ou:OU)<-[:WriteDacl|Owns|GenericAll|WriteOwner]-(n:Base) // Exclude Tier Zero - WHERE NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE NOT (n:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: https://bsky.app/profile/specterops.io/post/3lpua65qeu22l acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero principals with BadSuccessor rights (with prerequisites check).yml b/queries/Non-Tier Zero principals with BadSuccessor rights (with prerequisites check).yml index 438ad2a..c0e6fb0 100644 --- a/queries/Non-Tier Zero principals with BadSuccessor rights (with prerequisites check).yml +++ b/queries/Non-Tier Zero principals with BadSuccessor rights (with prerequisites check).yml @@ -18,9 +18,9 @@ query: |- // Confirm domain KDC key AND ou.domain = m.domain // Exclude Tier Zero - AND NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (n:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: https://bsky.app/profile/specterops.io/post/3lpua65qeu22l acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero principals with GPO link control over Tier Zero containers.yml b/queries/Non-Tier Zero principals with GPO link control over Tier Zero containers.yml index b32a204..dd32d9e 100644 --- a/queries/Non-Tier Zero principals with GPO link control over Tier Zero containers.yml +++ b/queries/Non-Tier Zero principals with GPO link control over Tier Zero containers.yml @@ -7,11 +7,11 @@ description: Non-Tier Zero principals that can link, or gain the ability to link query: |- MATCH p = (n:Base)-[r:WriteGPLink|GenericAll|GenericWrite|WriteDacl|WriteOwner|Owns]->(t:Base) WHERE (t:Domain OR t:OU) - AND ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') - AND NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND (t:Tag_Tier_Zero) + AND NOT (n:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: - https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/best-practices-for-securing-active-directory#avoid-granting-excessive-privileges acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero principals with access to enabled gMSA passwords.yml b/queries/Non-Tier Zero principals with access to enabled gMSA passwords.yml index 7e98917..2016527 100644 --- a/queries/Non-Tier Zero principals with access to enabled gMSA passwords.yml +++ b/queries/Non-Tier Zero principals with access to enabled gMSA passwords.yml @@ -10,17 +10,17 @@ query: |- AND d1.gmsa = true AND d1.enabled = true // Exclude Tier Zero principals - AND NOT ((s1:Tag_Tier_Zero) OR COALESCE(s1.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (s1:Tag_Tier_Zero) MATCH p2 = (s2:Base)-[:MemberOf*1..]->()-[:ReadGMSAPassword|GenericAll|GenericWrite|WriteOwner|WriteDacl]->(d2:Base) WHERE s2<>d2 AND d2.gmsa = true AND d2.enabled = true // Exclude Tier Zero principals - AND NOT ((s2:Tag_Tier_Zero) OR COALESCE(s2.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (s2:Tag_Tier_Zero) RETURN p1,p2 -revision: 2 +revision: 3 resources: - https://learn.microsoft.com/en-us/windows-server/security/group-managed-service-accounts/group-managed-service-accounts-overview acknowledgements: - Martin Sohn Christensen, @martinsohndk -- crusher, @chryzsh \ No newline at end of file +- crusher, @chryzsh diff --git a/queries/Non-Tier Zero principals with control of AdminSDHolder.yml b/queries/Non-Tier Zero principals with control of AdminSDHolder.yml index a31af43..51c3eec 100644 --- a/queries/Non-Tier Zero principals with control of AdminSDHolder.yml +++ b/queries/Non-Tier Zero principals with control of AdminSDHolder.yml @@ -6,10 +6,10 @@ category: Dangerous Privileges description: query: |- MATCH p=(n:Group)-[r:Owns|GenericAll|GenericWrite|WriteOwner|WriteDacl|ForceChangePassword|AllExtendedRights|AddMember|AllowedToDelegate|CoerceToTGT|AllowedToAct|AdminTo|CanPSRemote|CanRDP|ExecuteDCOM|HasSIDHistory|AddSelf|DCSync|ReadLAPSPassword|ReadGMSAPassword|DumpSMSAPassword|SQLAdmin|AddAllowedToAct|WriteSPN|AddKeyCredentialLink|SyncLAPSPassword|WriteAccountRestrictions|WriteOwnerLimitedRights|OwnsLimitedRights]->(m:Container) - WHERE NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE NOT (n:Tag_Tier_Zero) AND m.name STARTS WITH "ADMINSDHOLDER@" RETURN p -revision: 1 +revision: 2 resources: https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory#adminsdholder acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Non-Tier Zero users that can read LAPS passwords.yml b/queries/Non-Tier Zero users that can read LAPS passwords.yml index 9151337..2285f2f 100644 --- a/queries/Non-Tier Zero users that can read LAPS passwords.yml +++ b/queries/Non-Tier Zero users that can read LAPS passwords.yml @@ -8,10 +8,10 @@ query: |- MATCH p=(n:Base)-[:ReadLAPSPassword|AllExtendedRights|GenericAll|SyncLAPSPassword]->(c:Computer) WHERE c.haslaps = true AND c.enabled = true - AND NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (n:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: - https://learn.microsoft.com/en-us/windows-server/identity/laps/laps-overview acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Paths from Domain Users to Tier Zero High Value targets.yml b/queries/Paths from Domain Users to Tier Zero High Value targets.yml index f5ae8bf..3869b0b 100644 --- a/queries/Paths from Domain Users to Tier Zero High Value targets.yml +++ b/queries/Paths from Domain Users to Tier Zero High Value targets.yml @@ -7,10 +7,10 @@ description: query: |- MATCH p=shortestPath((s:Group)-[:AD_ATTACK_PATHS*1..]->(t:Base)) WHERE s.objectid ENDS WITH '-513' AND s<>t - AND ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 2 +revision: 3 resources: acknowledgements: diff --git a/queries/Principals that can write Shadow Credentials on Tier Zero principals.yml b/queries/Principals that can write Shadow Credentials on Tier Zero principals.yml index c149000..0783d1a 100644 --- a/queries/Principals that can write Shadow Credentials on Tier Zero principals.yml +++ b/queries/Principals that can write Shadow Credentials on Tier Zero principals.yml @@ -7,9 +7,9 @@ description: Principals with the ability to write to the 'msds-KeyCredentialLink query: |- MATCH p=(n:Base)-[:AddKeyCredentialLink]->(m:Base) WHERE (n:User or n:Computer) - AND ((m:Tag_Tier_Zero) OR COALESCE(m.system_tags, '') CONTAINS 'admin_tier_0') + AND (m:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: https://specterops.io/blog/2021/06/17/shadow-credentials-abusing-key-trust-account-mapping-for-account-takeover/ acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Shortest Paths from Owned Azure Users to Azure Keyvaults.yml b/queries/Shortest Paths from Owned Azure Users to Azure Keyvaults.yml index 773ed08..0f6a0f8 100644 --- a/queries/Shortest Paths from Owned Azure Users to Azure Keyvaults.yml +++ b/queries/Shortest Paths from Owned Azure Users to Azure Keyvaults.yml @@ -7,9 +7,9 @@ category: Shortest Paths description: Return shortest paths from Owned Azure Users to Azure Keyvaults to check for attack vectors. query: |- MATCH p = shortestPath((n:AZUser)-[:AZ_ATTACK_PATHS*..]->(g:AZKeyVault)) - WHERE ((n:Tag_Owned) OR COALESCE(n.system_tags, '') CONTAINS 'owned') + WHERE (n:Tag_Owned) RETURN p -revision: 2 +revision: 3 resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/Shortest Paths from Owned Azure Users to Azure VMs.yml b/queries/Shortest Paths from Owned Azure Users to Azure VMs.yml index e1bb37e..ac92504 100644 --- a/queries/Shortest Paths from Owned Azure Users to Azure VMs.yml +++ b/queries/Shortest Paths from Owned Azure Users to Azure VMs.yml @@ -7,9 +7,9 @@ category: Shortest Paths description: Return shortest paths from Owned Azure Users to Azure VMs to check for attack vectors. query: |- MATCH p = shortestPath((m:AZUser)-[:AZ_ATTACK_PATHS*..]->(n:AZVM)) - WHERE ((m:Tag_Owned) OR COALESCE(m.system_tags, '') CONTAINS 'owned') + WHERE (m:Tag_Owned) RETURN p -revision: 2 +revision: 3 resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/Shortest paths from Azure Applications to Tier Zero High Value targets.yml b/queries/Shortest paths from Azure Applications to Tier Zero High Value targets.yml index 79d3312..ec3da9b 100644 --- a/queries/Shortest paths from Azure Applications to Tier Zero High Value targets.yml +++ b/queries/Shortest paths from Azure Applications to Tier Zero High Value targets.yml @@ -6,10 +6,10 @@ category: Shortest Paths description: WARNING! MANY-TO-MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE query: |- MATCH p=shortestPath((s:AZApp)-[:AZ_ATTACK_PATHS*1..]->(t:AZBase)) - WHERE ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') AND s<>t + WHERE (t:Tag_Tier_Zero) AND s<>t RETURN p LIMIT 1000 -revision: 3 +revision: 4 resources: acknowledgements: diff --git a/queries/Shortest paths from Domain Users to Tier Zero High Value targets.yml b/queries/Shortest paths from Domain Users to Tier Zero High Value targets.yml index d7610a5..b11e553 100644 --- a/queries/Shortest paths from Domain Users to Tier Zero High Value targets.yml +++ b/queries/Shortest paths from Domain Users to Tier Zero High Value targets.yml @@ -7,10 +7,10 @@ description: query: |- MATCH p=shortestPath((s:Group)-[:AD_ATTACK_PATHS*1..]->(t:Base)) WHERE s.objectid ENDS WITH '-513' AND s<>t - AND ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 2 +revision: 3 resources: acknowledgements: diff --git a/queries/Shortest paths from Entra Users to Tier Zero High Value targets.yml b/queries/Shortest paths from Entra Users to Tier Zero High Value targets.yml index 3f1aae4..3fe39aa 100644 --- a/queries/Shortest paths from Entra Users to Tier Zero High Value targets.yml +++ b/queries/Shortest paths from Entra Users to Tier Zero High Value targets.yml @@ -6,10 +6,10 @@ category: Shortest Paths description: WARNING! MANY-TO-MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE query: |- MATCH p=shortestPath((s:AZUser)-[:AZ_ATTACK_PATHS*1..]->(t:AZBase)) - WHERE ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 3 +revision: 4 resources: acknowledgements: diff --git a/queries/Shortest paths from Owned objects to Tier Zero.yml b/queries/Shortest paths from Owned objects to Tier Zero.yml index e92b152..434f8f6 100644 --- a/queries/Shortest paths from Owned objects to Tier Zero.yml +++ b/queries/Shortest paths from Owned objects to Tier Zero.yml @@ -8,10 +8,10 @@ query: |- // MANY TO MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE MATCH p=shortestPath((s:Tag_Owned)-[:AD_ATTACK_PATHS*1..]->(t:Base)) WHERE s<>t - AND ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 4 +revision: 5 resources: acknowledgements: diff --git a/queries/Shortest paths from Owned objects.yml b/queries/Shortest paths from Owned objects.yml index e6183e8..28c310b 100644 --- a/queries/Shortest paths from Owned objects.yml +++ b/queries/Shortest paths from Owned objects.yml @@ -6,11 +6,11 @@ category: Shortest Paths description: query: |- MATCH p=shortestPath((s:Base)-[:AD_ATTACK_PATHS*1..]->(t:Base)) - WHERE ((s:Tag_Owned) OR COALESCE(s.system_tags, '') CONTAINS 'owned') + WHERE (s:Tag_Owned) AND s<>t RETURN p LIMIT 1000 -revision: 3 +revision: 4 resources: acknowledgements: diff --git a/queries/Shortest paths from non-Tier Zero computers to Tier Zero.yml b/queries/Shortest paths from non-Tier Zero computers to Tier Zero.yml index 30c54f8..21b0bc9 100644 --- a/queries/Shortest paths from non-Tier Zero computers to Tier Zero.yml +++ b/queries/Shortest paths from non-Tier Zero computers to Tier Zero.yml @@ -8,10 +8,10 @@ query: |- // MANY TO MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE MATCH p=shortestPath((s:Computer)-[:AD_ATTACK_PATHS*1..]->(t:Base)) WHERE s<>t - AND NOT ((s:Tag_Tier_Zero) OR COALESCE(s.system_tags, '') CONTAINS 'admin_tier_0') - AND ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (s:Tag_Tier_Zero) + AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: crusher, @chryzsh diff --git a/queries/Shortest paths from non-Tier Zero groups to Tier Zero.yml b/queries/Shortest paths from non-Tier Zero groups to Tier Zero.yml index 18505bb..1b89818 100644 --- a/queries/Shortest paths from non-Tier Zero groups to Tier Zero.yml +++ b/queries/Shortest paths from non-Tier Zero groups to Tier Zero.yml @@ -8,10 +8,10 @@ query: |- // MANY TO MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE MATCH p=shortestPath((s:Group)-[:AD_ATTACK_PATHS*1..]->(t:Base)) WHERE s<>t - AND NOT ((s:Tag_Tier_Zero) OR COALESCE(s.system_tags, '') CONTAINS 'admin_tier_0') - AND ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (s:Tag_Tier_Zero) + AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: crusher, @chryzsh diff --git a/queries/Shortest paths from non-Tier Zero objects to Tier Zero.yml b/queries/Shortest paths from non-Tier Zero objects to Tier Zero.yml index 501e7f7..54ed86a 100644 --- a/queries/Shortest paths from non-Tier Zero objects to Tier Zero.yml +++ b/queries/Shortest paths from non-Tier Zero objects to Tier Zero.yml @@ -8,10 +8,10 @@ query: |- // MANY TO MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE MATCH p=shortestPath((s:Base)-[:AD_ATTACK_PATHS*1..]->(t:Base)) WHERE s<>t - AND NOT ((s:Tag_Tier_Zero) OR COALESCE(s.system_tags, '') CONTAINS 'admin_tier_0') - AND ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (s:Tag_Tier_Zero) + AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: crusher, @chryzsh diff --git a/queries/Shortest paths from non-Tier Zero user accounts to Tier Zero.yml b/queries/Shortest paths from non-Tier Zero user accounts to Tier Zero.yml index 397a843..31fc0fe 100644 --- a/queries/Shortest paths from non-Tier Zero user accounts to Tier Zero.yml +++ b/queries/Shortest paths from non-Tier Zero user accounts to Tier Zero.yml @@ -8,10 +8,10 @@ query: |- // MANY TO MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE MATCH p=shortestPath((s:User)-[:AD_ATTACK_PATHS*1..]->(t:Base)) WHERE s<>t - AND NOT ((s:Tag_Tier_Zero) OR COALESCE(s.system_tags, '') CONTAINS 'admin_tier_0') - AND ((t:Tag_Tier_Zero) OR COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0') + AND NOT (s:Tag_Tier_Zero) + AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: crusher, @chryzsh diff --git a/queries/Shortest paths to Tier Zero High Value targets.yml b/queries/Shortest paths to Tier Zero High Value targets.yml index 50fb685..b8a2bd6 100644 --- a/queries/Shortest paths to Tier Zero High Value targets.yml +++ b/queries/Shortest paths to Tier Zero High Value targets.yml @@ -6,11 +6,11 @@ category: Shortest Paths description: query: |- MATCH p=shortestPath((s)-[:AD_ATTACK_PATHS*1..]->(t:Base)) - WHERE ((t:Tag_Tier_Zero) OR (COALESCE(t.system_tags, '') CONTAINS 'admin_tier_0')) + WHERE (t:Tag_Tier_Zero) AND s<>t RETURN p LIMIT 1000 -revision: 3 +revision: 4 resources: acknowledgements: diff --git a/queries/Tier Zero AD principals synchronized with Entra ID.yml b/queries/Tier Zero AD principals synchronized with Entra ID.yml index d5c426f..402fcf0 100644 --- a/queries/Tier Zero AD principals synchronized with Entra ID.yml +++ b/queries/Tier Zero AD principals synchronized with Entra ID.yml @@ -7,13 +7,13 @@ description: query: |- MATCH (ENTRA:AZBase) MATCH (AD:Base) - WHERE ((AD:Tag_Tier_Zero) OR COALESCE(AD.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (AD:Tag_Tier_Zero) AND ENTRA.onpremsyncenabled = true AND ENTRA.onpremid = AD.objectid RETURN ENTRA // Replace 'RETURN ENTRA' with 'RETURN AD' to see the corresponding AD principals LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Tier Zero Azure roles with more than 10 members.yml b/queries/Tier Zero Azure roles with more than 10 members.yml index c5f777c..db205dd 100644 --- a/queries/Tier Zero Azure roles with more than 10 members.yml +++ b/queries/Tier Zero Azure roles with more than 10 members.yml @@ -6,10 +6,10 @@ category: Azure Hygiene description: Entra ID Tier Zero roles individually holding more than 10 assigned principals, indicating over-provisioning of highly privileged role membership. query: |- MATCH (m:AZBase)-[:AZHasRole|AZRoleEligible]->(r:AZRole) - WHERE (r:Tag_Tier_Zero) OR COALESCE(r.system_tags, '') CONTAINS 'admin_tier_0' // Tier Zero roles + WHERE (r:Tag_Tier_Zero) // Tier Zero roles WITH r, COUNT(m) AS memberCount WHERE memberCount > 10 // more than 10 members assigned to a single Tier Zero role RETURN r -revision: 1 +revision: 2 resources: https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#6-limit-the-number-of-privileged-role-assignments-to-less-than-10 acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero Entra ID principals synchronized with AD.yml b/queries/Tier Zero Entra ID principals synchronized with AD.yml index 8f382a1..396ccb8 100644 --- a/queries/Tier Zero Entra ID principals synchronized with AD.yml +++ b/queries/Tier Zero Entra ID principals synchronized with AD.yml @@ -8,10 +8,10 @@ category: Cross Platform Attack Paths description: query: |- MATCH p=(ad:Base)-[:SyncedToEntraUser]->(entra:AZBase) - WHERE ((entra:Tag_Tier_Zero) OR COALESCE(entra.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (entra:Tag_Tier_Zero) RETURN p LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero High Value enabled users not requiring smart card authentication.yml b/queries/Tier Zero High Value enabled users not requiring smart card authentication.yml index 9e69169..4b52853 100644 --- a/queries/Tier Zero High Value enabled users not requiring smart card authentication.yml +++ b/queries/Tier Zero High Value enabled users not requiring smart card authentication.yml @@ -6,14 +6,14 @@ category: Active Directory Hygiene description: query: |- MATCH (u:User) - WHERE ((u:Tag_Tier_Zero) OR COALESCE(u.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (u:Tag_Tier_Zero) AND u.enabled = true AND u.smartcardrequired = false AND NOT u.name STARTS WITH 'MSOL_' // Removes false positive, Entra sync AND NOT u.name STARTS WITH 'PROVAGENTGMSA' // Removes false positive, Entra sync AND NOT u.name STARTS WITH 'ADSYNCMSA_' // Removes false positive, Entra sync RETURN u -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Tier Zero High Value external Entra ID users.yml b/queries/Tier Zero High Value external Entra ID users.yml index 06e73af..8c2fe0a 100644 --- a/queries/Tier Zero High Value external Entra ID users.yml +++ b/queries/Tier Zero High Value external Entra ID users.yml @@ -6,11 +6,11 @@ category: Azure Hygiene description: query: |- MATCH (n:AZUser) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.name CONTAINS '#EXT#@' RETURN n LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Tier Zero High Value users with non-expiring passwords.yml b/queries/Tier Zero High Value users with non-expiring passwords.yml index 663ab98..16e457a 100644 --- a/queries/Tier Zero High Value users with non-expiring passwords.yml +++ b/queries/Tier Zero High Value users with non-expiring passwords.yml @@ -6,11 +6,11 @@ category: Active Directory Hygiene description: query: |- MATCH (u:User) - WHERE ((u:Tag_Tier_Zero) OR COALESCE(u.system_tags, '') CONTAINS 'admin_tier_0') AND u.enabled = true + WHERE (u:Tag_Tier_Zero) AND u.enabled = true AND u.pwdneverexpires = true RETURN u LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Tier Zero OU containing Non-Tier Zero principals.yml b/queries/Tier Zero OU containing Non-Tier Zero principals.yml index f592864..96b5358 100644 --- a/queries/Tier Zero OU containing Non-Tier Zero principals.yml +++ b/queries/Tier Zero OU containing Non-Tier Zero principals.yml @@ -7,10 +7,10 @@ description: Identifies Tier Zero OUs that contain Non-Tier Zero principals. Org query: |- MATCH p = (ou:OU)-[:Contains]->(n:Base) WHERE (n:User OR n:Group OR n:Computer) - AND (COALESCE(ou.system_tags, '') CONTAINS 'admin_tier_0' OR ou:Tag_Tier_Zero) - AND NOT (COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0' OR n:Tag_Tier_Zero) + AND (ou:Tag_Tier_Zero) + AND NOT (n:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero accounts not members of Denied RODC Password Replication Group.yml b/queries/Tier Zero accounts not members of Denied RODC Password Replication Group.yml index 9f9e0bb..1edadba 100644 --- a/queries/Tier Zero accounts not members of Denied RODC Password Replication Group.yml +++ b/queries/Tier Zero accounts not members of Denied RODC Password Replication Group.yml @@ -7,20 +7,20 @@ description: query: |- // Get all Tier Zero accounts that are members of Denied RODC Password Replication Group MATCH (n:Base)-[:MemberOf*1..]->(m:Group) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND (n:User OR n:Computer) AND m.objectid ENDS WITH '-519' WITH COLLECT(n.objectid) AS MembersOfDeniedGroup // Get all Tier Zero accounts MATCH (x:Base) - WHERE ((x:Tag_Tier_Zero) OR COALESCE(x.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (x:Tag_Tier_Zero) AND (x:User OR x:Computer) // Filter the members of Denied RODC Password Replication Group AND NOT x.objectid IN MembersOfDeniedGroup RETURN x -revision: 2 +revision: 3 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero accounts that can be delegated.yml b/queries/Tier Zero accounts that can be delegated.yml index e0aa1dc..027fdc5 100644 --- a/queries/Tier Zero accounts that can be delegated.yml +++ b/queries/Tier Zero accounts that can be delegated.yml @@ -6,7 +6,7 @@ category: Kerberos Interaction description: Delegation protection is achieved by either enabling "Account is sensitive and cannot be delegated" or making it a member of the Protected Users security group. query: |- MATCH (m:Base) - WHERE ((m:Tag_Tier_Zero) OR COALESCE(m.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (m:Tag_Tier_Zero) AND m.enabled = true AND m.sensitive = false OPTIONAL MATCH (g:Group)<-[:MemberOf*1..]-(n:Base) @@ -14,7 +14,7 @@ query: |- WITH m, COLLECT(n) AS matchingNs WHERE NONE(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m -revision: 2 +revision: 3 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero computers at risk of constrained delegation.yml b/queries/Tier Zero computers at risk of constrained delegation.yml index e7b9644..ba663c4 100644 --- a/queries/Tier Zero computers at risk of constrained delegation.yml +++ b/queries/Tier Zero computers at risk of constrained delegation.yml @@ -6,9 +6,9 @@ category: Dangerous Privileges description: query: |- MATCH p = (n:Computer)<-[:AllowedToDelegate]-(:Base) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero computers at risk of resource-based constrained delegation.yml b/queries/Tier Zero computers at risk of resource-based constrained delegation.yml index 68101d8..53aa5a0 100644 --- a/queries/Tier Zero computers at risk of resource-based constrained delegation.yml +++ b/queries/Tier Zero computers at risk of resource-based constrained delegation.yml @@ -6,9 +6,9 @@ category: Dangerous Privileges description: query: |- MATCH p = (n:Computer)<-[:AllowedToAct]-(:Base) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero computers not requiring inbound SMB signing.yml b/queries/Tier Zero computers not requiring inbound SMB signing.yml index f5041ad..4073497 100644 --- a/queries/Tier Zero computers not requiring inbound SMB signing.yml +++ b/queries/Tier Zero computers not requiring inbound SMB signing.yml @@ -7,9 +7,9 @@ description: query: |- MATCH (n:Computer) WHERE n.smbsigning = False - AND ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND (n:Tag_Tier_Zero) RETURN n -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero computers with passwords older than the default maximum password age.yml b/queries/Tier Zero computers with passwords older than the default maximum password age.yml index bd48f67..cfe145d 100644 --- a/queries/Tier Zero computers with passwords older than the default maximum password age.yml +++ b/queries/Tier Zero computers with passwords older than the default maximum password age.yml @@ -9,9 +9,9 @@ query: |- WHERE n.enabled = true AND n.whencreated < (datetime().epochseconds - (60 * 3 * 86400)) AND n.pwdlastset < (datetime().epochseconds - (60 * 3 * 86400)) - AND ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND (n:Tag_Tier_Zero) RETURN n -revision: 2 +revision: 3 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero computers with the WebClient running.yml b/queries/Tier Zero computers with the WebClient running.yml index 816baf1..bb14a5c 100644 --- a/queries/Tier Zero computers with the WebClient running.yml +++ b/queries/Tier Zero computers with the WebClient running.yml @@ -7,9 +7,9 @@ description: query: |- MATCH (c:Computer) WHERE c.webclientrunning = True - AND ((c:Tag_Tier_Zero) OR COALESCE(c.system_tags, '') CONTAINS 'admin_tier_0') + AND (c:Tag_Tier_Zero) RETURN c LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero computers with unsupported operating systems.yml b/queries/Tier Zero computers with unsupported operating systems.yml index 665a6ba..2392112 100644 --- a/queries/Tier Zero computers with unsupported operating systems.yml +++ b/queries/Tier Zero computers with unsupported operating systems.yml @@ -7,10 +7,10 @@ description: query: |- MATCH (c:Computer) WHERE c.operatingsystem =~ '(?i).*Windows.* (2000|2003|2008|2012|xp|vista|7|8|me|nt).*' - AND ((c:Tag_Tier_Zero) OR COALESCE(c.system_tags, '') CONTAINS 'admin_tier_0') + AND (c:Tag_Tier_Zero) RETURN c LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero objects created in the past 10 days.yml b/queries/Tier Zero objects created in the past 10 days.yml index ed12482..9b37b27 100644 --- a/queries/Tier Zero objects created in the past 10 days.yml +++ b/queries/Tier Zero objects created in the past 10 days.yml @@ -7,10 +7,10 @@ description: query: |- MATCH (n:Base) WHERE n.whencreated > (datetime().epochseconds - (10 * 86400)) - AND ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + AND (n:Tag_Tier_Zero) RETURN n LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero principals not owned by Tier Zero.yml b/queries/Tier Zero principals not owned by Tier Zero.yml index b45a400..3ca6cb1 100644 --- a/queries/Tier Zero principals not owned by Tier Zero.yml +++ b/queries/Tier Zero principals not owned by Tier Zero.yml @@ -6,10 +6,10 @@ category: Dangerous Privileges description: query: |- MATCH p=(n:Base)-[:OwnsRaw|Owns|OwnsLimitedRights]->(m:Base) - WHERE NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') - AND ((m:Tag_Tier_Zero) OR COALESCE(m.system_tags, '') CONTAINS 'admin_tier_0') + WHERE NOT (n:Tag_Tier_Zero) + AND (m:Tag_Tier_Zero) RETURN p -revision: 3 +revision: 4 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero principals without AdminSDHolder protection.yml b/queries/Tier Zero principals without AdminSDHolder protection.yml index 7f49533..b9fc168 100644 --- a/queries/Tier Zero principals without AdminSDHolder protection.yml +++ b/queries/Tier Zero principals without AdminSDHolder protection.yml @@ -6,10 +6,10 @@ category: Active Directory Hygiene description: query: |- MATCH (n:Base) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.adminsdholderprotected = false RETURN n LIMIT 500 -revision: 1 +revision: 2 resources: acknowledgements: diff --git a/queries/Tier Zero users not member of Protected Users.yml b/queries/Tier Zero users not member of Protected Users.yml index 2f05a72..6bb7d69 100644 --- a/queries/Tier Zero users not member of Protected Users.yml +++ b/queries/Tier Zero users not member of Protected Users.yml @@ -6,13 +6,13 @@ category: Active Directory Hygiene description: query: |- MATCH (m:User) - WHERE ((m:Tag_Tier_Zero) OR COALESCE(m.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (m:Tag_Tier_Zero) OPTIONAL MATCH (g:Group)<-[:MemberOf*1..]-(n:Base) WHERE g.objectid ENDS WITH '-525' WITH m, COLLECT(n) AS matchingNs WHERE NONE(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero users with email.yml b/queries/Tier Zero users with email.yml index 8d3d336..6964689 100644 --- a/queries/Tier Zero users with email.yml +++ b/queries/Tier Zero users with email.yml @@ -6,7 +6,7 @@ category: Active Directory Hygiene description: Tier Zero accounts with email access have an increased attack surface. query: |- MATCH (n) - WHERE ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (n:Tag_Tier_Zero) AND n.email <> "" AND n.enabled = true AND NOT toUpper(n.email) ENDS WITH ".ONMICROSOFT.COM" @@ -23,7 +23,7 @@ query: |- OR n.name STARTS WITH "HEALTHMAILBOX") ) RETURN n -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Tier Zero users with passwords not rotated in over 1 year.yml b/queries/Tier Zero users with passwords not rotated in over 1 year.yml index c82c40f..7229076 100644 --- a/queries/Tier Zero users with passwords not rotated in over 1 year.yml +++ b/queries/Tier Zero users with passwords not rotated in over 1 year.yml @@ -7,12 +7,12 @@ description: query: |- WITH 365 as days_since_change MATCH (u:User) - WHERE ((u:Tag_Tier_Zero) OR COALESCE(u.system_tags, '') CONTAINS 'admin_tier_0') + WHERE (u:Tag_Tier_Zero) AND u.pwdlastset < (datetime().epochseconds - (days_since_change * 86400)) AND NOT u.pwdlastset IN [-1.0, 0.0] RETURN u LIMIT 100 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Uncommon permission on containers.yml b/queries/Uncommon permission on containers.yml index 01069e2..49bd341 100644 --- a/queries/Uncommon permission on containers.yml +++ b/queries/Uncommon permission on containers.yml @@ -8,7 +8,7 @@ query: |- MATCH p=(:Domain)-[:Contains*1..]->(c:Container)<-[r]-(n:Base) // Exclude Tier Zero - WHERE NOT ((n:Tag_Tier_Zero) OR COALESCE(n.system_tags, '') CONTAINS 'admin_tier_0') + WHERE NOT (n:Tag_Tier_Zero) // Scope edges to ACLs AND r.isacl @@ -43,7 +43,7 @@ query: |- RETURN p LIMIT 2000 -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk From aa3b81d326584b9b531d240afdc220b285a3b36d Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 12:45:04 +0200 Subject: [PATCH 08/17] Remove dulipcate query --- queries/CA administrators and CA managers.yml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 queries/CA administrators and CA managers.yml diff --git a/queries/CA administrators and CA managers.yml b/queries/CA administrators and CA managers.yml deleted file mode 100644 index 318e59b..0000000 --- a/queries/CA administrators and CA managers.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: CA administrators and CA managers -guid: fd35e3d8-0c74-4b5a-a847-c0dd1f1c9f19 -prebuilt: true -platforms: Active Directory -category: Active Directory Certificate Services -description: -query: |- - MATCH p = (:Base)-[:ManageCertificates|ManageCA]->(:EnterpriseCA) - RETURN p - LIMIT 1000 -revision: 1 -resources: -acknowledgements: - From 56966f65fe10a14ba1e61e24867e5812a9f661b5 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 12:57:24 +0200 Subject: [PATCH 09/17] Remove duplicate query --- ...e CA with User Specified SAN enabled (ESC6).yml | 14 -------------- ...terprise CA with User Specified SAN enabled.yml | 4 ++-- 2 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 queries/Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled (ESC6).yml diff --git a/queries/Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled (ESC6).yml b/queries/Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled (ESC6).yml deleted file mode 100644 index 3fadf6f..0000000 --- a/queries/Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled (ESC6).yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled (ESC6) -guid: ab14e9dc-996c-4737-878c-583c19cdbf5a -prebuilt: true -platforms: Active Directory -category: Active Directory Certificate Services -description: -query: |- - MATCH p = (:Base)-[:Enroll|GenericAll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(eca:EnterpriseCA) - WHERE eca.isuserspecifiessanenabled = True - RETURN p - LIMIT 1000 -revision: 1 -resources: -acknowledgements: diff --git a/queries/Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled.yml b/queries/Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled.yml index 90f0056..2181075 100644 --- a/queries/Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled.yml +++ b/queries/Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled.yml @@ -1,4 +1,4 @@ -name: Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled +name: Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled (ESC6) guid: 96e70597-2d74-4503-a624-f1e30b642894 prebuilt: true platforms: Active Directory @@ -9,7 +9,7 @@ query: |- WHERE eca.isuserspecifiessanenabled = True RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: acknowledgements: From f975c545812351e667e7166695d83992f765dc8c Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 12:57:46 +0200 Subject: [PATCH 10/17] Set prebuilt:false --- queries/Potential GPO 'Apply' misconfiguration.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/queries/Potential GPO 'Apply' misconfiguration.yml b/queries/Potential GPO 'Apply' misconfiguration.yml index 80a2b33..b9d7344 100644 --- a/queries/Potential GPO 'Apply' misconfiguration.yml +++ b/queries/Potential GPO 'Apply' misconfiguration.yml @@ -1,6 +1,6 @@ name: Potential GPO 'Apply' misconfiguration guid: f5f2455e-afdc-4708-9a34-98f539ce52d8 -prebuilt: true +prebuilt: false platforms: Active Directory category: Dangerous Privileges description: In Active Directory, GPO's are applied to objects in the Group Policy Management Console by ticking "Allow - Apply group policy", but administrators can mistakenly tick "Allow - Write" or "Allow - Full Control" resulting in a misconfigured GPO that allows a principal to compromise other principals the GPO also applies to. Results are potential risks and should be verified. @@ -19,7 +19,7 @@ query: |- RETURN p LIMIT 1000 -revision: 3 +revision: 4 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk From 18b9c1a9e68810c8af17937599f78f2110501e2a Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 12:57:50 +0200 Subject: [PATCH 11/17] Set prebuilt:false --- ...pals with ExecuteDCOM privileges on Domain Controllers.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/queries/Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers.yml b/queries/Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers.yml index 5b5ede8..073e223 100644 --- a/queries/Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers.yml +++ b/queries/Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers.yml @@ -1,6 +1,6 @@ name: Non-Tier Zero Principals with ExecuteDCOM privileges on Domain Controllers guid: 2aeefd57-cbe7-4f0e-907f-86a97d7b723c -prebuilt: true +prebuilt: false platforms: Active Directory category: Dangerous Privileges description: @@ -13,7 +13,7 @@ query: |- OR g.objectid ENDS WITH "S-1-5-32-559" // Performance Log Users RETURN p1,p2 LIMIT 1000 -revision: 2 +revision: 3 resources: acknowledgements: From c509ab0eaa2820086890902821eb011725f76f2b Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 12:58:53 +0200 Subject: [PATCH 12/17] set prebuilt:false --- queries/Accounts with weak password storage encryption.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/queries/Accounts with weak password storage encryption.yml b/queries/Accounts with weak password storage encryption.yml index 5571cdf..cf54afc 100644 --- a/queries/Accounts with weak password storage encryption.yml +++ b/queries/Accounts with weak password storage encryption.yml @@ -1,6 +1,6 @@ name: Accounts with weak password storage encryption guid: 8bd6fcf2-3f3c-414c-857a-4caf28e49def -prebuilt: true +prebuilt: false platforms: Active Directory category: Active Directory Hygiene description: Accounts with passwords set before the existence of Windows Server 2008 Domain Controller which therefore lack AES encryption keys. @@ -9,7 +9,7 @@ query: |- WHERE n.pwdlastset < 1204070400 // Password Last Set before Windows Server 2008 release RETURN n LIMIT 100 -revision: 2 resources: https://techcommunity.microsoft.com/blog/coreinfrastructureandsecurityblog/decrypting-the-selection-of-supported-kerberos-encryption-types/1628797 +revision: 3 acknowledgements: Martin Sohn Christensen, @martinsohndk From 8a92317f3ad0c34bec552cd2b4792d95079b8b33 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 13:04:53 +0200 Subject: [PATCH 13/17] Split and retarget Kerberos AES encryption queries Accounts with weak password storage encryption: - Exclude the pwdlastset sentinels -1 and 0 from the password age check, which previously matched every account flagged for password change at next logon - Add a branch for accounts whose password was never reset since creation, comparing whencreated against the Windows Server 2008 release date - Note in the description that the release date is the earliest date AES keys can exist in any domain, not the per-domain threshold - Add KB5073381 (CVE-2026-20833) and the RC4 detection guide as resources Kerberos-enabled service accounts permitting RC4 encryption (renamed from "Kerberos-enabled service accounts without AES encryption support"): - Retarget at accounts with RC4 explicitly permitted in msDS-SupportedEncryptionTypes. Since the April 2026 enforcement of CVE-2026-20833 the KDC no longer assumes RC4 support for accounts without the attribute set, so a missing value no longer indicates RC4 usage - Remove the no-op filter comparing supportedencryptiontypes against ['Not defined'] OR [], which was true for every account - Remove the password age branch, now covered by the other query alone - Add resources, previously empty security-assessment-mapping.json: - Update name, change PingCastle, MDI and PurpleKnight mappings from exact/superset to partial, as those tools flag accounts missing AES while the query now flags explicitly permitted RC4 --- docs/security-assessment-mapping.json | 14 +++++------ ... with weak password storage encryption.yml | 22 ++++++++++++++---- ...ice accounts permitting RC4 encryption.yml | 18 +++++++++++++++ ...ccounts without AES encryption support.yml | 23 ------------------- 4 files changed, 42 insertions(+), 35 deletions(-) create mode 100644 queries/Kerberos-enabled service accounts permitting RC4 encryption.yml delete mode 100644 queries/Kerberos-enabled service accounts without AES encryption support.yml diff --git a/docs/security-assessment-mapping.json b/docs/security-assessment-mapping.json index d4941c1..d08345c 100644 --- a/docs/security-assessment-mapping.json +++ b/docs/security-assessment-mapping.json @@ -744,15 +744,15 @@ { "bloodhound_query": { "guid": "cb8cf96e-21c9-422b-9439-390a13446ca6", - "name": "Kerberos-enabled service accounts without AES encryption support" + "name": "Kerberos-enabled service accounts permitting RC4 encryption" }, "maps_to": [ { "source": "PingCastle", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "partial", + "mapping_scope_detail": "PC flags service accounts not supporting AES. Following the RC4 disablement enforcement of CVE-2026-20833, the Key Distribution Center no longer assumes RC4 support for accounts without a msDS-SupportedEncryptionTypes value, so the BH query instead flags accounts where RC4 is explicitly permitted.", "id": "S-AesNotEnabled", "name": "[T]Check the use of Kerberos on services accounts without AES support" } @@ -762,8 +762,8 @@ "source": "MDI", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "partial", + "mapping_scope_detail": "MDI flags accounts not supporting AES. Following the RC4 disablement enforcement of CVE-2026-20833, the Key Distribution Center no longer assumes RC4 support for accounts without a msDS-SupportedEncryptionTypes value, so the BH query instead flags accounts where RC4 is explicitly permitted.", "id": "Unsecure account attributes: Enable Kerberos AES encryption support", "name": "Unsecure account attributes: Enable Kerberos AES encryption support" } @@ -773,8 +773,8 @@ "source": "PurpleKnight", "controls": [ { - "mapping_scope": "superset", - "mapping_scope_detail": "BH query matches all base nodes with SPNs (users and computers), while PK targets only user accounts. BH also catches accounts with passwords predating Windows Server 2008 which PK does not.", + "mapping_scope": "partial", + "mapping_scope_detail": "BH query matches all base nodes with SPNs (users and computers), while PK targets only user accounts. PK flags accounts not supporting AES, while the BH query flags accounts where RC4 is explicitly permitted following the RC4 disablement enforcement of CVE-2026-20833.", "id": "b608276e-3849-419d-bc34-6e5a362b3e79", "name": "Primary users with SPN not supporting AES encryption on Kerberos" } diff --git a/queries/Accounts with weak password storage encryption.yml b/queries/Accounts with weak password storage encryption.yml index cf54afc..3f7f543 100644 --- a/queries/Accounts with weak password storage encryption.yml +++ b/queries/Accounts with weak password storage encryption.yml @@ -3,13 +3,25 @@ guid: 8bd6fcf2-3f3c-414c-857a-4caf28e49def prebuilt: false platforms: Active Directory category: Active Directory Hygiene -description: Accounts with passwords set before the existence of Windows Server 2008 Domain Controller which therefore lack AES encryption keys. +description: Accounts whose password was set before the existence of Windows Server 2008 Domain Controllers, either explicitly or at account creation, and which therefore lack the AES encryption keys required for Kerberos authentication after RC4 disablement enforcement. The Windows Server 2008 release date is the earliest date AES keys can exist in any domain, so accounts in domains that promoted their first AES-capable Domain Controller later are not returned. The creation date of the Read-only Domain Controllers group provides the exact per-domain threshold. query: |- MATCH (n:Base) - WHERE n.pwdlastset < 1204070400 // Password Last Set before Windows Server 2008 release + // Password was set before AES keys could be generated + WHERE ( + n.pwdlastset < 1204070400 // Windows Server 2008 release date + AND NOT n.pwdlastset IN [-1.0, 0.0] + ) + // Password was never reset since creation, and creation predates AES support + OR ( + n.pwdlastset IN [-1.0, 0.0] + AND n.whencreated < 1204070400 // Windows Server 2008 release date + AND NOT n.whencreated IN [-1.0, 0.0] + ) RETURN n LIMIT 100 -resources: https://techcommunity.microsoft.com/blog/coreinfrastructureandsecurityblog/decrypting-the-selection-of-supported-kerberos-encryption-types/1628797 -revision: 3 +revision: 4 +resources: +- https://techcommunity.microsoft.com/blog/coreinfrastructureandsecurityblog/decrypting-the-selection-of-supported-kerberos-encryption-types/1628797 +- https://learn.microsoft.com/en-us/windows-server/security/kerberos/detect-remediate-rc4-kerberos +- https://support.microsoft.com/en-us/topic/how-to-manage-kerberos-kdc-usage-of-rc4-for-service-account-ticket-issuance-changes-related-to-cve-2026-20833-1ebcda33-720a-4da8-93c1-b0496e1910dc acknowledgements: Martin Sohn Christensen, @martinsohndk - diff --git a/queries/Kerberos-enabled service accounts permitting RC4 encryption.yml b/queries/Kerberos-enabled service accounts permitting RC4 encryption.yml new file mode 100644 index 0000000..d49a1b2 --- /dev/null +++ b/queries/Kerberos-enabled service accounts permitting RC4 encryption.yml @@ -0,0 +1,18 @@ +name: Kerberos-enabled service accounts permitting RC4 encryption +guid: cb8cf96e-21c9-422b-9439-390a13446ca6 +prebuilt: false +platforms: Active Directory +category: Active Directory Hygiene +description: Service accounts with RC4 explicitly permitted in msDS-SupportedEncryptionTypes, which are therefore still issued RC4-encrypted service tickets after RC4 disablement enforcement and remain exposed to offline password recovery through Kerberoasting. +query: |- + MATCH (n:Base) + WHERE n.hasspn = true + AND ANY(encryption_type IN n.supportedencryptiontypes WHERE encryption_type CONTAINS 'RC4') // RC4 explicitly permitted + RETURN n + LIMIT 100 +revision: 3 +resources: +- https://support.microsoft.com/en-us/topic/how-to-manage-kerberos-kdc-usage-of-rc4-for-service-account-ticket-issuance-changes-related-to-cve-2026-20833-1ebcda33-720a-4da8-93c1-b0496e1910dc +- https://learn.microsoft.com/en-us/windows-server/security/kerberos/detect-remediate-rc4-kerberos +- https://techcommunity.microsoft.com/blog/coreinfrastructureandsecurityblog/decrypting-the-selection-of-supported-kerberos-encryption-types/1628797 +acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Kerberos-enabled service accounts without AES encryption support.yml b/queries/Kerberos-enabled service accounts without AES encryption support.yml deleted file mode 100644 index b408c66..0000000 --- a/queries/Kerberos-enabled service accounts without AES encryption support.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Kerberos-enabled service accounts without AES encryption support -guid: cb8cf96e-21c9-422b-9439-390a13446ca6 -prebuilt: false -platforms: Active Directory -category: Active Directory Hygiene -description: Accounts without Kerberos AES encryption support, or passwords set before the existence of Windows Server 2008 Domain Controller which therefore lack AES encryption keys. -query: |- - MATCH (n:Base) - WHERE n.hasspn = true - AND (( - n.supportedencryptiontypes <> ['Not defined'] - OR n.supportedencryptiontypes <> [] - OR NONE(type IN n.supportedencryptiontypes WHERE type CONTAINS 'AES128' OR type CONTAINS 'AES256') - ) - OR (n.pwdlastset < 1204070400 // Password Last Set before Windows Server 2008 - AND NOT n.pwdlastset IN [-1.0, 0.0] - )) - RETURN n - LIMIT 100 -revision: 2 -resources: -acknowledgements: Martin Sohn Christensen, @martinsohndk - From 4271aa9ef08711d9c30b101177965d18604ca942 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 13:14:58 +0200 Subject: [PATCH 14/17] Align prebuilt queries with BloodHound --- queries/All members of high privileged roles.yml | 4 ++-- ...s from Domain Users to Tier Zero High Value targets.yml | 5 ++--- ...s from Domain Users to Tier Zero High Value targets.yml | 5 ++--- ...hs from Entra Users to Tier Zero High Value targets.yml | 4 ++-- queries/Shortest paths from Owned objects to Tier Zero.yml | 5 ++--- queries/Shortest paths to Tier Zero High Value targets.yml | 7 +++---- queries/Shortest paths to privileged roles.yml | 4 ++-- 7 files changed, 15 insertions(+), 19 deletions(-) diff --git a/queries/All members of high privileged roles.yml b/queries/All members of high privileged roles.yml index 189167a..fd34bb4 100644 --- a/queries/All members of high privileged roles.yml +++ b/queries/All members of high privileged roles.yml @@ -6,10 +6,10 @@ category: General description: query: |- MATCH p=(t:AZRole)<-[:AZHasRole|AZMemberOf*1..2]-(:AZBase) - WHERE t.name =~ '(?i)Global Administrator|User Administrator|Cloud Application Administrator|Authentication Policy Administrator|Exchange Administrator|Helpdesk Administrator|Privileged Authentication Administrator|Privileged Role Administrator' + WHERE t.name =~ '(?i)^(Global Administrator|User Administrator|Cloud Application Administrator|Authentication Policy Administrator|Exchange Administrator|Helpdesk Administrator|Privileged Authentication Administrator|Privileged Role Administrator).*$' RETURN p LIMIT 1000 -revision: 2 +revision: 3 resources: acknowledgements: diff --git a/queries/Paths from Domain Users to Tier Zero High Value targets.yml b/queries/Paths from Domain Users to Tier Zero High Value targets.yml index 3869b0b..e504206 100644 --- a/queries/Paths from Domain Users to Tier Zero High Value targets.yml +++ b/queries/Paths from Domain Users to Tier Zero High Value targets.yml @@ -5,12 +5,11 @@ platforms: Active Directory category: Dangerous Privileges description: query: |- - MATCH p=shortestPath((s:Group)-[:AD_ATTACK_PATHS*1..]->(t:Base)) + MATCH p=shortestPath((s:Group)-[:AD_ATTACK_PATHS*1..]->(t:Tag_Tier_Zero)) WHERE s.objectid ENDS WITH '-513' AND s<>t - AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 3 +revision: 4 resources: acknowledgements: diff --git a/queries/Shortest paths from Domain Users to Tier Zero High Value targets.yml b/queries/Shortest paths from Domain Users to Tier Zero High Value targets.yml index b11e553..7b05b70 100644 --- a/queries/Shortest paths from Domain Users to Tier Zero High Value targets.yml +++ b/queries/Shortest paths from Domain Users to Tier Zero High Value targets.yml @@ -5,12 +5,11 @@ platforms: Active Directory category: Shortest Paths description: query: |- - MATCH p=shortestPath((s:Group)-[:AD_ATTACK_PATHS*1..]->(t:Base)) + MATCH p=shortestPath((s:Group)-[:AD_ATTACK_PATHS*1..]->(t:Tag_Tier_Zero)) WHERE s.objectid ENDS WITH '-513' AND s<>t - AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 3 +revision: 4 resources: acknowledgements: diff --git a/queries/Shortest paths from Entra Users to Tier Zero High Value targets.yml b/queries/Shortest paths from Entra Users to Tier Zero High Value targets.yml index 3fe39aa..1913220 100644 --- a/queries/Shortest paths from Entra Users to Tier Zero High Value targets.yml +++ b/queries/Shortest paths from Entra Users to Tier Zero High Value targets.yml @@ -6,10 +6,10 @@ category: Shortest Paths description: WARNING! MANY-TO-MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE query: |- MATCH p=shortestPath((s:AZUser)-[:AZ_ATTACK_PATHS*1..]->(t:AZBase)) - WHERE (t:Tag_Tier_Zero) + WHERE (t:Tag_Tier_Zero) AND s<>t RETURN p LIMIT 1000 -revision: 4 +revision: 5 resources: acknowledgements: diff --git a/queries/Shortest paths from Owned objects to Tier Zero.yml b/queries/Shortest paths from Owned objects to Tier Zero.yml index 434f8f6..1a3562e 100644 --- a/queries/Shortest paths from Owned objects to Tier Zero.yml +++ b/queries/Shortest paths from Owned objects to Tier Zero.yml @@ -6,12 +6,11 @@ category: Shortest Paths description: WARNING! MANY-TO-MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE query: |- // MANY TO MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE - MATCH p=shortestPath((s:Tag_Owned)-[:AD_ATTACK_PATHS*1..]->(t:Base)) + MATCH p=shortestPath((s:Tag_Owned)-[:AD_ATTACK_PATHS*1..]->(t:Tag_Tier_Zero)) WHERE s<>t - AND (t:Tag_Tier_Zero) RETURN p LIMIT 1000 -revision: 5 +revision: 6 resources: acknowledgements: diff --git a/queries/Shortest paths to Tier Zero High Value targets.yml b/queries/Shortest paths to Tier Zero High Value targets.yml index b8a2bd6..9b3c17e 100644 --- a/queries/Shortest paths to Tier Zero High Value targets.yml +++ b/queries/Shortest paths to Tier Zero High Value targets.yml @@ -5,12 +5,11 @@ platforms: Active Directory category: Shortest Paths description: query: |- - MATCH p=shortestPath((s)-[:AD_ATTACK_PATHS*1..]->(t:Base)) - WHERE (t:Tag_Tier_Zero) - AND s<>t + MATCH p=shortestPath((s)-[:AD_ATTACK_PATHS*1..]->(t:Tag_Tier_Zero)) + WHERE s<>t RETURN p LIMIT 1000 -revision: 4 +revision: 5 resources: acknowledgements: diff --git a/queries/Shortest paths to privileged roles.yml b/queries/Shortest paths to privileged roles.yml index 5397c05..705f679 100644 --- a/queries/Shortest paths to privileged roles.yml +++ b/queries/Shortest paths to privileged roles.yml @@ -6,10 +6,10 @@ category: Shortest Paths description: WARNING! MANY-TO-MANY SHORTEST PATH QUERIES USE EXCESSIVE SYSTEM RESOURCES AND TYPICALLY WILL NOT COMPLETE query: |- MATCH p=shortestPath((s:AZBase)-[:AZ_ATTACK_PATHS*1..]->(t:AZRole)) - WHERE t.name =~ '(?i)Global Administrator|User Administrator|Cloud Application Administrator|Authentication Policy Administrator|Exchange Administrator|Helpdesk Administrator|Privileged Authentication Administrator|Privileged Role Administrator' AND s<>t + WHERE t.name =~ '(?i)^(Global Administrator|User Administrator|Cloud Application Administrator|Authentication Policy Administrator|Exchange Administrator|Helpdesk Administrator|Privileged Authentication Administrator|Privileged Role Administrator).*$' AND s<>t RETURN p LIMIT 1000 -revision: 3 +revision: 4 resources: acknowledgements: From a7fab16bc8466b739f12efd740e9d697b8292184 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 13:25:25 +0200 Subject: [PATCH 15/17] Add BloodHound Prebuilt Queries audit utility --- tests/test_compare_prebuilt.py | 534 +++++++++++++++++++ utilities/python/compare_prebuilt.py | 736 +++++++++++++++++++++++++++ 2 files changed, 1270 insertions(+) create mode 100644 tests/test_compare_prebuilt.py create mode 100644 utilities/python/compare_prebuilt.py diff --git a/tests/test_compare_prebuilt.py b/tests/test_compare_prebuilt.py new file mode 100644 index 0000000..e8dc90b --- /dev/null +++ b/tests/test_compare_prebuilt.py @@ -0,0 +1,534 @@ +from pathlib import Path +import sys + +import pytest +import yaml + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPOSITORY_ROOT / "utilities" / "python")) + +import compare_prebuilt # noqa: E402 +from compare_prebuilt import ( # noqa: E402 + AuditError, + AuditResult, + CATALOG_RELATIVE_PATH, + CatalogParseError, + QueryIdentity, + audit_repositories, + parse_catalog, + render_result, +) + + +def write_constants(source_directory: Path) -> None: + source_directory.mkdir(parents=True, exist_ok=True) + (source_directory / "constants.ts").write_text( + "\n".join( + ( + "export const OWNED_OBJECT_TAG = 'owned';", + "export const TIER_ZERO_TAG = 'admin_tier_0';", + "export const TAG_TIER_ZERO_AGT = 'Tag_Tier_Zero';", + "export const TAG_OWNED_AGT = 'Tag_Owned';", + ) + ), + encoding="utf-8", + ) + + +def write_catalog( + repository: Path, + variant: str, + groups: list[tuple[str, str, list[tuple[str, str]]]], + uncommon_query: tuple[str, str] | None = None, +) -> Path: + source_directory = repository / CATALOG_RELATIVE_PATH + write_constants(source_directory) + + lines = [ + "const categoryAD = 'Active Directory';", + "const categoryAzure = 'Azure';", + "const highPrivilegedRoleDisplayNameRegex =", + " '^(Global Administrator|User Administrator).*$';", + "export const CommonSearches: CommonSearchType[] = [", + ] + for platform, category, queries in groups: + category_symbol = ( + "categoryAD" if platform == "Active Directory" else "categoryAzure" + ) + lines.extend( + ( + " {", + f" subheader: '{category}',", + f" category: {category_symbol},", + " queries: [", + ) + ) + for name, query in queries: + lines.extend( + ( + " {", + f" name: '{name}',", + " description: '',", + f" query: `{query}` ,".replace("` ,", "`,"), + " },", + ) + ) + lines.extend((" ],", " },")) + lines.append("];") + + if uncommon_query is not None: + name, query = uncommon_query + lines.extend( + ( + "export const UncommonSearches: CommonSearchType[] = [", + " {", + " subheader: 'Browser Limit Test',", + " category: categoryAD,", + " queries: [", + " {", + f" name: '{name}',", + " description: '',", + f" query: `{query}` ,".replace("` ,", "`,"), + " },", + " ],", + " },", + "];", + ) + ) + + path = source_directory / f"commonSearches{variant}.ts" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def write_library_query( + query_directory: Path, + filename: str, + name: str, + platforms: str | list[str], + category: str, + query: str, + prebuilt: bool = True, +) -> Path: + query_directory.mkdir(parents=True, exist_ok=True) + path = query_directory / filename + path.write_text( + yaml.safe_dump( + { + "name": name, + "guid": f"guid-{filename}", + "prebuilt": prebuilt, + "platforms": platforms, + "category": category, + "description": "Library-only metadata", + "query": query, + "revision": 1, + "resources": None, + "acknowledgements": None, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + return path + + +def test_parse_catalog_normalizes_interpolations_and_excludes_uncommon( + tmp_path: Path, +) -> None: + repository = tmp_path / "BloodHound" + query = ( + "MATCH (n:${TAG_TIER_ZERO_AGT})" + "-[:${adTransitEdgeTypes}|${azureTransitEdgeTypes}]->" + "(o:${TAG_OWNED_AGT})\\n" + "WHERE n.name =~ '${highPrivilegedRoleDisplayNameRegex}'" + ) + path = write_catalog( + repository, + "AGT", + [("Active Directory", "Domain Information", [("Map trusts", query)])], + uncommon_query=("Query Parse Error", "not valid cypher"), + ) + + parsed = parse_catalog(path, "AGT") + + assert len(parsed) == 1 + assert parsed[0].query == ( + "MATCH (n:Tag_Tier_Zero)-[:AD_ATTACK_PATHS|AZ_ATTACK_PATHS]->" + "(o:Tag_Owned)\n" + "WHERE n.name =~ '^(Global Administrator|User Administrator).*$'" + ) + + +def test_parse_catalog_supports_multiline_templates_and_agi_constants( + tmp_path: Path, +) -> None: + repository = tmp_path / "BloodHound" + path = write_catalog( + repository, + "AGI", + [ + ( + "Active Directory", + "Domain Information", + [ + ( + "Locations of privileged objects", + "MATCH (n)\n" + "WHERE COALESCE(n.system_tags, '') CONTAINS " + "'${TIER_ZERO_TAG}'\n" + "AND COALESCE(n.system_tags, '') CONTAINS " + "'${OWNED_OBJECT_TAG}'\n" + "RETURN n", + ) + ], + ) + ], + ) + + parsed = parse_catalog(path, "AGI") + + assert parsed[0].query.endswith( + "CONTAINS 'admin_tier_0'\n" + "AND COALESCE(n.system_tags, '') CONTAINS 'owned'\nRETURN n" + ) + + +def test_audit_matches_either_variant_and_ignores_filename(tmp_path: Path) -> None: + repository = tmp_path / "BloodHound" + query_directory = tmp_path / "queries" + groups_agi = [ + ( + "Active Directory", + "Domain Information", + [ + ("Map domain trusts", "RETURN 'shared'\n"), + ("Variant query", "RETURN 'legacy'"), + ("Legacy only", "RETURN 'legacy only'"), + ], + ) + ] + groups_agt = [ + ( + "Active Directory", + "Domain Information", + [ + ("Map domain trusts", "RETURN 'shared'\n"), + ("Variant query", "RETURN 'current'"), + ("Current only", "RETURN 'current only'"), + ], + ) + ] + write_catalog(repository, "AGI", groups_agi) + write_catalog(repository, "AGT", groups_agt) + write_library_query( + query_directory, + "filename-does-not-match.yml", + "Map domain trusts", + "Active Directory", + "Domain Information", + "RETURN 'shared'", + ) + write_library_query( + query_directory, + "variant.yml", + "Variant query", + "Active Directory", + "Domain Information", + "RETURN 'current'", + ) + write_library_query( + query_directory, + "legacy.yml", + "Legacy only", + "Active Directory", + "Domain Information", + "RETURN 'legacy only'", + ) + write_library_query( + query_directory, + "current.yml", + "Current only", + "Active Directory", + "Domain Information", + "RETURN 'current only'", + ) + + result = audit_repositories(repository, query_directory) + matches = { + comparison.identity.name: comparison.matching_variants + for comparison in result.comparisons + } + + assert not result.has_drift + assert matches == { + "Current only": ("AGT",), + "Legacy only": ("AGI",), + "Map domain trusts": ("AGI", "AGT"), + "Variant query": ("AGT",), + } + implementation_statuses = { + comparison.identity.name: comparison.implementation_status + for comparison in result.comparisons + } + assert implementation_statuses == { + "Current only": "AGT only", + "Legacy only": "AGI only", + "Map domain trusts": "identical AGI/AGT", + "Variant query": "distinct AGI/AGT", + } + + +def test_platform_suffixes_disambiguate_duplicate_product_names( + tmp_path: Path, +) -> None: + repository = tmp_path / "BloodHound" + query_directory = tmp_path / "queries" + groups = [ + ( + "Active Directory", + "Hygiene", + [("Disabled privileged principals", "RETURN 'AD'")], + ), + ( + "Azure", + "Hygiene", + [("Disabled privileged principals", "RETURN 'AZ'")], + ), + ] + write_catalog(repository, "AGI", groups) + write_catalog(repository, "AGT", groups) + write_library_query( + query_directory, + "disabled-ad.yml", + "Disabled privileged principals (AD)", + "Active Directory", + "Hygiene", + "RETURN 'AD'", + ) + write_library_query( + query_directory, + "disabled-az.yml", + "Disabled privileged principals (AZ)", + "Azure", + "Hygiene", + "RETURN 'AZ'", + ) + + result = audit_repositories(repository, query_directory) + + assert not result.has_drift + assert result.body_match_count == 2 + + +def test_multiplatform_library_query_matches_product_platform(tmp_path: Path) -> None: + repository = tmp_path / "BloodHound" + query_directory = tmp_path / "queries" + groups = [ + ( + "Azure", + "Cross Platform Attack Paths", + [("Cross-platform query", "RETURN 1")], + ) + ] + write_catalog(repository, "AGI", groups) + write_catalog(repository, "AGT", groups) + write_library_query( + query_directory, + "cross-platform.yml", + "Cross-platform query", + ["Active Directory", "Azure"], + "Cross Platform Attack Paths", + "RETURN 1", + ) + + result = audit_repositories(repository, query_directory) + + assert not result.has_drift + assert result.body_match_count == 1 + + +def test_audit_reports_inventory_and_body_drift(tmp_path: Path) -> None: + repository = tmp_path / "BloodHound" + query_directory = tmp_path / "queries" + groups = [ + ( + "Active Directory", + "Domain Information", + [("Different body", "RETURN 'BloodHound'"), ("Product only", "RETURN 2")], + ) + ] + write_catalog(repository, "AGI", groups) + write_catalog(repository, "AGT", groups) + write_library_query( + query_directory, + "different.yml", + "Different body", + "Active Directory", + "Domain Information", + "RETURN 'Library'", + ) + write_library_query( + query_directory, + "library-only.yml", + "Library only", + "Active Directory", + "Domain Information", + "RETURN 3", + ) + + result = audit_repositories(repository, query_directory) + rendered = render_result(result) + + assert result.has_drift + assert result.bloodhound_only == ( + QueryIdentity("Active Directory", "Domain Information", "Product only"), + ) + assert [query.name for query in result.library_only] == ["Library only"] + assert "[matches: neither]" in rendered + assert "--- BloodHound (AGI+AGT)" in rendered + assert "+RETURN 'Library'" in rendered + + +def test_render_diffs_each_distinct_product_variant(tmp_path: Path) -> None: + repository = tmp_path / "BloodHound" + query_directory = tmp_path / "queries" + write_catalog( + repository, + "AGI", + [ + ( + "Active Directory", + "Domain Information", + [("Variant body", "RETURN 'AGI'")], + ) + ], + ) + write_catalog( + repository, + "AGT", + [ + ( + "Active Directory", + "Domain Information", + [("Variant body", "RETURN 'AGT'")], + ) + ], + ) + write_library_query( + query_directory, + "variant-body.yml", + "Variant body", + "Active Directory", + "Domain Information", + "RETURN 'Library'", + ) + + rendered = render_result(audit_repositories(repository, query_directory)) + + assert "--- BloodHound (AGI)" in rendered + assert "--- BloodHound (AGT)" in rendered + assert rendered.count("+RETURN 'Library'") == 2 + + +def test_audit_reports_ambiguous_library_identity(tmp_path: Path) -> None: + repository = tmp_path / "BloodHound" + query_directory = tmp_path / "queries" + groups = [ + ("Active Directory", "Domain Information", [("Duplicate", "RETURN 1")]) + ] + write_catalog(repository, "AGI", groups) + write_catalog(repository, "AGT", groups) + for filename in ("first.yml", "second.yml"): + write_library_query( + query_directory, + filename, + "Duplicate", + "Active Directory", + "Domain Information", + "RETURN 1", + ) + + result = audit_repositories(repository, query_directory) + + assert result.has_drift + assert len(result.ambiguous) == 1 + assert {query.path.name for query in result.ambiguous[0].library_queries} == { + "first.yml", + "second.yml", + } + + +def test_parse_catalog_rejects_unknown_interpolation(tmp_path: Path) -> None: + repository = tmp_path / "BloodHound" + path = write_catalog( + repository, + "AGT", + [ + ( + "Active Directory", + "Domain Information", + [("Unsupported", "RETURN '${notSupported}'")], + ) + ], + ) + + with pytest.raises(CatalogParseError, match="Unsupported interpolation"): + parse_catalog(path, "AGT") + + +def test_parse_catalog_rejects_missing_common_searches(tmp_path: Path) -> None: + path = tmp_path / "commonSearchesAGI.ts" + path.write_text("export const SomethingElse = [];\n", encoding="utf-8") + + with pytest.raises(CatalogParseError, match="CommonSearches array was not found"): + parse_catalog(path, "AGI") + + +def test_cli_exit_codes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + assert compare_prebuilt.main([str(tmp_path / "missing")]) == 2 + assert "repository path does not exist" in capsys.readouterr().err + + clean_result = AuditResult(0, 0, (), (), (), ()) + drift_result = AuditResult( + 1, + 0, + (), + (QueryIdentity("Active Directory", "Domain Information", "Missing"),), + (), + (), + ) + + monkeypatch.setattr( + compare_prebuilt, "audit_repositories", lambda repository: clean_result + ) + assert compare_prebuilt.main([str(tmp_path)]) == 0 + capsys.readouterr() + + monkeypatch.setattr( + compare_prebuilt, "audit_repositories", lambda repository: drift_result + ) + assert compare_prebuilt.main([str(tmp_path)]) == 1 + capsys.readouterr() + + def raise_audit_error(repository: Path) -> AuditResult: + raise AuditError("invalid fixture") + + monkeypatch.setattr(compare_prebuilt, "audit_repositories", raise_audit_error) + assert compare_prebuilt.main([str(tmp_path)]) == 2 + assert "Error: invalid fixture" in capsys.readouterr().err + + def raise_catalog_error(repository: Path) -> AuditResult: + raise CatalogParseError("invalid TypeScript") + + monkeypatch.setattr(compare_prebuilt, "audit_repositories", raise_catalog_error) + assert compare_prebuilt.main([str(tmp_path)]) == 2 + assert ( + "Malformed BloodHound catalog: invalid TypeScript" + in capsys.readouterr().err + ) diff --git a/utilities/python/compare_prebuilt.py b/utilities/python/compare_prebuilt.py new file mode 100644 index 0000000..962b860 --- /dev/null +++ b/utilities/python/compare_prebuilt.py @@ -0,0 +1,736 @@ +"""Audit Query Library prebuilt queries against the BloodHound product catalog. + +Usage: + python utilities/python/compare_prebuilt.py + +The read-only audit combines CommonSearches from commonSearchesAGI.ts and +commonSearchesAGT.ts. A Query Library entry is synchronized when its Cypher +matches either valid product implementation. UncommonSearches is excluded. + +Queries are matched by platform, category, and the YAML name field, never by +filename. Terminal (AD) and (AZ) suffixes are normalized only for a query's +sole matching platform. Multi-platform library queries can match the +corresponding BloodHound platform. + +Intentional TypeScript representations for attack-path edge sets, AGI system +tags, AGT labels, and the privileged-role regular expression are resolved +before comparison. Other Cypher differences are preserved and emitted as +unified diffs. The report also identifies product-only queries, library-only +prebuilt queries, ambiguous identities, and malformed catalog structures. + +The command requires PyYAML but not the repository's Pydantic test +dependencies. Install it, if needed, with: + python -m pip install PyYAML + +Run the audit-specific tests without loading the repository-wide Jinja2 report +fixture: + python -m pytest --noconftest tests/test_compare_prebuilt.py + +Exit codes: + 0 Catalogs are synchronized. + 1 Query content or inventory drift was detected. + 2 A path, dependency, or catalog structure is invalid. + +Neither repository is modified. Reconcile findings in the intended source and +increment a Query Library revision when its YAML changes. +""" + +from __future__ import annotations + +import argparse +from collections import defaultdict +from dataclasses import dataclass +import difflib +from pathlib import Path +import re +import sys + +try: + import yaml +except ModuleNotFoundError: + yaml = None + + +CATALOG_RELATIVE_PATH = Path("packages/javascript/bh-shared-ui/src") +DEFAULT_QUERY_DIRECTORY = Path(__file__).resolve().parents[2] / "queries" +VARIANTS = ("AGI", "AGT") +PLATFORM_SUFFIXES = { + "Active Directory": "AD", + "Azure": "AZ", +} + +CLI_EPILOG = """matching: + Product queries are read from both AGI and AGT CommonSearches catalogs. + Library queries are matched by platform, category, and YAML name. A body may + match either product variant. The audit never modifies either repository. + +exit codes: + 0 synchronized + 1 content or inventory drift + 2 invalid path, dependency, or catalog structure +""" + +class AuditError(Exception): + """Base error for invalid audit inputs.""" + + +class CatalogParseError(AuditError): + """Raised when a BloodHound TypeScript catalog cannot be parsed safely.""" + + +@dataclass(frozen=True, order=True) +class QueryIdentity: + platform: str + category: str + name: str + + def display(self) -> str: + return f"{self.platform} / {self.category} / {self.name}" + + +@dataclass(frozen=True) +class ProductQuery: + identity: QueryIdentity + query: str + variant: str + + +@dataclass(frozen=True) +class LibraryQuery: + path: Path + name: str + platforms: tuple[str, ...] + category: str + query: str + + def display_name(self) -> str: + if len(self.platforms) != 1: + return self.name + + suffix = PLATFORM_SUFFIXES.get(self.platforms[0]) + if suffix is None: + return self.name + + terminal_suffix = f" ({suffix})" + if self.name.endswith(terminal_suffix): + return self.name[: -len(terminal_suffix)] + return self.name + + def identities(self) -> tuple[QueryIdentity, ...]: + return tuple( + QueryIdentity(platform, self.category, self.display_name()) + for platform in self.platforms + ) + + +@dataclass(frozen=True) +class QueryComparison: + identity: QueryIdentity + library_query: LibraryQuery + product_queries: tuple[ProductQuery, ...] + matching_variants: tuple[str, ...] + + @property + def matches(self) -> bool: + return bool(self.matching_variants) + + @property + def implementation_status(self) -> str: + if len(self.product_queries) == 1: + return f"{self.product_queries[0].variant} only" + if len({query.query for query in self.product_queries}) == 1: + return "identical AGI/AGT" + return "distinct AGI/AGT" + + +@dataclass(frozen=True) +class AmbiguousMatch: + identity: QueryIdentity + library_queries: tuple[LibraryQuery, ...] + + +@dataclass(frozen=True) +class AuditResult: + product_identity_count: int + library_query_count: int + comparisons: tuple[QueryComparison, ...] + bloodhound_only: tuple[QueryIdentity, ...] + library_only: tuple[LibraryQuery, ...] + ambiguous: tuple[AmbiguousMatch, ...] + + @property + def body_match_count(self) -> int: + return sum(comparison.matches for comparison in self.comparisons) + + @property + def has_drift(self) -> bool: + return bool( + self.bloodhound_only + or self.library_only + or self.ambiguous + or any(not comparison.matches for comparison in self.comparisons) + ) + + +_CONST_LITERAL_PATTERN = re.compile( + r"\bconst\s+(?P[A-Za-z_$][\w$]*)\s*=\s*" + r"'(?P(?:\\.|[^'\\])*)'\s*;", + re.DOTALL, +) +_COMMON_SEARCHES_PATTERN = re.compile( + r"export\s+const\s+CommonSearches\s*:\s*CommonSearchType\[\]\s*=\s*\[" +) +_SUBHEADER_PATTERN = re.compile( + r"^\s*subheader:\s*'(?P(?:\\.|[^'\\])*)',\s*$" +) +_CATEGORY_PATTERN = re.compile( + r"^\s*category:\s*(?P[A-Za-z_$][\w$]*),\s*$" +) +_NAME_PATTERN = re.compile( + r"^\s*name:\s*'(?P(?:\\.|[^'\\])*)',\s*$" +) +_QUERY_PATTERN = re.compile( + r"^\s*query:\s*`(?P(?:\\.|[^`\\])*)`,\s*$", + re.DOTALL, +) +_INTERPOLATION_PATTERN = re.compile(r"\$\{(?P[A-Za-z_$][\w$]*)\}") + + +def _decode_javascript_string(value: str) -> str: + decoded: list[str] = [] + index = 0 + escapes = { + "n": "\n", + "r": "\r", + "t": "\t", + "b": "\b", + "f": "\f", + "v": "\v", + "0": "\0", + "\\": "\\", + "'": "'", + '"': '"', + "`": "`", + "$": "$", + } + + while index < len(value): + if value[index] != "\\": + decoded.append(value[index]) + index += 1 + continue + + index += 1 + if index >= len(value): + raise CatalogParseError("TypeScript string ends with an incomplete escape") + + escape = value[index] + if escape == "u": + digits = value[index + 1 : index + 5] + if len(digits) != 4 or not all( + character in "0123456789abcdefABCDEF" for character in digits + ): + raise CatalogParseError( + "TypeScript string contains an invalid Unicode escape" + ) + decoded.append(chr(int(digits, 16))) + index += 5 + continue + + if escape == "x": + digits = value[index + 1 : index + 3] + if len(digits) != 2 or not all( + character in "0123456789abcdefABCDEF" for character in digits + ): + raise CatalogParseError( + "TypeScript string contains an invalid hex escape" + ) + decoded.append(chr(int(digits, 16))) + index += 3 + continue + + decoded.append(escapes.get(escape, escape)) + index += 1 + + return "".join(decoded) + + +def _normalize_query(query: str) -> str: + return query.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") + + +def _literal_constants(source: str) -> dict[str, str]: + return { + match.group("name"): _decode_javascript_string(match.group("value")) + for match in _CONST_LITERAL_PATTERN.finditer(source) + } + + +def _extract_array_body(source: str, opening_bracket: int, path: Path) -> str: + depth = 0 + quote: str | None = None + escaped = False + line_comment = False + block_comment = False + body_start = opening_bracket + 1 + index = opening_bracket + + while index < len(source): + character = source[index] + next_character = source[index + 1] if index + 1 < len(source) else "" + + if line_comment: + if character == "\n": + line_comment = False + index += 1 + continue + + if block_comment: + if character == "*" and next_character == "/": + block_comment = False + index += 2 + else: + index += 1 + continue + + if quote is not None: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + index += 1 + continue + + if character == "/" and next_character == "/": + line_comment = True + index += 2 + continue + if character == "/" and next_character == "*": + block_comment = True + index += 2 + continue + if character in ("'", '"', "`"): + quote = character + index += 1 + continue + if character == "[": + depth += 1 + elif character == "]": + depth -= 1 + if depth == 0: + return source[body_start:index] + index += 1 + + raise CatalogParseError(f"Unterminated CommonSearches array in {path}") + + +def _resolve_interpolations( + value: str, symbols: dict[str, str], path: Path, line_number: int +) -> str: + def replace(match: re.Match[str]) -> str: + name = match.group("name") + if name not in symbols: + raise CatalogParseError( + f"Unsupported interpolation ${{{name}}} in {path}:{line_number}" + ) + return symbols[name] + + return _INTERPOLATION_PATTERN.sub(replace, value) + + +def parse_catalog(path: Path, variant: str) -> tuple[ProductQuery, ...]: + if variant not in VARIANTS: + raise CatalogParseError(f"Unknown BloodHound catalog variant: {variant}") + + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exception: + raise CatalogParseError(f"Unable to read {path}: {exception}") from exception + + common_searches = _COMMON_SEARCHES_PATTERN.search(source) + if common_searches is None: + raise CatalogParseError(f"CommonSearches array was not found in {path}") + + constants_source = "" + constants_path = path.with_name("constants.ts") + if constants_path.exists(): + try: + constants_source = constants_path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exception: + raise CatalogParseError( + f"Unable to read {constants_path}: {exception}" + ) from exception + + symbols = _literal_constants(constants_source) + symbols.update(_literal_constants(source)) + symbols.update( + { + "adTransitEdgeTypes": "AD_ATTACK_PATHS", + "azureTransitEdgeTypes": "AZ_ATTACK_PATHS", + } + ) + + opening_bracket = common_searches.end() - 1 + body = _extract_array_body(source, opening_bracket, path) + first_line = source.count("\n", 0, opening_bracket) + 1 + + category: str | None = None + platform: str | None = None + pending_name: str | None = None + queries: list[ProductQuery] = [] + query_lines = 0 + name_lines = 0 + + physical_lines = body.splitlines() + logical_lines: list[tuple[int, str]] = [] + offset = 0 + while offset < len(physical_lines): + line = physical_lines[offset] + start_offset = offset + 1 + if re.match(r"^\s*query:\s*", line): + while _QUERY_PATTERN.match(line) is None: + offset += 1 + if offset >= len(physical_lines): + raise CatalogParseError( + f"Unterminated query template in {path}:" + f"{first_line + start_offset}" + ) + line += "\n" + physical_lines[offset] + logical_lines.append((start_offset, line)) + offset += 1 + + for offset, line in logical_lines: + line_number = first_line + offset + + if match := _SUBHEADER_PATTERN.match(line): + category = _decode_javascript_string(match.group("value")) + platform = None + continue + + if match := _CATEGORY_PATTERN.match(line): + category_symbol = match.group("value") + if category_symbol not in symbols: + raise CatalogParseError( + f"Unknown category constant {category_symbol} in " + f"{path}:{line_number}" + ) + platform = symbols[category_symbol] + continue + + if re.match(r"^\s*name:\s*", line): + name_lines += 1 + match = _NAME_PATTERN.match(line) + if match is None: + raise CatalogParseError( + f"Unsupported query name syntax in {path}:{line_number}" + ) + if pending_name is not None: + raise CatalogParseError( + f"Query {pending_name!r} has no query body before " + f"{path}:{line_number}" + ) + pending_name = _decode_javascript_string(match.group("value")) + continue + + if re.match(r"^\s*query:\s*", line): + query_lines += 1 + match = _QUERY_PATTERN.match(line) + if match is None: + raise CatalogParseError( + f"Unsupported query template syntax in {path}:{line_number}" + ) + if pending_name is None or category is None or platform is None: + raise CatalogParseError( + f"Incomplete query metadata in {path}:{line_number}" + ) + + resolved = _resolve_interpolations( + match.group("value"), symbols, path, line_number + ) + identity = QueryIdentity(platform, category, pending_name) + queries.append( + ProductQuery( + identity=identity, + query=_normalize_query(_decode_javascript_string(resolved)), + variant=variant, + ) + ) + pending_name = None + + if pending_name is not None: + raise CatalogParseError(f"Query {pending_name!r} has no query body in {path}") + if not queries: + raise CatalogParseError(f"No product queries were parsed from {path}") + if name_lines != query_lines or query_lines != len(queries): + raise CatalogParseError( + f"Parsed {len(queries)} of {name_lines} names and {query_lines} query " + f"bodies in {path}" + ) + + seen: set[QueryIdentity] = set() + for query in queries: + if query.identity in seen: + raise CatalogParseError( + f"Duplicate {variant} product identity: {query.identity.display()}" + ) + seen.add(query.identity) + + return tuple(queries) + + +def load_product_catalog( + bloodhound_repository: Path, +) -> dict[QueryIdentity, tuple[ProductQuery, ...]]: + source_directory = bloodhound_repository / CATALOG_RELATIVE_PATH + product_queries: dict[QueryIdentity, list[ProductQuery]] = defaultdict(list) + + for variant in VARIANTS: + path = source_directory / f"commonSearches{variant}.ts" + for query in parse_catalog(path, variant): + product_queries[query.identity].append(query) + + return { + identity: tuple(sorted(queries, key=lambda query: query.variant)) + for identity, queries in product_queries.items() + } + + +def load_library_queries(query_directory: Path) -> tuple[LibraryQuery, ...]: + if not query_directory.is_dir(): + raise AuditError(f"Query directory does not exist: {query_directory}") + if yaml is None: + raise AuditError( + "PyYAML is required to read Query Library files. Install it with " + "'python -m pip install PyYAML'." + ) + + queries: list[LibraryQuery] = [] + for path in sorted(query_directory.rglob("*.yml")): + try: + value = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exception: + raise AuditError(f"Unable to load {path}: {exception}") from exception + + if not isinstance(value, dict): + raise AuditError(f"Expected a YAML object in {path}") + if value.get("prebuilt") is not True: + continue + + name = value.get("name") + platforms_value = value.get("platforms") + category = value.get("category") + query = value.get("query") + + if not isinstance(name, str) or not name: + raise AuditError(f"Invalid prebuilt query name in {path}") + if isinstance(platforms_value, str): + platforms = (platforms_value,) + elif isinstance(platforms_value, list) and all( + isinstance(platform, str) and platform for platform in platforms_value + ): + platforms = tuple(platforms_value) + else: + raise AuditError(f"Invalid platforms for prebuilt query {path}") + if not platforms: + raise AuditError(f"Prebuilt query has no platforms: {path}") + if not isinstance(category, str) or not category: + raise AuditError(f"Invalid category for prebuilt query {path}") + if not isinstance(query, str): + raise AuditError(f"Invalid Cypher body for prebuilt query {path}") + + queries.append( + LibraryQuery( + path=path, + name=name, + platforms=platforms, + category=category, + query=_normalize_query(query), + ) + ) + + return tuple(queries) + + +def audit_repositories( + bloodhound_repository: Path, query_directory: Path = DEFAULT_QUERY_DIRECTORY +) -> AuditResult: + product_catalog = load_product_catalog(bloodhound_repository) + library_queries = load_library_queries(query_directory) + + library_by_identity: dict[QueryIdentity, list[LibraryQuery]] = defaultdict(list) + for library_query in library_queries: + for identity in library_query.identities(): + library_by_identity[identity].append(library_query) + + comparisons: list[QueryComparison] = [] + bloodhound_only: list[QueryIdentity] = [] + ambiguous: list[AmbiguousMatch] = [] + used_library_paths: set[Path] = set() + + for identity in sorted(product_catalog): + candidates = library_by_identity.get(identity, []) + if not candidates: + bloodhound_only.append(identity) + continue + if len(candidates) > 1: + ambiguous.append( + AmbiguousMatch( + identity, + tuple(sorted(candidates, key=lambda item: item.path)), + ) + ) + used_library_paths.update(candidate.path for candidate in candidates) + continue + + library_query = candidates[0] + used_library_paths.add(library_query.path) + product_queries = product_catalog[identity] + matching_variants = tuple( + query.variant + for query in product_queries + if query.query == library_query.query + ) + comparisons.append( + QueryComparison( + identity=identity, + library_query=library_query, + product_queries=product_queries, + matching_variants=matching_variants, + ) + ) + + library_only = tuple( + query for query in library_queries if query.path not in used_library_paths + ) + + return AuditResult( + product_identity_count=len(product_catalog), + library_query_count=len(library_queries), + comparisons=tuple(comparisons), + bloodhound_only=tuple(bloodhound_only), + library_only=library_only, + ambiguous=tuple(ambiguous), + ) + + +def _render_section(lines: list[str], title: str, values: list[str]) -> None: + if not values: + return + lines.extend(("", title)) + lines.extend(f" {value}" for value in values) + + +def render_result(result: AuditResult) -> str: + lines = [ + "BloodHound product query audit", + f"Product identities: {result.product_identity_count}", + f"Query Library prebuilt queries: {result.library_query_count}", + f"Matching query bodies: {result.body_match_count}", + "", + "Product query status:", + ] + + for comparison in result.comparisons: + variants = ", ".join(comparison.matching_variants) or "neither" + lines.append( + f" [matches: {variants}] " + f"[product: {comparison.implementation_status}] " + f"{comparison.identity.display()} " + f"({comparison.library_query.path.name})" + ) + + _render_section( + lines, + "BloodHound-only product queries:", + [identity.display() for identity in result.bloodhound_only], + ) + _render_section( + lines, + "Query Library-only prebuilt queries:", + [ + f"{query.name} ({query.path.name})" + for query in sorted(result.library_only, key=lambda item: item.path) + ], + ) + _render_section( + lines, + "Ambiguous query identities:", + [ + f"{match.identity.display()}: " + + ", ".join(query.path.name for query in match.library_queries) + for match in result.ambiguous + ], + ) + + mismatches = [ + comparison for comparison in result.comparisons if not comparison.matches + ] + if mismatches: + lines.extend(("", "Query body differences:")) + + for comparison in mismatches: + lines.append("") + lines.append(f" {comparison.identity.display()}") + + variants_by_query: dict[str, list[str]] = defaultdict(list) + for product_query in comparison.product_queries: + variants_by_query[product_query.query].append(product_query.variant) + + for expected_query, variants in variants_by_query.items(): + variant_label = "+".join(variants) + diff = difflib.unified_diff( + expected_query.splitlines(), + comparison.library_query.query.splitlines(), + fromfile=f"BloodHound ({variant_label})", + tofile=f"Query Library ({comparison.library_query.path.name})", + lineterm="", + ) + lines.extend(f" {line}" for line in diff) + + status = "Drift detected." if result.has_drift else "Catalogs are synchronized." + lines.extend(("", status)) + return "\n".join(lines) + + +def main(arguments: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Compare BloodHound product queries with Query Library prebuilt " + "queries." + ), + epilog=CLI_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "bloodhound_repository", + type=Path, + help="Path to a local BloodHound repository checkout.", + ) + parsed_arguments = parser.parse_args(arguments) + bloodhound_repository = parsed_arguments.bloodhound_repository.resolve() + + if not bloodhound_repository.is_dir(): + print( + f"Error: BloodHound repository path does not exist: " + f"{bloodhound_repository}", + file=sys.stderr, + ) + return 2 + + try: + result = audit_repositories(bloodhound_repository) + except CatalogParseError as exception: + print(f"Malformed BloodHound catalog: {exception}", file=sys.stderr) + return 2 + except AuditError as exception: + print(f"Error: {exception}", file=sys.stderr) + return 2 + + print(render_result(result)) + return 1 if result.has_drift else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 6c94ffe1b45ef5d14ccc2082d96ce042588eb2c1 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 14:41:24 +0200 Subject: [PATCH 16/17] typo fix - platform vs platforms --- docs/query-structure.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/query-structure.yml b/docs/query-structure.yml index a1cee12..a98d315 100644 --- a/docs/query-structure.yml +++ b/docs/query-structure.yml @@ -3,7 +3,7 @@ name: The name of the query. guid: 51cd8e42-f795-4551-a009-61395e6cedf5 # Unique GUID for the query, for example from PowerShell Cmdlet `New-Guid`. prebuilt: false # Whether the query is part of the default query set in BloodHound's Pre-built Searches. Your custom query must set this to false. -platform: Active Directory # One or more platforms the query targets/shows risk in. +platforms: Active Directory # One or more platforms the query targets/shows risk in. # Use a list for multiple platforms: # - Active Directory # - Azure From ceafa0d1e174e266b7842ec1d18d5439f801d44f Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 16:01:23 +0200 Subject: [PATCH 17/17] Remove stray README character --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4dd1721..80dd7aa 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ These blog posts give an in-depth introduction to the project: - [Introducing the BloodHound Query Library](https://specterops.io/blog/2025/06/17/introducing-the-bloodhound-query-library/) - [What’s New in the BloodHound Query Library: BYOL, OpenGraph, Multi-Server, and More](https://specterops.io/blog/2026/04/15/whats-new-in-the-bloodhound-query-library-byol-opengraph-multi-server-and-more/) -s + ## Overview The library contains queries that demonstrate BloodHound's versatility beyond traditional attack path analysis. This includes: