Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Config:
APPLICATION_ROOT = os.environ.get("APPLICATION_ROOT", "/")

GHOSTWRITER_URL = os.environ.get("GHOSTWRITER_URL", "")
GHOSTWRITER_VERIFY_SSL = os.environ.get("GHOSTWRITER_VERIFY_SSL", "true").lower() not in ("false", "0", "no")

VAULTWARDEN_URL = os.environ.get("VAULTWARDEN_URL", "")
VAULTWARDEN_ORG_ID = os.environ.get("VAULTWARDEN_ORG_ID", "")
Expand Down
14 changes: 8 additions & 6 deletions app/dashboard/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def _client() -> GhostwriterClient:
return GhostwriterClient(
base_url=current_app.config["GHOSTWRITER_URL"],
token=session["gw_token"],
verify_ssl=current_app.config["GHOSTWRITER_VERIFY_SSL"],
)


Expand Down Expand Up @@ -102,9 +103,10 @@ def view_report_pdf(report_id: int):
if not template:
return jsonify({"error": f"Template '{template_name}' not found."}), 400

# Capture the Ghostwriter URL + token while we're still in request context
gw_url = current_app.config["GHOSTWRITER_URL"]
gw_token = session["gw_token"]
# Capture config while we're still in request context (background thread has no app context)
gw_url = current_app.config["GHOSTWRITER_URL"]
gw_token = session["gw_token"]
gw_verify_ssl = current_app.config["GHOSTWRITER_VERIFY_SSL"]

_purge_old_jobs()

Expand All @@ -113,14 +115,14 @@ def view_report_pdf(report_id: int):

threading.Thread(
target=_run_view,
args=(job_id, report_id, template, gw_url, gw_token),
args=(job_id, report_id, template, gw_url, gw_token, gw_verify_ssl),
daemon=True,
).start()

return jsonify({"job_id": job_id}), 202


def _run_view(job_id: str, report_id: int, template, gw_url: str, gw_token: str) -> None:
def _run_view(job_id: str, report_id: int, template, gw_url: str, gw_token: str, gw_verify_ssl: bool = True) -> None:
job = _render_jobs[job_id]
q = job["q"]
t0 = time.monotonic()
Expand All @@ -132,7 +134,7 @@ def emit(event: str, data: dict) -> None:
# ── Stage 1: Generate report JSON ─────────────────────────
emit("stage", {"stage": "generate", "label": "Fetching report data…"})

client = GhostwriterClient(base_url=gw_url, token=gw_token)
client = GhostwriterClient(base_url=gw_url, token=gw_token, verify_ssl=gw_verify_ssl)
raw_b64 = client.generate_report(report_id)
decoded = base64.b64decode(raw_b64).decode("utf-8")
report_json = _json.loads(decoded)
Expand Down
13 changes: 10 additions & 3 deletions app/ghostwriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ class GhostwriterError(Exception):


class GhostwriterClient:
def __init__(self, base_url: str, token: str):
def __init__(self, base_url: str, token: str, verify_ssl: bool = True):
self._base_url = base_url.rstrip("/")
self._url = self._base_url + _GRAPHQL_PATH
self._verify_ssl = verify_ssl
self._headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
Expand All @@ -62,7 +63,8 @@ def _gql(self, query: str, variables: dict | None = None) -> dict:
payload["variables"] = variables
try:
resp = requests.post(
self._url, json=payload, headers=self._headers, timeout=30
self._url, json=payload, headers=self._headers, timeout=30,
verify=self._verify_ssl,
)
resp.raise_for_status()
except requests.RequestException as exc:
Expand Down Expand Up @@ -92,7 +94,12 @@ def fetch_evidence(self, path: str) -> bytes:
"""Fetch a binary evidence file. path is relative, e.g. 'evidence/2/foo.png'."""
url = f"{self._base_url}/media/{path.lstrip('/')}"
try:
resp = requests.get(url, headers=self._headers, timeout=(5, 30))
resp = requests.get(
url,
headers={"Authorization": self._headers["Authorization"]},
timeout=(5, 30),
verify=self._verify_ssl,
)
resp.raise_for_status()
return resp.content
except requests.RequestException as exc:
Expand Down
18 changes: 17 additions & 1 deletion app/reporting/evidence.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
"""Fetch and persist report evidence files from Ghostwriter."""
from __future__ import annotations

import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

from ..ghostwriter import GhostwriterClient, GhostwriterError

_EVIDENCE_DIR = Path(__file__).parent / "resources" / "assets" / "_evidence"

# When set, used as a fallback if HTTP fetch fails (e.g. for local Docker deployments
# where Ghostwriter's media volume is mounted directly into this container).
_MEDIA_PATH = Path(os.environ["GHOSTWRITER_MEDIA_PATH"]) if os.environ.get("GHOSTWRITER_MEDIA_PATH") else None


def local_path(evidence_path: str) -> Path:
"""Return the local filesystem path for a given evidence path string.
Expand Down Expand Up @@ -36,11 +41,22 @@ def collect_paths(obj: object) -> set[str]:
def _fetch_and_save(client: GhostwriterClient, path: str) -> tuple[str, bool]:
dest = local_path(path)
dest.parent.mkdir(parents=True, exist_ok=True)

# Try HTTP first (works for remote Ghostwriter instances with proper ALLOWED_HOSTS)
try:
dest.write_bytes(client.fetch_evidence(path))
return path, True
except GhostwriterError:
return path, False
pass

# Fall back to direct volume read (for local Docker deployments)
if _MEDIA_PATH is not None:
src = _MEDIA_PATH / path
if src.exists():
dest.write_bytes(src.read_bytes())
return path, True

return path, False


def sync_evidence(
Expand Down
21 changes: 21 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,37 @@ services:
container_name: ghostbadger
image: ghcr.io/valientetechnologies/ghostbadger:latest
ports:
# Optional: to avoid discrapency with local ghostwriter installation
# - "8000:80"
- "80:80"
environment:
- SECRET_KEY=${SECRET_KEY}
- GHOSTWRITER_URL=${GHOSTWRITER_URL}
- VAULTWARDEN_URL=${VAULTWARDEN_URL}
- VAULTWARDEN_ORG_ID=${VAULTWARDEN_ORG_ID}
- VAULTWARDEN_COLLECTION_ID=${VAULTWARDEN_COLLECTION_ID}
# Optional: disable SSL verification for self-signed Ghostwriter certs
# - GHOSTWRITER_VERIFY_SSL=false
# Optional: serve under a subpath, e.g. /ghostbadger
# - APPLICATION_ROOT=/ghostbadger

# ── Evidence images ──────────────────────────────────────────────────────
# Evidence is fetched via HTTP. If that fails, a volume fallback can be
# used for local Docker deployments (see below).
#
# Uncomment to enable the volume fallback, then also uncomment the
# volume mount and top-level volumes section at the bottom of this file.
# - GHOSTWRITER_MEDIA_PATH=/ghostwriter_media

volumes:
# Seeded from image defaults on first run — edit freely on the host.
- ./resources:/app/reporting/resources
# Optional volume fallback for evidence images (local Docker deployments).
# Uncomment together with GHOSTWRITER_MEDIA_PATH above.
# - ghostwriter_production_data:/ghostwriter_media:ro
restart: unless-stopped

# Uncomment together with the volume mount above.
# volumes:
# ghostwriter_production_data:
# external: true