From b42762d7f483b11f1d43e83cb09672231a4855fc Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 27 Mar 2026 21:12:04 -0500 Subject: [PATCH] fix: address code and security review findings from Phase 2A - Remove committed __pycache__ artifacts; add to .gitignore - Wrap config JSON parse in try/except to prevent CLI crash on malformed config - Add SSRF mitigation to webhook_adapter: reject non-http(s) schemes, refuse auth_token over cleartext to non-localhost, block private IPs - Add _sanitize() to discord_formatter: strip ANSI/control chars, neutralize @everyone/@here Discord mentions --- .gitignore | 2 + bin/mosaic-macp | 6 ++- .../__pycache__/__init__.cpython-312.pyc | Bin 506 -> 0 bytes .../discord_formatter.cpython-312.pyc | Bin 6321 -> 0 bytes .../__pycache__/event_watcher.cpython-312.pyc | Bin 8097 -> 0 bytes .../webhook_adapter.cpython-312.pyc | Bin 4313 -> 0 bytes .../events/discord_formatter.py | 19 +++++++-- .../events/webhook_adapter.py | 40 ++++++++++++++++++ 8 files changed, 63 insertions(+), 4 deletions(-) delete mode 100644 tools/orchestrator-matrix/events/__pycache__/__init__.cpython-312.pyc delete mode 100644 tools/orchestrator-matrix/events/__pycache__/discord_formatter.cpython-312.pyc delete mode 100644 tools/orchestrator-matrix/events/__pycache__/event_watcher.cpython-312.pyc delete mode 100644 tools/orchestrator-matrix/events/__pycache__/webhook_adapter.cpython-312.pyc diff --git a/.gitignore b/.gitignore index f82a4ae..65aad93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ rails +*.pyc +**/__pycache__/ diff --git a/bin/mosaic-macp b/bin/mosaic-macp index 43024fa..581e231 100755 --- a/bin/mosaic-macp +++ b/bin/mosaic-macp @@ -229,7 +229,11 @@ from webhook_adapter import create_webhook_callback config = {} if config_path.exists(): - config = json.loads(config_path.read_text(encoding="utf-8")) + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + print(f"[macp] Warning: could not parse config {config_path}: {e}", file=sys.stderr) + config = {} macp = dict(config.get("macp") or {}) watcher = EventWatcher( diff --git a/tools/orchestrator-matrix/events/__pycache__/__init__.cpython-312.pyc b/tools/orchestrator-matrix/events/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 58ea1d475b533b83efabb487ffa4c7cd546ec03b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 506 zcmYjNJx{|h6nsvaCM}eYi4A2#5ww*M5)23lAvQp4h%A;Hn>K1vSB^_58^3~qjg4Qz zz%L+DC&a*p=+=qnv=w+b-8tvGm-kkyRRPcS{%ib106vUZ9BWP{XGk7_LkOI3D

B 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 diff --git a/tools/orchestrator-matrix/events/__pycache__/event_watcher.cpython-312.pyc b/tools/orchestrator-matrix/events/__pycache__/event_watcher.cpython-312.pyc deleted file mode 100644 index 41e5be50b802b19c6346fec3340b78d56f64d098..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8097 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 Fe*vYO3W@*# diff --git a/tools/orchestrator-matrix/events/__pycache__/webhook_adapter.cpython-312.pyc b/tools/orchestrator-matrix/events/__pycache__/webhook_adapter.cpython-312.pyc deleted file mode 100644 index 8fb4516047f8905de04e8c7722eb262555500558..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4313 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 diff --git a/tools/orchestrator-matrix/events/discord_formatter.py b/tools/orchestrator-matrix/events/discord_formatter.py index f07fa8c..c596868 100644 --- a/tools/orchestrator-matrix/events/discord_formatter.py +++ b/tools/orchestrator-matrix/events/discord_formatter.py @@ -3,8 +3,21 @@ from __future__ import annotations +import re from typing import Any +# Strip control characters and ANSI escapes from untrusted event fields +_CTRL_RE = re.compile(r"[\x00-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]") +# Collapse Discord @-mentions / role pings to prevent deceptive pings +_MENTION_RE = re.compile(r"@(everyone|here|&?\d+)") + + +def _sanitize(value: str) -> str: + """Normalize untrusted text for safe rendering in Discord/terminal output.""" + value = _CTRL_RE.sub(" ", value) + value = _MENTION_RE.sub(r"@\u200b\1", value) # zero-width space breaks pings + return value.strip() + def _task_label(event: dict[str, Any]) -> str: task_id = str(event.get("task_id") or "unknown") @@ -15,10 +28,10 @@ def _title(event: dict[str, Any]) -> str: metadata = event.get("metadata") if isinstance(metadata, dict): for key in ("task_title", "title", "description"): - value = str(metadata.get(key) or "").strip() + value = _sanitize(str(metadata.get(key) or "")) if value: return value - message = str(event.get("message") or "").strip() + message = _sanitize(str(event.get("message") or "")) return message if message else "No details provided" @@ -81,7 +94,7 @@ def format_event(event: dict[str, Any]) -> str | None: attempt_suffix = _attempt_suffix(event) duration_suffix = _duration_suffix(event) runtime_dispatch = _runtime_dispatch_suffix(event) - message = str(event.get("message") or "").strip() + message = _sanitize(str(event.get("message") or "")) if event_type == "task.completed": return f"✅ **{task_label} completed** — {title}{_meta_clause(attempt_suffix, duration_suffix)}" diff --git a/tools/orchestrator-matrix/events/webhook_adapter.py b/tools/orchestrator-matrix/events/webhook_adapter.py index b3a042b..fb37843 100644 --- a/tools/orchestrator-matrix/events/webhook_adapter.py +++ b/tools/orchestrator-matrix/events/webhook_adapter.py @@ -23,6 +23,40 @@ def _webhook_config(config: dict[str, Any]) -> dict[str, Any]: return dict(config) +def _validate_webhook_url(url: str, auth_token: str) -> str | None: + """Validate webhook URL for SSRF and cleartext credential risks. + + Returns an error message if the URL is disallowed, or None if safe. + """ + import ipaddress + import urllib.parse as urlparse + + parsed = urlparse.urlparse(url) + scheme = parsed.scheme.lower() + + if scheme not in ("http", "https"): + return f"unsupported scheme '{scheme}' — must be http or https" + + if auth_token and scheme == "http": + host = parsed.hostname or "" + # Allow cleartext only for explicit loopback (dev use) + if host not in ("localhost", "127.0.0.1", "::1"): + return "refusing to send auth_token over non-HTTPS to non-localhost — use https://" + + host = parsed.hostname or "" + # Block RFC1918, loopback, link-local, and metadata IPs unless auth_token is absent + try: + ip = ipaddress.ip_address(host) + if ip.is_loopback or ip.is_private or ip.is_link_local: + # Allow localhost for development (no token risk since we already checked above) + if auth_token and not ip.is_loopback: + return f"refusing to send auth_token to private/internal IP {ip}" + except ValueError: + pass # hostname — DNS resolution not validated here (best-effort) + + return None + + def send_webhook(event: dict[str, Any], config: dict[str, Any]) -> bool: """POST event to webhook URL. Returns True on success.""" @@ -35,6 +69,12 @@ def send_webhook(event: dict[str, Any], config: dict[str, Any]) -> bool: timeout_seconds = max(1.0, float(webhook.get("timeout_seconds") or 10)) retry_count = max(0, int(webhook.get("retry_count") or 0)) auth_token = str(webhook.get("auth_token") or "").strip() + + url_err = _validate_webhook_url(url, auth_token) + if url_err: + _warn(f"webhook URL rejected: {url_err}") + return False + payload = json.dumps(event, ensure_ascii=True).encode("utf-8") headers = {"Content-Type": "application/json"} if auth_token: