Files
f3nris 26d6273f60 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.
2026-09-18 08:25:34 +00:00

280 lines
10 KiB
YAML

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 '<unix ts>.<body>' 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 }