Compare commits
93 Commits
220fa75b99
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fbe592421 | |||
| 6d36b4e958 | |||
| 90c6c80843 | |||
| c67343b7cf | |||
| 2bc7601177 | |||
| 2a74bbc436 | |||
| 983414f278 | |||
| 046810c1f6 | |||
| dcf257d887 | |||
| 5085d96628 | |||
| c358814fe7 | |||
| 815db080f5 | |||
| 6b76270cc6 | |||
| ff461464fd | |||
| 88407dd1b4 | |||
| 8eed7ed9fb | |||
| f34c87f0c8 | |||
| 0444e120fa | |||
| 585c06385c | |||
| 725e6921f4 | |||
| e973ccb673 | |||
| 62157e5dee | |||
| 91855fce4f | |||
| 6f51520fb5 | |||
| 80de5399a6 | |||
| fb560cc937 | |||
| 3bfcebdf21 | |||
| ae1effafcd | |||
| 889cf660f6 | |||
| 847f31ce17 | |||
| 3e39f17bfe | |||
| 9605e1f0dc | |||
| 2245d5500e | |||
| 4bd468e671 | |||
| c3b15d1faf | |||
| c5da537434 | |||
| 4a339fda24 | |||
| b9e796e927 | |||
| 856dd2321d | |||
| 94142f4739 | |||
| d2742ec6fb | |||
| 5cf870309f | |||
| 45df291eeb | |||
| db9a45c9bc | |||
| ece2c41340 | |||
| ea04ea586c | |||
| d831d0973a | |||
| 91f6b8158b | |||
| 0f5f4a900e | |||
| 820bab8e5c | |||
| 41aa6723c5 | |||
| 89ffbc3c7c | |||
| 7750744abb | |||
| 4554354e06 | |||
| 71f8cd970e | |||
| 232fa2f0a6 | |||
| 18ae4a24ff | |||
| a6a52f7356 | |||
| beefe047a7 | |||
| 505c518bf8 | |||
| ed56523b07 | |||
| 42032e576e | |||
| 35f287837b | |||
| 3ca2d6ded2 | |||
| d682d66f04 | |||
| aacfa135aa | |||
| 6e03b17afb | |||
| 864300d7c5 | |||
| 11089e9e80 | |||
| 26d6273f60 | |||
| dd2b4629a1 | |||
| 741db61543 | |||
| 086150b200 | |||
| a32992d781 | |||
| d3c85cf332 | |||
| ab63ead00e | |||
| a26e71f29e | |||
| da2fcbba6c | |||
| 31e596d770 | |||
| fe564cec2c | |||
| 4cd1cda7cb | |||
| 9081bc66fa | |||
| b26257aaeb | |||
| e19530d3f3 | |||
| 2d6e097742 | |||
| 1888648930 | |||
| 995a39ea66 | |||
| 45e9cbd269 | |||
| c7569fd4d0 | |||
| eeebbfed3a | |||
| 306450a4b6 | |||
| 69e3a3e81c | |||
| 841ae1199f |
@@ -26,8 +26,14 @@ and walks the whole tree looking for files named exactly **`manifest.yaml`**.
|
|||||||
integrations/
|
integrations/
|
||||||
└── <integration-id>/
|
└── <integration-id>/
|
||||||
├── manifest.yaml # required — the integration definition
|
├── manifest.yaml # required — the integration definition
|
||||||
└── scripts/ # optional — only for script-based commands
|
├── scripts/ # optional — only for script-based commands
|
||||||
└── <command-id>.py
|
│ └── <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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -71,6 +77,7 @@ commands:
|
|||||||
- id: get_ip_report
|
- id: get_ip_report
|
||||||
name: Get IP report
|
name: Get IP report
|
||||||
description: Reputation for an IP address.
|
description: Reputation for an IP address.
|
||||||
|
risk: read # read | safe_write | destructive — see below
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
ip:
|
ip:
|
||||||
@@ -93,6 +100,7 @@ commands:
|
|||||||
- id: enrich_custom
|
- id: enrich_custom
|
||||||
name: Custom enrichment
|
name: Custom enrichment
|
||||||
description: Runs scripts/enrich_custom.py in a sandbox.
|
description: Runs scripts/enrich_custom.py in a sandbox.
|
||||||
|
risk: read
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
indicator:
|
indicator:
|
||||||
@@ -107,6 +115,206 @@ A command is **request-based** when it has a `request:` block, or
|
|||||||
**script-based** when a matching `scripts/<command-id>.py` exists. Prefer
|
**script-based** when a matching `scripts/<command-id>.py` exists. Prefer
|
||||||
request-based commands: they need no sandbox and are easier to audit.
|
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.
|
||||||
|
|
||||||
|
### Multi-line inputs
|
||||||
|
|
||||||
|
`format: multiline` (or `format: markdown`, which says what the text is) makes
|
||||||
|
the playbook editor show a resizable text area instead of a one-line field
|
||||||
|
(Riposte 0.322+). Use it for a mail body, a comment, a markdown description:
|
||||||
|
a one-line field turns the line breaks of a pasted text into spaces. Older
|
||||||
|
Riposte versions ignore `format` and keep the one-line field.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
description: { type: string, format: markdown, description: "Body in markdown" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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
|
## Adding this catalog to Riposte
|
||||||
@@ -129,9 +337,11 @@ Then **Sync**. Discovered integrations appear in the marketplace, ready to insta
|
|||||||
|
|
||||||
1. Create `integrations/<id>/manifest.yaml` (one directory per integration).
|
1. Create `integrations/<id>/manifest.yaml` (one directory per integration).
|
||||||
2. Add `scripts/<command-id>.py` only for script-based commands.
|
2. Add `scripts/<command-id>.py` only for script-based commands.
|
||||||
3. Bump `version` (semver) on every change — Riposte tracks versions per source.
|
3. Add `mappers/<command-id>.yaml` for every ingestion command — reshape with
|
||||||
4. Validate the YAML parses and `id`/`name` are set.
|
`jsonata:` expressions there rather than in the script (see above).
|
||||||
5. Open a merge request.
|
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
|
See [`templates/manifest.example.yaml`](templates/manifest.example.yaml) for a
|
||||||
fully-commented starting point (that file is intentionally **not** ingested).
|
fully-commented starting point (that file is intentionally **not** ingested).
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# The AbuseIPDB operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design: it never
|
||||||
|
# reports an IP.
|
||||||
|
key: operator
|
||||||
|
handle: Tally
|
||||||
|
title: AbuseIPDB operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
What AbuseIPDB's community reports say about an IP address or a small
|
||||||
|
network: abuse confidence score, number of reports and reporters, the
|
||||||
|
abuse categories reported, last reported date, ISP, usage type, country,
|
||||||
|
whether it is a Tor exit or whitelisted. IPs and CIDR blocks only. Not for
|
||||||
|
hashes, domains or URLs, and never for reporting an IP.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The IP addresses (or a CIDR block, /24 at most) to check, and optionally
|
||||||
|
how far back reports should count (default 30 days). Or a Riposte alert
|
||||||
|
id, to pick the IPs from it.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Per IP, facts from AbuseIPDB with the command that produced each:
|
||||||
|
abuseConfidenceScore, totalReports, numDistinctUsers, lastReportedAt,
|
||||||
|
isp, usageType, countryCode, isTor, isWhitelisted, and the most frequent
|
||||||
|
report categories when asked. "no reports" when totalReports is 0. The
|
||||||
|
score is attributed to AbuseIPDB, never your own verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. Only if you were given a Riposte alert id: get_incident_context (1 call)
|
||||||
|
and pick the public IPs from it. Otherwise skip.
|
||||||
|
2. Per IP: check_ip(ip=<ip>) with max_age_days 30 (default; at most 365).
|
||||||
|
Add verbose true only when the lead wants the report comments and
|
||||||
|
categories: it makes the answer much longer.
|
||||||
|
3. check_block(network=<CIDR>) only when the lead asks about the
|
||||||
|
neighbourhood of an IP. The free tier accepts /24 at most; write the
|
||||||
|
network as the lead gave it or as x.y.z.0/24 from an IP you were given.
|
||||||
|
|
||||||
|
Traps:
|
||||||
|
- A score of 0 with totalReports 0 means nobody reported it, not that it is
|
||||||
|
safe. isWhitelisted true means AbuseIPDB marks it as a known good
|
||||||
|
service. Report both as AbuseIPDB states them.
|
||||||
|
- usageType (e.g. "Data Center/Web Hosting/Transit") and isp often matter
|
||||||
|
more than the score: always include them.
|
||||||
|
- Private, loopback or link-local IPs are refused or meaningless: skip them
|
||||||
|
and say so.
|
||||||
|
- HTTP 422 means the IP or network was malformed: report it, do not adapt
|
||||||
|
the value. HTTP 429 is the daily quota: report it and stop.
|
||||||
|
- Copy IPs only from what you were given or a previous answer. Report
|
||||||
|
lastReportedAt exactly as the data gives it.
|
||||||
|
- On any other error: report the exact call and error, retry at most once.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- check_ip
|
||||||
|
- check_block
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: abuseipdb
|
id: abuseipdb
|
||||||
name: AbuseIPDB
|
name: AbuseIPDB
|
||||||
version: 1.0.0
|
version: 1.1.1
|
||||||
description: "AbuseIPDB (API v2) — check the abuse reputation of an IP, report abusive IPs, pull the blacklist and check a CIDR block. API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "AbuseIPDB (API v2) — check the abuse reputation of an IP, report abusive IPs, pull the blacklist and check a CIDR block. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: IP reputation check, report, blacklist retrieval and CIDR-block check."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: IP reputation check, report, blacklist retrieval and CIDR-block check."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|
||||||
config_schema:
|
config_schema:
|
||||||
@@ -29,6 +29,7 @@ commands:
|
|||||||
- id: report_ip
|
- id: report_ip
|
||||||
name: abuseipdb-report-ip
|
name: abuseipdb-report-ip
|
||||||
description: "Report an abusive IP address to AbuseIPDB."
|
description: "Report an abusive IP address to AbuseIPDB."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
ip: { type: string, description: "IP address to report" }
|
ip: { type: string, description: "IP address to report" }
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# The Active Directory operator. Installed with the integration by Riposte
|
||||||
|
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
|
||||||
|
# Riposte refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Ledger
|
||||||
|
title: Active Directory operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Who an on-prem Active Directory account or computer is: the user's
|
||||||
|
name, mail, manager, groups, and account flags (disabled, locked out,
|
||||||
|
password never expires...), last logon and password age; a computer's
|
||||||
|
groups and OS; who is in a group. It cannot show sign-in events: no
|
||||||
|
command does. Not for Entra ID (cloud) accounts, and not for changing any
|
||||||
|
account.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The user's sAMAccountName (e.g. jdoe, without DOMAIN\) or e-mail, a
|
||||||
|
computer name, or a group. The Riposte alert id if the user or host must
|
||||||
|
be read from the alert.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Active Directory, each with the command that produced it;
|
||||||
|
"not found" when a lookup came back empty; the exact call and error when
|
||||||
|
one failed. No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. If you were given an alert: get_incident_context (1 call) and read the
|
||||||
|
user and host from it, exactly as written. DOMAIN\jdoe becomes jdoe;
|
||||||
|
jdoe@corp.com is an e-mail or UPN: use the email input.
|
||||||
|
2. ad_get_user (1 call) with username=<sAMAccountName>, and
|
||||||
|
attributes "lastLogonTimestamp,pwdLastSet,whenCreated,lockoutTime"
|
||||||
|
(commas, no spaces).
|
||||||
|
Prefer username or email over a DN. Read userAccountControlFields for
|
||||||
|
the flags (disabled, locked out, password never expires). Report the
|
||||||
|
time attributes exactly as returned; do not convert them.
|
||||||
|
0 users: try once with email or name, then report "not found".
|
||||||
|
3. memberOf lists the groups. Name the privileged ones apart (Domain
|
||||||
|
Admins, Enterprise Admins, Administrators, and any name with "admin").
|
||||||
|
4. ad_get_computer(name=<host>) (1 call) for the host of the alert, with
|
||||||
|
attributes "operatingSystem,lastLogonTimestamp,whenCreated".
|
||||||
|
5. Optional: ad_get_group_members with group-dn copied from a memberOf
|
||||||
|
value (never typed or adapted), limit 50 (1-2 calls). Always set
|
||||||
|
limit: without it the whole group comes back.
|
||||||
|
6. Optional: ad_search for what the other commands cannot ask, with an
|
||||||
|
LDAP filter in the form the manifest shows,
|
||||||
|
(&(objectCategory=person)(objectClass=user)), size-limit 20. Only
|
||||||
|
when you are sure of the filter; otherwise skip it.
|
||||||
|
|
||||||
|
Take DNs and names only from the alert or a previous answer. On an error
|
||||||
|
("LDAP error: ..."): report the exact call and error; retry at most once.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- ad_get_user
|
||||||
|
- ad_get_computer
|
||||||
|
- ad_get_group_members
|
||||||
|
- ad_search
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: active_directory
|
id: active_directory
|
||||||
name: Active Directory
|
name: Active Directory
|
||||||
version: 1.0.0
|
version: 1.1.1
|
||||||
description: "Active Directory over LDAP/LDAPS — query and manage users, computers, groups and contacts (search, enable/disable/unlock, set/expire password, group membership, OU moves, create/update/delete). Runs on a remote engine inside the AD network. Requires the Python 'ldap3' library on the engine host (pip install ldap3)."
|
description: "Active Directory over LDAP/LDAPS — query and manage users, computers, groups and contacts (search, enable/disable/unlock, set/expire password, group membership, OU moves, create/update/delete). Runs on a remote engine inside the AD network. Requires the Python 'ldap3' library on the engine host (pip install ldap3)."
|
||||||
changelog: "1.0.0 — Initial release: full directory administration over LDAP (search, get user/computer/group-members, create/update/delete user/contact/group, enable/disable/unlock, set/expire password, password-never-expire, add/remove group membership, move user/computer OU, test credentials)."
|
changelog: "1.1.0 — Declare the risk tier on the 18 commands that carried none: 3 safe_write, 15 destructive. An unset risk is read as destructive by the agent approval gate, so the 3 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: full directory administration over LDAP (search, get user/computer/group-members, create/update/delete user/contact/group, enable/disable/unlock, set/expire password, password-never-expire, add/remove group membership, move user/computer OU, test credentials)."
|
||||||
category: identity
|
category: identity
|
||||||
|
|
||||||
# Per-instance configuration. The integration binds to a Domain Controller over
|
# Per-instance configuration. The integration binds to a Domain Controller over
|
||||||
@@ -139,6 +139,7 @@ commands:
|
|||||||
- id: ad_enable_account
|
- id: ad_enable_account
|
||||||
name: ad-enable-account
|
name: ad-enable-account
|
||||||
description: "Enable a previously disabled user account."
|
description: "Enable a previously disabled user account."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "sAMAccountName of the account to enable" }
|
username: { type: string, description: "sAMAccountName of the account to enable" }
|
||||||
@@ -149,6 +150,7 @@ commands:
|
|||||||
- id: ad_disable_account
|
- id: ad_disable_account
|
||||||
name: ad-disable-account
|
name: ad-disable-account
|
||||||
description: "Disable a user account."
|
description: "Disable a user account."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "sAMAccountName of the account to disable" }
|
username: { type: string, description: "sAMAccountName of the account to disable" }
|
||||||
@@ -158,6 +160,7 @@ commands:
|
|||||||
- id: ad_unlock_account
|
- id: ad_unlock_account
|
||||||
name: ad-unlock-account
|
name: ad-unlock-account
|
||||||
description: "Unlock a locked-out user account."
|
description: "Unlock a locked-out user account."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "sAMAccountName of the account to unlock" }
|
username: { type: string, description: "sAMAccountName of the account to unlock" }
|
||||||
@@ -167,6 +170,7 @@ commands:
|
|||||||
- id: ad_set_new_password
|
- id: ad_set_new_password
|
||||||
name: ad-set-new-password
|
name: ad-set-new-password
|
||||||
description: "Set a new password for a user. Requires a secure connection (SSL or TLS)."
|
description: "Set a new password for a user. Requires a secure connection (SSL or TLS)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "sAMAccountName whose password will be set" }
|
username: { type: string, description: "sAMAccountName whose password will be set" }
|
||||||
@@ -177,6 +181,7 @@ commands:
|
|||||||
- id: ad_expire_password
|
- id: ad_expire_password
|
||||||
name: ad-expire-password
|
name: ad-expire-password
|
||||||
description: "Force a user to change their password at next login."
|
description: "Force a user to change their password at next login."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "sAMAccountName of the user" }
|
username: { type: string, description: "sAMAccountName of the user" }
|
||||||
@@ -186,6 +191,7 @@ commands:
|
|||||||
- id: ad_modify_password_never_expire
|
- id: ad_modify_password_never_expire
|
||||||
name: ad-modify-password-never-expire
|
name: ad-modify-password-never-expire
|
||||||
description: "Set or clear the 'Password Never Expire' flag on a user account."
|
description: "Set or clear the 'Password Never Expire' flag on a user account."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "sAMAccountName of the user" }
|
username: { type: string, description: "sAMAccountName of the user" }
|
||||||
@@ -197,6 +203,7 @@ commands:
|
|||||||
- id: ad_add_to_group
|
- id: ad_add_to_group
|
||||||
name: ad-add-to-group
|
name: ad-add-to-group
|
||||||
description: "Add a user, computer or nested group to a group."
|
description: "Add a user, computer or nested group to a group."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "Username(s) to add (single or CSV). Mutually exclusive with computer-name." }
|
username: { type: string, description: "Username(s) to add (single or CSV). Mutually exclusive with computer-name." }
|
||||||
@@ -209,6 +216,7 @@ commands:
|
|||||||
- id: ad_remove_from_group
|
- id: ad_remove_from_group
|
||||||
name: ad-remove-from-group
|
name: ad-remove-from-group
|
||||||
description: "Remove a user or computer from a group."
|
description: "Remove a user or computer from a group."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "Username to remove. Mutually exclusive with computer-name." }
|
username: { type: string, description: "Username to remove. Mutually exclusive with computer-name." }
|
||||||
@@ -222,6 +230,7 @@ commands:
|
|||||||
- id: ad_modify_user_ou
|
- id: ad_modify_user_ou
|
||||||
name: ad-modify-user-ou
|
name: ad-modify-user-ou
|
||||||
description: "Move a user to a different organizational unit within the domain."
|
description: "Move a user to a different organizational unit within the domain."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
user-name: { type: string, description: "Name of the user to move" }
|
user-name: { type: string, description: "Name of the user to move" }
|
||||||
@@ -232,6 +241,7 @@ commands:
|
|||||||
- id: ad_modify_computer_ou
|
- id: ad_modify_computer_ou
|
||||||
name: ad-modify-computer-ou
|
name: ad-modify-computer-ou
|
||||||
description: "Move a computer to a different organizational unit within the domain."
|
description: "Move a computer to a different organizational unit within the domain."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
computer-name: { type: string, description: "Name of the computer to move" }
|
computer-name: { type: string, description: "Name of the computer to move" }
|
||||||
@@ -244,6 +254,7 @@ commands:
|
|||||||
- id: ad_create_user
|
- id: ad_create_user
|
||||||
name: ad-create-user
|
name: ad-create-user
|
||||||
description: "Create a user account, set its initial password and enable it. Requires a secure connection (SSL or TLS)."
|
description: "Create a user account, set its initial password and enable it. Requires a secure connection (SSL or TLS)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "sAMAccountName for the new user" }
|
username: { type: string, description: "sAMAccountName for the new user" }
|
||||||
@@ -260,6 +271,7 @@ commands:
|
|||||||
- id: ad_update_user
|
- id: ad_update_user
|
||||||
name: ad-update-user
|
name: ad-update-user
|
||||||
description: "Replace a single attribute on an existing user."
|
description: "Replace a single attribute on an existing user."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
username: { type: string, description: "sAMAccountName of the user" }
|
username: { type: string, description: "sAMAccountName of the user" }
|
||||||
@@ -271,6 +283,7 @@ commands:
|
|||||||
- id: ad_delete_user
|
- id: ad_delete_user
|
||||||
name: ad-delete-user
|
name: ad-delete-user
|
||||||
description: "Delete a user (or any object) by its DN."
|
description: "Delete a user (or any object) by its DN."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
user-dn: { type: string, description: "DN of the object to delete" }
|
user-dn: { type: string, description: "DN of the object to delete" }
|
||||||
@@ -281,6 +294,7 @@ commands:
|
|||||||
- id: ad_create_contact
|
- id: ad_create_contact
|
||||||
name: ad-create-contact
|
name: ad-create-contact
|
||||||
description: "Create a contact object."
|
description: "Create a contact object."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
contact-dn: { type: string, description: "Full DN of the new contact" }
|
contact-dn: { type: string, description: "Full DN of the new contact" }
|
||||||
@@ -295,6 +309,7 @@ commands:
|
|||||||
- id: ad_update_contact
|
- id: ad_update_contact
|
||||||
name: ad-update-contact
|
name: ad-update-contact
|
||||||
description: "Replace a single attribute on an existing contact."
|
description: "Replace a single attribute on an existing contact."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
contact-dn: { type: string, description: "DN of the contact" }
|
contact-dn: { type: string, description: "DN of the contact" }
|
||||||
@@ -307,6 +322,7 @@ commands:
|
|||||||
- id: ad_create_group
|
- id: ad_create_group
|
||||||
name: ad-create-group
|
name: ad-create-group
|
||||||
description: "Create a security or distribution group."
|
description: "Create a security or distribution group."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Group name (sAMAccountName)" }
|
name: { type: string, description: "Group name (sAMAccountName)" }
|
||||||
@@ -318,6 +334,7 @@ commands:
|
|||||||
- id: ad_update_group
|
- id: ad_update_group
|
||||||
name: ad-update-group
|
name: ad-update-group
|
||||||
description: "Replace a single attribute on an existing group."
|
description: "Replace a single attribute on an existing group."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
groupname: { type: string, description: "Group name (cn) to update" }
|
groupname: { type: string, description: "Group name (cn) to update" }
|
||||||
@@ -329,6 +346,7 @@ commands:
|
|||||||
- id: ad_delete_group
|
- id: ad_delete_group
|
||||||
name: ad-delete-group
|
name: ad-delete-group
|
||||||
description: "Delete a security or distribution group by its DN."
|
description: "Delete a security or distribution group by its DN."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
dn: { type: string, description: "DN of the group" }
|
dn: { type: string, description: "DN of the group" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: anomali_threatstream
|
id: anomali_threatstream
|
||||||
name: Anomali ThreatStream
|
name: Anomali ThreatStream
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Anomali ThreatStream (API v2/v1) — threat intelligence: reputation lookups for IPs, domains, file hashes and URLs, indicator search, passive DNS, threat-model listing, and indicator import (with or without approval). API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "Anomali ThreatStream (API v2/v1) — threat intelligence: reputation lookups for IPs, domains, file hashes and URLs, indicator search, passive DNS, threat-model listing, and indicator import (with or without approval). API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: ip/domain/file/url reputation, indicator search, passive DNS, threat models, import indicator."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none: 1 safe_write. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: ip/domain/file/url reputation, indicator search, passive DNS, threat models, import indicator."
|
||||||
category: threat_intel
|
category: threat_intel
|
||||||
|
|
||||||
# Per-instance configuration. Auth header 'Authorization: apikey <username>:<api_key>'.
|
# Per-instance configuration. Auth header 'Authorization: apikey <username>:<api_key>'.
|
||||||
@@ -93,6 +93,7 @@ commands:
|
|||||||
- id: import_indicator
|
- id: import_indicator
|
||||||
name: anomali-import-indicator
|
name: anomali-import-indicator
|
||||||
description: "Import an observable as an indicator (optionally requiring approval)."
|
description: "Import an observable as an indicator (optionally requiring approval)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
value: { type: string, description: "Observable value (IP, domain, hash, URL)" }
|
value: { type: string, description: "Observable value (IP, domain, hash, URL)" }
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# The ANY.RUN operator. Installed with the integration by Riposte 0.302.0 and
|
||||||
|
# later (older versions ignore this folder). Read-only by design: it never
|
||||||
|
# detonates a file or URL and never deletes a task.
|
||||||
|
key: operator
|
||||||
|
handle: Gauntlet
|
||||||
|
title: ANY.RUN operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
What our ANY.RUN account already found about a file or URL it analysed
|
||||||
|
before: the task, its verdict and threat level, tags, the processes,
|
||||||
|
network connections and MITRE techniques the report shows. Only tasks run
|
||||||
|
from this account. Not for detonating anything new — this operator cannot
|
||||||
|
submit — and not for reputation of IPs or domains.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The ANY.RUN task id if known. Otherwise the file hash (SHA256 preferred),
|
||||||
|
file name or URL to look for in the account's history. Or a Riposte alert
|
||||||
|
id, to pick them from it.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Per sample, facts from ANY.RUN with the command that produced each: the
|
||||||
|
task (id, date, name), verdict / threat level, tags, and from the report
|
||||||
|
the main processes, contacted domains and IPs, and techniques. "no existing
|
||||||
|
analysis in the account's history" when nothing matched — a new detonation
|
||||||
|
is a human decision. Verdicts are attributed to ANY.RUN, never your own.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. Only if you were given a Riposte alert id: get_incident_context (1 call)
|
||||||
|
and pick the hashes, file names and URLs from it. Otherwise skip.
|
||||||
|
2. If the lead gave a task id: go to step 4.
|
||||||
|
3. Find the task: get_history(limit 50) (1 call, at most 2 with skip 50).
|
||||||
|
There is no hash search: look in each task for your SHA256 / MD5 in its
|
||||||
|
hashes, your file name in its name, or your URL. Match the hash exactly;
|
||||||
|
a matching name alone is weak: say so.
|
||||||
|
4. get_report(task_id=<uuid from the history entry or from the lead>) for
|
||||||
|
at most 2 tasks. Read the verdict / threat level and tags, then the
|
||||||
|
processes, the network (domains, IPs, HTTP requests) and the MITRE
|
||||||
|
techniques. Keep the top items; the report is long.
|
||||||
|
|
||||||
|
Traps:
|
||||||
|
- You cannot detonate. If nothing is found, say "no existing analysis" and
|
||||||
|
that a detonation would need a human; do not suggest you will do it.
|
||||||
|
- The history holds only this account's tasks, newest first, not ANY.RUN's
|
||||||
|
public submissions: "not in history" does not mean ANY.RUN never saw it.
|
||||||
|
- A task still running has no final verdict: report its status, do not read
|
||||||
|
it as clean.
|
||||||
|
- Take task ids only from a previous answer or the lead, never type or
|
||||||
|
adapt one (they are long UUIDs). Report dates exactly as given.
|
||||||
|
- HTTP 429 or a limits error: report it and stop.
|
||||||
|
- On any other error: report the exact call and error, retry at most once.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- get_history
|
||||||
|
- get_report
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: anyrun
|
id: anyrun
|
||||||
name: ANY.RUN
|
name: ANY.RUN
|
||||||
version: 1.0.0
|
version: 1.1.1
|
||||||
description: "ANY.RUN (API v1) — interactive malware sandbox: detonate files and URLs on Windows/Linux, poll the analysis report and verdict, list analysis history, read user limits and delete tasks. API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "ANY.RUN (API v1) — interactive malware sandbox: detonate files and URLs on Windows/Linux, poll the analysis report and verdict, list analysis history, read user limits and delete tasks. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: file/URL detonation, report and verdict retrieval, analysis history, user limits and task deletion."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none: 2 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: file/URL detonation, report and verdict retrieval, analysis history, user limits and task deletion."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|
||||||
# The API key is sent as 'Authorization: API-Key <key>' on every request.
|
# The API key is sent as 'Authorization: API-Key <key>' on every request.
|
||||||
@@ -19,6 +19,7 @@ commands:
|
|||||||
- id: detonate_file
|
- id: detonate_file
|
||||||
name: anyrun-detonate-file
|
name: anyrun-detonate-file
|
||||||
description: "Detonate a file (base64) in the ANY.RUN sandbox. Returns a task_id; poll with anyrun-get-report."
|
description: "Detonate a file (base64) in the ANY.RUN sandbox. Returns a task_id; poll with anyrun-get-report."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_name: { type: string, description: "File name" }
|
file_name: { type: string, description: "File name" }
|
||||||
@@ -30,6 +31,7 @@ commands:
|
|||||||
- id: detonate_url
|
- id: detonate_url
|
||||||
name: anyrun-detonate-url
|
name: anyrun-detonate-url
|
||||||
description: "Detonate a URL in the ANY.RUN sandbox. Returns a task_id."
|
description: "Detonate a URL in the ANY.RUN sandbox. Returns a task_id."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
url: { type: string, description: "URL to detonate" }
|
url: { type: string, description: "URL to detonate" }
|
||||||
@@ -67,6 +69,7 @@ commands:
|
|||||||
- id: delete_task
|
- id: delete_task
|
||||||
name: anyrun-delete-task
|
name: anyrun-delete-task
|
||||||
description: "Delete an analysis task by ID."
|
description: "Delete an analysis task by ID."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
task_id: { type: string, description: "Task ID" }
|
task_id: { type: string, description: "Task ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: armis
|
id: armis
|
||||||
name: Armis
|
name: Armis
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Armis (API v1) — device and asset visibility: search devices and alerts with AQL, read a device, and update an alert's status. Secret-key (token exchange) authentication; stdlib-only, no extra Python dependencies."
|
description: "Armis (API v1) — device and asset visibility: search devices and alerts with AQL, read a device, and update an alert's status. Secret-key (token exchange) authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: search devices/alerts, get device, update alert status."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none: 1 safe_write. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: search devices/alerts, get device, update alert status."
|
||||||
category: asset_management
|
category: asset_management
|
||||||
|
|
||||||
# Per-instance configuration. The secret key is exchanged for a short-lived
|
# Per-instance configuration. The secret key is exchanged for a short-lived
|
||||||
@@ -53,6 +53,7 @@ commands:
|
|||||||
- id: update_alert
|
- id: update_alert
|
||||||
name: armis-update-alert
|
name: armis-update-alert
|
||||||
description: "Update an alert's status."
|
description: "Update an alert's status."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
alert_id: { type: string, description: "Alert ID" }
|
alert_id: { type: string, description: "Alert ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: automox
|
id: automox
|
||||||
name: Automox
|
name: Automox
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Automox (API) — endpoint patch and configuration management: list and read devices, list policies, and queue a command (e.g. install updates or run a policy) on a device. API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "Automox (API) — endpoint patch and configuration management: list and read devices, list policies, and queue a command (e.g. install updates or run a policy) on a device. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list/get devices, list policies, run device command."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: list/get devices, list policies, run device command."
|
||||||
category: endpoint
|
category: endpoint
|
||||||
|
|
||||||
# Per-instance configuration. Auth header 'Authorization: Bearer <api_key>'.
|
# Per-instance configuration. Auth header 'Authorization: Bearer <api_key>'.
|
||||||
@@ -49,6 +49,7 @@ commands:
|
|||||||
- id: run_command
|
- id: run_command
|
||||||
name: automox-run-command
|
name: automox-run-command
|
||||||
description: "Queue a command on a device (e.g. InstallUpdate, Reboot)."
|
description: "Queue a command on a device (e.g. InstallUpdate, Reboot)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
device_id: { type: string, description: "Device (server) ID" }
|
device_id: { type: string, description: "Device (server) ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: aws
|
id: aws
|
||||||
name: AWS
|
name: AWS
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Amazon Web Services (EC2, IAM, STS) — cloud containment: describe instances and security groups, authorize/revoke security-group ingress rules, change an instance's security groups (isolate), stop instances, and deactivate a compromised IAM access key. AWS Signature V4 authentication; stdlib-only, no extra Python dependencies."
|
description: "Amazon Web Services (EC2, IAM, STS) — cloud containment: describe instances and security groups, authorize/revoke security-group ingress rules, change an instance's security groups (isolate), stop instances, and deactivate a compromised IAM access key. AWS Signature V4 authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: EC2 describe instances/security-groups, authorize/revoke ingress, modify instance security groups, stop instances; IAM list/update access keys; STS caller identity."
|
changelog: "1.1.0 — Declare the risk tier on the 5 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: EC2 describe instances/security-groups, authorize/revoke ingress, modify instance security groups, stop instances; IAM list/update access keys; STS caller identity."
|
||||||
category: cloud
|
category: cloud
|
||||||
|
|
||||||
# Per-instance configuration. Requests are signed with AWS Signature V4.
|
# Per-instance configuration. Requests are signed with AWS Signature V4.
|
||||||
@@ -53,6 +53,7 @@ commands:
|
|||||||
- id: authorize_security_group_ingress
|
- id: authorize_security_group_ingress
|
||||||
name: aws-authorize-security-group-ingress
|
name: aws-authorize-security-group-ingress
|
||||||
description: "Add an inbound rule to a security group."
|
description: "Add an inbound rule to a security group."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
group_id: { type: string, description: "Security group ID" }
|
group_id: { type: string, description: "Security group ID" }
|
||||||
@@ -66,6 +67,7 @@ commands:
|
|||||||
- id: revoke_security_group_ingress
|
- id: revoke_security_group_ingress
|
||||||
name: aws-revoke-security-group-ingress
|
name: aws-revoke-security-group-ingress
|
||||||
description: "Remove an inbound rule from a security group (containment)."
|
description: "Remove an inbound rule from a security group (containment)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
group_id: { type: string, description: "Security group ID" }
|
group_id: { type: string, description: "Security group ID" }
|
||||||
@@ -79,6 +81,7 @@ commands:
|
|||||||
- id: modify_instance_security_groups
|
- id: modify_instance_security_groups
|
||||||
name: aws-modify-instance-security-groups
|
name: aws-modify-instance-security-groups
|
||||||
description: "Replace the security groups attached to an instance (e.g. move it to an isolation group)."
|
description: "Replace the security groups attached to an instance (e.g. move it to an isolation group)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
instance_id: { type: string, description: "Instance ID" }
|
instance_id: { type: string, description: "Instance ID" }
|
||||||
@@ -89,6 +92,7 @@ commands:
|
|||||||
- id: stop_instances
|
- id: stop_instances
|
||||||
name: aws-stop-instances
|
name: aws-stop-instances
|
||||||
description: "Stop one or more EC2 instances."
|
description: "Stop one or more EC2 instances."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
instance_ids: { type: string, description: "Comma-separated instance IDs" }
|
instance_ids: { type: string, description: "Comma-separated instance IDs" }
|
||||||
@@ -108,6 +112,7 @@ commands:
|
|||||||
- id: update_access_key
|
- id: update_access_key
|
||||||
name: aws-update-access-key
|
name: aws-update-access-key
|
||||||
description: "Activate or deactivate an IAM access key (deactivate to contain a compromised key)."
|
description: "Activate or deactivate an IAM access key (deactivate to contain a compromised key)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
access_key_id: { type: string, description: "The access key ID to update" }
|
access_key_id: { type: string, description: "The access key ID to update" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: azure_security
|
id: azure_security
|
||||||
name: Microsoft Azure
|
name: Microsoft Azure
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Microsoft Azure (Resource Manager: Defender for Cloud + Network) — cloud containment: list and read Defender for Cloud security alerts and update their state, read the secure score, list/read network security groups (NSGs), and add or delete NSG security rules (deny inbound to isolate). Azure AD OAuth2 client-credentials authentication; stdlib-only, no extra Python dependencies."
|
description: "Microsoft Azure (Resource Manager: Defender for Cloud + Network) — cloud containment: list and read Defender for Cloud security alerts and update their state, read the secure score, list/read network security groups (NSGs), and add or delete NSG security rules (deny inbound to isolate). Azure AD OAuth2 client-credentials authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list/get security alerts, update alert state, secure score, list/get NSGs, create/delete NSG security rules."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none: 1 safe_write, 2 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list/get security alerts, update alert state, secure score, list/get NSGs, create/delete NSG security rules."
|
||||||
category: cloud
|
category: cloud
|
||||||
|
|
||||||
# Per-instance configuration. Uses application (client-credentials) OAuth2 with
|
# Per-instance configuration. Uses application (client-credentials) OAuth2 with
|
||||||
@@ -50,6 +50,7 @@ commands:
|
|||||||
- id: update_alert_state
|
- id: update_alert_state
|
||||||
name: azure-update-alert-state
|
name: azure-update-alert-state
|
||||||
description: "Change a security alert's state (dismiss, resolve, activate, or inProgress)."
|
description: "Change a security alert's state (dismiss, resolve, activate, or inProgress)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
alert_id: { type: string, description: "Full ARM resource ID of the alert" }
|
alert_id: { type: string, description: "Full ARM resource ID of the alert" }
|
||||||
@@ -85,6 +86,7 @@ commands:
|
|||||||
- id: create_nsg_rule
|
- id: create_nsg_rule
|
||||||
name: azure-create-nsg-rule
|
name: azure-create-nsg-rule
|
||||||
description: "Create or update an NSG security rule (e.g. a Deny inbound rule to isolate a resource)."
|
description: "Create or update an NSG security rule (e.g. a Deny inbound rule to isolate a resource)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
resource_group: { type: string, description: "Resource group name" }
|
resource_group: { type: string, description: "Resource group name" }
|
||||||
@@ -102,6 +104,7 @@ commands:
|
|||||||
- id: delete_nsg_rule
|
- id: delete_nsg_rule
|
||||||
name: azure-delete-nsg-rule
|
name: azure-delete-nsg-rule
|
||||||
description: "Delete an NSG security rule."
|
description: "Delete an NSG security rule."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
resource_group: { type: string, description: "Resource group name" }
|
resource_group: { type: string, description: "Resource group name" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: beyondtrust_password_safe
|
id: beyondtrust_password_safe
|
||||||
name: BeyondTrust Password Safe
|
name: BeyondTrust Password Safe
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "BeyondTrust Password Safe (Secrets Safe REST API v3) — privileged access and credential retrieval: list managed accounts and systems, request a credential release, and retrieve the credential. API-key (PS-Auth) session authentication; stdlib-only, no extra Python dependencies."
|
description: "BeyondTrust Password Safe (Secrets Safe REST API v3) — privileged access and credential retrieval: list managed accounts and systems, request a credential release, and retrieve the credential. API-key (PS-Auth) session authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list managed accounts/systems, create release request, get credential."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 1 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list managed accounts/systems, create release request, get credential."
|
||||||
category: identity
|
category: identity
|
||||||
|
|
||||||
# Per-instance configuration. Auth signs in with an API key + runas user
|
# Per-instance configuration. Auth signs in with an API key + runas user
|
||||||
@@ -50,6 +50,7 @@ commands:
|
|||||||
- id: create_release_request
|
- id: create_release_request
|
||||||
name: beyondtrust-create-release-request
|
name: beyondtrust-create-release-request
|
||||||
description: "Request a credential release for a managed account."
|
description: "Request a credential release for a managed account."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
system_id: { type: string, description: "Managed system ID" }
|
system_id: { type: string, description: "Managed system ID" }
|
||||||
@@ -61,6 +62,7 @@ commands:
|
|||||||
- id: get_credential
|
- id: get_credential
|
||||||
name: beyondtrust-get-credential
|
name: beyondtrust-get-credential
|
||||||
description: "Retrieve the credential for an approved request."
|
description: "Retrieve the credential for an approved request."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
request_id: { type: string, description: "Request ID (from create-release-request)" }
|
request_id: { type: string, description: "Request ID (from create-release-request)" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: box
|
id: box
|
||||||
name: Box
|
name: Box
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Box (Content API v2) — evidence and file handling: search files, read file/folder metadata, list a folder's items, and create a shared link. Bearer-token authentication; stdlib-only, no extra Python dependencies."
|
description: "Box (Content API v2) — evidence and file handling: search files, read file/folder metadata, list a folder's items, and create a shared link. Bearer-token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: search, get file/folder info, list folder items, create shared link."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: search, get file/folder info, list folder items, create shared link."
|
||||||
category: productivity
|
category: productivity
|
||||||
|
|
||||||
# Per-instance configuration. Auth header 'Authorization: Bearer <access_token>'.
|
# Per-instance configuration. Auth header 'Authorization: Bearer <access_token>'.
|
||||||
@@ -57,6 +57,7 @@ commands:
|
|||||||
- id: create_shared_link
|
- id: create_shared_link
|
||||||
name: box-create-shared-link
|
name: box-create-shared-link
|
||||||
description: "Create a shared link for a file."
|
description: "Create a shared link for a file."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "File ID" }
|
file_id: { type: string, description: "File ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cape
|
id: cape
|
||||||
name: CAPE Sandbox
|
name: CAPE Sandbox
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "CAPE Sandbox (APIv2) — dynamic malware analysis and config extraction: submit files and URLs, read task status and reports, and list tasks. Token authentication; stdlib-only, no extra Python dependencies."
|
description: "CAPE Sandbox (APIv2) — dynamic malware analysis and config extraction: submit files and URLs, read task status and reports, and list tasks. Token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: submit file/URL, get task, get report, list tasks."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 2 safe_write. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: submit file/URL, get task, get report, list tasks."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|
||||||
# Per-instance configuration. The token is sent as 'Authorization: Token <api_token>'.
|
# Per-instance configuration. The token is sent as 'Authorization: Token <api_token>'.
|
||||||
@@ -27,6 +27,7 @@ commands:
|
|||||||
- id: submit_file
|
- id: submit_file
|
||||||
name: cape-submit-file
|
name: cape-submit-file
|
||||||
description: "Submit a file (base64) for analysis."
|
description: "Submit a file (base64) for analysis."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_name: { type: string, description: "File name" }
|
file_name: { type: string, description: "File name" }
|
||||||
@@ -36,6 +37,7 @@ commands:
|
|||||||
- id: submit_url
|
- id: submit_url
|
||||||
name: cape-submit-url
|
name: cape-submit-url
|
||||||
description: "Submit a URL for analysis."
|
description: "Submit a URL for analysis."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
url: { type: string, description: "URL to detonate" }
|
url: { type: string, description: "URL to detonate" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: carbon_black_cloud
|
id: carbon_black_cloud
|
||||||
name: VMware Carbon Black Cloud
|
name: VMware Carbon Black Cloud
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "VMware Carbon Black Cloud (Platform API) — endpoint containment: search and read devices, quarantine/unquarantine an endpoint, trigger a background scan, update a device's policy, search alerts, and ban/unban a file hash (reputation override). API-token authentication; stdlib-only, no extra Python dependencies."
|
description: "VMware Carbon Black Cloud (Platform API) — endpoint containment: search and read devices, quarantine/unquarantine an endpoint, trigger a background scan, update a device's policy, search alerts, and ban/unban a file hash (reputation override). API-token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: device search/get, quarantine/unquarantine, background scan, update policy, alert search, ban hash, delete reputation override."
|
changelog: "1.1.0 — Declare the risk tier on the 6 commands that carried none: 1 safe_write, 5 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: device search/get, quarantine/unquarantine, background scan, update policy, alert search, ban hash, delete reputation override."
|
||||||
category: endpoint
|
category: endpoint
|
||||||
|
|
||||||
# Per-instance configuration. The API token is sent as
|
# Per-instance configuration. The API token is sent as
|
||||||
@@ -51,6 +51,7 @@ commands:
|
|||||||
- id: quarantine_device
|
- id: quarantine_device
|
||||||
name: cbc-quarantine-device
|
name: cbc-quarantine-device
|
||||||
description: "Quarantine (network-isolate) a device."
|
description: "Quarantine (network-isolate) a device."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
device_id: { type: string, description: "Device ID" }
|
device_id: { type: string, description: "Device ID" }
|
||||||
@@ -59,6 +60,7 @@ commands:
|
|||||||
- id: unquarantine_device
|
- id: unquarantine_device
|
||||||
name: cbc-unquarantine-device
|
name: cbc-unquarantine-device
|
||||||
description: "Remove a device from quarantine."
|
description: "Remove a device from quarantine."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
device_id: { type: string, description: "Device ID" }
|
device_id: { type: string, description: "Device ID" }
|
||||||
@@ -67,6 +69,7 @@ commands:
|
|||||||
- id: background_scan
|
- id: background_scan
|
||||||
name: cbc-background-scan
|
name: cbc-background-scan
|
||||||
description: "Start or stop a background scan on a device."
|
description: "Start or stop a background scan on a device."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
device_id: { type: string, description: "Device ID" }
|
device_id: { type: string, description: "Device ID" }
|
||||||
@@ -76,6 +79,7 @@ commands:
|
|||||||
- id: update_device_policy
|
- id: update_device_policy
|
||||||
name: cbc-update-device-policy
|
name: cbc-update-device-policy
|
||||||
description: "Assign a device to a different policy."
|
description: "Assign a device to a different policy."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
device_id: { type: string, description: "Device ID" }
|
device_id: { type: string, description: "Device ID" }
|
||||||
@@ -98,6 +102,7 @@ commands:
|
|||||||
- id: ban_hash
|
- id: ban_hash
|
||||||
name: cbc-ban-hash
|
name: cbc-ban-hash
|
||||||
description: "Ban a file by SHA-256 hash (reputation deny-list override)."
|
description: "Ban a file by SHA-256 hash (reputation deny-list override)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
sha256: { type: string, description: "SHA-256 hash to ban" }
|
sha256: { type: string, description: "SHA-256 hash to ban" }
|
||||||
@@ -108,6 +113,7 @@ commands:
|
|||||||
- id: delete_reputation_override
|
- id: delete_reputation_override
|
||||||
name: cbc-delete-reputation-override
|
name: cbc-delete-reputation-override
|
||||||
description: "Delete a reputation override (unban) by its ID."
|
description: "Delete a reputation override (unban) by its ID."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
override_id: { type: string, description: "Reputation override ID" }
|
override_id: { type: string, description: "Reputation override ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: checkpoint
|
id: checkpoint
|
||||||
name: Check Point
|
name: Check Point
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Check Point Management (Web API) — firewall containment: list and add host objects, view the access rulebase, add a drop rule (block), publish changes, and install policy. Session (login) authentication; stdlib-only, no extra Python dependencies."
|
description: "Check Point Management (Web API) — firewall containment: list and add host objects, view the access rulebase, add a drop rule (block), publish changes, and install policy. Session (login) authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: show/add hosts, show access rulebase, add access rule (drop), publish, install policy."
|
changelog: "1.1.0 — Declare the risk tier on the 4 commands that carried none: 1 safe_write, 3 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: show/add hosts, show access rulebase, add access rule (drop), publish, install policy."
|
||||||
category: network
|
category: network
|
||||||
|
|
||||||
# Per-instance configuration. Each command logs in to the Management API
|
# Per-instance configuration. Each command logs in to the Management API
|
||||||
@@ -45,6 +45,7 @@ commands:
|
|||||||
- id: add_host
|
- id: add_host
|
||||||
name: checkpoint-add-host
|
name: checkpoint-add-host
|
||||||
description: "Create a host object."
|
description: "Create a host object."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Host object name" }
|
name: { type: string, description: "Host object name" }
|
||||||
@@ -64,6 +65,7 @@ commands:
|
|||||||
- id: add_access_rule
|
- id: add_access_rule
|
||||||
name: checkpoint-add-access-rule
|
name: checkpoint-add-access-rule
|
||||||
description: "Add an access rule to a layer (e.g. a Drop rule to block a source)."
|
description: "Add an access rule to a layer (e.g. a Drop rule to block a source)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
layer: { type: string, description: "Access layer name" }
|
layer: { type: string, description: "Access layer name" }
|
||||||
@@ -77,6 +79,7 @@ commands:
|
|||||||
- id: publish
|
- id: publish
|
||||||
name: checkpoint-publish
|
name: checkpoint-publish
|
||||||
description: "Publish the current session's changes."
|
description: "Publish the current session's changes."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties: {}
|
properties: {}
|
||||||
required: []
|
required: []
|
||||||
@@ -84,6 +87,7 @@ commands:
|
|||||||
- id: install_policy
|
- id: install_policy
|
||||||
name: checkpoint-install-policy
|
name: checkpoint-install-policy
|
||||||
description: "Install a policy package on gateway targets."
|
description: "Install a policy package on gateway targets."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
policy_package: { type: string, description: "Policy package name" }
|
policy_package: { type: string, description: "Policy package name" }
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
id: circl_hashlookup
|
||||||
|
name: CIRCL hashlookup
|
||||||
|
version: 1.0.0
|
||||||
|
description: "CIRCL hashlookup — tells whether a file hash belongs to known software: NSRL, Linux distribution packages, Windows updates and other trusted sets (6.5 billion hashes). The fastest way to rule out a false positive on a file alert. Free public service, no API key; the server is open source and can be self-hosted (set base_url). Stdlib-only. As an enrichment rule: command lookup_hash, score field `score`, benign threshold 0 and malicious threshold 100 — a known file reads benign, a record CIRCL flags as KnownMalicious reads malicious, and an unknown hash is an answer (not found), never a failure. Add the summary `Known file ${FileName} (${source})` (Riposte 0.299+) to see which file it is."
|
||||||
|
changelog: "1.0.0 — Initial release: single lookup (MD5, SHA-1, SHA-256), bulk lookup (MD5, SHA-1), test connection."
|
||||||
|
category: enrichment
|
||||||
|
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "hashlookup server (default https://hashlookup.circl.lu — set it to a self-hosted instance to keep hashes in-house)"
|
||||||
|
default: https://hashlookup.circl.lu
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure — a self-hosted instance with a private CA only)"
|
||||||
|
default: false
|
||||||
|
required: []
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: lookup_hash
|
||||||
|
name: hashlookup-lookup-hash
|
||||||
|
description: "Look up one MD5, SHA-1 or SHA-256 hash. Returns the CIRCL record (file name, product, source set, hashlookup:trust) plus `known: true` and `score` (0 = known file, 100 = flagged KnownMalicious). An unknown hash returns HTTP 404, which Riposte's enrichment reads as 'no record'."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
hash: { type: string, description: "MD5 (32 hex), SHA-1 (40 hex) or SHA-256 (64 hex)" }
|
||||||
|
required: [hash]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: bulk_lookup
|
||||||
|
name: hashlookup-bulk-lookup
|
||||||
|
description: "Look up many MD5 / SHA-1 hashes at once (the bulk API does not take SHA-256; those are looked up one by one). Returns `known` (records found) and `unknown` (hashes CIRCL has no record of)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
hashes: { type: string, description: "Hashes separated by commas, spaces or new lines (a JSON array also works)" }
|
||||||
|
required: [hashes]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: hashlookup-test-connection
|
||||||
|
description: "Reach the server's /info endpoint (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import json, os, re, ssl, sys, urllib.error, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://hashlookup.circl.lu"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(cfg, path, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "riposte-soar"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(_base(cfg) + path, data=data, headers=headers, method="POST" if data is not None else "GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
KINDS = {32: "md5", 40: "sha1", 64: "sha256"}
|
||||||
|
|
||||||
|
|
||||||
|
def kind_of(h):
|
||||||
|
h = h.strip().lower()
|
||||||
|
if not re.fullmatch(r"[0-9a-f]+", h) or len(h) not in KINDS:
|
||||||
|
raise Exception("not an MD5, SHA-1 or SHA-256 hash: " + h)
|
||||||
|
return KINDS[len(h)], h
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(rec):
|
||||||
|
# score: 0 for a known file, 100 when CIRCL itself flags the record as
|
||||||
|
# malicious — what an enrichment rule maps with benign 0 / malicious 100.
|
||||||
|
rec = dict(rec)
|
||||||
|
rec["known"] = True
|
||||||
|
rec["score"] = 100 if rec.get("KnownMalicious") else 0
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def parse_list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return [str(x) for x in v]
|
||||||
|
s = str(v or "").strip()
|
||||||
|
if s.startswith("["):
|
||||||
|
return [str(x) for x in json.loads(s)]
|
||||||
|
return [x for x in re.split(r"[\s,;]+", s) if x]
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
groups = {"md5": [], "sha1": [], "sha256": []}
|
||||||
|
for raw in parse_list(inputs.get("hashes")):
|
||||||
|
kind, h = kind_of(raw)
|
||||||
|
if h not in groups[kind]:
|
||||||
|
groups[kind].append(h)
|
||||||
|
known, unknown = [], []
|
||||||
|
for kind in ("md5", "sha1"):
|
||||||
|
hs = groups[kind]
|
||||||
|
for i in range(0, len(hs), 500):
|
||||||
|
chunk = hs[i:i + 500]
|
||||||
|
res = call(cfg, "/bulk/" + kind, {"hashes": chunk})
|
||||||
|
found = set()
|
||||||
|
for rec in res if isinstance(res, list) else []:
|
||||||
|
key = str(rec.get("MD5" if kind == "md5" else "SHA-1", "")).lower()
|
||||||
|
if key and "message" not in rec:
|
||||||
|
found.add(key)
|
||||||
|
known.append(annotate(rec))
|
||||||
|
unknown.extend(h for h in chunk if h not in found)
|
||||||
|
for h in groups["sha256"]:
|
||||||
|
try:
|
||||||
|
known.append(annotate(call(cfg, "/lookup/sha256/" + h)))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code != 404:
|
||||||
|
raise
|
||||||
|
unknown.append(h)
|
||||||
|
return {"known": known, "unknown": unknown, "known_count": len(known), "unknown_count": len(unknown)}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import json, os, re, ssl, sys, urllib.error, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://hashlookup.circl.lu"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(cfg, path, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "riposte-soar"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(_base(cfg) + path, data=data, headers=headers, method="POST" if data is not None else "GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
KINDS = {32: "md5", 40: "sha1", 64: "sha256"}
|
||||||
|
|
||||||
|
|
||||||
|
def kind_of(h):
|
||||||
|
h = h.strip().lower()
|
||||||
|
if not re.fullmatch(r"[0-9a-f]+", h) or len(h) not in KINDS:
|
||||||
|
raise Exception("not an MD5, SHA-1 or SHA-256 hash: " + h)
|
||||||
|
return KINDS[len(h)], h
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(rec):
|
||||||
|
# score: 0 for a known file, 100 when CIRCL itself flags the record as
|
||||||
|
# malicious — what an enrichment rule maps with benign 0 / malicious 100.
|
||||||
|
rec = dict(rec)
|
||||||
|
rec["known"] = True
|
||||||
|
rec["score"] = 100 if rec.get("KnownMalicious") else 0
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
kind, h = kind_of(str(inputs.get("hash", "")))
|
||||||
|
return annotate(call(cfg, "/lookup/%s/%s" % (kind, h)))
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import json, os, re, ssl, sys, urllib.error, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://hashlookup.circl.lu"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(cfg, path, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "riposte-soar"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(_base(cfg) + path, data=data, headers=headers, method="POST" if data is not None else "GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
KINDS = {32: "md5", 40: "sha1", 64: "sha256"}
|
||||||
|
|
||||||
|
|
||||||
|
def kind_of(h):
|
||||||
|
h = h.strip().lower()
|
||||||
|
if not re.fullmatch(r"[0-9a-f]+", h) or len(h) not in KINDS:
|
||||||
|
raise Exception("not an MD5, SHA-1 or SHA-256 hash: " + h)
|
||||||
|
return KINDS[len(h)], h
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(rec):
|
||||||
|
# score: 0 for a known file, 100 when CIRCL itself flags the record as
|
||||||
|
# malicious — what an enrichment rule maps with benign 0 / malicious 100.
|
||||||
|
rec = dict(rec)
|
||||||
|
rec["known"] = True
|
||||||
|
rec["score"] = 100 if rec.get("KnownMalicious") else 0
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
info = call(cfg, "/info")
|
||||||
|
return {"ok": True, "info": info}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cisco_fmc
|
id: cisco_fmc
|
||||||
name: Cisco Firepower Management Center
|
name: Cisco Firepower Management Center
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Cisco Secure Firewall Management Center (FMC REST API) — firewall containment: list and create/delete network host objects, list access policies and rules, and add a block access rule. Token authentication (generatetoken); stdlib-only, no extra Python dependencies."
|
description: "Cisco Secure Firewall Management Center (FMC REST API) — firewall containment: list and create/delete network host objects, list access policies and rules, and add a block access rule. Token authentication (generatetoken); stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list/create/delete host objects, list access policies/rules, create block access rule."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none: 1 safe_write, 2 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list/create/delete host objects, list access policies/rules, create block access rule."
|
||||||
category: network
|
category: network
|
||||||
|
|
||||||
# Per-instance configuration. Auth calls generatetoken with HTTP Basic and reuses
|
# Per-instance configuration. Auth calls generatetoken with HTTP Basic and reuses
|
||||||
@@ -41,6 +41,7 @@ commands:
|
|||||||
- id: create_host_object
|
- id: create_host_object
|
||||||
name: fmc-create-host-object
|
name: fmc-create-host-object
|
||||||
description: "Create a network host object."
|
description: "Create a network host object."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Object name" }
|
name: { type: string, description: "Object name" }
|
||||||
@@ -51,6 +52,7 @@ commands:
|
|||||||
- id: delete_host_object
|
- id: delete_host_object
|
||||||
name: fmc-delete-host-object
|
name: fmc-delete-host-object
|
||||||
description: "Delete a network host object by ID."
|
description: "Delete a network host object by ID."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
object_id: { type: string, description: "Host object ID" }
|
object_id: { type: string, description: "Host object ID" }
|
||||||
@@ -78,6 +80,7 @@ commands:
|
|||||||
- id: create_access_rule
|
- id: create_access_rule
|
||||||
name: fmc-create-access-rule
|
name: fmc-create-access-rule
|
||||||
description: "Add an access rule to a policy (e.g. a BLOCK rule for source/destination network objects)."
|
description: "Add an access rule to a policy (e.g. a BLOCK rule for source/destination network objects)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
policy_id: { type: string, description: "Access control policy ID" }
|
policy_id: { type: string, description: "Access control policy ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cisco_ise
|
id: cisco_ise
|
||||||
name: Cisco ISE
|
name: Cisco ISE
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Cisco Identity Services Engine (ERS + ANC API) — network access containment: list and read endpoints, apply an Adaptive Network Control (ANC) quarantine policy to an endpoint, and clear it. HTTP Basic authentication; stdlib-only, no extra Python dependencies."
|
description: "Cisco Identity Services Engine (ERS + ANC API) — network access containment: list and read endpoints, apply an Adaptive Network Control (ANC) quarantine policy to an endpoint, and clear it. HTTP Basic authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list/get endpoints, apply/clear ANC policy, list ANC policies."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: list/get endpoints, apply/clear ANC policy, list ANC policies."
|
||||||
category: network
|
category: network
|
||||||
|
|
||||||
# Per-instance configuration. HTTP Basic auth against the ERS API (port 9060).
|
# Per-instance configuration. HTTP Basic auth against the ERS API (port 9060).
|
||||||
@@ -49,6 +49,7 @@ commands:
|
|||||||
- id: apply_anc_policy
|
- id: apply_anc_policy
|
||||||
name: ise-apply-anc-policy
|
name: ise-apply-anc-policy
|
||||||
description: "Apply an ANC policy to an endpoint by MAC (quarantine — containment)."
|
description: "Apply an ANC policy to an endpoint by MAC (quarantine — containment)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
mac: { type: string, description: "Endpoint MAC address" }
|
mac: { type: string, description: "Endpoint MAC address" }
|
||||||
@@ -58,6 +59,7 @@ commands:
|
|||||||
- id: clear_anc_policy
|
- id: clear_anc_policy
|
||||||
name: ise-clear-anc-policy
|
name: ise-clear-anc-policy
|
||||||
description: "Clear the ANC policy from an endpoint by MAC."
|
description: "Clear the ANC policy from an endpoint by MAC."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
mac: { type: string, description: "Endpoint MAC address" }
|
mac: { type: string, description: "Endpoint MAC address" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cisco_secure_endpoint
|
id: cisco_secure_endpoint
|
||||||
name: Cisco Secure Endpoint
|
name: Cisco Secure Endpoint
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Cisco Secure Endpoint (AMP for Endpoints API v1) — endpoint containment: list and read computers, isolate/unisolate a host, list events, and read a computer's trajectory. HTTP Basic authentication; stdlib-only, no extra Python dependencies."
|
description: "Cisco Secure Endpoint (AMP for Endpoints API v1) — endpoint containment: list and read computers, isolate/unisolate a host, list events, and read a computer's trajectory. HTTP Basic authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list/get computers, isolate/stop-isolation, list events, get trajectory."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: list/get computers, isolate/stop-isolation, list events, get trajectory."
|
||||||
category: endpoint
|
category: endpoint
|
||||||
|
|
||||||
# Per-instance configuration. HTTP Basic auth with the API client ID + key.
|
# Per-instance configuration. HTTP Basic auth with the API client ID + key.
|
||||||
@@ -46,6 +46,7 @@ commands:
|
|||||||
- id: isolate_computer
|
- id: isolate_computer
|
||||||
name: cisco-amp-isolate-computer
|
name: cisco-amp-isolate-computer
|
||||||
description: "Isolate a computer from the network (containment)."
|
description: "Isolate a computer from the network (containment)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
connector_guid: { type: string, description: "Connector GUID" }
|
connector_guid: { type: string, description: "Connector GUID" }
|
||||||
@@ -55,6 +56,7 @@ commands:
|
|||||||
- id: stop_isolation
|
- id: stop_isolation
|
||||||
name: cisco-amp-stop-isolation
|
name: cisco-amp-stop-isolation
|
||||||
description: "Stop isolation on a computer."
|
description: "Stop isolation on a computer."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
connector_guid: { type: string, description: "Connector GUID" }
|
connector_guid: { type: string, description: "Connector GUID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cisco_umbrella_enforcement
|
id: cisco_umbrella_enforcement
|
||||||
name: Cisco Umbrella Enforcement
|
name: Cisco Umbrella Enforcement
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Cisco Umbrella Enforcement API — DNS-layer containment: push malicious domains to the enforcement block list, list enforced domains, and remove them. Customer-key authentication; stdlib-only, no extra Python dependencies."
|
description: "Cisco Umbrella Enforcement API — DNS-layer containment: push malicious domains to the enforcement block list, list enforced domains, and remove them. Customer-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: add domain event (block), list domains, delete domain."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: add domain event (block), list domains, delete domain."
|
||||||
category: network
|
category: network
|
||||||
|
|
||||||
# Per-instance configuration. The customer key is appended as the 'customerKey'
|
# Per-instance configuration. The customer key is appended as the 'customerKey'
|
||||||
@@ -24,6 +24,7 @@ commands:
|
|||||||
- id: add_domain
|
- id: add_domain
|
||||||
name: umbrella-add-domain
|
name: umbrella-add-domain
|
||||||
description: "Submit a security event that adds a domain to the Umbrella enforcement block list."
|
description: "Submit a security event that adds a domain to the Umbrella enforcement block list."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
domain: { type: string, description: "Domain to block (e.g. malicious.example.com)" }
|
domain: { type: string, description: "Domain to block (e.g. malicious.example.com)" }
|
||||||
@@ -46,6 +47,7 @@ commands:
|
|||||||
- id: delete_domain
|
- id: delete_domain
|
||||||
name: umbrella-delete-domain
|
name: umbrella-delete-domain
|
||||||
description: "Remove a domain from the enforcement block list by its numeric id."
|
description: "Remove a domain from the enforcement block list by its numeric id."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
domain_id: { type: string, description: "Numeric id of the domain to remove (from umbrella-list-domains)" }
|
domain_id: { type: string, description: "Numeric id of the domain to remove (from umbrella-list-domains)" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cloudflare
|
id: cloudflare
|
||||||
name: Cloudflare
|
name: Cloudflare
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Cloudflare (client API v4) — network containment at the edge: list zones, block/unblock IPs (firewall access rules), list access rules, and manage DNS records (list/create/delete). API-token (Bearer) authentication; stdlib-only, no extra Python dependencies."
|
description: "Cloudflare (client API v4) — network containment at the edge: list zones, block/unblock IPs (firewall access rules), list access rules, and manage DNS records (list/create/delete). API-token (Bearer) authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list/get zones, block IP, list/delete access rules, list/create/delete DNS records."
|
changelog: "1.1.0 — Declare the risk tier on the 4 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: list/get zones, block IP, list/delete access rules, list/create/delete DNS records."
|
||||||
category: network
|
category: network
|
||||||
|
|
||||||
# Per-instance configuration. The API token is sent as 'Authorization: Bearer <api_token>'.
|
# Per-instance configuration. The API token is sent as 'Authorization: Bearer <api_token>'.
|
||||||
@@ -37,6 +37,7 @@ commands:
|
|||||||
- id: block_ip
|
- id: block_ip
|
||||||
name: cloudflare-block-ip
|
name: cloudflare-block-ip
|
||||||
description: "Create a firewall access rule to block (or challenge) an IP, range, ASN, or country."
|
description: "Create a firewall access rule to block (or challenge) an IP, range, ASN, or country."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
zone_id: { type: string, description: "Zone ID" }
|
zone_id: { type: string, description: "Zone ID" }
|
||||||
@@ -58,6 +59,7 @@ commands:
|
|||||||
- id: delete_access_rule
|
- id: delete_access_rule
|
||||||
name: cloudflare-delete-access-rule
|
name: cloudflare-delete-access-rule
|
||||||
description: "Delete a firewall access rule (unblock)."
|
description: "Delete a firewall access rule (unblock)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
zone_id: { type: string, description: "Zone ID" }
|
zone_id: { type: string, description: "Zone ID" }
|
||||||
@@ -78,6 +80,7 @@ commands:
|
|||||||
- id: create_dns_record
|
- id: create_dns_record
|
||||||
name: cloudflare-create-dns-record
|
name: cloudflare-create-dns-record
|
||||||
description: "Create a DNS record (e.g. sinkhole a malicious host)."
|
description: "Create a DNS record (e.g. sinkhole a malicious host)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
zone_id: { type: string, description: "Zone ID" }
|
zone_id: { type: string, description: "Zone ID" }
|
||||||
@@ -91,6 +94,7 @@ commands:
|
|||||||
- id: delete_dns_record
|
- id: delete_dns_record
|
||||||
name: cloudflare-delete-dns-record
|
name: cloudflare-delete-dns-record
|
||||||
description: "Delete a DNS record."
|
description: "Delete a DNS record."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
zone_id: { type: string, description: "Zone ID" }
|
zone_id: { type: string, description: "Zone ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: confluence
|
id: confluence
|
||||||
name: Confluence
|
name: Confluence
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Atlassian Confluence (REST API) — runbook and knowledge handling: search content (CQL), read a page, create and update pages, and list spaces. Basic (email + API token) authentication; stdlib-only, no extra Python dependencies."
|
description: "Atlassian Confluence (REST API) — runbook and knowledge handling: search content (CQL), read a page, create and update pages, and list spaces. Basic (email + API token) authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: search content, get page, create/update page, list spaces."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 2 safe_write. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: search content, get page, create/update page, list spaces."
|
||||||
category: productivity
|
category: productivity
|
||||||
|
|
||||||
# Per-instance configuration. HTTP Basic auth with an email + API token (Cloud)
|
# Per-instance configuration. HTTP Basic auth with an email + API token (Cloud)
|
||||||
@@ -47,6 +47,7 @@ commands:
|
|||||||
- id: create_page
|
- id: create_page
|
||||||
name: confluence-create-page
|
name: confluence-create-page
|
||||||
description: "Create a page in a space."
|
description: "Create a page in a space."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
space_key: { type: string, description: "Space key" }
|
space_key: { type: string, description: "Space key" }
|
||||||
@@ -58,6 +59,7 @@ commands:
|
|||||||
- id: update_page
|
- id: update_page
|
||||||
name: confluence-update-page
|
name: confluence-update-page
|
||||||
description: "Update a page's title/body (increments the version)."
|
description: "Update a page's title/body (increments the version)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
page_id: { type: string, description: "Page ID" }
|
page_id: { type: string, description: "Page ID" }
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# The Cortex XDR operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design: Riposte
|
||||||
|
# refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Synapse
|
||||||
|
title: Cortex XDR operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 12
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Anything Cortex XDR can tell about an endpoint: the incident behind a Cortex
|
||||||
|
alert and every alert in it (process, command line, parent, file and
|
||||||
|
network artifacts, MITRE), the raw events of an alert, the endpoint's
|
||||||
|
details and policy, whether a file is quarantined, the risk score of a user
|
||||||
|
or host, and XQL hunting in xdr_data (same hash, host or user elsewhere).
|
||||||
|
Not for other vendors' alerts, and never for isolation, scripts or file
|
||||||
|
retrieval: it only reads.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id or the Cortex XDR incident id. For a pivot without an
|
||||||
|
alert: the hostname, a SHA-256, a user name, and a time window.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Cortex XDR, each with the command that produced it; "not found"
|
||||||
|
when a lookup came back empty; the exact query and error when one failed.
|
||||||
|
No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 12 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call). Its source_ref is the incident_id (a
|
||||||
|
short number). hosts entries read "<hostname>:<endpoint id>": the
|
||||||
|
hostname is the part before the colon.
|
||||||
|
If the raw fields are ONE alert (internal_id, alert_name), skip 2 and
|
||||||
|
call get_original_alerts(alert_id_list=<internal_id>).
|
||||||
|
2. get_incident_extra_data(incident_id, alerts_limit=50) (1 call): the
|
||||||
|
alerts (agent_hostname, actor process, command line, action_file_sha256,
|
||||||
|
user) and the file/network artifacts.
|
||||||
|
3. get_original_alerts(alert_id_list=<alert_id>) (1 call) only when an
|
||||||
|
alert's own fields do not answer; ids from step 2 only.
|
||||||
|
4. Host: get_endpoints(hostname=<name>) (1 call) — the name, not the id.
|
||||||
|
get_policy(endpoint_id) only if asked.
|
||||||
|
5. get_risk_score(id=<user or host as the data spells it>) (1 call) when a
|
||||||
|
user is involved. 404 or empty = "no risk score", not an error.
|
||||||
|
6. get_quarantine_status(endpoint_id, file_path, file_hash) needs all
|
||||||
|
three from the alert; file_hash is SHA-256.
|
||||||
|
7. ALWAYS keep 2-3 calls for "elsewhere" with xql_query. The manifest's
|
||||||
|
form is:
|
||||||
|
dataset = xdr_data | filter event_type = ENUM.PROCESS | fields agent_hostname, action_process_image_name
|
||||||
|
Change only the filter: action_file_sha256 = "<sha256>" for the hash
|
||||||
|
on other hosts, agent_hostname = "<name>" for the host. Values in
|
||||||
|
double quotes. Keep fields short, limit 50.
|
||||||
|
Window: time_frame as "<number> <unit>" (e.g. "7 days"). Empty means
|
||||||
|
the last 24 hours only, not all of time. Or from_time / to_time copied
|
||||||
|
exactly from the incident's timestamps. Never compute dates.
|
||||||
|
status PENDING is not a failure: call xql_get_query_results with the
|
||||||
|
execution_id it returned. Never rerun the query: each run costs quota.
|
||||||
|
A syntax error: report the exact query and error, one fix at most.
|
||||||
|
|
||||||
|
Ids: endpoint ids are 32 hex characters, alert ids are numbers. Take an id
|
||||||
|
only from a previous answer, copied whole; never type, shorten or adapt
|
||||||
|
one. Prefer hostnames and user names.
|
||||||
|
Time: pass timestamps exactly as the data gives them (epoch ms or ISO8601).
|
||||||
|
On an error: report the exact call and error, try one variant at most.
|
||||||
|
|
||||||
|
In your answer: the incident (name, status, severity, MITRE), each alert's
|
||||||
|
process, command line, user, file and hash, the network artifacts, the
|
||||||
|
host, and what XQL found and in which window — with each fact's command.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- get_incident_extra_data
|
||||||
|
- get_original_alerts
|
||||||
|
- get_endpoints
|
||||||
|
- get_policy
|
||||||
|
- get_risk_score
|
||||||
|
- get_quarantine_status
|
||||||
|
- xql_query
|
||||||
|
- xql_get_query_results
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
File diff suppressed because one or more lines are too long
@@ -23,11 +23,13 @@ ocsf:
|
|||||||
- { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" }
|
- { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" }
|
||||||
- { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" }
|
- { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" }
|
||||||
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
|
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
|
||||||
# ── Detection time: the MTTD anchor ───────────────────────────────
|
# ── Event time (OCSF `time`) ───────────────────────────────────────
|
||||||
# `time` is what Riposte measures detection-to-ingestion against. The grid
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
||||||
# exposes when the tenant took the alert in (source_insert_ts); a sensor-side
|
# starts when Riposte records the alert. It is the alert's own record of when
|
||||||
# detection timestamp, when the tenant sends one, is the better anchor and
|
# the source saw the event.
|
||||||
# comes last so it wins.
|
# The grid exposes when the tenant took the alert in (source_insert_ts); a
|
||||||
|
# sensor-side detection timestamp, when the tenant sends one, is closer to the
|
||||||
|
# event and comes last so it wins.
|
||||||
- { source_path: "source_insert_ts", ocsf_field: "time" }
|
- { source_path: "source_insert_ts", ocsf_field: "time" }
|
||||||
- { source_path: "detection_timestamp", ocsf_field: "time" }
|
- { source_path: "detection_timestamp", ocsf_field: "time" }
|
||||||
# ── Alert state ───────────────────────────────────────────────────
|
# ── Alert state ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ ocsf:
|
|||||||
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
||||||
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
||||||
- { source_path: "status", ocsf_field: "status" }
|
- { source_path: "status", ocsf_field: "status" }
|
||||||
# `time` is the MTTD anchor — when XDR opened the incident, as opposed to when
|
# `time` is the OCSF event time — when XDR opened the incident, as opposed to
|
||||||
# Riposte ingested it. Without it the detection delay column stays empty.
|
# when Riposte ingested it.
|
||||||
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
||||||
|
# starts when Riposte records the alert. It is the alert's own record of when
|
||||||
|
# the source saw the event.
|
||||||
- { source_path: "creation_time", ocsf_field: "time" }
|
- { source_path: "creation_time", ocsf_field: "time" }
|
||||||
- { source_path: "alert_count", ocsf_field: "count" }
|
- { source_path: "alert_count", ocsf_field: "count" }
|
||||||
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
|
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
name: "Cortex XDR Incidents (full) → OCSF"
|
name: "Cortex XDR Incidents (full) → OCSF"
|
||||||
description: "Maps one Cortex XDR incident fetched with its alerts and artifacts (incidents/get_multiple_incidents_extra_data/, results_path = reply.incidents) to OCSF finding fields. The script flattens the record, so incident fields sit at the top level — every expression written against get_incidents keeps working — while alerts[], file_artifacts[] and network_artifacts[] are plain lists beside them. Incident-level values are mapped first and the first alert's equivalents last, so the alert wins wherever the aggregate says nothing."
|
description: "Maps one Cortex XDR incident fetched with its alerts and artifacts (incidents/get_multiple_incidents_extra_data/, results_path = reply.incidents) to OCSF finding fields. The script flattens the record, so incident fields sit at the top level — every expression written against get_incidents keeps working — while alerts[], file_artifacts[] and network_artifacts[] are plain lists beside them. Incident-level values are mapped first and the first alert's equivalents last, so the alert wins wherever the aggregate says nothing."
|
||||||
field_mappings:
|
field_mappings:
|
||||||
# incident_name is null on most tenants (it is only set when someone renames
|
# The title is the rule that fired, the description the sentence XDR writes
|
||||||
# the incident), and a mapping that resolves to nothing leaves the title to
|
# about the incident. Both used to read `description`, so a ticket showed the
|
||||||
# the incident type's fallback. description is the sentence XDR itself shows.
|
# same sentence twice and its title ran to the host and user it already names.
|
||||||
title: "description"
|
# incident_name wins when someone renamed the incident in the console; the
|
||||||
description: "description"
|
# count says the rule is only the first of several detections folded in.
|
||||||
|
title: "jsonata: incident_name ? incident_name : alerts[0].name ? alerts[0].name & (alert_count > 1 ? ' (+' & $string(alert_count - 1) & ' other alerts)' : '') : description"
|
||||||
|
# XDR's sentence first (rule, sensor, host, user), then what each distinct
|
||||||
|
# rule means — the incident sentence names a rule without explaining it.
|
||||||
|
description: "jsonata: $join($distinct([description ? description, alerts[name and description].(name & ' — ' & description)]), '\\n\\n')"
|
||||||
severity: "severity"
|
severity: "severity"
|
||||||
# incident_source, not incident_sources[0]: this mapper reads dotted paths and
|
# incident_source rather than incident_sources[0], kept for the tenants whose
|
||||||
# cannot index a list, so the script lifts the first sensor out for it.
|
# mapper copy predates the jsonata: prefix; the script still lifts it out.
|
||||||
source: "incident_source"
|
source: "incident_source"
|
||||||
# results_path = reply.incidents; source_path is JSONata over ONE flattened
|
# results_path = reply.incidents; source_path is JSONata over ONE flattened
|
||||||
# incident. Paths absent from a given incident are skipped at ingestion, so
|
# incident. Paths absent from a given incident are skipped at ingestion, so
|
||||||
@@ -17,17 +21,24 @@ field_mappings:
|
|||||||
ocsf:
|
ocsf:
|
||||||
# ── Finding ───────────────────────────────────────────────────────
|
# ── Finding ───────────────────────────────────────────────────────
|
||||||
- { source_path: "incident_id", ocsf_field: "finding_info.uid" }
|
- { source_path: "incident_id", ocsf_field: "finding_info.uid" }
|
||||||
- { source_path: "incident_name ? incident_name : description", ocsf_field: "finding_info.title" }
|
- { source_path: "incident_name ? incident_name : alerts[0].name ? alerts[0].name : description", ocsf_field: "finding_info.title" }
|
||||||
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
||||||
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
|
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
|
||||||
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
||||||
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
||||||
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
|
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
|
||||||
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
|
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
|
||||||
|
# What the rule means, and its id — the handle for an exclusion or a tuning
|
||||||
|
# ticket, which the rule name alone does not give.
|
||||||
|
- { source_path: "alerts[0].description", ocsf_field: "finding_info.analytic.desc" }
|
||||||
|
- { source_path: "alerts[0].matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
|
||||||
# The detection that opened the incident, so an analyst reading the aggregate
|
# The detection that opened the incident, so an analyst reading the aggregate
|
||||||
# can still pivot to the single alert in the XDR console.
|
# can still pivot to the single alert in the XDR console.
|
||||||
- { source_path: "alerts[0].alert_id", ocsf_field: "finding_info.uid_alt" }
|
- { source_path: "alerts[0].alert_id", ocsf_field: "finding_info.uid_alt" }
|
||||||
# ── Detection time: the MTTD anchor ───────────────────────────────
|
# ── Event time (OCSF `time`) ───────────────────────────────────────
|
||||||
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
||||||
|
# starts when Riposte records the alert. It is the alert's own record of when
|
||||||
|
# the source saw the event.
|
||||||
# Weakest first, strongest last. detection_time is often null on an XDR
|
# Weakest first, strongest last. detection_time is often null on an XDR
|
||||||
# incident, and then the first alert's own detection timestamp is the honest
|
# incident, and then the first alert's own detection timestamp is the honest
|
||||||
# anchor; incident creation is the last resort.
|
# anchor; incident creation is the last resort.
|
||||||
@@ -41,6 +52,9 @@ ocsf:
|
|||||||
- { source_path: "alert_count", ocsf_field: "count" }
|
- { source_path: "alert_count", ocsf_field: "count" }
|
||||||
- { source_path: "aggregated_score", ocsf_field: "risk_score" }
|
- { source_path: "aggregated_score", ocsf_field: "risk_score" }
|
||||||
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
||||||
|
# Who already holds the incident on the XDR side, so nobody picks it up twice.
|
||||||
|
- { source_path: "assigned_user_pretty_name", ocsf_field: "assignee.name" }
|
||||||
|
- { source_path: "assigned_user_mail", ocsf_field: "assignee.email_addr" }
|
||||||
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
|
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
|
||||||
# What kind of activity the sensor saw ("Process Execution", "Network
|
# What kind of activity the sensor saw ("Process Execution", "Network
|
||||||
# Connection"…). It is what tells an analyst, before opening anything, whether
|
# Connection"…). It is what tells an analyst, before opening anything, whether
|
||||||
@@ -216,16 +230,28 @@ ocsf:
|
|||||||
- { source_path: "alerts[0].action_registry_data", ocsf_field: "reg_value.data" }
|
- { source_path: "alerts[0].action_registry_data", ocsf_field: "reg_value.data" }
|
||||||
- { source_path: "alerts[0].dns_query_name", ocsf_field: "dns_query.hostname" }
|
- { source_path: "alerts[0].dns_query_name", ocsf_field: "dns_query.hostname" }
|
||||||
- { source_path: "alerts[0].user_agent", ocsf_field: "http_request.user_agent" }
|
- { source_path: "alerts[0].user_agent", ocsf_field: "http_request.user_agent" }
|
||||||
|
# The network artifacts come first, as a fallback only: they are the
|
||||||
|
# incident's unordered set of destinations, and network_artifacts[0] used to
|
||||||
|
# come last and overwrite the alert's own flow piecemeal — an artifact's
|
||||||
|
# hostname and IP next to the alert's port, a destination the alert never
|
||||||
|
# connected to.
|
||||||
|
- { source_path: "network_artifacts[0].network_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
||||||
|
- { source_path: "network_artifacts[0].network_remote_port", ocsf_field: "dst_endpoint.port" }
|
||||||
|
- { source_path: "network_artifacts[0].network_domain", ocsf_field: "dst_endpoint.hostname" }
|
||||||
|
- { source_path: "network_artifacts[0].network_country", ocsf_field: "dst_endpoint.location.country" }
|
||||||
- { source_path: "alerts[0].action_local_ip", ocsf_field: "src_endpoint.ip" }
|
- { source_path: "alerts[0].action_local_ip", ocsf_field: "src_endpoint.ip" }
|
||||||
- { source_path: "alerts[0].action_local_port", ocsf_field: "src_endpoint.port" }
|
- { source_path: "alerts[0].action_local_port", ocsf_field: "src_endpoint.port" }
|
||||||
- { source_path: "alerts[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
- { source_path: "alerts[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
||||||
- { source_path: "alerts[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
|
- { source_path: "alerts[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
|
||||||
- { source_path: "alerts[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
|
- { source_path: "alerts[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
|
||||||
|
# The alert's action_country is UNKNOWN on most network alerts too; the
|
||||||
|
# artifact XDR built for the alert's own remote IP carries the geolocation.
|
||||||
|
- { source_path: "($ip := alerts[0].action_remote_ip; $ip ? network_artifacts[network_remote_ip = $ip][0].network_country)", ocsf_field: "dst_endpoint.location.country" }
|
||||||
# XDR writes the literal "UNKNOWN" rather than nothing when an alert has no
|
# XDR writes the literal "UNKNOWN" rather than nothing when an alert has no
|
||||||
# network dimension at all, and mapping it would put a destination country on
|
# network dimension at all, and mapping it would put a destination country on
|
||||||
# a process-execution incident that never had a destination.
|
# a process-execution incident that never had a destination.
|
||||||
- { source_path: 'alerts[0].action_country != ''UNKNOWN'' ? alerts[0].action_country', ocsf_field: "dst_endpoint.location.country" }
|
- { source_path: 'alerts[0].action_country != ''UNKNOWN'' ? alerts[0].action_country', ocsf_field: "dst_endpoint.location.country" }
|
||||||
# ── The artifact the incident is really about ─────────────────────
|
# ── The file the incident is really about ─────────────────────────
|
||||||
# Last, because a file artifact is the incident's verdict on the file, where
|
# Last, because a file artifact is the incident's verdict on the file, where
|
||||||
# the alert only reports what one detection touched.
|
# the alert only reports what one detection touched.
|
||||||
- { source_path: "file_artifacts[0].file_name", ocsf_field: "file.name" }
|
- { source_path: "file_artifacts[0].file_name", ocsf_field: "file.name" }
|
||||||
@@ -233,5 +259,3 @@ ocsf:
|
|||||||
- { source_path: "file_artifacts[0].file_signature_vendor_name", ocsf_field: "file.company_name" }
|
- { source_path: "file_artifacts[0].file_signature_vendor_name", ocsf_field: "file.company_name" }
|
||||||
- { source_path: "file_artifacts[0].file_signature_status", ocsf_field: "file.signature.value" }
|
- { source_path: "file_artifacts[0].file_signature_status", ocsf_field: "file.signature.value" }
|
||||||
- { source_path: "file_artifacts[0].file_wildfire_verdict", ocsf_field: "malware.classifications" }
|
- { source_path: "file_artifacts[0].file_wildfire_verdict", ocsf_field: "malware.classifications" }
|
||||||
- { source_path: "network_artifacts[0].network_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
|
||||||
- { source_path: "network_artifacts[0].network_domain", ocsf_field: "dst_endpoint.hostname" }
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.error
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
|
key = s.get("api_key", "")
|
||||||
|
kid = str(s.get("api_key_id", ""))
|
||||||
|
if not key or not kid:
|
||||||
|
raise ValueError("api_key and api_key_id are both required")
|
||||||
|
# Nonce and timestamp ride along in both modes, as the reference client does.
|
||||||
|
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
|
||||||
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
|
ts = str(int(time.time()) * 1000)
|
||||||
|
headers = {
|
||||||
|
"x-xdr-auth-id": kid,
|
||||||
|
"x-xdr-nonce": nonce,
|
||||||
|
"x-xdr-timestamp": ts,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
||||||
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = key
|
||||||
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, request_data):
|
||||||
|
base, headers = _client()
|
||||||
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def to_ms(v):
|
||||||
|
if v in (None, ""):
|
||||||
|
return None
|
||||||
|
s = str(v)
|
||||||
|
if s.isdigit():
|
||||||
|
return int(s)
|
||||||
|
try:
|
||||||
|
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# input name -> (API filter field, operator). Values are comma-separated lists,
|
||||||
|
# which is the shape every one of these fields takes in the API.
|
||||||
|
LIST_FILTERS = [
|
||||||
|
("hostname", "hostname", "in"),
|
||||||
|
("alias", "alias", "in"),
|
||||||
|
("endpoint_id", "endpoint_id_list", "in"),
|
||||||
|
("ip_list", "ip_list", "in"),
|
||||||
|
("public_ip_list", "public_ip_list", "in"),
|
||||||
|
("group_name", "group_name", "in"),
|
||||||
|
("platform", "platform", "in"),
|
||||||
|
("dist_name", "dist_name", "in"),
|
||||||
|
("status", "endpoint_status", "IN"),
|
||||||
|
("isolate", "isolate", "in"),
|
||||||
|
("scan_status", "scan_status", "IN"),
|
||||||
|
("username", "username", "IN"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# input name -> (API filter field, operator). Values are epoch-ms bounds.
|
||||||
|
RANGE_FILTERS = [
|
||||||
|
("first_seen_after", "first_seen", "gte"),
|
||||||
|
("first_seen_before", "first_seen", "lte"),
|
||||||
|
("last_seen_after", "last_seen", "gte"),
|
||||||
|
("last_seen_before", "last_seen", "lte"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_filters(inputs):
|
||||||
|
filters = []
|
||||||
|
for name, field, operator in LIST_FILTERS:
|
||||||
|
values = csv(inputs.get(name))
|
||||||
|
if values:
|
||||||
|
filters.append({"field": field, "operator": operator, "value": values})
|
||||||
|
for name, field, operator in RANGE_FILTERS:
|
||||||
|
ms = to_ms(inputs.get(name))
|
||||||
|
if ms is not None:
|
||||||
|
filters.append({"field": field, "operator": operator, "value": ms})
|
||||||
|
# Escape hatch for any filter this command does not name explicitly: a JSON
|
||||||
|
# array of {field, operator, value} blocks, appended as-is.
|
||||||
|
raw = inputs.get("filters")
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
extra = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
|
except ValueError:
|
||||||
|
extra = None
|
||||||
|
if not isinstance(extra, list) or not all(isinstance(f, dict) for f in extra):
|
||||||
|
raise ValueError("filters must be a JSON array of {field, operator, value} objects, got: " + str(raw)[:120])
|
||||||
|
filters.extend(extra)
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def count(filters):
|
||||||
|
# The endpoints grid answers with reply.total_count: the number of endpoints
|
||||||
|
# matching the filters, independent of the page asked for. search_to=1 keeps
|
||||||
|
# the page — and so the payload — to a single endpoint record.
|
||||||
|
request_data = {"search_from": 0, "search_to": 1, "filters": filters}
|
||||||
|
reply = post("/endpoints/get_endpoint/", request_data).get("reply") or {}
|
||||||
|
return int(reply.get("total_count") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def version(v):
|
||||||
|
# "8.5.0.12345" -> (8, 5, 0, 12345); a shorter target such as "8.5" reads as
|
||||||
|
# 8.5.0.0, so it compares against every build of that release line.
|
||||||
|
parts = []
|
||||||
|
for x in str(v or "").strip().split("."):
|
||||||
|
digits = "".join(c for c in x if c.isdigit())
|
||||||
|
parts.append(int(digits) if digits else 0)
|
||||||
|
while len(parts) < 4:
|
||||||
|
parts.append(0)
|
||||||
|
return tuple(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def count_local(filters, keep):
|
||||||
|
# The endpoints grid filters neither on operational_status nor on the agent
|
||||||
|
# version, so the endpoints the other filters match are read 100 at a time —
|
||||||
|
# the API's page ceiling — and counted here. One call per hundred endpoints.
|
||||||
|
n, start = 0, 0
|
||||||
|
while True:
|
||||||
|
request_data = {"search_from": start, "search_to": start + 100, "filters": filters}
|
||||||
|
reply = post("/endpoints/get_endpoint/", request_data).get("reply") or {}
|
||||||
|
page = reply.get("endpoints") or []
|
||||||
|
n += sum(1 for e in page if keep(e))
|
||||||
|
start += len(page)
|
||||||
|
if not page or start >= int(reply.get("total_count") or 0):
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
filters = build_filters(inputs)
|
||||||
|
result = {"filters": filters}
|
||||||
|
tests = []
|
||||||
|
wanted = {x.upper() for x in csv(inputs.get("operational_status"))}
|
||||||
|
if wanted:
|
||||||
|
tests.append(lambda e: str(e.get("operational_status") or "").upper() in wanted)
|
||||||
|
result["operational_status"] = sorted(wanted)
|
||||||
|
below = str(inputs.get("agent_version_lt") or "").strip()
|
||||||
|
if below:
|
||||||
|
target = version(below)
|
||||||
|
# An endpoint that reports no version is not counted as outdated: it is
|
||||||
|
# unknown, and calling it outdated would inflate the figure.
|
||||||
|
tests.append(lambda e: bool(e.get("agent_version")) and version(e.get("agent_version")) < target)
|
||||||
|
result["agent_version_lt"] = below
|
||||||
|
if tests:
|
||||||
|
result["count"] = count_local(filters, lambda e: all(t(e) for t in tests))
|
||||||
|
else:
|
||||||
|
result["count"] = count(filters)
|
||||||
|
# The fleet total is the denominator the filtered count is read against: it
|
||||||
|
# turns a bare number into a proportion, and makes a filter that matched the
|
||||||
|
# whole estate — because it was too broad — visible instead of plausible.
|
||||||
|
result["total"] = count([]) if filters or tests else result["count"]
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# The CrowdStrike Falcon operator. Installed with the integration by Riposte
|
||||||
|
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
|
||||||
|
# Riposte refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Talon
|
||||||
|
title: CrowdStrike Falcon operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 12
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Anything CrowdStrike Falcon can tell about an endpoint: the detection behind
|
||||||
|
a Falcon alert (tactic/technique, file, command line, parent, user), the
|
||||||
|
host's details and online state, other detections on the same host, how
|
||||||
|
many and which devices saw a hash, domain or IP, the processes that ran it,
|
||||||
|
whether it is a custom IOC or was quarantined. Not for other vendors'
|
||||||
|
alerts, and never for containment or Real Time Response: it only reads.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id or the Falcon alert composite id. For a pivot without
|
||||||
|
an alert: the hostname or local IP, an indicator (sha256, sha1, md5,
|
||||||
|
domain, ipv4, ipv6) and its type.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Falcon, each with the command that produced it; "not found" when
|
||||||
|
a lookup came back empty; the exact call and error when one failed.
|
||||||
|
No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 12 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call). The raw fields are the Falcon alert:
|
||||||
|
composite_id, device.hostname, device.device_id, user_name, filename,
|
||||||
|
cmdline, sha256, tactic/technique. If they answer, do not re-fetch.
|
||||||
|
2. list_detection_summaries(ids=<composite_id>) (1 call) only when the raw
|
||||||
|
fields are missing or stale.
|
||||||
|
3. Host: endpoint(hostname=<device.hostname>) (1 call) — the hostname, not
|
||||||
|
the id. Hostname is an exact match. device_online_state(ids) only if
|
||||||
|
asked whether it is up now.
|
||||||
|
4. Indicator prevalence (the core of the job), with the alert's sha256:
|
||||||
|
- ioc_device_count(type="sha256", value=<hash>) (1 call): how many hosts.
|
||||||
|
- device_ran_on(type, value) (1 call): which device ids; then
|
||||||
|
get_device_details(ids=<those ids>) (1 call) to name the hosts.
|
||||||
|
- processes_ran_on(type, value, device_id) then process_details(ids)
|
||||||
|
(2 calls): the processes on the alert's host.
|
||||||
|
type is lowercase: sha256, sha1, md5, domain, ipv4, ipv6. An empty
|
||||||
|
answer or a 404 means "not observed", say so; it is not an error.
|
||||||
|
5. search_custom_iocs(values=<value>) (1 call): already a custom IOC?
|
||||||
|
list_quarantined_file only if asked.
|
||||||
|
6. ALWAYS keep 1 call for other detections on the same host or user:
|
||||||
|
list_detection_summaries(fetch_query=<FQL>). FQL is field:'value',
|
||||||
|
joined with + for AND, as in product:'epp'+type:'ldt'. Use the field
|
||||||
|
names the alert itself carries (device.hostname, user_name, sha256),
|
||||||
|
e.g. device.hostname:'<name>'. One page only, no sort: say so if many.
|
||||||
|
Never send fetch_query empty — that lists every alert.
|
||||||
|
|
||||||
|
Ids: device ids are 32 hex characters, composite ids are longer. Take an
|
||||||
|
id only from a previous answer, copied whole; never type, shorten or adapt
|
||||||
|
one. Prefer hostname and indicator values over ids.
|
||||||
|
Time: pass timestamps exactly as the data gives them. Never compute dates.
|
||||||
|
On an error (HTTP 400 on FQL, 403 on a missing API scope): report the exact
|
||||||
|
call and error, try one variant at most, then move on.
|
||||||
|
|
||||||
|
In your answer: the detection (name, tactic/technique, file, command line,
|
||||||
|
parent, user, status), the host (name, OS, IPs, last seen, containment
|
||||||
|
status), the prevalence figures and devices, and the other detections —
|
||||||
|
with each fact's command.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- list_detection_summaries
|
||||||
|
- endpoint
|
||||||
|
- device_online_state
|
||||||
|
- ioc_device_count
|
||||||
|
- device_ran_on
|
||||||
|
- processes_ran_on
|
||||||
|
- process_details
|
||||||
|
- search_custom_iocs
|
||||||
|
- list_quarantined_file
|
||||||
|
- get_device_details
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
name: "CrowdStrike NG-SIEM Detection"
|
||||||
|
color: "#c1121f"
|
||||||
|
icon: "shield"
|
||||||
File diff suppressed because one or more lines are too long
@@ -27,11 +27,10 @@ ocsf:
|
|||||||
- { source_path: "name", ocsf_field: "finding_info.title" }
|
- { source_path: "name", ocsf_field: "finding_info.title" }
|
||||||
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
||||||
- { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" }
|
- { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" }
|
||||||
# `time` is the MTTD anchor: the ingestion engine reads THIS field and no other
|
# `time` is the OCSF event time: when Falcon raised the alert.
|
||||||
# to stamp detected_at. Without it the incident is anchored at ingestion, so the
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
||||||
# delay between Falcon detecting and Riposte collecting — over a day on a slow
|
# starts when Riposte records the alert. It is the alert's own record of when
|
||||||
# poll — is measured as zero. finding_info.created_time below does NOT serve
|
# the source saw the event.
|
||||||
# this: it is the finding's own record of the same instant, read by nothing.
|
|
||||||
- { source_path: "created_timestamp", ocsf_field: "time" }
|
- { source_path: "created_timestamp", ocsf_field: "time" }
|
||||||
- { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" }
|
- { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" }
|
||||||
- { source_path: "scenario", ocsf_field: "finding_info.analytic.name" }
|
- { source_path: "scenario", ocsf_field: "finding_info.analytic.name" }
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: "CrowdStrike Next-Gen SIEM Detections → OCSF"
|
||||||
|
description: "Map of a Falcon Next-Gen SIEM detection (alerts/entities/alerts/v2 on products 'thirdparty' and 'ngsiem', results_path = resources) to OCSF. Read from real FortiGate IPS detections: a third-party detection carries no sensor process or file block, but names its source product and vendor, the source and target endpoints with ports and protocol, the host Falcon matched it to (enriched_entities) and the vendor's own signature id. It has no description: the incident's is built from the source product, the signature and the connection."
|
||||||
|
field_mappings:
|
||||||
|
title: "name"
|
||||||
|
# Plain path: the ingestion engine reads informational/low/medium/high/critical natively.
|
||||||
|
severity: "severity_name"
|
||||||
|
# Third-party detections carry no description of their own; say what fired,
|
||||||
|
# from which product, on which connection.
|
||||||
|
description: "jsonata: description ? description : $join(source_products, ', ') & ' — ' & name & (source_endpoint_address_ip4 ? ' — ' & source_endpoint_address_ip4 & (local_port ? ':' & local_port : '') & ' → ' & target_endpoint_address_ip4 & (remote_port ? ':' & remote_port : '') & (protocol ? ' (' & protocol & ')' : '') : '')"
|
||||||
|
# results_path = resources; source_path is JSONata over ONE alert object.
|
||||||
|
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
||||||
|
ocsf:
|
||||||
|
# ── Finding / analytic ────────────────────────────────────────────
|
||||||
|
- { source_path: "composite_id", ocsf_field: "finding_info.uid" }
|
||||||
|
- { source_path: "name", ocsf_field: "finding_info.title" }
|
||||||
|
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
||||||
|
- { source_path: "categorization", ocsf_field: "finding_info.types" }
|
||||||
|
- { source_path: "data_domains", ocsf_field: "finding_info.data_sources" }
|
||||||
|
- { source_path: "falcon_host_link", ocsf_field: "finding_info.src_url" }
|
||||||
|
- { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" }
|
||||||
|
- { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" }
|
||||||
|
# `time` is when the source product saw the event (timestamp), ahead of
|
||||||
|
# Falcon's own record of the detection (created_timestamp, ~20 s later).
|
||||||
|
- { source_path: "timestamp", ocsf_field: "time" }
|
||||||
|
# The vendor's signature id (FortiGate IPS 100012) identifies the rule;
|
||||||
|
# pattern_id is Falcon's generic third-party bucket (300000 for all of them).
|
||||||
|
- { source_path: "name", ocsf_field: "finding_info.analytic.name" }
|
||||||
|
- { source_path: "vendor_pattern_id", ocsf_field: "finding_info.analytic.uid" }
|
||||||
|
- { source_path: "confidence", ocsf_field: "confidence" }
|
||||||
|
- { source_path: "priority_value", ocsf_field: "risk_score" }
|
||||||
|
- { source_path: "status", ocsf_field: "status" }
|
||||||
|
# ── Source product — the log the detection came from ──────────────
|
||||||
|
- { source_path: "source_products[0]", ocsf_field: "metadata.product.name" }
|
||||||
|
- { source_path: "source_vendors[0]", ocsf_field: "metadata.product.vendor_name" }
|
||||||
|
- { source_path: "product", ocsf_field: "metadata.product.feature.name" }
|
||||||
|
- { source_path: "cid", ocsf_field: "cloud.account.uid" }
|
||||||
|
# ── Host Falcon matched the detection to ──────────────────────────
|
||||||
|
- { source_path: "agent_id", ocsf_field: "device.uid" }
|
||||||
|
- { source_path: "enriched_entities.hostnames[0]", ocsf_field: "device.hostname" }
|
||||||
|
# ── Connection ────────────────────────────────────────────────────
|
||||||
|
- { source_path: "source_endpoint_address_ip4 ? source_endpoint_address_ip4 : source_ips[0]", ocsf_field: "src_endpoint.ip" }
|
||||||
|
- { source_path: "source_hosts[0]", ocsf_field: "src_endpoint.hostname" }
|
||||||
|
- { source_path: "$number(local_port)", ocsf_field: "src_endpoint.port" }
|
||||||
|
- { source_path: "target_endpoint_address_ip4 ? target_endpoint_address_ip4 : destination_ips[0]", ocsf_field: "dst_endpoint.ip" }
|
||||||
|
- { source_path: "$number(remote_port)", ocsf_field: "dst_endpoint.port" }
|
||||||
|
- { source_path: "protocol", ocsf_field: "connection_info.protocol_name" }
|
||||||
|
- { source_path: "users[0]", ocsf_field: "actor.user.name" }
|
||||||
|
# ── MITRE ATT&CK (correlation rules that declare it) ──────────────
|
||||||
|
- { source_path: "tactic", ocsf_field: "attacks.tactic.name" }
|
||||||
|
- { source_path: "tactic_id", ocsf_field: "attacks.tactic.uid" }
|
||||||
|
- { source_path: "technique", ocsf_field: "attacks.technique.name" }
|
||||||
|
- { source_path: "technique_id", ocsf_field: "attacks.technique.uid" }
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import datetime, json, os, re, sys, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
SECRETS = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
INPUTS = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
BASE = SECRETS.get("base_url", "https://api.crowdstrike.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
data = urllib.parse.urlencode({"client_id": SECRETS.get("client_id", ""), "client_secret": SECRETS.get("client_secret", "")}).encode()
|
||||||
|
req = urllib.request.Request(BASE + "/oauth2/token", data=data, headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read()).get("access_token", "")
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, tok, params=None, body=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
# params is a list of (key, value) tuples to allow repeated keys (e.g. ids)
|
||||||
|
url += "?" + urllib.parse.urlencode(params)
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + tok, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# input name -> FQL property. Values are comma-separated; several values on one
|
||||||
|
# property become an OR group, and the groups are ANDed together.
|
||||||
|
STRING_FILTERS = [
|
||||||
|
("hostname", "hostname"),
|
||||||
|
("device_id", "device_id"),
|
||||||
|
("local_ip", "local_ip"),
|
||||||
|
("external_ip", "external_ip"),
|
||||||
|
("platform_name", "platform_name"),
|
||||||
|
("os_version", "os_version"),
|
||||||
|
("product_type_desc", "product_type_desc"),
|
||||||
|
("status", "status"),
|
||||||
|
("agent_version", "agent_version"),
|
||||||
|
("machine_domain", "machine_domain"),
|
||||||
|
("ou", "ou"),
|
||||||
|
("site_name", "site_name"),
|
||||||
|
("serial_number", "serial_number"),
|
||||||
|
("tags", "tags"),
|
||||||
|
("groups", "groups"),
|
||||||
|
("reduced_functionality_mode", "reduced_functionality_mode"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# input name -> (FQL property, operator). Values are UTC timestamps.
|
||||||
|
RANGE_FILTERS = [
|
||||||
|
("first_seen_after", "first_seen", ">="),
|
||||||
|
("first_seen_before", "first_seen", "<="),
|
||||||
|
("last_seen_after", "last_seen", ">="),
|
||||||
|
("last_seen_before", "last_seen", "<="),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# A bound on first_seen / last_seen can also be relative to now ("24h", "7d",
|
||||||
|
# "30m"), so a scheduled playbook counts the sensors seen in the last day without
|
||||||
|
# computing a date. It is turned into the UTC instant here.
|
||||||
|
RELATIVE = re.compile(r"^\s*(\d+)\s*([mhd])\s*$", re.I)
|
||||||
|
UNITS = {"m": "minutes", "h": "hours", "d": "days"}
|
||||||
|
|
||||||
|
|
||||||
|
def instant(v):
|
||||||
|
m = RELATIVE.match(str(v))
|
||||||
|
if not m:
|
||||||
|
return v
|
||||||
|
delta = datetime.timedelta(**{UNITS[m.group(2).lower()]: int(m.group(1))})
|
||||||
|
return (datetime.datetime.now(datetime.timezone.utc) - delta).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
# Sensor version bounds. FQL compares agent_version as text, where 7.9 sorts
|
||||||
|
# above 7.18, so these are compared here, number by number, over the devices the
|
||||||
|
# other filters keep.
|
||||||
|
VERSION_BOUNDS = {
|
||||||
|
"agent_version_lt": lambda v, t: v < t,
|
||||||
|
"agent_version_lte": lambda v, t: v <= t,
|
||||||
|
"agent_version_gt": lambda v, t: v > t,
|
||||||
|
"agent_version_gte": lambda v, t: v >= t,
|
||||||
|
}
|
||||||
|
SCROLL = 5000
|
||||||
|
DETAILS = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(v):
|
||||||
|
parts = re.findall(r"\d+", str(v or ""))
|
||||||
|
return tuple(int(x) for x in parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def pad(a, b):
|
||||||
|
n = max(len(a), len(b))
|
||||||
|
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
|
||||||
|
|
||||||
|
|
||||||
|
def literal(v):
|
||||||
|
# FQL quotes strings with single quotes, so a value carrying one would end
|
||||||
|
# the literal early and change what the query means.
|
||||||
|
return "'" + str(v).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def build_fql(inputs):
|
||||||
|
clauses = []
|
||||||
|
for name, prop in STRING_FILTERS:
|
||||||
|
values = csv(inputs.get(name))
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
if len(values) == 1:
|
||||||
|
clauses.append(prop + ":" + literal(values[0]))
|
||||||
|
else:
|
||||||
|
# ',' is OR in FQL. Parenthesised so the group binds before the '+'
|
||||||
|
# that ANDs it to the other clauses.
|
||||||
|
clauses.append("(" + ",".join(prop + ":" + literal(v) for v in values) + ")")
|
||||||
|
for name, prop, operator in RANGE_FILTERS:
|
||||||
|
value = inputs.get(name)
|
||||||
|
if value:
|
||||||
|
clauses.append(prop + ":" + operator + literal(instant(value)))
|
||||||
|
# Escape hatch for any property this command does not name explicitly: raw
|
||||||
|
# FQL, ANDed with the rest.
|
||||||
|
raw = str(inputs.get("filter") or "").strip()
|
||||||
|
if raw:
|
||||||
|
clauses.append("(" + raw + ")")
|
||||||
|
return "+".join(clauses)
|
||||||
|
|
||||||
|
|
||||||
|
def count(tok, fql):
|
||||||
|
# The device query answers with meta.pagination.total: the number of devices
|
||||||
|
# matching the filter, independent of the page asked for. limit=1 keeps the
|
||||||
|
# page — and so the payload — to a single device ID.
|
||||||
|
params = [("limit", 1)]
|
||||||
|
if fql:
|
||||||
|
params.insert(0, ("filter", fql))
|
||||||
|
reply = call("GET", "/devices/queries/devices/v1", tok, params=params)
|
||||||
|
total = ((reply.get("meta") or {}).get("pagination") or {}).get("total")
|
||||||
|
if total is None:
|
||||||
|
# devices/v1 stops reporting a total on an estate it cannot page to the
|
||||||
|
# end of (it refuses past 10000). The scroll query has no such ceiling.
|
||||||
|
reply = call("GET", "/devices/queries/devices-scroll/v1", tok, params=params)
|
||||||
|
total = ((reply.get("meta") or {}).get("pagination") or {}).get("total")
|
||||||
|
if total is None:
|
||||||
|
raise ValueError("the API returned no meta.pagination.total for this query")
|
||||||
|
return int(total)
|
||||||
|
|
||||||
|
|
||||||
|
def version_bounds(inputs):
|
||||||
|
bounds = {}
|
||||||
|
for field in VERSION_BOUNDS:
|
||||||
|
value = str(inputs.get(field) or "").strip()
|
||||||
|
if value:
|
||||||
|
target = parse_version(value)
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(field + " must be a version such as 7.18.18803, got: " + repr(value))
|
||||||
|
bounds[field] = target
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
|
||||||
|
def count_by_version(tok, fql, bounds):
|
||||||
|
"""Every device ID the filter keeps, then their sensor versions, compared
|
||||||
|
here. A device with no readable version is left out and counted apart."""
|
||||||
|
ids, offset = [], None
|
||||||
|
while True:
|
||||||
|
params = [("limit", SCROLL)]
|
||||||
|
if fql:
|
||||||
|
params.insert(0, ("filter", fql))
|
||||||
|
if offset:
|
||||||
|
params.append(("offset", offset))
|
||||||
|
reply = call("GET", "/devices/queries/devices-scroll/v1", tok, params=params)
|
||||||
|
page = reply.get("resources") or []
|
||||||
|
ids.extend(page)
|
||||||
|
pagination = (reply.get("meta") or {}).get("pagination") or {}
|
||||||
|
offset = pagination.get("offset")
|
||||||
|
if not page or not offset or len(ids) >= int(pagination.get("total") or 0):
|
||||||
|
break
|
||||||
|
matched, unreadable = 0, 0
|
||||||
|
for i in range(0, len(ids), DETAILS):
|
||||||
|
reply = call("POST", "/devices/entities/devices/v2", tok, body={"ids": ids[i:i + DETAILS]})
|
||||||
|
for device in reply.get("resources") or []:
|
||||||
|
version = parse_version(device.get("agent_version"))
|
||||||
|
if version is None:
|
||||||
|
unreadable += 1
|
||||||
|
elif all(VERSION_BOUNDS[f](*pad(version, t)) for f, t in bounds.items()):
|
||||||
|
matched += 1
|
||||||
|
return matched, len(ids), unreadable
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tok = token()
|
||||||
|
fql = build_fql(INPUTS)
|
||||||
|
bounds = version_bounds(INPUTS)
|
||||||
|
if bounds:
|
||||||
|
matched, scanned, unreadable = count_by_version(tok, fql, bounds)
|
||||||
|
result = {"count": matched, "filter": fql,
|
||||||
|
"agent_version_bounds": {f: INPUTS[f] for f in bounds},
|
||||||
|
"scanned": scanned, "unreadable_version": unreadable}
|
||||||
|
result["total"] = count(tok, "")
|
||||||
|
print(json.dumps(result))
|
||||||
|
return
|
||||||
|
result = {"count": count(tok, fql), "filter": fql}
|
||||||
|
# The estate total is the denominator the filtered count is read against: it
|
||||||
|
# turns a bare number into a proportion, and makes a filter that matched the
|
||||||
|
# whole estate — because it was too broad — visible instead of plausible.
|
||||||
|
result["total"] = count(tok, "") if fql else result["count"]
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -31,17 +31,54 @@ def csv(v):
|
|||||||
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
VERDICTS = ("true_positive", "false_positive", "ignored")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
tok = token()
|
tok = token()
|
||||||
|
ids = csv(INPUTS.get("ids"))
|
||||||
|
verdict = str(INPUTS.get("verdict") or "").strip()
|
||||||
|
if verdict and verdict not in VERDICTS:
|
||||||
|
raise ValueError("verdict must be one of " + ", ".join(VERDICTS) + ", got " + verdict)
|
||||||
action_params = []
|
action_params = []
|
||||||
|
if verdict:
|
||||||
|
# The console's Resolution is read from these tags, and add_tag never
|
||||||
|
# replaces one: drop the other two first, or a false positive turned
|
||||||
|
# true positive would show both. Removing a tag the alert does not
|
||||||
|
# carry is not an error worth stopping for — the add below is the call
|
||||||
|
# that has to land.
|
||||||
|
for other in VERDICTS:
|
||||||
|
if other == verdict:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
call("PATCH", "/alerts/entities/alerts/v3", tok,
|
||||||
|
body={"action_parameters": [{"name": "remove_tag", "value": other}], "composite_ids": ids})
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
pass
|
||||||
|
action_params.append({"name": "add_tag", "value": verdict})
|
||||||
if INPUTS.get("status"):
|
if INPUTS.get("status"):
|
||||||
action_params.append({"name": "update_status", "value": str(INPUTS["status"])})
|
action_params.append({"name": "update_status", "value": str(INPUTS["status"])})
|
||||||
if INPUTS.get("comment"):
|
if INPUTS.get("comment"):
|
||||||
action_params.append({"name": "append_comment", "value": str(INPUTS["comment"])})
|
action_params.append({"name": "append_comment", "value": str(INPUTS["comment"])})
|
||||||
if INPUTS.get("assign_to_uuid"):
|
if INPUTS.get("assign_to_uuid"):
|
||||||
action_params.append({"name": "assign_to_uuid", "value": str(INPUTS["assign_to_uuid"])})
|
action_params.append({"name": "assign_to_uuid", "value": str(INPUTS["assign_to_uuid"])})
|
||||||
body = {"action_parameters": action_params, "composite_ids": csv(INPUTS.get("ids"))}
|
if not action_params:
|
||||||
|
raise ValueError("nothing to change: give a status, a verdict, a comment or an assignee")
|
||||||
|
body = {"action_parameters": action_params, "composite_ids": ids}
|
||||||
result = call("PATCH", "/alerts/entities/alerts/v3", tok, body=body)
|
result = call("PATCH", "/alerts/entities/alerts/v3", tok, body=body)
|
||||||
|
# Falcon answers 200 to an update it refused: the reasons sit in errors and
|
||||||
|
# meta.writes.resources_affected stays at 0. Passing that on as a success
|
||||||
|
# left an incident closed in Riposte and still open in the console.
|
||||||
|
affected = ((result.get("meta") or {}).get("writes") or {}).get("resources_affected")
|
||||||
|
if result.get("errors") or affected == 0:
|
||||||
|
print(json.dumps({
|
||||||
|
"error": "Falcon updated no alert" if affected == 0 else "Falcon refused part of the update",
|
||||||
|
"resources_affected": affected,
|
||||||
|
"errors": result.get("errors") or [],
|
||||||
|
# The PATCH takes composite ids (<cid>:<id>), not the alert's bare id.
|
||||||
|
"composite_ids": ids,
|
||||||
|
}))
|
||||||
|
sys.exit(1)
|
||||||
print(json.dumps(result))
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ def main():
|
|||||||
result = call("POST", "/alerts/entities/alerts/v2", tok, body={"composite_ids": ids})
|
result = call("POST", "/alerts/entities/alerts/v2", tok, body={"composite_ids": ids})
|
||||||
else:
|
else:
|
||||||
result = {"resources": []}
|
result = {"resources": []}
|
||||||
|
# An empty page says nothing on its own: keep what the query answered — the
|
||||||
|
# FQL actually sent, Falcon's total for it and any errors — so a run with no
|
||||||
|
# alerts reads as "matched nothing" rather than as a silent failure.
|
||||||
|
result["query"] = {
|
||||||
|
"filter": fql,
|
||||||
|
"total": ((found.get("meta") or {}).get("pagination") or {}).get("total"),
|
||||||
|
"errors": found.get("errors") or [],
|
||||||
|
}
|
||||||
print(json.dumps(result))
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
SECRETS = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
INPUTS = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
BASE = SECRETS.get("base_url", "https://api.crowdstrike.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
data = urllib.parse.urlencode({"client_id": SECRETS.get("client_id", ""), "client_secret": SECRETS.get("client_secret", "")}).encode()
|
||||||
|
req = urllib.request.Request(BASE + "/oauth2/token", data=data, headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read()).get("access_token", "")
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, tok, params=None, body=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
# params is a list of (key, value) tuples to allow repeated keys (e.g. ids)
|
||||||
|
url += "?" + urllib.parse.urlencode(params)
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + tok, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tok = token()
|
||||||
|
# Next-Gen SIEM files its detections in the same unified Alerts API as the
|
||||||
|
# EDR, under two products: 'thirdparty' for the alerts it raises from ingested
|
||||||
|
# third-party logs (a FortiGate IPS hit, say) and 'ngsiem' for those of the
|
||||||
|
# tenant's own correlation rules. The product clause is always kept, so a
|
||||||
|
# filter narrows these detections and never widens past them.
|
||||||
|
products = [p.strip().strip("'\"") for p in str(INPUTS.get("products") or "thirdparty,ngsiem").split(",") if p.strip()]
|
||||||
|
# ',' is OR in FQL; parenthesised so the group binds before the '+' ANDs.
|
||||||
|
fql = "(" + ",".join("product:'" + p.replace("'", "\\'") + "'" for p in products) + ")"
|
||||||
|
extra = str(INPUTS.get("filter") or "").strip()
|
||||||
|
if extra:
|
||||||
|
fql += "+(" + extra + ")"
|
||||||
|
# The alert rule's watermark lands in created_from, folded into the FQL as
|
||||||
|
# created_timestamp (see search_detections for why it is not the filter input).
|
||||||
|
since = str(INPUTS.get("created_from") or "").strip().strip("'\"")
|
||||||
|
if since:
|
||||||
|
fql += "+created_timestamp:>'" + since + "'"
|
||||||
|
limit = INPUTS.get("limit", 50)
|
||||||
|
params = [("filter", fql), ("limit", limit), ("sort", "created_timestamp.desc")]
|
||||||
|
found = call("GET", "/alerts/queries/alerts/v2", tok, params=params)
|
||||||
|
ids = found.get("resources", []) or []
|
||||||
|
if ids:
|
||||||
|
result = call("POST", "/alerts/entities/alerts/v2", tok, body={"composite_ids": ids})
|
||||||
|
else:
|
||||||
|
result = {"resources": []}
|
||||||
|
# An empty page says nothing on its own: keep what the query answered — the
|
||||||
|
# FQL actually sent, Falcon's total for it and any errors — so a run with no
|
||||||
|
# alerts reads as "matched nothing" rather than as a silent failure.
|
||||||
|
result["query"] = {
|
||||||
|
"filter": fql,
|
||||||
|
"total": ((found.get("meta") or {}).get("pagination") or {}).get("total"),
|
||||||
|
"errors": found.get("errors") or [],
|
||||||
|
}
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
id: crtsh
|
||||||
|
name: crt.sh (Certificate Transparency)
|
||||||
|
version: 1.1.0
|
||||||
|
description: "crt.sh by Sectigo — searches the public Certificate Transparency logs, where every publicly trusted TLS certificate is recorded. Lists a domain's sub-domains and who issued their certificates, shows when a host first got a certificate (a phishing site usually gets one the day it goes live), and finds look-alike domains that start with a client's brand. Free, no API key; stdlib-only. crt.sh is a shared public service: it is slow on big domains and a starts_with search on a short word can time out — keep searches narrow. When crt.sh is down (it often answers 502), sub-domain and exact searches fall back to SSLMate Cert Spotter, which reads the same logs (unexpired certificates only; set a free API key to lift its 10-requests-an-hour limit). As a context enrichment rule (Riposte 0.299+, no score field) on domains: search_certificates with summary `${name_count} names, newest certificate ${certificates.0.not_before}`."
|
||||||
|
changelog: "1.1.0 — Cert Spotter fallback: when crt.sh is down (502/503/504 or no answer), sub-domain and exact searches are answered by SSLMate Cert Spotter, from the same CT logs; the output says which (`source`) and whether it may be partial (`complete`). Every call now fits Riposte's 60 s script limit (crt.sh used to be retried for minutes and killed before it answered). 1.0.0 — Initial release: certificate search (sub-domains, exact name, starts with), test connection."
|
||||||
|
category: enrichment
|
||||||
|
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "crt.sh server (default https://crt.sh)"
|
||||||
|
default: https://crt.sh
|
||||||
|
certspotter_fallback:
|
||||||
|
type: boolean
|
||||||
|
description: "When crt.sh is down, answer sub-domain and exact searches from SSLMate Cert Spotter (same CT logs, unexpired certificates only; no starts_with)"
|
||||||
|
default: true
|
||||||
|
certspotter_api_key:
|
||||||
|
type: string
|
||||||
|
description: "Cert Spotter API key (optional, free account at sslmate.com). Without one Cert Spotter allows about 10 requests an hour and the fallback reads at most 3 pages; with one, 10 pages"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
certspotter_url:
|
||||||
|
type: string
|
||||||
|
description: "Cert Spotter API (default https://api.certspotter.com)"
|
||||||
|
default: https://api.certspotter.com
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure)"
|
||||||
|
default: false
|
||||||
|
required: []
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: search_certificates
|
||||||
|
name: crtsh-search-certificates
|
||||||
|
description: "Certificates logged for a domain. Returns `names` (each DNS name seen, with the first and last time a certificate covered it, newest first, at most 1000 — `name_count` counts them all), `issuers` (certificate count per CA) and `certificates` (the most recent ones, with a crt.sh link), plus `source` (crt.sh, or certspotter when crt.sh was down and the fallback answered) and `complete` (false when the answer may miss certificates — Cert Spotter page cap, or crt.sh too slow for the apex query). mode: subdomains (default — the domain and every name under it), exact (that name only), starts_with (every logged name beginning with the text, any TLD: look-alikes of a brand such as brand-login.com or brand.co; crt.sh no longer searches for text in the middle of a name, so my-brand.com is not found)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
domain: { type: string, description: "Domain, or the brand to look for with mode=starts_with" }
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
description: "subdomains | exact | starts_with (default subdomains)"
|
||||||
|
enum: [subdomains, exact, starts_with]
|
||||||
|
exclude_expired: { type: boolean, description: "Ignore expired certificates (default true)" }
|
||||||
|
since_days: { type: number, description: "Only certificates issued in the last N days (0 = no limit, default 0)" }
|
||||||
|
limit: { type: number, description: "How many certificates to return in `certificates` (default 50, max 500); `names` and `issuers` always count them all" }
|
||||||
|
required: [domain]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: crtsh-test-connection
|
||||||
|
description: "Run a tiny search against crt.sh (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import datetime, json, os, ssl, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://crt.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CERTSPOTTER = "https://api.certspotter.com"
|
||||||
|
|
||||||
|
# Riposte stops an integration script after 60 s, whatever it is doing. Every
|
||||||
|
# network call below takes its timeout from what is left of this budget, so the
|
||||||
|
# script always gets to print an answer (or a clear error) before it is killed.
|
||||||
|
BUDGET = 55.0
|
||||||
|
# Kept back from crt.sh so the Cert Spotter fallback still has time to run.
|
||||||
|
FALLBACK_RESERVE = 15.0
|
||||||
|
_start = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _left():
|
||||||
|
return BUDGET - (time.monotonic() - _start)
|
||||||
|
|
||||||
|
|
||||||
|
class Unavailable(Exception):
|
||||||
|
"""crt.sh did not answer in time or kept answering 5xx."""
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_on(cfg):
|
||||||
|
return cfg.get("certspotter_fallback") not in (False, "false", "False", 0, "0")
|
||||||
|
|
||||||
|
|
||||||
|
def search(cfg, q, exclude_expired):
|
||||||
|
base = (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
params = {"q": q, "output": "json"}
|
||||||
|
if exclude_expired:
|
||||||
|
params["exclude"] = "expired"
|
||||||
|
req = urllib.request.Request(base + "/?" + urllib.parse.urlencode(params),
|
||||||
|
headers={"Accept": "application/json", "User-Agent": "riposte-soar"})
|
||||||
|
reserve = FALLBACK_RESERVE if _fallback_on(cfg) else 0.0
|
||||||
|
# crt.sh's database is shared by the whole internet and its front answers
|
||||||
|
# 502/503/504 when a query queues too long; the same query often goes
|
||||||
|
# through a few seconds later, so those are retried, three tries at most.
|
||||||
|
# A timeout or a refused connection is not: it has already cost its time,
|
||||||
|
# and the fallback is the better use of what is left.
|
||||||
|
for attempt in range(3):
|
||||||
|
timeout = min(45.0, _left() - reserve)
|
||||||
|
if timeout < 5:
|
||||||
|
raise Unavailable("crt.sh unavailable (out of time)")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw.strip() else []
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code not in (502, 503, 504):
|
||||||
|
raise
|
||||||
|
if attempt == 2:
|
||||||
|
raise Unavailable("crt.sh unavailable (HTTP " + str(e.code) + ")")
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||||
|
raise Unavailable("crt.sh unavailable (" + ("timeout" if "timed out" in str(e) else str(getattr(e, "reason", e))) + ")")
|
||||||
|
time.sleep(min(3.0 * (attempt + 1), max(0.0, _left() - reserve - 5)))
|
||||||
|
|
||||||
|
|
||||||
|
def certspotter(cfg, domain, include_subdomains):
|
||||||
|
"""The same Certificate Transparency logs, read through SSLMate's Cert Spotter.
|
||||||
|
|
||||||
|
Its rows are reshaped into crt.sh's, so everything after the search is shared.
|
||||||
|
Cert Spotter only lists certificates that have not expired, and without an
|
||||||
|
API key allows about 10 requests an hour, so the pages read are capped.
|
||||||
|
"""
|
||||||
|
key = str(cfg.get("certspotter_api_key") or "").strip()
|
||||||
|
base = (str(cfg.get("certspotter_url") or "").strip() or DEFAULT_CERTSPOTTER).rstrip("/")
|
||||||
|
params = [("domain", domain), ("expand", "dns_names"), ("expand", "issuer")]
|
||||||
|
if include_subdomains:
|
||||||
|
params.append(("include_subdomains", "true"))
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "riposte-soar"}
|
||||||
|
if key:
|
||||||
|
headers["Authorization"] = "Bearer " + key
|
||||||
|
max_pages = 10 if key else 3
|
||||||
|
rows, after, complete = [], None, False
|
||||||
|
for _ in range(max_pages):
|
||||||
|
timeout = _left() - 2
|
||||||
|
if timeout < 3:
|
||||||
|
break
|
||||||
|
qs = params + ([("after", after)] if after else [])
|
||||||
|
req = urllib.request.Request(base + "/v1/issuances?" + urllib.parse.urlencode(qs), headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_ctx(cfg)) as r:
|
||||||
|
page = json.loads(r.read() or b"[]")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 429 and not rows:
|
||||||
|
raise Exception("crt.sh unavailable and Cert Spotter quota reached"
|
||||||
|
+ ("" if key else " (about 10 requests an hour without an API key — set certspotter_api_key)"))
|
||||||
|
if rows:
|
||||||
|
break
|
||||||
|
raise
|
||||||
|
for c in page:
|
||||||
|
rows.append({
|
||||||
|
"id": "cs-" + str(c.get("id")),
|
||||||
|
"common_name": (c.get("dns_names") or [""])[0],
|
||||||
|
"name_value": "\n".join(c.get("dns_names") or []),
|
||||||
|
"issuer_name": (c.get("issuer") or {}).get("name", ""),
|
||||||
|
# crt.sh dates carry no zone; keep one format for sorting and comparing.
|
||||||
|
"not_before": str(c.get("not_before", "")).rstrip("Z"),
|
||||||
|
"not_after": str(c.get("not_after", "")).rstrip("Z"),
|
||||||
|
"serial_number": "",
|
||||||
|
"cert_sha256": c.get("cert_sha256", ""),
|
||||||
|
})
|
||||||
|
if not page:
|
||||||
|
complete = True
|
||||||
|
break
|
||||||
|
after = page[-1].get("id")
|
||||||
|
return rows, complete
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")[:2000]}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def truthy(v, default):
|
||||||
|
if v is None or v == "":
|
||||||
|
return default
|
||||||
|
return v not in (False, "false", "False", 0, "0")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
domain = str(inputs.get("domain", "")).strip().lower().rstrip(".")
|
||||||
|
if not domain:
|
||||||
|
raise Exception("domain is required")
|
||||||
|
mode = str(inputs.get("mode") or "subdomains").strip().lower()
|
||||||
|
# crt.sh no longer runs a search that starts with a wildcard (%brand% comes
|
||||||
|
# back empty), so a look-alike hunt is a prefix search: brand% finds
|
||||||
|
# brand.co, brand-login.com and brandpay.ch, not my-brand.com.
|
||||||
|
q = {"subdomains": "%." + domain, "exact": domain, "starts_with": domain + "%"}.get(mode)
|
||||||
|
if q is None:
|
||||||
|
raise Exception("mode must be subdomains, exact or starts_with")
|
||||||
|
exclude_expired = truthy(inputs.get("exclude_expired"), True)
|
||||||
|
source, complete = "crt.sh", True
|
||||||
|
try:
|
||||||
|
rows = search(cfg, q, exclude_expired)
|
||||||
|
if mode == "subdomains" and _left() > 10:
|
||||||
|
# %.domain leaves the apex out; it is part of what "the domain" means.
|
||||||
|
# Skipped rather than risked when crt.sh was slow enough to eat the budget.
|
||||||
|
ids = {x.get("id") for x in rows}
|
||||||
|
try:
|
||||||
|
rows += [r for r in search(cfg, domain, exclude_expired) if r.get("id") not in ids]
|
||||||
|
except Unavailable:
|
||||||
|
complete = False
|
||||||
|
except Unavailable:
|
||||||
|
# Cert Spotter cannot search by prefix, so a look-alike hunt has no fallback.
|
||||||
|
if not _fallback_on(cfg):
|
||||||
|
raise
|
||||||
|
if mode == "starts_with":
|
||||||
|
raise Exception(str(sys.exc_info()[1]) + "; no fallback for starts_with — Cert Spotter cannot search by prefix")
|
||||||
|
rows, complete = certspotter(cfg, domain, mode == "subdomains")
|
||||||
|
source = "certspotter"
|
||||||
|
|
||||||
|
since = int(float(inputs.get("since_days") or 0))
|
||||||
|
if since > 0:
|
||||||
|
floor = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=since)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
rows = [r for r in rows if str(r.get("not_before", "")) >= floor]
|
||||||
|
|
||||||
|
# crt.sh lists a certificate once per log entry (precertificate and final):
|
||||||
|
# the same id twice, which would double every count below.
|
||||||
|
seen, certs = set(), []
|
||||||
|
for r in rows:
|
||||||
|
if r.get("id") in seen:
|
||||||
|
continue
|
||||||
|
seen.add(r.get("id"))
|
||||||
|
certs.append(r)
|
||||||
|
certs.sort(key=lambda r: str(r.get("not_before", "")), reverse=True)
|
||||||
|
|
||||||
|
names, issuers = {}, {}
|
||||||
|
for r in certs:
|
||||||
|
nb, na = str(r.get("not_before", "")), str(r.get("not_after", ""))
|
||||||
|
for n in str(r.get("name_value", "")).lower().split("\n"):
|
||||||
|
n = n.strip()
|
||||||
|
if not n:
|
||||||
|
continue
|
||||||
|
e = names.setdefault(n, {"name": n, "first_seen": nb, "last_seen": nb, "valid_until": na, "certificates": 0})
|
||||||
|
e["certificates"] += 1
|
||||||
|
e["first_seen"] = min(e["first_seen"], nb)
|
||||||
|
e["last_seen"] = max(e["last_seen"], nb)
|
||||||
|
e["valid_until"] = max(e["valid_until"], na)
|
||||||
|
issuer = str(r.get("issuer_name", ""))
|
||||||
|
cn = [p.split("=", 1)[1] for p in issuer.split(", ") if p.startswith("CN=")]
|
||||||
|
o = [p.split("=", 1)[1] for p in issuer.split(", ") if p.startswith("O=")]
|
||||||
|
label = " / ".join(x for x in (o[:1] + cn[:1]) if x) or issuer
|
||||||
|
issuers[label] = issuers.get(label, 0) + 1
|
||||||
|
|
||||||
|
limit = max(1, min(500, int(float(inputs.get("limit") or 50))))
|
||||||
|
base = (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
def link(r):
|
||||||
|
if source == "certspotter":
|
||||||
|
return base + "/?q=" + str(r.get("cert_sha256", ""))
|
||||||
|
return base + "/?id=" + str(r.get("id"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": q,
|
||||||
|
# Which service answered: crt.sh, or Cert Spotter when crt.sh was down.
|
||||||
|
"source": source,
|
||||||
|
# False when the answer may miss certificates: Cert Spotter's page cap was
|
||||||
|
# hit, or crt.sh gave up on the apex query. Cert Spotter never lists
|
||||||
|
# expired certificates, whatever exclude_expired says.
|
||||||
|
"complete": complete,
|
||||||
|
"certificate_count": len(certs),
|
||||||
|
"name_count": len(names),
|
||||||
|
# Newest first and capped: a big domain logs thousands of names, and the
|
||||||
|
# recent ones are what an analyst is looking for. name_count counts all.
|
||||||
|
"names": sorted(names.values(), key=lambda e: e["first_seen"], reverse=True)[:1000],
|
||||||
|
"issuers": [{"issuer": k, "certificates": v} for k, v in sorted(issuers.items(), key=lambda kv: -kv[1])],
|
||||||
|
"certificates": [{
|
||||||
|
"id": r.get("id"),
|
||||||
|
"common_name": r.get("common_name", ""),
|
||||||
|
"names": [n for n in str(r.get("name_value", "")).lower().split("\n") if n],
|
||||||
|
"issuer": r.get("issuer_name", ""),
|
||||||
|
"not_before": r.get("not_before", ""),
|
||||||
|
"not_after": r.get("not_after", ""),
|
||||||
|
"serial_number": r.get("serial_number", ""),
|
||||||
|
"url": link(r),
|
||||||
|
} for r in certs[:limit]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import datetime, json, os, ssl, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://crt.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url, headers, cfg, timeout):
|
||||||
|
req = urllib.request.Request(url, headers=dict({"Accept": "application/json", "User-Agent": "riposte-soar"}, **headers))
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_ctx(cfg)) as r:
|
||||||
|
return json.loads(r.read() or b"[]")
|
||||||
|
|
||||||
|
|
||||||
|
def _crtsh(cfg):
|
||||||
|
base = (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
# Riposte stops the script at 60 s: two tries at most, Cert Spotter keeps the rest.
|
||||||
|
last = ""
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
return len(_get(base + "/?" + urllib.parse.urlencode({"q": "crt.sh", "output": "json", "exclude": "expired"}), {}, cfg, 20)), ""
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last = "HTTP " + str(e.code)
|
||||||
|
if e.code not in (502, 503, 504):
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last = str(getattr(e, "reason", e))
|
||||||
|
if attempt == 0:
|
||||||
|
time.sleep(3)
|
||||||
|
return None, last
|
||||||
|
|
||||||
|
|
||||||
|
def _certspotter(cfg):
|
||||||
|
key = str(cfg.get("certspotter_api_key") or "").strip()
|
||||||
|
base = (str(cfg.get("certspotter_url") or "").strip() or "https://api.certspotter.com").rstrip("/")
|
||||||
|
try:
|
||||||
|
_get(base + "/v1/issuances?domain=crt.sh", {"Authorization": "Bearer " + key} if key else {}, cfg, 15)
|
||||||
|
return True, ""
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return False, "HTTP " + str(e.code) + (" (quota reached)" if e.code == 429 else "")
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(getattr(e, "reason", e))
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")[:2000]}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
count, crt_err = _crtsh(cfg)
|
||||||
|
if count is not None:
|
||||||
|
return {"ok": True, "crtsh": "ok", "certificates": count}
|
||||||
|
if cfg.get("certspotter_fallback") in (False, "false", "False", 0, "0"):
|
||||||
|
raise Exception("crt.sh unavailable (" + crt_err + ") and the Cert Spotter fallback is off")
|
||||||
|
ok, cs_err = _certspotter(cfg)
|
||||||
|
if not ok:
|
||||||
|
raise Exception("crt.sh unavailable (" + crt_err + ") and Cert Spotter unreachable (" + cs_err + ")")
|
||||||
|
# crt.sh being down is common and the fallback answers: the instance works.
|
||||||
|
return {"ok": True, "crtsh": "unavailable (" + crt_err + ")", "certspotter": "ok",
|
||||||
|
"note": "crt.sh is down right now; searches will be answered by Cert Spotter"}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cuckoo
|
id: cuckoo
|
||||||
name: Cuckoo Sandbox
|
name: Cuckoo Sandbox
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Cuckoo Sandbox (REST API) — dynamic malware analysis: submit files and URLs for detonation, read task status and reports, list tasks, and delete a task. Optional token authentication; stdlib-only, no extra Python dependencies."
|
description: "Cuckoo Sandbox (REST API) — dynamic malware analysis: submit files and URLs for detonation, read task status and reports, list tasks, and delete a task. Optional token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: submit file/URL, get task, get report, list tasks, delete task."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none: 2 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: submit file/URL, get task, get report, list tasks, delete task."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|
||||||
# Per-instance configuration. If the Cuckoo API server requires a token it is
|
# Per-instance configuration. If the Cuckoo API server requires a token it is
|
||||||
@@ -27,6 +27,7 @@ commands:
|
|||||||
- id: submit_file
|
- id: submit_file
|
||||||
name: cuckoo-submit-file
|
name: cuckoo-submit-file
|
||||||
description: "Submit a file (base64) for analysis."
|
description: "Submit a file (base64) for analysis."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_name: { type: string, description: "File name" }
|
file_name: { type: string, description: "File name" }
|
||||||
@@ -36,6 +37,7 @@ commands:
|
|||||||
- id: submit_url
|
- id: submit_url
|
||||||
name: cuckoo-submit-url
|
name: cuckoo-submit-url
|
||||||
description: "Submit a URL for analysis."
|
description: "Submit a URL for analysis."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
url: { type: string, description: "URL to detonate" }
|
url: { type: string, description: "URL to detonate" }
|
||||||
@@ -71,6 +73,7 @@ commands:
|
|||||||
- id: delete_task
|
- id: delete_task
|
||||||
name: cuckoo-delete-task
|
name: cuckoo-delete-task
|
||||||
description: "Delete a task and its data."
|
description: "Delete a task and its data."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
task_id: { type: string, description: "Task ID" }
|
task_id: { type: string, description: "Task ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cybelangel
|
id: cybelangel
|
||||||
name: CybelAngel
|
name: CybelAngel
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "CybelAngel (External Attack Surface / data-leak detection API) — read exposure reports: list reports, get a report, and update a report's status. OAuth2 client-credentials authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
|
description: "CybelAngel (External Attack Surface / data-leak detection API) — read exposure reports: list reports, get a report, and update a report's status. OAuth2 client-credentials authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
|
||||||
changelog: "1.0.0 — Initial release: list/get reports, update report status."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none: 1 safe_write. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list/get reports, update report status."
|
||||||
category: threat_intel
|
category: threat_intel
|
||||||
|
|
||||||
# Per-instance configuration. Client credentials are exchanged for a bearer
|
# Per-instance configuration. Client credentials are exchanged for a bearer
|
||||||
@@ -50,6 +50,7 @@ commands:
|
|||||||
- id: update_report_status
|
- id: update_report_status
|
||||||
name: cybelangel-update-report-status
|
name: cybelangel-update-report-status
|
||||||
description: "Update a report's status."
|
description: "Update a report's status."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
report_id: { type: string, description: "Report ID" }
|
report_id: { type: string, description: "Report ID" }
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# The CyberArk PAS operator. Installed with the integration by Riposte
|
||||||
|
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
|
||||||
|
# Riposte refuses a sheet that lists a write — and get_password, which
|
||||||
|
# reveals a secret, is left out.
|
||||||
|
key: operator
|
||||||
|
handle: Lockjaw
|
||||||
|
title: CyberArk PAS operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Whether an account or a machine is under CyberArk privileged access
|
||||||
|
management: the vaulted accounts matching a username or address, their
|
||||||
|
safe, platform and credential management status (last change, last
|
||||||
|
verification, failures), the safes, and the vault users. It cannot show
|
||||||
|
privileged sessions or recordings, nor reveal a password: no command
|
||||||
|
does. Not for changing or rotating any credential.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The account username, the target machine address or host name, or a
|
||||||
|
safe name. The Riposte alert id if they must be read from the alert.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from CyberArk, each with the command that produced it; "not found"
|
||||||
|
when a search came back empty; the exact call and error when one failed.
|
||||||
|
No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. If you were given an alert: get_incident_context (1 call) and read the
|
||||||
|
user and host from it, exactly as written. DOMAIN\jdoe becomes jdoe.
|
||||||
|
2. list_accounts(search=<username>, limit=20) (1 call). search matches
|
||||||
|
username, address and name. 0 results means the account is not vaulted:
|
||||||
|
say so, it is a finding.
|
||||||
|
3. Reserve one call for the machine: list_accounts(search=<host or IP>,
|
||||||
|
limit=20): the other privileged accounts on the same target.
|
||||||
|
4. get_account_details(account_id=<id>) for the accounts that matter, at
|
||||||
|
most 4 calls. The account id looks like 12_34: copy it from the
|
||||||
|
list_accounts answer, never type or adapt it. Read safeName,
|
||||||
|
platformId, address, userName, and secretManagement (automatic
|
||||||
|
management on or off, status, lastModifiedTime, lastReconciledTime).
|
||||||
|
Report times exactly as given; do not convert them.
|
||||||
|
5. Optional: list_safes(search=<safe name>) (1 call); list_users(search=
|
||||||
|
<username>) (1 call) to see whether the person is a vault user.
|
||||||
|
6. filter takes the form shown in the manifest, e.g. safeName eq MySafe
|
||||||
|
(list_accounts) or userType eq EPVUser (list_users). Use a value copied
|
||||||
|
from an answer.
|
||||||
|
|
||||||
|
On an error: report the exact call and error; retry at most once. A 401
|
||||||
|
means the logon session failed: report it and stop.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- list_accounts
|
||||||
|
- get_account_details
|
||||||
|
- list_safes
|
||||||
|
- list_users
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cyberark_pas
|
id: cyberark_pas
|
||||||
name: CyberArk PAS
|
name: CyberArk PAS
|
||||||
version: 1.0.0
|
version: 1.1.1
|
||||||
description: "CyberArk Privileged Access Security (PVWA REST API) — PAM containment and IR: list and read privileged accounts, rotate/verify/reconcile credentials (CPM), retrieve a password for incident response, add/delete accounts, and list safes and vault users. Session (logon) authentication; stdlib-only, no extra Python dependencies."
|
description: "CyberArk Privileged Access Security (PVWA REST API) — PAM containment and IR: list and read privileged accounts, rotate/verify/reconcile credentials (CPM), retrieve a password for incident response, add/delete accounts, and list safes and vault users. Session (logon) authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list/get accounts, change/verify/reconcile credentials, retrieve password, add/delete account, list safes, list users."
|
changelog: "1.1.0 — Declare the risk tier on the 6 commands that carried none: 2 safe_write, 4 destructive. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list/get accounts, change/verify/reconcile credentials, retrieve password, add/delete account, list safes, list users."
|
||||||
category: identity
|
category: identity
|
||||||
|
|
||||||
# Per-instance configuration. Each command logs on to PVWA (returns a session
|
# Per-instance configuration. Each command logs on to PVWA (returns a session
|
||||||
@@ -56,6 +56,7 @@ commands:
|
|||||||
- id: change_credentials
|
- id: change_credentials
|
||||||
name: cyberark-change-credentials
|
name: cyberark-change-credentials
|
||||||
description: "Rotate an account's credential immediately to a new random password (CPM change). Use to contain a compromised privileged account."
|
description: "Rotate an account's credential immediately to a new random password (CPM change). Use to contain a compromised privileged account."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
account_id: { type: string, description: "Account ID" }
|
account_id: { type: string, description: "Account ID" }
|
||||||
@@ -65,6 +66,7 @@ commands:
|
|||||||
- id: verify_credentials
|
- id: verify_credentials
|
||||||
name: cyberark-verify-credentials
|
name: cyberark-verify-credentials
|
||||||
description: "Mark an account's credential for verification by the CPM."
|
description: "Mark an account's credential for verification by the CPM."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
account_id: { type: string, description: "Account ID" }
|
account_id: { type: string, description: "Account ID" }
|
||||||
@@ -73,6 +75,7 @@ commands:
|
|||||||
- id: reconcile_credentials
|
- id: reconcile_credentials
|
||||||
name: cyberark-reconcile-credentials
|
name: cyberark-reconcile-credentials
|
||||||
description: "Mark an account's credential for reconciliation by the CPM."
|
description: "Mark an account's credential for reconciliation by the CPM."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
account_id: { type: string, description: "Account ID" }
|
account_id: { type: string, description: "Account ID" }
|
||||||
@@ -81,6 +84,7 @@ commands:
|
|||||||
- id: get_password
|
- id: get_password
|
||||||
name: cyberark-get-password
|
name: cyberark-get-password
|
||||||
description: "Retrieve an account's current password (for incident response). Requires a reason."
|
description: "Retrieve an account's current password (for incident response). Requires a reason."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
account_id: { type: string, description: "Account ID" }
|
account_id: { type: string, description: "Account ID" }
|
||||||
@@ -90,6 +94,7 @@ commands:
|
|||||||
- id: add_account
|
- id: add_account
|
||||||
name: cyberark-add-account
|
name: cyberark-add-account
|
||||||
description: "Add a new privileged account to a safe."
|
description: "Add a new privileged account to a safe."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Account name" }
|
name: { type: string, description: "Account name" }
|
||||||
@@ -104,6 +109,7 @@ commands:
|
|||||||
- id: delete_account
|
- id: delete_account
|
||||||
name: cyberark-delete-account
|
name: cyberark-delete-account
|
||||||
description: "Delete an account by ID."
|
description: "Delete an account by ID."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
account_id: { type: string, description: "Account ID" }
|
account_id: { type: string, description: "Account ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cybereason
|
id: cybereason
|
||||||
name: Cybereason
|
name: Cybereason
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Cybereason EDR — endpoint containment and investigation: query Malops, list sensors, isolate/un-isolate a machine (containment), block a file hash, and read machine details. Session (login) authentication; stdlib-only, no extra Python dependencies."
|
description: "Cybereason EDR — endpoint containment and investigation: query Malops, list sensors, isolate/un-isolate a machine (containment), block a file hash, and read machine details. Session (login) authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: query Malops, list sensors, isolate/un-isolate machine, block file, get machine details."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: query Malops, list sensors, isolate/un-isolate machine, block file, get machine details."
|
||||||
category: endpoint
|
category: endpoint
|
||||||
|
|
||||||
# Per-instance configuration. Each command logs in (form POST to /login.html,
|
# Per-instance configuration. Each command logs in (form POST to /login.html,
|
||||||
@@ -67,6 +67,7 @@ commands:
|
|||||||
- id: isolate_machine
|
- id: isolate_machine
|
||||||
name: cybereason-isolate-machine
|
name: cybereason-isolate-machine
|
||||||
description: "Isolate one or more machines from the network (containment)."
|
description: "Isolate one or more machines from the network (containment)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
sensor_ids: { type: string, description: "Comma-separated sensor IDs to isolate" }
|
sensor_ids: { type: string, description: "Comma-separated sensor IDs to isolate" }
|
||||||
@@ -75,6 +76,7 @@ commands:
|
|||||||
- id: unisolate_machine
|
- id: unisolate_machine
|
||||||
name: cybereason-unisolate-machine
|
name: cybereason-unisolate-machine
|
||||||
description: "Remove one or more machines from isolation."
|
description: "Remove one or more machines from isolation."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
sensor_ids: { type: string, description: "Comma-separated sensor IDs to un-isolate" }
|
sensor_ids: { type: string, description: "Comma-separated sensor IDs to un-isolate" }
|
||||||
@@ -83,6 +85,7 @@ commands:
|
|||||||
- id: block_file
|
- id: block_file
|
||||||
name: cybereason-block-file
|
name: cybereason-block-file
|
||||||
description: "Block a file by hash (add to the block list)."
|
description: "Block a file by hash (add to the block list)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
md5: { type: string, description: "MD5 hash of the file to block" }
|
md5: { type: string, description: "MD5 hash of the file to block" }
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# The Darktrace operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design: Riposte
|
||||||
|
# refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Phantom
|
||||||
|
title: Darktrace operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 12
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Anything Darktrace can answer about a device on the network: the model
|
||||||
|
breach behind an alert (model, score, time, triggering components), the
|
||||||
|
device it concerns (hostname, IP, MAC, type), the other model breaches
|
||||||
|
that device raised, and the Antigena (autonomous response) actions
|
||||||
|
applied to it. Not for acknowledging breaches or activating Antigena, not
|
||||||
|
for raw packet or proxy logs, not for other vendors' alerts.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id or the Darktrace model breach id (PBID). Otherwise
|
||||||
|
the device IP (or Darktrace device id) and the time of the activity.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Darktrace, each with the command that produced it: the breach,
|
||||||
|
the device, its other breaches and Antigena actions; "not found" when a
|
||||||
|
lookup came back empty; the exact call and error when one failed. No
|
||||||
|
verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 12 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call): the Riposte alert. Its source_ref and
|
||||||
|
raw fields carry the PBID, the device (did, IP, hostname) and the time.
|
||||||
|
Take every id from there, exactly as written; never type or adapt one.
|
||||||
|
2. get_model_breach(pbid) (1 call): model name, score, time, triggered
|
||||||
|
components, device.
|
||||||
|
3. get_devices (1-2 calls) with ip = the device IP, or did = the device id
|
||||||
|
from step 2. Prefer the IP from the data over a copied id.
|
||||||
|
4. Other breaches of the same device (at most 3 calls). get_model_breaches
|
||||||
|
has NO device filter: it lists every breach since from_time, count 50
|
||||||
|
by default. from_time is "YYYY-MM-DD HH:MM:SS": take the date and time
|
||||||
|
of the alert's first seen exactly as the data gives them, replace the
|
||||||
|
"T" with a space and drop the zone and fractions; do not compute other
|
||||||
|
dates. Raise count to 200 when the first answer is full. Keep only the
|
||||||
|
breaches whose device did matches, and list them (model, score, time).
|
||||||
|
min_score (0-1) narrows a noisy list.
|
||||||
|
5. ALWAYS call get_antigena_actions once (include_cleared true): keep the
|
||||||
|
actions on that device and say whether they are active or cleared.
|
||||||
|
|
||||||
|
Times in Darktrace answers are often epoch milliseconds: report them as
|
||||||
|
given, do not convert them yourself.
|
||||||
|
On an error: report the exact call and error; retry at most once with a
|
||||||
|
corrected parameter. An empty result is "not found", not an error.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- get_model_breach
|
||||||
|
- get_model_breaches
|
||||||
|
- get_devices
|
||||||
|
- get_antigena_actions
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: darktrace
|
id: darktrace
|
||||||
name: Darktrace
|
name: Darktrace
|
||||||
version: 1.0.0
|
version: 1.1.1
|
||||||
description: "Darktrace (Threat Visualizer API) — network detection and autonomous response: read model breaches, acknowledge a breach, look up devices, and read/activate Antigena network actions (containment). HMAC-SHA1 signed authentication; stdlib-only, no extra Python dependencies."
|
description: "Darktrace (Threat Visualizer API) — network detection and autonomous response: read model breaches, acknowledge a breach, look up devices, and read/activate Antigena network actions (containment). HMAC-SHA1 signed authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: get/acknowledge model breaches, get devices, get/activate Antigena actions."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 1 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: get/acknowledge model breaches, get devices, get/activate Antigena actions."
|
||||||
category: ndr
|
category: ndr
|
||||||
|
|
||||||
# Per-instance configuration. Requests are signed (HMAC-SHA1) with the public +
|
# Per-instance configuration. Requests are signed (HMAC-SHA1) with the public +
|
||||||
@@ -52,6 +52,7 @@ commands:
|
|||||||
- id: acknowledge_breach
|
- id: acknowledge_breach
|
||||||
name: darktrace-acknowledge-breach
|
name: darktrace-acknowledge-breach
|
||||||
description: "Acknowledge a model breach."
|
description: "Acknowledge a model breach."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
pbid: { type: string, description: "Policy breach ID (PBID)" }
|
pbid: { type: string, description: "Policy breach ID (PBID)" }
|
||||||
@@ -80,6 +81,7 @@ commands:
|
|||||||
- id: activate_antigena
|
- id: activate_antigena
|
||||||
name: darktrace-activate-antigena
|
name: darktrace-activate-antigena
|
||||||
description: "Activate an Antigena action (containment — enforce autonomous response)."
|
description: "Activate an Antigena action (containment — enforce autonomous response)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
codeid: { type: string, description: "Antigena action code ID" }
|
codeid: { type: string, description: "Antigena action code ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: datadog
|
id: datadog
|
||||||
name: Datadog
|
name: Datadog
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Datadog (API v1/v2) — observability and monitoring: search logs, list and read monitors, submit and list events. API-key + Application-key authentication; stdlib-only, no extra Python dependencies."
|
description: "Datadog (API v1/v2) — observability and monitoring: search logs, list and read monitors, submit and list events. API-key + Application-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: search logs, list/get monitors, submit event, list events."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none: 1 safe_write. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: search logs, list/get monitors, submit event, list events."
|
||||||
category: siem
|
category: siem
|
||||||
|
|
||||||
# Per-instance configuration. Auth uses the 'DD-API-KEY' and 'DD-APPLICATION-KEY'
|
# Per-instance configuration. Auth uses the 'DD-API-KEY' and 'DD-APPLICATION-KEY'
|
||||||
@@ -59,6 +59,7 @@ commands:
|
|||||||
- id: submit_event
|
- id: submit_event
|
||||||
name: datadog-submit-event
|
name: datadog-submit-event
|
||||||
description: "Post an event to the Datadog event stream."
|
description: "Post an event to the Datadog event stream."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
title: { type: string, description: "Event title" }
|
title: { type: string, description: "Event title" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: delinea_secret_server
|
id: delinea_secret_server
|
||||||
name: Delinea Secret Server
|
name: Delinea Secret Server
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Delinea Secret Server (REST API v1) — privileged-credential retrieval for IR: search secrets, read a secret, read a specific secret field, and list folders. OAuth2 password-grant authentication; stdlib-only, no extra Python dependencies."
|
description: "Delinea Secret Server (REST API v1) — privileged-credential retrieval for IR: search secrets, read a secret, read a specific secret field, and list folders. OAuth2 password-grant authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: search secrets, get secret, get secret field, list folders."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: search secrets, get secret, get secret field, list folders."
|
||||||
category: identity
|
category: identity
|
||||||
|
|
||||||
# Per-instance configuration. Credentials are exchanged for a bearer token at
|
# Per-instance configuration. Credentials are exchanged for a bearer token at
|
||||||
@@ -42,6 +42,7 @@ commands:
|
|||||||
- id: get_secret
|
- id: get_secret
|
||||||
name: delinea-get-secret
|
name: delinea-get-secret
|
||||||
description: "Get a secret (with its fields) by ID."
|
description: "Get a secret (with its fields) by ID."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
secret_id: { type: string, description: "Secret ID" }
|
secret_id: { type: string, description: "Secret ID" }
|
||||||
@@ -50,6 +51,7 @@ commands:
|
|||||||
- id: get_secret_field
|
- id: get_secret_field
|
||||||
name: delinea-get-secret-field
|
name: delinea-get-secret-field
|
||||||
description: "Get a single field's value from a secret (e.g. password)."
|
description: "Get a single field's value from a secret (e.g. password)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
secret_id: { type: string, description: "Secret ID" }
|
secret_id: { type: string, description: "Secret ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: discord
|
id: discord
|
||||||
name: Discord
|
name: Discord
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Discord — SOC alerting and ChatOps: post messages via an incoming webhook, and (with a bot token) send channel messages, read recent channel messages, and get channel info. Webhook + bot-token authentication; stdlib-only, no extra Python dependencies."
|
description: "Discord — SOC alerting and ChatOps: post messages via an incoming webhook, and (with a bot token) send channel messages, read recent channel messages, and get channel info. Webhook + bot-token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: webhook message, bot channel message, list channel messages, get channel."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 2 safe_write. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: webhook message, bot channel message, list channel messages, get channel."
|
||||||
category: notification
|
category: notification
|
||||||
|
|
||||||
# Per-instance configuration. webhook_url is used by the simple message command;
|
# Per-instance configuration. webhook_url is used by the simple message command;
|
||||||
@@ -23,6 +23,7 @@ commands:
|
|||||||
- id: send_webhook_message
|
- id: send_webhook_message
|
||||||
name: discord-send-webhook-message
|
name: discord-send-webhook-message
|
||||||
description: "Post a message via the configured incoming webhook."
|
description: "Post a message via the configured incoming webhook."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
content: { type: string, description: "Message text" }
|
content: { type: string, description: "Message text" }
|
||||||
@@ -32,6 +33,7 @@ commands:
|
|||||||
- id: send_channel_message
|
- id: send_channel_message
|
||||||
name: discord-send-channel-message
|
name: discord-send-channel-message
|
||||||
description: "Send a message to a channel using the bot token."
|
description: "Send a message to a channel using the bot token."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
channel_id: { type: string, description: "Channel ID" }
|
channel_id: { type: string, description: "Channel ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: dropbox
|
id: dropbox
|
||||||
name: Dropbox
|
name: Dropbox
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Dropbox (API v2) — evidence and file handling: search files, get file/folder metadata, list a folder, and create a shared link. Bearer-token authentication; stdlib-only, no extra Python dependencies."
|
description: "Dropbox (API v2) — evidence and file handling: search files, get file/folder metadata, list a folder, and create a shared link. Bearer-token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: search, get metadata, list folder, create shared link."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: search, get metadata, list folder, create shared link."
|
||||||
category: productivity
|
category: productivity
|
||||||
|
|
||||||
# Per-instance configuration. Auth header 'Authorization: Bearer <access_token>'.
|
# Per-instance configuration. Auth header 'Authorization: Bearer <access_token>'.
|
||||||
@@ -47,6 +47,7 @@ commands:
|
|||||||
- id: create_shared_link
|
- id: create_shared_link
|
||||||
name: dropbox-create-shared-link
|
name: dropbox-create-shared-link
|
||||||
description: "Create a shared link for a file or folder."
|
description: "Create a shared link for a file or folder."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
path: { type: string, description: "Path to share" }
|
path: { type: string, description: "Path to share" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: duo
|
id: duo
|
||||||
name: Cisco Duo
|
name: Cisco Duo
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Cisco Duo (Admin API) — MFA/identity containment and investigation: list and read users, disable/enable/bypass a user (containment), delete users, read bypass codes and devices, and pull authentication logs. HMAC-SHA1 signed authentication; stdlib-only, no extra Python dependencies."
|
description: "Cisco Duo (Admin API) — MFA/identity containment and investigation: list and read users, disable/enable/bypass a user (containment), delete users, read bypass codes and devices, and pull authentication logs. HMAC-SHA1 signed authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: get users/user, modify user status (disable/enable/bypass), delete user, get bypass codes, get devices, get user devices, authentication logs."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: get users/user, modify user status (disable/enable/bypass), delete user, get bypass codes, get devices, get user devices, authentication logs."
|
||||||
category: identity
|
category: identity
|
||||||
|
|
||||||
# Per-instance configuration. Requests are signed (HMAC-SHA1) with the
|
# Per-instance configuration. Requests are signed (HMAC-SHA1) with the
|
||||||
@@ -48,6 +48,7 @@ commands:
|
|||||||
- id: modify_user
|
- id: modify_user
|
||||||
name: duo-modify-user
|
name: duo-modify-user
|
||||||
description: "Change a user's status — disable (containment), enable (active), or bypass."
|
description: "Change a user's status — disable (containment), enable (active), or bypass."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string, description: "Duo user ID" }
|
user_id: { type: string, description: "Duo user ID" }
|
||||||
@@ -57,6 +58,7 @@ commands:
|
|||||||
- id: delete_user
|
- id: delete_user
|
||||||
name: duo-delete-user
|
name: duo-delete-user
|
||||||
description: "Delete a user."
|
description: "Delete a user."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
user_id: { type: string, description: "Duo user ID" }
|
user_id: { type: string, description: "Duo user ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: efficientip
|
id: efficientip
|
||||||
name: EfficientIP SOLIDserver
|
name: EfficientIP SOLIDserver
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "EfficientIP SOLIDserver (REST API) — DNS/DDI containment and visibility: add an RPZ record to block a domain (DNS sinkhole), list DNS zones, search IP addresses, and list networks. HTTP Basic authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
|
description: "EfficientIP SOLIDserver (REST API) — DNS/DDI containment and visibility: add an RPZ record to block a domain (DNS sinkhole), list DNS zones, search IP addresses, and list networks. HTTP Basic authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
|
||||||
changelog: "1.0.0 — Initial release: add RPZ record, list DNS zones, search IP, list networks."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: add RPZ record, list DNS zones, search IP, list networks."
|
||||||
category: network
|
category: network
|
||||||
|
|
||||||
# Per-instance configuration. HTTP Basic auth against the SOLIDserver REST API.
|
# Per-instance configuration. HTTP Basic auth against the SOLIDserver REST API.
|
||||||
@@ -31,6 +31,7 @@ commands:
|
|||||||
- id: add_rpz_record
|
- id: add_rpz_record
|
||||||
name: efficientip-add-rpz-record
|
name: efficientip-add-rpz-record
|
||||||
description: "Add an RPZ resource record to block a domain (DNS sinkhole)."
|
description: "Add an RPZ resource record to block a domain (DNS sinkhole)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
dns_name: { type: string, description: "DNS server name that hosts the RPZ zone" }
|
dns_name: { type: string, description: "DNS server name that hosts the RPZ zone" }
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# The Elasticsearch operator. Installed with the integration by Riposte
|
||||||
|
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
|
||||||
|
# Riposte refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Grep
|
||||||
|
title: Elasticsearch operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
What the logs stored in Elasticsearch show for a user, host, IP or other
|
||||||
|
value: which indices exist, how many documents match, the matching
|
||||||
|
documents, and one document read in full. Not for other vendors' alerts,
|
||||||
|
and not for changing anything in Elasticsearch.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The value to look for (user, host, IP, hash, domain), and if known the
|
||||||
|
index or index pattern (e.g. logs-*) and a time window. A ready query
|
||||||
|
from the lead if there is one.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Elasticsearch, each with the index, query and size that
|
||||||
|
produced it; "not found" when 0 documents matched; the exact query and
|
||||||
|
error when one failed. No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call) for the values to look for (user, host,
|
||||||
|
IP, hash) and the alert time. Take them exactly as written.
|
||||||
|
2. list_indices (1 call) unless the lead named the index. Pick an index
|
||||||
|
name or a pattern ending in * from that answer (e.g. logs-*); never
|
||||||
|
invent one.
|
||||||
|
3. count(index, query_string) first (1 call per value): it tells you
|
||||||
|
whether a search is worth it. query_string = the bare value, nothing
|
||||||
|
else (no field names, no boolean operators) unless the lead gave the
|
||||||
|
query. A value holding : / or a space goes in double quotes, e.g.
|
||||||
|
"fe80::1". For DOMAIN\user, search the user part alone.
|
||||||
|
4. search(index, query_string, size=20) (1-3 calls) where count > 0.
|
||||||
|
Never above size 50.
|
||||||
|
5. Optional: get_document with the _index and _id of a hit, both copied
|
||||||
|
from the search answer. It needs the concrete _index of the hit, not a
|
||||||
|
pattern with *.
|
||||||
|
6. Reserve one count + search for the same user, host or IP elsewhere:
|
||||||
|
the other value from the alert, or a wider pattern (e.g. *).
|
||||||
|
|
||||||
|
Time: these commands have no time parameter. If the lead gave a query_json
|
||||||
|
with a time range, pass it exactly as given. Otherwise say in your answer
|
||||||
|
that the search was not time-bounded, prefer an index whose name carries
|
||||||
|
the alert's date when list_indices shows such names, and keep size small.
|
||||||
|
Report document timestamps exactly as stored (@timestamp).
|
||||||
|
|
||||||
|
On an error: report the exact index, query and error; retry at most once
|
||||||
|
with a simpler query. Do not write query DSL yourself.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- list_indices
|
||||||
|
- count
|
||||||
|
- search
|
||||||
|
- get_document
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: elasticsearch
|
id: elasticsearch
|
||||||
name: Elasticsearch
|
name: Elasticsearch
|
||||||
version: 1.0.0
|
version: 1.1.1
|
||||||
description: "Elasticsearch (REST API) — log search and enrichment for security data: run queries, count and read documents, list indices, index a document, and delete by query. API-key authentication; stdlib-only, no extra Python dependencies. Works with Elastic Security indices."
|
description: "Elasticsearch (REST API) — log search and enrichment for security data: run queries, count and read documents, list indices, index a document, and delete by query. API-key authentication; stdlib-only, no extra Python dependencies. Works with Elastic Security indices."
|
||||||
changelog: "1.0.0 — Initial release: search, count, get document, index document, list indices, delete by query."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 1 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: search, count, get document, index document, list indices, delete by query."
|
||||||
category: siem
|
category: siem
|
||||||
|
|
||||||
# Per-instance configuration. Auth header 'Authorization: ApiKey <api_key>'.
|
# Per-instance configuration. Auth header 'Authorization: ApiKey <api_key>'.
|
||||||
@@ -59,6 +59,7 @@ commands:
|
|||||||
- id: index_document
|
- id: index_document
|
||||||
name: elasticsearch-index-document
|
name: elasticsearch-index-document
|
||||||
description: "Index (create) a document."
|
description: "Index (create) a document."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
index: { type: string, description: "Index name" }
|
index: { type: string, description: "Index name" }
|
||||||
@@ -77,6 +78,7 @@ commands:
|
|||||||
- id: delete_by_query
|
- id: delete_by_query
|
||||||
name: elasticsearch-delete-by-query
|
name: elasticsearch-delete-by-query
|
||||||
description: "Delete documents matching a query."
|
description: "Delete documents matching a query."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
index: { type: string, description: "Index name" }
|
index: { type: string, description: "Index name" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: emailrep
|
id: emailrep
|
||||||
name: EmailRep
|
name: EmailRep
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "EmailRep.io (API) — reputation and risk profile of an email address (deliverability, first/last seen, malicious activity, breaches, profiles), plus reporting an address as malicious. API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "EmailRep.io (API) — reputation and risk profile of an email address (deliverability, first/last seen, malicious activity, breaches, profiles), plus reporting an address as malicious. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: email reputation lookup and malicious-address reporting."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: email reputation lookup and malicious-address reporting."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|
||||||
config_schema:
|
config_schema:
|
||||||
@@ -27,6 +27,7 @@ commands:
|
|||||||
- id: report
|
- id: report
|
||||||
name: emailrep-report
|
name: emailrep-report
|
||||||
description: "Report an email address as malicious to EmailRep.io."
|
description: "Report an email address as malicious to EmailRep.io."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
email: { type: string, description: "Email address to report" }
|
email: { type: string, description: "Email address to report" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: f5_bigip
|
id: f5_bigip
|
||||||
name: F5 BIG-IP
|
name: F5 BIG-IP
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "F5 BIG-IP (iControl REST) — application-delivery visibility and containment: list virtual servers and pools, read pool members, disable a pool member, and add an IP to an internal data-group (block list). HTTP Basic authentication; stdlib-only, no extra Python dependencies."
|
description: "F5 BIG-IP (iControl REST) — application-delivery visibility and containment: list virtual servers and pools, read pool members, disable a pool member, and add an IP to an internal data-group (block list). HTTP Basic authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list virtual servers/pools, get pool members, disable pool member, add data-group record."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: list virtual servers/pools, get pool members, disable pool member, add data-group record."
|
||||||
category: network
|
category: network
|
||||||
|
|
||||||
# Per-instance configuration. HTTP Basic auth against the iControl REST API.
|
# Per-instance configuration. HTTP Basic auth against the iControl REST API.
|
||||||
@@ -56,6 +56,7 @@ commands:
|
|||||||
- id: disable_pool_member
|
- id: disable_pool_member
|
||||||
name: f5-disable-pool-member
|
name: f5-disable-pool-member
|
||||||
description: "Disable a pool member (drains it — containment)."
|
description: "Disable a pool member (drains it — containment)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
pool_name: { type: string, description: "Pool name (partition path)" }
|
pool_name: { type: string, description: "Pool name (partition path)" }
|
||||||
@@ -65,6 +66,7 @@ commands:
|
|||||||
- id: add_datagroup_record
|
- id: add_datagroup_record
|
||||||
name: f5-add-datagroup-record
|
name: f5-add-datagroup-record
|
||||||
description: "Add a record (e.g. an IP) to an internal data-group used by a block iRule."
|
description: "Add a record (e.g. an IP) to an internal data-group used by a block iRule."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
datagroup_name: { type: string, description: "Internal data-group name (partition path)" }
|
datagroup_name: { type: string, description: "Internal data-group name (partition path)" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: forcepoint_dlp
|
id: forcepoint_dlp
|
||||||
name: Forcepoint DLP
|
name: Forcepoint DLP
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Forcepoint DLP (Security Manager REST API v1) — data-loss-prevention incident handling: list incidents in a time window, read an incident, and update incident status/severity/assignment. Token authentication; stdlib-only, no extra Python dependencies."
|
description: "Forcepoint DLP (Security Manager REST API v1) — data-loss-prevention incident handling: list incidents in a time window, read an incident, and update incident status/severity/assignment. Token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list incidents, get incident, update incident."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none: 1 safe_write. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list incidents, get incident, update incident."
|
||||||
category: dlp
|
category: dlp
|
||||||
|
|
||||||
# Per-instance configuration. Credentials are exchanged for an access token
|
# Per-instance configuration. Credentials are exchanged for an access token
|
||||||
@@ -52,6 +52,7 @@ commands:
|
|||||||
- id: update_incident
|
- id: update_incident
|
||||||
name: forcepoint-update-incident
|
name: forcepoint-update-incident
|
||||||
description: "Update DLP incidents (status, severity, or assignee)."
|
description: "Update DLP incidents (status, severity, or assignee)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
incident_ids: { type: string, description: "Comma-separated incident IDs" }
|
incident_ids: { type: string, description: "Comma-separated incident IDs" }
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# The FortiGate operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design: Riposte
|
||||||
|
# refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Rampart
|
||||||
|
title: FortiGate operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
How the FortiGate configuration covers a host or IP: which firewall
|
||||||
|
address objects and address groups contain it, which IPv4 policies use
|
||||||
|
those objects (source, destination, service, accept or deny), and what a
|
||||||
|
custom service object means. This integration has no access to FortiGate
|
||||||
|
traffic logs: it cannot say what traffic a host produced or what was
|
||||||
|
actually blocked. Not for banning IPs or changing objects or policies,
|
||||||
|
not for other vendors' alerts.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id, or the IP or subnet, and the VDOM when the firewall
|
||||||
|
has several.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
The address objects, groups, policies and services that concern the host,
|
||||||
|
each with the command that produced it; "not found" when no object
|
||||||
|
contains it; the exact call and error when one failed. No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call): the Riposte alert. Take the IP, host
|
||||||
|
name and VDOM from its source_ref and raw fields, exactly as written.
|
||||||
|
2. list_addresses (1 call, no name): every address object. Look in the
|
||||||
|
answer yourself for objects whose subnet contains the IP (subnet is
|
||||||
|
"IP netmask"; 255.255.255.255 is that single host) and note their
|
||||||
|
names.
|
||||||
|
3. list_address_groups (1 call, no name): keep the groups whose members
|
||||||
|
include those object names.
|
||||||
|
4. list_policies (1 call, no policy_id): keep the policies whose srcaddr
|
||||||
|
or dstaddr name one of those objects or groups, or "all". Report
|
||||||
|
policyid, name, srcintf, dstintf, srcaddr, dstaddr, service, action
|
||||||
|
and status as the answer gives them.
|
||||||
|
5. list_services (at most 2 calls) only for a service name a kept policy
|
||||||
|
uses that is not a predefined one; pass the name exactly as the policy
|
||||||
|
answer writes it.
|
||||||
|
|
||||||
|
Names are case-sensitive and must be copied from a previous answer, never
|
||||||
|
typed or adapted: a single-object lookup by name answers 404 when the
|
||||||
|
name does not exist. Pass vdom only when the data names one.
|
||||||
|
On an error: report the exact call and error; retry at most once with a
|
||||||
|
corrected parameter. No object containing the IP is "not found".
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- list_addresses
|
||||||
|
- list_address_groups
|
||||||
|
- list_policies
|
||||||
|
- list_services
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: fortigate
|
id: fortigate
|
||||||
name: FortiGate
|
name: FortiGate
|
||||||
version: 1.0.0
|
version: 1.1.1
|
||||||
description: "Fortinet FortiGate (FortiOS REST API v2) — network containment: ban/unban IP addresses (quarantine), manage firewall address objects and address groups, and read firewall policies and services. API-token authentication; stdlib-only, no extra Python dependencies. Supports multi-VDOM."
|
description: "Fortinet FortiGate (FortiOS REST API v2) — network containment: ban/unban IP addresses (quarantine), manage firewall address objects and address groups, and read firewall policies and services. API-token authentication; stdlib-only, no extra Python dependencies. Supports multi-VDOM."
|
||||||
changelog: "1.0.0 — Initial release: ban/unban/list banned IPs, address object create/delete/list, address-group update/list, firewall policy and service listing."
|
changelog: "1.1.0 — Declare the risk tier on the 5 commands that carried none: 1 safe_write, 4 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: ban/unban/list banned IPs, address object create/delete/list, address-group update/list, firewall policy and service listing."
|
||||||
category: network
|
category: network
|
||||||
|
|
||||||
# Per-instance configuration. The API token is sent as 'Authorization: Bearer <token>'.
|
# Per-instance configuration. The API token is sent as 'Authorization: Bearer <token>'.
|
||||||
@@ -31,6 +31,7 @@ commands:
|
|||||||
- id: ban_ip
|
- id: ban_ip
|
||||||
name: fortigate-ban-ip
|
name: fortigate-ban-ip
|
||||||
description: "Quarantine one or more source IP addresses (adds them to the FortiGate banned-IP list)."
|
description: "Quarantine one or more source IP addresses (adds them to the FortiGate banned-IP list)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
ip_addresses: { type: string, description: "Comma-separated IPv4/IPv6 addresses to ban" }
|
ip_addresses: { type: string, description: "Comma-separated IPv4/IPv6 addresses to ban" }
|
||||||
@@ -41,6 +42,7 @@ commands:
|
|||||||
- id: unban_ip
|
- id: unban_ip
|
||||||
name: fortigate-unban-ip
|
name: fortigate-unban-ip
|
||||||
description: "Remove one or more IP addresses from the FortiGate banned-IP list."
|
description: "Remove one or more IP addresses from the FortiGate banned-IP list."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
ip_addresses: { type: string, description: "Comma-separated addresses to unban (empty = clear all)" }
|
ip_addresses: { type: string, description: "Comma-separated addresses to unban (empty = clear all)" }
|
||||||
@@ -59,6 +61,7 @@ commands:
|
|||||||
- id: create_address
|
- id: create_address
|
||||||
name: fortigate-create-firewall-address
|
name: fortigate-create-firewall-address
|
||||||
description: "Create a firewall address object (subnet type)."
|
description: "Create a firewall address object (subnet type)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Address object name" }
|
name: { type: string, description: "Address object name" }
|
||||||
@@ -70,6 +73,7 @@ commands:
|
|||||||
- id: delete_address
|
- id: delete_address
|
||||||
name: fortigate-delete-firewall-address
|
name: fortigate-delete-firewall-address
|
||||||
description: "Delete a firewall address object by name."
|
description: "Delete a firewall address object by name."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Address object name" }
|
name: { type: string, description: "Address object name" }
|
||||||
@@ -89,6 +93,7 @@ commands:
|
|||||||
- id: update_address_group
|
- id: update_address_group
|
||||||
name: fortigate-update-firewall-address-group
|
name: fortigate-update-firewall-address-group
|
||||||
description: "Replace the members of a firewall address group (e.g. a blocked-hosts group referenced by a deny policy)."
|
description: "Replace the members of a firewall address group (e.g. a blocked-hosts group referenced by a deny policy)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Address group name" }
|
name: { type: string, description: "Address group name" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: freshservice
|
id: freshservice
|
||||||
name: Freshservice
|
name: Freshservice
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Freshservice (REST API v2) — IT service management: create, read, update and list tickets, add notes (public or private), and list agents. API-key (Basic) authentication; stdlib-only, no extra Python dependencies."
|
description: "Freshservice (REST API v2) — IT service management: create, read, update and list tickets, add notes (public or private), and list agents. API-key (Basic) authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: create/get/update/list tickets, add note, list agents."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none: 3 safe_write. An unset risk is read as destructive by the agent approval gate, so the 3 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: create/get/update/list tickets, add note, list agents."
|
||||||
category: ticketing
|
category: ticketing
|
||||||
|
|
||||||
# Per-instance configuration. Auth is HTTP Basic with '<api_key>:X'.
|
# Per-instance configuration. Auth is HTTP Basic with '<api_key>:X'.
|
||||||
@@ -23,6 +23,7 @@ commands:
|
|||||||
- id: create_ticket
|
- id: create_ticket
|
||||||
name: freshservice-create-ticket
|
name: freshservice-create-ticket
|
||||||
description: "Create a ticket."
|
description: "Create a ticket."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
subject: { type: string, description: "Ticket subject" }
|
subject: { type: string, description: "Ticket subject" }
|
||||||
@@ -45,6 +46,7 @@ commands:
|
|||||||
- id: update_ticket
|
- id: update_ticket
|
||||||
name: freshservice-update-ticket
|
name: freshservice-update-ticket
|
||||||
description: "Update a ticket (status, priority, or assignment)."
|
description: "Update a ticket (status, priority, or assignment)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
ticket_id: { type: string, description: "Ticket ID" }
|
ticket_id: { type: string, description: "Ticket ID" }
|
||||||
@@ -66,6 +68,7 @@ commands:
|
|||||||
- id: add_note
|
- id: add_note
|
||||||
name: freshservice-add-note
|
name: freshservice-add-note
|
||||||
description: "Add a note to a ticket (public or private)."
|
description: "Add a note to a ticket (public or private)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
ticket_id: { type: string, description: "Ticket ID" }
|
ticket_id: { type: string, description: "Ticket ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: gcp_security
|
id: gcp_security
|
||||||
name: Google Cloud
|
name: Google Cloud
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Google Cloud Platform (Compute Engine + Security Command Center) — cloud containment: list/create/delete VPC firewall rules (deny to isolate), list and stop Compute Engine instances, and list Security Command Center findings. Authenticates with a Google service account (RS256 JWT bearer flow). Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)."
|
description: "Google Cloud Platform (Compute Engine + Security Command Center) — cloud containment: list/create/delete VPC firewall rules (deny to isolate), list and stop Compute Engine instances, and list Security Command Center findings. Authenticates with a Google service account (RS256 JWT bearer flow). Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)."
|
||||||
changelog: "1.0.0 — Initial release: list/create/delete firewall rules, list/stop instances, list Security Command Center findings."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: list/create/delete firewall rules, list/stop instances, list Security Command Center findings."
|
||||||
category: cloud
|
category: cloud
|
||||||
|
|
||||||
# Per-instance configuration. The scripts build a signed RS256 assertion from the
|
# Per-instance configuration. The scripts build a signed RS256 assertion from the
|
||||||
@@ -37,6 +37,7 @@ commands:
|
|||||||
- id: create_firewall
|
- id: create_firewall
|
||||||
name: gcp-create-firewall
|
name: gcp-create-firewall
|
||||||
description: "Create a VPC firewall rule (e.g. a deny rule to isolate targets)."
|
description: "Create a VPC firewall rule (e.g. a deny rule to isolate targets)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Firewall rule name" }
|
name: { type: string, description: "Firewall rule name" }
|
||||||
@@ -53,6 +54,7 @@ commands:
|
|||||||
- id: delete_firewall
|
- id: delete_firewall
|
||||||
name: gcp-delete-firewall
|
name: gcp-delete-firewall
|
||||||
description: "Delete a VPC firewall rule by name."
|
description: "Delete a VPC firewall rule by name."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Firewall rule name" }
|
name: { type: string, description: "Firewall rule name" }
|
||||||
@@ -70,6 +72,7 @@ commands:
|
|||||||
- id: stop_instance
|
- id: stop_instance
|
||||||
name: gcp-stop-instance
|
name: gcp-stop-instance
|
||||||
description: "Stop a Compute Engine instance (containment)."
|
description: "Stop a Compute Engine instance (containment)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
zone: { type: string, description: "Zone of the instance" }
|
zone: { type: string, description: "Zone of the instance" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: github
|
id: github
|
||||||
name: GitHub
|
name: GitHub
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "GitHub (REST API) — code security and issue tracking: list secret-scanning, code-scanning and Dependabot alerts for a repository, read a repository, and create an issue. Personal-access-token authentication; stdlib-only, no extra Python dependencies."
|
description: "GitHub (REST API) — code security and issue tracking: list secret-scanning, code-scanning and Dependabot alerts for a repository, read a repository, and create an issue. Personal-access-token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list secret/code/Dependabot alerts, get repo, create issue."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none: 1 safe_write. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list secret/code/Dependabot alerts, get repo, create issue."
|
||||||
category: devsecops
|
category: devsecops
|
||||||
|
|
||||||
# Per-instance configuration. Auth header 'Authorization: Bearer <token>'.
|
# Per-instance configuration. Auth header 'Authorization: Bearer <token>'.
|
||||||
@@ -62,6 +62,7 @@ commands:
|
|||||||
- id: create_issue
|
- id: create_issue
|
||||||
name: github-create-issue
|
name: github-create-issue
|
||||||
description: "Create an issue in a repository."
|
description: "Create an issue in a repository."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
owner: { type: string, description: "Repository owner" }
|
owner: { type: string, description: "Repository owner" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: gitlab
|
id: gitlab
|
||||||
name: GitLab
|
name: GitLab
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "GitLab (API v4) — project security and issue tracking: list project vulnerabilities, list and create issues, read a project, and list audit events. Private-token authentication; stdlib-only, no extra Python dependencies."
|
description: "GitLab (API v4) — project security and issue tracking: list project vulnerabilities, list and create issues, read a project, and list audit events. Private-token authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: list vulnerabilities, list/create issues, get project, list audit events."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none: 1 safe_write. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list vulnerabilities, list/create issues, get project, list audit events."
|
||||||
category: devsecops
|
category: devsecops
|
||||||
|
|
||||||
# Per-instance configuration. Auth header 'PRIVATE-TOKEN: <token>'.
|
# Per-instance configuration. Auth header 'PRIVATE-TOKEN: <token>'.
|
||||||
@@ -42,6 +42,7 @@ commands:
|
|||||||
- id: create_issue
|
- id: create_issue
|
||||||
name: gitlab-create-issue
|
name: gitlab-create-issue
|
||||||
description: "Create an issue in a project."
|
description: "Create an issue in a project."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
project_id: { type: string, description: "Project ID or path" }
|
project_id: { type: string, description: "Project ID or path" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: glimps
|
id: glimps
|
||||||
name: GLIMPS Detect
|
name: GLIMPS Detect
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "GLIMPS Detect (GDetect API lite v2) — deep malware analysis: submit a file for analysis, poll the result by UUID, and look up a file by SHA-256. Token authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
|
description: "GLIMPS Detect (GDetect API lite v2) — deep malware analysis: submit a file for analysis, poll the result by UUID, and look up a file by SHA-256. Token authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
|
||||||
changelog: "1.0.0 — Initial release: submit file, get result, search by hash."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none: 1 safe_write. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: submit file, get result, search by hash."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|
||||||
# Per-instance configuration. Auth header 'X-Auth-Token: <api_token>'.
|
# Per-instance configuration. Auth header 'X-Auth-Token: <api_token>'.
|
||||||
@@ -23,6 +23,7 @@ commands:
|
|||||||
- id: submit_file
|
- id: submit_file
|
||||||
name: glimps-submit-file
|
name: glimps-submit-file
|
||||||
description: "Submit a file (base64) for analysis. Returns an analysis UUID."
|
description: "Submit a file (base64) for analysis. Returns an analysis UUID."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_name: { type: string, description: "File name" }
|
file_name: { type: string, description: "File name" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: gmail_single_user
|
id: gmail_single_user
|
||||||
name: Gmail Single User
|
name: Gmail Single User
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Gmail for a single mailbox over OAuth 2.0 (no service account, no domain-wide delegation) — search/read messages, send and reply (with attachments), retrieve attachments, and ingest messages (get_incidents) with an OCSF mapper. Uses a per-mailbox OAuth refresh token; stdlib-only, no extra Python dependencies."
|
description: "Gmail for a single mailbox over OAuth 2.0 (no service account, no domain-wide delegation) — search/read messages, send and reply (with attachments), retrieve attachments, and ingest messages (get_incidents) with an OCSF mapper. Uses a per-mailbox OAuth refresh token; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: OAuth authorization link + code exchange, connectivity test, message ingestion with OCSF mapper, send/reply mail with attachments, and attachment retrieval."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none: 2 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: OAuth authorization link + code exchange, connectivity test, message ingestion with OCSF mapper, send/reply mail with attachments, and attachment retrieval."
|
||||||
category: email
|
category: email
|
||||||
|
|
||||||
# Per-instance configuration. This integration authenticates as ONE Gmail user
|
# Per-instance configuration. This integration authenticates as ONE Gmail user
|
||||||
@@ -55,6 +55,7 @@ commands:
|
|||||||
- id: exchange_code
|
- id: exchange_code
|
||||||
name: gmail-single-user-exchange-code
|
name: gmail-single-user-exchange-code
|
||||||
description: "Exchange an OAuth authorization code for a refresh token (paste the refresh_token into the instance config afterwards)."
|
description: "Exchange an OAuth authorization code for a refresh token (paste the refresh_token into the instance config afterwards)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
code: { type: string, description: "Authorization code from the consent redirect" }
|
code: { type: string, description: "Authorization code from the consent redirect" }
|
||||||
@@ -112,6 +113,7 @@ commands:
|
|||||||
- id: send_mail
|
- id: send_mail
|
||||||
name: gmail-single-user-send-mail
|
name: gmail-single-user-send-mail
|
||||||
description: "Send an email from the mailbox (optional single attachment as base64)."
|
description: "Send an email from the mailbox (optional single attachment as base64)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
to: { type: string, description: "Recipient(s), comma-separated" }
|
to: { type: string, description: "Recipient(s), comma-separated" }
|
||||||
@@ -128,6 +130,7 @@ commands:
|
|||||||
- id: reply_mail
|
- id: reply_mail
|
||||||
name: gmail-single-user-reply-mail
|
name: gmail-single-user-reply-mail
|
||||||
description: "Reply to a message (sets In-Reply-To/References so it threads correctly)."
|
description: "Reply to a message (sets In-Reply-To/References so it threads correctly)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
to: { type: string, description: "Recipient(s), comma-separated" }
|
to: { type: string, description: "Recipient(s), comma-separated" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: gmail
|
id: gmail
|
||||||
name: Gmail
|
name: Gmail
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Gmail API + Directory API — mailbox search/read/send/trash/labels, attachments retrieval, vacation auto-reply, filters, forwarding addresses, delegates, and Workspace user administration; message ingestion (get_incidents) with an OCSF mapper; service-account auth with domain-wide delegation. Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)."
|
description: "Gmail API + Directory API — mailbox search/read/send/trash/labels, attachments retrieval, vacation auto-reply, filters, forwarding addresses, delegates, and Workspace user administration; message ingestion (get_incidents) with an OCSF mapper; service-account auth with domain-wide delegation. Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)."
|
||||||
changelog: "1.0.0 — Initial release: mailbox commands (search, get mail/thread, send with attachments, trash/permanent delete, label moves, attachments download, labels list), vacation auto-reply get/set, mail filters (add/list/remove), forwarding addresses and auto-forwarding, delegates, Workspace user administration (list/get/create/delete, password reset, directory visibility), and message ingestion with a bundled OCSF mapper."
|
changelog: "1.1.0 — Declare the risk tier on the 15 commands that carried none: 4 safe_write, 11 destructive. An unset risk is read as destructive by the agent approval gate, so the 4 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: mailbox commands (search, get mail/thread, send with attachments, trash/permanent delete, label moves, attachments download, labels list), vacation auto-reply get/set, mail filters (add/list/remove), forwarding addresses and auto-forwarding, delegates, Workspace user administration (list/get/create/delete, password reset, directory visibility), and message ingestion with a bundled OCSF mapper."
|
||||||
category: email
|
category: email
|
||||||
|
|
||||||
# Authentication: Google service account with domain-wide delegation.
|
# Authentication: Google service account with domain-wide delegation.
|
||||||
@@ -84,6 +84,7 @@ commands:
|
|||||||
- id: send_mail
|
- id: send_mail
|
||||||
name: gmail-send-mail
|
name: gmail-send-mail
|
||||||
description: "Send an email from the impersonated mailbox — plain text and/or HTML body, optional single attachment (base64), reply threading via In-Reply-To/References."
|
description: "Send an email from the impersonated mailbox — plain text and/or HTML body, optional single attachment (base64), reply threading via In-Reply-To/References."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
to: { type: string, description: "Recipients, comma-separated" }
|
to: { type: string, description: "Recipients, comma-separated" }
|
||||||
@@ -103,6 +104,7 @@ commands:
|
|||||||
- id: delete_mail
|
- id: delete_mail
|
||||||
name: gmail-delete-mail
|
name: gmail-delete-mail
|
||||||
description: "Move a message to Trash, or delete it permanently (bypasses Trash) when permanent is true."
|
description: "Move a message to Trash, or delete it permanently (bypasses Trash) when permanent is true."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
message_id: { type: string, description: "Gmail message ID" }
|
message_id: { type: string, description: "Gmail message ID" }
|
||||||
@@ -113,6 +115,7 @@ commands:
|
|||||||
- id: move_mail
|
- id: move_mail
|
||||||
name: gmail-move-mail
|
name: gmail-move-mail
|
||||||
description: "Add and/or remove labels on a message (move between folders — e.g. remove INBOX, add SPAM or a quarantine label)."
|
description: "Add and/or remove labels on a message (move between folders — e.g. remove INBOX, add SPAM or a quarantine label)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
message_id: { type: string, description: "Gmail message ID" }
|
message_id: { type: string, description: "Gmail message ID" }
|
||||||
@@ -154,6 +157,7 @@ commands:
|
|||||||
- id: autoreply_set
|
- id: autoreply_set
|
||||||
name: gmail-autoreply-set
|
name: gmail-autoreply-set
|
||||||
description: "Enable, update or disable the mailbox's vacation auto-reply."
|
description: "Enable, update or disable the mailbox's vacation auto-reply."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
enable: { type: boolean, description: "Enable the auto-reply (default true; false disables it)" }
|
enable: { type: boolean, description: "Enable the auto-reply (default true; false disables it)" }
|
||||||
@@ -172,6 +176,7 @@ commands:
|
|||||||
- id: add_filter
|
- id: add_filter
|
||||||
name: gmail-add-filter
|
name: gmail-add-filter
|
||||||
description: "Create a mail filter on the mailbox (criteria: from/to/subject/query/has_attachment; actions: add/remove labels, forward)."
|
description: "Create a mail filter on the mailbox (criteria: from/to/subject/query/has_attachment; actions: add/remove labels, forward)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
from: { type: string, description: "Criteria — sender address" }
|
from: { type: string, description: "Criteria — sender address" }
|
||||||
@@ -197,6 +202,7 @@ commands:
|
|||||||
- id: remove_filter
|
- id: remove_filter
|
||||||
name: gmail-remove-filter
|
name: gmail-remove-filter
|
||||||
description: "Delete a mail filter by ID."
|
description: "Delete a mail filter by ID."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
filter_id: { type: string, description: "Filter ID (from gmail-list-filters)" }
|
filter_id: { type: string, description: "Filter ID (from gmail-list-filters)" }
|
||||||
@@ -208,6 +214,7 @@ commands:
|
|||||||
- id: forwarding_add
|
- id: forwarding_add
|
||||||
name: gmail-forwarding-add
|
name: gmail-forwarding-add
|
||||||
description: "Register a forwarding address on the mailbox (Google may require verification before it becomes usable)."
|
description: "Register a forwarding address on the mailbox (Google may require verification before it becomes usable)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
forwarding_email: { type: string, description: "Forwarding address to register" }
|
forwarding_email: { type: string, description: "Forwarding address to register" }
|
||||||
@@ -226,6 +233,7 @@ commands:
|
|||||||
- id: forwarding_remove
|
- id: forwarding_remove
|
||||||
name: gmail-forwarding-remove
|
name: gmail-forwarding-remove
|
||||||
description: "Delete a forwarding address from the mailbox."
|
description: "Delete a forwarding address from the mailbox."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
forwarding_email: { type: string, description: "Forwarding address to remove" }
|
forwarding_email: { type: string, description: "Forwarding address to remove" }
|
||||||
@@ -235,6 +243,7 @@ commands:
|
|||||||
- id: forwarding_update
|
- id: forwarding_update
|
||||||
name: gmail-forwarding-update
|
name: gmail-forwarding-update
|
||||||
description: "Enable auto-forwarding of the mailbox to a verified forwarding address, with a disposition for the forwarded copy."
|
description: "Enable auto-forwarding of the mailbox to a verified forwarding address, with a disposition for the forwarded copy."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
forwarding_email: { type: string, description: "Verified forwarding address to auto-forward to" }
|
forwarding_email: { type: string, description: "Verified forwarding address to auto-forward to" }
|
||||||
@@ -247,6 +256,7 @@ commands:
|
|||||||
- id: delegate_add
|
- id: delegate_add
|
||||||
name: gmail-delegate-add
|
name: gmail-delegate-add
|
||||||
description: "Grant a delegate access to the mailbox (delegate can read, send and delete on the owner's behalf)."
|
description: "Grant a delegate access to the mailbox (delegate can read, send and delete on the owner's behalf)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
delegate_email: { type: string, description: "Delegate's email address (same Workspace domain)" }
|
delegate_email: { type: string, description: "Delegate's email address (same Workspace domain)" }
|
||||||
@@ -256,6 +266,7 @@ commands:
|
|||||||
- id: delegate_remove
|
- id: delegate_remove
|
||||||
name: gmail-delegate-remove
|
name: gmail-delegate-remove
|
||||||
description: "Revoke a delegate's access to the mailbox."
|
description: "Revoke a delegate's access to the mailbox."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
delegate_email: { type: string, description: "Delegate's email address to revoke" }
|
delegate_email: { type: string, description: "Delegate's email address to revoke" }
|
||||||
@@ -289,6 +300,7 @@ commands:
|
|||||||
- id: create_user
|
- id: create_user
|
||||||
name: gmail-create-user
|
name: gmail-create-user
|
||||||
description: "Create a Workspace user (Directory API)."
|
description: "Create a Workspace user (Directory API)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
email: { type: string, description: "New user's primary email address" }
|
email: { type: string, description: "New user's primary email address" }
|
||||||
@@ -300,6 +312,7 @@ commands:
|
|||||||
- id: delete_user
|
- id: delete_user
|
||||||
name: gmail-delete-user
|
name: gmail-delete-user
|
||||||
description: "Delete a Workspace user (Directory API)."
|
description: "Delete a Workspace user (Directory API)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
user_key: { type: string, description: "User's primary email, alias email or unique ID" }
|
user_key: { type: string, description: "User's primary email, alias email or unique ID" }
|
||||||
@@ -308,6 +321,7 @@ commands:
|
|||||||
- id: set_password
|
- id: set_password
|
||||||
name: gmail-set-password
|
name: gmail-set-password
|
||||||
description: "Reset a Workspace user's password (Directory API) — a common containment step for a compromised account."
|
description: "Reset a Workspace user's password (Directory API) — a common containment step for a compromised account."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
user_key: { type: string, description: "User's primary email, alias email or unique ID" }
|
user_key: { type: string, description: "User's primary email, alias email or unique ID" }
|
||||||
@@ -317,6 +331,7 @@ commands:
|
|||||||
- id: hide_user
|
- id: hide_user
|
||||||
name: gmail-hide-user
|
name: gmail-hide-user
|
||||||
description: "Set a Workspace user's global directory visibility (Directory API)."
|
description: "Set a Workspace user's global directory visibility (Directory API)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
user_key: { type: string, description: "User's primary email, alias email or unique ID" }
|
user_key: { type: string, description: "User's primary email, alias email or unique ID" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: google_calendar
|
id: google_calendar
|
||||||
name: Google Calendar
|
name: Google Calendar
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Google Calendar (Calendar API v3) — manage calendar access control lists: grant a role on a calendar to a user, group, domain or the public, and list the existing ACL rules. Service-account authentication with domain-wide delegation. Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)."
|
description: "Google Calendar (Calendar API v3) — manage calendar access control lists: grant a role on a calendar to a user, group, domain or the public, and list the existing ACL rules. Service-account authentication with domain-wide delegation. Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)."
|
||||||
changelog: "1.0.0 — Initial release: ACL rule creation and listing on user calendars."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: ACL rule creation and listing on user calendars."
|
||||||
category: productivity
|
category: productivity
|
||||||
|
|
||||||
# Per-instance configuration. Create a Google Cloud service account with
|
# Per-instance configuration. Create a Google Cloud service account with
|
||||||
@@ -27,6 +27,7 @@ commands:
|
|||||||
- id: acl_add
|
- id: acl_add
|
||||||
name: google-calendar-acl-add
|
name: google-calendar-acl-add
|
||||||
description: "Create an access control rule on a calendar (grant a role to a user, group, domain or the public)."
|
description: "Create an access control rule on a calendar (grant a role to a user, group, domain or the public)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
calendar_id: { type: string, description: "Calendar identifier — use 'primary' for the impersonated user's main calendar" }
|
calendar_id: { type: string, description: "Calendar identifier — use 'primary' for the impersonated user's main calendar" }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: google_drive
|
id: google_drive
|
||||||
name: Google Drive
|
name: Google Drive
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: "Google Drive (Drive API v3) — manage shared drives (create/list/get/delete), track file changes (start page token + change lists), query Drive activity, search and read files, create folders and metadata, upload/replace/download file content, copy, move and delete files, manage permissions (list/create/update/delete) and work with Drive labels. Authenticates with a Google service account (JWT bearer flow with domain-wide delegation to impersonate a Workspace user). Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)."
|
description: "Google Drive (Drive API v3) — manage shared drives (create/list/get/delete), track file changes (start page token + change lists), query Drive activity, search and read files, create folders and metadata, upload/replace/download file content, copy, move and delete files, manage permissions (list/create/update/delete) and work with Drive labels. Authenticates with a Google service account (JWT bearer flow with domain-wide delegation to impersonate a Workspace user). Runs on a remote engine. Requires the Python 'PyJWT' and 'cryptography' libraries on the engine host (pip install pyjwt cryptography)."
|
||||||
changelog: "1.0.0 — Initial release: shared drive management, changes tracking, Drive activity queries, file search/get/create, content upload/replace/download, copy/move/delete, parents, permissions management, Drive labels, test connection."
|
changelog: "1.1.0 — Declare the risk tier on the 12 commands that carried none: 6 safe_write, 6 destructive. An unset risk is read as destructive by the agent approval gate, so the 6 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: shared drive management, changes tracking, Drive activity queries, file search/get/create, content upload/replace/download, copy/move/delete, parents, permissions management, Drive labels, test connection."
|
||||||
category: productivity
|
category: productivity
|
||||||
|
|
||||||
# Per-instance configuration. Authentication uses the Google service-account
|
# Per-instance configuration. Authentication uses the Google service-account
|
||||||
@@ -29,6 +29,7 @@ commands:
|
|||||||
- id: drive_create
|
- id: drive_create
|
||||||
name: google-drive-drive-create
|
name: google-drive-drive-create
|
||||||
description: "Create a new shared drive."
|
description: "Create a new shared drive."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
name: { type: string, description: "Name of the shared drive to create" }
|
name: { type: string, description: "Name of the shared drive to create" }
|
||||||
@@ -63,6 +64,7 @@ commands:
|
|||||||
- id: drive_delete
|
- id: drive_delete
|
||||||
name: google-drive-drive-delete
|
name: google-drive-drive-delete
|
||||||
description: "Permanently delete a shared drive."
|
description: "Permanently delete a shared drive."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
drive_id: { type: string, description: "ID of the shared drive to delete" }
|
drive_id: { type: string, description: "ID of the shared drive to delete" }
|
||||||
@@ -141,6 +143,7 @@ commands:
|
|||||||
- id: file_create
|
- id: file_create
|
||||||
name: google-drive-file-create
|
name: google-drive-file-create
|
||||||
description: "Create a folder or an empty file (metadata only — use file-upload for content)."
|
description: "Create a folder or an empty file (metadata only — use file-upload for content)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_name: { type: string, description: "Name of the file or folder to create" }
|
file_name: { type: string, description: "Name of the file or folder to create" }
|
||||||
@@ -153,6 +156,7 @@ commands:
|
|||||||
- id: file_upload
|
- id: file_upload
|
||||||
name: google-drive-file-upload
|
name: google-drive-file-upload
|
||||||
description: "Upload a new file with content (base64-encoded)."
|
description: "Upload a new file with content (base64-encoded)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_name: { type: string, description: "Name of the file to create" }
|
file_name: { type: string, description: "Name of the file to create" }
|
||||||
@@ -164,6 +168,7 @@ commands:
|
|||||||
- id: file_replace
|
- id: file_replace
|
||||||
name: google-drive-file-replace
|
name: google-drive-file-replace
|
||||||
description: "Replace an existing file's content (base64-encoded)."
|
description: "Replace an existing file's content (base64-encoded)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "ID of the file to update" }
|
file_id: { type: string, description: "ID of the file to update" }
|
||||||
@@ -184,6 +189,7 @@ commands:
|
|||||||
- id: file_copy
|
- id: file_copy
|
||||||
name: google-drive-file-copy
|
name: google-drive-file-copy
|
||||||
description: "Copy a file, optionally with a new title."
|
description: "Copy a file, optionally with a new title."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "ID of the file to copy" }
|
file_id: { type: string, description: "ID of the file to copy" }
|
||||||
@@ -194,6 +200,7 @@ commands:
|
|||||||
- id: file_move
|
- id: file_move
|
||||||
name: google-drive-file-move
|
name: google-drive-file-move
|
||||||
description: "Move a file between folders (add one parent, remove another)."
|
description: "Move a file between folders (add one parent, remove another)."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "ID of the file to move" }
|
file_id: { type: string, description: "ID of the file to move" }
|
||||||
@@ -205,6 +212,7 @@ commands:
|
|||||||
- id: file_delete
|
- id: file_delete
|
||||||
name: google-drive-file-delete
|
name: google-drive-file-delete
|
||||||
description: "Delete a file — permanently, or move it to the trash with soft_delete."
|
description: "Delete a file — permanently, or move it to the trash with soft_delete."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "ID of the file to delete" }
|
file_id: { type: string, description: "ID of the file to delete" }
|
||||||
@@ -238,6 +246,7 @@ commands:
|
|||||||
- id: permission_create
|
- id: permission_create
|
||||||
name: google-drive-permission-create
|
name: google-drive-permission-create
|
||||||
description: "Grant a permission on a file or shared drive (share with a user, group, domain or anyone)."
|
description: "Grant a permission on a file or shared drive (share with a user, group, domain or anyone)."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "ID of the file or shared drive" }
|
file_id: { type: string, description: "ID of the file or shared drive" }
|
||||||
@@ -254,6 +263,7 @@ commands:
|
|||||||
- id: permission_update
|
- id: permission_update
|
||||||
name: google-drive-permission-update
|
name: google-drive-permission-update
|
||||||
description: "Update a permission's role or expiration time."
|
description: "Update a permission's role or expiration time."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "ID of the file or shared drive" }
|
file_id: { type: string, description: "ID of the file or shared drive" }
|
||||||
@@ -266,6 +276,7 @@ commands:
|
|||||||
- id: permission_delete
|
- id: permission_delete
|
||||||
name: google-drive-permission-delete
|
name: google-drive-permission-delete
|
||||||
description: "Revoke a permission from a file or shared drive."
|
description: "Revoke a permission from a file or shared drive."
|
||||||
|
risk: destructive
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "ID of the file or shared drive" }
|
file_id: { type: string, description: "ID of the file or shared drive" }
|
||||||
@@ -295,6 +306,7 @@ commands:
|
|||||||
- id: modify_label
|
- id: modify_label
|
||||||
name: google-drive-modify-label
|
name: google-drive-modify-label
|
||||||
description: "Apply, update or remove a Drive label on a file."
|
description: "Apply, update or remove a Drive label on a file."
|
||||||
|
risk: safe_write
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
file_id: { type: string, description: "ID of the file" }
|
file_id: { type: string, description: "ID of the file" }
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# The GreyNoise operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design.
|
||||||
|
key: operator
|
||||||
|
handle: Hush
|
||||||
|
title: GreyNoise operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Whether an IP address is internet background noise (a mass scanner seen
|
||||||
|
by GreyNoise sensors) or a known common business service (RIOT: CDNs,
|
||||||
|
Office365, Google DNS…), and what GreyNoise says about it: classification
|
||||||
|
(benign / malicious / unknown), actor, tags, first/last seen, scan
|
||||||
|
timeline. IPs only. Not for hashes, domains or URLs, and not for whether
|
||||||
|
an IP targeted us specifically.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The IP addresses to check (public IPs; private ranges are never in
|
||||||
|
GreyNoise). Or a Riposte alert id, to pick the IPs from it.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Per IP, facts from GreyNoise with the command that produced each: seen or
|
||||||
|
not, classification, actor, tags, RIOT name and category, first/last seen.
|
||||||
|
"not seen by GreyNoise" when it was not observed. Classifications are
|
||||||
|
attributed to GreyNoise, never your own verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. Only if you were given a Riposte alert id: get_incident_context (1 call)
|
||||||
|
and pick the public IPs from it. Otherwise skip.
|
||||||
|
2. Per IP: ip_quick_check(ip=<ip>) first. It tells whether the IP is noise
|
||||||
|
and whether it is RIOT, in one cheap call.
|
||||||
|
3. Then per IP, only one of:
|
||||||
|
- noise true: ip_context(ip=<ip>) for classification, actor, tags,
|
||||||
|
first_seen / last_seen.
|
||||||
|
- riot true: riot(ip=<ip>) for the service name, category and trust
|
||||||
|
level.
|
||||||
|
- neither: stop there for this IP and report "not seen by GreyNoise".
|
||||||
|
4. timeline(ip=<ip>, days 30) only when asked when the scanning started or
|
||||||
|
whether it is still going.
|
||||||
|
5. query and stats (GNQL) only when the lead asks for other IPs sharing a
|
||||||
|
tag or actor. Use a query only if the lead hands it to you or it is a
|
||||||
|
plain value from a previous answer; do not invent GNQL syntax.
|
||||||
|
|
||||||
|
Traps:
|
||||||
|
- "Not seen" is an answer, not an error: an IP absent from GreyNoise that
|
||||||
|
hits us is more likely targeted than background noise. Say only what
|
||||||
|
GreyNoise said; do not conclude for the lead.
|
||||||
|
- riot answers 404 for an IP that is not a known business service: that
|
||||||
|
means "not RIOT", not a failure.
|
||||||
|
- Private, loopback or link-local IPs (10., 172.16-31., 192.168., 127.,
|
||||||
|
169.254.) are never in GreyNoise: skip them and say so.
|
||||||
|
- HTTP 401/403 on ip_context, query or stats can mean the key's plan does
|
||||||
|
not include that endpoint: report the call and error, do not retry.
|
||||||
|
- HTTP 429 is the quota: report it and stop.
|
||||||
|
- Copy IPs only from what you were given or a previous answer. Report
|
||||||
|
dates exactly as the data gives them.
|
||||||
|
- On any other error: report the exact call and error, retry at most once.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- ip_quick_check
|
||||||
|
- ip_context
|
||||||
|
- riot
|
||||||
|
- timeline
|
||||||
|
- query
|
||||||
|
- stats
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
id: greynoise
|
id: greynoise
|
||||||
name: GreyNoise
|
name: GreyNoise
|
||||||
version: 1.0.0
|
version: 1.0.1
|
||||||
description: "GreyNoise (API v2/v3) — tell internet background-noise scanners from targeted activity: IP context and quick classification, RIOT common-business-service lookup, GNQL query and stats, and IP scan timeline. API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "GreyNoise (API v2/v3) — tell internet background-noise scanners from targeted activity: IP context and quick classification, RIOT common-business-service lookup, GNQL query and stats, and IP scan timeline. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: IP context, quick check, RIOT, GNQL query and stats, and IP timeline."
|
changelog: "1.0.0 — Initial release: IP context, quick check, RIOT, GNQL query and stats, and IP timeline."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# The HarfangLab EDR operator. Installed with the integration by Riposte
|
||||||
|
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
|
||||||
|
# Riposte refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Nyx
|
||||||
|
title: HarfangLab EDR operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 13
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Anything HarfangLab can tell about an endpoint: the threat behind a
|
||||||
|
HarfangLab alert and its security events (rule, process, command line,
|
||||||
|
user), the process graph, the host's details, its process, network, DNS and
|
||||||
|
Windows logon telemetry around the alert, where else a hash was seen in the
|
||||||
|
fleet, and quarantined files. Not for other vendors' alerts, and never for
|
||||||
|
isolation, jobs or remediation: it only reads.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id or the HarfangLab threat id. For a pivot without an
|
||||||
|
alert: the hostname, a hash, a user, and a time window.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from HarfangLab, each with the command that produced it; "not found"
|
||||||
|
when a lookup came back empty; the exact call and error when one failed.
|
||||||
|
No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 13 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call). The raw fields are a HarfangLab threat:
|
||||||
|
id (the threat id), top_agents[0].agent_hostname, top_impacted_users,
|
||||||
|
top_rules, first_seen / last_seen.
|
||||||
|
2. get_threat(threat_id) (1 call), then threat_security_events(threat_id,
|
||||||
|
max_results=50) (1 call): the alerts inside the threat. Say if
|
||||||
|
"truncated" is true.
|
||||||
|
3. alert_details(alert_id) (1 call) on the most severe alert: the rule,
|
||||||
|
the process and its process unique id. Then
|
||||||
|
telemetry_process_graph(process_uuid=<that id>) (1 call) for the tree.
|
||||||
|
4. Host: endpoint_search(hostname=<name>) (1 call) — the name, not an id.
|
||||||
|
5. Telemetry around the alert, at most 4 calls, with hostname=<name> and
|
||||||
|
from_date / to_date: telemetry_processes, telemetry_network,
|
||||||
|
telemetry_dns, telemetry_authentication_windows (for a logon or
|
||||||
|
lateral-movement alert; target_username / source_username to pivot on
|
||||||
|
a user). Dates are YYYY-MM-DDTHH:MM:SS: take them from the threat's
|
||||||
|
first_seen / last_seen and pass the same instants, cut to that form;
|
||||||
|
never add or subtract time yourself. Keep limit at 50.
|
||||||
|
6. ALWAYS keep 2 calls for "elsewhere":
|
||||||
|
- hunt_search_hash(hash) (1 call): where the file hash was seen in the
|
||||||
|
fleet. hunt_search_running_process_hash is NOT listed: running now is
|
||||||
|
not history.
|
||||||
|
- get_alerts(hostname=<name>) (1 call): other alerts on the same host,
|
||||||
|
newest first. Or get_alerts(search=<user or hash>). Never call it
|
||||||
|
without a filter.
|
||||||
|
quarantine_search(hash or agent_hostname) only if asked.
|
||||||
|
|
||||||
|
Ids: alert, threat and agent ids and process uuids are long; take an id
|
||||||
|
only from a previous answer, copied whole; never type, shorten or adapt
|
||||||
|
one. Prefer hostname over agent_id when both are accepted.
|
||||||
|
Hashes: telemetry_processes and quarantine_search take md5, sha1 or
|
||||||
|
sha256; hunt_search_hash any hash.
|
||||||
|
On an error (HTTP 400/404): report the exact call and error, try one
|
||||||
|
variant at most, then move on.
|
||||||
|
|
||||||
|
In your answer: the threat (rules, severity, status, alert count), the
|
||||||
|
process and its tree, command line and user, the host, what the telemetry
|
||||||
|
shows (connections, domains, logons) in which window, and the elsewhere
|
||||||
|
checks — with each fact's command.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- get_threat
|
||||||
|
- threat_security_events
|
||||||
|
- alert_details
|
||||||
|
- telemetry_process_graph
|
||||||
|
- endpoint_search
|
||||||
|
- telemetry_processes
|
||||||
|
- telemetry_network
|
||||||
|
- telemetry_dns
|
||||||
|
- telemetry_authentication_windows
|
||||||
|
- hunt_search_hash
|
||||||
|
- get_alerts
|
||||||
|
- quarantine_search
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,9 @@ description: "Maps a HarfangLab EDR alert (/api/data/alert/alert/Alert/, results
|
|||||||
field_mappings:
|
field_mappings:
|
||||||
title: "rule_name"
|
title: "rule_name"
|
||||||
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
||||||
description: "alert_type"
|
# msg is the rule's own explanation of what it detects; the alert type
|
||||||
|
# (sigma, yara, ioc…) stands in only on the alerts that carry none.
|
||||||
|
description: "msg ? msg : alert_type"
|
||||||
# results_path = results; source_path is JSONata over ONE alert object.
|
# results_path = results; source_path is JSONata over ONE alert object.
|
||||||
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
||||||
ocsf:
|
ocsf:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ description: "Maps a HarfangLab EDR security event/alert (/api/data/alert/alert/
|
|||||||
field_mappings:
|
field_mappings:
|
||||||
title: "rule_name"
|
title: "rule_name"
|
||||||
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
||||||
description: "alert_type"
|
# msg is the rule's own explanation of what it detects; the alert type
|
||||||
|
# (sigma, yara, ioc…) stands in only on the alerts that carry none.
|
||||||
|
description: "msg ? msg : alert_type"
|
||||||
# results_path = results; source_path is JSONata over ONE alert object.
|
# results_path = results; source_path is JSONata over ONE alert object.
|
||||||
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
||||||
ocsf:
|
ocsf:
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
def csv(v):
|
def csv(v):
|
||||||
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def csv(v):
|
def csv(v):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
FIELD_TYPES = {
|
FIELD_TYPES = {
|
||||||
"name": 0, "path": 1, "username": 3, "commandline": 4,
|
"name": 0, "path": 1, "username": 3, "commandline": 4,
|
||||||
@@ -8,12 +8,31 @@ FIELD_TYPES = {
|
|||||||
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
|
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import json, os, re, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, url, headers, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
|
def qs(params):
|
||||||
|
clean = {}
|
||||||
|
for k, v in params.items():
|
||||||
|
if v is None or v == "" or v == []:
|
||||||
|
continue
|
||||||
|
if isinstance(v, bool):
|
||||||
|
v = "true" if v else "false"
|
||||||
|
clean[k] = v
|
||||||
|
return ("?" + urllib.parse.urlencode(clean)) if clean else ""
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# Agent fields HarfangLab exposes as exact-match query filters on
|
||||||
|
# /api/data/endpoint/Agent/. Input name == API filter name.
|
||||||
|
EXACT_FILTERS = [
|
||||||
|
"hostname",
|
||||||
|
"domainname",
|
||||||
|
"domain",
|
||||||
|
"ipaddress",
|
||||||
|
"external_ipaddress",
|
||||||
|
"ostype",
|
||||||
|
"osproducttype",
|
||||||
|
"osversion",
|
||||||
|
"producttype",
|
||||||
|
"version",
|
||||||
|
"status",
|
||||||
|
"isolation_state",
|
||||||
|
"tenant",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Convenience bounds mapped onto Django REST Framework range lookups.
|
||||||
|
RANGE_FILTERS = {
|
||||||
|
"lastseen_after": "lastseen__gte",
|
||||||
|
"lastseen_before": "lastseen__lte",
|
||||||
|
"firstseen_after": "firstseen__gte",
|
||||||
|
"firstseen_before": "firstseen__lte",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Version bounds. The API matches a version only exactly, and a range lookup it
|
||||||
|
# does not support would be dropped without a word — so these are compared here,
|
||||||
|
# number by number (4.10 is above 4.9, which a string comparison denies).
|
||||||
|
VERSION_BOUNDS = {
|
||||||
|
"version_lt": lambda v, t: v < t,
|
||||||
|
"version_lte": lambda v, t: v <= t,
|
||||||
|
"version_gt": lambda v, t: v > t,
|
||||||
|
"version_gte": lambda v, t: v >= t,
|
||||||
|
}
|
||||||
|
PAGE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(v):
|
||||||
|
parts = re.findall(r"\d+", str(v or ""))
|
||||||
|
return tuple(int(x) for x in parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def pad(a, b):
|
||||||
|
n = max(len(a), len(b))
|
||||||
|
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
|
||||||
|
|
||||||
|
|
||||||
|
def to_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
return str(v).strip().lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
base = secrets.get("url", "").rstrip("/")
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
# === per-command logic ===
|
||||||
|
# An instance bound to one tenant of a shared, multi-tenant manager carries it
|
||||||
|
# in its config: it scopes the count, and the fleet total beside it, unless the
|
||||||
|
# block names a tenant itself.
|
||||||
|
scope = {}
|
||||||
|
if not str(inputs.get("tenant") or "").strip() and str(secrets.get("tenant") or "").strip():
|
||||||
|
scope["tenant"] = str(secrets["tenant"]).strip()
|
||||||
|
filters = dict(scope)
|
||||||
|
for field in EXACT_FILTERS:
|
||||||
|
value = inputs.get(field)
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
filters[field] = to_bool(value) if field == "isolation_state" else value
|
||||||
|
for field, lookup in RANGE_FILTERS.items():
|
||||||
|
value = inputs.get(field)
|
||||||
|
if value:
|
||||||
|
filters[lookup] = value
|
||||||
|
# Escape hatch for any filter this command does not name explicitly,
|
||||||
|
# e.g. "groups__name=Serveurs&osmajor=10".
|
||||||
|
for tok in str(inputs.get("filters") or "").split("&"):
|
||||||
|
kv = tok.split("=", 1)
|
||||||
|
if len(kv) == 2 and kv[0].strip():
|
||||||
|
filters[kv[0].strip()] = kv[1]
|
||||||
|
|
||||||
|
bounds = {}
|
||||||
|
for field in VERSION_BOUNDS:
|
||||||
|
value = str(inputs.get(field) or "").strip()
|
||||||
|
if value:
|
||||||
|
target = parse_version(value)
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(field + " must be a version such as 4.10.2, got: " + repr(value))
|
||||||
|
bounds[field] = target
|
||||||
|
|
||||||
|
# The agent list is a DRF paginated collection: `count` is the total number
|
||||||
|
# of matches, independent of the page size. limit=1 keeps the payload tiny.
|
||||||
|
agents = base + "/api/data/endpoint/Agent/"
|
||||||
|
if bounds:
|
||||||
|
# A version bound reads every agent the other filters keep and counts
|
||||||
|
# here. An agent with no readable version is left out and counted apart,
|
||||||
|
# rather than guessed above or below the bound.
|
||||||
|
count, scanned, unreadable, offset = 0, 0, 0, 0
|
||||||
|
while True:
|
||||||
|
page = request("GET", agents + qs(dict(filters, limit=PAGE, offset=offset)), headers)
|
||||||
|
rows = page.get("results") or []
|
||||||
|
for agent in rows:
|
||||||
|
scanned += 1
|
||||||
|
version = parse_version(agent.get("version"))
|
||||||
|
if version is None:
|
||||||
|
unreadable += 1
|
||||||
|
continue
|
||||||
|
if all(VERSION_BOUNDS[f](*pad(version, t)) for f, t in bounds.items()):
|
||||||
|
count += 1
|
||||||
|
offset += len(rows)
|
||||||
|
if not rows or offset >= page.get("count", 0):
|
||||||
|
break
|
||||||
|
result = {"count": count, "filters": dict(filters, **{f: inputs[f] for f in bounds}),
|
||||||
|
"scanned": scanned, "unreadable_version": unreadable}
|
||||||
|
else:
|
||||||
|
matched = request("GET", agents + qs(dict(filters, limit=1, offset=0)), headers)
|
||||||
|
result = {"count": matched.get("count", 0), "filters": filters}
|
||||||
|
|
||||||
|
# The API silently drops a query parameter it does not recognise as a
|
||||||
|
# filter, which yields the unfiltered total dressed up as a filtered count.
|
||||||
|
# Returning the fleet total alongside makes that failure visible.
|
||||||
|
if filters != scope or bounds:
|
||||||
|
everything = request("GET", agents + qs(dict(scope, limit=1, offset=0)), headers)
|
||||||
|
result["total"] = everything.get("count", 0)
|
||||||
|
else:
|
||||||
|
result["total"] = result["count"]
|
||||||
|
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
@@ -53,7 +72,11 @@ def main():
|
|||||||
"alert_time__gte": inputs.get("from_date"),
|
"alert_time__gte": inputs.get("from_date"),
|
||||||
"alert_time__lte": inputs.get("to_date"),
|
"alert_time__lte": inputs.get("to_date"),
|
||||||
}
|
}
|
||||||
print(json.dumps(request("GET", base + "/api/data/alert/alert/Alert/" + qs(params), headers)))
|
out = request("GET", base + "/api/data/alert/alert/Alert/" + qs(params), headers)
|
||||||
|
# The list endpoints echo the catalogue of filterable fields (~50 KB) on
|
||||||
|
# every call; it says nothing about the results, so it is not passed on.
|
||||||
|
out.pop("filters", None)
|
||||||
|
print(json.dumps(out))
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt == RETRIES or not retryable(method, e.code):
|
||||||
|
raise
|
||||||
|
e.close()
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
def qs(params):
|
def qs(params):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user