From 26d6273f6049c65817949ba25045a115b0a0cb1d Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Fri, 18 Sep 2026 08:25:34 +0000 Subject: [PATCH] feat(webhook): outbound webhooks to any third-party solution (v1.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog could POST to Discord and to Teams, and to nothing else: every other receiver needed its own package. This is the generic one — the destination is instance config, the body is the command's. Four senders and a test: post_json a JSON document, the receiver defines the schema send_event a normalised Riposte envelope (id, title, severity, link) post_form x-www-form-urlencoded, for receivers older than JSON webhooks post_raw a body verbatim with the Content-Type you choose Three decisions worth naming: - The destination is pinned on the instance and a per-call `url` is refused unless allow_url_override is set. An integration that always took its URL from the command inputs would be an arbitrary HTTP client for anything that can call it, the agent included. The SSRF guard (public addresses only, https only) sits behind the same kind of explicit opt-in. - `url` is sensitive. Slack, Teams, Discord, Zapier and n8n all carry the token in the path, so command output echoes scheme://host and never the path or the query. - test_connection opens a TCP/TLS connection and sends nothing. The health checker runs it on a schedule on every enabled instance; a receiver should not have to filter a Riposte ping every few minutes to keep one green. test_sends_ping opts into the real POST. Optional HMAC-SHA256 over the body (or over `.`, the replay- resistant scheme Slack and Stripe use) lets the receiver prove the call came from Riposte. Retries cover 408/425/429/5xx and connection failures, honour Retry-After, and stay inside the sandbox's 60s ceiling. A non-2xx that survives them fails the command, so a playbook block never reports a delivery that did not happen. Verified against a local receiver: 52 checks over payload shapes, the three auth modes, both signature schemes, header precedence, retry and give-up, every guard, path/method handling, and both test modes. --- integrations/webhook/manifest.yaml | 279 ++++++++++++++++ integrations/webhook/scripts/post_form.py | 281 ++++++++++++++++ integrations/webhook/scripts/post_json.py | 274 +++++++++++++++ integrations/webhook/scripts/post_raw.py | 277 +++++++++++++++ integrations/webhook/scripts/send_event.py | 300 +++++++++++++++++ .../webhook/scripts/test_connection.py | 316 ++++++++++++++++++ 6 files changed, 1727 insertions(+) create mode 100644 integrations/webhook/manifest.yaml create mode 100644 integrations/webhook/scripts/post_form.py create mode 100644 integrations/webhook/scripts/post_json.py create mode 100644 integrations/webhook/scripts/post_raw.py create mode 100644 integrations/webhook/scripts/send_event.py create mode 100644 integrations/webhook/scripts/test_connection.py diff --git a/integrations/webhook/manifest.yaml b/integrations/webhook/manifest.yaml new file mode 100644 index 0000000..a4e0c7d --- /dev/null +++ b/integrations/webhook/manifest.yaml @@ -0,0 +1,279 @@ +id: webhook +name: Outbound Webhook +version: 1.0.0 +description: "Outbound webhook — POST from Riposte to any third-party solution: a JSON document, a normalised incident event, a form body, or a raw payload. Per-instance destination, static and per-call headers, bearer/basic/custom-header auth, HMAC-SHA256 body signature, retry on 429/5xx. Stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: post_json, send_event, post_form, post_raw, and a non-intrusive test_connection." +category: notification + +# --------------------------------------------------------------------------- +# The destination lives on the instance, not in the command inputs: one instance +# per receiving solution, named after it. A per-call `url` is refused unless the +# operator opens allow_url_override — the agent can then be handed this +# integration without also being handed an arbitrary HTTP client. +# +# `url` is sensitive because a webhook URL is a credential in practice: Slack, +# Teams, Discord, Zapier and n8n all carry the token in the path. Command output +# echoes scheme://host only, never the path or the query. +# --------------------------------------------------------------------------- +config_schema: + properties: + url: + type: string + description: "Destination URL, e.g. https://hooks.example.com/services/T000/B000/xxxx. Stored encrypted; commands echo back the host only." + x-soar-sensitive: true + method: + type: string + description: "Default HTTP verb — POST (default), PUT or PATCH." + default: POST + + auth_type: + type: string + description: "none (default) | bearer | basic | header." + default: none + auth_token: + type: string + description: "Bearer token, basic password, or the custom header value — depending on auth_type." + x-soar-sensitive: true + auth_username: + type: string + description: "Username, for auth_type=basic." + auth_header_name: + type: string + description: "Header carrying auth_token, for auth_type=header." + default: Authorization + + hmac_secret: + type: string + description: "Shared secret. When set, every request carries an HMAC-SHA256 signature of its body so the receiver can prove it came from Riposte." + x-soar-sensitive: true + hmac_header: + type: string + description: "Header carrying the signature." + default: X-Riposte-Signature + hmac_prefix: + type: string + description: "Prefix before the hex digest. Set to an empty string for a bare digest." + default: "sha256=" + hmac_scheme: + type: string + description: "body (default) signs the raw body. timestamp_body signs '.' and sends the timestamp header too — replay-resistant, the scheme Slack and Stripe use." + default: body + hmac_timestamp_header: + type: string + description: "Timestamp header, for hmac_scheme=timestamp_body." + default: X-Riposte-Timestamp + + default_headers: + type: object + description: "Static headers added to every request, e.g. {\"X-Tenant\": \"acme\"}. Per-call headers win over these." + user_agent: + type: string + description: "User-Agent sent to the receiver." + default: "Riposte-SOAR/1.0 (+webhook)" + content_type: + type: string + description: "Content-Type for JSON commands." + default: application/json + + timeout_seconds: + type: number + description: "Per-attempt timeout, 1-40s. The sandbox kills a command at 60s, so total budget stays under it." + default: 20 + max_attempts: + type: number + description: "Attempts, 1-5. Retries only on 408/425/429/5xx and on connection failures, honouring Retry-After." + default: 3 + verify_tls: + type: boolean + description: "Verify the receiver's TLS certificate. Turn off only for an internal host with a private CA." + default: true + + allow_url_override: + type: boolean + description: "Let a command supply its own url, ignoring the one above. Off by default — an on instance is an arbitrary HTTP client for anything that can call it." + default: false + allow_private_targets: + type: boolean + description: "Allow destinations resolving to private, loopback or link-local addresses. Off by default (SSRF guard); turn on to post to a receiver on the internal network." + default: false + allow_plaintext_http: + type: boolean + description: "Allow http:// destinations. Off by default — a plaintext webhook ships its payload and its auth header in clear." + default: false + + test_sends_ping: + type: boolean + description: "Make the Test button POST a small ping payload. Off by default: the background health checker runs this command on a schedule, and every run would reach the third party." + default: false + required: + - url + +commands: + - id: post_json + name: webhook-post-json + description: "POST a JSON document to the configured destination. The payload is sent as-is — use this when the receiver defines the schema." + risk: safe_write + category: notify + inputs_schema: + properties: + payload: + type: object + description: "Body to send. An object or an array; a JSON string is parsed." + url: + type: string + description: "Destination override (refused unless allow_url_override is on)." + path: + type: string + description: "Appended to the instance URL's path, e.g. /events." + headers: + type: object + description: "Extra headers for this call only." + method: + type: string + description: "POST | PUT | PATCH — overrides the instance default." + required: + - payload + outputs_schema: + properties: + ok: { type: boolean } + status: { type: number } + attempts: { type: number } + target: { type: string } + duration_ms: { type: number } + response: { type: object } + + - id: send_event + name: webhook-send-event + description: "POST a normalised Riposte event envelope — incident id, title, severity, status, link — to the destination. Use this to notify a solution from a playbook without composing JSON by hand." + risk: safe_write + category: notify + inputs_schema: + properties: + event: + type: string + description: "Event name, e.g. incident.created, incident.closed. Default: incident.notified." + incident_id: + type: string + description: "Riposte incident id." + title: + type: string + description: "Incident title / message subject." + severity: + type: string + description: "Severity, as a number (1-5) or a label — passed through untouched." + status: + type: string + description: "Incident status." + description: + type: string + description: "Free-text body." + incident_url: + type: string + description: "Deep link back into Riposte." + tags: + type: string + description: "Comma-separated list, or an array." + data: + type: object + description: "Anything extra, sent under `data`." + url: + type: string + description: "Destination override (refused unless allow_url_override is on)." + path: + type: string + description: "Appended to the instance URL's path." + headers: + type: object + description: "Extra headers for this call only." + required: + - title + outputs_schema: + properties: + ok: { type: boolean } + status: { type: number } + attempts: { type: number } + target: { type: string } + duration_ms: { type: number } + event: { type: string } + + - id: post_form + name: webhook-post-form + description: "POST an application/x-www-form-urlencoded body. For receivers that predate JSON webhooks." + risk: safe_write + category: notify + inputs_schema: + properties: + fields: + type: object + description: "Field name to value. A list value is repeated as multiple parameters." + url: + type: string + description: "Destination override (refused unless allow_url_override is on)." + path: + type: string + description: "Appended to the instance URL's path." + headers: + type: object + description: "Extra headers for this call only." + method: + type: string + description: "POST | PUT | PATCH." + required: + - fields + outputs_schema: + properties: + ok: { type: boolean } + status: { type: number } + attempts: { type: number } + target: { type: string } + duration_ms: { type: number } + + - id: post_raw + name: webhook-post-raw + description: "POST a body verbatim with the Content-Type you choose — XML, CSV, NDJSON, plain text. The escape hatch for a receiver none of the other commands fits." + risk: safe_write + category: notify + inputs_schema: + properties: + body: + type: string + description: "Body, sent verbatim as UTF-8." + content_type: + type: string + description: "Content-Type header. Default: text/plain; charset=utf-8." + url: + type: string + description: "Destination override (refused unless allow_url_override is on)." + path: + type: string + description: "Appended to the instance URL's path." + headers: + type: object + description: "Extra headers for this call only." + method: + type: string + description: "POST | PUT | PATCH." + required: + - body + outputs_schema: + properties: + ok: { type: boolean } + status: { type: number } + attempts: { type: number } + target: { type: string } + duration_ms: { type: number } + + - id: test_connection + name: webhook-test-connection + description: "Check the destination without disturbing it: validate the URL and auth settings, then open a TCP/TLS connection to the host. Set test_sends_ping on the instance to POST a real ping instead." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: + properties: + ok: { type: boolean } + target: { type: string } + mode: { type: string } + auth: { type: string } + signed: { type: boolean } diff --git a/integrations/webhook/scripts/post_form.py b/integrations/webhook/scripts/post_form.py new file mode 100644 index 0000000..560dc40 --- /dev/null +++ b/integrations/webhook/scripts/post_form.py @@ -0,0 +1,281 @@ +"""Riposte -> webhook: POST an application/x-www-form-urlencoded body. + +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(): + fields = structure(I.get("fields"), "fields") + if not fields: + raise ValueError("fields is required and must hold at least one entry") + pairs = [] + for key, value in fields.items(): + if isinstance(value, (list, tuple)): + pairs.extend((str(key), str(item)) for item in value) + elif isinstance(value, bool): + pairs.append((str(key), "true" if value else "false")) + elif value is not None: + pairs.append((str(key), str(value))) + body = urllib.parse.urlencode(pairs).encode("utf-8") + out(deliver(body, "application/x-www-form-urlencoded")) + + +guarded(run) diff --git a/integrations/webhook/scripts/post_json.py b/integrations/webhook/scripts/post_json.py new file mode 100644 index 0000000..9234a6d --- /dev/null +++ b/integrations/webhook/scripts/post_json.py @@ -0,0 +1,274 @@ +"""Riposte -> webhook: POST a JSON document to the configured destination. + +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(): + raw = I.get("payload") + if raw is None or raw == "": + raise ValueError("payload is required") + payload = structure(raw, "payload", allow_list=True) + body = json.dumps(payload).encode("utf-8") + out(deliver(body, str(S.get("content_type") or "application/json"))) + + +guarded(run) diff --git a/integrations/webhook/scripts/post_raw.py b/integrations/webhook/scripts/post_raw.py new file mode 100644 index 0000000..5033e9b --- /dev/null +++ b/integrations/webhook/scripts/post_raw.py @@ -0,0 +1,277 @@ +"""Riposte -> webhook: POST a body verbatim, with the Content-Type of your choice. + +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(): + body = I.get("body") + if body is None or body == "": + raise ValueError("body is required") + if not isinstance(body, str): + # An upstream block that built an object still gets a faithful send + # rather than Python's repr of a dict. + body = json.dumps(body) + content_type = str(I.get("content_type") or "text/plain; charset=utf-8") + out(deliver(body.encode("utf-8"), content_type)) + + +guarded(run) diff --git a/integrations/webhook/scripts/send_event.py b/integrations/webhook/scripts/send_event.py new file mode 100644 index 0000000..41d41b5 --- /dev/null +++ b/integrations/webhook/scripts/send_event.py @@ -0,0 +1,300 @@ +"""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) diff --git a/integrations/webhook/scripts/test_connection.py b/integrations/webhook/scripts/test_connection.py new file mode 100644 index 0000000..595e063 --- /dev/null +++ b/integrations/webhook/scripts/test_connection.py @@ -0,0 +1,316 @@ +"""Riposte -> webhook: check the destination, without disturbing the receiver. + +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(): + url = target() # validates the URL, the scheme and the SSRF guard + headers(b"", "application/json") # validates auth_type against the secrets present + kind = str(S.get("auth_type") or "none").strip().lower() + signed = bool(str(S.get("hmac_secret") or "")) + + if flag(S.get("test_sends_ping")): + payload = {"source": "riposte", "event": "test_connection", + "sent_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} + delivered = deliver(json.dumps(payload).encode("utf-8"), + str(S.get("content_type") or "application/json")) + delivered.update({"mode": "ping", "auth": kind, "signed": signed}) + out(delivered) + return + + # Default: reach the host and disturb nothing. The background health checker + # runs this command on every enabled instance on a schedule — a receiver + # should not have to filter a Riposte ping every few minutes to keep an + # instance green. test_sends_ping opts into the real POST. + parts = urllib.parse.urlsplit(url) + port = parts.port or (443 if parts.scheme == "https" else 80) + timeout = num(S.get("timeout_seconds"), 20, 1, 40) + started = time.time() + try: + sock = socket.create_connection((parts.hostname, port), timeout=timeout) + except OSError as err: + raise ValueError("cannot reach %s: %s" % (redact(url), err)) + + version = None + try: + if parts.scheme == "https": + context = (ssl.create_default_context() if flag(S.get("verify_tls"), True) + else ssl._create_unverified_context()) + try: + with context.wrap_socket(sock, server_hostname=parts.hostname) as tls: + version = tls.version() + except ssl.SSLError as err: + raise ValueError("TLS handshake with %s failed: %s" % (redact(url), err)) + finally: + try: + sock.close() + except OSError: + pass + + answer = {"ok": True, "target": redact(url), "mode": "reachability", "auth": kind, + "signed": signed, "duration_ms": int((time.time() - started) * 1000)} + if version: + answer["tls"] = version + out(answer) + + +guarded(run)