# 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/ └── / ├── manifest.yaml # required — the integration definition ├── scripts/ # optional — only for script-based commands │ └── .py ├── mappers/ # optional — bundled mapper templates, one per command │ └── .yaml └── incident-types/ # optional — bundled incident types └── .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/.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/.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 a `GET`, but the tier describes the stake, not the HTTP verb. --- ## Bundled mappers (`mappers/.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:. 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 # 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//manifest.yaml` (one directory per integration). 2. Add `scripts/.py` only for script-based commands. 3. Add `mappers/.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).