Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ jobs:
run: |
.venv/bin/pytest tests/test_saved_searches_sync.py -v

- name: Run saved searches bundle test
run: |
.venv/bin/pytest tests/test_saved_searches_bundle.py -v

- name: Run Cypher syntax tests
run: |
.venv/bin/pytest tests/test_cypher_syntax.py -v

- name: Run extension validation tests
run: |
.venv/bin/pytest tests/test_extensions_format.py -v
Expand Down
4 changes: 2 additions & 2 deletions deployments/helm/openhound/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ collector:
name: jamf
extraSecretMounts: { }

# The BHE destination config. This is typically stored inside the same secrets.toml referenced by config.existingSecret, but non-sensitive values can be provided here for convenience.
# The BHE destination url is non-sensitive config; set it here, via environment variable, or in config.toml.
# Sensitive credentials (token_id and token_key) belong in secrets.toml referenced by config.existingSecret.
destination:
bloodhoundEnterprise:
url: https://test.bloodhoundenterprise.io
interval: "300"

```

Expand Down
3 changes: 2 additions & 1 deletion deployments/helm/values.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ collector:
name: jamf
extraSecretMounts: { }

# The BHE destination config. This is typically stored inside the same secrets.toml referenced by config.existingSecret, but non-sensitive values can be provided here for convenience.
# The BHE destination url is non-sensitive config; set it here, via environment variable, or in config.toml.
# Sensitive credentials (token_id and token_key) belong in secrets.toml referenced by config.existingSecret.
destination:
bloodhoundEnterprise:
url: https://test.bloodhoundenterprise.io
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ request_max_retry_delay = 900
# To switch to structured JSON instead, uncomment the line below (must be uppercase "JSON")
# log_format = "JSON"

# BHE destination URL (non-sensitive; tokens go in secrets.toml)
[destination.bloodhoundenterprise]
url = "bhe_url"

[extract]
workers = 8

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
[destination.bloodhoundenterprise]
token_id = "client_token_id"
token_key = "client_token_key"
url = "bhe_url"

# Example configuration for github secrets: https://bloodhound.specterops.io/openhound/collectors/github/collect-data#example-configuration
[sources.source.github.credentials]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
[destination.bloodhoundenterprise]
token_id = "client_token_id"
token_key = "client_token_key"
url = "bhe_url"

# Example configuration for jamf secrets: https://bloodhound.specterops.io/openhound/collectors/jamf/collect-data#example-configuration
[sources.source.jamf.credentials]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
[destination.bloodhoundenterprise]
token_id = "client_token_id"
token_key = "client_token_key"
url = "bhe_url"

# Example configuration for okta secrets: https://bloodhound.specterops.io/openhound/collectors/okta/collect-data#example-configuration
[sources.source.okta.credentials]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ services:

secrets:
# Copy the .dlt-example folder to ${HOME}/.dlt as a starting point for each secrets file.
# Each secrets file must also contain [destination.bloodhoundenterprise] with url, token_id, and token_key.
# Each secrets file must contain [destination.bloodhoundenterprise] with token_id and token_key.

# Jamf: username + password auth
secrets_jamf:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,5 @@ dev = [
"types-pyyaml>=6.0.12.20250915",
"types-requests>=2.33.0.20260408",
"httpx>=0.28.1",
"antlr4-python3-runtime>=4.13.2",
]
90 changes: 69 additions & 21 deletions src/openhound/cli/saved_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from typing import Annotated

import typer
from rich.console import Console

from openhound.core.models.saved_search import QueryBundle
from openhound.core.progress import Progress
from openhound.core.saved_searches import SavedSearches, Strategy

Expand All @@ -15,30 +17,35 @@ class Format(str, Enum):
yaml = "yaml"


class OutputFormat(str, Enum):
json = "json"
zip = "zip"


@saved_searches.command(help="Upload saved searches to BloodHound")
def upload(
path: Annotated[
Path,
typer.Argument(
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
resolve_path=True,
help="Directory where saved searches are located",
path: Annotated[
Path,
typer.Argument(
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
resolve_path=True,
help="Directory where saved searches are located",
),
],
file_format: Format = typer.Option(
default=Format.json,
help="File format of the saved searches (json or yaml)",
),
strategy: Strategy = typer.Option(
default=Strategy.skip,
help="Skip or overwrite saved search if the name already exists",
),
progress: Progress = typer.Option(
Progress.tqdm, help="Select progress tracker option"
),
],
file_format: Format = typer.Option(
default=Format.json,
help="File format of the saved searches (json or yaml)",
),
strategy: Strategy = typer.Option(
default=Strategy.skip,
help="Skip or overwrite saved search if the name already exists",
),
progress: Progress = typer.Option(
Progress.tqdm, help="Select progress tracker option"
),
):
search_files = (
path.rglob("**/*.json")
Expand All @@ -48,3 +55,44 @@ def upload(
pipeline = SavedSearches(progress=progress, strategy=strategy)
results = pipeline.run(list(search_files), file_format=file_format)
return results


@saved_searches.command(help="Create a single saved searches json/zip bundle")
def bundle(
path: Annotated[
Path,
typer.Argument(
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
resolve_path=True,
help="Directory where saved searches are located",
),
],
output_path: Annotated[typer.FileTextWrite, typer.Argument(
help="Output file path for the generated saved-search bundle (including filename)")],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Windows PermissionError from double-opening output file.

Using typer.FileTextWrite causes Typer to immediately open the file and hold a text-write lock. When building a ZIP bundle, _to_zip re-opens this exact same file by name using zipfile.ZipFile. On Windows, this dual exclusive lock collision triggers a PermissionError: [Errno 13] Permission denied, which contradicts the PR's goal (BHE-8837) of fixing transient access denied errors on Windows. Furthermore, it results in writing binary ZIP data into an actively bound text stream.

To fix this, change the CLI to resolve a Path without opening it, giving the model full control over the file's lifecycle and mode.

  • src/openhound/cli/saved_search.py#L73-L74: Change the argument type from typer.FileTextWrite to Path. Retain Typer's validation by using typer.Argument(file_okay=True, dir_okay=False, writable=True, ...).
  • src/openhound/cli/saved_search.py#L96-L98: Update the print statement to call .resolve() directly on output_path, as it will now be a Path object instead of a TextIOWrapper.
  • src/openhound/core/models/saved_search.py#L92-L113: Update save, _to_json, and _to_zip to accept a Path object. Open the file directly within _to_json in text mode, and pass the Path directly into zipfile.ZipFile.
🐛 Proposed fixes across both files

1. src/openhound/cli/saved_search.py:

-        output_path: Annotated[typer.FileTextWrite, typer.Argument(
-            help="Output file path for the generated saved-search bundle (including filename)")],
+        output_path: Annotated[
+            Path,
+            typer.Argument(
+                file_okay=True,
+                dir_okay=False,
+                writable=True,
+                help="Output file path for the generated saved-search bundle (including filename)",
+            ),
+        ],
-    console.print(
-        f"[bold magenta]Output path:[/bold magenta] [italic]{Path(output_path.name).resolve()}[/italic]"
-    )
+    console.print(
+        f"[bold magenta]Output path:[/bold magenta] [italic]{output_path.resolve()}[/italic]"
+    )

2. src/openhound/core/models/saved_search.py:

-    def _to_json(self, output_file: TextIOWrapper) -> None:
+    def _to_json(self, output_path: Path) -> None:
         all_objects = [query.model_dump() for query in self.queries]
-        output_file.write(json.dumps(all_objects, indent=2))
+        with open(output_path, "w", encoding="utf-8") as output_file:
+            json.dump(all_objects, output_file, indent=2)
 
-    def _to_zip(self, output_file: TextIOWrapper) -> None:
+    def _to_zip(self, output_path: Path) -> None:
         with zipfile.ZipFile(
-                file=output_file.name,
+                file=output_path,
                 mode="w",
                 compression=zipfile.ZIP_DEFLATED,
                 compresslevel=9,
         ) as archive:
             for query in self.queries:
                 archive.writestr(
                     zinfo_or_arcname=f"{query.name}.json",
                     data=query.model_dump_json().encode(),
                 )
 
-    def save(self, output_file: TextIOWrapper, output_format: OutputFormat = OutputFormat.json) -> None:
+    def save(self, output_path: Path, output_format: OutputFormat = OutputFormat.json) -> None:
         if output_format == OutputFormat.json:
-            self._to_json(output_file)
+            self._to_json(output_path)
         elif output_format == OutputFormat.zip:
-            self._to_zip(output_file)
+            self._to_zip(output_path)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
output_path: Annotated[typer.FileTextWrite, typer.Argument(
help="Output file path for the generated saved-search bundle (including filename)")],
output_path: Annotated[
Path,
typer.Argument(
file_okay=True,
dir_okay=False,
writable=True,
help="Output file path for the generated saved-search bundle (including filename)",
),
],
Suggested change
output_path: Annotated[typer.FileTextWrite, typer.Argument(
help="Output file path for the generated saved-search bundle (including filename)")],
console.print(
f"[bold magenta]Output path:[/bold magenta] [italic]{output_path.resolve()}[/italic]"
)
Suggested change
output_path: Annotated[typer.FileTextWrite, typer.Argument(
help="Output file path for the generated saved-search bundle (including filename)")],
def _to_json(self, output_path: Path) -> None:
all_objects = [query.model_dump() for query in self.queries]
with open(output_path, "w", encoding="utf-8") as output_file:
json.dump(all_objects, output_file, indent=2)
def _to_zip(self, output_path: Path) -> None:
with zipfile.ZipFile(
file=output_path,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
for query in self.queries:
archive.writestr(
zinfo_or_arcname=f"{query.name}.json",
data=query.model_dump_json().encode(),
)
def save(self, output_path: Path, output_format: OutputFormat = OutputFormat.json) -> None:
if output_format == OutputFormat.json:
self._to_json(output_path)
elif output_format == OutputFormat.zip:
self._to_zip(output_path)
📍 Affects 2 files
  • src/openhound/cli/saved_search.py#L73-L74 (this comment)
  • src/openhound/cli/saved_search.py#L96-L98
  • src/openhound/core/models/saved_search.py#L92-L113
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhound/cli/saved_search.py` around lines 73 - 74, Change
saved_search.py lines 73-74 to accept a Path via typer.Argument(file_okay=True,
dir_okay=False, writable=True, ...), then update lines 96-98 to call resolve()
directly on output_path. In saved_search.py lines 92-113, update save, _to_json,
and _to_zip to accept Path values; open the path in text mode within _to_json
and pass it directly to zipfile.ZipFile in _to_zip, avoiding any pre-opened file
handle.

file_format: Format = typer.Option(
default=Format.json,
help="File format of the saved searches (json or yaml)",
),
output_format: OutputFormat = typer.Option(
default=OutputFormat.json,
help="File format for the saved searches bundle (json or zip)",
)
):
search_files = list(
path.rglob("**/*.json")
if file_format == Format.json
else path.rglob("**/*.yaml")
)

bundle_object = QueryBundle.from_paths(search_files, file_format=file_format)
bundle_object.save(output_path, output_format=output_format)

console = Console()
console.print("[bold green]Saved-search bundle created[/bold green]")
console.print(f"[bold magenta]Saved searches:[/bold magenta] {len(bundle_object.queries)}")
console.print(
f"[bold magenta]Output path:[/bold magenta] [italic]{Path(output_path.name).resolve()}[/italic]"
)
77 changes: 32 additions & 45 deletions src/openhound/core/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ def __init__(
self.base_path = Path(base_path) if base_path else self.default_platform_path()
self.log_file_path: Path | None = None

# Share one rotating handler per file across loggers; opening the same file
# with multiple handlers breaks rotation on Windows (WinError 32).
self._file_handlers: dict[Path, "RotatingFileHandler"] = {}

self.handlers = {
LogMode.CLI: self.cli_handlers,
LogMode.CONTAINER: self.container_handlers,
Expand Down Expand Up @@ -374,33 +378,45 @@ def _file_formatter(self) -> logging.Formatter:
return OpenHoundJSONFormatter()
return OpenHoundTextFormatter()

def container_handlers(self, logger: logging.Logger, file_path: Path) -> None:
"""Set the logging handler/format when running in a container"""

formatter = self._file_formatter()

# Log to stdout for better compatibility with container-based logging systems.
# Output is human-readable text by default; set runtime.log_format = "JSON" for structured JSON.
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(formatter)
logger.addHandler(stdout_handler)

# But also log the same format to a file for persistence and debugging when needed
def _build_file_handler(self, file_path: Path) -> "RotatingFileHandler":
"""Create and configure a rotating file handler for the given path."""
rotating_file_handler = RotatingFileHandler(
file_path,
when=self.rotate_when,
interval=self.interval,
backupCount=self.backup_count,
max_bytes=self.max_bytes,
)
rotating_file_handler.setFormatter(formatter)
rotating_file_handler.setFormatter(self._file_formatter())
# This regular expression overrides the default extMatch to recognize both
# default time based rotation filenames and size based rotation filenames (which gets a seconds added as well)
rotating_file_handler.extMatch = re.compile(
r"(?<!\d)\d{4}-\d{2}-\d{2}_\d{2}(-\d{2}-\d{2})?(?!\d)", re.ASCII
)
return rotating_file_handler

logger.addHandler(rotating_file_handler)
def _get_file_handler(self, file_path: Path) -> "RotatingFileHandler":
"""Return a shared rotating file handler for the path (one open file per path)."""
key = Path(file_path).resolve()
handler = self._file_handlers.get(key)
if handler is None:
handler = self._build_file_handler(file_path)
self._file_handlers[key] = handler
return handler

def container_handlers(self, logger: logging.Logger, file_path: Path) -> None:
"""Set the logging handler/format when running in a container"""

formatter = self._file_formatter()

# Log to stdout for better compatibility with container-based logging systems.
# Output is human-readable text by default; set runtime.log_format = "JSON" for structured JSON.
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(formatter)
logger.addHandler(stdout_handler)

# But also log the same format to a file for persistence and debugging when needed
logger.addHandler(self._get_file_handler(file_path))

def cli_handlers(self, logger: logging.Logger, file_path: Path) -> None:
"""Set the logging handler/format when running as a standalone CLI tool"""
Expand All @@ -420,40 +436,11 @@ def cli_handlers(self, logger: logging.Logger, file_path: Path) -> None:
logger.addHandler(console_handler)

# But also save the logs to a file using the configured format (text by default)
file_formatter = self._file_formatter()
rotating_file_handler = RotatingFileHandler(
file_path,
when=self.rotate_when,
interval=self.interval,
backupCount=self.backup_count,
max_bytes=self.max_bytes,
)
rotating_file_handler.setFormatter(file_formatter)
# This regular expression overrides the default extMatch to recognize both
# default time based rotation filenames and size based rotation filenames (which gets a seconds added as well)
rotating_file_handler.extMatch = re.compile(
r"(?<!\d)\d{4}-\d{2}-\d{2}_\d{2}(-\d{2}-\d{2})?(?!\d)", re.ASCII
)

logger.addHandler(rotating_file_handler)
logger.addHandler(self._get_file_handler(file_path))

def service_handlers(self, logger: logging.Logger, file_path: Path) -> None:
"""Set the logging handler/format when running the OpenHound service"""
file_formatter = self._file_formatter()
rotating_file_handler = RotatingFileHandler(
file_path,
when=self.rotate_when,
interval=self.interval,
backupCount=self.backup_count,
max_bytes=self.max_bytes,
)
rotating_file_handler.setFormatter(file_formatter)
# This regular expression overrides the default extMatch to recognize both
# default time based rotation filenames and size based rotation filenames (which gets a seconds added as well)
rotating_file_handler.extMatch = re.compile(
r"(?<!\d)\d{4}-\d{2}-\d{2}_\d{2}(-\d{2}-\d{2})?(?!\d)", re.ASCII
)
logger.addHandler(rotating_file_handler)
logger.addHandler(self._get_file_handler(file_path))

@property
def runtime_mode(self) -> LogMode:
Expand Down
59 changes: 57 additions & 2 deletions src/openhound/core/models/saved_search.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import json
import zipfile
from enum import Enum
from io import TextIOWrapper
from pathlib import Path
from typing import Optional, Union

from pydantic import BaseModel, ConfigDict, field_validator
from yaml import safe_load


class Format(str, Enum):
json = "json"
yaml = "yaml"


class OutputFormat(str, Enum):
json = "json"
zip = "zip"


class SavedSearch(BaseModel):
model_config = ConfigDict(extra="forbid")
# Required for an extension
Expand All @@ -14,7 +27,7 @@ class SavedSearch(BaseModel):
query: str

@classmethod
def from_json(cls, file_path: Path) -> "SavedSearch":
def from_file(cls, file_path: Path) -> "SavedSearch":
with open(file_path, "r") as file_object:
json_object = json.loads(file_object.read())
return cls(**json_object)
Expand Down Expand Up @@ -52,7 +65,49 @@ def acknowledgementsis_list(cls, value: str | list[str]) -> list[str]:
return value if isinstance(value, list) else [value]

@classmethod
def from_yaml(cls, file_path: Path) -> "SavedSearchExtended":
def from_file(cls, file_path: Path) -> "SavedSearchExtended":
with open(file_path, "r") as file_object:
yaml_object = safe_load(file_object.read())
return cls(**yaml_object)


class QueryBundle:
def __init__(self, queries: list[SavedSearchExtended | SavedSearch], file_format: Format = Format.json) -> None:
self.queries = queries
self.file_format = file_format

@classmethod
def from_paths(cls, all_files: list[Path], file_format: Format = Format.json) -> "QueryBundle":
model_choices = {
'yaml': SavedSearchExtended,
'json': SavedSearch,
}

queries = [
model_choices[file_format].from_file(cypher_query) for cypher_query in
all_files
]
return cls(queries, file_format)

def _to_json(self, output_file: TextIOWrapper) -> None:
all_objects = [query.model_dump() for query in self.queries]
output_file.write(json.dumps(all_objects, indent=2))

def _to_zip(self, output_file: TextIOWrapper) -> None:
with zipfile.ZipFile(
file=output_file.name,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
for query in self.queries:
archive.writestr(
zinfo_or_arcname=f"{query.name}.json",
data=query.model_dump_json().encode(),
)

def save(self, output_file: TextIOWrapper, output_format: OutputFormat = OutputFormat.json) -> None:
if output_format == OutputFormat.json:
self._to_json(output_file)
elif output_format == OutputFormat.zip:
self._to_zip(output_file)
Loading
Loading