820bab8e5c
create_issue and edit_issue declare options_from on project_key, issue_type_name and priority, and link_issues on link_type, so the playbook editor lists the Jira site's own values. Three read commands feed them: list_projects, list_issue_types (per project) and list_priorities. The README documents enum and options_from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WhDQSYZd1mFtRsXK4ixtyB
292 lines
13 KiB
Markdown
292 lines
13 KiB
Markdown
# Riposte Marketplace
|
|
|
|
Official catalog of **integrations** for the [Riposte](https://gitea.riposte-labs.com/f3nris/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.yaml` is 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 `*.py` file becomes a command implementation, keyed by
|
|
filename without the extension. So a command with `id: get_ip_report` is backed
|
|
by `get_ip_report.py`.
|
|
- Files **not** named `manifest.yaml` are ignored as integration roots, so
|
|
templates like `manifest.example.yaml` are 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`)
|
|
|
|
```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.
|
|
|
|
### Inputs with a list of values
|
|
|
|
An input can offer its values in the playbook editor instead of a free-text box.
|
|
|
|
- **A fixed list** — `enum:`, when the values are the vendor's own and the same
|
|
everywhere (a verdict, a status). The editor shows a drop-down, and the
|
|
command bar refuses a value outside it.
|
|
- **A list read from the tenant** — `options_from:`, when the values belong to
|
|
each customer's tool (projects, issue types, priorities). The editor runs the
|
|
named command on the instance and lists what it returns; a typed value or an
|
|
expression is still accepted.
|
|
|
|
```yaml
|
|
issue_type_name:
|
|
type: string
|
|
options_from:
|
|
command: list_issue_types # a command of this integration, risk: read only
|
|
path: issue_types # where the rows are in its output (empty: the output itself)
|
|
value: name # row field written into the input
|
|
label: name # row field shown in the list (defaults to value)
|
|
depends_on: [project_key] # sibling inputs passed to the command, when filled with a literal
|
|
```
|
|
|
|
Riposte refuses an `options_from` command that is not `risk: read`: opening a
|
|
field must never change anything in the customer's tool.
|
|
|
|
### `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 a `GET`, 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.
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```yaml
|
|
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.
|
|
|
|
---
|
|
|
|
## Well-known commands: the on-call router
|
|
|
|
Most command ids are an integration's own business. **Three are a contract**, and
|
|
declaring them is how an integration becomes something Riposte's pager can hand its
|
|
on-call ladder to — a telephony carrier that will ring the on-call team when a
|
|
client calls the number you published to them.
|
|
|
|
| Command | Inputs | What it must do |
|
|
|---|---|---|
|
|
| `set_oncall_ladder` | `numbers` (ordered, E.164), `ring_seconds` | Ring those numbers, **in that order**, one after another. A carrier that can only hold ONE destination takes `numbers[0]` and says so in its description — a single rung that follows the rota still beats a redirect a human has to edit. Must be idempotent: Riposte republishes on a schedule. |
|
|
| `get_oncall_ladder` | — | Answer `{ numbers: [...] }`: what the carrier **actually holds**, not what was last sent. Optional, and what makes Riposte's comparison honest. |
|
|
| `list_calls` | `since`, `limit` | Answer `{ calls: [...] }` with `from_number`, `did`, `started_at`, `duration`. Optional; it fills the pager journal after the fact. |
|
|
|
|
**The id is the whole contract.** There is no registry, no manifest field to set and
|
|
no vendor list anywhere in Riposte: the pager's settings screen offers whichever
|
|
instances declare `set_oncall_ladder`.
|
|
|
|
**Declarative, never reactive.** Riposte says *who* to ring; the carrier decides
|
|
*when*. A contract shaped the other way round — the carrier calling us as each call
|
|
arrives — is a webhook, and a self-hosted SOAR on a VM with no inbound path from the
|
|
internet cannot receive one.
|
|
|
|
Two things a router should be honest about in its description, because they decide
|
|
whether an operator should use it at all: whether the carrier can **ask the answerer
|
|
to press a key** before connecting the caller (most cannot, so a mobile's voicemail
|
|
ends the search as though a person had answered), and whether ringing a rung costs
|
|
anything on the operator's own trunk.
|
|
|
|
`integrations/ovhcloud` implements all three against a VoIP call queue; read it
|
|
before writing another.
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
1. Create `integrations/<id>/manifest.yaml` (one directory per integration).
|
|
2. Add `scripts/<command-id>.py` only for script-based commands.
|
|
3. Add `mappers/<command-id>.yaml` for every ingestion command — reshape with
|
|
`jsonata:` expressions there rather than in the script (see above).
|
|
4. Bump `version` (semver) on every change — Riposte tracks versions per source.
|
|
5. Validate the YAML parses and `id`/`name` are set.
|
|
6. Open a merge request.
|
|
|
|
See [`templates/manifest.example.yaml`](templates/manifest.example.yaml) for a
|
|
fully-commented starting point (that file is intentionally **not** ingested).
|