├── requirements.txt ├── MANIFEST.in ├── .github ├── FUNDING.yml └── workflows │ ├── simple-tests.yml │ ├── python-poetry-build.yml │ ├── python-pip-build.yml │ ├── release-tests.yml │ ├── auto_prefix_issues.yml │ └── tests.yml ├── bhopengraph ├── tests │ ├── __init__.py │ ├── run_tests.py │ ├── datasets │ │ └── generate.py │ ├── test_Edge.py │ ├── test_Node.py │ └── test_Properties.py ├── __init__.py ├── utils.py ├── Logger.py ├── Properties.py ├── Node.py ├── __main__.py ├── Edge.py └── OpenGraph.py ├── docs ├── api │ ├── Edge.rst │ ├── Node.rst │ ├── Logger.rst │ ├── utils.rst │ ├── OpenGraph.rst │ ├── Properties.rst │ └── index.rst ├── requirements.txt ├── installation.rst ├── index.rst ├── examples │ └── index.rst ├── contributing.rst ├── quickstart.rst └── conf.py ├── pyproject.toml ├── .readthedocs.yml ├── LICENSE ├── examples ├── minimal_working_json.py ├── 01_simple_path_finding.py ├── 02_node_types_and_properties.py ├── 03_edge_operations.py ├── 04_graph_analysis.py └── advanced_features.py ├── Makefile ├── README.md ├── .gitignore └── setup.py /requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | recursive-include bhopengraph * -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: p0dalirius 4 | patreon: Podalirius -------------------------------------------------------------------------------- /bhopengraph/tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Test package for bhopengraph. 5 | """ 6 | -------------------------------------------------------------------------------- /docs/api/Edge.rst: -------------------------------------------------------------------------------- 1 | Edge 2 | ==== 3 | 4 | .. automodule:: bhopengraph.Edge 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/Node.rst: -------------------------------------------------------------------------------- 1 | Node 2 | ==== 3 | 4 | .. automodule:: bhopengraph.Node 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/Logger.rst: -------------------------------------------------------------------------------- 1 | Logger 2 | ====== 3 | 4 | .. automodule:: bhopengraph.Logger 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/utils.rst: -------------------------------------------------------------------------------- 1 | Utilities 2 | ========= 3 | 4 | .. automodule:: bhopengraph.utils 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/OpenGraph.rst: -------------------------------------------------------------------------------- 1 | OpenGraph 2 | ========= 3 | 4 | .. automodule:: bhopengraph.OpenGraph 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/Properties.rst: -------------------------------------------------------------------------------- 1 | Properties 2 | ========== 3 | 4 | .. automodule:: bhopengraph.Properties 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Documentation building requirements 2 | sphinx>=6.0.0 3 | sphinx-rtd-theme>=1.3.0 4 | sphinx-autodoc-typehints>=1.24.0 5 | sphinxcontrib-applehelp>=1.0.4 6 | sphinxcontrib-devhelp>=1.0.2 7 | sphinxcontrib-htmlhelp>=2.0.1 8 | sphinxcontrib-jsmath>=1.0.1 9 | sphinxcontrib-qthelp>=1.0.3 10 | sphinxcontrib-serializinghtml>=1.1.5 11 | 12 | # For better code documentation 13 | myst-parser>=1.0.0 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "bhopengraph" 3 | version = "1.3.0" 4 | description = "A python library to create BloodHound OpenGraphs easily" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | license = { text = "MIT" } 8 | authors = [ 9 | { name = "p0dalirius" } 10 | ] 11 | dependencies = [ 12 | ] 13 | 14 | [tool.poetry.scripts] 15 | bhopengraph = "bhopengraph.__main__:main" 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.7.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============ 3 | 4 | This section contains the complete API reference for bhopengraph. 5 | 6 | Core Classes 7 | ----------- 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | OpenGraph 13 | Node 14 | Edge 15 | Properties 16 | 17 | Utilities 18 | --------- 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | Logger 24 | utils 25 | 26 | Module Index 27 | ----------- 28 | 29 | .. toctree:: 30 | :maxdepth: 2 31 | 32 | modules 33 | 34 | Indices and tables 35 | ================== 36 | 37 | * :ref:`genindex` 38 | * :ref:`modindex` 39 | * :ref:`search` 40 | -------------------------------------------------------------------------------- /bhopengraph/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : __init__.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | """ 8 | OpenGraph module for BloodHound integration. 9 | 10 | This module provides Python classes for creating and managing graph structures 11 | that are compatible with BloodHound OpenGraph. 12 | """ 13 | 14 | from .Edge import Edge 15 | from .Node import Node 16 | from .OpenGraph import OpenGraph 17 | from .Properties import Properties 18 | 19 | __version__ = "1.1.0" 20 | __author__ = "Remi Gascou (@podalirius_)" 21 | 22 | __all__ = ["Properties", "Node", "Edge", "OpenGraph"] 23 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | # Set the OS, Python version and other tools you might need 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.11" 11 | 12 | # Build documentation in the docs/ directory with Sphinx 13 | sphinx: 14 | configuration: docs/conf.py 15 | fail_on_warning: false 16 | 17 | # Install the project and its dependencies 18 | python: 19 | install: 20 | - method: pip 21 | path: . 22 | extra_requirements: 23 | - docs 24 | - requirements: docs/requirements.txt 25 | 26 | # Build documentation in additional formats such as PDF and ePub 27 | formats: all 28 | -------------------------------------------------------------------------------- /bhopengraph/tests/run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Test runner script for bhopengraph. 5 | Discovers and runs all test cases. 6 | """ 7 | 8 | import os 9 | import sys 10 | import unittest 11 | 12 | # Add the parent directory to the path so we can import bhopengraph 13 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 14 | 15 | 16 | def run_all_tests(): 17 | """Discover and run all tests.""" 18 | # Discover tests in the current directory 19 | loader = unittest.TestLoader() 20 | start_dir = os.path.dirname(os.path.abspath(__file__)) 21 | suite = loader.discover(start_dir, pattern="test_*.py") 22 | 23 | # Run the tests 24 | runner = unittest.TextTestRunner(verbosity=2) 25 | result = runner.run(suite) 26 | 27 | # Return exit code 28 | return 0 if result.wasSuccessful() else 1 29 | 30 | 31 | if __name__ == "__main__": 32 | exit_code = run_all_tests() 33 | sys.exit(exit_code) 34 | -------------------------------------------------------------------------------- /.github/workflows/simple-tests.yml: -------------------------------------------------------------------------------- 1 | name: Simple Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | release: 9 | types: [ published, created ] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.11", "3.12"] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -e . 31 | 32 | - name: Run all tests 33 | run: | 34 | cd bhopengraph/tests 35 | python run_tests.py 36 | 37 | - name: Run tests with verbose output 38 | run: | 39 | cd bhopengraph/tests 40 | python -m unittest discover -v 41 | -------------------------------------------------------------------------------- /bhopengraph/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : utils.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 21 Aug 2025 6 | 7 | 8 | def filesize_string(size: int) -> str: 9 | """ 10 | Convert a file size from bytes to a more readable format using the largest appropriate unit. 11 | 12 | This function takes an integer representing a file size in bytes and converts it to a human-readable 13 | string using the largest appropriate unit from bytes (B) to petabytes (PB). The result is rounded to 14 | two decimal places. 15 | 16 | Args: 17 | size (int): The file size in bytes. 18 | 19 | Returns: 20 | str: A string representing the file size in a more readable format, including the appropriate unit. 21 | """ 22 | 23 | units = ["B", "kB", "MB", "GB", "TB", "PB"] 24 | for k in range(len(units)): 25 | if size < (1024 ** (k + 1)): 26 | break 27 | return "%4.2f %s" % (round(size / (1024 ** (k)), 2), units[k]) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Rémi GASCOU (Podalirius) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/python-poetry-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python poetry build 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.11 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.11" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 poetry 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=25 --max-line-length=512 --statistics 37 | - name: Poetry build 38 | run: | 39 | poetry build 40 | - name: Poetry install 41 | run: | 42 | poetry install 43 | -------------------------------------------------------------------------------- /.github/workflows/python-pip-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python pip build 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.11 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.11" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 wheel 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=25 --max-line-length=512 --statistics 37 | - name: Pip build 38 | run: | 39 | python3 setup.py sdist bdist_wheel 40 | - name: Pip install 41 | run: | 42 | python3 setup.py install 43 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Requirements 5 | ----------- 6 | 7 | * Python 3.11 or higher 8 | * pip (Python package installer) 9 | 10 | Installation 11 | ----------- 12 | 13 | Install from PyPI 14 | ~~~~~~~~~~~~~~~~~ 15 | 16 | The easiest way to install bhopengraph is using pip: 17 | 18 | .. code-block:: bash 19 | 20 | pip install bhopengraph 21 | 22 | Install from source 23 | ~~~~~~~~~~~~~~~~~~ 24 | 25 | If you want to install the latest development version, you can install directly from the GitHub repository: 26 | 27 | .. code-block:: bash 28 | 29 | git clone https://github.com/p0dalirius/bhopengraph.git 30 | cd bhopengraph 31 | pip install -e . 32 | 33 | Development installation 34 | ~~~~~~~~~~~~~~~~~~~~~~~ 35 | 36 | For development purposes, you can install the package in editable mode with all development dependencies: 37 | 38 | .. code-block:: bash 39 | 40 | git clone https://github.com/p0dalirius/bhopengraph.git 41 | cd bhopengraph 42 | pip install -e ".[dev]" 43 | 44 | Verifying the installation 45 | ------------------------- 46 | 47 | To verify that bhopengraph has been installed correctly, you can run: 48 | 49 | .. code-block:: python 50 | 51 | python -c "import bhopengraph; print(bhopengraph.__version__)" 52 | 53 | This should output the version number of the installed package. 54 | 55 | Upgrading 56 | --------- 57 | 58 | To upgrade to the latest version: 59 | 60 | .. code-block:: bash 61 | 62 | pip install --upgrade bhopengraph 63 | 64 | Uninstalling 65 | ------------ 66 | 67 | To uninstall bhopengraph: 68 | 69 | .. code-block:: bash 70 | 71 | pip uninstall bhopengraph 72 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to bhopengraph's documentation! 2 | ======================================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/bhopengraph 5 | :target: https://pypi.org/project/bhopengraph/ 6 | :alt: PyPI version 7 | 8 | .. image:: https://img.shields.io/github/release/p0dalirius/bhopengraph 9 | :target: https://github.com/p0dalirius/bhopengraph/releases 10 | :alt: GitHub release 11 | 12 | .. image:: https://img.shields.io/twitter/follow/podalirius_?label=Podalirius&style=social 13 | :target: https://twitter.com/podalirius_ 14 | :alt: Twitter Follow 15 | 16 | .. image:: https://img.shields.io/youtube/channel/subscribers/UCF_x5O7CSfr82AfNVTKOv_A?style=social 17 | :target: https://www.youtube.com/c/Podalirius_ 18 | :alt: YouTube Channel Subscribers 19 | 20 | A Python library to create BloodHound OpenGraphs easily. 21 | 22 | This module provides Python classes for creating and managing graph structures that are compatible with BloodHound OpenGraph. The classes follow the `BloodHound OpenGraph schema `_ and `best practices `_. 23 | 24 | If you don't know about BloodHound OpenGraph yet, a great introduction can be found here: `https://bloodhound.specterops.io/opengraph/best-practices `_ 25 | 26 | .. toctree:: 27 | :maxdepth: 2 28 | :caption: Contents: 29 | 30 | installation 31 | quickstart 32 | api/index 33 | examples/index 34 | contributing 35 | 36 | Indices and tables 37 | ================== 38 | 39 | * :ref:`genindex` 40 | * :ref:`modindex` 41 | * :ref:`search` 42 | -------------------------------------------------------------------------------- /docs/examples/index.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | This section contains various examples demonstrating how to use bhopengraph for different scenarios. 5 | 6 | Basic Examples 7 | ------------- 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | simple_path_finding 13 | node_types_and_properties 14 | edge_operations 15 | graph_analysis 16 | 17 | Advanced Examples 18 | ---------------- 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | advanced_features 24 | minimal_working_json 25 | 26 | Running Examples 27 | --------------- 28 | 29 | All examples can be run directly from the command line. Make sure you have bhopengraph installed first: 30 | 31 | .. code-block:: bash 32 | 33 | # Navigate to the examples directory 34 | cd examples 35 | 36 | # Run a specific example 37 | python 01_simple_path_finding.py 38 | python 02_node_types_and_properties.py 39 | python 03_edge_operations.py 40 | python 04_graph_analysis.py 41 | python advanced_features.py 42 | python minimal_working_json.py 43 | 44 | Example Outputs 45 | -------------- 46 | 47 | Each example generates output files that demonstrate the functionality: 48 | 49 | * **01_simple_path_finding.py**: Creates a simple graph with path finding 50 | * **02_node_types_and_properties.py**: Shows different node types and properties 51 | * **03_edge_operations.py**: Demonstrates edge creation and manipulation 52 | * **04_graph_analysis.py**: Performs basic graph analysis operations 53 | * **advanced_features.py**: Shows advanced features and techniques 54 | * **minimal_working_json.py**: Creates the minimal working JSON example 55 | 56 | These examples follow the BloodHound OpenGraph schema and best practices, making them suitable for learning and as templates for your own projects. 57 | -------------------------------------------------------------------------------- /.github/workflows/release-tests.yml: -------------------------------------------------------------------------------- 1 | name: Release Tests 2 | 3 | on: 4 | release: 5 | types: [ published, created ] 6 | 7 | jobs: 8 | comprehensive-test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.11", "3.12"] 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -e . 27 | 28 | - name: Run all tests 29 | run: | 30 | cd bhopengraph/tests 31 | python run_tests.py 32 | 33 | - name: Run tests with verbose output 34 | run: | 35 | cd bhopengraph/tests 36 | python -m unittest discover -v 37 | 38 | - name: Run tests with coverage 39 | run: | 40 | pip install coverage 41 | coverage run --source=bhopengraph bhopengraph/tests/run_tests.py 42 | coverage report 43 | 44 | build-package: 45 | runs-on: ubuntu-latest 46 | needs: comprehensive-test 47 | 48 | steps: 49 | - name: Checkout code 50 | uses: actions/checkout@v4 51 | 52 | - name: Set up Python 3.12 53 | uses: actions/setup-python@v4 54 | with: 55 | python-version: "3.12" 56 | 57 | - name: Install build dependencies 58 | run: | 59 | python -m pip install --upgrade pip 60 | pip install build wheel 61 | 62 | - name: Build package 63 | run: | 64 | python -m build 65 | 66 | - name: Upload build artifacts 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: bhopengraph-package 70 | path: dist/ 71 | -------------------------------------------------------------------------------- /examples/minimal_working_json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : minimalworkingjson_example.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | """ 8 | Example script demonstrating how to use the OpenGraph classes 9 | to recreate the minimal working JSON from BloodHound documentation. 10 | """ 11 | 12 | from bhopengraph.OpenGraph import OpenGraph 13 | from bhopengraph.Node import Node 14 | from bhopengraph.Edge import Edge 15 | from bhopengraph.Properties import Properties 16 | 17 | if __name__ == "__main__": 18 | """ 19 | Create the minimal working example from BloodHound OpenGraph documentation. 20 | https://bloodhound.specterops.io/opengraph/schema#minimal-working-json 21 | """ 22 | # Create an OpenGraph instance 23 | graph = OpenGraph(source_kind="Base") 24 | 25 | # Create nodes 26 | bob_node = Node( 27 | id="123", 28 | kinds=["Person", "Base"], 29 | properties=Properties( 30 | displayname="bob", 31 | property="a", 32 | objectid="123", 33 | name="BOB" 34 | ) 35 | ) 36 | 37 | alice_node = Node( 38 | id="234", 39 | kinds=["Person", "Base"], 40 | properties=Properties( 41 | displayname="alice", 42 | property="b", 43 | objectid="234", 44 | name="ALICE" 45 | ) 46 | ) 47 | 48 | # Add nodes to graph 49 | graph.add_node(bob_node) 50 | graph.add_node(alice_node) 51 | 52 | # Create edge: Bob knows Alice 53 | knows_edge = Edge( 54 | start_node=bob_node.id, # Bob is the start 55 | end_node=alice_node.id, # Alice is the end 56 | kind="Knows" 57 | ) 58 | 59 | 60 | # Add edge to graph 61 | graph.add_edge(knows_edge) 62 | 63 | # Export to file 64 | graph.export_to_file("minimal_working_json.json") 65 | -------------------------------------------------------------------------------- /bhopengraph/Logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : Logger.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 21 Aug 2025 6 | 7 | import time 8 | 9 | 10 | class Logger(object): 11 | """ 12 | Simple logger class that prints messages with timestamps 13 | """ 14 | 15 | def __init__(self, debug=False): 16 | super(Logger, self).__init__() 17 | self.__debug = debug 18 | self.__indent_level = 0 19 | 20 | def increment_indent(self): 21 | """ 22 | Increment the indentation level 23 | """ 24 | self.__indent_level += 1 25 | 26 | def decrement_indent(self): 27 | """ 28 | Decrement the indentation level 29 | """ 30 | if self.__indent_level > 0: 31 | self.__indent_level -= 1 32 | 33 | def log(self, message): 34 | """ 35 | Print a message with timestamp 36 | """ 37 | timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 38 | indent = " │ " * self.__indent_level 39 | if self.__debug: 40 | print("[%s] [\x1b[92m-----\x1b[0m] %s%s" % (timestamp, indent, message)) 41 | else: 42 | print("[%s] %s%s" % (timestamp, indent, message)) 43 | 44 | def error(self, message): 45 | """ 46 | Print a message with timestamp 47 | """ 48 | timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 49 | indent = " │ " * self.__indent_level 50 | print("[%s] [\x1b[91mERROR\x1b[0m] %s%s" % (timestamp, indent, message)) 51 | 52 | def debug(self, message): 53 | """ 54 | Print a debug message with timestamp 55 | """ 56 | if self.__debug: 57 | timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 58 | indent = " │ " * self.__indent_level 59 | print("[%s] [\x1b[93mDEBUG\x1b[0m] %s%s" % (timestamp, indent, message)) 60 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY : all clean build upload test test-verbose test-coverage lint lint-fix fix 2 | 3 | all: install clean 4 | 5 | clean: 6 | @rm -rf `find ./ -type d -name "*__pycache__"` 7 | @rm -rf ./build/ ./dist/ ./bhopengraph.egg-info/ 8 | @rm -rf ./bhopengraph/tests/datasets/dataset_n*.json 9 | 10 | generate-docs: 11 | @python3 -m pip install pdoc --break-system-packages 12 | @echo "[$(shell date)] Generating docs ..." 13 | @PDOC_ALLOW_EXEC=1 python3 -m pdoc -d markdown -o ./documentation/ ./bhopengraph/ 14 | @echo "[$(shell date)] Done!" 15 | 16 | uninstall: 17 | pip uninstall bhopengraph --yes --break-system-packages 18 | 19 | install: build 20 | python3 -m pip install . --break-system-packages 21 | 22 | build: 23 | python3 -m pip uninstall bhopengraph --yes --break-system-packages 24 | python3 -m pip install build --break-system-packages 25 | python3 -m build --wheel 26 | 27 | upload: build 28 | python3 -m pip install twine --break-system-packages 29 | python3 -m twine upload dist/* 30 | 31 | test: 32 | @echo "[$(shell date)] Running tests ..." 33 | @cd bhopengraph/tests && python3 run_tests.py 34 | @echo "[$(shell date)] Tests completed!" 35 | 36 | test-verbose: 37 | @echo "[$(shell date)] Running tests with verbose output ..." 38 | @cd bhopengraph/tests && python3 -m unittest discover -v 39 | @echo "[$(shell date)] Tests completed!" 40 | 41 | test-coverage: 42 | @echo "[$(shell date)] Installing coverage and running tests with coverage ..." 43 | @python3 -m pip install coverage --break-system-packages 44 | @coverage run --source=bhopengraph bhopengraph/tests/run_tests.py 45 | @coverage report 46 | @coverage html 47 | @echo "[$(shell date)] Coverage report generated in htmlcov/" 48 | 49 | lint: 50 | @echo "[$(shell date)] Installing linting tools ..." 51 | @python3 -m pip install flake8 black isort --break-system-packages 52 | @echo "[$(shell date)] Running flake8 linting ..." 53 | @python3 -m flake8 bhopengraph/ --max-line-length=88 --extend-ignore=E501 54 | @echo "[$(shell date)] Running black code formatting check ..." 55 | @python3 -m black --check --diff bhopengraph/ 56 | @echo "[$(shell date)] Running isort import sorting check ..." 57 | @python3 -m isort --check-only --diff bhopengraph/ 58 | @echo "[$(shell date)] Linting completed!" 59 | 60 | lint-fix: 61 | @echo "[$(shell date)] Installing linting tools ..." 62 | @python3 -m pip install flake8 black isort --break-system-packages 63 | @echo "[$(shell date)] Running black to fix formatting issues ..." 64 | @python3 -m black bhopengraph/ 65 | @echo "[$(shell date)] Running isort to fix import sorting ..." 66 | @python3 -m isort bhopengraph/ 67 | @echo "[$(shell date)] Running flake8 to check remaining issues ..." 68 | @python3 -m flake8 bhopengraph/ --max-line-length=88 --extend-ignore=E501 69 | @echo "[$(shell date)] Code formatting fixes completed!" 70 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | We welcome contributions to bhopengraph! This document provides guidelines and information for contributors. 5 | 6 | Getting Started 7 | -------------- 8 | 9 | 1. **Fork the repository** on GitHub 10 | 2. **Clone your fork** locally 11 | 3. **Create a feature branch** for your changes 12 | 4. **Make your changes** following the coding standards 13 | 5. **Test your changes** thoroughly 14 | 6. **Submit a pull request** 15 | 16 | Development Setup 17 | ---------------- 18 | 19 | .. code-block:: bash 20 | 21 | # Clone your fork 22 | git clone https://github.com/YOUR_USERNAME/bhopengraph.git 23 | cd bhopengraph 24 | 25 | # Install in development mode 26 | pip install -e ".[dev]" 27 | 28 | # Run tests 29 | python -m pytest tests/ 30 | 31 | # Run linting 32 | flake8 bhopengraph/ 33 | black bhopengraph/ 34 | 35 | Coding Standards 36 | ---------------- 37 | 38 | **Python Code Style** 39 | * Follow PEP 8 style guidelines 40 | * Use type hints where appropriate 41 | * Write docstrings for all public functions and classes 42 | * Keep functions focused and concise 43 | 44 | **Documentation** 45 | * Update relevant documentation when adding new features 46 | * Follow the existing documentation style 47 | * Include examples for new functionality 48 | * Update the changelog for significant changes 49 | 50 | **Testing** 51 | * Write tests for new functionality 52 | * Ensure all tests pass before submitting 53 | * Aim for good test coverage 54 | * Include both unit tests and integration tests 55 | 56 | **Git Commit Messages** 57 | * Use clear, descriptive commit messages 58 | * Start with a verb (Add, Fix, Update, etc.) 59 | * Reference issues when applicable 60 | * Keep commits focused and atomic 61 | 62 | Pull Request Guidelines 63 | ---------------------- 64 | 65 | 1. **Title**: Clear, descriptive title 66 | 2. **Description**: Explain what the PR does and why 67 | 3. **Tests**: Ensure all tests pass 68 | 4. **Documentation**: Update docs if needed 69 | 5. **Changelog**: Update CHANGELOG.md if applicable 70 | 71 | Issue Reporting 72 | -------------- 73 | 74 | When reporting issues, please include: 75 | 76 | * **Description**: Clear description of the problem 77 | * **Steps to reproduce**: Detailed steps to reproduce the issue 78 | * **Expected behavior**: What you expected to happen 79 | * **Actual behavior**: What actually happened 80 | * **Environment**: OS, Python version, bhopengraph version 81 | * **Code example**: Minimal code that reproduces the issue 82 | 83 | Code of Conduct 84 | --------------- 85 | 86 | * Be respectful and inclusive 87 | * Focus on the code and technical aspects 88 | * Help others learn and grow 89 | * Report any inappropriate behavior 90 | 91 | Getting Help 92 | ------------ 93 | 94 | * **GitHub Issues**: For bug reports and feature requests 95 | * **GitHub Discussions**: For questions and general discussion 96 | * **Pull Requests**: For code contributions 97 | 98 | Thank you for contributing to bhopengraph! 99 | -------------------------------------------------------------------------------- /.github/workflows/auto_prefix_issues.yml: -------------------------------------------------------------------------------- 1 | name: Auto‑prefix & Label Issues 2 | 3 | on: 4 | issues: 5 | types: [opened, edited] 6 | 7 | jobs: 8 | prefix_and_label: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Ensure labels exist, then prefix titles & add labels 12 | uses: actions/github-script@v6 13 | with: 14 | script: | 15 | const owner = context.repo.owner; 16 | const repo = context.repo.repo; 17 | 18 | // 1. Ensure required labels exist 19 | const required = [ 20 | { name: 'bug', color: 'd73a4a', description: 'Something isn\'t working' }, 21 | { name: 'enhancement', color: 'a2eeef', description: 'New feature or request' } 22 | ]; 23 | 24 | // Fetch current labels in the repo 25 | const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({ 26 | owner, repo, per_page: 100 27 | }); 28 | const existingNames = new Set(existingLabels.map(l => l.name)); 29 | 30 | // Create any missing labels 31 | for (const lbl of required) { 32 | if (!existingNames.has(lbl.name)) { 33 | await github.rest.issues.createLabel({ 34 | owner, 35 | repo, 36 | name: lbl.name, 37 | color: lbl.color, 38 | description: lbl.description 39 | }); 40 | console.log(`Created label "${lbl.name}"`); 41 | } 42 | } 43 | 44 | // 2. Fetch all open issues 45 | const issues = await github.paginate( 46 | github.rest.issues.listForRepo, 47 | { owner, repo, state: 'open', per_page: 100 } 48 | ); 49 | 50 | // 3. Keyword sets 51 | const enhancementWords = ["add", "added", "improve", "improved"]; 52 | const bugWords = ["bug", "error", "problem", "crash", "failed", "fix", "fixed"]; 53 | 54 | // 4. Process each issue 55 | for (const issue of issues) { 56 | const origTitle = issue.title; 57 | const lower = origTitle.toLowerCase(); 58 | 59 | // skip if already prefixed 60 | if (/^\[(bug|enhancement)\]/i.test(origTitle)) continue; 61 | 62 | let prefix, labelToAdd; 63 | if (enhancementWords.some(w => lower.includes(w))) { 64 | prefix = "[enhancement]"; 65 | labelToAdd = "enhancement"; 66 | } else if (bugWords.some(w => lower.includes(w))) { 67 | prefix = "[bug]"; 68 | labelToAdd = "bug"; 69 | } 70 | 71 | if (prefix) { 72 | // update title 73 | await github.rest.issues.update({ 74 | owner, repo, issue_number: issue.number, 75 | title: `${prefix} ${origTitle}` 76 | }); 77 | console.log(`Prefixed title of #${issue.number}`); 78 | 79 | // add label 80 | await github.rest.issues.addLabels({ 81 | owner, repo, issue_number: issue.number, 82 | labels: [labelToAdd] 83 | }); 84 | console.log(`Added label "${labelToAdd}" to #${issue.number}`); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | release: 9 | types: [ published, created ] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.11", "3.12"] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Cache pip dependencies 28 | uses: actions/cache@v3 29 | with: 30 | path: ~/.cache/pip 31 | key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements.txt') }} 32 | restore-keys: | 33 | ${{ runner.os }}-pip-${{ matrix.python-version }}- 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install -e . 39 | 40 | - name: Run tests 41 | run: | 42 | cd bhopengraph/tests 43 | python run_tests.py 44 | 45 | - name: Run tests with verbose output 46 | run: | 47 | cd bhopengraph/tests 48 | python -m unittest discover -v 49 | 50 | test-coverage: 51 | runs-on: ubuntu-latest 52 | needs: test 53 | 54 | steps: 55 | - name: Checkout code 56 | uses: actions/checkout@v4 57 | 58 | - name: Set up Python 3.12 59 | uses: actions/setup-python@v4 60 | with: 61 | python-version: "3.12" 62 | 63 | - name: Install dependencies 64 | run: | 65 | python -m pip install --upgrade pip 66 | pip install -e . 67 | 68 | - name: Install coverage 69 | run: | 70 | pip install coverage 71 | 72 | - name: Run tests with coverage 73 | run: | 74 | coverage run --source=bhopengraph bhopengraph/tests/run_tests.py 75 | 76 | - name: Generate coverage report 77 | run: | 78 | coverage report 79 | coverage html 80 | 81 | - name: Upload coverage to Codecov 82 | uses: codecov/codecov-action@v3 83 | with: 84 | file: ./coverage.xml 85 | flags: unittests 86 | name: codecov-umbrella 87 | fail_ci_if_error: false 88 | verbose: true 89 | 90 | lint: 91 | runs-on: ubuntu-latest 92 | needs: test 93 | 94 | steps: 95 | - name: Checkout code 96 | uses: actions/checkout@v4 97 | 98 | - name: Set up Python 3.12 99 | uses: actions/setup-python@v4 100 | with: 101 | python-version: "3.12" 102 | 103 | - name: Install dependencies 104 | run: | 105 | python -m pip install --upgrade pip 106 | pip install flake8 black isort 107 | 108 | - name: Lint with flake8 109 | run: | 110 | # stop the build if there are Python syntax errors or undefined names 111 | flake8 bhopengraph --count --select=E9,F63,F7,F82 --show-source --statistics 112 | # exit-zero treats all errors as warnings 113 | flake8 bhopengraph --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 114 | 115 | - name: Check code formatting with black 116 | run: | 117 | black --check --diff bhopengraph 118 | 119 | - name: Check import sorting with isort 120 | run: | 121 | isort --check-only --diff bhopengraph 122 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | This guide will help you get started with bhopengraph quickly. We'll create a simple graph with a few nodes and edges to demonstrate the basic functionality. 5 | 6 | Basic Example 7 | ------------ 8 | 9 | Here's a minimal example that creates a simple graph with two nodes and one edge: 10 | 11 | .. code-block:: python 12 | 13 | from bhopengraph.OpenGraph import OpenGraph 14 | from bhopengraph.Node import Node 15 | from bhopengraph.Edge import Edge 16 | from bhopengraph.Properties import Properties 17 | 18 | # Create an OpenGraph instance 19 | graph = OpenGraph(source_kind="Base") 20 | 21 | # Create nodes 22 | bob_node = Node( 23 | id="123", 24 | kinds=["Person", "Base"], 25 | properties=Properties( 26 | displayname="bob", 27 | property="a", 28 | objectid="123", 29 | name="BOB" 30 | ) 31 | ) 32 | 33 | alice_node = Node( 34 | id="234", 35 | kinds=["Person", "Base"], 36 | properties=Properties( 37 | displayname="alice", 38 | property="b", 39 | objectid="234", 40 | name="ALICE" 41 | ) 42 | ) 43 | 44 | # Add nodes to graph 45 | graph.addNode(bob_node) 46 | graph.addNode(alice_node) 47 | 48 | # Create edge: Bob knows Alice 49 | knows_edge = Edge( 50 | start_node_id=alice_node.id, 51 | end_node_id=bob_node.id, 52 | kind="Knows" 53 | ) 54 | 55 | # Add edge to graph 56 | graph.addEdge(knows_edge) 57 | 58 | # Export to file 59 | graph.exportToFile("minimal_example.json") 60 | 61 | This creates a JSON file that follows the BloodHound OpenGraph schema. 62 | 63 | Key Concepts 64 | ----------- 65 | 66 | **OpenGraph**: The main container that holds all nodes and edges. 67 | 68 | **Node**: Represents an entity in your graph (like a person, computer, or group). 69 | 70 | **Edge**: Represents a relationship between two nodes. 71 | 72 | **Properties**: Contains metadata about nodes or edges. 73 | 74 | Working with Nodes 75 | ----------------- 76 | 77 | Nodes can have multiple types (kinds) and various properties: 78 | 79 | .. code-block:: python 80 | 81 | # Create a computer node 82 | computer_node = Node( 83 | id="COMP001", 84 | kinds=["Computer", "Base"], 85 | properties=Properties( 86 | displayname="DESKTOP-COMP001", 87 | name="DESKTOP-COMP001", 88 | objectid="S-1-5-21-1234567890-1234567890-1234567890-1001", 89 | operatingsystem="Windows 10", 90 | primarygroup="DOMAIN COMPUTERS" 91 | ) 92 | ) 93 | 94 | Working with Edges 95 | ----------------- 96 | 97 | Edges define relationships between nodes: 98 | 99 | .. code-block:: python 100 | 101 | # Create an "AdminTo" relationship 102 | admin_edge = Edge( 103 | start_node_id="USER001", 104 | end_node_id="COMP001", 105 | kind="AdminTo" 106 | ) 107 | 108 | # Create a "MemberOf" relationship 109 | member_edge = Edge( 110 | start_node_id="USER001", 111 | end_node_id="ADMINS", 112 | kind="MemberOf" 113 | ) 114 | 115 | Exporting Your Graph 116 | ------------------- 117 | 118 | You can export your graph in different formats: 119 | 120 | .. code-block:: python 121 | 122 | # Export as JSON (default) 123 | graph.exportToFile("my_graph.json") 124 | 125 | # Export with custom formatting 126 | graph.exportToFile("my_graph.json", indent=2) 127 | 128 | # Get the JSON string directly 129 | json_string = graph.toJson() 130 | 131 | Next Steps 132 | ---------- 133 | 134 | * Check out the :doc:`examples/index` for more complex examples 135 | * Read the :doc:`api/index` for detailed API documentation 136 | * Learn about :doc:`advanced_features` for more advanced usage 137 | 138 | The examples in the :doc:`examples/index` section provide more detailed scenarios and use cases. 139 | -------------------------------------------------------------------------------- /examples/01_simple_path_finding.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : 01_simple_path_finding.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | """ 8 | Simple Path Finding Example 9 | 10 | This example demonstrates: 11 | - Creating a small graph with nodes and edges 12 | - Finding paths between two nodes 13 | - Basic graph operations 14 | """ 15 | 16 | import sys 17 | import os 18 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 19 | 20 | from bhopengraph.OpenGraph import OpenGraph 21 | from bhopengraph.Node import Node 22 | from bhopengraph.Edge import Edge 23 | from bhopengraph.Properties import Properties 24 | 25 | def main(): 26 | """ 27 | Create a simple graph and find paths between nodes. 28 | """ 29 | print("Simple Path Finding Example") 30 | print("=" * 40) 31 | 32 | # Create a simple graph 33 | graph = OpenGraph(source_kind="Simple") 34 | 35 | # Create nodes 36 | alice = Node("alice_001", ["Person"], Properties(name="Alice", age=25)) 37 | bob = Node("bob_002", ["Person"], Properties(name="Bob", age=30)) 38 | charlie = Node("charlie_003", ["Person"], Properties(name="Charlie", age=35)) 39 | diana = Node("diana_004", ["Person"], Properties(name="Diana", age=28)) 40 | 41 | # Add nodes to graph 42 | graph.add_node(alice) 43 | graph.add_node(bob) 44 | graph.add_node(charlie) 45 | graph.add_node(diana) 46 | 47 | # Create edges (relationships) 48 | edges = [ 49 | Edge(alice.id, bob.id, "Knows"), 50 | Edge(bob.id, charlie.id, "Knows"), 51 | Edge(charlie.id, diana.id, "Knows"), 52 | Edge(alice.id, diana.id, "Knows"), # Direct connection 53 | Edge(bob.id, diana.id, "Knows"), # Another path 54 | ] 55 | 56 | # Add edges to graph 57 | for edge in edges: 58 | graph.add_edge(edge) 59 | 60 | print(f"Graph created with {graph.get_node_count()} nodes and {graph.get_edge_count()} edges") 61 | 62 | # Find paths between Alice and Diana 63 | print(f"\nFinding paths from Alice to Diana:") 64 | paths = graph.find_paths(alice.id, diana.id, max_depth=5) 65 | 66 | if paths: 67 | print(f"Found {len(paths)} path(s):") 68 | for i, path in enumerate(paths): 69 | path_names = [] 70 | for node_id in path: 71 | node = graph.get_node_by_id(node_id) 72 | path_names.append(node.get_property('name', node_id)) 73 | print(f" Path {i+1}: {' -> '.join(path_names)}") 74 | else: 75 | print("No path found") 76 | 77 | # Find paths between Bob and Diana 78 | print(f"\nFinding paths from Bob to Diana:") 79 | paths = graph.find_paths(bob.id, diana.id, max_depth=5) 80 | 81 | if paths: 82 | print(f"Found {len(paths)} path(s):") 83 | for i, path in enumerate(paths): 84 | path_names = [] 85 | for node_id in path: 86 | node = graph.get_node_by_id(node_id) 87 | path_names.append(node.get_property('name', node_id)) 88 | print(f" Path {i+1}: {' -> '.join(path_names)}") 89 | else: 90 | print("No path found") 91 | 92 | # Show all edges 93 | print(f"\nAll edges in the graph:") 94 | for edge in graph.edges: 95 | start_node = graph.get_node_by_id(edge.start_node) 96 | end_node = graph.get_node_by_id(edge.end_node) 97 | start_name = start_node.get_property('name', edge.start_node) 98 | end_name = end_node.get_property('name', edge.end_node) 99 | print(f" {start_name} --[{edge.kind}]--> {end_name}") 100 | 101 | # Export to JSON 102 | graph.export_to_file("simple_path_finding.json") 103 | print(f"\nGraph exported to 'simple_path_finding.json'") 104 | 105 | if __name__ == "__main__": 106 | main() 107 | -------------------------------------------------------------------------------- /examples/02_node_types_and_properties.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : 02_node_types_and_properties.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | """ 8 | Node Types and Properties Example 9 | 10 | This example demonstrates: 11 | - Creating different types of nodes 12 | - Using various property types 13 | - Adding and removing node properties 14 | - Working with node kinds 15 | """ 16 | 17 | from bhopengraph.OpenGraph import OpenGraph 18 | from bhopengraph.Node import Node 19 | from bhopengraph.Properties import Properties 20 | 21 | def main(): 22 | """ 23 | Demonstrate different node types and properties. 24 | """ 25 | print("Node Types and Properties Example") 26 | print("=" * 45) 27 | 28 | # Create a graph 29 | graph = OpenGraph(source_kind="Demo") 30 | 31 | # Create different types of nodes with various properties 32 | 33 | # Person node 34 | person = Node( 35 | "person_001", 36 | ["Person"], 37 | Properties( 38 | name="John Smith", 39 | age=32, 40 | department="Engineering", 41 | salary=75000.0, 42 | is_active=True, 43 | skills="Python,JavaScript,Docker" 44 | ) 45 | ) 46 | 47 | # Computer node 48 | computer = Node( 49 | "computer_001", 50 | ["Computer", "Workstation"], 51 | Properties( 52 | hostname="WS-JSMITH-01", 53 | os="Windows 11", 54 | ip_address="192.168.1.100", 55 | memory_gb=16, 56 | is_encrypted=True, 57 | last_patch="2024-01-15" 58 | ) 59 | ) 60 | 61 | # Application node 62 | application = Node( 63 | "app_001", 64 | ["Application", "WebApp"], 65 | Properties( 66 | name="Customer Portal", 67 | version="2.1.0", 68 | port=8080, 69 | is_production=True, 70 | dependencies="PostgreSQL,Redis", # Changed from list to string 71 | last_deploy="2024-01-20" 72 | ) 73 | ) 74 | 75 | # Database node 76 | database = Node( 77 | "db_001", 78 | ["Database", "PostgreSQL"], 79 | Properties( 80 | name="CustomerDB", 81 | version="14.5", 82 | size_gb=25.5, 83 | is_clustered=False, 84 | backup_frequency="daily", 85 | retention_days=30 86 | ) 87 | ) 88 | 89 | # Add nodes to graph 90 | graph.add_node(person) 91 | graph.add_node(computer) 92 | graph.add_node(application) 93 | graph.add_node(database) 94 | 95 | print(f"Graph created with {graph.get_node_count()} nodes") 96 | 97 | # Demonstrate node operations 98 | print(f"\nNode Operations:") 99 | 100 | # Show all nodes by type 101 | for kind in ["Person", "Computer", "Application", "Database"]: 102 | nodes = graph.get_nodes_by_kind(kind) 103 | print(f" {kind} nodes: {len(nodes)}") 104 | for node in nodes: 105 | name = node.get_property('name', node.id) 106 | print(f" - {name}") 107 | 108 | # Demonstrate property operations 109 | print(f"\nProperty Operations:") 110 | 111 | # Get a specific node 112 | john = graph.get_node_by_id("person_001") 113 | if john: 114 | print(f" John's current properties:") 115 | for key, value in john.properties.get_all_properties().items(): 116 | print(f" {key}: {value}") 117 | 118 | # Add a new property 119 | john.set_property("location", "New York") 120 | print(f" Added location property: {john.get_property('location')}") 121 | 122 | # Update an existing property 123 | john.set_property("age", 33) 124 | print(f" Updated age: {john.get_property('age')}") 125 | 126 | # Remove a property 127 | john.remove_property("skills") 128 | print(f" Removed skills property") 129 | 130 | # Check if property exists 131 | print(f" Has location: {john.properties.has_property('location')}") 132 | print(f" Has skills: {john.properties.has_property('skills')}") 133 | 134 | # Demonstrate kind operations 135 | print(f"\nKind Operations:") 136 | 137 | # Add a new kind 138 | john.add_kind("Manager") 139 | print(f" Added 'Manager' kind to John") 140 | 141 | # Check kinds 142 | print(f" John's kinds: {john.kinds}") 143 | print(f" Is John a Manager? {john.has_kind('Manager')}") 144 | print(f" Is John a Director? {john.has_kind('Director')}") 145 | 146 | # Remove a kind 147 | john.remove_kind("Demo") 148 | print(f" Removed 'Demo' kind from John") 149 | print(f" John's kinds after removal: {john.kinds}") 150 | 151 | # Show final node state 152 | print(f"\nFinal Node States:") 153 | for node_id, node in graph.nodes.items(): 154 | name = node.get_property('name', node_id) 155 | kinds = ', '.join(node.kinds) 156 | print(f" {name}: {kinds}") 157 | 158 | # Export to JSON 159 | graph.export_to_file("node_types_example.json") 160 | print(f"\nGraph exported to 'node_types_example.json'") 161 | 162 | if __name__ == "__main__": 163 | main() 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bhopengraph: A python library to create BloodHound OpenGraphs 2 | 3 |

4 | A python library to create BloodHound OpenGraphs easily 5 |
6 | PyPI 7 | GitHub release (latest by date) 8 | 9 | YouTube Channel Subscribers 10 | Get BloodHound Enterprise 11 | Get BloodHound Community 12 |
13 |
14 | This library also exists in: Go | Python 15 |

16 | 17 | ## Features 18 | 19 | This module provides Python classes for creating and managing graph structures that are compatible with BloodHound OpenGraph. The classes follow the [BloodHound OpenGraph schema](https://bloodhound.specterops.io/opengraph/schema) and [best practices](https://bloodhound.specterops.io/opengraph/best-practices). 20 | 21 | If you don't know about BloodHound OpenGraph yet, a great introduction can be found here: [https://bloodhound.specterops.io/opengraph/best-practices](https://bloodhound.specterops.io/opengraph/best-practices) 22 | 23 | The complete documentation of this library can be found here: https://bhopengraph.readthedocs.io/en/latest/ 24 | 25 | ## Examples 26 | 27 | Here is an example of a Python program using the [bhopengraph](https://github.com/p0dalirius/bhopengraph) python library to model the [Minimal Working JSON](https://bloodhound.specterops.io/opengraph/schema#minimal-working-json) from the OpenGraph Schema documentation: 28 | 29 | ```py 30 | from bhopengraph.OpenGraph import OpenGraph 31 | from bhopengraph.Node import Node 32 | from bhopengraph.Edge import Edge 33 | from bhopengraph.Properties import Properties 34 | 35 | # Create an OpenGraph instance 36 | graph = OpenGraph(source_kind="Base") 37 | 38 | # Create nodes 39 | bob_node = Node( 40 | id="123", 41 | kinds=["Person", "Base"], 42 | properties=Properties( 43 | displayname="bob", 44 | property="a", 45 | objectid="123", 46 | name="BOB" 47 | ) 48 | ) 49 | 50 | alice_node = Node( 51 | id="234", 52 | kinds=["Person", "Base"], 53 | properties=Properties( 54 | displayname="alice", 55 | property="b", 56 | objectid="234", 57 | name="ALICE" 58 | ) 59 | ) 60 | 61 | # Add nodes to graph 62 | graph.add_node(bob_node) 63 | graph.add_node(alice_node) 64 | 65 | # Create edge: Bob knows Alice 66 | knows_edge = Edge( 67 | start_node=alice_node.id, 68 | end_node=bob_node.id, 69 | kind="Knows" 70 | ) 71 | 72 | # Add edge to graph 73 | graph.add_edge(knows_edge) 74 | 75 | # Export to file 76 | graph.export_to_file("minimal_example.json") 77 | ``` 78 | 79 | This gives us the following [Minimal Working JSON](https://bloodhound.specterops.io/opengraph/schema#minimal-working-json) as per the documentation: 80 | 81 | ```json 82 | { 83 | "graph": { 84 | "nodes": [ 85 | { 86 | "id": "123", 87 | "kinds": [ 88 | "Person", 89 | "Base" 90 | ], 91 | "properties": { 92 | "displayname": "bob", 93 | "property": "a", 94 | "objectid": "123", 95 | "name": "BOB" 96 | } 97 | }, 98 | { 99 | "id": "234", 100 | "kinds": [ 101 | "Person", 102 | "Base" 103 | ], 104 | "properties": { 105 | "displayname": "alice", 106 | "property": "b", 107 | "objectid": "234", 108 | "name": "ALICE" 109 | } 110 | } 111 | ], 112 | "edges": [ 113 | { 114 | "kind": "Knows", 115 | "start": { 116 | "value": "123", 117 | "match_by": "id" 118 | }, 119 | "end": { 120 | "value": "234", 121 | "match_by": "id" 122 | } 123 | } 124 | ] 125 | }, 126 | "metadata": { 127 | "source_kind": "Base" 128 | } 129 | } 130 | ``` 131 | 132 | ## Contributing 133 | 134 | Pull requests are welcome. Feel free to open an issue if you want to add other features. 135 | 136 | ## References 137 | 138 | - [BloodHound OpenGraph Best Practices](https://bloodhound.specterops.io/opengraph/best-practices) 139 | - [BloodHound OpenGraph Schema](https://bloodhound.specterops.io/opengraph/schema) 140 | - [BloodHound OpenGraph API](https://bloodhound.specterops.io/opengraph/api) 141 | - [BloodHound OpenGraph Custom Icons](https://bloodhound.specterops.io/opengraph/custom-icons) 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | #poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | #pdm.lock 116 | #pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | #pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # SageMath parsed files 135 | *.sage.py 136 | 137 | # Environments 138 | .env 139 | .envrc 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # PyCharm 172 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 173 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 174 | # and can be added to the global gitignore or merged into this file. For a more nuclear 175 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 176 | #.idea/ 177 | 178 | # Abstra 179 | # Abstra is an AI-powered process automation framework. 180 | # Ignore directories containing user credentials, local state, and settings. 181 | # Learn more at https://abstra.io/docs 182 | .abstra/ 183 | 184 | # Visual Studio Code 185 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 186 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 187 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 188 | # you could uncomment the following to ignore the entire vscode folder 189 | # .vscode/ 190 | 191 | # Ruff stuff: 192 | .ruff_cache/ 193 | 194 | # PyPI configuration file 195 | .pypirc 196 | 197 | # Cursor 198 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 199 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 200 | # refer to https://docs.cursor.com/context/ignore-files 201 | .cursorignore 202 | .cursorindexingignore 203 | 204 | # Marimo 205 | marimo/_static/ 206 | marimo/_lsp/ 207 | __marimo__/ 208 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : setup.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | import setuptools 8 | 9 | long_description = """ 10 |

11 | A python library to create BloodHound OpenGraphs easily 12 |
13 | PyPI 14 | GitHub release (latest by date) 15 | 16 | YouTube Channel Subscribers 17 |
18 |

19 | 20 | ## Features 21 | 22 | This module provides Python classes for creating and managing graph structures that are compatible with BloodHound OpenGraph. The classes follow the [BloodHound OpenGraph schema](https://bloodhound.specterops.io/opengraph/schema) and [best practices](https://bloodhound.specterops.io/opengraph/best-practices). 23 | 24 | If you don't know about BloodHound OpenGraph yet, a great introduction can be found here: [https://bloodhound.specterops.io/opengraph/best-practices](https://bloodhound.specterops.io/opengraph/best-practices) 25 | 26 | ## Examples 27 | 28 | Here is an example of a Python program using the [bhopengraph](https://github.com/p0dalirius/bhopengraph) python library to model the [Minimal Working JSON](https://bloodhound.specterops.io/opengraph/schema#minimal-working-json) from the OpenGraph Schema documentation: 29 | 30 | ```py 31 | from bhopengraph.OpenGraph import OpenGraph 32 | from bhopengraph.Node import Node 33 | from bhopengraph.Edge import Edge 34 | from bhopengraph.Properties import Properties 35 | 36 | # Create an OpenGraph instance 37 | graph = OpenGraph(source_kind="Base") 38 | 39 | # Create nodes 40 | bob_node = Node( 41 | id="123", 42 | kinds=["Person", "Base"], 43 | properties=Properties( 44 | displayname="bob", 45 | property="a", 46 | objectid="123", 47 | name="BOB" 48 | ) 49 | ) 50 | 51 | alice_node = Node( 52 | id="234", 53 | kinds=["Person", "Base"], 54 | properties=Properties( 55 | displayname="alice", 56 | property="b", 57 | objectid="234", 58 | name="ALICE" 59 | ) 60 | ) 61 | 62 | # Add nodes to graph 63 | graph.addNode(bob_node) 64 | graph.addNode(alice_node) 65 | 66 | # Create edge: Bob knows Alice 67 | knows_edge = Edge( 68 | start_node_id=alice_node.id, 69 | end_node_id=bob_node.id, 70 | kind="Knows" 71 | ) 72 | 73 | # Add edge to graph 74 | graph.addEdge(knows_edge) 75 | 76 | # Export to file 77 | graph.exportToFile("minimal_example.json") 78 | ``` 79 | 80 | This gives us the following [Minimal Working JSON](https://bloodhound.specterops.io/opengraph/schema#minimal-working-json) as per the documentation: 81 | 82 | ```json 83 | { 84 | "graph": { 85 | "nodes": [ 86 | { 87 | "id": "123", 88 | "kinds": [ 89 | "Person", 90 | "Base" 91 | ], 92 | "properties": { 93 | "displayname": "bob", 94 | "property": "a", 95 | "objectid": "123", 96 | "name": "BOB" 97 | } 98 | }, 99 | { 100 | "id": "234", 101 | "kinds": [ 102 | "Person", 103 | "Base" 104 | ], 105 | "properties": { 106 | "displayname": "alice", 107 | "property": "b", 108 | "objectid": "234", 109 | "name": "ALICE" 110 | } 111 | } 112 | ], 113 | "edges": [ 114 | { 115 | "kind": "Knows", 116 | "start": { 117 | "value": "123", 118 | "match_by": "id" 119 | }, 120 | "end": { 121 | "value": "234", 122 | "match_by": "id" 123 | } 124 | } 125 | ] 126 | }, 127 | "metadata": { 128 | "source_kind": "Base" 129 | } 130 | } 131 | ``` 132 | 133 | ## Contributing 134 | 135 | Pull requests are welcome. Feel free to open an issue if you want to add other features. 136 | 137 | ## References 138 | 139 | - [BloodHound OpenGraph Best Practices](https://bloodhound.specterops.io/opengraph/best-practices) 140 | - [BloodHound OpenGraph Schema](https://bloodhound.specterops.io/opengraph/schema) 141 | - [BloodHound OpenGraph API](https://bloodhound.specterops.io/opengraph/api) 142 | - [BloodHound OpenGraph Custom Icons](https://bloodhound.specterops.io/opengraph/custom-icons) 143 | 144 | """ 145 | 146 | with open('requirements.txt', 'r', encoding='utf-8') as f: 147 | requirements = [x.strip() for x in f.readlines()] 148 | 149 | setuptools.setup( 150 | name="bhopengraph", 151 | version="1.3.0", 152 | description="A python library to create BloodHound OpenGraphs easily", 153 | url="https://github.com/p0dalirius/bhopengraph", 154 | author="Podalirius", 155 | long_description=long_description, 156 | long_description_content_type="text/markdown", 157 | author_email="podalirius@protonmail.com", 158 | packages=["bhopengraph"], 159 | include_package_data=True, 160 | license="MIT", 161 | classifiers=[ 162 | "Programming Language :: Python :: 3", 163 | "License :: OSI Approved :: MIT License", 164 | "Operating System :: OS Independent", 165 | ], 166 | python_requires='>=3.11', 167 | install_requires=requirements, 168 | entry_points={ 169 | "console_scripts": [ 170 | "bhopengraph=bhopengraph.__main__:main" 171 | ] 172 | } 173 | ) 174 | -------------------------------------------------------------------------------- /bhopengraph/tests/datasets/generate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : generate.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 22 Aug 2025 6 | 7 | import os 8 | import random 9 | import string 10 | import time 11 | import uuid 12 | 13 | from bhopengraph import OpenGraph 14 | from bhopengraph.Edge import Edge 15 | from bhopengraph.Node import Node 16 | from bhopengraph.Properties import Properties 17 | 18 | 19 | def generate_random_name(): 20 | """ 21 | Generate a random name. 22 | """ 23 | return "".join(random.choices(string.ascii_letters + string.digits, k=10)) 24 | 25 | 26 | def generate_opengraph_dataset( 27 | number_of_nodes, 28 | number_of_edges, 29 | number_of_node_kinds=10, 30 | number_of_edge_kinds=10, 31 | percentage_of_isolated_nodes=0.0, 32 | percentage_of_isolated_edges=0.0, 33 | ): 34 | """ 35 | Generate a dataset of OpenGraph objects. 36 | """ 37 | 38 | if percentage_of_isolated_nodes > 100.0 or percentage_of_isolated_nodes < 0.0: 39 | raise ValueError("percentage_of_isolated_nodes must be between 0.0 and 100.0") 40 | 41 | if percentage_of_isolated_edges > 100.0 or percentage_of_isolated_edges < 0.0: 42 | raise ValueError("percentage_of_isolated_edges must be between 0.0 and 100.0") 43 | 44 | # Create a new OpenGraph instance 45 | graph = OpenGraph() 46 | graph.source_kind = f"n{number_of_nodes}_e{number_of_edges}_pin{percentage_of_isolated_nodes}_pie{percentage_of_isolated_edges}" 47 | 48 | # Calculate the number of isolated nodes and edges 49 | isolated_nodes_count = int(number_of_nodes * (percentage_of_isolated_nodes / 100.0)) 50 | connected_nodes_count = number_of_nodes - isolated_nodes_count 51 | 52 | isolated_edges_count = int(number_of_edges * (percentage_of_isolated_edges / 100.0)) 53 | connected_edges_count = number_of_edges - isolated_edges_count 54 | 55 | # Generate node types and kinds for variety 56 | node_kinds = [generate_random_name() for _ in range(number_of_node_kinds)] 57 | edge_kinds = [generate_random_name() for _ in range(number_of_edge_kinds)] 58 | 59 | # Generate all nodes first 60 | all_nodes = [] 61 | 62 | # Generate connected nodes 63 | for i in range(connected_nodes_count): 64 | node_id = str(uuid.uuid4()) 65 | node_kind = random.choice(node_kinds) 66 | node = Node(id=node_id, kinds=[node_kind], properties=Properties()) 67 | all_nodes.append(node) 68 | graph.add_node(node) 69 | 70 | # Generate isolated nodes 71 | for i in range(isolated_nodes_count): 72 | node_id = str(uuid.uuid4()) 73 | node_kind = random.choice(node_kinds) 74 | node = Node(id=node_id, kinds=[node_kind], properties=Properties()) 75 | all_nodes.append(node) 76 | graph.add_node(node) 77 | 78 | # Generate connected edges (edges that reference existing nodes) 79 | for i in range(connected_edges_count): 80 | if len(all_nodes) >= 2: 81 | source_node = random.choice(all_nodes) 82 | target_node = random.choice(all_nodes) 83 | 84 | # Avoid self-loops 85 | while source_node.id == target_node.id and len(all_nodes) > 1: 86 | target_node = random.choice(all_nodes) 87 | 88 | edge_kind = random.choice(edge_kinds) 89 | edge = Edge( 90 | start_node=source_node.id, 91 | start_match_by="id", 92 | end_node=target_node.id, 93 | end_match_by="id", 94 | kind=edge_kind, 95 | properties=Properties(), 96 | ) 97 | graph.add_edge(edge) 98 | 99 | # Generate isolated edges (edges that reference non-existent nodes) 100 | for i in range(isolated_edges_count): 101 | # Create fake node IDs that don't exist in the graph 102 | match random.randint(1, 3): 103 | case 1: 104 | source_id = str(uuid.uuid4()) 105 | target_id = str(uuid.uuid4()) 106 | case 2: 107 | source_id = random.choice(all_nodes).id 108 | target_id = str(uuid.uuid4()) 109 | case 3: 110 | source_id = str(uuid.uuid4()) 111 | target_id = random.choice(all_nodes).id 112 | 113 | edge_kind = random.choice(edge_kinds) 114 | edge = Edge( 115 | start_node=source_id, 116 | start_match_by="id", 117 | end_node=target_id, 118 | end_match_by="id", 119 | kind=edge_kind, 120 | properties=Properties(), 121 | ) 122 | graph.add_edge(edge) 123 | 124 | graph.export_to_file( 125 | os.path.join(os.path.dirname(__file__), f"dataset_{graph.source_kind}.json") 126 | ) 127 | 128 | return graph 129 | 130 | 131 | def benchmark_validate_graph(graph, number_of_iterations=10): 132 | """ 133 | Benchmark the validate_graph method. 134 | """ 135 | total_time = 0 136 | for i in range(number_of_iterations): 137 | start_time = time.perf_counter() 138 | graph.validate_graph() 139 | end_time = time.perf_counter() 140 | total_time += (end_time - start_time) * 1000 141 | 142 | return round(total_time / number_of_iterations, 2) 143 | 144 | 145 | if __name__ == "__main__": 146 | 147 | for testcase in [ 148 | (10000, 10000, 0.0, 0.0), 149 | (10000, 10000, 0.0, 100.0), 150 | (10000, 10000, 25.0, 75.0), 151 | (10000, 10000, 50.0, 50.0), 152 | (10000, 10000, 75.0, 25.0), 153 | (10000, 10000, 100.0, 0.0), 154 | (10000, 10000, 100.0, 100.0), 155 | (100000, 100000, 0.0, 0.0), 156 | (100000, 100000, 0.0, 100.0), 157 | (100000, 100000, 25.0, 75.0), 158 | (100000, 100000, 50.0, 50.0), 159 | (100000, 100000, 75.0, 25.0), 160 | (100000, 100000, 100.0, 0.0), 161 | (100000, 100000, 100.0, 100.0), 162 | ]: 163 | graph = generate_opengraph_dataset( 164 | number_of_nodes=testcase[0], 165 | number_of_edges=testcase[1], 166 | number_of_node_kinds=10, 167 | number_of_edge_kinds=10, 168 | percentage_of_isolated_nodes=testcase[2], 169 | percentage_of_isolated_edges=testcase[3], 170 | ) 171 | print( 172 | "Testcase: %-45s | Validation time: %s ms" 173 | % (testcase, benchmark_validate_graph(graph)) 174 | ) 175 | -------------------------------------------------------------------------------- /bhopengraph/Properties.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : Properties.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | # https://bloodhound.specterops.io/opengraph/schema#node-json 8 | PROPERTIES_SCHEMA = { 9 | "type": ["object", "null"], 10 | "description": "A key-value map of node attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", 11 | "additionalProperties": { 12 | "type": ["string", "number", "boolean", "array"], 13 | "items": {"not": {"type": "object"}}, 14 | }, 15 | } 16 | 17 | 18 | class Properties(object): 19 | """ 20 | Properties class for storing arbitrary key-value pairs for nodes and edges. 21 | Follows BloodHound OpenGraph schema requirements where properties must be primitive types. 22 | """ 23 | 24 | def __init__(self, **kwargs): 25 | """ 26 | Initialize Properties with optional key-value pairs. 27 | 28 | Args: 29 | - **kwargs: Key-value pairs to initialize properties 30 | """ 31 | self._properties = {} 32 | for key, value in kwargs.items(): 33 | self.set_property(key, value) 34 | 35 | def set_property(self, key: str, value): 36 | """ 37 | Set a property value. Only primitive types are allowed. 38 | 39 | Args: 40 | - key (str): Property name 41 | - value: Property value (must be primitive type: str, int, float, bool, None, list) 42 | """ 43 | if self.is_valid_property_value(value): 44 | self._properties[key] = value 45 | else: 46 | raise ValueError( 47 | f"Property value must be a primitive type (str, int, float, bool, None, list), got {type(value)}" 48 | ) 49 | 50 | def get_property(self, key: str, default=None): 51 | """ 52 | Get a property value. 53 | 54 | Args: 55 | - key (str): Property name 56 | - default: Default value if key doesn't exist 57 | 58 | Returns: 59 | - Property value or default 60 | """ 61 | return self._properties.get(key, default) 62 | 63 | def remove_property(self, key: str): 64 | """ 65 | Remove a property. 66 | 67 | Args: 68 | - key (str): Property name to remove 69 | """ 70 | if key in self._properties: 71 | del self._properties[key] 72 | 73 | def has_property(self, key: str) -> bool: 74 | """ 75 | Check if a property exists. 76 | 77 | Args: 78 | - key (str): Property name to check 79 | 80 | Returns: 81 | - bool: True if property exists, False otherwise 82 | """ 83 | return key in self._properties 84 | 85 | def get_all_properties(self) -> dict: 86 | """ 87 | Get all properties as a dictionary. 88 | 89 | Returns: 90 | - dict: Copy of all properties 91 | """ 92 | return self._properties.copy() 93 | 94 | def clear(self): 95 | """Clear all properties.""" 96 | self._properties.clear() 97 | 98 | def validate(self) -> tuple[bool, list[str]]: 99 | """ 100 | Validate all properties according to OpenGraph schema rules. 101 | 102 | Returns: 103 | - tuple[bool, list[str]]: (is_valid, list_of_errors) 104 | """ 105 | errors = [] 106 | 107 | for key, value in self._properties.items(): 108 | if not self.is_valid_property_value(value): 109 | errors.append( 110 | f"Property '{key}' has invalid value type '{type(value)}' not in (str, int, float, bool, None, list)" 111 | ) 112 | 113 | return len(errors) == 0, errors 114 | 115 | def is_valid_property_value(self, value) -> bool: 116 | """ 117 | Validate a single property value according to OpenGraph schema rules. 118 | 119 | Args: 120 | - value: The property value to validate 121 | 122 | Returns: 123 | - bool: True if valid, False otherwise 124 | """ 125 | # Check if value is None (allowed) 126 | if value is None: 127 | return True 128 | 129 | # Check if value is a primitive type 130 | if isinstance(value, (str, int, float, bool)): 131 | return True 132 | 133 | # Check if value is an array 134 | if isinstance(value, list): 135 | if not value: # Empty array is valid 136 | return True 137 | 138 | # Check if all items are of the same primitive type 139 | first_type = type(value[0]) 140 | if first_type not in (str, int, float, bool): 141 | return False 142 | 143 | # Check that all items are the same type and not objects 144 | for item in value: 145 | if not isinstance(item, first_type) or isinstance(item, (dict, list)): 146 | return False 147 | 148 | return True 149 | 150 | # Objects are not allowed 151 | return False 152 | 153 | def to_dict(self) -> dict: 154 | """ 155 | Convert properties to dictionary for JSON serialization. 156 | 157 | Returns: 158 | - dict: Properties as dictionary 159 | """ 160 | return self._properties.copy() 161 | 162 | def __len__(self) -> int: 163 | return len(self._properties) 164 | 165 | def __contains__(self, key: str) -> bool: 166 | return key in self._properties 167 | 168 | def __getitem__(self, key: str): 169 | return self._properties[key] 170 | 171 | def __setitem__(self, key: str, value): 172 | self.set_property(key, value) 173 | 174 | def __delitem__(self, key: str): 175 | self.remove_property(key) 176 | 177 | def items(self): 178 | """ 179 | Return a view of the properties as (key, value) pairs. 180 | 181 | Returns: 182 | - dict_items: View of properties as key-value pairs 183 | """ 184 | return self._properties.items() 185 | 186 | def keys(self): 187 | """ 188 | Return a view of the property keys. 189 | 190 | Returns: 191 | - dict_keys: View of property keys 192 | """ 193 | return self._properties.keys() 194 | 195 | def __repr__(self) -> str: 196 | return f"Properties({self._properties})" 197 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('..')) 16 | 17 | # -- Project information ----------------------------------------------------- 18 | 19 | project = 'bhopengraph' 20 | copyright = '2025, Remi Gascou (Podalirius)' 21 | author = 'Remi Gascou (Podalirius)' 22 | release = '1.1.0' 23 | version = '1.1.0' 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx.ext.napoleon', 33 | 'sphinx.ext.viewcode', 34 | 'sphinx.ext.intersphinx', 35 | 'sphinx.ext.githubpages', 36 | 'sphinx_rtd_theme', 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # List of patterns, relative to source directory, that match files and 43 | # directories to ignore when looking for source files. 44 | # This pattern also affects html_static_path and html_extra_path. 45 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 46 | 47 | # The suffix of source filenames. 48 | source_suffix = '.rst' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | 55 | # The theme to use for HTML and HTML Help pages. See the documentation for 56 | # a list of builtin themes. 57 | html_theme = 'sphinx_rtd_theme' 58 | 59 | # Theme options are theme-specific and customize the look and feel of a theme 60 | # further. For a list of options available for each theme, see the 61 | # documentation. 62 | html_theme_options = { 63 | 'navigation_depth': 4, 64 | 'collapse_navigation': False, 65 | 'sticky_navigation': True, 66 | 'includehidden': True, 67 | 'titles_only': False, 68 | 'logo_only': False, 69 | } 70 | 71 | # Add any paths that contain custom static files (such as style sheets) here, 72 | # relative to this directory. They are copied after the builtin static files, 73 | # so a file named "default.css" will overwrite the builtin "default.css". 74 | html_static_path = ['_static'] 75 | 76 | # The name of an image file (relative to this directory) to place at the top 77 | # of the sidebar. 78 | # html_logo = None 79 | 80 | # The name of an image file (relative to this directory) to use as a favicon of 81 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 82 | # pixels large. 83 | # html_favicon = None 84 | 85 | # -- Options for autodoc ---------------------------------------------------- 86 | 87 | # Automatically extract type hints 88 | autodoc_typehints = 'description' 89 | autodoc_member_order = 'bysource' 90 | autodoc_default_options = { 91 | 'members': True, 92 | 'member-order': 'bysource', 93 | 'special-members': '__init__', 94 | 'undoc-members': True, 95 | 'exclude-members': '__weakref__' 96 | } 97 | 98 | # -- Options for napoleon --------------------------------------------------- 99 | 100 | # Napoleon settings 101 | napoleon_google_docstring = True 102 | napoleon_numpy_docstring = True 103 | napoleon_include_init_with_doc = False 104 | napoleon_include_private_with_doc = False 105 | napoleon_include_special_with_doc = True 106 | napoleon_use_admonition_for_examples = False 107 | napoleon_use_admonition_for_notes = False 108 | napoleon_use_admonition_for_references = False 109 | napoleon_use_ivar = False 110 | napoleon_use_param = True 111 | napoleon_use_rtype = True 112 | napoleon_use_keyword = True 113 | napoleon_custom_sections = None 114 | 115 | # -- Options for intersphinx ------------------------------------------------- 116 | 117 | # Example configuration for intersphinx: refer to the Python standard library. 118 | intersphinx_mapping = { 119 | 'python': ('https://docs.python.org/3/', None), 120 | } 121 | 122 | # -- Options for HTMLHelp output ------------------------------------------ 123 | 124 | # Output file base name for HTML help builder. 125 | htmlhelp_basename = 'bhopengraphdoc' 126 | 127 | # -- Options for LaTeX output --------------------------------------------- 128 | 129 | latex_elements = { 130 | # The paper size ('letterpaper' or 'a4paper'). 131 | 'papersize': 'a4paper', 132 | 133 | # The font size ('10pt', '11pt' or '12pt'). 134 | 'pointsize': '11pt', 135 | 136 | # Additional stuff for the LaTeX preamble. 137 | 'preamble': '', 138 | 139 | # Latex figure (float) alignment 140 | 'figure_align': 'htbp', 141 | } 142 | 143 | # Grouping the document tree into LaTeX files. List of tuples 144 | # (source start file, target name, title, 145 | # author, documentclass [howto, manual, or own class]). 146 | latex_documents = [ 147 | (master_doc, 'bhopengraph.tex', 'bhopengraph Documentation', 148 | 'p0dalirius', 'manual'), 149 | ] 150 | 151 | # -- Options for manual page output --------------------------------------- 152 | 153 | # One entry per manual page. List of tuples 154 | # (source start file, name, description, authors, manual section). 155 | man_pages = [ 156 | (master_doc, 'bhopengraph', 'bhopengraph Documentation', 157 | [author], 1) 158 | ] 159 | 160 | # -- Options for Texinfo output ------------------------------------------- 161 | 162 | # Grouping the document tree into Texinfo files. List of tuples 163 | # (source start file, target name, title, author, 164 | # dir menu entry, description, category) 165 | texinfo_documents = [ 166 | (master_doc, 'bhopengraph', 'bhopengraph Documentation', 167 | author, 'bhopengraph', 'A python library to create BloodHound OpenGraphs easily', 168 | 'Miscellaneous'), 169 | ] 170 | 171 | # -- Options for Epub output ---------------------------------------------- 172 | 173 | # Bibliographic Dublin Core info. 174 | epub_title = project 175 | epub_author = author 176 | epub_publisher = author 177 | epub_copyright = copyright 178 | 179 | # The unique identifier of the text. This can be a ISBN number 180 | # or the project homepage. 181 | # epub_identifier = '' 182 | 183 | # A unique identification for the text. 184 | # epub_uid = '' 185 | 186 | # A list of files that should not be packed into the epub file. 187 | epub_exclude_files = ['search.html'] 188 | -------------------------------------------------------------------------------- /bhopengraph/tests/test_Edge.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Test cases for the Edge class. 5 | """ 6 | 7 | import unittest 8 | 9 | from bhopengraph.Edge import Edge 10 | from bhopengraph.Properties import Properties 11 | 12 | 13 | class TestEdge(unittest.TestCase): 14 | """Test cases for the Edge class.""" 15 | 16 | def setUp(self): 17 | """Set up test fixtures.""" 18 | self.edge = Edge("start_node", "end_node", "OWNS", Properties(weight=1)) 19 | 20 | def test_init_with_valid_params(self): 21 | """Test Edge initialization with valid parameters.""" 22 | edge = Edge("start_node", "end_node", "OWNS", Properties(weight=1)) 23 | self.assertEqual(edge.start_node, "start_node") 24 | self.assertEqual(edge.end_node, "end_node") 25 | self.assertEqual(edge.kind, "OWNS") 26 | self.assertIsInstance(edge.properties, Properties) 27 | 28 | def test_init_with_empty_start_node_raises_error(self): 29 | """Test that Edge initialization with empty start node raises ValueError.""" 30 | with self.assertRaises(ValueError): 31 | Edge("", "end_node", "OWNS") 32 | 33 | def test_init_with_none_start_node_raises_error(self): 34 | """Test that Edge initialization with None start node raises ValueError.""" 35 | with self.assertRaises(ValueError): 36 | Edge(None, "end_node", "OWNS") 37 | 38 | def test_init_with_empty_end_node_raises_error(self): 39 | """Test that Edge initialization with empty end node raises ValueError.""" 40 | with self.assertRaises(ValueError): 41 | Edge("start_node", "", "OWNS") 42 | 43 | def test_init_with_none_end_node_raises_error(self): 44 | """Test that Edge initialization with None end node raises ValueError.""" 45 | with self.assertRaises(ValueError): 46 | Edge("start_node", None, "OWNS") 47 | 48 | def test_init_with_empty_kind_raises_error(self): 49 | """Test that Edge initialization with empty kind raises ValueError.""" 50 | with self.assertRaises(ValueError): 51 | Edge("start_node", "end_node", "") 52 | 53 | def test_init_with_none_kind_raises_error(self): 54 | """Test that Edge initialization with None kind raises ValueError.""" 55 | with self.assertRaises(ValueError): 56 | Edge("start_node", "end_node", None) 57 | 58 | def test_init_with_default_properties(self): 59 | """Test Edge initialization with default properties.""" 60 | edge = Edge("start_node", "end_node", "OWNS") 61 | self.assertIsInstance(edge.properties, Properties) 62 | 63 | def test_set_property(self): 64 | """Test setting a property on an edge.""" 65 | self.edge.set_property("color", "red") 66 | self.assertEqual(self.edge.get_property("color"), "red") 67 | 68 | def test_get_property_with_default(self): 69 | """Test getting a property with default value.""" 70 | value = self.edge.get_property("nonexistent", "default_value") 71 | self.assertEqual(value, "default_value") 72 | 73 | def test_get_property_without_default(self): 74 | """Test getting a non-existent property without default.""" 75 | value = self.edge.get_property("nonexistent") 76 | self.assertIsNone(value) 77 | 78 | def test_remove_property(self): 79 | """Test removing a property from an edge.""" 80 | self.edge.set_property("temp", "value") 81 | self.edge.remove_property("temp") 82 | self.assertIsNone(self.edge.get_property("temp")) 83 | 84 | def test_to_dict_with_properties(self): 85 | """Test converting edge to dictionary with properties.""" 86 | edge_dict = self.edge.to_dict() 87 | expected = { 88 | "kind": "OWNS", 89 | "start": {"value": "start_node", "match_by": "id"}, 90 | "end": {"value": "end_node", "match_by": "id"}, 91 | "properties": {"weight": 1}, 92 | } 93 | self.assertEqual(edge_dict, expected) 94 | 95 | def test_to_dict_without_properties(self): 96 | """Test converting edge to dictionary without properties.""" 97 | edge = Edge("start_node", "end_node", "OWNS") 98 | edge_dict = edge.to_dict() 99 | expected = { 100 | "kind": "OWNS", 101 | "start": {"value": "start_node", "match_by": "id"}, 102 | "end": {"value": "end_node", "match_by": "id"}, 103 | } 104 | self.assertEqual(edge_dict, expected) 105 | 106 | def test_to_dict_empty_properties(self): 107 | """Test converting edge to dictionary with empty properties.""" 108 | edge = Edge("start_node", "end_node", "OWNS", Properties()) 109 | edge_dict = edge.to_dict() 110 | expected = { 111 | "kind": "OWNS", 112 | "start": {"value": "start_node", "match_by": "id"}, 113 | "end": {"value": "end_node", "match_by": "id"}, 114 | } 115 | self.assertEqual(edge_dict, expected) 116 | 117 | def test_get_start_node(self): 118 | """Test getting start node ID.""" 119 | self.assertEqual(self.edge.get_start_node(), "start_node") 120 | 121 | def test_get_end_node(self): 122 | """Test getting end node ID.""" 123 | self.assertEqual(self.edge.get_end_node(), "end_node") 124 | 125 | def test_get_kind(self): 126 | """Test getting edge kind.""" 127 | self.assertEqual(self.edge.get_kind(), "OWNS") 128 | 129 | def test_eq_same_edge(self): 130 | """Test equality with same edge.""" 131 | edge2 = Edge("start_node", "end_node", "OWNS") 132 | self.assertEqual(self.edge, edge2) 133 | 134 | def test_eq_different_start_node(self): 135 | """Test equality with different start node.""" 136 | edge2 = Edge("different_start", "end_node", "OWNS") 137 | self.assertNotEqual(self.edge, edge2) 138 | 139 | def test_eq_different_end_node(self): 140 | """Test equality with different end node.""" 141 | edge2 = Edge("start_node", "different_end", "OWNS") 142 | self.assertNotEqual(self.edge, edge2) 143 | 144 | def test_eq_different_kind(self): 145 | """Test equality with different kind.""" 146 | edge2 = Edge("start_node", "end_node", "DIFFERENT") 147 | self.assertNotEqual(self.edge, edge2) 148 | 149 | def test_eq_different_type(self): 150 | """Test equality with different type.""" 151 | self.assertNotEqual(self.edge, "not an edge") 152 | 153 | def test_hash_consistency(self): 154 | """Test that hash is consistent for same edge properties.""" 155 | edge2 = Edge("start_node", "end_node", "OWNS") 156 | self.assertEqual(hash(self.edge), hash(edge2)) 157 | 158 | def test_hash_different_edges(self): 159 | """Test that different edges have different hashes.""" 160 | edge2 = Edge("different_start", "end_node", "OWNS") 161 | self.assertNotEqual(hash(self.edge), hash(edge2)) 162 | 163 | def test_repr(self): 164 | """Test string representation of edge.""" 165 | repr_str = repr(self.edge) 166 | self.assertIn("start_node", repr_str) 167 | self.assertIn("end_node", repr_str) 168 | self.assertIn("OWNS", repr_str) 169 | self.assertIn("Properties", repr_str) 170 | 171 | 172 | if __name__ == "__main__": 173 | unittest.main() 174 | -------------------------------------------------------------------------------- /examples/03_edge_operations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : 03_edge_operations.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | """ 8 | Edge Operations Example 9 | 10 | This example demonstrates: 11 | - Creating different types of edges 12 | - Adding edge properties 13 | - Finding edges by type 14 | - Edge traversal operations 15 | """ 16 | 17 | from bhopengraph.OpenGraph import OpenGraph 18 | from bhopengraph.Node import Node 19 | from bhopengraph.Edge import Edge 20 | from bhopengraph.Properties import Properties 21 | 22 | def main(): 23 | """ 24 | Demonstrate edge operations and different edge types. 25 | """ 26 | print("Edge Operations Example") 27 | print("=" * 35) 28 | 29 | # Create a graph 30 | graph = OpenGraph(source_kind="Network") 31 | 32 | # Create nodes 33 | nodes = { 34 | "router": Node("router_001", ["Device", "Router"], Properties(name="Core Router", ip="10.0.0.1")), 35 | "switch1": Node("switch_001", ["Device", "Switch"], Properties(name="Access Switch 1", ip="10.0.1.1")), 36 | "switch2": Node("switch_002", ["Device", "Switch"], Properties(name="Access Switch 2", ip="10.0.2.1")), 37 | "server1": Node("server_001", ["Device", "Server"], Properties(name="Web Server", ip="10.0.1.10")), 38 | "server2": Node("server_002", ["Device", "Server"], Properties(name="Database Server", ip="10.0.2.10")), 39 | "workstation1": Node("ws_001", ["Device", "Workstation"], Properties(name="User PC 1", ip="10.0.1.100")), 40 | "workstation2": Node("ws_002", ["Device", "Workstation"], Properties(name="User PC 2", ip="10.0.2.100")), 41 | } 42 | 43 | # Add nodes to graph 44 | for node in nodes.values(): 45 | graph.add_node(node) 46 | 47 | # Create edges with different types and properties 48 | edges = [ 49 | # Network connections 50 | Edge(nodes["router"].id, nodes["switch1"].id, "ConnectedTo", 51 | Properties(bandwidth="1Gbps", cable_type="fiber")), 52 | Edge(nodes["router"].id, nodes["switch2"].id, "ConnectedTo", 53 | Properties(bandwidth="1Gbps", cable_type="fiber")), 54 | Edge(nodes["switch1"].id, nodes["server1"].id, "ConnectedTo", 55 | Properties(bandwidth="100Mbps", cable_type="copper")), 56 | Edge(nodes["switch1"].id, nodes["workstation1"].id, "ConnectedTo", 57 | Properties(bandwidth="100Mbps", cable_type="copper")), 58 | Edge(nodes["switch2"].id, nodes["server2"].id, "ConnectedTo", 59 | Properties(bandwidth="100Mbps", cable_type="copper")), 60 | Edge(nodes["switch2"].id, nodes["workstation2"].id, "ConnectedTo", 61 | Properties(bandwidth="100Mbps", cable_type="copper")), 62 | 63 | # Administrative access 64 | Edge(nodes["router"].id, nodes["switch1"].id, "Manages", 65 | Properties(access_level="admin", protocol="SSH")), 66 | Edge(nodes["router"].id, nodes["switch2"].id, "Manages", 67 | Properties(access_level="admin", protocol="SSH")), 68 | 69 | # Service dependencies 70 | Edge(nodes["server1"].id, nodes["server2"].id, "DependsOn", 71 | Properties(service="database", criticality="high")), 72 | Edge(nodes["workstation1"].id, nodes["server1"].id, "Accesses", 73 | Properties(service="web", protocol="HTTP")), 74 | Edge(nodes["workstation2"].id, nodes["server1"].id, "Accesses", 75 | Properties(service="web", protocol="HTTP")), 76 | ] 77 | 78 | # Add edges to graph 79 | for edge in edges: 80 | graph.add_edge(edge) 81 | 82 | print(f"Graph created with {graph.get_node_count()} nodes and {graph.get_edge_count()} edges") 83 | 84 | # Demonstrate edge operations 85 | print(f"\nEdge Operations:") 86 | 87 | # Show edges by type 88 | for edge_type in ["ConnectedTo", "Manages", "DependsOn", "Accesses"]: 89 | edges_of_type = graph.get_edges_by_kind(edge_type) 90 | print(f" {edge_type} edges: {len(edges_of_type)}") 91 | for edge in edges_of_type: 92 | start_node = graph.get_node_by_id(edge.start_node) 93 | end_node = graph.get_node_by_id(edge.end_node) 94 | start_name = start_node.get_property('name', edge.start_node) 95 | end_name = end_node.get_property('name', edge.end_node) 96 | print(f" {start_name} -> {end_name}") 97 | 98 | # Show edges from specific nodes 99 | print(f"\nEdges from Router:") 100 | router_edges = graph.get_edges_from_node(nodes["router"].id) 101 | for edge in router_edges: 102 | end_node = graph.get_node_by_id(edge.end_node) 103 | end_name = end_node.get_property('name', edge.end_node) 104 | print(f" Router --[{edge.kind}]--> {end_name}") 105 | 106 | # Show edges to specific nodes 107 | print(f"\nEdges to Web Server:") 108 | server1_edges = graph.get_edges_to_node(nodes["server1"].id) 109 | for edge in server1_edges: 110 | start_node = graph.get_node_by_id(edge.start_node) 111 | start_name = start_node.get_property('name', edge.start_node) 112 | print(f" {start_name} --[{edge.kind}]--> Web Server") 113 | 114 | # Demonstrate edge properties 115 | print(f"\nEdge Properties:") 116 | for edge in graph.edges[:3]: # Show first 3 edges 117 | start_node = graph.get_node_by_id(edge.start_node) 118 | end_node = graph.get_node_by_id(edge.end_node) 119 | start_name = start_node.get_property('name', edge.start_node) 120 | end_name = end_node.get_property('name', edge.end_node) 121 | 122 | print(f" {start_name} --[{edge.kind}]--> {end_name}") 123 | if edge.properties: 124 | for key, value in edge.properties.get_all_properties().items(): 125 | print(f" {key}: {value}") 126 | 127 | # Find paths between workstations and database server 128 | print(f"\nPath Finding:") 129 | 130 | # Path from Workstation 1 to Database Server 131 | paths = graph.find_paths(nodes["workstation1"].id, nodes["server2"].id, max_depth=5) 132 | if paths: 133 | print(f" Paths from Workstation 1 to Database Server:") 134 | for i, path in enumerate(paths): 135 | path_names = [] 136 | for node_id in path: 137 | node = graph.get_node_by_id(node_id) 138 | path_names.append(node.get_property('name', node_id)) 139 | print(f" Path {i+1}: {' -> '.join(path_names)}") 140 | else: 141 | print(f" No path found from Workstation 1 to Database Server") 142 | 143 | # Path from Workstation 2 to Database Server 144 | paths = graph.find_paths(nodes["workstation2"].id, nodes["server2"].id, max_depth=5) 145 | if paths: 146 | print(f" Paths from Workstation 2 to Database Server:") 147 | for i, path in enumerate(paths): 148 | path_names = [] 149 | for node_id in path: 150 | node = graph.get_node_by_id(node_id) 151 | path_names.append(node.get_property('name', node_id)) 152 | print(f" Path {i+1}: {' -> '.join(path_names)}") 153 | else: 154 | print(f" No path found from Workstation 2 to Database Server") 155 | 156 | # Show network topology 157 | print(f"\nNetwork Topology:") 158 | for node_id, node in graph.nodes.items(): 159 | name = node.get_property('name', node_id) 160 | print(f" {name}:") 161 | 162 | # Show outgoing connections 163 | outgoing = graph.get_edges_from_node(node_id) 164 | for edge in outgoing: 165 | end_node = graph.get_node_by_id(edge.end_node) 166 | end_name = end_node.get_property('name', edge.end_node) 167 | print(f" -> {end_name} ({edge.kind})") 168 | 169 | # Export to JSON 170 | graph.export_to_file("edge_operations_example.json") 171 | print(f"\nGraph exported to 'edge_operations_example.json'") 172 | 173 | if __name__ == "__main__": 174 | main() 175 | -------------------------------------------------------------------------------- /examples/04_graph_analysis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : 04_graph_analysis.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | """ 8 | Graph Analysis Example 9 | 10 | This example demonstrates: 11 | - Graph validation 12 | - Connected components analysis 13 | - Node and edge statistics 14 | - Graph integrity checks 15 | """ 16 | 17 | from bhopengraph.OpenGraph import OpenGraph 18 | from bhopengraph.Node import Node 19 | from bhopengraph.Edge import Edge 20 | from bhopengraph.Properties import Properties 21 | 22 | def main(): 23 | """ 24 | Demonstrate graph analysis and validation capabilities. 25 | """ 26 | print("Graph Analysis Example") 27 | print("=" * 35) 28 | 29 | # Create a graph with some intentional issues to demonstrate validation 30 | graph = OpenGraph(source_kind="Analysis") 31 | 32 | # Create nodes 33 | nodes = { 34 | "user1": Node("user_001", ["User"], Properties(name="Alice", role="analyst")), 35 | "user2": Node("user_002", ["User"], Properties(name="Bob", role="developer")), 36 | "user3": Node("user_003", ["User"], Properties(name="Charlie", role="admin")), 37 | "group1": Node("group_001", ["Group"], Properties(name="Developers", type="security")), 38 | "group2": Node("group_002", ["Group"], Properties(name="Admins", type="security")), 39 | "server1": Node("server_001", ["Server"], Properties(name="Web Server", os="Linux")), 40 | "server2": Node("server_002", ["Server"], Properties(name="DB Server", os="Windows")), 41 | "isolated": Node("isolated_001", ["User"], Properties(name="Isolated User", role="guest")), 42 | } 43 | 44 | # Add nodes to graph 45 | for node in nodes.values(): 46 | graph.add_node(node) 47 | 48 | # Create edges (some will create isolated components) 49 | edges = [ 50 | # Connected component 1: Users and Groups 51 | Edge(nodes["user1"].id, nodes["group1"].id, "MemberOf"), 52 | Edge(nodes["user2"].id, nodes["group1"].id, "MemberOf"), 53 | Edge(nodes["user3"].id, nodes["group2"].id, "MemberOf"), 54 | 55 | # Connected component 2: Servers 56 | Edge(nodes["server1"].id, nodes["server2"].id, "ConnectedTo"), 57 | Edge(nodes["group1"].id, nodes["server1"].id, "CanAccess"), 58 | Edge(nodes["group2"].id, nodes["server2"].id, "AdminTo"), 59 | 60 | # Isolated node (no edges) 61 | # nodes["isolated"] has no edges 62 | ] 63 | 64 | # Add edges to graph 65 | for edge in edges: 66 | graph.add_edge(edge) 67 | 68 | print(f"Graph created with {graph.get_node_count()} nodes and {graph.get_edge_count()} edges") 69 | 70 | # Demonstrate graph analysis 71 | print(f"\n" + "="*50) 72 | print("GRAPH ANALYSIS") 73 | print("="*50) 74 | 75 | # 1. Basic statistics 76 | print(f"\n1. Basic Statistics:") 77 | print(f" Total nodes: {graph.get_node_count()}") 78 | print(f" Total edges: {graph.get_edge_count()}") 79 | print(f" Graph size: {len(graph)}") 80 | 81 | # 2. Node type distribution 82 | print(f"\n2. Node Type Distribution:") 83 | for kind in ["User", "Group", "Server"]: 84 | nodes_of_kind = graph.get_nodes_by_kind(kind) 85 | print(f" {kind}: {len(nodes_of_kind)}") 86 | for node in nodes_of_kind: 87 | name = node.get_property('name', node.id) 88 | print(f" - {name}") 89 | 90 | # 3. Edge type distribution 91 | print(f"\n3. Edge Type Distribution:") 92 | edge_types = {} 93 | for edge in graph.edges: 94 | edge_types[edge.kind] = edge_types.get(edge.kind, 0) + 1 95 | 96 | for edge_type, count in sorted(edge_types.items()): 97 | print(f" {edge_type}: {count}") 98 | 99 | # 4. Connected components analysis 100 | print(f"\n4. Connected Components Analysis:") 101 | components = graph.get_connected_components() 102 | print(f" Number of components: {len(components)}") 103 | 104 | for i, component in enumerate(components): 105 | print(f" Component {i+1}: {len(component)} nodes") 106 | if len(component) <= 5: # Show small components 107 | node_names = [] 108 | for node_id in component: 109 | node = graph.get_node_by_id(node_id) 110 | if node: 111 | node_names.append(node.get_property('name', node_id)) 112 | else: 113 | node_names.append(node_id) 114 | print(f" Nodes: {', '.join(node_names)}") 115 | 116 | # 5. Node connectivity analysis 117 | print(f"\n5. Node Connectivity Analysis:") 118 | for node_id, node in graph.nodes.items(): 119 | name = node.get_property('name', node_id) 120 | incoming_edges = len(graph.get_edges_to_node(node_id)) 121 | outgoing_edges = len(graph.get_edges_from_node(node_id)) 122 | total_edges = incoming_edges + outgoing_edges 123 | 124 | print(f" {name}:") 125 | print(f" Incoming edges: {incoming_edges}") 126 | print(f" Outgoing edges: {outgoing_edges}") 127 | print(f" Total edges: {total_edges}") 128 | 129 | if total_edges == 0: 130 | print(f" ⚠️ ISOLATED NODE") 131 | 132 | # 6. Graph validation 133 | print(f"\n6. Graph Validation:") 134 | is_valid, error_list = graph.validate_graph() 135 | 136 | if not is_valid: 137 | total_issues = len(error_list) 138 | print(f" ⚠️ Found {total_issues} validation issue(s):") 139 | for err in error_list: 140 | print(f" - {err}") 141 | else: 142 | print(f" ✅ No validation issues found") 143 | 144 | # 7. Path analysis between components 145 | print(f"\n7. Path Analysis Between Components:") 146 | 147 | # Try to find paths between different components 148 | if len(components) > 1: 149 | print(f" Testing connectivity between components...") 150 | 151 | # Get nodes from different components 152 | comp1_nodes = list(components[0]) 153 | comp2_nodes = list(components[1]) 154 | 155 | if comp1_nodes and comp2_nodes: 156 | start_node = comp1_nodes[0] 157 | end_node = comp2_nodes[0] 158 | 159 | start_name = graph.get_node_by_id(start_node).get_property('name', start_node) 160 | end_name = graph.get_node_by_id(end_node).get_property('name', end_node) 161 | 162 | print(f" Testing path from {start_name} to {end_name}:") 163 | paths = graph.find_paths(start_node, end_node, max_depth=10) 164 | 165 | if paths: 166 | print(f" Found {len(paths)} path(s)") 167 | for i, path in enumerate(paths[:2]): # Show first 2 paths 168 | path_names = [] 169 | for nid in path: 170 | node = graph.get_node_by_id(nid) 171 | if node: 172 | path_names.append(node.get_property('name', nid)) 173 | else: 174 | path_names.append(nid) 175 | print(f" Path {i+1}: {' -> '.join(path_names)}") 176 | else: 177 | print(f" No path found (components are disconnected)") 178 | 179 | # 8. Graph density analysis 180 | print(f"\n8. Graph Density Analysis:") 181 | max_possible_edges = graph.get_node_count() * (graph.get_node_count() - 1) 182 | actual_edges = graph.get_edge_count() 183 | density = actual_edges / max_possible_edges if max_possible_edges > 0 else 0 184 | 185 | print(f" Maximum possible edges: {max_possible_edges}") 186 | print(f" Actual edges: {actual_edges}") 187 | print(f" Graph density: {density:.2%}") 188 | 189 | if density < 0.1: 190 | print(f" 📊 Sparse graph (low connectivity)") 191 | elif density < 0.5: 192 | print(f" 📊 Moderate density graph") 193 | else: 194 | print(f" 📊 Dense graph (high connectivity)") 195 | 196 | # Export to JSON 197 | graph.export_to_file("graph_analysis_example.json") 198 | print(f"\nGraph exported to 'graph_analysis_example.json'") 199 | 200 | # Summary 201 | print(f"\n" + "="*50) 202 | print("SUMMARY") 203 | print("="*50) 204 | print(f"✅ Graph analysis completed successfully") 205 | print(f"📊 Found {len(components)} connected components") 206 | print(f"🔍 Identified {len(error_list)} validation issues") 207 | print(f"📈 Graph density: {density:.2%}") 208 | print(f"💾 Results exported to JSON file") 209 | 210 | if __name__ == "__main__": 211 | main() 212 | -------------------------------------------------------------------------------- /bhopengraph/Node.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : Node.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | from bhopengraph.Properties import Properties 8 | 9 | # https://bloodhound.specterops.io/opengraph/schema#node-json 10 | 11 | NODE_SCHEMA = { 12 | "title": "Generic Ingest Node", 13 | "description": "A node used in a generic graph ingestion system. Each node must have a unique identifier (`id`) and at least one kind describing its role or type. Nodes may also include a `properties` object containing custom attributes.", 14 | "type": "object", 15 | "properties": { 16 | "id": {"type": "string"}, 17 | "properties": { 18 | "type": ["object", "null"], 19 | "description": "A key-value map of node attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", 20 | "additionalProperties": { 21 | "type": ["string", "number", "boolean", "array"], 22 | "items": {"not": {"type": "object"}}, 23 | }, 24 | }, 25 | "kinds": { 26 | "type": ["array"], 27 | "items": {"type": "string"}, 28 | "maxItems": 3, 29 | "minItems": 1, 30 | "description": "An array of kind labels for the node. The first element is treated as the node's primary kind and is used to determine which icon to display in the graph UI. This primary kind is only used for visual representation and has no semantic significance for data processing.", 31 | }, 32 | }, 33 | "required": ["id", "kinds"], 34 | "examples": [ 35 | {"id": "user-1234", "kinds": ["Person"]}, 36 | { 37 | "id": "device-5678", 38 | "properties": { 39 | "manufacturer": "Brandon Corp", 40 | "model": "4000x", 41 | "isActive": True, 42 | "rating": 43.50, 43 | }, 44 | "kinds": ["Device", "Asset"], 45 | }, 46 | {"id": "location-001", "properties": None, "kinds": ["Location"]}, 47 | ], 48 | } 49 | 50 | 51 | class Node(object): 52 | """ 53 | Node class representing a node in the OpenGraph. 54 | 55 | Follows BloodHound OpenGraph schema requirements with unique IDs, kinds, and properties. 56 | 57 | Sources: 58 | - https://bloodhound.specterops.io/opengraph/schema#nodes 59 | - https://bloodhound.specterops.io/opengraph/schema#minimal-working-json 60 | """ 61 | 62 | def __init__(self, id: str, kinds: list = None, properties: Properties = None): 63 | """ 64 | Initialize a Node. 65 | 66 | Args: 67 | - id (str): Universally unique identifier for the node 68 | - kinds (list): List of node types/classes 69 | - properties (Properties): Node properties 70 | """ 71 | if not id: 72 | raise ValueError("Node ID cannot be empty") 73 | 74 | self.id = id 75 | self.kinds = kinds or [] 76 | self.properties = properties or Properties() 77 | 78 | def add_kind(self, kind: str): 79 | """ 80 | Add a kind/type to the node. 81 | 82 | Args: 83 | - kind (str): Kind/type to add 84 | """ 85 | 86 | if len(self.kinds) >= 3: 87 | raise ValueError("Node can only have a maximum of 3 kinds") 88 | 89 | if kind not in self.kinds: 90 | self.kinds.append(kind) 91 | 92 | def remove_kind(self, kind: str): 93 | """ 94 | Remove a kind/type from the node. 95 | 96 | Args: 97 | - kind (str): Kind/type to remove 98 | """ 99 | if kind in self.kinds: 100 | self.kinds.remove(kind) 101 | 102 | def has_kind(self, kind: str) -> bool: 103 | """ 104 | Check if node has a specific kind/type. 105 | 106 | Args: 107 | - kind (str): Kind/type to check 108 | 109 | Returns: 110 | - bool: True if node has the kind, False otherwise 111 | """ 112 | return kind in self.kinds 113 | 114 | def set_property(self, key: str, value): 115 | """ 116 | Set a property on the node. 117 | 118 | Args: 119 | - key (str): Property name 120 | - value: Property value 121 | """ 122 | self.properties[key] = value 123 | 124 | def get_property(self, key: str, default=None): 125 | """ 126 | Get a property from the node. 127 | 128 | Args: 129 | - key (str): Property name 130 | - default: Default value if property doesn't exist 131 | 132 | Returns: 133 | - Property value or default 134 | """ 135 | return self.properties.get_property(key, default) 136 | 137 | def remove_property(self, key: str): 138 | """ 139 | Remove a property from the node. 140 | 141 | Args: 142 | - key (str): Property name to remove 143 | """ 144 | self.properties.remove_property(key) 145 | 146 | def to_dict(self) -> dict: 147 | """ 148 | Convert node to dictionary for JSON serialization. 149 | 150 | Returns: 151 | - dict: Node as dictionary following BloodHound OpenGraph schema 152 | """ 153 | node_dict = { 154 | "id": self.id, 155 | "kinds": self.kinds.copy(), 156 | "properties": self.properties.to_dict(), 157 | } 158 | return node_dict 159 | 160 | @classmethod 161 | def from_dict(cls, node_data: dict): 162 | """ 163 | Create a Node instance from a dictionary. 164 | 165 | Args: 166 | - node_data (dict): Dictionary containing node data 167 | 168 | Returns: 169 | - Node: Node instance or None if data is invalid 170 | """ 171 | try: 172 | if "id" not in node_data: 173 | return None 174 | 175 | node_id = node_data["id"] 176 | kinds = node_data.get("kinds", []) 177 | properties_data = node_data.get("properties", {}) 178 | 179 | # Create Properties instance if properties data exists 180 | properties = None 181 | if properties_data: 182 | properties = Properties() 183 | for key, value in properties_data.items(): 184 | properties[key] = value 185 | 186 | return cls(node_id, kinds, properties) 187 | except (KeyError, TypeError, ValueError): 188 | return None 189 | 190 | def __eq__(self, other): 191 | """ 192 | Check if two nodes are equal based on their ID. 193 | 194 | Args: 195 | - other (Node): The other node to compare to 196 | 197 | Returns: 198 | - bool: True if the nodes are equal, False otherwise 199 | """ 200 | if isinstance(other, Node): 201 | return self.id == other.id 202 | return False 203 | 204 | def __hash__(self): 205 | """ 206 | Hash based on node ID for use in sets and as dictionary keys. 207 | 208 | Returns: 209 | - int: Hash of the node ID 210 | """ 211 | return hash(self.id) 212 | 213 | def validate(self) -> tuple[bool, list[str]]: 214 | """ 215 | Validate the node against the NODE_SCHEMA. 216 | 217 | Returns: 218 | - tuple[bool, list[str]]: (is_valid, list_of_errors) 219 | """ 220 | errors = [] 221 | 222 | # Validate required fields 223 | if not self.id or self.id is None: 224 | errors.append("Node ID cannot be empty") 225 | elif not isinstance(self.id, str): 226 | errors.append("Node ID must be a string") 227 | 228 | # Validate kinds 229 | if not isinstance(self.kinds, list): 230 | errors.append("Kinds must be a list") 231 | elif len(self.kinds) < 1: 232 | errors.append("Node must have at least one kind") 233 | elif len(self.kinds) > 3: 234 | errors.append("Node can have at most 3 kinds") 235 | else: 236 | for i, kind in enumerate(self.kinds): 237 | if not isinstance(kind, str): 238 | errors.append(f"Kind at index {i} must be a string") 239 | 240 | # Validate properties if they exist 241 | if self.properties is not None: 242 | if not isinstance(self.properties, Properties): 243 | errors.append("Properties must be a Properties instance") 244 | else: 245 | is_props_valid, prop_errors = self.properties.validate() 246 | if not is_props_valid: 247 | errors.extend(prop_errors) 248 | 249 | return len(errors) == 0, errors 250 | 251 | def __repr__(self) -> str: 252 | return f"Node(id='{self.id}', kinds={self.kinds}, properties={self.properties})" 253 | -------------------------------------------------------------------------------- /bhopengraph/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : __main__.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 21 Aug 2025 6 | 7 | import argparse 8 | import json 9 | import os 10 | 11 | from bhopengraph import OpenGraph 12 | from bhopengraph.Logger import Logger 13 | from bhopengraph.utils import filesize_string 14 | 15 | """ 16 | Main module for BloodHound OpenGraph. 17 | 18 | This module provides the main entry point for the BloodHound OpenGraph package. 19 | """ 20 | 21 | 22 | def parseArgs(): 23 | parser = argparse.ArgumentParser(description="") 24 | 25 | parser.add_argument( 26 | "--debug", dest="debug", action="store_true", default=False, help="Debug mode." 27 | ) 28 | 29 | # Creating the "info" subparser ============================================================================================================== 30 | mode_info = argparse.ArgumentParser(add_help=False) 31 | mode_info.add_argument( 32 | "--file", 33 | dest="file", 34 | required=True, 35 | default=None, 36 | help="OpenGraph JSON file to process", 37 | ) 38 | mode_info.add_argument( 39 | "--json", 40 | dest="json", 41 | action="store_true", 42 | default=False, 43 | help="Output info in JSON format", 44 | ) 45 | 46 | # Creating the "validate" subparser ============================================================================================================== 47 | mode_validate = argparse.ArgumentParser(add_help=False) 48 | mode_validate.add_argument( 49 | "--file", 50 | dest="file", 51 | required=True, 52 | default=None, 53 | help="OpenGraph JSON file to process", 54 | ) 55 | mode_validate.add_argument( 56 | "--json", 57 | dest="json", 58 | action="store_true", 59 | default=False, 60 | help="Output validation errors in JSON format", 61 | ) 62 | 63 | # Mode paths 64 | mode_showpaths = argparse.ArgumentParser(add_help=False) 65 | mode_showpaths.add_argument( 66 | "--file", 67 | dest="file", 68 | required=True, 69 | default=None, 70 | help="OpenGraph JSON file to process", 71 | ) 72 | mode_showpaths.add_argument( 73 | "--start-node-kind", 74 | dest="start_node_kind", 75 | required=True, 76 | default=None, 77 | help="Start node kind, This will be used to find the start node in the graph. This is required to find the start node in the graph", 78 | ) 79 | mode_showpaths.add_argument( 80 | "--start-node-id", 81 | dest="start_node_id", 82 | required=True, 83 | default=None, 84 | help="Start node ID, This will be used to find the start node in the graph. This is required to find the start node in the graph", 85 | ) 86 | mode_showpaths.add_argument( 87 | "--end-node-id", 88 | dest="end_node_id", 89 | required=True, 90 | default=None, 91 | help="End node ID, This will be used to find the end node in the graph. This is required to find the end node in the graph", 92 | ) 93 | mode_showpaths.add_argument( 94 | "--max-depth", 95 | dest="max_depth", 96 | type=int, 97 | default=10, 98 | help="Maximum path depth to search (default: 10)", 99 | ) 100 | 101 | # Adding the subparsers to the base parser 102 | subparsers = parser.add_subparsers(help="Mode", dest="mode", required=True) 103 | subparsers.add_parser("info", parents=[mode_info], help="Info mode") 104 | subparsers.add_parser("validate", parents=[mode_validate], help="Validate mode") 105 | subparsers.add_parser("showpaths", parents=[mode_showpaths], help="Show paths mode") 106 | 107 | return parser.parse_args() 108 | 109 | 110 | def main(): 111 | options = parseArgs() 112 | 113 | logger = Logger(options.debug) 114 | 115 | if options.mode == "info": 116 | if os.path.exists(options.file): 117 | logger.log( 118 | f"[+] Loading OpenGraph data from {options.file} (size %s)" 119 | % filesize_string(os.path.getsize(options.file)) 120 | ) 121 | graph = OpenGraph() 122 | graph.import_from_file(options.file) 123 | logger.log(" └── OpenGraph successfully loaded") 124 | 125 | # Get the graph information 126 | logger.log("[+] Computing OpenGraph information") 127 | logger.log(" ├── Computing node count (total and isolated) ...") 128 | nodeCount = graph.get_node_count() 129 | isolatedNodesCount = graph.get_isolated_nodes_count() 130 | connectedNodesCount = nodeCount - isolatedNodesCount 131 | logger.log(" └── Computing edge count (total and isolated) ...") 132 | edgeCount = graph.get_edge_count() 133 | isolatedEdgesCount = graph.get_isolated_edges_count() 134 | connectedEdgesCount = edgeCount - isolatedEdgesCount 135 | 136 | # Print the OpenGraph information 137 | logger.log("[+] OpenGraph information:") 138 | logger.log(" ├── Metadata:") 139 | logger.log(f" │ └── Source kind: {graph.source_kind}") 140 | logger.log(f" ├── Total nodes: {nodeCount}") 141 | logger.log( 142 | f" │ ├── Connected nodes : \x1b[96m{connectedNodesCount}\x1b[0m (\x1b[92m{connectedNodesCount / nodeCount * 100:.2f}%\x1b[0m)" 143 | ) 144 | logger.log( 145 | f" │ └── Isolated nodes : \x1b[96m{isolatedNodesCount}\x1b[0m (\x1b[91m{isolatedNodesCount / nodeCount * 100:.2f}%\x1b[0m)" 146 | ) 147 | logger.log(f" ├── Total edges: {edgeCount}") 148 | logger.log( 149 | f" │ ├── Connected edges : \x1b[96m{connectedEdgesCount}\x1b[0m (\x1b[92m{connectedEdgesCount / edgeCount * 100:.2f}%\x1b[0m)" 150 | ) 151 | logger.log( 152 | f" │ └── Isolated edges : \x1b[96m{isolatedEdgesCount}\x1b[0m (\x1b[91m{isolatedEdgesCount / edgeCount * 100:.2f}%\x1b[0m)" 153 | ) 154 | logger.log(" └──") 155 | 156 | logger.log("[+] OpenGraph validation ...") 157 | is_valid, validation_errors = graph.validate_graph() 158 | if not is_valid: 159 | total_errors = len(validation_errors) 160 | logger.log(f" ├── ❌ Validation errors: ({total_errors})") 161 | 162 | k = 0 163 | for error in validation_errors: 164 | k += 1 165 | if k == len(validation_errors): 166 | logger.log(f" │ └── {error}") 167 | else: 168 | logger.log(f" │ ├── {error}") 169 | logger.log(" └──") 170 | else: 171 | logger.log(" │ └── ✅ No validation errors") 172 | logger.log(" └──") 173 | 174 | else: 175 | logger.error(f"File {options.file} does not exist") 176 | return 177 | 178 | elif options.mode == "validate": 179 | if os.path.exists(options.file): 180 | logger.debug( 181 | f"[+] Loading OpenGraph data from {options.file} (size %s)" 182 | % filesize_string(os.path.getsize(options.file)) 183 | ) 184 | graph = OpenGraph() 185 | graph.import_from_file(options.file) 186 | logger.debug(" └── OpenGraph successfully loaded") 187 | 188 | # Validate the graph 189 | logger.log("[+] OpenGraph validation ...") 190 | is_valid, validation_errors = graph.validate_graph() 191 | 192 | if options.json: 193 | result = {"valid": is_valid, "errors": validation_errors} 194 | print(json.dumps(result, indent=4)) 195 | else: 196 | if not is_valid: 197 | total_errors = len(validation_errors) 198 | logger.log(f" └── ❌ Validation errors: ({total_errors})") 199 | 200 | k = 0 201 | for error in validation_errors: 202 | k += 1 203 | if k == len(validation_errors): 204 | logger.log(f" │ └── {error}") 205 | else: 206 | logger.log(f" │ ├── {error}") 207 | logger.log(" └──") 208 | else: 209 | logger.log(" │ └── ✅ No validation errors") 210 | logger.log(" └──") 211 | 212 | else: 213 | if options.json: 214 | print( 215 | json.dumps( 216 | {"error": f"File {options.file} does not exist"}, indent=4 217 | ) 218 | ) 219 | else: 220 | logger.error(f"File {options.file} does not exist") 221 | return 222 | 223 | elif options.mode == "showpaths": 224 | if os.path.exists(options.file): 225 | logger.debug( 226 | f"[+] Loading OpenGraph data from {options.file} (size %s)" 227 | % filesize_string(os.path.getsize(options.file)) 228 | ) 229 | graph = OpenGraph() 230 | graph.import_from_file(options.file) 231 | logger.debug(" └── OpenGraph successfully loaded") 232 | 233 | # Find paths 234 | logger.debug("[+] Finding paths ...") 235 | paths = graph.find_paths( 236 | options.start_node_id, options.end_node_id, options.max_depth 237 | ) 238 | 239 | if options.json: 240 | print(json.dumps(paths, indent=4)) 241 | else: 242 | logger.debug(f" └── Paths: {paths}") 243 | 244 | 245 | if __name__ == "__main__": 246 | main() 247 | -------------------------------------------------------------------------------- /bhopengraph/tests/test_Node.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Test cases for the Node class. 5 | """ 6 | 7 | import unittest 8 | 9 | from bhopengraph.Node import Node 10 | from bhopengraph.Properties import Properties 11 | 12 | 13 | class TestNode(unittest.TestCase): 14 | """Test cases for the Node class.""" 15 | 16 | def setUp(self): 17 | """Set up test fixtures.""" 18 | self.node = Node("test_id", ["User"], Properties(name="Test User")) 19 | 20 | def test_init_with_valid_params(self): 21 | """Test Node initialization with valid parameters.""" 22 | node = Node("test_id", ["User"], Properties(name="Test User")) 23 | self.assertEqual(node.id, "test_id") 24 | self.assertEqual(node.kinds, ["User"]) 25 | self.assertIsInstance(node.properties, Properties) 26 | 27 | def test_init_with_empty_id_raises_error(self): 28 | """Test that Node initialization with empty ID raises an error.""" 29 | with self.assertRaises(ValueError): 30 | Node("", ["User"]) 31 | 32 | def test_init_with_none_id_raises_error(self): 33 | """Test that Node initialization with None ID raises an error.""" 34 | with self.assertRaises(ValueError): 35 | Node(None, ["User"]) 36 | 37 | def test_init_with_default_kinds(self): 38 | """Test Node initialization with default kinds.""" 39 | node = Node("test_id") 40 | self.assertEqual(node.kinds, []) 41 | 42 | def test_init_with_default_properties(self): 43 | """Test Node initialization with default properties.""" 44 | node = Node("test_id") 45 | self.assertIsInstance(node.properties, Properties) 46 | 47 | def test_add_kind(self): 48 | """Test adding a kind to a node.""" 49 | self.node.add_kind("Admin") 50 | self.assertIn("Admin", self.node.kinds) 51 | self.assertEqual(len(self.node.kinds), 2) 52 | 53 | def test_add_duplicate_kind(self): 54 | """Test adding a duplicate kind doesn't create duplicates.""" 55 | initial_count = len(self.node.kinds) 56 | self.node.add_kind("User") # Already exists 57 | self.assertEqual(len(self.node.kinds), initial_count) 58 | 59 | def test_remove_kind(self): 60 | """Test removing a kind from a node.""" 61 | self.node.add_kind("Admin") 62 | self.node.remove_kind("Admin") 63 | self.assertNotIn("Admin", self.node.kinds) 64 | 65 | def test_remove_nonexistent_kind(self): 66 | """Test removing a non-existent kind doesn't cause errors.""" 67 | initial_kinds = self.node.kinds.copy() 68 | self.node.remove_kind("NonExistent") 69 | self.assertEqual(self.node.kinds, initial_kinds) 70 | 71 | def test_has_kind_true(self): 72 | """Test has_kind returns True for existing kind.""" 73 | self.assertTrue(self.node.has_kind("User")) 74 | 75 | def test_has_kind_false(self): 76 | """Test has_kind returns False for non-existing kind.""" 77 | self.assertFalse(self.node.has_kind("Admin")) 78 | 79 | def test_set_property(self): 80 | """Test setting a property on a node.""" 81 | self.node.set_property("email", "test@example.com") 82 | self.assertEqual(self.node.get_property("email"), "test@example.com") 83 | 84 | def test_get_property_with_default(self): 85 | """Test getting a property with default value.""" 86 | value = self.node.get_property("nonexistent", "default_value") 87 | self.assertEqual(value, "default_value") 88 | 89 | def test_get_property_without_default(self): 90 | """Test getting a non-existent property without default.""" 91 | value = self.node.get_property("nonexistent") 92 | self.assertIsNone(value) 93 | 94 | def test_remove_property(self): 95 | """Test removing a property from a node.""" 96 | self.node.set_property("temp", "value") 97 | self.node.remove_property("temp") 98 | self.assertIsNone(self.node.get_property("temp")) 99 | 100 | def test_to_dict(self): 101 | """Test converting node to dictionary.""" 102 | node_dict = self.node.to_dict() 103 | expected = { 104 | "id": "test_id", 105 | "kinds": ["User"], 106 | "properties": {"name": "Test User"}, 107 | } 108 | self.assertEqual(node_dict, expected) 109 | 110 | def test_to_dict_creates_copy(self): 111 | """Test that to_dict creates a copy of kinds.""" 112 | node_dict = self.node.to_dict() 113 | self.node.kinds.append("Admin") 114 | self.assertNotEqual(node_dict["kinds"], self.node.kinds) 115 | 116 | def test_eq_same_node(self): 117 | """Test equality with same node.""" 118 | node2 = Node("test_id", ["User"]) 119 | self.assertEqual(self.node, node2) 120 | 121 | def test_eq_different_node(self): 122 | """Test equality with different node.""" 123 | node2 = Node("different_id", ["User"]) 124 | self.assertNotEqual(self.node, node2) 125 | 126 | def test_eq_different_type(self): 127 | """Test equality with different type.""" 128 | self.assertNotEqual(self.node, "not a node") 129 | 130 | def test_hash_consistency(self): 131 | """Test that hash is consistent for same ID.""" 132 | node2 = Node("test_id", ["Different"]) 133 | self.assertEqual(hash(self.node), hash(node2)) 134 | 135 | def test_hash_different_ids(self): 136 | """Test that different IDs have different hashes.""" 137 | node2 = Node("different_id", ["User"]) 138 | self.assertNotEqual(hash(self.node), hash(node2)) 139 | 140 | def test_repr(self): 141 | """Test string representation of node.""" 142 | repr_str = repr(self.node) 143 | self.assertIn("test_id", repr_str) 144 | self.assertIn("User", repr_str) 145 | self.assertIn("Properties", repr_str) 146 | 147 | def test_validate_empty_id(self): 148 | """Test validation with empty ID.""" 149 | with self.assertRaises(ValueError) as context: 150 | Node("", ["User"]) 151 | self.assertIn("Node ID cannot be empty", str(context.exception)) 152 | 153 | def test_validate_none_id(self): 154 | """Test validation with None ID.""" 155 | with self.assertRaises(ValueError) as context: 156 | Node(None, ["User"]) 157 | self.assertIn("Node ID cannot be empty", str(context.exception)) 158 | 159 | def test_validate_non_string_id(self): 160 | """Test validation with non-string ID.""" 161 | node = Node(123, ["User"]) 162 | is_valid, errors = node.validate() 163 | self.assertFalse(is_valid) 164 | self.assertIn("Node ID must be a string", errors) 165 | 166 | def test_validate_zero_kinds(self): 167 | """Test validation with zero kinds.""" 168 | node = Node("test_id", []) 169 | is_valid, errors = node.validate() 170 | self.assertFalse(is_valid) 171 | self.assertIn("Node must have at least one kind", errors) 172 | 173 | def test_validate_one_kind(self): 174 | """Test validation with one kind.""" 175 | node = Node("test_id", ["User"]) 176 | is_valid, errors = node.validate() 177 | self.assertTrue(is_valid) 178 | self.assertEqual(errors, []) 179 | 180 | def test_validate_two_kinds(self): 181 | """Test validation with two kinds.""" 182 | node = Node("test_id", ["User", "Admin"]) 183 | is_valid, errors = node.validate() 184 | self.assertTrue(is_valid) 185 | self.assertEqual(errors, []) 186 | 187 | def test_validate_three_kinds(self): 188 | """Test validation with three kinds.""" 189 | node = Node("test_id", ["User", "Admin", "Manager"]) 190 | is_valid, errors = node.validate() 191 | self.assertTrue(is_valid) 192 | self.assertEqual(errors, []) 193 | 194 | def test_validate_four_kinds(self): 195 | """Test validation with four kinds.""" 196 | node = Node("test_id", ["User", "Admin", "Manager", "Owner"]) 197 | is_valid, errors = node.validate() 198 | self.assertFalse(is_valid) 199 | self.assertIn("Node can have at most 3 kinds", errors) 200 | 201 | def test_validate_five_kinds(self): 202 | """Test validation with five kinds.""" 203 | node = Node("test_id", ["User", "Admin", "Manager", "Owner", "Guest"]) 204 | is_valid, errors = node.validate() 205 | self.assertFalse(is_valid) 206 | self.assertIn("Node can have at most 3 kinds", errors) 207 | 208 | def test_validate_non_string_kind(self): 209 | """Test validation with non-string kind.""" 210 | node = Node("test_id", ["User", 123, "Admin"]) 211 | is_valid, errors = node.validate() 212 | self.assertFalse(is_valid) 213 | self.assertIn("Kind at index 1 must be a string", errors) 214 | 215 | def test_validate_non_list_kinds(self): 216 | """Test validation with non-list kinds.""" 217 | node = Node("test_id", "User") 218 | is_valid, errors = node.validate() 219 | self.assertFalse(is_valid) 220 | self.assertIn("Kinds must be a list", errors) 221 | 222 | def test_validate_invalid_properties(self): 223 | """Test validation with invalid properties.""" 224 | from bhopengraph.Properties import Properties 225 | 226 | props = Properties() 227 | props._properties = { 228 | "invalid": {"nested": "object"} 229 | } # Directly set invalid property 230 | node = Node("test_id", ["User"], props) 231 | is_valid, errors = node.validate() 232 | self.assertFalse(is_valid) 233 | self.assertTrue(any("invalid" in error for error in errors)) 234 | 235 | def test_validate_valid_node(self): 236 | """Test validation with completely valid node.""" 237 | is_valid, errors = self.node.validate() 238 | self.assertTrue(is_valid) 239 | self.assertEqual(errors, []) 240 | 241 | 242 | if __name__ == "__main__": 243 | unittest.main() 244 | -------------------------------------------------------------------------------- /bhopengraph/Edge.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : Edge.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | from bhopengraph.Properties import Properties 8 | 9 | # https://bloodhound.specterops.io/opengraph/schema#edge-json 10 | EDGE_SCHEMA = { 11 | "title": "Generic Ingest Edge", 12 | "description": "Defines an edge between two nodes in a generic graph ingestion system. Each edge specifies a start and end node using either a unique identifier (id) or a name-based lookup. A kind is required to indicate the relationship type. Optional properties may include custom attributes. You may optionally constrain the start or end node to a specific kind using the kind field inside each reference.", 13 | "type": "object", 14 | "properties": { 15 | "start": { 16 | "type": "object", 17 | "properties": { 18 | "match_by": { 19 | "type": "string", 20 | "enum": ["id", "name"], 21 | "default": "id", 22 | "description": "Whether to match the start node by its unique object ID or by its name property.", 23 | }, 24 | "value": { 25 | "type": "string", 26 | "description": "The value used for matching — either an object ID or a name, depending on match_by.", 27 | }, 28 | "kind": { 29 | "type": "string", 30 | "description": "Optional kind filter; the referenced node must have this kind.", 31 | }, 32 | }, 33 | "required": ["value"], 34 | }, 35 | "end": { 36 | "type": "object", 37 | "properties": { 38 | "match_by": { 39 | "type": "string", 40 | "enum": ["id", "name"], 41 | "default": "id", 42 | "description": "Whether to match the end node by its unique object ID or by its name property.", 43 | }, 44 | "value": { 45 | "type": "string", 46 | "description": "The value used for matching — either an object ID or a name, depending on match_by.", 47 | }, 48 | "kind": { 49 | "type": "string", 50 | "description": "Optional kind filter; the referenced node must have this kind.", 51 | }, 52 | }, 53 | "required": ["value"], 54 | }, 55 | "kind": {"type": "string"}, 56 | "properties": { 57 | "type": ["object", "null"], 58 | "description": "A key-value map of edge attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", 59 | "additionalProperties": { 60 | "type": ["string", "number", "boolean", "array"], 61 | "items": {"not": {"type": "object"}}, 62 | }, 63 | }, 64 | }, 65 | "required": ["start", "end", "kind"], 66 | "examples": [ 67 | { 68 | "start": {"match_by": "id", "value": "user-1234"}, 69 | "end": {"match_by": "id", "value": "server-5678"}, 70 | "kind": "HasSession", 71 | "properties": {"timestamp": "2025-04-16T12:00:00Z", "duration_minutes": 45}, 72 | }, 73 | { 74 | "start": {"match_by": "name", "value": "alice", "kind": "User"}, 75 | "end": {"match_by": "name", "value": "file-server-1", "kind": "Server"}, 76 | "kind": "AccessedResource", 77 | "properties": {"via": "SMB", "sensitive": True}, 78 | }, 79 | { 80 | "start": {"value": "admin-1"}, 81 | "end": {"value": "domain-controller-9"}, 82 | "kind": "AdminTo", 83 | "properties": {"reason": "elevated_permissions", "confirmed": False}, 84 | }, 85 | { 86 | "start": {"match_by": "name", "value": "Printer-007"}, 87 | "end": {"match_by": "id", "value": "network-42"}, 88 | "kind": "ConnectedTo", 89 | "properties": None, 90 | }, 91 | ], 92 | } 93 | 94 | 95 | class Edge(object): 96 | """ 97 | Edge class representing a directed edge in the OpenGraph. 98 | 99 | Follows BloodHound OpenGraph schema requirements with start/end nodes, kind, and properties. 100 | All edges are directed and one-way as per BloodHound requirements. 101 | 102 | Sources: 103 | - https://bloodhound.specterops.io/opengraph/schema#edges 104 | - https://bloodhound.specterops.io/opengraph/schema#minimal-working-json 105 | """ 106 | 107 | def __init__( 108 | self, 109 | start_node: str, 110 | end_node: str, 111 | kind: str, 112 | properties: Properties = None, 113 | start_match_by: str = "id", 114 | end_match_by: str = "id", 115 | ): 116 | """ 117 | Initialize an Edge. 118 | 119 | Args: 120 | - start_node (str): ID of the source node 121 | - end_node (str): ID of the destination node 122 | - kind (str): Type/class of the edge relationship 123 | - properties (Properties): Edge properties 124 | """ 125 | if not start_node: 126 | raise ValueError("Start node ID cannot be empty") 127 | if not end_node: 128 | raise ValueError("End node ID cannot be empty") 129 | if not kind: 130 | raise ValueError("Edge kind cannot be empty") 131 | 132 | self.start_node = start_node 133 | self.end_node = end_node 134 | self.kind = kind 135 | self.properties = properties or Properties() 136 | 137 | self.start_match_by = start_match_by 138 | self.end_match_by = end_match_by 139 | 140 | def set_property(self, key: str, value): 141 | """ 142 | Set a property on the edge. 143 | 144 | Args: 145 | - key (str): Property name 146 | - value: Property value 147 | """ 148 | self.properties[key] = value 149 | 150 | def get_property(self, key: str, default=None): 151 | """ 152 | Get a property from the edge. 153 | 154 | Args: 155 | - key (str): Property name 156 | - default: Default value if property doesn't exist 157 | 158 | Returns: 159 | - Property value or default 160 | """ 161 | return self.properties.get_property(key, default) 162 | 163 | def remove_property(self, key: str): 164 | """ 165 | Remove a property from the edge. 166 | 167 | Args: 168 | - key (str): Property name to remove 169 | """ 170 | self.properties.remove_property(key) 171 | 172 | def to_dict(self) -> dict: 173 | """ 174 | Convert edge to dictionary for JSON serialization. 175 | 176 | Returns: 177 | - dict: Edge as dictionary following BloodHound OpenGraph schema 178 | """ 179 | edge_dict = { 180 | "kind": self.kind, 181 | "start": {"value": self.start_node, "match_by": self.start_match_by}, 182 | "end": {"value": self.end_node, "match_by": self.end_match_by}, 183 | } 184 | 185 | # Only include properties if they exist and are not empty 186 | if self.properties and len(self.properties) > 0: 187 | edge_dict["properties"] = self.properties.to_dict() 188 | 189 | return edge_dict 190 | 191 | @classmethod 192 | def from_dict(cls, edge_data: dict): 193 | """ 194 | Create an Edge instance from a dictionary. 195 | 196 | Args: 197 | - edge_data (dict): Dictionary containing edge data 198 | 199 | Returns: 200 | - Edge: Edge instance or None if data is invalid 201 | """ 202 | try: 203 | if "kind" not in edge_data: 204 | return None 205 | 206 | kind = edge_data["kind"] 207 | 208 | # Handle different edge data formats 209 | start_node = None 210 | end_node = None 211 | start_match_by = None 212 | end_match_by = None 213 | 214 | if "start" in edge_data and "end" in edge_data: 215 | # Direct format: {"start": "id", "end": "id"} 216 | start_node = edge_data["start"]["value"] 217 | start_match_by = edge_data["start"]["match_by"] 218 | end_node = edge_data["end"]["value"] 219 | end_match_by = edge_data["end"]["match_by"] 220 | 221 | if not start_node or not end_node: 222 | return None 223 | 224 | properties_data = edge_data.get("properties", {}) 225 | 226 | # Create Properties instance if properties data exists 227 | properties = None 228 | if properties_data: 229 | properties = Properties() 230 | for key, value in properties_data.items(): 231 | properties[key] = value 232 | 233 | return cls( 234 | start_node, end_node, kind, properties, start_match_by, end_match_by 235 | ) 236 | except (KeyError, TypeError, ValueError): 237 | return None 238 | 239 | def get_start_node(self) -> str: 240 | """ 241 | Get the start node ID. 242 | 243 | Returns: 244 | - str: Start node ID 245 | """ 246 | return self.start_node 247 | 248 | def get_end_node(self) -> str: 249 | """ 250 | Get the end node ID. 251 | 252 | Returns: 253 | - str: End node ID 254 | """ 255 | return self.end_node 256 | 257 | def get_kind(self) -> str: 258 | """ 259 | Get the edge kind/type. 260 | 261 | Returns: 262 | - str: Edge kind 263 | """ 264 | return self.kind 265 | 266 | def get_unique_id(self) -> str: 267 | """ 268 | Get a unique ID for the edge. 269 | 270 | Returns: 271 | - str: Unique ID for the edge 272 | """ 273 | return f"[{self.start_match_by}:{self.start_node}]-({self.kind})->[{self.end_match_by}:{self.end_node}]" 274 | 275 | def __eq__(self, other): 276 | """ 277 | Check if two edges are equal based on their start, end, and kind. 278 | 279 | Args: 280 | - other (Edge): The other edge to compare to 281 | 282 | Returns: 283 | - bool: True if the edges are equal, False otherwise 284 | """ 285 | if isinstance(other, Edge): 286 | return ( 287 | self.start_node == other.start_node 288 | and self.end_node == other.end_node 289 | and self.kind == other.kind 290 | ) 291 | return False 292 | 293 | def __hash__(self): 294 | """ 295 | Hash based on start, end, and kind for use in sets and as dictionary keys. 296 | 297 | Returns: 298 | - int: Hash of the start, end, and kind 299 | """ 300 | return hash((self.start_node, self.end_node, self.kind)) 301 | 302 | def validate(self) -> tuple[bool, list[str]]: 303 | """ 304 | Validate the edge against the EDGE_SCHEMA. 305 | 306 | Returns: 307 | - tuple[bool, list[str]]: (is_valid, list_of_errors) 308 | """ 309 | errors = [] 310 | 311 | # Validate required fields 312 | if not self.start_node or self.start_node is None: 313 | errors.append("Start node cannot be empty") 314 | elif not isinstance(self.start_node, str): 315 | errors.append("Start node must be a string") 316 | 317 | if not self.end_node or self.end_node is None: 318 | errors.append("End node cannot be empty") 319 | elif not isinstance(self.end_node, str): 320 | errors.append("End node must be a string") 321 | 322 | if not self.kind or self.kind is None: 323 | errors.append("Edge kind cannot be empty") 324 | elif not isinstance(self.kind, str): 325 | errors.append("Edge kind must be a string") 326 | 327 | # Validate match_by values 328 | if not isinstance(self.start_match_by, str): 329 | errors.append("Start match_by must be a string") 330 | elif self.start_match_by not in ["id", "name"]: 331 | errors.append("Start match_by must be either 'id' or 'name'") 332 | 333 | if not isinstance(self.end_match_by, str): 334 | errors.append("End match_by must be a string") 335 | elif self.end_match_by not in ["id", "name"]: 336 | errors.append("End match_by must be either 'id' or 'name'") 337 | 338 | # Validate properties if they exist 339 | if self.properties is not None: 340 | if not isinstance(self.properties, Properties): 341 | errors.append("Properties must be a Properties instance") 342 | else: 343 | is_props_valid, prop_errors = self.properties.validate() 344 | if not is_props_valid: 345 | errors.extend(prop_errors) 346 | 347 | return len(errors) == 0, errors 348 | 349 | def __repr__(self) -> str: 350 | return f"Edge(start='{self.start_node}', end='{self.end_node}', kind='{self.kind}', properties={self.properties})" 351 | -------------------------------------------------------------------------------- /examples/advanced_features.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : example_advanced_features.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | """ 8 | Advanced example demonstrating BloodHound OpenGraph best practices 9 | and attack path modeling capabilities. 10 | 11 | This example shows how to create a realistic attack graph that follows 12 | BloodHound's requirements for path finding between non-adjacent nodes. 13 | """ 14 | 15 | from bhopengraph.OpenGraph import OpenGraph 16 | from bhopengraph.Node import Node 17 | from bhopengraph.Edge import Edge 18 | from bhopengraph.Properties import Properties 19 | 20 | def create_attack_graph_example(): 21 | """ 22 | Create an attack graph example following BloodHound best practices. 23 | 24 | This demonstrates: 25 | - Proper edge directionality for attack paths 26 | - Multi-hop privilege escalation 27 | - Realistic Active Directory-like relationships 28 | """ 29 | graph = OpenGraph(source_kind="ADBase") 30 | 31 | # Create nodes representing different entity types 32 | nodes = { 33 | # Users 34 | "john_doe": Node("S-1-5-21-1234567890-1234567890-1234567890-1001", 35 | ["User", "ADBase"], 36 | Properties(name="John Doe", samaccountname="jdoe", 37 | displayname="John Doe", enabled=True)), 38 | 39 | "jane_smith": Node("S-1-5-21-1234567890-1234567890-1234567890-1002", 40 | ["User", "ADBase"], 41 | Properties(name="Jane Smith", samaccountname="jsmith", 42 | displayname="Jane Smith", enabled=True)), 43 | 44 | "admin_user": Node("S-1-5-21-1234567890-1234567890-1234567890-1003", 45 | ["User", "ADBase"], 46 | Properties(name="Admin User", samaccountname="admin", 47 | displayname="Administrator", enabled=True)), 48 | 49 | # Groups 50 | "domain_users": Node("S-1-5-21-1234567890-1234567890-1234567890-513", 51 | ["Group", "ADBase"], 52 | Properties(name="Domain Users", samaccountname="Domain Users", 53 | description="All domain users")), 54 | 55 | "helpdesk_group": Node("S-1-5-21-1234567890-1234567890-1234567890-1101", 56 | ["Group", "ADBase"], 57 | Properties(name="HelpDesk", samaccountname="HelpDesk", 58 | description="Help desk technicians")), 59 | 60 | "admin_group": Node("S-1-5-21-1234567890-1234567890-1234567890-512", 61 | ["Group", "ADBase"], 62 | Properties(name="Domain Admins", samaccountname="Domain Admins", 63 | description="Domain administrators")), 64 | 65 | # Computers 66 | "workstation1": Node("S-1-5-21-1234567890-1234567890-1234567890-2001", 67 | ["Computer", "ADBase"], 68 | Properties(name="WORKSTATION-01", samaccountname="WORKSTATION-01$", 69 | operatingsystem="Windows 10 Pro")), 70 | 71 | "workstation2": Node("S-1-5-21-1234567890-1234567890-1234567890-2002", 72 | ["Computer", "ADBase"], 73 | Properties(name="WORKSTATION-02", samaccountname="WORKSTATION-02$", 74 | operatingsystem="Windows 10 Pro")), 75 | 76 | "server1": Node("S-1-5-21-1234567890-1234567890-1234567890-3001", 77 | ["Computer", "ADBase"], 78 | Properties(name="SERVER-01", samaccountname="SERVER-01$", 79 | operatingsystem="Windows Server 2019")), 80 | 81 | # Domain 82 | "domain": Node("S-1-5-21-1234567890-1234567890-1234567890", 83 | ["Domain", "ADBase"], 84 | Properties(name="CONTOSO.LOCAL", samaccountname="CONTOSO", 85 | description="Contoso Corporation Domain")) 86 | } 87 | 88 | # Add all nodes to the graph 89 | for node in nodes.values(): 90 | graph.add_node(node) 91 | 92 | # Create edges following BloodHound best practices 93 | # Edge direction represents the direction of access/attack 94 | 95 | edges = [ 96 | # User memberships (User -> Group) 97 | Edge(nodes["john_doe"].id, nodes["domain_users"].id, "MemberOf"), 98 | Edge(nodes["jane_smith"].id, nodes["domain_users"].id, "MemberOf"), 99 | Edge(nodes["admin_user"].id, nodes["domain_users"].id, "MemberOf"), 100 | Edge(nodes["jane_smith"].id, nodes["helpdesk_group"].id, "MemberOf"), 101 | Edge(nodes["admin_user"].id, nodes["admin_group"].id, "MemberOf"), 102 | 103 | # Group memberships (Group -> Group) 104 | Edge(nodes["helpdesk_group"].id, nodes["domain_users"].id, "MemberOf"), 105 | 106 | # Computer access (User -> Computer) 107 | Edge(nodes["john_doe"].id, nodes["workstation1"].id, "CanLogon"), 108 | Edge(nodes["jane_smith"].id, nodes["workstation2"].id, "CanLogon"), 109 | Edge(nodes["admin_user"].id, nodes["workstation1"].id, "CanLogon"), 110 | Edge(nodes["admin_user"].id, nodes["workstation2"].id, "CanLogon"), 111 | Edge(nodes["admin_user"].id, nodes["server1"].id, "CanLogon"), 112 | 113 | # Admin access (User/Group -> Computer) 114 | Edge(nodes["admin_user"].id, nodes["workstation1"].id, "AdminTo"), 115 | Edge(nodes["admin_user"].id, nodes["workstation2"].id, "AdminTo"), 116 | Edge(nodes["admin_user"].id, nodes["server1"].id, "AdminTo"), 117 | Edge(nodes["helpdesk_group"].id, nodes["workstation1"].id, "AdminTo"), 118 | Edge(nodes["helpdesk_group"].id, nodes["workstation2"].id, "AdminTo"), 119 | 120 | # Domain relationships 121 | Edge(nodes["admin_group"].id, nodes["domain"].id, "GenericAll"), 122 | Edge(nodes["helpdesk_group"].id, nodes["domain"].id, "GenericRead"), 123 | 124 | # Computer relationships 125 | Edge(nodes["workstation1"].id, nodes["domain"].id, "MemberOf"), 126 | Edge(nodes["workstation2"].id, nodes["domain"].id, "MemberOf"), 127 | Edge(nodes["server1"].id, nodes["domain"].id, "MemberOf") 128 | ] 129 | 130 | # Add all edges to the graph 131 | for edge in edges: 132 | graph.add_edge(edge) 133 | 134 | return graph 135 | 136 | def demonstrate_attack_paths(graph): 137 | """ 138 | Demonstrate various attack paths in the graph. 139 | """ 140 | print("\n" + "="*60) 141 | print("ATTACK PATH ANALYSIS") 142 | print("="*60) 143 | 144 | # Example 1: Can John Doe become a domain admin? 145 | print("\n1. Attack Path: John Doe -> Domain Admin") 146 | john_doe_id = None 147 | admin_group_id = None 148 | 149 | # Find the actual IDs 150 | for node_id, node in graph.nodes.items(): 151 | if node.get_property('name') == 'John Doe': 152 | john_doe_id = node_id 153 | elif node.get_property('name') == 'Domain Admins': 154 | admin_group_id = node_id 155 | 156 | if john_doe_id and admin_group_id: 157 | paths = graph.find_paths(john_doe_id, admin_group_id, max_depth=5) 158 | if paths: 159 | print(f" Found {len(paths)} possible paths:") 160 | for i, path in enumerate(paths[:3]): # Show first 3 paths 161 | path_names = [] 162 | for nid in path: 163 | node = graph.get_node_by_id(nid) 164 | if node: 165 | path_names.append(node.get_property('name', nid)) 166 | else: 167 | path_names.append(nid) 168 | print(f" Path {i+1}: {' -> '.join(path_names)}") 169 | else: 170 | print(" No direct path found (Good security posture!)") 171 | else: 172 | print(" Could not find required nodes") 173 | 174 | # Example 2: Can Jane Smith access Server-01? 175 | print("\n2. Attack Path: Jane Smith -> Server-01") 176 | jane_smith_id = None 177 | server1_id = None 178 | 179 | # Find the actual IDs 180 | for node_id, node in graph.nodes.items(): 181 | if node.get_property('name') == 'Jane Smith': 182 | jane_smith_id = node_id 183 | elif node.get_property('name') == 'SERVER-01': 184 | server1_id = node_id 185 | 186 | if jane_smith_id and server1_id: 187 | paths = graph.find_paths(jane_smith_id, server1_id, max_depth=5) 188 | if paths: 189 | print(f" Found {len(paths)} possible paths:") 190 | for i, path in enumerate(paths[:3]): 191 | path_names = [] 192 | for nid in path: 193 | node = graph.get_node_by_id(nid) 194 | if node: 195 | path_names.append(node.get_property('name', nid)) 196 | else: 197 | path_names.append(nid) 198 | print(f" Path {i+1}: {' -> '.join(path_names)}") 199 | else: 200 | print(" No direct path found (Good access control!)") 201 | else: 202 | print(" Could not find required nodes") 203 | 204 | # Example 3: What can John Doe access through group memberships? 205 | print("\n3. John Doe's Group Memberships:") 206 | if john_doe_id: 207 | john_edges = graph.get_edges_from_node(john_doe_id) 208 | for edge in john_edges: 209 | if edge.kind == "MemberOf": 210 | target_node = graph.get_node_by_id(edge.end_node) 211 | if target_node: 212 | print(f" - Member of: {target_node.get_property('name', edge.end_node)}") 213 | else: 214 | print(f" - Member of: {edge.end_node}") 215 | 216 | # Example 4: Who has admin access to Workstation-01? 217 | print("\n4. Admin Access to Workstation-01:") 218 | workstation1_id = None 219 | for node_id, node in graph.nodes.items(): 220 | if node.get_property('name') == 'WORKSTATION-01': 221 | workstation1_id = node_id 222 | break 223 | 224 | if workstation1_id: 225 | workstation1_edges = graph.get_edges_to_node(workstation1_id) 226 | for edge in workstation1_edges: 227 | if edge.kind == "AdminTo": 228 | source_node = graph.get_node_by_id(edge.start_node) 229 | if source_node: 230 | print(f" - {source_node.get_property('name', edge.start_node)} (User)") 231 | else: 232 | print(f" - {edge.start_node} (User)") 233 | 234 | # Example 5: Find all paths from regular users to domain admin 235 | print("\n5. All Paths from Regular Users to Domain Admin:") 236 | regular_users = [] 237 | for node_id, node in graph.nodes.items(): 238 | if node.has_kind("User") and node.get_property('name') in ['John Doe', 'Jane Smith']: 239 | regular_users.append(node_id) 240 | 241 | for user_id in regular_users: 242 | user_node = graph.get_node_by_id(user_id) 243 | if user_node and admin_group_id: 244 | paths = graph.find_paths(user_id, admin_group_id, max_depth=6) 245 | if paths: 246 | user_name = user_node.get_property('name', user_id) 247 | print(f" {user_name}: {len(paths)} paths found") 248 | else: 249 | user_name = user_node.get_property('name', user_id) 250 | print(f" {user_name}: No path found (Secure!)") 251 | 252 | def demonstrate_graph_analysis(graph): 253 | """ 254 | Demonstrate graph analysis capabilities. 255 | """ 256 | print("\n" + "="*60) 257 | print("GRAPH ANALYSIS") 258 | print("="*60) 259 | 260 | # Connected components 261 | components = graph.get_connected_components() 262 | print(f"\nConnected Components: {len(components)}") 263 | for i, component in enumerate(components): 264 | print(f" Component {i+1}: {len(component)} nodes") 265 | if len(component) <= 5: # Show small components 266 | node_names = [graph.get_node_by_id(nid).get_property('name', nid) for nid in component] 267 | print(f" Nodes: {', '.join(node_names)}") 268 | 269 | # Node type distribution 270 | print(f"\nNode Type Distribution:") 271 | for kind in ["User", "Group", "Computer", "Domain"]: 272 | nodes = graph.get_nodes_by_kind(kind) 273 | print(f" {kind}: {len(nodes)}") 274 | 275 | # Edge type distribution 276 | print(f"\nEdge Type Distribution:") 277 | edge_types = {} 278 | for edge in graph.edges: 279 | edge_types[edge.kind] = edge_types.get(edge.kind, 0) + 1 280 | 281 | for edge_type, count in sorted(edge_types.items()): 282 | print(f" {edge_type}: {count}") 283 | 284 | # Graph validation 285 | print(f"\nGraph Validation:") 286 | is_valid, error_list = graph.validate_graph() 287 | 288 | if not is_valid: 289 | print(" Issues found:") 290 | for err in error_list: 291 | print(f" - {err}") 292 | else: 293 | print(" No validation issues found") 294 | 295 | def main(): 296 | """ 297 | Main function demonstrating advanced OpenGraph features. 298 | """ 299 | print("BloodHound OpenGraph Advanced Features Example") 300 | print("=" * 60) 301 | 302 | # Create the attack graph 303 | print("\nCreating attack graph example...") 304 | graph = create_attack_graph_example() 305 | 306 | print(f"Graph created successfully!") 307 | print(f" Nodes: {graph.get_node_count()}") 308 | print(f" Edges: {graph.get_edge_count()}") 309 | 310 | # Demonstrate attack paths 311 | demonstrate_attack_paths(graph) 312 | 313 | # Demonstrate graph analysis 314 | demonstrate_graph_analysis(graph) 315 | 316 | # Export the graph 317 | print(f"\n" + "="*60) 318 | print("EXPORTING GRAPH") 319 | print("="*60) 320 | 321 | graph.export_to_file("attack_graph_example.json") 322 | print("Graph exported to 'attack_graph_example.json'") 323 | 324 | # Show a sample of the JSON output 325 | print("\nSample JSON output (first 500 chars):") 326 | json_output = graph.export_json() 327 | print(json_output[:500] + "..." if len(json_output) > 500 else json_output) 328 | 329 | if __name__ == "__main__": 330 | main() 331 | -------------------------------------------------------------------------------- /bhopengraph/OpenGraph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : OpenGraph.py 4 | # Author : Remi Gascou (@podalirius_) 5 | # Date created : 12 Aug 2025 6 | 7 | import json 8 | from typing import Dict, List, Optional, Set 9 | 10 | from bhopengraph.Edge import Edge 11 | from bhopengraph.Node import Node 12 | 13 | 14 | class OpenGraph(object): 15 | """ 16 | OpenGraph class for managing a graph structure compatible with BloodHound OpenGraph. 17 | 18 | Follows BloodHound OpenGraph schema requirements and best practices. 19 | 20 | Sources: 21 | 22 | - https://bloodhound.specterops.io/opengraph/schema#opengraph 23 | 24 | - https://bloodhound.specterops.io/opengraph/schema#minimal-working-json 25 | 26 | - https://bloodhound.specterops.io/opengraph/best-practices 27 | """ 28 | 29 | def __init__(self, source_kind: str = None): 30 | """ 31 | Initialize an OpenGraph. 32 | 33 | Args: 34 | - source_kind (str): Optional source kind for all nodes in the graph 35 | """ 36 | self.nodes: Dict[str, Node] = {} 37 | self.edges: Dict[str, Edge] = {} 38 | 39 | self.source_kind = source_kind 40 | 41 | # Edges methods 42 | 43 | @staticmethod 44 | def _edge_key(edge: Edge) -> str: 45 | """ 46 | Generate a unique key for an edge based on start_node, end_node, and kind. 47 | 48 | Args: 49 | - edge (Edge): Edge to generate key for 50 | 51 | Returns: 52 | - str: Unique key for the edge 53 | """ 54 | return f"{edge.start_node}|{edge.end_node}|{edge.kind}" 55 | 56 | def add_edge(self, edge: Edge) -> bool: 57 | """ 58 | Add an edge to the graph if it doesn't already exist and if the start and end nodes exist. 59 | 60 | Args: 61 | - edge (Edge): Edge to add 62 | 63 | Returns: 64 | - bool: True if edge was added, False if start or end node doesn't exist 65 | """ 66 | if edge.start_node not in self.nodes: 67 | return False 68 | if edge.end_node not in self.nodes: 69 | return False 70 | 71 | edge_key = self._edge_key(edge) 72 | if edge_key in self.edges: 73 | return False 74 | 75 | self.edges[edge_key] = edge 76 | return True 77 | 78 | def add_edges(self, edges: List[Edge]) -> bool: 79 | """ 80 | Add a list of edges to the graph. 81 | 82 | Returns: 83 | - bool: True if all edges were added successfully, False if any failed 84 | """ 85 | success = True 86 | for edge in edges: 87 | if not self.add_edge(edge): 88 | success = False 89 | return success 90 | 91 | def add_edge_without_validation(self, edge: Edge) -> bool: 92 | """ 93 | Add an edge to the graph. If an edge with the same key already exists, it will be overwritten. 94 | 95 | Args: 96 | - edge (Edge): Edge to add 97 | 98 | Returns: 99 | - bool: True if edge was added, False if edge is invalid 100 | """ 101 | if not isinstance(edge, Edge): 102 | return False 103 | 104 | edge_key = self._edge_key(edge) 105 | self.edges[edge_key] = edge 106 | return True 107 | 108 | def add_edges_without_validation(self, edges: List[Edge]) -> bool: 109 | """ 110 | Add a list of edges to the graph without validation. 111 | 112 | Args: 113 | - edges (List[Edge]): List of edges to add 114 | 115 | Returns: 116 | - bool: True if edges were added successfully 117 | """ 118 | if not isinstance(edges, list): 119 | return False 120 | 121 | for edge in edges: 122 | self.add_edge_without_validation(edge) 123 | return True 124 | 125 | def get_edges_by_kind(self, kind: str) -> List[Edge]: 126 | """ 127 | Get all edges of a specific kind. 128 | 129 | Args: 130 | - kind (str): Kind/type to filter by 131 | 132 | Returns: 133 | - List[Edge]: List of edges with the specified kind 134 | """ 135 | return [edge for edge in self.edges.values() if edge.kind == kind] 136 | 137 | def get_edges_from_node(self, node_id: str) -> List[Edge]: 138 | """ 139 | Get all edges starting from a specific node. 140 | 141 | Args: 142 | - node_id (str): ID of the source node 143 | 144 | Returns: 145 | - List[Edge]: List of edges starting from the specified node 146 | """ 147 | return [edge for edge in self.edges.values() if edge.start_node == node_id] 148 | 149 | def get_edges_to_node(self, node_id: str) -> List[Edge]: 150 | """ 151 | Get all edges ending at a specific node. 152 | 153 | Args: 154 | - node_id (str): ID of the destination node 155 | 156 | Returns: 157 | - List[Edge]: List of edges ending at the specified node 158 | """ 159 | return [edge for edge in self.edges.values() if edge.end_node == node_id] 160 | 161 | def get_isolated_edges(self) -> List[Edge]: 162 | """ 163 | Get all edges that have no start or end node. 164 | These are edges that are not connected to any other nodes in the graph. 165 | 166 | Returns: 167 | - List[Edge]: List of edges with no start or end node 168 | """ 169 | return [ 170 | edge 171 | for edge in self.edges.values() 172 | if edge.start_node not in self.nodes or edge.end_node not in self.nodes 173 | ] 174 | 175 | def get_isolated_edges_count(self) -> int: 176 | """ 177 | Get the total number of Isolated edges in the graph. 178 | These are edges that are not connected to any other nodes in the graph. 179 | 180 | Returns: 181 | - int: Number of Isolated edges 182 | """ 183 | return len(self.get_isolated_edges()) 184 | 185 | def get_edge_count(self) -> int: 186 | """ 187 | Get the total number of edges in the graph. 188 | 189 | Returns: 190 | - int: Number of edges 191 | """ 192 | return len(self.edges) 193 | 194 | # Nodes methods 195 | 196 | def add_node(self, node: Node) -> bool: 197 | """ 198 | Add a node to the graph. 199 | 200 | Args: 201 | - node (Node): Node to add 202 | 203 | Returns: 204 | - bool: True if node was added, False if node with same ID already exists 205 | """ 206 | if node.id in self.nodes: 207 | return False 208 | 209 | # Add source_kind to node kinds if specified 210 | if self.source_kind and self.source_kind not in node.kinds: 211 | node.add_kind(self.source_kind) 212 | 213 | self.nodes[node.id] = node 214 | return True 215 | 216 | def add_nodes(self, nodes: List[Node]) -> bool: 217 | """ 218 | Add a list of nodes to the graph. 219 | """ 220 | for node in nodes: 221 | self.add_node(node) 222 | return True 223 | 224 | def add_node_without_validation(self, node: Node) -> bool: 225 | """ 226 | Add a node to the graph without validation. 227 | 228 | Args: 229 | - node (Node): Node to add 230 | 231 | Returns: 232 | - bool: True if node was added, False if node is invalid 233 | """ 234 | if not isinstance(node, Node): 235 | return False 236 | 237 | self.nodes[node.id] = node 238 | return True 239 | 240 | def add_nodes_without_validation(self, nodes: List[Node]) -> bool: 241 | """ 242 | Add a list of nodes to the graph without validation. 243 | 244 | Args: 245 | - nodes (List[Node]): List of nodes to add 246 | 247 | Returns: 248 | - bool: True if nodes were added successfully 249 | """ 250 | if not isinstance(nodes, list): 251 | return False 252 | 253 | for node in nodes: 254 | self.add_node_without_validation(node) 255 | return True 256 | 257 | def get_node_by_id(self, id: str) -> Optional[Node]: 258 | """ 259 | Get a node by ID. 260 | 261 | Args: 262 | - id (str): ID of the node to retrieve 263 | 264 | Returns: 265 | - Node: The node if found, None otherwise 266 | """ 267 | return self.nodes.get(id) 268 | 269 | def get_nodes_by_kind(self, kind: str) -> List[Node]: 270 | """ 271 | Get all nodes of a specific kind. 272 | 273 | Args: 274 | - kind (str): Kind/type to filter by 275 | 276 | Returns: 277 | - List[Node]: List of nodes with the specified kind 278 | """ 279 | return [node for node in self.nodes.values() if node.has_kind(kind)] 280 | 281 | def get_node_count(self) -> int: 282 | """ 283 | Get the total number of nodes in the graph. 284 | 285 | Returns: 286 | - int: Number of nodes 287 | """ 288 | return len(self.nodes.keys()) 289 | 290 | def get_isolated_nodes(self) -> List[Node]: 291 | """ 292 | Get all nodes that have no edges. 293 | These are nodes that are not connected to any other nodes in the graph. 294 | 295 | Returns: 296 | - List[Node]: List of nodes with no edges 297 | """ 298 | return [ 299 | node 300 | for node in self.nodes.values() 301 | if not self.get_edges_from_node(node.id) 302 | and not self.get_edges_to_node(node.id) 303 | ] 304 | 305 | def get_isolated_nodes_count(self) -> int: 306 | """ 307 | Get the total number of Isolated nodes in the graph. 308 | These are nodes that are not connected to any other nodes in the graph. 309 | 310 | Returns: 311 | - int: Number of Isolated nodes 312 | """ 313 | return len(self.get_isolated_nodes()) 314 | 315 | def remove_node_by_id(self, id: str) -> bool: 316 | """ 317 | Remove a node and all its associated edges from the graph. 318 | 319 | Args: 320 | - id (str): ID of the node to remove 321 | 322 | Returns: 323 | - bool: True if node was removed, False if node doesn't exist 324 | """ 325 | if id not in self.nodes: 326 | return False 327 | 328 | # Remove the node 329 | del self.nodes[id] 330 | 331 | # Remove all edges that reference this node 332 | edges_to_remove = [ 333 | key 334 | for key, edge in self.edges.items() 335 | if edge.start_node == id or edge.end_node == id 336 | ] 337 | for key in edges_to_remove: 338 | del self.edges[key] 339 | 340 | return True 341 | 342 | # Paths methods 343 | 344 | def find_paths( 345 | self, start_id: str, end_id: str, max_depth: int = 10 346 | ) -> List[List[str]]: 347 | """ 348 | Find all paths between two nodes using BFS. 349 | 350 | Args: 351 | - start_id (str): Starting node ID 352 | - end_id (str): Target node ID 353 | - max_depth (int): Maximum path length to search 354 | 355 | Returns: 356 | - List[List[str]]: List of paths, where each path is a list of node IDs 357 | """ 358 | if start_id not in self.nodes or end_id not in self.nodes: 359 | return [] 360 | 361 | if start_id == end_id: 362 | return [[start_id]] 363 | 364 | paths = [] 365 | queue = [(start_id, [start_id])] 366 | 367 | while queue and len(queue[0][1]) <= max_depth: 368 | current_id, path = queue.pop(0) 369 | current_depth = len(path) 370 | 371 | # Only explore if we haven't reached max depth 372 | if current_depth >= max_depth: 373 | continue 374 | 375 | for edge in self.get_edges_from_node(current_id): 376 | next_id = edge.end_node 377 | # Check if next_id is not already in the current path (prevents cycles) 378 | if next_id not in path: 379 | new_path = path + [next_id] 380 | if next_id == end_id: 381 | paths.append(new_path) 382 | else: 383 | queue.append((next_id, new_path)) 384 | 385 | return paths 386 | 387 | def get_connected_components(self) -> List[Set[str]]: 388 | """ 389 | Find all connected components in the graph. 390 | 391 | Returns: 392 | - List[Set[str]]: List of connected component sets 393 | """ 394 | visited = set() 395 | components = [] 396 | 397 | for node_id in self.nodes: 398 | if node_id not in visited: 399 | component = set() 400 | stack = [node_id] 401 | 402 | while stack: 403 | current = stack.pop() 404 | if current not in visited: 405 | visited.add(current) 406 | component.add(current) 407 | 408 | # Add all adjacent nodes 409 | for edge in self.get_edges_from_node(current): 410 | if edge.end_node not in visited: 411 | stack.append(edge.end_node) 412 | for edge in self.get_edges_to_node(current): 413 | if edge.start_node not in visited: 414 | stack.append(edge.start_node) 415 | 416 | components.append(component) 417 | 418 | return components 419 | 420 | def validate_graph(self) -> tuple[bool, list[str]]: 421 | """ 422 | Validate the graph for common issues including node and edge validation. 423 | 424 | Validates: 425 | - All nodes using their individual validate() methods 426 | - All edges using their individual validate() methods 427 | - Graph structure issues (isolated nodes/edges) 428 | 429 | Returns: 430 | - tuple[bool, list[str]]: (is_valid, list_of_errors) 431 | """ 432 | errors = [] 433 | 434 | # Validate all nodes 435 | for node_id, node in self.nodes.items(): 436 | is_node_valid, node_errors = node.validate() 437 | if not is_node_valid: 438 | for error in node_errors: 439 | errors.append(f"Node '{node_id}': {error}") 440 | 441 | # Validate all edges 442 | for edge_key, edge in self.edges.items(): 443 | is_edge_valid, edge_errors = edge.validate() 444 | if not is_edge_valid: 445 | for error in edge_errors: 446 | errors.append( 447 | f"Edge {edge_key} ({edge.start_node}->{edge.end_node}): {error}" 448 | ) 449 | 450 | # Check for graph structure issues 451 | # Pre-compute edge mappings for O(1) lookups 452 | start_node_edges = {} 453 | end_node_edges = {} 454 | 455 | # Build edge mappings and check for isolated edges 456 | for edge_key, edge in self.edges.items(): 457 | # Check for isolated edges (edges referencing non-existent nodes) 458 | if edge.start_node not in self.nodes: 459 | errors.append( 460 | f"Edge {edge_key} ({edge.start_node}->{edge.end_node}): Start node '{edge.start_node}' does not exist" 461 | ) 462 | else: 463 | # Build start node mapping 464 | if edge.start_node not in start_node_edges: 465 | start_node_edges[edge.start_node] = [] 466 | start_node_edges[edge.start_node].append(edge) 467 | 468 | if edge.end_node not in self.nodes: 469 | errors.append( 470 | f"Edge {edge_key} ({edge.start_node}->{edge.end_node}): End node '{edge.end_node}' does not exist" 471 | ) 472 | else: 473 | # Build end node mapping 474 | if edge.end_node not in end_node_edges: 475 | end_node_edges[edge.end_node] = [] 476 | end_node_edges[edge.end_node].append(edge) 477 | 478 | # Check for isolated nodes using pre-computed mappings 479 | for node_id in self.nodes: 480 | # O(1) lookup instead of O(m) scan 481 | has_outgoing = node_id in start_node_edges 482 | has_incoming = node_id in end_node_edges 483 | 484 | if not has_outgoing and not has_incoming: 485 | errors.append( 486 | f"Node '{node_id}' is isolated (no incoming or outgoing edges)" 487 | ) 488 | 489 | return len(errors) == 0, errors 490 | 491 | # Export methods 492 | 493 | def export_json( 494 | self, include_metadata: bool = True, indent: None | int = None 495 | ) -> str: 496 | """ 497 | Export the graph to JSON format compatible with BloodHound OpenGraph. 498 | 499 | Args: 500 | - include_metadata (bool): Whether to include metadata in the export 501 | 502 | Returns: 503 | - str: JSON string representation of the graph 504 | """ 505 | graph_data = { 506 | "graph": { 507 | "nodes": [node.to_dict() for node in self.nodes.values()], 508 | "edges": [edge.to_dict() for edge in self.edges.values()], 509 | } 510 | } 511 | 512 | if include_metadata and self.source_kind: 513 | graph_data["metadata"] = {"source_kind": self.source_kind} 514 | 515 | return json.dumps(graph_data, indent=indent) 516 | 517 | def export_to_file( 518 | self, filename: str, include_metadata: bool = True, indent: None | int = None 519 | ) -> bool: 520 | """ 521 | Export the graph to a JSON file. 522 | 523 | Args: 524 | - filename (str): Name of the file to write 525 | - include_metadata (bool): Whether to include metadata in the export 526 | 527 | Returns: 528 | - bool: True if export was successful, False otherwise 529 | """ 530 | try: 531 | json_data = self.export_json(include_metadata, indent) 532 | with open(filename, "w") as f: 533 | f.write(json_data) 534 | return True 535 | except (IOError, OSError, TypeError): 536 | return False 537 | 538 | def export_to_dict(self) -> Dict: 539 | """ 540 | Export the graph to a dictionary. 541 | """ 542 | 543 | return { 544 | "graph": { 545 | "nodes": [node.to_dict() for node in self.nodes.values()], 546 | "edges": [edge.to_dict() for edge in self.edges.values()], 547 | }, 548 | "metadata": { 549 | "source_kind": (self.source_kind if self.source_kind else None) 550 | }, 551 | } 552 | 553 | # Import methods 554 | 555 | def import_from_json(self, json_data: str) -> bool: 556 | """ 557 | Load graph data from a JSON string. 558 | """ 559 | return self.import_from_dict(json.loads(json_data)) 560 | 561 | def import_from_file(self, filename: str) -> bool: 562 | """ 563 | Load graph data from a JSON file. 564 | 565 | Args: 566 | - filename (str): Name of the file to read 567 | 568 | Returns: 569 | - bool: True if load was successful, False otherwise 570 | """ 571 | try: 572 | with open(filename, "r") as f: 573 | data = json.load(f) 574 | return self.import_from_dict(data) 575 | except (IOError, OSError, json.JSONDecodeError): 576 | return False 577 | 578 | def import_from_dict(self, data: Dict) -> bool: 579 | """ 580 | Load graph data from a dictionary (typically from JSON). 581 | 582 | Args: 583 | - data (Dict): Dictionary containing graph data 584 | 585 | Returns: 586 | - bool: True if load was successful, False otherwise 587 | """ 588 | try: 589 | if "graph" not in data: 590 | return False 591 | 592 | graph_data = data["graph"] 593 | 594 | # Load nodes 595 | if "nodes" in graph_data: 596 | for node_data in graph_data["nodes"]: 597 | node = Node.from_dict(node_data) 598 | if node: 599 | self.nodes[node.id] = node 600 | 601 | # Load edges 602 | if "edges" in graph_data: 603 | for edge_data in graph_data["edges"]: 604 | edge = Edge.from_dict(edge_data) 605 | if edge: 606 | edge_key = self._edge_key(edge) 607 | self.edges[edge_key] = edge 608 | 609 | # Load metadata 610 | if "metadata" in data and "source_kind" in data["metadata"]: 611 | self.source_kind = data["metadata"]["source_kind"] 612 | 613 | return True 614 | except (KeyError, TypeError, ValueError): 615 | return False 616 | 617 | # Other methods 618 | 619 | def clear(self) -> None: 620 | """ 621 | Clear all nodes and edges from the graph. 622 | """ 623 | self.nodes.clear() 624 | self.edges.clear() 625 | 626 | def __len__(self) -> int: 627 | """ 628 | Return the total number of nodes and edges. 629 | 630 | Returns: 631 | - int: Total number of nodes and edges 632 | """ 633 | return len(self.nodes) + len(self.edges) 634 | 635 | def __repr__(self) -> str: 636 | return f"OpenGraph(nodes={len(self.nodes)}, edges={len(self.edges)}, source_kind='{self.source_kind}')" 637 | -------------------------------------------------------------------------------- /bhopengraph/tests/test_Properties.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Test cases for the Properties class. 5 | """ 6 | 7 | import unittest 8 | 9 | from bhopengraph.Properties import Properties 10 | 11 | 12 | class TestProperties(unittest.TestCase): 13 | """Test cases for the Properties class.""" 14 | 15 | def setUp(self): 16 | """Set up test fixtures.""" 17 | self.props = Properties(name="Test", count=42, active=True) 18 | 19 | def test_init_with_kwargs(self): 20 | """Test Properties initialization with keyword arguments.""" 21 | props = Properties(name="Test", count=42) 22 | self.assertEqual(props.get_property("name"), "Test") 23 | self.assertEqual(props.get_property("count"), 42) 24 | 25 | def test_init_without_args(self): 26 | """Test Properties initialization without arguments.""" 27 | props = Properties() 28 | self.assertEqual(len(props), 0) 29 | 30 | def test_set_property_string(self): 31 | """Test setting a string property.""" 32 | self.props.set_property("description", "A test description") 33 | self.assertEqual(self.props.get_property("description"), "A test description") 34 | 35 | def test_set_property_int(self): 36 | """Test setting an integer property.""" 37 | self.props.set_property("age", 25) 38 | self.assertEqual(self.props.get_property("age"), 25) 39 | 40 | def test_set_property_float(self): 41 | """Test setting a float property.""" 42 | self.props.set_property("score", 95.5) 43 | self.assertEqual(self.props.get_property("score"), 95.5) 44 | 45 | def test_set_property_bool(self): 46 | """Test setting a boolean property.""" 47 | self.props.set_property("enabled", False) 48 | self.assertEqual(self.props.get_property("enabled"), False) 49 | 50 | def test_set_property_none(self): 51 | """Test setting a None property.""" 52 | self.props.set_property("optional", None) 53 | self.assertIsNone(self.props.get_property("optional")) 54 | 55 | def test_set_property_list(self): 56 | """Test setting a list property.""" 57 | self.props.set_property("tags", ["tag1", "tag2"]) 58 | self.assertEqual(self.props.get_property("tags"), ["tag1", "tag2"]) 59 | 60 | def test_set_property_invalid_type_raises_error(self): 61 | """Test that setting invalid property type raises ValueError.""" 62 | with self.assertRaises(ValueError): 63 | self.props.set_property("invalid", {"dict": "not allowed"}) 64 | 65 | def test_set_property_invalid_type_function_raises_error(self): 66 | """Test that setting function as property raises ValueError.""" 67 | with self.assertRaises(ValueError): 68 | self.props.set_property("invalid", lambda x: x) 69 | 70 | def test_get_property_existing(self): 71 | """Test getting an existing property.""" 72 | self.assertEqual(self.props.get_property("name"), "Test") 73 | 74 | def test_get_property_nonexistent_with_default(self): 75 | """Test getting a non-existent property with default value.""" 76 | value = self.props.get_property("nonexistent", "default") 77 | self.assertEqual(value, "default") 78 | 79 | def test_get_property_nonexistent_without_default(self): 80 | """Test getting a non-existent property without default.""" 81 | value = self.props.get_property("nonexistent") 82 | self.assertIsNone(value) 83 | 84 | def test_remove_property_existing(self): 85 | """Test removing an existing property.""" 86 | self.props.remove_property("name") 87 | self.assertIsNone(self.props.get_property("name")) 88 | 89 | def test_remove_property_nonexistent(self): 90 | """Test removing a non-existent property doesn't cause errors.""" 91 | initial_count = len(self.props) 92 | self.props.remove_property("nonexistent") 93 | self.assertEqual(len(self.props), initial_count) 94 | 95 | def test_has_property_true(self): 96 | """Test has_property returns True for existing property.""" 97 | self.assertTrue(self.props.has_property("name")) 98 | 99 | def test_has_property_false(self): 100 | """Test has_property returns False for non-existing property.""" 101 | self.assertFalse(self.props.has_property("nonexistent")) 102 | 103 | def test_get_all_properties(self): 104 | """Test getting all properties as a dictionary.""" 105 | all_props = self.props.get_all_properties() 106 | expected = {"name": "Test", "count": 42, "active": True} 107 | self.assertEqual(all_props, expected) 108 | 109 | def test_get_all_properties_creates_copy(self): 110 | """Test that get_all_properties creates a copy.""" 111 | all_props = self.props.get_all_properties() 112 | self.props.set_property("new", "value") 113 | self.assertNotIn("new", all_props) 114 | 115 | def test_clear(self): 116 | """Test clearing all properties.""" 117 | self.props.clear() 118 | self.assertEqual(len(self.props), 0) 119 | self.assertIsNone(self.props.get_property("name")) 120 | 121 | def test_len(self): 122 | """Test length of properties.""" 123 | self.assertEqual(len(self.props), 3) 124 | 125 | def test_contains_true(self): 126 | """Test contains operator returns True for existing property.""" 127 | self.assertIn("name", self.props) 128 | 129 | def test_contains_false(self): 130 | """Test contains operator returns False for non-existing property.""" 131 | self.assertNotIn("nonexistent", self.props) 132 | 133 | def test_getitem_existing(self): 134 | """Test getting item with bracket notation.""" 135 | self.assertEqual(self.props["name"], "Test") 136 | 137 | def test_getitem_nonexistent_raises_error(self): 138 | """Test that getting non-existent item raises KeyError.""" 139 | with self.assertRaises(KeyError): 140 | _ = self.props["nonexistent"] 141 | 142 | def test_setitem_valid(self): 143 | """Test setting item with bracket notation.""" 144 | self.props["new_property"] = "new_value" 145 | self.assertEqual(self.props.get_property("new_property"), "new_value") 146 | 147 | def test_setitem_invalid_type_raises_error(self): 148 | """Test that setting invalid type with bracket notation raises ValueError.""" 149 | with self.assertRaises(ValueError): 150 | self.props["invalid"] = {"dict": "not allowed"} 151 | 152 | def test_delitem_existing(self): 153 | """Test deleting item with bracket notation.""" 154 | del self.props["name"] 155 | self.assertNotIn("name", self.props) 156 | 157 | def test_delitem_nonexistent_no_error(self): 158 | """Test that deleting non-existent item doesn't raise KeyError.""" 159 | # The Properties class doesn't raise KeyError for non-existent keys 160 | # It just does nothing, which is the expected behavior 161 | initial_count = len(self.props) 162 | del self.props["nonexistent"] 163 | self.assertEqual(len(self.props), initial_count) 164 | 165 | def test_to_dict(self): 166 | """Test converting properties to dictionary.""" 167 | props_dict = self.props.to_dict() 168 | expected = {"name": "Test", "count": 42, "active": True} 169 | self.assertEqual(props_dict, expected) 170 | 171 | def test_to_dict_creates_copy(self): 172 | """Test that to_dict creates a copy.""" 173 | props_dict = self.props.to_dict() 174 | self.props.set_property("new", "value") 175 | self.assertNotIn("new", props_dict) 176 | 177 | def test_repr(self): 178 | """Test string representation of properties.""" 179 | repr_str = repr(self.props) 180 | self.assertIn("Properties", repr_str) 181 | self.assertIn("name", repr_str) 182 | self.assertIn("Test", repr_str) 183 | 184 | def test_is_valid_property_value_string(self): 185 | """Test that string values are valid.""" 186 | self.assertTrue(self.props.is_valid_property_value("test")) 187 | 188 | def test_is_valid_property_value_int(self): 189 | """Test that integer values are valid.""" 190 | self.assertTrue(self.props.is_valid_property_value(42)) 191 | 192 | def test_is_valid_property_value_float(self): 193 | """Test that float values are valid.""" 194 | self.assertTrue(self.props.is_valid_property_value(3.14)) 195 | 196 | def test_is_valid_property_value_bool(self): 197 | """Test that boolean values are valid.""" 198 | self.assertTrue(self.props.is_valid_property_value(True)) 199 | 200 | def test_is_valid_property_value_none(self): 201 | """Test that None values are valid.""" 202 | self.assertTrue(self.props.is_valid_property_value(None)) 203 | 204 | def test_is_valid_property_value_list(self): 205 | """Test that list values are valid.""" 206 | self.assertTrue(self.props.is_valid_property_value([1, 2, 3])) 207 | 208 | def test_is_valid_property_value_dict_false(self): 209 | """Test that dictionary values are invalid.""" 210 | self.assertFalse(self.props.is_valid_property_value({"key": "value"})) 211 | 212 | def test_is_valid_property_value_function_false(self): 213 | """Test that function values are invalid.""" 214 | self.assertFalse(self.props.is_valid_property_value(lambda x: x)) 215 | 216 | def test_set_property_comprehensive_types(self): 217 | """Test setting properties with various Python types.""" 218 | # Valid primitive types 219 | self.props.set_property("test", "string") 220 | self.assertEqual(self.props.get_property("test"), "string") 221 | 222 | self.props.set_property("test", 42) 223 | self.assertEqual(self.props.get_property("test"), 42) 224 | 225 | self.props.set_property("test", 3.14) 226 | self.assertEqual(self.props.get_property("test"), 3.14) 227 | 228 | self.props.set_property("test", True) 229 | self.assertEqual(self.props.get_property("test"), True) 230 | 231 | self.props.set_property("test", None) 232 | self.assertIsNone(self.props.get_property("test")) 233 | 234 | # Valid homogeneous arrays 235 | self.props.set_property("test", []) 236 | self.assertEqual(self.props.get_property("test"), []) 237 | 238 | self.props.set_property("test", ["a", "b", "c"]) 239 | self.assertEqual(self.props.get_property("test"), ["a", "b", "c"]) 240 | 241 | self.props.set_property("test", [1, 2, 3]) 242 | self.assertEqual(self.props.get_property("test"), [1, 2, 3]) 243 | 244 | self.props.set_property("test", [1.1, 2.2, 3.3]) 245 | self.assertEqual(self.props.get_property("test"), [1.1, 2.2, 3.3]) 246 | 247 | self.props.set_property("test", [True, False, True]) 248 | self.assertEqual(self.props.get_property("test"), [True, False, True]) 249 | 250 | def test_set_property_invalid_types_raise_error(self): 251 | """Test that setting invalid property types raises ValueError.""" 252 | invalid_types = [ 253 | {"dict": "not allowed"}, 254 | {"nested": {"dict": "not allowed"}}, 255 | set([1, 2, 3]), 256 | frozenset([1, 2, 3]), 257 | tuple([1, 2, 3]), 258 | range(10), 259 | lambda x: x, 260 | object(), 261 | type, 262 | int, 263 | str, 264 | list, 265 | dict, 266 | set, 267 | frozenset, 268 | tuple, 269 | range, 270 | lambda: None, 271 | complex(1, 2), 272 | bytes([1, 2, 3]), 273 | bytearray([1, 2, 3]), 274 | memoryview(bytes([1, 2, 3])), 275 | ] 276 | 277 | for invalid_type in invalid_types: 278 | with self.subTest(invalid_type=type(invalid_type).__name__): 279 | with self.assertRaises(ValueError): 280 | self.props.set_property("test", invalid_type) 281 | 282 | def test_set_property_invalid_arrays_raise_error(self): 283 | """Test that setting invalid arrays raises ValueError.""" 284 | invalid_arrays = [ 285 | [{"dict": "not allowed"}], 286 | [["nested", "list"]], 287 | [1, "mixed", "types"], 288 | [1, 2.0, "mixed"], 289 | [True, 1, "mixed"], 290 | [None, 1, "mixed"], 291 | [1, [2, 3]], # nested list 292 | [1, {"key": "value"}], # mixed with dict 293 | [lambda x: x], # function in list 294 | [object()], # object in list 295 | [set([1, 2])], # set in list 296 | [frozenset([1, 2])], # frozenset in list 297 | [tuple([1, 2])], # tuple in list 298 | [range(10)], # range in list 299 | [complex(1, 2)], # complex in list 300 | [bytes([1, 2, 3])], # bytes in list 301 | [bytearray([1, 2, 3])], # bytearray in list 302 | [memoryview(bytes([1, 2, 3]))], # memoryview in list 303 | ] 304 | 305 | for invalid_array in invalid_arrays: 306 | with self.subTest(invalid_array=invalid_array): 307 | with self.assertRaises(ValueError): 308 | self.props.set_property("test", invalid_array) 309 | 310 | def test_is_valid_property_value_comprehensive_types(self): 311 | """Test is_valid_property_value with comprehensive Python types.""" 312 | # Valid types 313 | valid_types = [ 314 | None, 315 | "string", 316 | "", 317 | "unicode_string_ñáéíóú", 318 | "string with spaces", 319 | "string\nwith\nnewlines", 320 | "string\twith\ttabs", 321 | "string with special chars !@#$%^&*()", 322 | 0, 323 | 1, 324 | -1, 325 | 42, 326 | -42, 327 | 999999999999999999999999999999, 328 | -999999999999999999999999999999, 329 | 0.0, 330 | 1.0, 331 | -1.0, 332 | 3.14159, 333 | -3.14159, 334 | 1e10, 335 | -1e10, 336 | 1e-10, 337 | -1e-10, 338 | float("inf"), 339 | float("-inf"), 340 | float("nan"), 341 | True, 342 | False, 343 | [], 344 | [1, 2, 3], 345 | [1.1, 2.2, 3.3], 346 | ["a", "b", "c"], 347 | [True, False, True], 348 | [1, 1, 1], # homogeneous ints 349 | [1.0, 1.0, 1.0], # homogeneous floats 350 | ["a", "a", "a"], # homogeneous strings 351 | [True, True, True], # homogeneous bools 352 | ] 353 | 354 | for valid_type in valid_types: 355 | with self.subTest(valid_type=repr(valid_type)): 356 | self.assertTrue( 357 | self.props.is_valid_property_value(valid_type), 358 | f"Expected {repr(valid_type)} to be valid", 359 | ) 360 | 361 | def test_is_valid_property_value_invalid_types(self): 362 | """Test is_valid_property_value with invalid Python types.""" 363 | invalid_types = [ 364 | {"dict": "not allowed"}, 365 | {"nested": {"dict": "not allowed"}}, 366 | {"empty": {}}, 367 | set([1, 2, 3]), 368 | frozenset([1, 2, 3]), 369 | tuple([1, 2, 3]), 370 | tuple(), 371 | range(10), 372 | range(0), 373 | lambda x: x, 374 | lambda: None, 375 | object(), 376 | type, 377 | int, 378 | str, 379 | list, 380 | dict, 381 | set, 382 | frozenset, 383 | tuple, 384 | range, 385 | complex(1, 2), 386 | complex(0, 0), 387 | complex(1.5, 2.5), 388 | bytes([1, 2, 3]), 389 | bytes(), 390 | bytearray([1, 2, 3]), 391 | bytearray(), 392 | memoryview(bytes([1, 2, 3])), 393 | memoryview(bytes()), 394 | slice(1, 10, 2), 395 | slice(None), 396 | slice(1, None), 397 | slice(None, 10), 398 | slice(None, None, 2), 399 | ] 400 | 401 | for invalid_type in invalid_types: 402 | with self.subTest(invalid_type=type(invalid_type).__name__): 403 | self.assertFalse( 404 | self.props.is_valid_property_value(invalid_type), 405 | f"Expected {type(invalid_type).__name__} to be invalid", 406 | ) 407 | 408 | def test_is_valid_property_value_invalid_arrays(self): 409 | """Test is_valid_property_value with invalid arrays.""" 410 | invalid_arrays = [ 411 | [{"dict": "not allowed"}], 412 | [["nested", "list"]], 413 | [1, "mixed", "types"], 414 | [1, 2.0, "mixed"], 415 | [True, 1, "mixed"], 416 | [None, 1, "mixed"], 417 | [1, [2, 3]], # nested list 418 | [1, {"key": "value"}], # mixed with dict 419 | [lambda x: x], # function in list 420 | [object()], # object in list 421 | [set([1, 2])], # set in list 422 | [frozenset([1, 2])], # frozenset in list 423 | [tuple([1, 2])], # tuple in list 424 | [range(10)], # range in list 425 | [complex(1, 2)], # complex in list 426 | [bytes([1, 2, 3])], # bytes in list 427 | [bytearray([1, 2, 3])], # bytearray in list 428 | [memoryview(bytes([1, 2, 3]))], # memoryview in list 429 | [slice(1, 10, 2)], # slice in list 430 | [1, 2, 3, [4, 5]], # mixed with nested list 431 | [1, 2, 3, {"key": "value"}], # mixed with dict 432 | [1, 2, 3, lambda x: x], # mixed with function 433 | [1, 2, 3, object()], # mixed with object 434 | [1, 2, 3, set([4, 5])], # mixed with set 435 | [1, 2, 3, frozenset([4, 5])], # mixed with frozenset 436 | [1, 2, 3, tuple([4, 5])], # mixed with tuple 437 | [1, 2, 3, range(10)], # mixed with range 438 | [1, 2, 3, complex(4, 5)], # mixed with complex 439 | [1, 2, 3, bytes([4, 5])], # mixed with bytes 440 | [1, 2, 3, bytearray([4, 5])], # mixed with bytearray 441 | [1, 2, 3, memoryview(bytes([4, 5]))], # mixed with memoryview 442 | [1, 2, 3, slice(4, 10, 2)], # mixed with slice 443 | ] 444 | 445 | for invalid_array in invalid_arrays: 446 | with self.subTest(invalid_array=invalid_array): 447 | self.assertFalse( 448 | self.props.is_valid_property_value(invalid_array), 449 | f"Expected {repr(invalid_array)} to be invalid", 450 | ) 451 | 452 | def test_set_property_edge_cases(self): 453 | """Test setting properties with edge cases.""" 454 | # Test with very long strings 455 | long_string = "a" * 10000 456 | self.props.set_property("test", long_string) 457 | self.assertEqual(self.props.get_property("test"), long_string) 458 | 459 | # Test with very large numbers 460 | large_int = 2**100 461 | self.props.set_property("test", large_int) 462 | self.assertEqual(self.props.get_property("test"), large_int) 463 | 464 | # Test with very small/large floats 465 | small_float = 1e-100 466 | self.props.set_property("test", small_float) 467 | self.assertEqual(self.props.get_property("test"), small_float) 468 | 469 | large_float = 1e100 470 | self.props.set_property("test", large_float) 471 | self.assertEqual(self.props.get_property("test"), large_float) 472 | 473 | # Test with special float values 474 | self.props.set_property("test", float("inf")) 475 | self.assertEqual(self.props.get_property("test"), float("inf")) 476 | 477 | self.props.set_property("test", float("-inf")) 478 | self.assertEqual(self.props.get_property("test"), float("-inf")) 479 | 480 | # Note: NaN comparison is tricky, so we test it separately 481 | self.props.set_property("test", float("nan")) 482 | self.assertTrue( 483 | self.props.get_property("test") != self.props.get_property("test") 484 | ) # NaN != NaN 485 | 486 | # Test with empty string 487 | self.props.set_property("test", "") 488 | self.assertEqual(self.props.get_property("test"), "") 489 | 490 | # Test with zero values 491 | self.props.set_property("test", 0) 492 | self.assertEqual(self.props.get_property("test"), 0) 493 | 494 | self.props.set_property("test", 0.0) 495 | self.assertEqual(self.props.get_property("test"), 0.0) 496 | 497 | # Test with single item arrays 498 | self.props.set_property("test", [1]) 499 | self.assertEqual(self.props.get_property("test"), [1]) 500 | 501 | self.props.set_property("test", ["single"]) 502 | self.assertEqual(self.props.get_property("test"), ["single"]) 503 | 504 | self.props.set_property("test", [True]) 505 | self.assertEqual(self.props.get_property("test"), [True]) 506 | 507 | def test_validate_comprehensive_properties(self): 508 | """Test validate method with comprehensive property sets.""" 509 | # Test with all valid properties 510 | valid_props = Properties( 511 | string_prop="test", 512 | int_prop=42, 513 | float_prop=3.14, 514 | bool_prop=True, 515 | none_prop=None, 516 | empty_list_prop=[], 517 | string_list_prop=["a", "b", "c"], 518 | int_list_prop=[1, 2, 3], 519 | float_list_prop=[1.1, 2.2, 3.3], 520 | bool_list_prop=[True, False, True], 521 | ) 522 | 523 | is_valid, errors = valid_props.validate() 524 | self.assertTrue( 525 | is_valid, 526 | f"Expected valid properties to pass validation, but got errors: {errors}", 527 | ) 528 | self.assertEqual(errors, []) 529 | 530 | def test_validate_invalid_properties(self): 531 | """Test validate method with invalid properties.""" 532 | # Create properties with invalid values by directly manipulating _properties 533 | invalid_props = Properties() 534 | invalid_props._properties = { 535 | "valid_string": "test", 536 | "invalid_dict": {"key": "value"}, 537 | "invalid_mixed_array": [1, "mixed", True], 538 | "invalid_nested_array": [[1, 2], [3, 4]], 539 | "invalid_function": lambda x: x, 540 | "invalid_set": set([1, 2, 3]), 541 | } 542 | 543 | is_valid, errors = invalid_props.validate() 544 | self.assertFalse(is_valid, "Expected invalid properties to fail validation") 545 | self.assertGreater(len(errors), 0, "Expected validation errors") 546 | 547 | # Check that specific errors are present 548 | error_messages = " ".join(errors) 549 | self.assertIn("invalid_dict", error_messages) 550 | self.assertIn("invalid_mixed_array", error_messages) 551 | self.assertIn("invalid_nested_array", error_messages) 552 | self.assertIn("invalid_function", error_messages) 553 | self.assertIn("invalid_set", error_messages) 554 | 555 | 556 | if __name__ == "__main__": 557 | unittest.main() 558 | --------------------------------------------------------------------------------