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)