diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4a2946..e316f98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,6 +64,7 @@ jobs: run: | mkdir pages cp Queries.json pages/Queries.json + cp docs/security-assessment-mapping.json pages/security-assessment-mapping.json - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v4 diff --git a/README.md b/README.md index 80dd7aa..a6d52ad 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The library contains queries that demonstrate BloodHound's versatility beyond tr - Cherry-picked community queries - SpecterOps-created queries BloodHound Enterprise customers found valuable - Community contributed queries (see [Contributing](#contributing)) -- Novel queries to further showcase BloodHound's security assessment capabilities (see [security-assessment-mapping.md](/docs/security-assessment-mapping.md)) +- Novel queries to further showcase BloodHound's [security assessment capabilities](#security-assessment-mapping). Individual query files are stored in [/queries](/queries/) as `.yml` and are automatically combined into a single Queries.json/Queries.zip as part of our [releases](https://github.com/SpecterOps/BloodHoundQueryLibrary/releases). @@ -72,7 +72,10 @@ Whenever new queries are added, the syntax is automatically validated, ensuring BloodHound queries in this library have been mapped to controls from common security assessment tools, demonstrating how BloodHound can validate findings typically associated with dedicated tools like PingCastle, Microsoft Defender for Identity, and Tenable Nessus. -For full coverage details and mapping structure, see [security-assessment-mapping.md](/docs/security-assessment-mapping.md). +Explore supported tools, mapped controls, and coverage statistics on the [Security Assessments page](https://queries.specterops.io/security-assessments). The page is backed by the repository's [mapping overview](docs/security-assessment-mapping.md) and [mapping data](docs/security-assessment-mapping.json). + + +![Security Assessments page screenshot](queries.specterops.io-security-assessments.png) ## Learning Cypher Queries diff --git a/docs/security-assessment-mapping.json b/docs/security-assessment-mapping.json index d08345c..af8cb9c 100644 --- a/docs/security-assessment-mapping.json +++ b/docs/security-assessment-mapping.json @@ -16,8 +16,8 @@ "mappings": [ { "bloodhound_query": { - "guid": "d263e621-7f1b-4efb-ad25-098fc7d4fb72", - "name": "Enabled computers inactive for 180 days - MSSQL Failover Cluster" + "guid": "8fbb372e-f3fd-4fc8-a362-06d6315812c9", + "name": "MSSQL Failover Cluster accounts with passwords older than 60 days" }, "maps_to": [ { @@ -203,6 +203,23 @@ "name": "[M]Inactive account check" } ] + }, + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "partial", + "mapping_scope_detail": "Query does not exclude service accounts.", + "id": "Remove stale Active Directory accounts", + "name": "Remove stale Active Directory accounts" + }, + { + "mapping_scope": "partial", + "mapping_scope_detail": "Query does not exclude non-service accounts.", + "id": "Remove Stale Service Accounts", + "name": "Remove Stale Service Accounts" + } + ] } ] }, @@ -225,6 +242,55 @@ } ] }, + { + "bloodhound_query": { + "guid": "b1c2fc7d-61fd-4047-81dc-42e75bea35b1", + "name": "Accounts with possible secrets in description" + }, + "maps_to": [ + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "partial", + "mapping_scope_detail": "Query searches limited attributes and does not use GenAI-powered analysis.", + "id": "Remove discoverable passwords in Active Directory account attributes", + "name": "Remove discoverable passwords in Active Directory account attributes" + } + ] + } + ] + }, + { + "bloodhound_query": { + "guid": "372579b2-a671-4697-a771-a9d32c5c61da", + "name": "Hybrid accounts Tier Zero in both Entra ID and Active Directory" + }, + "maps_to": [ + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "superset", + "mapping_scope_detail": "Expanded scope to Tier Zero", + "id": "Microsoft Entra ID privileged user accounts that are also privileged in Active Directory", + "name": "Microsoft Entra ID privileged user accounts that are also privileged in Active Directory" + } + ] + }, + { + "source": "PurpleKnight", + "controls": [ + { + "mapping_scope": "superset", + "mapping_scope_detail": "Expanded scope to Tier Zero", + "id": "c117b6a5-00c8-4e77-93d2-e291e36b462a", + "name": "Entra ID privileged users that are also privileged in AD" + } + ] + } + ] + }, { "bloodhound_query": { "guid": "5862dc4e-6f6f-4321-9474-d838968495ed", @@ -410,8 +476,8 @@ { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Unsecure account attributes: Remove Do not require Kerberos preauthentication", - "name": "Unsecure account attributes: Remove Do not require Kerberos preauthentication" + "id": "Unsecure account attributes: Remove Don't require Kerberos preauthentication", + "name": "Unsecure account attributes: Remove Don't require Kerberos preauthentication" } ] }, @@ -743,16 +809,16 @@ }, { "bloodhound_query": { - "guid": "cb8cf96e-21c9-422b-9439-390a13446ca6", - "name": "Kerberos-enabled service accounts permitting RC4 encryption" + "guid": "8ff729ab-74e2-44a7-b02b-e9a81137c44b", + "name": "Kerberos-enabled service accounts without AES encryption" }, "maps_to": [ { "source": "PingCastle", "controls": [ { - "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.", + "mapping_scope": "exact", + "mapping_scope_detail": "Assumes updated domain controllers with default AES KDC settings and available AES keys; unset and empty encryption configurations use the AES default.", "id": "S-AesNotEnabled", "name": "[T]Check the use of Kerberos on services accounts without AES support" } @@ -762,8 +828,8 @@ "source": "MDI", "controls": [ { - "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.", + "mapping_scope": "exact", + "mapping_scope_detail": "Assumes updated domain controllers with default AES KDC settings and available AES keys; unset and empty encryption configurations use the AES default.", "id": "Unsecure account attributes: Enable Kerberos AES encryption support", "name": "Unsecure account attributes: Enable Kerberos AES encryption support" } @@ -773,8 +839,8 @@ "source": "PurpleKnight", "controls": [ { - "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.", + "mapping_scope": "superset", + "mapping_scope_detail": "Assumes updated domain controllers with default AES KDC settings and available AES keys; unset and empty encryption configurations use the AES default. Expanded scope to include computer accounts with SPNs as well as users.", "id": "b608276e-3849-419d-bc34-6e5a362b3e79", "name": "Primary users with SPN not supporting AES encryption on Kerberos" } @@ -862,17 +928,6 @@ } ] }, - { - "source": "MDI", - "controls": [ - { - "mapping_scope": "exact", - "mapping_scope_detail": "", - "id": "Weak cipher usage", - "name": "Weak cipher usage" - } - ] - }, { "source": "PurpleKnight", "controls": [ @@ -928,8 +983,8 @@ { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Unsecure domain configurations: ms-DS-MachineAccountQuota", - "name": "Unsecure domain configurations: ms-DS-MachineAccountQuota" + "id": "Unsecure domain configurations: Set ms-DS-MachineAccountQuota to \"0\"", + "name": "Unsecure domain configurations: Set ms-DS-MachineAccountQuota to \"0\"" } ] }, @@ -975,8 +1030,8 @@ "source": "PingCastle", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "superset", + "mapping_scope_detail": "Expanded scope to Tier Zero", "id": "P-Delegated", "name": "[M]At least one administrator account can be delegated" } @@ -986,8 +1041,8 @@ "source": "MDI", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "superset", + "mapping_scope_detail": "Expanded scope to Tier Zero", "id": "Ensure privileged accounts are not delegated", "name": "Ensure privileged accounts are not delegated" } @@ -1017,23 +1072,12 @@ "controls": [ { "mapping_scope": "superset", - "mapping_scope_detail": "Expanded scope to Tier Zero", + "mapping_scope_detail": "", "id": "https://www.tenable.com/plugins/nessus/150480", "name": "Kerberoasting" } ] }, - { - "source": "MDI", - "controls": [ - { - "mapping_scope": "superset", - "mapping_scope_detail": "Expanded scope to Tier Zero", - "id": "Unsecure account attributes: Remove a Service Principal Name (SPN)", - "name": "Unsecure account attributes: Remove a Service Principal Name (SPN)" - } - ] - }, { "source": "PurpleKnight", "controls": [ @@ -1063,6 +1107,17 @@ "name": "Users with SPN defined" } ] + }, + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "exact", + "mapping_scope_detail": "", + "id": "Unsecure account attributes: Remove a Service Principal Name (SPN)", + "name": "Unsecure account attributes: Remove a Service Principal Name (SPN)" + } + ] } ] }, @@ -1210,8 +1265,8 @@ "source": "MDI", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "partial", + "mapping_scope_detail": "Query infers lack the AES encryption keys by password reset time or account creation instead of reading NTDS.", "id": "Unsecure account attributes: Remove Password stored with weak encryption", "name": "Unsecure account attributes: Remove Password stored with weak encryption" } @@ -1284,6 +1339,23 @@ "name": "[M]At least one domain controller is not owned correctly" } ] + }, + { + "source": "PurpleKnight", + "controls": [ + { + "mapping_scope": "superset", + "mapping_scope_detail": "Remove false positives by reducing source scope to Non-Tier Zero. Expanded target scope to Tier Zero", + "id": "d2df85d9-abbc-4585-be11-123a6d90a871", + "name": "Domain Controller owner is not an administrator" + }, + { + "mapping_scope": "superset", + "mapping_scope_detail": "Remove false positives by reducing source scope to Non-Tier Zero. Expanded target scope to Tier Zero", + "id": "40499bf5-9087-4d55-9db3-2cb641a47ac8", + "name": "Privileged objects with unprivileged owners" + } + ] } ] }, @@ -1427,8 +1499,8 @@ { "mapping_scope": "superset", "mapping_scope_detail": "Remove false positives by reducing scope to Non-Tier Zero", - "id": "Admin SDHolder permissions", - "name": "Admin SDHolder permissions" + "id": "Remove access rights on suspicious accounts with the Admin SDHolder permission", + "name": "Remove access rights on suspicious accounts with the Admin SDHolder permission" } ] }, @@ -1552,6 +1624,17 @@ } ] }, + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "exact", + "mapping_scope_detail": "", + "id": "Locate accounts in built-in Operator Groups", + "name": "Locate accounts in built-in Operator Groups" + } + ] + }, { "source": "PurpleKnight", "controls": [ @@ -1646,8 +1729,8 @@ }, { "bloodhound_query": { - "guid": "310b3626-f8e6-4ab0-832c-72df6048597f", - "name": "Large default groups with outbound control of OUs" + "guid": "a334f21a-3d7f-448e-b7ea-1465a3127bce", + "name": "Large default groups with outbound control" }, "maps_to": [ { @@ -1655,7 +1738,7 @@ "controls": [ { "mapping_scope": "superset", - "mapping_scope_detail": "Expanded technique scope to include all BloodHound traversable edges", + "mapping_scope_detail": "Expanded technique scope to include all BloodHound traversable edges on all object types", "id": "P-DelegationEveryone", "name": "[M][T]A Delegation is granted to Everyone" } @@ -1875,6 +1958,17 @@ "name": "[M][T]Check if Service Accounts are domain administrators" } ] + }, + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "exact", + "mapping_scope_detail": "", + "id": "Identify service accounts in privileged groups", + "name": "Identify service accounts in privileged groups" + } + ] } ] }, @@ -2174,8 +2268,8 @@ { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Overly permissive certificate template with privileged EKU (ESC2)", - "name": "Overly permissive certificate template with privileged EKU (ESC2)" + "id": "Edit overly permissive certificate template with privileged EKU (Any purpose EKU or No EKU) (ESC2)", + "name": "Edit overly permissive certificate template with privileged EKU (Any purpose EKU or No EKU) (ESC2)" } ] } @@ -2222,38 +2316,38 @@ { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Prevent requests for certificates valid for arbitrary users (ESC1)", - "name": "Prevent requests for certificates valid for arbitrary users (ESC1)" + "id": "Prevent users to request a certificate valid for arbitrary users based on the certificate template (ESC1)", + "name": "Prevent users to request a certificate valid for arbitrary users based on the certificate template (ESC1)" }, { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Misconfigured enrollment agent certificate template (ESC3)", - "name": "Misconfigured enrollment agent certificate template (ESC3)" + "id": "Edit misconfigured enrollment agent certificate template (ESC3)", + "name": "Edit misconfigured enrollment agent certificate template (ESC3)" }, { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Misconfigured certificate templates ACL (ESC4)", - "name": "Misconfigured certificate templates ACL (ESC4)" + "id": "Edit misconfigured certificate templates ACL (ESC4)", + "name": "Edit misconfigured certificate templates ACL (ESC4)" }, { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Misconfigured certificate templates owner (ESC4)", - "name": "Misconfigured certificate templates owner (ESC4)" + "id": "Edit misconfigured certificate templates owner (ESC4)", + "name": "Edit misconfigured certificate templates owner (ESC4)" }, { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Vulnerable Certificate Authority setting (ESC6)", - "name": "Vulnerable Certificate Authority setting (ESC6)" + "id": "Edit vulnerable Certificate Authority setting (ESC6)", + "name": "Edit vulnerable Certificate Authority setting (ESC6)" }, { "mapping_scope": "exact", "mapping_scope_detail": "", - "id": "Insecure ADCS certificate enrollment IIS endpoints (ESC8)", - "name": "Insecure ADCS certificate enrollment IIS endpoints (ESC8)" + "id": "Edit insecure ADCS certificate enrollment IIS endpoints (ESC8)", + "name": "Edit insecure ADCS certificate enrollment IIS endpoints (ESC8)" } ] }, @@ -2284,18 +2378,18 @@ }, { "bloodhound_query": { - "guid": "96e70597-2d74-4503-a624-f1e30b642894", - "name": "Enrollment rights on certificate templates published to Enterprise CA with User Specified SAN enabled" + "guid": "77a708b8-962e-4c3d-ad70-e994126aaeba", + "name": "CA Administrators and CA Managers (ESC7)" }, "maps_to": [ { - "source": "PingCastle", + "source": "MDI", "controls": [ { "mapping_scope": "exact", - "mapping_scope_detail": "Checks ESC1 scenario which is also covered by the query 49db8edc-8421-438f-b97b-23c042959bef", - "id": "A-CertTempCustomSubject", - "name": "[T]Check if authentication certificate templates allow users to control the subject" + "mapping_scope_detail": "", + "id": "Edit misconfigured Certificate Authority ACL (ESC7)", + "name": "Edit misconfigured Certificate Authority ACL (ESC7)" } ] } @@ -2448,14 +2542,14 @@ "source": "PingCastle", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "superset", + "mapping_scope_detail": "Query returns DCs where both LDAP signing is not required or LDAPS channel binding is not enforced.", "id": "A-DCLdapSign", "name": "[T]Check if signing is really required for LDAP" }, { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "superset", + "mapping_scope_detail": "Query returns DCs where both LDAP signing is not required or LDAPS channel binding is not enforced.", "id": "A-DCLdapsChannelBinding", "name": "[T]Check if the Channel Binding is enabled for LDAPS" } @@ -2465,10 +2559,10 @@ "source": "MDI", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", - "id": "Unsecure domain configurations: LDAP Signing", - "name": "Unsecure domain configurations: LDAP Signing" + "mapping_scope": "superset", + "mapping_scope_detail": "Query returns DCs where both LDAP signing is not required or LDAPS channel binding is not enforced.", + "id": "Resolve unsecure domain configurations: Enforce LDAP Signing policy to \"Require signing\"", + "name": "Resolve unsecure domain configurations: Enforce LDAP Signing policy to \"Require signing\"" } ] }, @@ -2476,8 +2570,8 @@ "source": "PurpleKnight", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "superset", + "mapping_scope_detail": "Query returns DCs where both LDAP signing is not required or LDAPS channel binding is not enforced.", "id": "4fe825ed-07fb-4b06-913a-be5c9542ca54", "name": "LDAP signing is not required on Domain Controllers" } @@ -2594,12 +2688,23 @@ "controls": [ { "mapping_scope": "partial", - "mapping_scope_detail": "Returns all principals with DCSync privileges.", + "mapping_scope_detail": "Returns all principals with DCSync privileges. In the results, look for accounts that are not Tier Zero.", "id": "Remove non-admin accounts with DCSync permissions", "name": "Remove non-admin accounts with DCSync permissions" } ] }, + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "partial", + "mapping_scope_detail": "Returns all principals with DCSync privileges. In the results, look for User accounts prefixed with 'MSOL_'.", + "id": "Remove unnecessary replication permissions for Microsoft Entra Connect AD DS Connector account", + "name": "Remove unnecessary replication permissions for Microsoft Entra Connect AD DS Connector account" + } + ] + }, { "source": "PurpleKnight", "controls": [ @@ -2980,16 +3085,16 @@ }, { "bloodhound_query": { - "guid": "95bec736-86ef-4017-8465-9b9b66548b17", - "name": "Foreign principals in Tier Zero / High Value targets" + "guid": "bf8701bb-964f-484e-98e9-6a5dc9a0b35f", + "name": "Foreign principals in Tier Zero groups" }, "maps_to": [ { "source": "PurpleKnight", "controls": [ { - "mapping_scope": "exact", - "mapping_scope_detail": "", + "mapping_scope": "superset", + "mapping_scope_detail": "Expanded exclusion scope to Tier Zero", "id": "3bcaff82-ae70-4646-bc66-f7997be54e5e", "name": "Foreign Security Principals in Privileged Group" } @@ -3073,36 +3178,6 @@ } ] }, - { - "bloodhound_query": { - "guid": "99d29ded-223a-442b-a0e0-f8b5694c6441", - "name": "Tier Zero principals not owned by Tier Zero" - }, - "maps_to": [ - { - "source": "PurpleKnight", - "controls": [ - { - "mapping_scope": "superset", - "mapping_scope_detail": "Remove false positives by reducing source scope to Non-Tier Zero. Expanded target scope to Tier Zero", - "id": "d2df85d9-abbc-4585-be11-123a6d90a871", - "name": "Domain Controller owner is not an administrator" - } - ] - }, - { - "source": "PurpleKnight", - "controls": [ - { - "mapping_scope": "superset", - "mapping_scope_detail": "Remove false positives by reducing source scope to Non-Tier Zero. Expanded target scope to Tier Zero", - "id": "40499bf5-9087-4d55-9db3-2cb641a47ac8", - "name": "Privileged objects with unprivileged owners" - } - ] - } - ] - }, { "bloodhound_query": { "guid": "a8b6ec67-21aa-4dd2-8906-47bb81bf5262", @@ -3119,36 +3194,6 @@ "name": "AD privileged users that are synced to Entra ID" } ] - }, - { - "source": "PurpleKnight", - "controls": [ - { - "mapping_scope": "combination", - "mapping_scope_detail": "Finds the direction AD -> Entra only.", - "id": "c117b6a5-00c8-4e77-93d2-e291e36b462a", - "name": "Entra ID privileged users that are also privileged in AD" - } - ] - } - ] - }, - { - "bloodhound_query": { - "guid": "0d65c62e-bd77-4027-b967-fb36690739c9", - "name": "Tier Zero Entra ID principals synchronized with AD" - }, - "maps_to": [ - { - "source": "PurpleKnight", - "controls": [ - { - "mapping_scope": "combination", - "mapping_scope_detail": "Finds the direction Entra -> AD only.", - "id": "c117b6a5-00c8-4e77-93d2-e291e36b462a", - "name": "Entra ID privileged users that are also privileged in AD" - } - ] } ] }, @@ -3244,6 +3289,17 @@ "name": "Accounts with Kerberos constrained delegation configured to SSO computer account" } ] + }, + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "exact", + "mapping_scope_detail": "", + "id": "Remove unsafe permissions on sensitive Microsoft Entra Connect accounts", + "name": "Remove unsafe permissions on sensitive Microsoft Entra Connect accounts" + } + ] } ] }, @@ -3277,6 +3333,25 @@ } ] }, + { + "bloodhound_query": { + "guid": "52490ad1-42b8-416b-ac38-4a45cbe4d73c", + "name": "Accounts local Administrator of Tier Zero computers" + }, + "maps_to": [ + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "exact", + "mapping_scope_detail": "", + "id": "Remove local admins on identity assets", + "name": "Remove local admins on identity assets" + } + ] + } + ] + }, { "bloodhound_query": { "guid": "d65a801f-d3ef-4b7e-8030-99ebfd6dad12", @@ -3527,6 +3602,17 @@ "name": "Privileged group contains guest account" } ] + }, + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "superset", + "mapping_scope_detail": "Expanded scope to Tier Zero", + "id": "Ensure external and guest accounts are not granted privileged roles", + "name": "Ensure external and guest accounts are not granted privileged roles" + } + ] } ] }, @@ -3568,6 +3654,44 @@ } ] }, + { + "bloodhound_query": { + "guid": "d9e80a24-93a8-4847-b354-762b7ac7510d", + "name": "Non-Tier Zero accounts with WriteDACL on Tier Zero group" + }, + "maps_to": [ + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "superset", + "mapping_scope_detail": "Expanded scope to Tier Zero", + "id": "Ensure non-admin identities cannot have WriteDACL permissions on sensitive groups", + "name": "Ensure non-admin identities cannot have WriteDACL permissions on sensitive groups" + } + ] + } + ] + }, + { + "bloodhound_query": { + "guid": "92f269ee-3727-4ffa-947b-aad492ac0fa2", + "name": "All privileged Azure Service Principals" + }, + "maps_to": [ + { + "source": "MDI", + "controls": [ + { + "mapping_scope": "exact", + "mapping_scope_detail": "", + "id": "Ensure service accounts in Entra ID are not assigned Domain Name Admin or Global Admin roles", + "name": "Ensure service accounts in Entra ID are not assigned Domain Name Admin or Global Admin roles" + } + ] + } + ] + }, { "bloodhound_query": { "guid": "43481dea-6c9c-47f0-8a3a-0c012c1a811e", diff --git a/docs/security-assessment-mapping.md b/docs/security-assessment-mapping.md index 20fdff5..f12e6c3 100644 --- a/docs/security-assessment-mapping.md +++ b/docs/security-assessment-mapping.md @@ -9,14 +9,7 @@ The BloodHound-centric mapping data is available at [security-assessment-mapping ## Assessment Coverage Overview -The following shows which other security tools the mapping supports and the number of BloodHound queries in the BloodHound Query Library that correspond to controls performed by the tools. - -| Security Tool | Total Controls | Mapped Controls | Coverage | -|---------------|-------------------|---------------|----------| -| [Netwrix PingCastle](https://www.pingcastle.com/PingCastleFiles/ad_hc_rules_list.html) | 186 | 105 | 56% | -| [Semperis PurpleKnight](https://www.semperis.com/purple-knight/security-indicators/) | 190 | 96 | 51% | -| [Microsoft Defender for Identity: Security Posture Assessment](https://learn.microsoft.com/en-us/defender-for-identity/security-assessment) | 45 | 35 | 78% | -| [Tenable Nessus: Active Directory Starter Scan](https://www.tenable.com/blog/new-in-nessus-find-and-fix-these-10-active-directory-misconfigurations) | 10 | 10 | 100% | +See the [Security Assessments page](https://queries.specterops.io/security-assessments) for supported security tools, mapped controls, and coverage statistics calculated from the mapping JSON on GitHub's `main` branch. You can search the mappings and explore the corresponding BloodHound queries there. ## Mapping Structure Each mapping includes a type that describes the relationship: diff --git a/queries.specterops.io-security-assessments.png b/queries.specterops.io-security-assessments.png new file mode 100644 index 0000000..66122c6 Binary files /dev/null and b/queries.specterops.io-security-assessments.png differ diff --git a/queries/Accounts with SID History to a non-existent domain.yml b/queries/Accounts with SID History to a non-existent domain.yml index 991a358..5bcd78a 100644 --- a/queries/Accounts with SID History to a non-existent domain.yml +++ b/queries/Accounts with SID History to a non-existent domain.yml @@ -8,9 +8,9 @@ query: |- MATCH (d:Domain) WITH collect(d.objectid) AS domainSIDs MATCH p=(n:Base)-[:HasSIDHistory]->(m:Base) - WHERE NOT n.domainsid IN domainSIDs + WHERE NOT m.domainsid IN domainSIDs RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Accounts with local Administrator access to Tier Zero computers.yml b/queries/Accounts with local Administrator access to Tier Zero computers.yml new file mode 100644 index 0000000..d228fea --- /dev/null +++ b/queries/Accounts with local Administrator access to Tier Zero computers.yml @@ -0,0 +1,14 @@ +name: Accounts local Administrator of Tier Zero computers +guid: 52490ad1-42b8-416b-ac38-4a45cbe4d73c +prebuilt: false +platforms: Active Directory +category: Dangerous Privileges +description: +query: |- + MATCH p=()-[:MemberOf*0..]->()-[:AdminTo]->(n:Computer) + WHERE (n:Tag_Tier_Zero) + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: diff --git a/queries/Accounts with possible secrets in description.yml b/queries/Accounts with possible secrets in description.yml new file mode 100644 index 0000000..ff85222 --- /dev/null +++ b/queries/Accounts with possible secrets in description.yml @@ -0,0 +1,18 @@ +name: Accounts with possible secrets in description +guid: b1c2fc7d-61fd-4047-81dc-42e75bea35b1 +prebuilt: false +platforms: +- Active Directory +- Azure +category: Active Directory Hygiene +description: Users and computers whose description contains secret/password-related keywords. Broad match, so expect false positives. +query: |- + MATCH (n) + WHERE (n:User OR n:Computer) + AND n.description IS NOT NULL + AND n.description =~ '(?i).*(\\bpass(word|wd|phrase|code)?s?\\b|\\bpwd\\b|\\bpw\\b|p[@a]ssw[o0]rd|\\bcred(ential)?s?\\b|\\bsecret\\b|\\bpsk\\b|\\bpin\\b|temp(orary)?\\s?(pw|pass)|initial\\s?(pw|pass)|default\\s?(pw|pass)|reset\\s?to).*' + RETURN n + LIMIT 1000 +revision: 1 +resources: +acknowledgements: diff --git a/queries/Accounts with weak password storage encryption.yml b/queries/Accounts with weak password storage encryption.yml index 3f7f543..9e53bab 100644 --- a/queries/Accounts with weak password storage encryption.yml +++ b/queries/Accounts with weak password storage encryption.yml @@ -18,8 +18,8 @@ query: |- AND NOT n.whencreated IN [-1.0, 0.0] ) RETURN n - LIMIT 100 -revision: 4 + LIMIT 1000 +revision: 5 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 diff --git a/queries/AdminSDHolder protected Accounts and Groups.yml b/queries/AdminSDHolder protected Accounts and Groups.yml deleted file mode 100644 index 35bc2b4..0000000 --- a/queries/AdminSDHolder protected Accounts and Groups.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: AdminSDHolder protected Accounts and Groups -guid: 5ee2f40e-a55c-4140-ab8a-91746ba3752b -prebuilt: false -platforms: Active Directory -category: Domain Information -description: Objects whose permissions are set by SDProp to the template AdminSDHolder object as per MS-ADTS 3.1.1.6.1.2 Protected Objects. Does not exclude objects if specified in dSHeuristics dwAdminSDExMask -query: |- - MATCH (n:Base)-[:MemberOf*0..]->(m:Group) - WHERE ( - n.objectid =~ ".*-(S-1-5-32-544|S-1-5-32-548|S-1-5-32-549|S-1-5-32-550|S-1-5-32-551|S-1-5-32-552|518|512|519)$" // Groups - OR m.objectid =~ ".*-(S-1-5-32-544|S-1-5-32-548|S-1-5-32-549|S-1-5-32-550|S-1-5-32-551|S-1-5-32-552|518|512|519)$" // Members of groups - OR n.objectid =~ ".*-(500|502|516|521)$" // Direct objects - ) - RETURN n -revision: 1 -resources: -- https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a0d0b4fa-2895-4c64-b182-ba64ad0f84b8 -- https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory -acknowledgements: Martin Sohn Christensen, @martinsohndk - diff --git a/queries/AdminSDHolder to protected objects relationship.yml b/queries/AdminSDHolder to protected objects relationship.yml index df1d9cd..0986948 100644 --- a/queries/AdminSDHolder to protected objects relationship.yml +++ b/queries/AdminSDHolder to protected objects relationship.yml @@ -3,11 +3,13 @@ guid: c751f95c-8bb0-4be4-b027-84f5709c91d2 prebuilt: true platforms: Active Directory category: Active Directory Hygiene -description: +description: Objects whose permissions are set by SDProp to the template AdminSDHolder object as per MS-ADTS 3.1.1.6.1.2 Protected Objects. query: |- MATCH p=(n)-[:ProtectAdminGroups]->(m) RETURN p LIMIT 1000 -revision: 1 +revision: 2 resources: +- https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a0d0b4fa-2895-4c64-b182-ba64ad0f84b8 +- https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory acknowledgements: diff --git a/queries/All DNSAdmins.yml b/queries/All DNSAdmins.yml index 243b914..0181c53 100644 --- a/queries/All DNSAdmins.yml +++ b/queries/All DNSAdmins.yml @@ -5,10 +5,10 @@ platforms: Active Directory category: Domain Information description: query: |- - MATCH p=(n:Base)-[:MemberOf]->(g:Group) - WHERE n.name STARTS WITH "DNSADMINS@" + MATCH p=(n:Base)-[:MemberOf*1..]->(g:Group) + WHERE g.name STARTS WITH "DNSADMINS@" RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/All Domain Controllers.yml b/queries/All Domain Controllers.yml new file mode 100644 index 0000000..c5da2ef --- /dev/null +++ b/queries/All Domain Controllers.yml @@ -0,0 +1,17 @@ +name: All Domain Controllers +guid: b6c75830-f4d1-4c9d-b1ad-105687ee6990 +prebuilt: false +platforms: Active Directory +category: Domain Information +description: Displays all Domain Controllers (Writeable and Read-only) connected to their domain-specific Domain Controllers group. +query: |- + MATCH p = (g:Group)<-[:MemberOf*1..]-(:Computer) + WHERE g.objectid ENDS WITH '-516' // (Writeable) Domain Controllers + OR g.objectid ENDS WITH '-521' // Read-Only Domain Controllers + RETURN p + LIMIT 1000 +revision: 1 +resources: +- https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-groups#domain-controllers +- https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-groups#read-only-domain-controllers +acknowledgements: NCV, @nickvourd diff --git a/queries/All members of Operator groups.yml b/queries/All members of Operator groups.yml index 147ee5f..7b0bf02 100644 --- a/queries/All members of Operator groups.yml +++ b/queries/All members of Operator groups.yml @@ -5,7 +5,7 @@ platforms: Active Directory category: Domain Information description: query: |- - MATCH p=(:Base)-[:MemberOf]->(n:Group) + MATCH p=(:Base)-[:MemberOf*1..]->(n:Group) WHERE ( n.objectid ENDS WITH 'S-1-5-32-551' OR // Backup Operators n.objectid ENDS WITH 'S-1-5-32-556' OR // Network Configuration Operators @@ -16,7 +16,8 @@ query: |- n.objectid ENDS WITH 'S-1-5-32-550' // Print Operators ) RETURN p -revision: 2 + LIMIT 1000 +revision: 3 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/All privileged Azure Service Principals.yml b/queries/All privileged Azure Service Principals.yml index 19ec4c1..d38740f 100644 --- a/queries/All privileged Azure Service Principals.yml +++ b/queries/All privileged Azure Service Principals.yml @@ -6,9 +6,10 @@ platforms: category: Azure Hygiene description: Return all privileged Azure Service Principals. query: |- - MATCH p=(n:AZServicePrincipal)-[:AZHasRole|AZMemberOf*1..2]->(r:AZRole) - WHERE r.displayname =~ '(?i)Global Administrator|User Administrator|Cloud Application Administrator|Authentication Policy Administrator|Exchange Administrator|Helpdesk Administrator|PRIVILEGED AUTHENTICATION ADMINISTRATOR|Domain Name Administrator|Hybrid Identity Administrator|External Identity Provider Administrator|Privileged Role Administrator|Partner Tier2 Support|Application Administrator|Directory Synchronization Accounts' - RETURN p -revision: 1 + MATCH (n:AZServicePrincipal) + OPTIONAL MATCH p1=(n)-[:AZHasRole]->(:AZRole) + OPTIONAL MATCH p2=(n)-[:AZMemberOf*1..]->(:AZGroup)-[:AZHasRole]->(:AZRole) + RETURN p1, p2 +revision: 2 resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/Collection health of DC Registry Data.yml b/queries/Collection health of DC Registry Data.yml index 3186341..b829fad 100644 --- a/queries/Collection health of DC Registry Data.yml +++ b/queries/Collection health of DC Registry Data.yml @@ -3,15 +3,19 @@ guid: 3f0fa2f3-fbdf-42c0-9e7d-97e689009161 prebuilt: false platforms: Active Directory category: Domain Information -description: BloodHound's ADCS analysis requires collecting CA registry data to increase accuracy/enable more edges. Collection by default requires SharpHound has Administrators membership. Requires SharpHound v2.3.5 or above. It only requires one misconfigured DC to potentially a full forest compromise by any principal. DCs returned by this query have an incomplete collection for one or more of the queried attributes. +description: Returns active DCs with incomplete collection of certificate binding enforcement or the Netlogon security descriptor. DC registry data supports BloodHound's ADCS analysis and assessment of Netlogon secure channel exceptions. Collection requires Administrators membership on the DC by default. Requires SharpHound v2.3.5 or above for certificate binding enforcement; Netlogon collection requires SharpHound v2.8.0 or above and BloodHound v8.3.0 or above. query: |- MATCH p=(:Domain)<-[:DCFor]-(c:Computer) - WHERE c.strongcertificatebindingenforcementraw IS NULL + WHERE ( + c.strongcertificatebindingenforcementraw IS NULL + OR c.vulnerablenetlogonsecuritydescriptorcollected IS NULL + OR c.vulnerablenetlogonsecuritydescriptorcollected = false + ) // Exclude inactive DCs AND c.enabled = true AND c.lastlogontimestamp > (datetime().epochseconds - (30 * 86400)) RETURN p -revision: 2 +revision: 3 resources: https://bloodhound.specterops.io/collect-data/sharphound-data-permissions#dc-registry acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Computer owners who can obtain LAPS passwords.yml b/queries/Computer owners who can obtain LAPS passwords.yml index 8fd33ee..3a7a83f 100644 --- a/queries/Computer owners who can obtain LAPS passwords.yml +++ b/queries/Computer owners who can obtain LAPS passwords.yml @@ -3,12 +3,17 @@ guid: 92aa81d6-b08e-4abb-ae39-ecbe5735a74c prebuilt: false platforms: Active Directory category: Dangerous Privileges -description: Creators of computer objects get abusable rights on the computer object. If the owner is not explicitly granted ReadLAPSPassword they can still compromise the computer with the abusable owner rights. +description: Owners of enabled LAPS-managed computers whose ownership rights allow reading LAPS passwords or modifying the DACL to grant that access. query: |- - MATCH p = (c:Computer)<-[:GenericAll|Owns|WriteDacl|WriteOwner|AllExtendedRights]-(n:User) - WHERE c.haslaps = true AND c.ownersid = n.objectid + MATCH p=(c:Computer)<-[r:Owns|OwnsLimitedRights]-(n:User) + WHERE c.haslaps = true + AND c.enabled = true + AND ( + type(r) = 'Owns' + OR ANY(privilege IN r.privileges WHERE privilege IN ['ReadLAPSPassword', 'AllExtendedRights', 'GenericAll', 'WriteDacl']) + ) RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Computers with non-default Primary Group membership.yml b/queries/Computers with non-default Primary Group membership.yml index 2950851..edaaa0e 100644 --- a/queries/Computers with non-default Primary Group membership.yml +++ b/queries/Computers with non-default Primary Group membership.yml @@ -5,27 +5,29 @@ platforms: Active Directory category: Active Directory Hygiene description: query: |- - // Domain Controllers - MATCH p1=(n:Computer)-[r:MemberOf]->(g:Group) - WHERE NOT g.objectid ENDS WITH "-516" - AND n.isdc = true - AND r.isprimarygroup = true - - // Domain Computers - OPTIONAL MATCH p2=(n:Computer)-[r:MemberOf]->(g:Group) - WHERE NOT g.objectid ENDS WITH "-515" - AND NOT n.isdc = true - AND NOT n.isreadonlydc = true - AND r.isprimarygroup = true - - // Read-Only Domain Controllers - OPTIONAL MATCH p3=(n:Computer)-[r:MemberOf]->(g:Group) - WHERE NOT g.objectid ENDS WITH "-521" - AND n.isreadonlydc = true - AND r.isprimarygroup = true - - RETURN p1,p2,p3 -revision: 3 + MATCH p=(n:Computer)-[r:MemberOf]->(g:Group) + WHERE r.isprimarygroup = true + AND ( + ( + n.isreadonlydc = true + AND NOT g.objectid ENDS WITH "-521" // Read-Only Domain Controllers + ) + OR + ( + n.isdc = true + AND coalesce(n.isreadonlydc, false) = false + AND NOT g.objectid ENDS WITH "-516" // Domain Controllers + ) + OR + ( + coalesce(n.isdc, false) = false + AND coalesce(n.isreadonlydc, false) = false + AND NOT g.objectid ENDS WITH "-515" // Domain Computers + ) + ) + RETURN p + LIMIT 1000 +revision: 4 resources: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-ada3/e12954a4-6865-4432-94e6-00c310ca87c0 - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/5dbcf875-e802-4357-a6e2-1bdff19ff9b5 diff --git a/queries/Computers with passwords older than the default maximum password age - MSSQL Failover Cluster.yml b/queries/Computers with passwords older than the default maximum password age - MSSQL Failover Cluster.yml new file mode 100644 index 0000000..3699955 --- /dev/null +++ b/queries/Computers with passwords older than the default maximum password age - MSSQL Failover Cluster.yml @@ -0,0 +1,24 @@ +name: Computers with passwords older than the default maximum password age - MSSQL Failover Cluster +guid: 8fbb372e-f3fd-4fc8-a362-06d6315812c9 +prebuilt: false +platforms: Active Directory +category: Active Directory Hygiene +description: Identifies enabled MSSQL failover cluster computer accounts whose password has not changed in the last 60 days and have logged on recently. +query: |- + WITH 60 AS password_age_days + MATCH (n:Computer) + WHERE n.enabled = true + AND n.whencreated < (datetime().epochseconds - (password_age_days * 86400)) + AND n.lastlogontimestamp > (datetime().epochseconds - (60 * 86400)) + AND n.lastlogon > (datetime().epochseconds - (60 * 86400)) + AND n.pwdlastset < (datetime().epochseconds - (password_age_days * 86400)) + AND ANY(spn IN n.serviceprincipalnames WHERE + toLower(spn) CONTAINS 'mssqlservercluster' OR + toLower(spn) CONTAINS 'mssqlserverclustermgmtapi' OR + toLower(spn) CONTAINS 'msclustervirtualserver') + RETURN n + LIMIT 1000 +revision: 1 +resources: +- https://learn.microsoft.com/en-us/troubleshoot/windows-server/high-availability/troubleshoot-issues-accounts-used-failover-clusters#troubleshoot-password-issues-with-the-cluster-name-account +acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Domains with functional level not the latest version.yml b/queries/Domains with functional level not the latest version.yml index 8012de2..877d9ed 100644 --- a/queries/Domains with functional level not the latest version.yml +++ b/queries/Domains with functional level not the latest version.yml @@ -6,9 +6,9 @@ category: Active Directory Hygiene description: query: |- MATCH (n:Domain) - WHERE toString(n.functionallevel) IN ['2008','2003','2003 Interim','2000 Mixed/Native'] + WHERE toString(n.functionallevel) IN ['2016', '2012 R2', '2012', '2008','2003','2003 Interim','2000 Mixed/Native'] RETURN n -revision: 1 -resources: +revision: 2 +resources: https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/active-directory-functional-levels acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Enrollment rights on published ESC15 certificate templates.yml b/queries/Enrollment rights on published ESC15 certificate templates.yml index 2897a34..2229143 100644 --- a/queries/Enrollment rights on published ESC15 certificate templates.yml +++ b/queries/Enrollment rights on published ESC15 certificate templates.yml @@ -3,7 +3,7 @@ guid: 78d59fe1-e1a0-4813-adc9-c3c96ac08b66 prebuilt: false platforms: Active Directory category: Active Directory Certificate Services -description: Enrollment rights on certificate templates that meet the requirements for the ADCS ESC15 (EKUwu) attack. +description: Enrollment rights on certificate templates that meet the requirements for the ADCS ESC15 (EKUwu) attack, addressed by CVE-2024-49019. query: |- MATCH p=(:Base)-[:Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(:Domain) WHERE ct.enrolleesuppliessubject = True @@ -11,7 +11,7 @@ query: |- AND ct.requiresmanagerapproval = False AND ct.schemaversion = 1 RETURN p -revision: 1 +revision: 2 resources: - https://x.com/SpecterOps/status/1844800558151901639 - https://msrc.microsoft.com/update-guide/en-US/advisory/CVE-2024-49019 diff --git a/queries/Foreign principals in Tier Zero groups.yml b/queries/Foreign principals in Tier Zero groups.yml new file mode 100644 index 0000000..a588432 --- /dev/null +++ b/queries/Foreign principals in Tier Zero groups.yml @@ -0,0 +1,16 @@ +name: Foreign principals in Tier Zero groups +guid: bf8701bb-964f-484e-98e9-6a5dc9a0b35f +prebuilt: false +platforms: Active Directory +category: Dangerous Privileges +description: Identifies foreign domain principals that are direct members of Tier Zero groups. +query: |- + MATCH p=(s:Base)-[:MemberOf]->(t:Group) + WHERE s.domainsid <> t.domainsid + AND (t:Tag_Tier_Zero) + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: Martin Sohn Christensen, @martinsohndk + diff --git a/queries/GPOs with computer configuration disabled linked to computers.yml b/queries/GPOs with computer configuration disabled linked to computers.yml new file mode 100644 index 0000000..56b296e --- /dev/null +++ b/queries/GPOs with computer configuration disabled linked to computers.yml @@ -0,0 +1,15 @@ +name: GPOs with computer configuration disabled linked to computers +guid: 82191294-bc75-4053-8646-f5be4ceb2a24 +prebuilt: false +platforms: Active Directory +category: Active Directory Hygiene +description: Shows GPOs with computer configuration disabled (status 2) linked at the domain level with descendant enabled computers. Returns up to 1000 GPLink and Contains paths to those computers. Linked scope does not establish effective policy application and does not account for blocked inheritance, enforcement, security filtering, or WMI filtering. +query: |- + MATCH p = (g:GPO)-[:GPLink]->(:Domain)-[:Contains*1..]->(c:Computer) + WHERE toString(g.gpostatusraw) = '2' // computer configuration disabled + AND c.enabled = true + RETURN p + LIMIT 1000 +revision: 2 +resources: +acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/GPOs with paths outside domain SYSVOL.yml b/queries/GPOs with paths outside domain SYSVOL.yml new file mode 100644 index 0000000..69c3b12 --- /dev/null +++ b/queries/GPOs with paths outside domain SYSVOL.yml @@ -0,0 +1,18 @@ +name: GPOs with paths outside domain SYSVOL +guid: 361c044c-e76b-4969-a27c-c08ca588f1b1 +prebuilt: false +platforms: Active Directory +category: Active Directory Hygiene +description: Finds GPOs whose collected GPC path does not contain \SYSVOL\ +query: |- + MATCH (g:GPO) + WHERE g.gpcpath IS NOT NULL + AND NOT (toUpper(g.gpcpath) CONTAINS ( + head(tail(split(toUpper(g.name), '@'))) + '\\SYSVOL\\' + )) + RETURN g + LIMIT 1000 +revision: 1 +resources: +- https://www.synacktiv.com/publications/gpoddity-exploiting-active-directory-gpos-through-ntlm-relaying-and-more#4-a-new-versatile-attack-vector-spoofing-gpo-location-through-the-gpcfilesyspath-attribute +acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/GPOs with user configuration disabled linked to users.yml b/queries/GPOs with user configuration disabled linked to users.yml new file mode 100644 index 0000000..cd47971 --- /dev/null +++ b/queries/GPOs with user configuration disabled linked to users.yml @@ -0,0 +1,15 @@ +name: GPOs with user configuration disabled linked to users +guid: a1afd8a8-bdb2-466f-b876-c470521f5055 +prebuilt: false +platforms: Active Directory +category: Active Directory Hygiene +description: Shows GPOs with user configuration disabled (status 1) linked at the domain level with descendant enabled users. Returns up to 1000 GPLink and Contains paths to those users. Linked scope does not establish effective policy application and does not account for blocked inheritance, enforcement, security filtering, or WMI filtering. +query: |- + MATCH p = (g:GPO)-[:GPLink]->(:Domain)-[:Contains*1..]->(u:User) + WHERE toString(g.gpostatusraw) = '1' // user configuration disabled + AND u.enabled = true + RETURN p + LIMIT 1000 +revision: 2 +resources: +acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Hybrid accounts Tier Zero in both Entra ID and Active Directory.yml b/queries/Hybrid accounts Tier Zero in both Entra ID and Active Directory.yml new file mode 100644 index 0000000..428f4db --- /dev/null +++ b/queries/Hybrid accounts Tier Zero in both Entra ID and Active Directory.yml @@ -0,0 +1,15 @@ +name: Hybrid accounts Tier Zero in both Entra ID and Active Directory +guid: 372579b2-a671-4697-a771-a9d32c5c61da +prebuilt: false +platforms: +- Active Directory +- Azure +category: Cross Platform Attack Paths +description: +query: |- + MATCH p = (:Tag_Tier_Zero)-[:SyncedToADUser|SyncedToEntraUser]->(:Tag_Tier_Zero) + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: diff --git a/queries/Kerberos-enabled service account member of built-in Admins groups.yml b/queries/Kerberos-enabled service account member of built-in Admins groups.yml index 4961f49..414e0b1 100644 --- a/queries/Kerberos-enabled service account member of built-in Admins groups.yml +++ b/queries/Kerberos-enabled service account member of built-in Admins groups.yml @@ -10,10 +10,11 @@ query: |- g.objectid ENDS WITH '-512' // Domain Admins OR g.objectid ENDS WITH '-519' // Enterprise Admins OR g.objectid ENDS WITH '-518' // Schema Admins + OR g.objectid ENDS WITH 'S-1-5-32-544' // Administrators ) AND n.hasspn = true RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Kerberos-enabled service accounts without AES encryption.yml b/queries/Kerberos-enabled service accounts without AES encryption.yml new file mode 100644 index 0000000..ba4a30a --- /dev/null +++ b/queries/Kerberos-enabled service accounts without AES encryption.yml @@ -0,0 +1,22 @@ +name: Kerberos-enabled service accounts without AES encryption +guid: 8ff729ab-74e2-44a7-b02b-e9a81137c44b +prebuilt: false +platforms: Active Directory +category: Active Directory Hygiene +description: SPN accounts with an explicit supported encryption type configuration that excludes AES128 and AES256 ticket encryption. Unset and empty configurations are excluded because updated domain controllers default to AES under CVE-2026-20833 enforcement. Assumes default KDC settings and available AES keys; does not inspect effective KDC policy or key material. +query: |- + MATCH (n:Base) + WHERE n.hasspn = true + AND ANY(encryption_type IN n.supportedencryptiontypes + WHERE encryption_type <> 'Not defined') + AND NONE(encryption_type IN n.supportedencryptiontypes WHERE encryption_type IN [ + 'AES128-CTS-HMAC-SHA1-96', + 'AES256-CTS-HMAC-SHA1-96' + ]) + RETURN n + LIMIT 1000 +revision: 1 +resources: +- https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/6cfc7b50-11ed-4b4d-846d-6f08f0812919 +- https://support.microsoft.com/en-us/servicing/os/windows/2025/11/how-to-manage-kerberos-kdc-usage-of-rc4-for-service-account-ticket-issuance-changes-related-to-cve-2 +acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Large default groups with outbound control of OUs.yml b/queries/Large default groups with outbound control of OUs.yml deleted file mode 100644 index 68e69ae..0000000 --- a/queries/Large default groups with outbound control of OUs.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Large default groups with outbound control of OUs -guid: 310b3626-f8e6-4ab0-832c-72df6048597f -prebuilt: false -platforms: Active Directory -category: Dangerous Privileges -description: -query: |- - MATCH p=(n:Group)-[]->(:OU) - WHERE n.objectid ENDS WITH "-513" // DOMAIN USERS - OR n.objectid ENDS WITH "-515" // DOMAIN COMPUTERS - OR n.objectid ENDS WITH "-S-1-5-11" // AUTHENTICATED USERS - OR n.objectid ENDS WITH "-S-1-1-0" // EVERYONE - OR n.objectid ENDS WITH "S-1-5-32-545" // USERS - OR n.objectid ENDS WITH "S-1-5-32-546" // GUESTS - OR n.objectid ENDS WITH "S-1-5-7" // ANONYMOUS - RETURN p -revision: 1 -resources: -acknowledgements: Martin Sohn Christensen, @martinsohndk - diff --git a/queries/Large default groups with outbound control.yml b/queries/Large default groups with outbound control.yml index cfcb4ac..e7d4578 100644 --- a/queries/Large default groups with outbound control.yml +++ b/queries/Large default groups with outbound control.yml @@ -5,16 +5,19 @@ platforms: Active Directory category: Dangerous Privileges description: query: |- - MATCH p=(n:Group)-[:Owns|GenericAll|GenericWrite|WriteOwner|WriteDacl|ForceChangePassword|AllExtendedRights|AddMember|AllowedToDelegate|AllowedToAct|AdminTo|CanPSRemote|CanRDP|ExecuteDCOM|HasSIDHistory|AddSelf|DCSync|ReadLAPSPassword|ReadGMSAPassword|DumpSMSAPassword|SQLAdmin|AddAllowedToAct|WriteSPN|AddKeyCredentialLink|SyncLAPSPassword|WriteAccountRestrictions|GoldenCert|ADCSESC1|ADCSESC3|ADCSESC4|ADCSESC5|ADCSESC6a|ADCSESC6b|ADCSESC7|ADCSESC9a|ADCSESC9b|ADCSESC10a|ADCSESC10b|ADCSESC13]->(:Base) - WHERE n.objectid ENDS WITH "-513" // DOMAIN USERS - OR n.objectid ENDS WITH "-515" // DOMAIN COMPUTERS - OR n.objectid ENDS WITH "-S-1-5-11" // AUTHENTICATED USERS - OR n.objectid ENDS WITH "-S-1-1-0" // EVERYONE - OR n.objectid ENDS WITH "S-1-5-32-545" // USERS - OR n.objectid ENDS WITH "S-1-5-32-546" // GUESTS - OR n.objectid ENDS WITH "S-1-5-7" // ANONYMOUS + MATCH p=(n:Group)-[r:AD_ATTACK_PATHS]->(:Base) + WHERE ( + n.objectid ENDS WITH "-513" // DOMAIN USERS + OR n.objectid ENDS WITH "-515" // DOMAIN COMPUTERS + OR n.objectid ENDS WITH "-S-1-5-11" // AUTHENTICATED USERS + OR n.objectid ENDS WITH "-S-1-1-0" // EVERYONE + OR n.objectid ENDS WITH "S-1-5-32-545" // USERS + OR n.objectid ENDS WITH "S-1-5-32-546" // GUESTS + OR n.objectid ENDS WITH "S-1-5-7" // ANONYMOUS + ) + AND NOT type(r) IN ["MemberOf", "ClaimSpecialIdentity"] RETURN p -revision: 1 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Map AD site topology.yml b/queries/Map AD site topology.yml new file mode 100644 index 0000000..66c3cdc --- /dev/null +++ b/queries/Map AD site topology.yml @@ -0,0 +1,14 @@ +name: Map AD site topology +guid: 23665310-99fc-4a14-b00c-1739cad3a6be +prebuilt: true +platforms: Active Directory +category: AD Sites +description: Shows sites and the site-server or site-subnet objects they contain. +query: |- + MATCH p = (:Site)-[:Contains]->(n:Base) + WHERE n:SiteServer OR n:SiteSubnet + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: diff --git a/queries/Map site controllers.yml b/queries/Map site controllers.yml new file mode 100644 index 0000000..5b35292 --- /dev/null +++ b/queries/Map site controllers.yml @@ -0,0 +1,14 @@ +name: Map site controllers +guid: d7d7e8c6-971c-48ca-a77e-92228ed01402 +prebuilt: true +platforms: Active Directory +category: AD Sites +description: Shows sites, their controllers, and the site-server or site-subnet objects they contain. +query: |- + MATCH p = (:Base)-[:Owns|WriteOwner|WriteDacl|GenericAll|GenericWrite|WriteGPLink]->(:Site)-[:Contains]->(n:Base) + WHERE n:SiteServer OR n:SiteSubnet + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: diff --git a/queries/Map site servers to computers.yml b/queries/Map site servers to computers.yml new file mode 100644 index 0000000..4938086 --- /dev/null +++ b/queries/Map site servers to computers.yml @@ -0,0 +1,13 @@ +name: Map site servers to computers +guid: aacb8a10-e2ab-4ba1-a6c5-f2896502299e +prebuilt: true +platforms: Active Directory +category: AD Sites +description: Shows SiteServer nodes and the Computer objects they resolve to through ServerIs. +query: |- + MATCH p = (:Site)-[:Contains]->(:SiteServer)-[:ServerIs]->(:Computer) + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: diff --git a/queries/Map site-linked GPO controllers.yml b/queries/Map site-linked GPO controllers.yml new file mode 100644 index 0000000..ad87ccb --- /dev/null +++ b/queries/Map site-linked GPO controllers.yml @@ -0,0 +1,14 @@ +name: Map site-linked GPO controllers +guid: 3fa47036-3999-410a-834c-680ec8dfdd0b +prebuilt: true +platforms: Active Directory +category: AD Sites +description: Shows site-linked GPO controllers and affected site-servers and site-subnets +query: |- + MATCH p = (:Base)-[:Owns|WriteOwner|WriteDacl|GenericAll|GenericWrite]->(:GPO)-[:GPLink]->(:Site)-[:Contains]->(n:Base) + WHERE n:SiteServer OR n:SiteSubnet + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: diff --git a/queries/Map site-linked GPOs.yml b/queries/Map site-linked GPOs.yml new file mode 100644 index 0000000..783e377 --- /dev/null +++ b/queries/Map site-linked GPOs.yml @@ -0,0 +1,14 @@ +name: Map site-linked GPOs +guid: 5bd38b39-bdc9-4f4c-9463-fe0631904fe7 +prebuilt: true +platforms: Active Directory +category: AD Sites +description: Shows site-linked GPOs and affected site-servers and site-subnets +query: |- + MATCH p = (:GPO)-[:GPLink]->(:Site)-[:Contains]->(n:Base) + WHERE n:SiteServer OR n:SiteSubnet + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: diff --git a/queries/Non-Tier Zero Principals with Control of Disabled Objects.yml b/queries/Non-Tier Zero Principals with Control of Disabled Objects.yml new file mode 100644 index 0000000..f2bf462 --- /dev/null +++ b/queries/Non-Tier Zero Principals with Control of Disabled Objects.yml @@ -0,0 +1,17 @@ +name: Non-Tier Zero Principals with Control of Disabled Objects +guid: dbe1220a-2f30-4af1-8090-a79f347cb5ba +prebuilt: false +platforms: Active Directory +category: Active Directory Hygiene +description: Identifies non-Tier Zero users, groups, or computers with dangerous control permissions over disabled user or computer objects. +query: |- + MATCH p = (source)-[r:GenericAll|GenericWrite|WriteDacl|WriteAccountRestrictions|WriteOwner|Owns|OwnsLimitedRights|WriteOwnerLimitedRights]->(target) + WHERE (source:User OR source:Group OR source:Computer) + AND NOT (source:Tag_Tier_Zero) + AND (target:User OR target:Computer) + AND target.enabled = false + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: NCV, @nickvourd diff --git a/queries/Non-Tier Zero accounts with WriteDACL on Tier Zero group.yml b/queries/Non-Tier Zero accounts with WriteDACL on Tier Zero group.yml new file mode 100644 index 0000000..d07a4cf --- /dev/null +++ b/queries/Non-Tier Zero accounts with WriteDACL on Tier Zero group.yml @@ -0,0 +1,15 @@ +name: Non-Tier Zero accounts with WriteDACL on Tier Zero group +guid: d9e80a24-93a8-4847-b354-762b7ac7510d +prebuilt: false +platforms: Active Directory +category: Dangerous Privileges +description: +query: |- + MATCH p=(n:Base)-[:WriteDacl]->(g:Group) + WHERE NOT (n:Tag_Tier_Zero) + AND (g:Tag_Tier_Zero) + RETURN p +revision: 1 +resources: +acknowledgements: Martin Sohn Christensen, @martinsohndk + 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 51c3eec..f5a39ac 100644 --- a/queries/Non-Tier Zero principals with control of AdminSDHolder.yml +++ b/queries/Non-Tier Zero principals with control of AdminSDHolder.yml @@ -5,11 +5,11 @@ platforms: Active Directory 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) + MATCH p=(n:Group)-[r:AD_ATTACK_PATHS]->(m:Container) WHERE NOT (n:Tag_Tier_Zero) AND m.name STARTS WITH "ADMINSDHOLDER@" RETURN p -revision: 2 +revision: 3 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/Principals With Direct Rights Assignment.yml b/queries/Principals With Direct Rights Assignment.yml index 8141476..70bf3b9 100644 --- a/queries/Principals With Direct Rights Assignment.yml +++ b/queries/Principals With Direct Rights Assignment.yml @@ -5,15 +5,16 @@ platforms: Active Directory category: Active Directory Hygiene description: Identifies principals with rights assigned directly to them instead of to groups they are a member of. Active Directory best practice requires granting rights to groups, then adding users as group members. This role-based access control (RBAC) approach ensures permissions are easily auditable and manageable. Results include inherited rights, which must be modified at the parent container level. query: |- - MATCH p=(n:Base)-[r:GenericAll|GenericWrite|WriteOwner|WriteDacl|ForceChangePassword|AllExtendedRights|AddMember|AllowedToDelegate|AllowedToAct|AdminTo|CanPSRemote|CanRDP|ExecuteDCOM|AddSelf|DCSync|ReadLAPSPassword|ReadGMSAPassword|DumpSMSAPassword|AddAllowedToAct|WriteSPN|AddKeyCredentialLink|SyncLAPSPassword|WriteAccountRestrictions|WriteGPLink|ADCSESC1|ADCSESC3|ADCSESC4|ADCSESC6a|ADCSESC6b|ADCSESC9a|ADCSESC9b|ADCSESC10a|ADCSESC10b|ADCSESC13|WriteOwnerLimitedRights|OwnsLimitedRights|CanApplyGPO|ManageCA|ManageCertificates]->(:Base) - WHERE (n:User OR n:Computer) + MATCH p=(s:Base)-[r:AD_ATTACK_PATHS]->(t:Base) + WHERE (s:User OR s:Computer) + AND NOT type(r) IN ["MemberOf"] // Uncomment the below to only search enabled principals. - // AND n.enabled = true + // AND s.enabled = true RETURN p LIMIT 1000 -revision: 2 +revision: 3 resources: https://csrc.nist.gov/Projects/Role-Based-Access-Control acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/queries/Shortest Paths from Owned Principals to Disabled Objects.yml b/queries/Shortest Paths from Owned Principals to Disabled Objects.yml new file mode 100644 index 0000000..ad7efa5 --- /dev/null +++ b/queries/Shortest Paths from Owned Principals to Disabled Objects.yml @@ -0,0 +1,17 @@ +name: Shortest Paths from Owned Principals to Disabled Objects +guid: 22dd14ca-2893-45ae-b07a-1b9868e93772 +prebuilt: false +platforms: Active Directory +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((source:Tag_Owned)-[:ALL_ATTACK_PATHS*1..]->(target:Base)) + WHERE source<>target + AND (target:User OR target:Computer) + AND target.enabled = false + RETURN p + LIMIT 1000 +revision: 1 +resources: +acknowledgements: NCV, @nickvourd 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 1edadba..40a8688 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 @@ -9,7 +9,7 @@ query: |- MATCH (n:Base)-[:MemberOf*1..]->(m:Group) WHERE (n:Tag_Tier_Zero) AND (n:User OR n:Computer) - AND m.objectid ENDS WITH '-519' + AND m.objectid ENDS WITH '-572' // Denied RODC Password Replication Group WITH COLLECT(n.objectid) AS MembersOfDeniedGroup // Get all Tier Zero accounts @@ -20,7 +20,7 @@ query: |- // Filter the members of Denied RODC Password Replication Group AND NOT x.objectid IN MembersOfDeniedGroup RETURN x -revision: 3 +revision: 4 resources: 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 1cd4824..5a0b461 100644 --- a/queries/Users which do not require password to authenticate.yml +++ b/queries/Users which do not require password to authenticate.yml @@ -7,11 +7,9 @@ description: query: |- MATCH (u:User) WHERE u.passwordnotreqd = true - AND u.enabled = true - // Exclude interdomain trust accounts via UAC bit (0x0800 = 2048) - AND NOT u.useraccountcontrol % 4096 < 2048 RETURN u + RETURN u LIMIT 100 -revision: 2 +revision: 3 resources: acknowledgements: diff --git a/queries/Users with non-default Primary Group membership.yml b/queries/Users with non-default Primary Group membership.yml index 190f7aa..6e6bde7 100644 --- a/queries/Users with non-default Primary Group membership.yml +++ b/queries/Users with non-default Primary Group membership.yml @@ -6,13 +6,23 @@ category: Active Directory Hygiene description: query: |- MATCH p=(n:User)-[r:MemberOf]->(g:Group) - WHERE NOT g.objectid ENDS WITH "-513" // Domain Users - AND r.isprimarygroup = true - AND NOT n.objectid ENDS WITH "-501" // Guests account, as it has primaryGroup to Guests - AND (n.gmsa IS NULL OR n.gmsa = false) // Not gMSA, as it has primaryGroup to Domain Computers - AND (n.msa IS NULL OR n.msa = false) // Not MSA, as it has primaryGroup to Domain Computers + WHERE r.isprimarygroup = true + AND ( + ( + n.objectid ENDS WITH "-501" // User: Guest + AND NOT g.objectid ENDS WITH "-514" // Group: Domain Guests + ) + OR + ( + NOT n.objectid ENDS WITH "-501" // Other users + AND NOT g.objectid ENDS WITH "-513" // Group: Domain Users + ) + ) + AND (n.gmsa IS NULL OR n.gmsa = false) + AND (n.msa IS NULL OR n.msa = false) RETURN p -revision: 1 + LIMIT 1000 +revision: 2 resources: acknowledgements: Martin Sohn Christensen, @martinsohndk diff --git a/utilities/python/run_tenant_queries.py b/utilities/python/run_tenant_queries.py new file mode 100644 index 0000000..5fc1c82 --- /dev/null +++ b/utilities/python/run_tenant_queries.py @@ -0,0 +1,183 @@ +r"""Run every query in the library against a BloodHound tenant, one at a time. + +Credentials are read from the repository's .env file. The file may use either +KEY=value entries or BloodHound's defautlt output format: ``Key: value`` / ``ID: value`` + +Run with: +.\.venv\Scripts\python.exe utilities\python\run_tenant_queries.py --tenant https://localhost:8080 --show-http-200 +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import json +import os +from pathlib import Path +import sys +from datetime import datetime +from collections import Counter +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +import yaml + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +ENDPOINT = "/api/v2/graphs/cypher" + + +def load_env(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.is_file(): + return values + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + separators = [position for position in (line.find("="), line.find(":")) if position >= 0] + if not separators: + continue + position = min(separators) + name, value = line[:position], line[position + 1:] + values[name.strip().lower()] = value.strip().strip("\"'") + return values + + +def credentials() -> tuple[str, str]: + env = load_env(REPOSITORY_ROOT / ".env") + key = os.environ.get("BH_API_KEY") or env.get("key") or env.get("bh_api_key") + token_id = os.environ.get("BH_API_ID") or env.get("id") or env.get("bh_api_id") + if not key or not token_id: + raise ValueError(".env must contain Key: and ID: (or set BH_API_KEY and BH_API_ID).") + return token_id, key + + +def signed_request(url: str, token_id: str, token_key: str, body: bytes, timeout: int): + request_date = datetime.now().astimezone().isoformat("T") + digester = hmac.new(token_key.encode(), ("POST" + ENDPOINT).encode(), hashlib.sha256) + digester = hmac.new(digester.digest(), request_date[:13].encode(), hashlib.sha256) + digester = hmac.new(digester.digest(), body, hashlib.sha256) + request = Request( + url.rstrip("/") + ENDPOINT, + data=body, + method="POST", + headers={ + "Authorization": f"bhesignature {token_id}", + "RequestDate": request_date, + "Signature": base64.b64encode(digester.digest()).decode(), + "Content-Type": "application/json", + "User-Agent": "BloodHoundQueryLibrary tenant query runner", + }, + ) + try: + with urlopen(request, timeout=timeout) as response: + return response.status, response.read().decode("utf-8", errors="replace") + except HTTPError as error: + return error.code, error.read().decode("utf-8", errors="replace") + except URLError as error: + raise RuntimeError(f"Request failed: {error.reason}") from error + + +def is_api_error(status: int, payload: object) -> bool: + if not 200 <= status < 300: + return True + if isinstance(payload, dict): + if payload.get("errors"): + return True + status_value = str(payload.get("status", "")).lower() + if status_value in {"error", "failed"}: + return True + return False + + +def result_count(payload: object) -> int | None: + if not isinstance(payload, dict): + return None + data = payload.get("data") + if isinstance(data, dict): + graph_keys = ("nodes", "edges", "literals") + if any(key in data for key in graph_keys): + return sum( + len(data[key]) + for key in graph_keys + if isinstance(data.get(key), (dict, list)) + ) + for key in ("nodes", "rows", "results"): + value = data.get(key) + if isinstance(value, (dict, list)): + return len(value) + if isinstance(data, list): + return len(data) + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tenant", required=True, help="BloodHound tenant base URL, for example https://tenant.example.com") + parser.add_argument("--queries", type=Path, default=REPOSITORY_ROOT / "queries") + parser.add_argument("--timeout", type=int, default=1800, help="Per-query HTTP timeout in seconds (default: 1800).") + parser.add_argument( + "--show-http-200", + action="store_true", + help="Print each successful HTTP 200 response and its result content.", + ) + args = parser.parse_args() + + try: + token_id, token_key = credentials() + except ValueError as error: + parser.error(str(error)) + + files = sorted(args.queries.rglob("*.yml")) + if not files: + print(f"No query YAML files found in {args.queries}", file=sys.stderr) + return 2 + + failures = 0 + empty_results = 0 + http_status_counts: Counter[int] = Counter() + print(f"Running {len(files)} queries against {args.tenant} sequentially (timeout {args.timeout}s each).") + for index, path in enumerate(files, 1): + try: + query_doc = yaml.safe_load(path.read_text(encoding="utf-8")) + name = query_doc["name"] + query = query_doc["query"] + body = json.dumps({"query": query, "include_properties": False}).encode("utf-8") + status, response_text = signed_request(args.tenant, token_id, token_key, body, args.timeout) + http_status_counts[status] += 1 + try: + payload = json.loads(response_text) + except json.JSONDecodeError: + payload = {"response": response_text} + count = result_count(payload) + state = "FAIL" if is_api_error(status, payload) else "PASS" + if state == "FAIL": + failures += 1 + elif count == 0: + empty_results += 1 + state = "INFO" + if status != 200 or args.show_http_200: + print(f"\n[{index}/{len(files)}] {state} {name} ({path.name}) HTTP {status}") + if count is not None: + print(f"Result items: {count}" + (" (no results; this may reflect tenant data or query conditions)" if count == 0 else "")) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + except Exception as error: # Keep processing subsequent queries. + failures += 1 + print(f"\n[{index}/{len(files)}] FAIL {path.name}: {type(error).__name__}: {error}") + + print( + f"\nCompleted: {len(files) - failures} passed/informational, {failures} failed, " + f"{empty_results} with no results." + ) + if http_status_counts: + print("HTTP status counts:") + for status, count in sorted(http_status_counts.items()): + print(f" HTTP {status}: {count}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main())