f3nris ff461464fd fix(crowdstrike): resolve_detection fails when Falcon changed nothing (1.6.3)
The alerts PATCH answers 200 to a refused update (errors, resources_affected
0) and the command reported it as a success. It now exits in error with
Falcon's reasons and the ids sent; an empty update is refused up front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBnLYwQbUCaf5vpv77N14Q
2026-10-15 12:08:05 +00:00

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.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
    └── agents/                # optional — the operator agent (Riposte 0.302.0+)
        └── operator.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.

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.
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.

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.


Bundled operator agents (agents/*.yaml)

An integration can ship the operator agent that knows how to use it. Installing the integration in Riposte (0.302.0 and later) creates the agent in the crew, named <handle> · <integration name>; older versions ignore the folder. The sheet carries what took a real investigation to learn — the vendor's traps, the order to call things in — so nobody has to rebuild it by hand.

key: operator              # stable id within the package; one agent per key
handle: Static             # an invented netrunner handle, never an existing character
title: Rapid7 InsightIDR operator
role: analyst_l2           # must hold integrations.execute
approval_threshold: safe_write
max_tool_calls: 10
when_to_delegate: |        # the only thing an orchestrator reads to route work to it
  …
needs: |
  …
returns: |
  …
instructions: |            # the vendor know-how: procedure and traps
  …
commands: [list_investigation_alerts, get_alert_actors]   # command ids of THIS manifest
riposte_tools: [get_incident, get_incident_context]

Riposte refuses the sheet — and still installs the integration — when it:

  • lists a command this manifest does not declare, or one that is not risk: read (an omitted risk counts as destructive);
  • lists a Riposte tool that is not read-tier;
  • names a role without integrations.execute. viewer, auditor, mcp_readonly and analyst_l1 lack it, and with them every integration command would be invisible to the agent;
  • has no key, handle, role, when_to_delegate or command.

The model is not part of the sheet: operators borrow Riposte's Crew role. A sheet nobody edited follows the package's updates; an edited one keeps its edit and offers the new version for review. Removing a sheet from the package retires its agent on the next install.

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 for a fully-commented starting point (that file is intentionally not ingested).

S
Description
No description provided
Readme 6.1 MiB
Languages
Python 100%