From c4bea1bbfa3c59277c97edf4672a11cd6b9cade1 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 20:20:15 +0100 Subject: [PATCH 1/9] Fixed query validation for queries without a resource --- queries/All Azure VMs with a tied Managed Identity.yml | 2 +- queries/All direct Controllers of MS Graph.yml | 2 +- queries/All privileged Azure Service Principals.yml | 2 +- queries/Owners of Azure Applications.yml | 2 +- queries/Owners of Azure Subscriptions.yml | 2 +- queries/Shortest Paths from Azure Users to Azure Keyvaults.yml | 2 +- queries/Shortest Paths from Azure Users to Azure VMs.yml | 2 +- ...Shortest Paths from Owned Azure Users to Azure Keyvaults.yml | 2 +- queries/Shortest Paths from Owned Azure Users to Azure VMs.yml | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/queries/All Azure VMs with a tied Managed Identity.yml b/queries/All Azure VMs with a tied Managed Identity.yml index 11c535e..f4e161a 100644 --- a/queries/All Azure VMs with a tied Managed Identity.yml +++ b/queries/All Azure VMs with a tied Managed Identity.yml @@ -9,5 +9,5 @@ query: |- MATCH p=(:AZVM)-[:AZManagedIdentity]->(n) RETURN p revision: 1 -resources: - +resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/All direct Controllers of MS Graph.yml b/queries/All direct Controllers of MS Graph.yml index 4978285..55b9bda 100644 --- a/queries/All direct Controllers of MS Graph.yml +++ b/queries/All direct Controllers of MS Graph.yml @@ -10,5 +10,5 @@ query: |- WHERE g.displayname = "MICROSOFT GRAPH" RETURN p revision: 1 -resources: - +resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/All privileged Azure Service Principals.yml b/queries/All privileged Azure Service Principals.yml index ddd9ba4..19ec4c1 100644 --- a/queries/All privileged Azure Service Principals.yml +++ b/queries/All privileged Azure Service Principals.yml @@ -10,5 +10,5 @@ query: |- 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 -resources: - +resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/Owners of Azure Applications.yml b/queries/Owners of Azure Applications.yml index 4d66a57..4c59ba5 100644 --- a/queries/Owners of Azure Applications.yml +++ b/queries/Owners of Azure Applications.yml @@ -9,5 +9,5 @@ query: |- MATCH p = (n)-[r:AZOwns]->(g:AZApp) RETURN p revision: 1 -resources: - +resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/Owners of Azure Subscriptions.yml b/queries/Owners of Azure Subscriptions.yml index d04aa9c..5b54239 100644 --- a/queries/Owners of Azure Subscriptions.yml +++ b/queries/Owners of Azure Subscriptions.yml @@ -11,5 +11,5 @@ query: |- RETURN p LIMIT 1000 revision: 1 -resources: - +resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/Shortest Paths from Azure Users to Azure Keyvaults.yml b/queries/Shortest Paths from Azure Users to Azure Keyvaults.yml index 5773e92..3b1b6aa 100644 --- a/queries/Shortest Paths from Azure Users to Azure Keyvaults.yml +++ b/queries/Shortest Paths from Azure Users to Azure Keyvaults.yml @@ -9,5 +9,5 @@ query: |- MATCH p = shortestPath((n:AZUser)-[:AZ_ATTACK_PATHS*..]->(g:AZKeyVault)) RETURN p revision: 1 -resources: - +resources: acknowledgements: Daniel Scheidt, @theluemmel diff --git a/queries/Shortest Paths from Azure Users to Azure VMs.yml b/queries/Shortest Paths from Azure Users to Azure VMs.yml index 0a1df47..e3e25c0 100644 --- a/queries/Shortest Paths from Azure Users to Azure VMs.yml +++ b/queries/Shortest Paths from Azure Users to Azure VMs.yml @@ -9,5 +9,5 @@ query: |- MATCH p = shortestPath((m:AZUser)-[:AZ_ATTACK_PATHS*..]->(n:AZVM)) RETURN p revision: 1 -resources: - +resources: acknowledgements: Daniel Scheidt, @theluemmel 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 b14985a..8c0e010 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 @@ -10,5 +10,5 @@ query: |- WHERE m.system_tags CONTAINS 'owned' RETURN p revision: 1 -resources: - +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 506e97a..f8e86a9 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 @@ -10,5 +10,5 @@ query: |- WHERE m.system_tags CONTAINS 'owned' RETURN p revision: 1 -resources: - +resources: acknowledgements: Daniel Scheidt, @theluemmel From 1c27f354a92768e1e141764a645acfd371666c27 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 21:16:20 +0100 Subject: [PATCH 2/9] Add ability to specify output format (zip/json) --- utilities/python/convert.py | 80 ++++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/utilities/python/convert.py b/utilities/python/convert.py index 3e92769..f9cd2dd 100644 --- a/utilities/python/convert.py +++ b/utilities/python/convert.py @@ -1,16 +1,62 @@ -from typing_extensions import Annotated +from typing_extensions import Annotated, Optional from pathlib import Path from schema import CypherQuery +from io import TextIOWrapper import json -import glob import typer import yaml +import zipfile + + +ALLOWED_FORMATS = ["json", "zip"] app = typer.Typer() +def validate_format(value: str): + if value.lower() not in ALLOWED_FORMATS: + raise typer.BadParameter(f"Only {', '.join(ALLOWED_FORMATS)} is allowed") + return value + + +class QueryBundle: + def __init__(self, queries: list[CypherQuery]): + self.queries = queries + + @staticmethod + def load_query(cypher_query: Path) -> CypherQuery: + with open(cypher_query, "r") as yaml_file: + yaml_obj = yaml.safe_load(yaml_file) + return CypherQuery(**yaml_obj) + + @classmethod + def from_path(cls, input_dir: Path) -> "QueryBundle": + cypher_queries = list(input_dir.rglob("*.yml")) + queries = [ + QueryBundle.load_query(cypher_query) for cypher_query in cypher_queries + ] + return cls(queries) + + def to_json(self, output_file: TextIOWrapper) -> None: + all_objects = [query.model_dump() for query in self.queries] + output_file.write(json.dumps(all_objects, indent=2)) + + def to_zip(self, output_file: TextIOWrapper) -> None: + with zipfile.ZipFile( + file=output_file.name, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + for query in self.queries: + archive.writestr( + zinfo_or_arcname=f"{query.name}.json", + data=query.model_dump_json().encode(), + ) + + @app.command() -def to_json( +def convert( input_dir: Annotated[ Path, typer.Argument( @@ -21,19 +67,25 @@ def to_json( resolve_path=True, ), ], - output_file: Annotated[typer.FileTextWrite, typer.Argument()] + output_file: Annotated[typer.FileTextWrite, typer.Argument()], + file_format: Annotated[ + Optional[str], + typer.Option(help="Format for export (json/zip)", callback=validate_format), + ] = "json", ): - cypher_queries = glob.glob(f"{input_dir}/**/*.yml", recursive=True) - typer.echo(f"Converting Queries {len(cypher_queries)} to combined JSON") - all_objects = [] - for cypher_query in cypher_queries: - with open(cypher_query, "r") as yaml_file: - yaml_obj = yaml.safe_load(yaml_file) - query = CypherQuery(**yaml_obj) - all_objects.append(query.model_dump()) - output_file.write(json.dumps(all_objects, indent=2)) - typer.echo(f"Finished converting Cypher queries to JSON to {output_file.name}") + typer.echo(f"Converting queries to {file_format} output") + cypher_queries = QueryBundle.from_path(input_dir=input_dir) + + if file_format == "json": + cypher_queries.to_json(output_file) + + else: + cypher_queries.to_zip(output_file) + + typer.echo( + f"Finished converting {len(cypher_queries.queries)} Cypher queries ({file_format}) to {output_file.name}" + ) if __name__ == "__main__": From 6d32cf9cea16dc7eeaba47d23395ca6124b8bb19 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 21:40:30 +0100 Subject: [PATCH 3/9] Remove query bundle generation --- .github/workflows/syntax.yml | 79 ------------------------------------ .github/workflows/test.yml | 37 +++++++++++++++++ 2 files changed, 37 insertions(+), 79 deletions(-) delete mode 100644 .github/workflows/syntax.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/syntax.yml b/.github/workflows/syntax.yml deleted file mode 100644 index 982ef47..0000000 --- a/.github/workflows/syntax.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Query syntax validation -on: - pull_request: - branches: [ 'main' ] - - workflow_dispatch: - -permissions: - contents: write - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Test queries with pytest - run: | - pytest tests/test_cypher_syntax.py - - - name: Add test report to summary - run: cat test-report.md >> $GITHUB_STEP_SUMMARY - - - name: Upload test report - uses: actions/upload-artifact@v4 - with: - name: test-report - path: test-report.md - - build: - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Convert queries into single json - run: | - python utilities/python/convert.py ./queries ./Queries.json - - - name: Configure Git - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Commit if changed - run: | - git add ./Queries.json - if git diff --staged --quiet; then - echo "No changes to commit" - else - git commit -m "Update combined queries" - git push - fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7db1ff7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Query syntax validation +on: + pull_request: + branches: [ 'main' ] + + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Test queries with pytest + run: | + pytest tests/test_cypher_syntax.py + + - name: Add test report to summary + run: cat test-report.md >> $GITHUB_STEP_SUMMARY + + - name: Upload test report + uses: actions/upload-artifact@v4 + with: + name: test-report + path: test-report.md From a56587b9d4161ab9307799ca59ca93850034806f Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 22:00:22 +0100 Subject: [PATCH 4/9] Names have to be unique + draft release --- .github/workflows/release.yml | 67 +++++++++++++++++++ ...d Tier Zero High Value principals - AD.yml | 2 +- ...d Tier Zero High Value principals - AZ.yml | 2 +- queries/Locations of Owned objects - AD.yml | 2 +- queries/Locations of Owned objects - AZ.yml | 2 +- 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e18560 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Generate query bundle and push release +on: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Test queries with pytest + run: | + pytest tests/test_cypher_syntax.py + + - name: Add test report to summary + run: cat test-report.md >> $GITHUB_STEP_SUMMARY + + - name: Upload test report + uses: actions/upload-artifact@v4 + with: + name: test-report + path: test-report.md + + + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Convert queries into single zip file + run: | + python utilities/python/convert.py ./queries ./Queries.zip --file-format zip + + - name: Set metadata + id: release_meta + run: | + release_date="$(date -u +%Y-%m-%d)" + echo "release_date=${release_date}" >> "$GITHUB_OUTPUT" + echo "release_tag=queries-${release_date}" >> "$GITHUB_OUTPUT" + echo "release_name=Queries ${release_date}" >> "$GITHUB_OUTPUT" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.release_meta.outputs.release_tag }} + name: ${{ steps.release_meta.outputs.release_name }} + files: Queries.zip + # generate_release_notes: true + draft: true diff --git a/queries/Disabled Tier Zero High Value principals - AD.yml b/queries/Disabled Tier Zero High Value principals - AD.yml index c1ce266..ae835e0 100644 --- a/queries/Disabled Tier Zero High Value principals - AD.yml +++ b/queries/Disabled Tier Zero High Value principals - AD.yml @@ -1,4 +1,4 @@ -name: Disabled Tier Zero / High Value principals +name: Disabled Tier Zero / High Value principals (AD) guid: d65a801f-d3ef-4b7e-8030-99ebfd6dad12 prebuilt: true platforms: Active Directory diff --git a/queries/Disabled Tier Zero High Value principals - AZ.yml b/queries/Disabled Tier Zero High Value principals - AZ.yml index d3f0899..2f81497 100644 --- a/queries/Disabled Tier Zero High Value principals - AZ.yml +++ b/queries/Disabled Tier Zero High Value principals - AZ.yml @@ -1,4 +1,4 @@ -name: Disabled Tier Zero / High Value principals +name: Disabled Tier Zero / High Value principals (AZ) guid: 860d5c2d-84fe-4c85-80de-e0a9badbd0e7 prebuilt: true platforms: Azure diff --git a/queries/Locations of Owned objects - AD.yml b/queries/Locations of Owned objects - AD.yml index 81e2154..4a12f81 100644 --- a/queries/Locations of Owned objects - AD.yml +++ b/queries/Locations of Owned objects - AD.yml @@ -1,4 +1,4 @@ -name: Locations of Owned objects +name: Locations of Owned objects (AD) guid: c88bfab4-3da0-4b36-b71d-7b324ebd2243 prebuilt: false platforms: Active Directory diff --git a/queries/Locations of Owned objects - AZ.yml b/queries/Locations of Owned objects - AZ.yml index df528f2..b758a9b 100644 --- a/queries/Locations of Owned objects - AZ.yml +++ b/queries/Locations of Owned objects - AZ.yml @@ -1,4 +1,4 @@ -name: Locations of Owned objects +name: Locations of Owned objects (AZ) guid: 350b8b8a-ea4c-44f3-874b-c9316de6c41b prebuilt: false platforms: Azure From 8b029800e0eb3654ed418b8f5d87eadea74ecffa Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 22:02:43 +0100 Subject: [PATCH 5/9] Test release --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e18560..dae0c87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,9 @@ name: Generate query bundle and push release on: workflow_dispatch: + push: + branches: + - feature/ziprelease jobs: test: From 4079561af0bec59b64a624651827b4ebc3c10ccd Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 22:13:50 +0100 Subject: [PATCH 6/9] Add description to release --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dae0c87..5425ea9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,8 @@ jobs: with: tag_name: ${{ steps.release_meta.outputs.release_tag }} name: ${{ steps.release_meta.outputs.release_name }} - files: Queries.zip - # generate_release_notes: true + files: | + Queries.zip + body: | + This release contains all queries exported as JSON and bundled in a single file. The compressed .zip file can be uploaded to BloodHound to bulk-import all queries. draft: true From 38f855e2f49900134e9e18088cc969438605bf4a Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 22:19:47 +0100 Subject: [PATCH 7/9] Add json bundle to release --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5425ea9..d098287 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,10 @@ jobs: - name: Convert queries into single zip file run: | python utilities/python/convert.py ./queries ./Queries.zip --file-format zip + + - name: Convert queries into single json file + run: | + python utilities/python/convert.py ./queries ./Queries.json - name: Set metadata id: release_meta @@ -67,6 +71,7 @@ jobs: name: ${{ steps.release_meta.outputs.release_name }} files: | Queries.zip + Queries.json body: | This release contains all queries exported as JSON and bundled in a single file. The compressed .zip file can be uploaded to BloodHound to bulk-import all queries. draft: true From e77025e386e3f67a8b4825afaf2f3644b9764bd8 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 22:21:42 +0100 Subject: [PATCH 8/9] Add job dependency --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d098287..a48a35c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,7 @@ jobs: release: runs-on: ubuntu-latest + needs: test steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 From 89c00bee54e1f6f7193c6c51ea1c763def046ea5 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 22 Dec 2025 22:27:08 +0100 Subject: [PATCH 9/9] Change trigger to main branch --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a48a35c..a20f418 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: push: branches: - - feature/ziprelease + - main jobs: test: