-
Notifications
You must be signed in to change notification settings - Fork 4
BED-7975 - Add Support Bundle upload functionality #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
be0c768
360ba79
81deb16
1d74d05
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ ui_catalog.db | |
| site/ | ||
| addons/ | ||
| collectors/ | ||
| .worktrees/ | ||
|
|
||
| notebooks | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,44 @@ | ||
| import base64 | ||
| import gzip | ||
| import hashlib | ||
| import json | ||
| import logging | ||
| import math | ||
| import socket | ||
| import time | ||
| from enum import Enum | ||
| from pathlib import Path | ||
| from typing import Callable, TypeVar | ||
|
|
||
| from openhound.core.clients.bloodhound import BloodHound | ||
| import openhound | ||
| import requests | ||
| from openhound.core.clients.bloodhound import BloodHound, BloodHoundHTTPError | ||
| from openhound.core.clients.models.jobs import ( | ||
| JobsAvailable, | ||
| JobsCurrent, | ||
| JobsEnd, | ||
| JobStart, | ||
| ArtifactUploadSession, | ||
| ManagementAvailable, | ||
| ManagementOperationResult, | ||
| ManagementOperationStatus, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class JobStatus(str, Enum): | ||
| COMPLETE = "complete" | ||
| FAILED = "failed" | ||
|
|
||
|
|
||
| SUPPORT_BUNDLE_PART_SIZE = 8 * 1024 * 1024 # 8 MiB | ||
| SUPPORT_BUNDLE_MAX_RETRIES = 3 | ||
| SUPPORT_BUNDLE_RETRY_DELAY_SECONDS = 2 | ||
|
|
||
| T = TypeVar("T") | ||
|
|
||
|
|
||
| class BloodHoundEnterprise(BloodHound): | ||
| @property | ||
| def jobs_available(self) -> JobsAvailable: | ||
|
|
@@ -54,6 +76,156 @@ def ingest(self, data: str) -> None: | |
| method="POST", path=path, body=compressed_data, extra_headers=headers | ||
| ) | ||
|
|
||
| @property | ||
| def management_available(self) -> ManagementAvailable: | ||
| response = self.request( | ||
| method="GET", path="/api/v2/clients/management/available" | ||
| ) | ||
| return ManagementAvailable.model_validate(response.json()) | ||
|
|
||
| def start_operation(self, operation_id: str) -> ManagementOperationResult: | ||
| response = self._retry_support_bundle_request( | ||
| "start management operation", | ||
| lambda: self.request( | ||
| method="POST", | ||
| path="/api/v2/clients/management/start", | ||
| body=json.dumps({"operation_id": operation_id}).encode(), | ||
| ), | ||
| ) | ||
| return ManagementOperationResult.model_validate(response.json()) | ||
|
|
||
| def end_operation( | ||
| self, operation_id: str, status: ManagementOperationStatus | ||
| ) -> ManagementOperationResult: | ||
| response = self._retry_support_bundle_request( | ||
| "end management operation", | ||
| lambda: self.request( | ||
| method="POST", | ||
| path="/api/v2/clients/management/end", | ||
| body=json.dumps( | ||
| {"operation_id": operation_id, "status": status} | ||
| ).encode(), | ||
| ), | ||
| ) | ||
| return ManagementOperationResult.model_validate(response.json()) | ||
|
|
||
| def create_artifact_upload( | ||
| self, operation_id: str, bundle_path: Path | ||
| ) -> ArtifactUploadSession: | ||
| total_size = bundle_path.stat().st_size | ||
|
|
||
| logger.info("Total size of the support bundle: %s", total_size) | ||
| if total_size <= 0: | ||
| raise ValueError("Support bundle must not be empty.") | ||
|
|
||
| part_size = SUPPORT_BUNDLE_PART_SIZE | ||
| checksum = self._file_checksum(bundle_path) | ||
| response = self._retry_support_bundle_request( | ||
| "create support bundle upload", | ||
| lambda: self.request( | ||
| method="POST", | ||
| path="/api/v2/clients/management/artifacts", | ||
| body=json.dumps( | ||
| { | ||
| "operation_id": operation_id, | ||
| "artifact_type": "support_bundle", | ||
| "total_size": total_size, | ||
| "part_size": part_size, | ||
| "part_count": math.ceil(total_size / part_size), | ||
| "content_type": "application/zip", | ||
| "checksum_algorithm": "sha256", | ||
| "checksum": checksum, | ||
| } | ||
| ).encode(), | ||
| ), | ||
| ) | ||
| return ArtifactUploadSession.model_validate(response.json()["data"]) | ||
|
|
||
| def upload_artifact_part( | ||
| self, artifact_id: str, part_number: int, content: bytes | ||
| ) -> None: | ||
| checksum = base64.b64encode(hashlib.sha256(content).digest()).decode("ascii") | ||
| self._retry_support_bundle_request( | ||
| f"upload support bundle part {part_number}", | ||
| lambda: self.request( | ||
| method="POST", | ||
| path=f"/api/v2/clients/management/artifacts/{artifact_id}/parts/{part_number}", | ||
| body=content, | ||
| extra_headers={ | ||
| "Content-Length": str(len(content)), | ||
| "Content-Type": "application/zip", | ||
| "Content-Digest": f"sha-256=:{checksum}:", | ||
| }, | ||
| ), | ||
| ) | ||
|
Comment on lines
+144
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Set a timeout for artifact-part uploads.
🤖 Prompt for AI Agents |
||
|
|
||
| def complete_artifact_upload(self, artifact_id: str, operation_id: str) -> None: | ||
| self._retry_support_bundle_request( | ||
| "complete support bundle upload", | ||
| lambda: self.request( | ||
| method="POST", | ||
| path=f"/api/v2/clients/management/artifacts/{artifact_id}/complete", | ||
| body=json.dumps({"operation_id": operation_id}).encode(), | ||
| ), | ||
| ) | ||
|
|
||
| def upload_support_bundle(self, operation_id: str, bundle_path: Path) -> None: | ||
| """Create an upload session, transfer every ZIP part, then complete it.""" | ||
| session = self.create_artifact_upload(operation_id, bundle_path) | ||
| with bundle_path.open("rb") as bundle: | ||
| for part_number in range(1, session.part_count + 1): | ||
| part = bundle.read(session.part_size) | ||
| if not part: | ||
| raise ValueError(f"Support bundle ended before part {part_number}.") | ||
| self.upload_artifact_part(session.artifact_id, part_number, part) | ||
| if bundle.read(1): | ||
| raise ValueError("Support bundle grew while it was being uploaded.") | ||
| self.complete_artifact_upload(session.artifact_id, operation_id) | ||
|
|
||
| @staticmethod | ||
| def _file_checksum(path: Path) -> str: | ||
| digest = hashlib.sha256() | ||
| with path.open("rb") as bundle: | ||
| for chunk in iter(lambda: bundle.read(1024 * 1024), b""): | ||
| digest.update(chunk) | ||
| return digest.hexdigest() | ||
|
|
||
| @staticmethod | ||
| def _is_transient_support_bundle_error(error: Exception) -> bool: | ||
| if isinstance(error, requests.RequestException): | ||
| return True | ||
| return isinstance(error, BloodHoundHTTPError) and error.code in { | ||
| 408, | ||
| 429, | ||
| 500, | ||
| 502, | ||
| 503, | ||
| 504, | ||
| } | ||
|
|
||
| def _retry_support_bundle_request( | ||
| self, description: str, request: Callable[[], T] | ||
| ) -> T: | ||
| for retry in range(SUPPORT_BUNDLE_MAX_RETRIES + 1): | ||
| try: | ||
| return request() | ||
| except Exception as error: | ||
| if not self._is_transient_support_bundle_error(error): | ||
| raise | ||
| if retry == SUPPORT_BUNDLE_MAX_RETRIES: | ||
| raise | ||
| logger.warning( | ||
| "%s failed transiently; retrying in %s seconds (%s/%s).", | ||
| description, | ||
| SUPPORT_BUNDLE_RETRY_DELAY_SECONDS, | ||
| retry + 1, | ||
| SUPPORT_BUNDLE_MAX_RETRIES, | ||
| exc_info=True, | ||
| ) | ||
| time.sleep(SUPPORT_BUNDLE_RETRY_DELAY_SECONDS) | ||
|
|
||
| raise AssertionError("Support bundle retry loop exited unexpectedly.") | ||
|
|
||
| def update_client_metadata(self) -> None: | ||
| path = "/api/v2/clients/update" | ||
| try: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: SpecterOps/OpenHound
Length of output: 204
🏁 Script executed:
Repository: SpecterOps/OpenHound
Length of output: 8010
Keep the Compose file and build context aligned with the documented workflow.
When users copy this file to
${HOME},build.context: ../..resolves to the filesystem root instead of the repository root. The scheduler build cannot use the repository Dockerfile. Update the README to run the file in place or move the build settings to a local override.🤖 Prompt for AI Agents