"""Riposte -> webhook: POST a normalised Riposte event envelope. Shares the transport helpers with the other webhook commands: each command is its own file in this catalog, so the block below is duplicated by design. """ import base64, hashlib, hmac, ipaddress, json, os, socket, ssl, sys, time import urllib.error, urllib.parse, urllib.request S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) RETRYABLE = {408, 425, 429, 500, 502, 503, 504} MAX_ECHO = 4000 def out(value): print(json.dumps(value)) def fail(message, **extra): payload = {"error": message} payload.update(extra) print(json.dumps(payload)) sys.exit(1) def flag(value, default=False): if value is None or value == "": return default if isinstance(value, bool): return value return str(value).strip().lower() in ("1", "true", "yes", "on") def num(value, default, lo, hi): try: n = float(value) except (TypeError, ValueError): return default return max(lo, min(hi, n)) def structure(value, what, allow_list=False): """A dict (or list), or the same encoded as a JSON string. Playbook blocks and the agent both reach these commands, and they do not agree on which one they pass, so accept either rather than making the caller guess. """ if value is None or value == "": return {} if isinstance(value, dict) or (allow_list and isinstance(value, list)): return value if isinstance(value, str): try: parsed = json.loads(value) except ValueError as err: raise ValueError("%s is not valid JSON: %s" % (what, err)) if isinstance(parsed, dict) or (allow_list and isinstance(parsed, list)): return parsed raise ValueError("%s must be a JSON object%s" % (what, " or array" if allow_list else "")) raise ValueError("%s must be an object%s" % (what, " or an array" if allow_list else "")) def guard(url): """Refuse anything that is not a public http(s) endpoint. The destination is operator-supplied and, on an instance that opened allow_url_override, agent-supplied — so the SSRF check belongs here. DNS is resolved once for the check and once again by urllib, which a hostile name can still exploit between the two: this raises the bar, it is not a boundary. The boundary is leaving allow_url_override off. """ parts = urllib.parse.urlsplit(url) if parts.scheme not in ("http", "https"): raise ValueError("url must start with http:// or https:// (got %r)" % (parts.scheme or url)) if not parts.hostname: raise ValueError("url has no host") if parts.scheme == "http" and not flag(S.get("allow_plaintext_http")): raise ValueError("plaintext http is refused: use https, or set allow_plaintext_http on the instance") if flag(S.get("allow_private_targets")): return url port = parts.port or (443 if parts.scheme == "https" else 80) try: infos = socket.getaddrinfo(parts.hostname, port, proto=socket.IPPROTO_TCP) except socket.gaierror as err: raise ValueError("cannot resolve %s: %s" % (parts.hostname, err)) for info in infos: addr = ipaddress.ip_address(info[4][0]) if not addr.is_global or addr.is_multicast: raise ValueError( "%s resolves to %s, which is not a public address; set allow_private_targets " "on the instance to post to a receiver on the internal network" % (parts.hostname, addr)) return url def target(): """The destination for this call: the instance URL, overridden or extended.""" url = str(S.get("url") or "").strip() override = str(I.get("url") or "").strip() if override: if not flag(S.get("allow_url_override")): raise ValueError("this instance pins its destination; set allow_url_override to pass a url per call") url = override if not url: raise ValueError("no destination: set url on the instance") extra = str(I.get("path") or "").strip() if extra: parts = urllib.parse.urlsplit(url) joined = parts.path.rstrip("/") + "/" + extra.lstrip("/") url = urllib.parse.urlunsplit((parts.scheme, parts.netloc, joined, parts.query, parts.fragment)) return guard(url) def redact(url): """scheme://host[:port] and nothing else. Slack, Teams and Discord webhook URLs carry their token in the path, and this string lands in playbook output and the incident timeline. """ parts = urllib.parse.urlsplit(url) host = parts.hostname or "" if parts.port: host += ":" + str(parts.port) return "%s://%s" % (parts.scheme, host) def headers(body, content_type): head = { "Content-Type": content_type, "Accept": "application/json, */*", "User-Agent": str(S.get("user_agent") or "Riposte-SOAR/1.0 (+webhook)"), } for source in (structure(S.get("default_headers"), "default_headers"), structure(I.get("headers"), "headers")): for key, value in source.items(): head[str(key)] = str(value) kind = str(S.get("auth_type") or "none").strip().lower() token = str(S.get("auth_token") or "") if kind == "bearer": if not token: raise ValueError("auth_type is bearer but auth_token is empty") head["Authorization"] = "Bearer " + token elif kind == "basic": raw = str(S.get("auth_username") or "") + ":" + token head["Authorization"] = "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii") elif kind == "header": if not token: raise ValueError("auth_type is header but auth_token is empty") head[str(S.get("auth_header_name") or "Authorization")] = token elif kind not in ("", "none"): raise ValueError("unknown auth_type %r — none | bearer | basic | header" % kind) secret = str(S.get("hmac_secret") or "") if secret: signed = body if str(S.get("hmac_scheme") or "body").strip().lower() == "timestamp_body": stamp = str(int(time.time())) head[str(S.get("hmac_timestamp_header") or "X-Riposte-Timestamp")] = stamp signed = stamp.encode("utf-8") + b"." + body digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() prefix = S.get("hmac_prefix") if prefix is None: prefix = "sha256=" head[str(S.get("hmac_header") or "X-Riposte-Signature")] = str(prefix) + digest return head def verb(default_method=None): chosen = str(default_method or I.get("method") or S.get("method") or "POST").strip().upper() if chosen not in ("POST", "PUT", "PATCH"): raise ValueError("method must be POST, PUT or PATCH (got %r)" % chosen) return chosen def decode(raw): if not raw: return "" text = raw.decode("utf-8", "replace").strip() return text[:MAX_ECHO] + ("…" if len(text) > MAX_ECHO else "") def backoff(response_headers, attempt): retry_after = None if response_headers is not None: try: retry_after = response_headers.get("Retry-After") except AttributeError: retry_after = None if retry_after: try: return max(0.0, min(float(str(retry_after).strip()), 15.0)) except ValueError: pass return min(2 ** (attempt - 1), 8) def deliver(body, content_type, method=None): """Send body once, retrying the failures that are worth retrying. A non-2xx that survives the retries fails the command: a webhook the receiver rejected is a failed notification, and the playbook block that called it should say so rather than report a delivery that did not happen. """ url = target() head = headers(body, content_type) method = verb(method) attempts = int(num(S.get("max_attempts"), 3, 1, 5)) timeout = num(S.get("timeout_seconds"), 20, 1, 40) context = ssl.create_default_context() if flag(S.get("verify_tls"), True) else ssl._create_unverified_context() started = time.time() for attempt in range(1, attempts + 1): request = urllib.request.Request(url, data=body, headers=head, method=method) try: with urllib.request.urlopen(request, timeout=timeout, context=context) as response: status = getattr(response, "status", None) or response.getcode() return result(status, response.read(), attempt, url, started) except urllib.error.HTTPError as err: try: raw = err.read() except Exception: raw = b"" if err.code in RETRYABLE and attempt < attempts: time.sleep(backoff(err.headers, attempt)) continue fail("the webhook target answered HTTP %d" % err.code, status=err.code, attempts=attempt, target=redact(url), detail=decode(raw)) except (urllib.error.URLError, socket.timeout, ssl.SSLError, OSError) as err: if attempt < attempts: time.sleep(min(2 ** (attempt - 1), 8)) continue fail("cannot reach the webhook target: %s" % err, attempts=attempt, target=redact(url)) def result(status, raw, attempt, url, started): text = decode(raw) payload = { "ok": True, "status": status, "attempts": attempt, "target": redact(url), "duration_ms": int((time.time() - started) * 1000), } if text: try: payload["response"] = json.loads(text) except ValueError: payload["response_text"] = text return payload def guarded(run): try: run() except ValueError as err: fail(str(err)) except SystemExit: raise except Exception as err: fail("%s: %s" % (type(err).__name__, err)) def run(): title = str(I.get("title") or "").strip() if not title: raise ValueError("title is required") event = str(I.get("event") or "incident.notified").strip() incident = {"title": title} for key, field in (("id", "incident_id"), ("severity", "severity"), ("status", "status"), ("description", "description"), ("url", "incident_url")): value = I.get(field) if value not in (None, ""): incident[key] = value tags = I.get("tags") if isinstance(tags, list): incident["tags"] = [str(tag).strip() for tag in tags if str(tag).strip()] elif isinstance(tags, str) and tags.strip(): incident["tags"] = [tag.strip() for tag in tags.split(",") if tag.strip()] envelope = { "source": "riposte", "event": event, "sent_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "incident": incident, } extra = structure(I.get("data"), "data") if extra: envelope["data"] = extra body = json.dumps(envelope).encode("utf-8") delivered = deliver(body, str(S.get("content_type") or "application/json")) delivered["event"] = event out(delivered) guarded(run)