feat(webhook): outbound webhooks to any third-party solution (v1.0.0)

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 `<ts>.<body>`, 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.
This commit is contained in:
2026-09-18 08:25:34 +00:00
parent dd2b4629a1
commit 26d6273f60
6 changed files with 1727 additions and 0 deletions
+281
View File
@@ -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)