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.
Riposte Marketplace
Official catalog of integrations for the Riposte SOAR platform.
Riposte syncs this repository (a "git source") and lists every integration it finds so operators can install them in one click. Nothing here is executed at sync time — Riposte only reads manifests and scripts.
How discovery works
When Riposte syncs a source it does a shallow git clone of the selected branch
and walks the whole tree looking for files named exactly manifest.yaml.
- Each
manifest.yamlis one integration. Its containing directory is the package root. Put one integration per directory. - Scripts are collected from the package directory and its
scripts/subdirectory: every*.pyfile becomes a command implementation, keyed by filename without the extension. So a command withid: get_ip_reportis backed byget_ip_report.py. - Files not named
manifest.yamlare ignored as integration roots, so templates likemanifest.example.yamlare never ingested.
integrations/
└── <integration-id>/
├── manifest.yaml # required — the integration definition
├── scripts/ # optional — only for script-based commands
│ └── <command-id>.py
├── mappers/ # optional — bundled mapper templates, one per command
│ └── <command-id>.yaml
└── incident-types/ # optional — bundled incident types
└── <anything>.yaml
Manifest schema (manifest.yaml)
id: my_integration # required, unique, slug
name: My Integration # required, human name
version: 1.0.0 # semver
description: What it does.
changelog: "1.1.0 — notes" # optional: shown on update so operators can assess risk
category: enrichment # free text (enrichment, containment, ticketing…)
# Per-instance configuration the operator fills when creating an instance.
# JSON-Schema shape: { properties: {...}, required: [...] }.
config_schema:
properties:
base_url:
type: string # string | number | boolean
description: API base URL
default: https://api.example.com/v1
api_key:
type: string
description: API key
x-soar-sensitive: true # stored encrypted in the vault, never returned
required:
- api_key
# Authentication methods, referenced by commands via `auth_ref`.
auth:
- id: apikey
type: api_key # api_key | bearer | basic | oauth2_client_credentials
in: header # header | query
name: x-apikey # header/query parameter name
value_template: "{{secret}}" # {{secret}} is replaced by the secret_field value
secret_field: api_key # which config_schema field holds the secret
commands:
# --- Request-based command (recommended, no code) -----------------------
- id: get_ip_report
name: Get IP report
description: Reputation for an IP address.
risk: read # read | safe_write | destructive — see below
inputs_schema:
properties:
ip:
type: string
description: IP address to look up
required:
- ip
outputs_schema:
properties: {}
request:
method: GET # GET | POST | PUT | PATCH | DELETE
path: /ip/{ip} # {ip} is filled from inputs
query: [] # input names sent as query params
body: [] # input names sent as JSON body fields
auth_ref: apikey
# --- Script-based command -----------------------------------------------
# Omit `request` and provide scripts/<id>.py instead. The script receives the
# resolved inputs + instance config and MUST print one JSON object to stdout.
- id: enrich_custom
name: Custom enrichment
description: Runs scripts/enrich_custom.py in a sandbox.
risk: read
inputs_schema:
properties:
indicator:
type: string
required:
- indicator
outputs_schema:
properties: {}
A command is request-based when it has a request: block, or
script-based when a matching scripts/<command-id>.py exists. Prefer
request-based commands: they need no sandbox and are easier to audit.
risk: — the agent approval gate
Every command declares a tier. It is what Riposte's agent approval gate reads:
read runs unattended, safe_write and destructive sit behind the operator's
configured threshold. An omitted risk is treated as destructive — safe, but
it makes a lookup as noisy as an isolation, so declare it rather than default it.
| Tier | What belongs there |
|---|---|
read |
Only queries. Changes nothing outside Riposte: lookups, searches, telemetry, fetching a job's result. |
safe_write |
Additive or reversible, inside the tool's own case/enrichment plane: comments, notes, tags, tickets, alert statuses, threat-intel records, sandbox submissions, collection jobs, outbound messages. |
destructive |
Changes the estate or weakens a control: containment and its removal, blocklists and allowlists, exclusions and detection rules, every delete, identity and credential operations, running code on a host, generic API escape hatches. |
Two rules the catalog applies consistently, because they are not obvious:
- Undoing a containment is tiered like the containment it lifts. Un-isolating a host or un-banning an IP mid-incident is its own decision, not a return to a neutral state.
- Retrieving a live secret is
destructive. It is aGET, but the tier describes the stake, not the HTTP verb.
Bundled mappers (mappers/<command-id>.yaml)
A mapper turns one raw element of a command's output into an incident: the fields Riposte shows and routes on, and an OCSF projection for entity indexing. Bundled mappers are registered as read-only library templates on install and replaced on every update — an operator's clones are never touched, which also means a clone never receives your fixes. Ship correct templates; let operators clone only to diverge on purpose.
name: "My Product Investigations → OCSF" # required — shown in the mapper library
description: |
Free text. Say what one element looks like and what the mapper reads.
# Incident fields. Target: title, severity, source, description, custom:<name>.
field_mappings:
title: "title" # dotted path into the element
severity: "priority" # values run through toSeverity (CRITICAL→5 … LOW→2)
"custom:vendor_status": "status" # lands in the declared custom field <name>
# OCSF projection. source_path is full JSONata; ocsf_field is the destination.
ocsf:
- { source_path: "rrn", ocsf_field: "finding_info.uid" }
- { source_path: "created_time", ocsf_field: "finding_info.created_time" }
Expressions: dotted paths and jsonata:
A field_mappings expression is a dotted path (with an optional
cond ? then : else ternary). Prefixed with jsonata:, it is evaluated
by the full JSONata engine instead — the same one ocsf.source_path always
uses — which can index arrays, filter, and reshape:
field_mappings:
"custom:evidence": "jsonata: $toTable(alerts.evidences.fields[])"
"custom:alert_name": "jsonata: $parseJson(alerts[0].evidences[0].data).source_json.AlertDisplayName"
severity: "jsonata: priority = 'HIGH' ? 4 : 2"
Two Riposte extensions exist alongside the standard JSONata functions:
| Function | What it does |
|---|---|
$parseJson(str) |
Opens a JSON-encoded string — the original log line most SIEMs bury inside a JSON string field. |
$toTable(x [, cols]) |
Turns an array of objects into rows for a table custom field (a single object becomes key/value rows). cols keeps only the named columns. |
Mind JSONata's singleton semantics: a path that finds one element unwraps
it from its array. End the path with [] (alerts.evidences.fields[]) so a
one-alert investigation still produces table rows instead of key/value pairs.
Convention: reshape in the mapper, not in the script. A command script should return what the API gives, plus at most the flattening the API forces on it. Building display structures in Python (pre-joined rows, markdown summaries) welds them to the integration version — as expressions in the mapper they are visible, testable against the rule's captured samples (editor → Test), and an operator can adapt them without a package release. Pre-1.11 integrations that ship pre-built fields keep working; don't extend the pattern.
An expression that fails costs its own field and never the alert — silently, by design. The mapper editor's Test button is where those errors surface, named by field, evaluated against the rule's last captured samples.
Adding this catalog to Riposte
In Riposte → Integrations → Marketplace → Add source:
| Field | Value |
|---|---|
| Name | Official marketplace |
| Git URL | https://gitea.riposte-labs.com/f3nris/riposte-marketplace.git |
| Branch | main |
| Provider | Gitea (sets the right auth scheme for private repos) |
| Token | a read-only token if the repo is private; leave empty if public |
Then Sync. Discovered integrations appear in the marketplace, ready to install.
Contributing an integration
- Create
integrations/<id>/manifest.yaml(one directory per integration). - Add
scripts/<command-id>.pyonly for script-based commands. - Add
mappers/<command-id>.yamlfor every ingestion command — reshape withjsonata:expressions there rather than in the script (see above). - Bump
version(semver) on every change — Riposte tracks versions per source. - Validate the YAML parses and
id/nameare set. - Open a merge request.
See templates/manifest.example.yaml for a
fully-commented starting point (that file is intentionally not ingested).