Compare commits

...

96 Commits

Author SHA1 Message Date
f3nris 9fbe592421 feat(crowdstrike): version bounds and relative dates on device_count (1.7.0)
agent_version_lt/lte/gt/gte count the sensors below or above a build. FQL
compares versions as text, so the script scrolls the matching device IDs,
reads their versions and compares number by number. first_seen / last_seen
bounds also take a duration relative to now (24h, 7d).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
2026-10-19 20:09:31 +00:00
f3nris 6d36b4e958 feat(harfanglab): version bounds on endpoint_count (1.9.0)
version_lt, version_lte, version_gt and version_gte count the agents below or
above a build. The API matches a version only exactly, so the script pages
through the agents the other filters keep and compares number by number;
unreadable versions are reported apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
2026-10-19 19:31:41 +00:00
f3nris 90c6c80843 feat(harfanglab): instance-level tenant scope (1.8.0)
An instance can carry the tenant of the client it serves, for clients sharing
one multi-tenant manager. endpoint_count uses it, for the count and the fleet
total beside it, when the block leaves its tenant filter empty; a tenant named
on the block still wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
2026-10-19 19:23:40 +00:00
f3nris c67343b7cf feat(sentinelone): instance-level account scope (1.20.0)
An instance can carry the account IDs of the client it serves, for clients
sharing one multi-tenant console. count_agents, get_threats, get_alerts and
get_unified_alerts use them when the block leaves its account filter empty;
an account named on the block still wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
2026-10-19 19:08:10 +00:00
f3nris 2bc7601177 fix(mail-templates): callout_title points to the badge in the mail language (1.0.4)
Its description gave "⚠ SLA ALERT" as the default, which read as a
value to type: a French mail ended up with an English box title. It now
says to leave the field empty for the badge in the mail language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
2026-10-19 09:32:17 +00:00
f3nris 2a74bbc436 feat(mail-templates): markdown inputs in a multi-line field (1.0.3)
description and callout_text declare format: markdown, which Riposte
0.322+ edits in a text area that keeps line breaks; older versions
ignore it and \n still works. README documents format: multiline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
2026-10-19 09:17:27 +00:00
f3nris 983414f278 fix(mail-templates): read \n as a line break in one-line inputs (1.0.2)
The playbook editor's input field is a single <input>, which drops the
newlines of a pasted text: the whole description reached the script as
one line starting with ## and rendered as a single heading. description
and callout_text without a real line break now take the two characters
\n as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
2026-10-19 09:10:52 +00:00
f3nris 046810c1f6 fix(mail-templates): run under Riposte's exec() runner (1.0.1)
The runner execs a script without __file__ or __name__: the Test button
failed on __file__ and render-mail-template never reached main(). The
test no longer imports the renderer, and the renderer calls main()
unconditionally like the other integrations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
2026-10-19 08:49:46 +00:00
f3nris dcf257d887 feat(mail-templates): render ACKnowledge SOC emails from markdown (1.0.0)
render-mail-template turns a template, a subject and a markdown
description into the dark ACKnowledge SOC mail (header badge, optional
callout and action button, signature with the logo, SOC or client
footer, fr/en). Nothing is sent: subject, html_body and text_body go to
exchange-send-mail or mail-sender, which embed the data-URI logo.

Eight templates (sla_breach, incident_notification, incident_update,
incident_closure, action_required, escalation, threat_bulletin, info).
The markdown covers GFM tables, code blocks and {{level}} pills; raw
HTML in the inputs is escaped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
2026-10-19 08:40:32 +00:00
f3nris 5085d96628 feat(webhook-relay): map Rapid7 InsightIDR basic rule alerts (1.1.0)
fetch_rapid7_alerts opens the alert InsightIDR Essential posts as a JSON
string in a single payload field (JSON or form body) and returns one flat
item per relayed message; the correlation id is the dedup key. Own mapper
and alert type, written from a real delivery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa1QDbsPTYiVREv2Cvs3Zn
2026-10-19 07:56:45 +00:00
f3nris c358814fe7 feat(microsoft-exchange-mail): send mail from Exchange Online over Graph (1.0.0)
Mail Sender speaks SMTP and Defender for Office 365 only reads and
purges, so nothing in the catalogue could send from an Exchange Online
mailbox with an app registration (tenant ID, client ID, client secret).

send_mail posts to /users/{mailbox}/sendMail: text or HTML, inline
data-URI images as cid attachments, CC/BCC, Reply-To, importance and
saveToSentItems. Over 3 MB of attachments the message goes through a
draft and upload sessions instead, since sendMail caps the request at
4 MB; that path needs Mail.ReadWrite. reply_mail and forward_mail take
the Graph id or the internet Message-ID (resolved with a $filter, which
needs Mail.ReadBasic.All). test_connection reads the token's roles and
fails when Mail.Send is missing, without sending anything.

Exercised against a stubbed Graph: request bodies, the draft path with a
7 MB file in three ranges and no bearer on the upload URL, and Riposte's
ParseManifest. Not yet tried against a real tenant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:29:32 +00:00
f3nris 815db080f5 feat(webhook-relay): map relayed messages into OCSF, not only custom fields (1.0.1)
The generic mapper now fills the OCSF fields a relayed message has a
place for: metadata.logged_time, log_provider and log_name, the checks
the delivery passed as metadata.labels (relay:hmac…), and raw_data. The
sender's address goes to unmapped.relay.sender_ip rather than
src_endpoint.ip: it is the vendor's cloud, and as an entity it would
correlate every alert of that vendor. metadata.product.name no longer
holds the source id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa1QDbsPTYiVREv2Cvs3Zn
2026-10-16 15:48:27 +00:00
f3nris 6b76270cc6 feat(webhook-relay): read the alerts vendors push to riposte-relay (1.0.0)
Riposte accepts no inbound connection, so a vendor that can only push
(Rapid7 InsightIDR Essential basic detection rules) posts to the relay,
and an alert rule on this integration reads one relay source back over
outbound HTTPS.

fetch_messages takes the rule's watermark as `since` and walks every
page within the run, since the next watermark is this run's instant. A
window holding more than `limit` fails the run instead of truncating, so
the watermark stays put. relay_status and test_connection tell a 404
from the proxy (this Riposte's address is not listed) from a refused
token. The generic mapper keeps the raw body, the sender's address and
the checks the delivery passed (custom:webhook_verified).

Tested against a local relay: 2503 messages over three pages, a
millisecond watermark, the limit, unknown source and bad token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa1QDbsPTYiVREv2Cvs3Zn
2026-10-16 11:23:03 +00:00
f3nris ff461464fd fix(crowdstrike): resolve_detection fails when Falcon changed nothing (1.6.3)
The alerts PATCH answers 200 to a refused update (errors, resources_affected
0) and the command reported it as a success. It now exits in error with
Falcon's reasons and the ids sent; an empty update is refused up front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBnLYwQbUCaf5vpv77N14Q
2026-10-15 12:08:05 +00:00
f3nris 88407dd1b4 feat(sekoia-xdr): list_alerts attaches each alert's events (1.3.0)
An alert only summarises its detection; addresses, user, process and
command line live in the events tagged with its short id. with_events
runs one event search per alert (alert_short_ids, first_seen_at - 1 min
to now, as Sekoia's XSOAR integration does), in parallel within the
60 s command budget, and nests the dotted ECS names. A search not done
in time leaves events_pending + events_job_uuid: the alert still goes
out, as the rule's watermark would not offer it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBnLYwQbUCaf5vpv77N14Q
2026-10-15 10:10:56 +00:00
f3nris 8eed7ed9fb fix(crowdstrike): NG-SIEM detections are filed under thirdparty (1.6.2)
On a real tenant the Unified Detections raised from ingested FortiGate
logs are product 'thirdparty'; 'ngsiem' matched nothing. The command now
reads thirdparty and ngsiem by default (products input), and its mapper
is rewritten from real payloads: source product/vendor, matched host,
connection endpoints and ports, vendor signature id, Falcon priority,
console link, and a description built from what fired on which connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBnLYwQbUCaf5vpv77N14Q
2026-10-15 08:32:36 +00:00
f3nris f34c87f0c8 fix(crowdstrike): say what an empty alert search matched (1.6.1)
search_detections and search_ngsiem_detections now return a query block
(FQL sent, Falcon's total, errors) next to resources, so an empty run can
be told apart from a wrong region or an API client that sees no alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:09:39 +00:00
f3nris 0444e120fa feat(crowdstrike): search Next-Gen SIEM detections (1.6.0)
NG-SIEM correlation rules file their detections in the unified Alerts
API under product 'ngsiem'. search_detections defaults to epp/ldt and
maps device/process/file blocks those detections do not carry.

New search_ngsiem_detections command (product clause always kept, filter
ANDed, created_from watermark), its own first-cut mapper on the fields
every unified alert carries, and a CrowdStrike NG-SIEM Detection type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:53:05 +00:00
f3nris 585c06385c fix(harfanglab): retry transient API errors (1.7.3)
HarfangLab answers a 500 now and then on a call that passes when sent
again. Each script's request() now retries up to three times (1, 2, 4 s):
GET on 429/500/502/503/504, writes only on 429/502/503 so an isolation,
a kill or a job is never sent twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:28:03 +00:00
f3nris 725e6921f4 feat(jira): watch_issues reports what changed on a set of tickets (1.4.0)
Given up to 50 keys and an instant, returns the issues updated since with
their status, new comments (author id, plain-text body, own comments
flagged) and status transitions, plus the instance's own account. JQL dates
are converted to the account's time zone; malformed keys are never queried;
a deleted key falls back to per-key reads instead of failing the batch.

get_comments gains order, start_at and author_id, and decodes HTML entities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:59:42 +00:00
f3nris e973ccb673 feat(cortex-xdr): endpoint_count counts agents below a target build (1.10.0)
agent_version_lt, compared number by number in the script, as the
endpoints API does not filter on the agent version either. Folded into
the unpushed 1.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaiNEwG2W5Y9fMo4qXcesr
2026-10-13 08:49:29 +00:00
f3nris 62157e5dee feat(cortex-xdr): endpoint_count counts by operational status (1.10.0)
The endpoints API has no filter on operational_status, so the endpoints
the other filters match are read 100 at a time and counted by the script.
Lets an agent reporting playbook count the agents in error, as
SentinelOne's operationalStatesNin does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaiNEwG2W5Y9fMo4qXcesr
2026-10-13 08:43:18 +00:00
Claude 91855fce4f fix(sentinelone): count the host's other alerts from what was read (1.19.1)
A real run answered one other alert and named none: the count came
from the console's total minus the alert itself, and that total did
not hold the alert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 11:37:19 +00:00
Claude 6f51520fb5 feat(sentinelone): investigate_alert, one call for an agent (1.19.0)
Wraith spent ten calls on a real unified alert and still lost the host:
it took the UAM asset id for an agent id and the raw agent list ran past
what a tool result carries. investigate_alert reads the alert, its
detail, the host's agent (by id or UUID, then name), the other alerts
and threats on that host around the detection, and the file SHA1's
reputation, blocklist and fleet-wide threats, summarised in one answer.
The operator sheet now starts with it (budget 8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 11:27:36 +00:00
f3nris 80de5399a6 rapid7-insightidr 1.14.9: a user_authentications answer that fits
Riposte gives a tool result 8000 characters; two full summaries ran to 11903
and were cut at 3903, the asset list half gone. The answer now keeps only the
fields an authentication question is answered from (destination and source
asset and address, domain, result, logon type, service) and lists up to 40
new destination assets in full: about 2.5 KB for 1000 events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 08:14:10 +00:00
f3nris fb560cc937 rapid7-insightidr 1.14.8: user_authentications counts the user's own logons
The keyword search matches the account name anywhere in a line, so it brought
other accounts' logons: on a real run the only "new" event reported was
another user's failed password. Only events whose account/user field is the
user (DOMAIN\\user and user@domain normalized) are summarized now, with
keyword_matches and user_matches both reported. A first authentication is
recognized as Rapid7 flags it, a boolean new_authentication field, as well as
the word. The event limit cannot be set below 10000 (the agent passed 1000).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 07:53:57 +00:00
f3nris 3bfcebdf21 rapid7-insightidr 1.14.7: user_authentications, one call for a UBA alert
A new read command answers what a behaviour authentication alert asks —
which assets the user authenticated to around the alert, and which were new —
in one call: it resolves the Asset Authentication log set, builds the
keyword query and the 24 h window itself, and summarizes all events and,
apart, those marked new_authentication. Across real runs gpt-oss mangled log
set ids, computed the window a day off, put a filter inside the LEQL (HTTP
400) and read ten lines out of a thousand; this leaves it nothing to get
wrong. Static calls it first for such alerts; list_investigation_alerts
points to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 07:45:31 +00:00
f3nris ae1effafcd rapid7-insightidr 1.14.6: narrow a log result by field, no query syntax
query_log_set takes only="field=value[, …]", applied to the events' fields
after the search (case ignored), and says how many matched before it. Static
runs a second summary of the same query restricted to what the alert is about
— for a "new assets" alert, the new authentications — and names their
destination assets: the first real run summarized 978 events but left the 64
new ones mixed in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 07:27:36 +00:00
f3nris 889cf660f6 rapid7-insightidr 1.14.5: query_log_set can summarize; Static always does
summarize=true returns, over every matching event, each field's most frequent
values with counts, the first and last event time and five samples, instead of
the raw events. On a real run Static got 1000 raw events, could read about
ten, and reported the hosts and accounts of those ten; 300 events now fit in
about 1.5 KB. Static passes summarize always and quotes the returned window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 07:18:09 +00:00
f3nris 847f31ce17 zscaler 1.1.2: withdraw the Relay operator
It could only read a sandbox report by MD5 — no web logs — so it added noise to
the crew lead's routing without answering an investigation question. Riposte
retires the installed agent on the next update of the integration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 07:12:54 +00:00
f3nris 3e39f17bfe rapid7-insightidr 1.14.4: keep UBA alerts in the full investigation feed
get_investigations_full dropped every alert without an RRN, so a behaviour
(UBA) investigation arrived with alerts: [] and alert_count: 0 and its
reading worked from a title alone. Those alerts are now kept under
alerts_without_rrn as the investigation endpoint returns them, counted in
alert_count, rendered in summary_markdown with where their evidence lives,
and lift first_alert_type / first_alert_source / first_alert_id when they are
all the investigation has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 07:02:12 +00:00
f3nris 9605e1f0dc feat(agents): operator sheets for 33 integrations (lot 4)
EDR: Wraith (SentinelOne), Talon (CrowdStrike), Nyx (HarfangLab), Bastion
(Defender), Synapse (Cortex XDR), Kestrel (Sophos). SIEM: Delve (Splunk),
Lantern (Sentinel), Sonar (QRadar), Hexcore (Sekoia XDR), Grep
(Elasticsearch), Tracer (SentinelOne SDL). Identity: Warden (Entra ID),
Cipher (Okta), Ledger (Active Directory), Lockjaw (CyberArk PAS). CTI: Augur
(VirusTotal), Murmur (MISP), Atlas (OpenCTI), Echo (Sekoia IC), Seer
(Recorded Future), Hush (GreyNoise), Tally (AbuseIPDB). Sandbox: Scalpel (Joe),
Gauntlet (ANY.RUN), Prism (VMRay). Email: Hook (Proofpoint TAP), Drift
(Mimecast), Riptide (Vade). Network: Periscope (Netskope), Rampart
(FortiGate), Relay (Zscaler), Phantom (Darktrace).

Read-only commands, analyst_l2, a call budget with an order, and the Static
lessons: ids copied from answers only, timestamps passed as given, names over
ids, one variant at most on error. Each sheet passes Riposte's validator;
none is field-tested yet. Each integration gets a patch bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-11 23:00:15 +00:00
f3nris 2245d5500e rapid7-insightidr 1.14.3: the log window is computed from the alert's time
query_log_set takes anchor_time with hours_before / minutes_after and computes
the window itself, and returns it as text: on a real run the agent computed
"24 hours before 2026-10-11T00:04Z" as ending on the 10th and reported a
day-old window. Static passes the alert's first seen time instead of dates,
and lists domain controllers apart from the new assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-11 22:38:42 +00:00
f3nris 4bd468e671 rapid7-insightidr 1.14.2: query a log set by name; Static widens UBA windows
query_log_set accepts log_set_name and resolves the id itself (exact name, or
the one set whose name contains it; otherwise refused with the existing
names): on a real run the model mangled a 36-character id it copied. Static
queries by name, retries a 0-event keyword search case-insensitively, takes
the 24 hours before a behaviour alert (they aggregate hours of activity), and
always looks for the user's other investigations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-11 22:32:22 +00:00
f3nris c3b15d1faf rapid7-insightidr 1.14.1: mark UBA alerts in the data, give Static a budget
list_investigation_alerts now marks each alert with alert_rrn, evidence_api
and next: a behaviour alert with no RRN says, in the output the agent reads,
that the Alert Triage commands answer 404 for it and that its evidence is in
the authentication logs. Static still called them when only its instructions
said so. Its sheet gets 14 calls spent in a stated order, authentication log
sets for lateral-movement alerts (never file access), and a window built from
first and last seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-11 22:20:58 +00:00
f3nris c5da537434 rapid7-insightidr 1.14.0: ship Static, the InsightIDR operator agent
agents/operator.yaml: read-only commands, analyst_l2 ceiling, and the procedure
learned on a real UBA alert (start from the investigation's alerts, plain-UUID
alerts have no Alert Triage evidence, LEQL keyword search in parentheses,
search_investigation takes start_time/end_time). README documents agents/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-11 21:07:52 +00:00
f3nris 4a339fda24 rapid7-insightidr 1.13.2: trim pasted log and log set IDs, explain the 404
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bH7kirDAfMjDLj8AYRrNU
2026-10-11 15:37:49 +00:00
f3nris b9e796e927 rapid7-insightidr 1.13.1: accept an InsightIDR Essential key in test_connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:13:22 +00:00
f3nris 856dd2321d feat(crtsh): Cert Spotter fallback and a 60 s time budget (1.1.0)
crt.sh often answers 502 for minutes at a time. When it is down,
sub-domain and exact searches are now answered by SSLMate Cert Spotter,
which reads the same CT logs; its rows are reshaped into crt.sh's so the
output keeps its shape, plus 'source' and 'complete'. starts_with has no
fallback (Cert Spotter cannot search by prefix).

Riposte kills an integration script at 60 s, and the old retry loop
(4 x 120 s) could never finish. Every call now takes its timeout from a
55 s budget, with 15 s kept for the fallback; only 5xx are retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmWT9wtWHGtZpMS64Hp4q5
2026-10-10 21:12:15 +00:00
f3nris 94142f4739 docs(ioc-sources): suggest summary lines for Riposte context enrichment rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmWT9wtWHGtZpMS64Hp4q5
2026-10-09 22:53:04 +00:00
f3nris d2742ec6fb feat: CIRCL hashlookup, RDAP and crt.sh — free IOC lookups, no API key
Three keyless sources for the questions an analyst asks first:

- circl-hashlookup 1.0.0: is this hash known software (NSRL, distro
  packages, Windows updates)? `score` 0 for a known file, 100 when CIRCL
  flags it KnownMalicious, so an enrichment rule with benign 0 marks it
  benign; an unknown hash is an HTTP 404, read as "no record". Single and
  bulk lookups; base_url for a self-hosted instance.
- rdap 1.0.0: who registered a domain, IP block or AS, and when — with
  age_days for the phishing question. Authoritative server from the IANA
  bootstrap, rdap.org as fallback; a host name walks up to its registered
  domain; GDPR placeholders are not reported as registrants; a TLD with no
  RDAP (.ch) is an error, not a fake "unregistered".
- crtsh 1.0.0: Certificate Transparency — sub-domains with first/last seen,
  issuers, recent certificates, and a starts_with mode for look-alikes of a
  brand (crt.sh no longer searches with a leading wildcard). Retries the
  502/503/504 its shared front throws under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmWT9wtWHGtZpMS64Hp4q5
2026-10-09 22:33:42 +00:00
f3nris 5cf870309f feat(harfanglab): describe an incident with the rule's message (1.7.1)
The get_security_events and get_alerts mappers set an incident's
description to alert_type alone — "sigma", "yara" — which says nothing
an analyst can act on. It now takes msg, the rule's own account of what
it detects and what to check, and falls back to the alert type only on
alerts that carry no message. Folded into the unreleased 1.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXFMuDDLohs4qnL12mBJoP
2026-10-07 19:11:35 +00:00
f3nris 45df291eeb fix(harfanglab): drop the 50 KB filters catalogue from list outputs (1.7.1)
HarfangLab's alert and threat list endpoints echo, on every call, the
catalogue of every filterable field — about 50 KB, unrelated to the
results. get_security_events, get_alerts and get_threats now drop it
before printing, so a fetch carries the alerts and little else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXFMuDDLohs4qnL12mBJoP
2026-10-07 12:42:32 +00:00
f3nris db9a45c9bc docs(mappers): MTTD is not retired, only re-anchored
The previous commit said MTTD was retired. Migration 0256 did retire it,
but 0257 restored it at once: what stays is its anchor, the moment
Riposte records the alert. The OCSF `time` field is still not read to
date detection — that part was right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:58:20 +00:00
f3nris ece2c41340 docs(mappers): time no longer dates detection
Eight mappers (Cortex XDR, CrowdStrike, Rapid7) still called the OCSF
`time` field "the MTTD anchor" that Riposte reads to stamp detected_at.
Riposte retired MTTD in migration 0256 and stamps detection at
ingestion for every alert; the mappings stay, as the alert's own record
of when the source saw the event. Comments only: no mapping changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:56:50 +00:00
f3nris ea04ea586c fix(sekoia-xdr): mapper reviewed on a real alert (1.2.0)
- incident source is 'Sekoia XDR'; Sekoia's `source` (the alert's source
  address) goes to src_endpoint.ip/.hostname, `target` to dst_endpoint;
- description: the alert's sentence with {rule:<uuid>} replaced by the rule
  name; the rule documentation (`details`) moves to analytic.desc;
- every TTP mapped into attacks; kill_chain_short_id no longer written as
  an ATT&CK tactic uid;
- OCSF times in epoch ms; finding_info.types, analytic.category,
  metadata.product added.

Checked with Riposte's mapper preview on the alert and a variant (IPv4
source, named target, one TTP): no expression error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:13:03 +00:00
f3nris d831d0973a feat(harfanglab): fetch a threat's security events (1.7.0)
Since HarfangLab merged alerts and security events, a threat's events
are the alerts whose threat_key is the threat id — the filter the
vendor's XSOAR integration uses. get_alerts could filter on it but
stops at one page of 100; the new threat_security_events command pages
through up to max_results and returns the threat's total with a
truncated flag.

threat_status, threat_level and alert_tag offer their status and level
as drop-downs, and threat_status now reads as the place to close a
threat or mark it a false positive, HarfangLab's only verdict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:35:31 +00:00
f3nris 91f6b8158b fix(cortex-xdr): give a full incident its own title (1.9.0)
Title and description both read the incident's description, so every
ticket showed the same sentence twice. The title is now the rule that
fired, with "(+N other alerts)" when several are folded in, or the
incident's console name; the description adds what each distinct rule
means.

network_artifacts[0] no longer overwrites the alert's own flow: the
artifacts are the fallback, and the destination country comes from the
artifact matching the alert's remote IP. Also maps the rule description
and id, and the XDR assignee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:25:32 +00:00
f3nris 0f5f4a900e feat(crowdstrike): set a detection's verdict (1.5.0)
Falcon records a verdict as a tag — true_positive, false_positive or
ignored, which the console's Resolution column reads — and update_status
only takes new, in_progress, reopened and closed, so resolve_detection
had no way to record one. Its new verdict input adds the tag after
removing the other two, add_tag never replacing one; status and verdict
are drop-downs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhDQSYZd1mFtRsXK4ixtyB
2026-10-02 14:25:22 +00:00
f3nris 820bab8e5c feat(jira): pick the project, issue type and priority from the site (1.3.0)
create_issue and edit_issue declare options_from on project_key,
issue_type_name and priority, and link_issues on link_type, so the
playbook editor lists the Jira site's own values. Three read commands
feed them: list_projects, list_issue_types (per project) and
list_priorities. The README documents enum and options_from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhDQSYZd1mFtRsXK4ixtyB
2026-10-02 10:16:41 +00:00
f3nris 41aa6723c5 feat(cortex-xdr): list the incident statuses and severities as enums (1.8.0)
cortex-xdr-update-incident declares the API's nine status values and
the four severities, so the playbook editor offers them in a drop-down
and the command bar refuses a misspelt one. resolved_other joins the
list; resolved_auto leaves it, Cortex XDR setting it itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhDQSYZd1mFtRsXK4ixtyB
2026-10-02 07:50:12 +00:00
f3nris 89ffbc3c7c feat(sentinelone): list the UAM alert statuses as an enum (1.18.0)
sentinelone-update-uam-alert-status declares NEW, IN_PROGRESS and
RESOLVED, folded into the unreleased 1.18.0, whose changelog entry
the previous commit left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Azv82i33uiPff2PFj2PLi7
2026-10-01 20:26:45 +00:00
f3nris 7750744abb feat(sentinelone): list the UAM analyst verdicts as an enum (1.18.0)
sentinelone-update-uam-alert-verdict declares the console's 20
AnalystVerdict values, so the playbook editor offers them in a
drop-down and the command bar refuses a misspelt one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Azv82i33uiPff2PFj2PLi7
2026-10-01 20:25:24 +00:00
f3nris 4554354e06 fix(sentinelone): accept a skipped UAM update when the alert is already set (1.17.3)
The console skips an alert already in the requested state, and 1.17.2
failed every skip: re-assigning an alert to its current holder, as a
second run of the same playbook does, broke the block.

A skipped alert is now read back. Already assigned to that user, or
already carrying that status or verdict, the block succeeds with
already_assigned / already_set; otherwise it fails naming the state the
console holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3
2026-09-28 14:48:09 +00:00
f3nris 71f8cd970e fix(sentinelone): fail a UAM update the console did not apply (1.17.2)
The assignee mutation came back with the alert under success while the
console kept its previous assignee, and the playbook block passed. The
script now reads the alert back and fails, naming the assignee the
console holds, when it is not the requested user.

The status, verdict and assignee updates also fail when the alert is
listed under failure or skip: the console answers those with a 200 and
no errors array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3
2026-09-28 14:43:53 +00:00
f3nris 232fa2f0a6 fix(sentinelone): type the assignee mutation as the console does (1.17.1)
update_uam_alert_assignee declared the action id as String! and the user
as ID!; the console types them ID! and Long and refused the mutation at
validation (VariableTypeMismatch), so no alert was ever assigned.

The user id now goes out as a JSON number, and a value that is not the
numeric console id is refused with an error naming assignee_user_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3
2026-09-28 14:37:00 +00:00
f3nris 18ae4a24ff fix(sentinelone): map the unified alert as the console describes it (1.17.0)
Corrected and completed against a real Identity alert.

Wrong data, not just missing:

- device.type read asset.type, which is UNKNOWN on every alert whose
  surface is not the endpoint. A domain controller the same alert called
  a Server was ingested as an unknown device. asset.category is read
  first now, with a numeric device.type_id beside it.
- device.ip took the IPv6 address ahead of the IPv4, so every Windows
  host carried a link-local fe80:: address nothing can pivot on, and
  which the indicator extractor mined as an IOC all the same.
- The flat file summary went straight into observables, so an alert with
  no file — identity, cloud — carried one observable whose name and value
  were both the literal string N/A. It now fills file.name and the
  file.hashes entry its digest length names, and only when the process
  block did not already describe the file.
- finding_info.types is a list, as OCSF and the layout generator expect.
- src_endpoint and dst_endpoint are oriented by the detection rather than
  by habit. SentinelOne fills detectionTime.attacker only when a party
  distinct from the asset is involved, so its presence decides which way
  the pair points: attacker as source and asset as destination when it is
  filled, asset as source and no destination when it is not. Mapping the
  attacker to dst_endpoint unconditionally announced a domain controller
  as the source of the enumeration it had in fact suffered. device.* is
  the invariant across both branches, and the asset's full OS version
  moved onto it so the flip costs nothing.
- The base event had no clock of its own. time, start_time and end_time
  come from detectedAt, firstSeenAt and lastSeenAt, so a timeline no
  longer falls back to the moment Riposte polled.

47 entries added, 97 to 144, covering every one of the 120 fields the
query fetches. 23 sit under unmapped.* — the Azure image, instance size
and resource tags, the console-side IP, the Kubernetes cluster,
namespace, node and controller, the agent's connectivity, pending reboot
and policy, the ticket and whether a note exists — surfaced for the page
instead of being left in the raw payload.

Checked by running the platform's own jsonata-go mapping engine over the
real Identity alert, over a STAR alert fetched with_details, over an
alert naming only the attacker's address, and over a bare alert: no
expression errors on any of them, and the process chain, evidences,
attacks and observables come out unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:28:02 +00:00
f3nris a6a52f7356 feat(crowdstrike): count devices by filter without listing them (1.4.0)
The device query already answers with meta.pagination.total — the number
of devices matching the filter, independent of the page asked for — so
device_count asks for a one-ID page and reads it, where search-device
resolves every matching device in full through
/devices/entities/devices/v2 to reach the same figure, and only filters
on four properties.

Filters cover sixteen device FQL properties (hostname, device id, local
and external IP, platform, OS version, role, containment status, sensor
version, AD domain/OU/site, serial, tags, host groups, RFM), add
first_seen/last_seen bounds, and keep a raw FQL escape hatch.

Two details the reference client leaves to chance are handled here.
Several values on one property become an OR group, parenthesised so it
binds before the '+' that ANDs it to the other clauses. And values are
quoted with the single quote escaped, so a hostname carrying an
apostrophe cannot end the literal early and change what is counted.

devices/v1 stops reporting a total on an estate it cannot page to the
end of, so a missing total falls back to the scroll query, which has no
such ceiling.

The estate `total` comes back next to `count` so the figure reads as a
proportion, and a filter too broad to mean anything shows as matching
everything rather than passing for an answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:41:57 +00:00
f3nris beefe047a7 feat(cortex-xdr): count endpoints by filter without listing them (1.7.0)
The endpoints grid already answers with reply.total_count — the number
of endpoints matching the filters, independent of the page asked for —
so endpoint_count asks for a one-record page and reads it, where
get-endpoints has to walk the estate 100 records at a time to arrive at
the same figure, and only filters on four fields.

Filters cover the twelve list fields the API accepts (hostname, alias,
endpoint id, internal and public IP, group, platform, distribution,
status, isolation, scan status, username), add first_seen/last_seen
bounds taking ISO8601 or epoch ms, and keep a raw JSON `filters` escape
hatch for anything else. Filters are ANDed; the API has no OR.

The estate `total` comes back next to `count` so the figure reads as a
proportion, and a filter too broad to mean anything shows as matching
everything rather than passing for an answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:37:34 +00:00
f3nris 505c518bf8 feat(harfanglab): count agents by filter without listing them (1.6.0)
The agent collection is DRF-paginated, so `count` already carries the
filtered total. endpoint_count asks for limit=1 and reads it, instead of
pulling up to 10000 agent records back through the playbook the way
endpoint_search does — and endpoint_search only filters on hostname.

Filters cover the documented agent query parameters (hostname, network,
os, version, status, isolation_state, tenant), add lastseen/firstseen
bounds over the DRF range lookups, and keep a raw `filters` escape hatch
for anything else the instance accepts.

The API silently drops a query parameter it does not recognise as a
filter, which turns a typo into a plausible-looking fleet-wide total, so
the fleet `total` is returned next to `count` to make that case visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:29:53 +00:00
f3nris ed56523b07 fix(ovhcloud): treat the billing-account list as advice, never as a verdict (1.2.6)
The listing now answers 200 with an empty array rather than erroring, and the
probe failed on "not one of the accounts these credentials can see" — the same
mistake as 1.2.4, in its other shape: a diagnostic step deciding the outcome of
the test it exists to explain.

OVH's IAM filters what you may SEE rather than refusing you, so an empty list is
how it says no — and a policy can grant a resource without granting the route that
enumerates it, which makes "not in the list" weak evidence either way. Only the
account-scoped routes decide now.

When nothing is reachable anywhere, the message names the IAM policy as the cause
and says what to grant, because an empty array is the least self-explanatory
refusal this API produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:29:09 +00:00
f3nris 42032e576e fix(ovhcloud): use the API endpoint as configured, for both schemes (1.2.5)
1.2.0 rewrote /1.0 to /v1 whenever a service account was in use, reasoning from a
documentation example that showed OAuth2 against a /v1 path. It was wrong, and it
was the cause of the 404s and the 500 that followed: OVH's own API console issues
Bearer requests against /1.0, while /v1 — which exists, and answers 401
unauthenticated like any live route — answers 500 with an empty body for a token
on the telephony routes. A failure with no message at all, produced by a helpful
rewrite nobody asked for.

The endpoint is now whatever was typed, for both authentication schemes. The
config field says where to copy it from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:26:02 +00:00
f3nris 35f287837b fix(ovhcloud): stop a diagnostic step from failing the test (1.2.4)
GET /telephony — the list of billing accounts — answers 500 with an empty body on
at least one service account. Nothing in this integration needs that list: it was
added as a diagnostic, and it promptly failed the very test it exists to explain,
hiding whatever the account-scoped routes had to say behind its own failure. It is
now recorded and stepped over.

When neither product can be listed either, the message names what each code means
rather than repeating the status: 403 is an IAM policy missing the telephony
actions, 404 is a billing account that does not exist under these credentials, and
500 on every telephony route means this service account cannot reach the telephony
API at all — for which the way round is an application key from
api.ovh.com/createToken, which this integration already accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:25:00 +00:00
f3nris 3ca2d6ded2 fix(ovhcloud): make the test say where it stopped (1.2.3)
This endpoint answers a bare 404 — "This service does not exist" — for a wrong
billing account, a wrong service name, a queue filed under the other product, and
a number nobody has configured yet. Four different jobs, one message, and nothing
on the Riposte screen could tell them apart. Two rounds went to guessing which one
it was.

The probe now walks there one step at a time and reports what it saw at each: the
billing accounts these credentials can reach, the services each product holds, the
queues in the chosen one. A failure names the step and lists the values that do
exist, which is what somebody can act on.

It also answers its own VERSION. When a test fails identically twice, the first
question is whether the new code is the code that ran, and nothing on the screen
could answer it.

And a failed diagnosis now exits non-zero with an "error" key. Returning ok:false
on exit 0 would have been worse than the 404 it replaces: the screen would call
the instance healthy while naming the step where it is not.

Verified end to end through the real entry point for six shapes — success under
either product, unreachable account, wrong account, number that is not a queue,
service with no queue, and a listing the IAM policy refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:21:54 +00:00
f3nris d682d66f04 fix(ovhcloud): find the call queue under either product (1.2.2)
A queue reached 404 "This service does not exist" on an account where the number
was plainly configured as one. The number was right: the PRODUCT was wrong. OVH
files a call queue under easyHunting or under ovhPabx depending on the offer the
number carries, the hunting routes underneath are identical either way, and the
control panel says "File d'appels" for both — so there is nothing an operator can
look at to tell which one they have.

It is now discovered once per run rather than assumed, and a service found under
neither product says which names DO exist under each. That turns the least
helpful error this API produces — one that reads as a wrong number — into a list
somebody can copy the right value out of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:15:57 +00:00
f3nris aacfa135aa fix(ovhcloud): test what the instance is for, not the account (1.2.1)
An instance created for on-call routing has no business reading the account, and a
service account narrowed to /telephony — which is exactly how it should be
narrowed — answers 403 "User not granted for this request" on
account:apiovh:me/get. The credentials were perfect and the Test button said they
were not, which is the worst thing a test can do: it sends somebody off to
regenerate a secret that was never wrong.

With telephony coordinates configured the probe now reads the service's call
queues instead. That is worth more than /me ever was: one call proves the
credentials, the IAM policy, the billing account, the service AND the queue. With
no coordinates it still reads the account, which is the right probe for an
instance used to look at servers.

A service found holding no queue is reported as a SUCCESS with a sentence saying
so. It is not a credential failure, and confusing the two is the difference
between "go and create a queue" and an evening spent on authentication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 18:52:04 +00:00
f3nris 6e03b17afb feat(ovhcloud): accept a service account (OAuth2) as well as application keys (1.2.0)
The control panel does not offer application keys on every account — some can only
create a SERVICE ACCOUNT, which authenticates with OAuth2 client credentials. An
instance built that way could not talk to the API at all, which is a hard stop for
anybody whose account is in that shape.

Both schemes are now supported, and which one is used is decided by WHAT WAS
FILLED IN rather than by a mode selector: a client_id and a client_secret mean the
service account, anything else falls back to the signed scheme. One less field to
get wrong, and every existing instance keeps working untouched.

The API base is corrected to /v1 for a service account. The same routes answer
under /1.0 and /v1, but a Bearer token sent to the legacy spelling fails in a way
that reads as "bad credentials" — which sends somebody off to regenerate a
perfectly good secret. An operator should not have to know that the spelling and
the credential go together.

Nothing in config_schema is required any more. The schema cannot express "one set
or the other", and keeping the historical three required would refuse every
service-account instance; the scripts refuse an instance with neither and name
both sets, which is an error somebody can act on — where a form that will not
submit is one they can only fight.

Two things the shared block now caches for the life of the process, both of which
matter on the ladder path that runs every time the rota turns: the access token
(a token request in front of each of a dozen calls would triple the traffic) and
the clock offset against OVH's time (it was a round trip per signed request).

Verified offline for both schemes: the base URL each one talks to, the headers
that land on the wire, the single token request, and the refusal when neither set
is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 18:31:31 +00:00
f3nris 864300d7c5 feat(ovhcloud): hold Riposte's on-call ladder in a VoIP call queue (1.1.0)
Riposte's pager can hand its on-call ladder to a marketplace instance, so that a
number published to clients rings whoever is on duty without the call ever
crossing the SOC's own trunk — and without spending one of its simultaneous
calls, which are sold in blocks and are the reason the PBX-side mode is too
expensive for a small deployment.

Three commands, and they are a contract rather than this integration's own
vocabulary: set_oncall_ladder (ordered numbers + ring_seconds), get_oncall_ladder
(what the carrier ACTUALLY holds, so a comparison can be honest) and list_calls
(the journal, after the fact). The pager offers whichever instances declare the
first one; the README now documents the contract for whoever writes the next
router.

Extends the existing ovhcloud integration rather than adding a second one: it
already carries the signed-request authentication, and making an operator enter
the same application key twice would serve nobody. Three telephony_* config
fields are added; every existing command is untouched, and an instance that
leaves them empty is exactly the OVHcloud reader it was before.

set_oncall_ladder RECONCILES rather than rebuilds. Agents are created when a
number has none, reordered by position, and anybody who drops off the ladder is
LOGGED OUT and removed from the queue — never deleted, because deleting throws
away whatever else the operator configured on that agent and they are on the rota
again next week. It also enforces the sequential strategy: publishing an ordered
list into a queue set to ring everybody at once is a lie that is invisible from
Riposte.

Two details that would each have cost an evening: OVH accepts no "+" in a
phoneNumber field, so E.164 is converted to the 00-prefixed form both ways; and a
service holding several queues is REFUSED rather than guessed, because guessing
publishes the on-call ladder into whichever queue the API happened to list first.

Verified against a stubbed API for the first publication and for a rota handover.
The signature, the error codes and the exact number format are only proved by the
first real call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 18:00:33 +00:00
f3nris 11089e9e80 rapid7-insightidr 1.13.0: comment an investigation and read the thread back
add_comment (POST /idr/v1/comments) and list_comments (GET /idr/v1/comments)
close the gap the Demisto integration never covered: the investigation comment
thread is where an analyst's reasoning is recorded, and a playbook could neither
write into it nor read it back.

The Comments API is v1-only and has no multi-customer parameter, so neither
command takes api_version. It addresses its target by RRN; the short
investigation ID the other v1 endpoints accept is refused with a 400 that names
no parameter, so both commands reject a non-RRN up front and say why.

list_comments walks the pages (size capped at 100 by the API) up to `limit`, or
all of them with all_results, with a 50-page backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:22:58 +00:00
f3nris 26d6273f60 feat(webhook): outbound webhooks to any third-party solution (v1.0.0)
The catalog could POST to Discord and to Teams, and to nothing else: every
other receiver needed its own package. This is the generic one — the
destination is instance config, the body is the command's.

Four senders and a test:
  post_json   a JSON document, the receiver defines the schema
  send_event  a normalised Riposte envelope (id, title, severity, link)
  post_form   x-www-form-urlencoded, for receivers older than JSON webhooks
  post_raw    a body verbatim with the Content-Type you choose

Three decisions worth naming:

- The destination is pinned on the instance and a per-call `url` is refused
  unless allow_url_override is set. An integration that always took its URL
  from the command inputs would be an arbitrary HTTP client for anything that
  can call it, the agent included. The SSRF guard (public addresses only,
  https only) sits behind the same kind of explicit opt-in.

- `url` is sensitive. Slack, Teams, Discord, Zapier and n8n all carry the
  token in the path, so command output echoes scheme://host and never the
  path or the query.

- test_connection opens a TCP/TLS connection and sends nothing. The health
  checker runs it on a schedule on every enabled instance; a receiver should
  not have to filter a Riposte ping every few minutes to keep one green.
  test_sends_ping opts into the real POST.

Optional HMAC-SHA256 over the body (or over `<ts>.<body>`, the replay-
resistant scheme Slack and Stripe use) lets the receiver prove the call came
from Riposte. Retries cover 408/425/429/5xx and connection failures, honour
Retry-After, and stay inside the sandbox's 60s ceiling. A non-2xx that
survives them fails the command, so a playbook block never reports a
delivery that did not happen.

Verified against a local receiver: 52 checks over payload shapes, the three
auth modes, both signature schemes, header precedence, retry and give-up,
every guard, path/method handling, and both test modes.
2026-09-18 08:25:34 +00:00
f3nris dd2b4629a1 sentinelone: count_agents takes agentVersion__gte and __lt too
Folded into the unreleased 1.16.0 entry, which now covers the three
comparators added beside the agentVersion__gt the command already had.
Sent together they bound a range, so a fleet can be counted on a band
of builds rather than only above one. Empty ones are not sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:45:22 +00:00
f3nris 741db61543 sentinelone 1.16.0: count_agents takes agentVersion__lte
The other end of the agentVersion__gt range the command already had:
agents on the given version or older, so a target build counts as
still to be upgraded rather than as already done. Left empty the
filter is not sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:43:00 +00:00
f3nris 086150b200 rapid7-insightidr 1.12.0: ingest LEQL query results as alerts
A tenant whose basic detection rules only notify — email, Slack — creates no
investigation and no alert any API can read: /investigations stays empty and the
Alert Triage API is MDR-gated. The rule's LEQL query is the only artefact left,
so replay it on a schedule and turn every matching line into an alert.

query_log and query_log_set gain:

- an ingest block (results_path events, dedup_key event_id, watermark start_time);
- event_id = log_id + the line's sequence number, the line's own address in Log
  Search. It has to be stable across runs because the scheduler deliberately
  re-reads the last minute of the previous window on every tick, and anything
  derived from the fetch time would make that overlap a second incident. A hash
  of (log_id, timestamp, message) covers a source that does not number entries;
- start_time read as ISO-8601, UNIX seconds or milliseconds alike. The API only
  takes milliseconds, but which one the scheduler sends is an operator setting on
  the rule — the feed must not fetch the wrong window because of a dropdown;
- a bounded page walk (limit, default 1000): unattended every few minutes, a
  query matching more than intended would otherwise hold the run open long
  enough for the next tick to find it in flight and skip itself;
- detection_name, stamped on every event. The log line does not know which rule
  it is being replayed for, and without it an incident is titled by its own first
  characters where the source console shows an alert name.

Plus mappers/query_log{,_set}.yaml: the parsed event lives in `message` as a
JSON-encoded string, so account, addresses, asset and process come out through
$parseJson. A raw unparsed line is not JSON — those entries resolve to nothing
and are skipped, and custom:rapid7_log_line still carries the line verbatim.
Severity is left unmapped: a log line has no priority of its own, so the alert
rule's default applies.

Known limits, by construction: this endpoint rejects calculations, so a rule with
a threshold (N occurrences in M minutes) cannot be reproduced — only pattern
rules are 1:1 — and an inactivity rule never can, since it fires on the absence
of events. The query also becomes a copy: editing the rule in InsightIDR leaves
the feed reading the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjtRtCzw6FEajDDHJ3mTQf
2026-09-16 15:06:02 +00:00
f3nris a32992d781 docs: document bundled mappers, jsonata: expressions and the reshape-in-the-mapper convention
The README never described mappers/ or incident-types/ although the
sync collects both. Documents the mapper file format, the jsonata:
prefix with $parseJson/$toTable, the singleton [] trap, and the
convention that new integrations reshape in the mapper rather than
pre-building display structures in scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1fWBoEKvAw3k78XDkpRQo
2026-09-11 21:34:25 +00:00
f3nris d3c85cf332 rapid7-insightidr 1.11.1: strip whitespace from comma-separated filter params
"HIGH, CRITICAL" as typed sent " CRITICAL" to the API, which rejects
the whole call — a priority filter written naturally fetched nothing.
Normalize statuses/priorities/sources/tags in list_investigations,
get_investigations_full and search_investigation_alerts before the URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1fWBoEKvAw3k78XDkpRQo
2026-09-11 18:28:15 +00:00
f3nris ab63ead00e docs(manifest): document the per-command category
A command can now declare what it is FOR, and Riposte's agent browses the estate
by it: enrich, hunt, inspect, transform, collect, contain, remediate, identity,
notify, ticket, manage.

It is a second axis to the manifest-level `category`, which says what the
product is. All 101 of HarfangLab's commands are "endpoint"; the analyst looking
for a decoder and the one looking for an isolation are not looking in the same
place, and the product axis cannot separate them.

Optional, and the template says why it is worth writing anyway: a command that
declares nothing is classified by Riposte from its id and description, which is
a guess. A value outside the list is ignored and the command classified instead,
so a typo costs the declaration rather than inventing a twelfth category that
nothing else ever lands in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8cMrB8KPHVpL47rmKynDQ
2026-09-10 14:26:09 +00:00
f3nris a26e71f29e fix(sentinelone): make the process tree draw itself (v1.15.0)
The block reads actor.process.file.name and would have rendered the chain,
but mapperHasProcessChain — the test that places it when a layout is
generated from a mapper — wants actor.process.name alongside the parent's.
This mapper declared only the file name, so the block was never placed and
the chain went unseen on every SentinelOne incident.

process.file.name now feeds both, and certSubject feeds the node's signer
badge (the block reads file.company_name for it, and certSubject is the only
signing identity a unified alert carries).

Verified by running processTree.ts itself over this mapper's output on the
live ClickFix alert: placement true, and a two-node chain explorer.exe ->
powershell.exe carrying the host, the user, both paths and both sets of
hashes when the fetch ran with_details. Without it the chain still draws,
with the parent named but unhashed — which is all the list nodes say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:42:28 +00:00
f3nris da2fcbba6c fix(sentinelone): map the unified alert as a real one is shaped (v1.14.0)
Checked against a live STAR alert, three of the details mappings were wrong
about where the data actually sits.

Its alert-level observables list is empty while all twelve observables sit
under the indicators, so the mapper saw none. The two lists are appended now
rather than reading the alert level alone.

The same alert has process.username null while its indicators name the user
(src.process.user), and names its parent process without hashing it anywhere
else. actor.user.name and the parent process path and hashes are read from the
indicator observables, with the alert's own field winning where it is filled
in, so nothing regresses on an alert that does populate it.

attacks and relatedAlerts are guarded on $count rather than $exists: that
alert carries "attacks": null, and null exists in JSONata — the entry would
have written an empty array onto every alert without an ATT&CK mapping.

Verified by running the whole mapper through the scheduler's own jsonata-go
build on that payload, with and without with_details: 95 entries, no empty
arrays, and the plain list fetch still falls back to the fileName/fileHash
observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:38:21 +00:00
f3nris 31e596d770 feat(sentinelone): with_details on the unified alerts fetch (v1.13.0)
The list nodes carry no indicator, no ATT&CK mapping, no observable and no
related alert — everything that says why the console fired, and the analyst
opens the alert in SentinelOne to find it. with_details reads the per-alert
view for every alert the fetch returns and folds it into the alert, so the
incident is created with that context instead of a title and a severity.

Read in aliased batches of ten, so a default-limit fetch costs ten extra
requests rather than a hundred. Merged under the list node — the list is the
authority on the fields both carry, the detail only fills in — so the richer
list asset/process survive the merge and gain process.username and file.size.
Best effort: an alert the console will not detail keeps its list fields and
the shortfall is reported in pagination.details, rather than failing a fetch
whose alerts are already in hand.

Off by default, and verified query-identical to 1.12.0 for a rule that does
not set it.

The mapper gains evidences, finding_info.attacks, finding_info.related_events
and the full observables list. Each is guarded with $exists and wrapped in [ ]
— checked against the jsonata-go build the scheduler uses: the brackets keep a
single indicator from collapsing into a bare object, and the guard leaves the
entry undefined on a plain list fetch so it cannot write an empty array over
the file summary those alerts do carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:32:49 +00:00
f3nris fe564cec2c feat(sentinelone): account_ids filter on get_unified_alerts (v1.12.0)
get_threats and get_alerts have taken an accountIds filter since 1.4.0, so a
multi-tenant console could be ingested one account at a time. The unified
alerts could not, which left a tenant-per-rule setup unable to move off the
REST commands.

An alert carries its account twice — the scope it sits in now
(realTime.scope.account.id) and the one it was detected in
(detectionTime.scope.accountId). The first is what an operator means by "this
account", so it is filtered on first, degrading to the second and then to a
filter over the fetched alerts if the console rejects the fieldId, the same
ladder the analytic category uses. pagination.accountFilter reports which of
the three ran. Client-side, an alert matches on either of its two accounts.

The query is unchanged for a rule that does not set the new input, verified
against 1.11.0 on the inputs an existing alert rule sends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:03:53 +00:00
f3nris 4cd1cda7cb feat(sentinelone): absorb the SentinelOne SDL package (v1.11.0)
The SDL package covered the same unified (UAM) alerts from a second
integration: one console, two packages, two instances, two tokens. Its
commands now live here, so it can be dropped.

get_unified_alerts gains the SDL filters — classification, attack_surface,
os_type, asset_id, external_id, search_text, unassigned_only,
unmitigated_only and a detectedAt window. status, severity, analyst_verdict
and analytics_category take comma-separated lists (stringEqual on one value,
stringIn on several), and every time input also reads a relative window
('24 hours') on top of ISO8601 and epochs. The query is byte-identical to
1.9.0's for the inputs an existing alert rule sends, apart from enum values
now being upper-cased — a lowercase severity used to filter nothing.

Three capabilities the SDL package had and this one did not:
get_unified_alert_details (indicators, their MITRE mapping, observables,
related alerts), add_uam_alert_note and trigger_uam_alert_action (any console
action by ID). update_uam_alert_assignee completes the status/verdict pair,
on the same alertTriggerActions mutation.

analytics_category filters the fetch to one analytic category, e.g. only the
platform-rule alerts (analyticsCategory=Platform Rule, the console's own
filter). The SDL package showed this endpoint takes dotted fieldIds
(asset.osType, assignee.userId) and spells its sort argument differently, so
both spellings of each are tried before giving up: the category degrades to a
filter over the fetched alerts rather than failing the run, and
pagination.categoryFilter / .sortForm report which shape the console accepted.

update_uam_alert_status and _verdict now report a GraphQL error instead of
printing it as a successful update — a playbook that chained on a refused
update used to carry on as if it had gone through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 11:05:54 +00:00
f3nris 9081bc66fa feat(rapid7-insightidr): evidence rows for the table field (v1.11.0)
The Markdown summary reads as a page. It cannot be sorted or filtered, and an
investigation aggregating several alerts, each with several evidences, is
exactly where those are wanted: "Multiple User Accounts Were Disabled" is a
question about which accounts, and the answer is a column.

So one row per evidence, across every alert, into custom:rapid7_evidence. The
table field reads an array of objects as it stands, so nothing is reshaped on
the mapper side. It sits beside the summary rather than replacing it — one is
read, the other is queried.

Two decisions worth stating.

Each row carries its alert's name first. An investigation aggregates several,
and a row that does not say where it came from cannot be acted on.

Column order is load-bearing, because it is the only ordering signal the field
has: it takes the union of keys in order of first appearance, so whatever the
first row names first is what an analyst sees without scrolling. Known keys
lead — action, result, the source and target pairs, the host, the addresses —
and the rest follow alphabetically.

Width is capped at 30 columns, decided by how many rows carry each key rather
than by which appeared first: an investigation mixing rule types has a sparse
union, and a column present on one row out of eighty would otherwise push out
one present on all of them. Exercised on a 187-column case — the five keys
common to every row all survived, and the count is reported as
evidence_columns_dropped rather than the loss being silent.

Verified against the account-disabled investigation: two evidences become two
rows of twenty columns, `alert` first, the two disabled accounts in
target_account — checked through the table field's own reader, not just the
generator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:57:10 +02:00
f3nris b26257aaeb feat(rapid7-insightidr): render the detection as Markdown, and fix four gaps (v1.10.0)
An "Multiple User Accounts Were Disabled" investigation came back with one
entity — the actor — while the layout stayed nearly empty. Four separate
reasons, and the last one is why the first three keep recurring.

  evidences was an OBJECT, not an array. JSONata returns a bare object when the
  path yields one item, so the key had one shape on an investigation with a
  single alert and another with two. Bracketed to force an array.

  Only the actor was mapped. An ad_admin rule names who acted AND who it was
  done to, and on this alert the disabled accounts are the point of it. Target
  account, target user and both domains now map to the OCSF Target User group,
  the actor to Actor — the distinction that lets the sentence be read in one
  line.

  The domain controller was nowhere. A Windows event puts it in
  source_json.computerName and nowhere else, and source_json was skipped whole
  because the Azure AD one runs to kilobytes. Its top-level SCALARS are now
  read — bounded, and where computerName, osVersion and callerIpAddress live —
  while its nested part stays skipped.

  source_asset was the string "unknown". Mapped as-is it becomes an asset named
  "unknown" in the entity index, shared by every incident that had no asset: a
  pivot that groups things by the absence of a fact. Placeholders are dropped,
  and a key whose value cleans away to nothing is removed rather than kept
  empty, since "" is not nil and would still be written to its OCSF field.

Five entities now, from one: both accounts, both display names, the DC.

And the reason this kept happening. The layout builder's field picker is a
closed <select> over the OCSF catalogue in web/src/lib/ocsf.ts, so a path absent
from it cannot be placed on a layout at all — which is what `detection` and
`evidence` are, and what every unanticipated key will be. Mapping each one to a
real OCSF field is right where a field exists, but rule_keys_of_interest is an
open vocabulary: four spellings turned up in as many days.

So the whole of it is also rendered as a Markdown table into
custom:rapid7_summary — keys of interest first, since the rule itself says
those are what matter, then the evidence fields. Declare a custom field of type
markdown with that name, put it on the layout, and a new rule type is readable
the day it appears instead of after another mapping round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:04:14 +02:00
f3nris e19530d3f3 fix(rapid7-insightidr): read the evidence in both of its shapes (v1.9.2)
evidence_fields came back {} on a live run. The evidence was fetched, the run
was clean — one call, 1.3s, no error — and the parser found nothing in it.

`data` does not have one shape. The parser was written against an unparsed
Fortigate source and looked only for {source_data, custom_data}. A PARSED
source has no such wrapper: the event is the object.

  {user, account, source_ip, geoip_country_code, geoip_organization,
   result, service, user_agent, source_json{…}, r7_context{…},
   detection_context{…}}

Every one of those is what the incident was missing, sitting one json.loads
away, and the parser walked past all of it and returned an empty dict. No error
— which is how it survived a run that reported success.

Now both are read as a union rather than one or the other: scalars at the top
level are always taken, so a third shape still yields its plain fields instead
of nothing, and the wrapped keys are overlaid when present. source_json is
skipped — several kilobytes of vendor payload that stay in the raw line —
while r7_context and detection_context are flattened one level, being Rapid7's
own annotations and the same across parsed sources. They carry the account age
in minutes, which is exactly what says whether a sign-in is from a new account.

The Azure AD sign-in now maps to src_endpoint.ip, .isp_org and
.location.country, actor.user.name/.uid/.account.name, http_request.user_agent
and metadata.product.feature.name, and indexes the address and the account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:19:08 +02:00
f3nris 2d6e097742 fix(rapid7-insightidr): explain the process-tree 404 (v1.9.1)
Running the command on an alert reports `{"error": "HTTP 404", "detail": ""}`.
The API returns 404 with an empty body, so the operator is told a number and
nothing else — and cannot tell a normal answer from a fault.

It is usually normal, and the URL is not at fault: it is built exactly as
get_alert_evidence builds its own, which works. Rapid7 uses the same 404 for
three unrelated situations and does not distinguish them:

  - the alert is not an endpoint detection, so no tree exists. This is the
    common case on a feed of ingress_auth, asset_auth or firewall events;

  - the tree exists in principle but was never generated. The endpoint returns
    the CACHED tree unless force_refresh is set, so an ungenerated tree answers
    404 while its data is there. Retrying with force_refresh=true is the first
    thing to try on an endpoint detection;

  - the key lacks the privilege.

All three are now named in the error, and the force_refresh input says what it
is really for rather than describing it only as a staleness control.

The ingestion path already handled this correctly — it treats 404 as "no tree"
and counts only other statuses as errors — so nothing changes there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:56:23 +02:00
f3nris 1888648930 feat(rapid7-insightidr): feed the layout Process Tree block (v1.9.0)
Rapid7 exposes a process tree per alert; the layout renderer has a block that
draws one. They do not speak the same shape.

The block reads ctx.process_tree as N FLAT chains, root first, each
{alert_id, rule, host, user, nodes[]} — it merges shared ancestors itself.
Rapid7 answers with a NESTED tree: one root entity and children[]. So the
command walks each root-to-leaf path into its own chain, maps the node fields
(cmdline -> cmd_line, hashes.md5 -> md5, start_time -> started_at, pid coerced
from the string Rapid7 sends), and rolls every alert's chains up onto the
investigation, since the block reads the key at the top level and no mapper
expression reaches past alerts[0].

Verified through the block's own reader: a tree of explorer -> cmd ->
{bcdedit, vssadmin} flattens to two chains and comes back as one tree with the
ancestors shared and both leaves marked.

That mark is worth stating plainly. The block marks the last node of a chain as
the target, and Rapid7 flags no node as the one the alert fired on — so here it
means "end of this branch" and nothing stronger. Naming a target would assert
something the sensor never said.

Gated, because this is a third call per alert on top of the evidence one:
include_process_tree=auto (the default) asks only for the event types an
endpoint sensor produces a tree for, true asks for every alert, false never.
An authentication or firewall alert never has one, and on a feed where those
dominate the default saves a call per alert. 404 is treated as "no tree", which
is what it means, and only other statuses are recorded as errors. Bounded at 12
chains and 24 levels so an unexpected shape cannot expand without limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:53:22 +02:00
f3nris 995a39ea66 feat(rapid7-insightidr): fetch and parse the evidence behind each alert (v1.8.0)
An alert names what fired. The evidence is what happened, and until now it was
never fetched — which is why an incident could show a rule name and nothing
else. On a Fortigate admin login the alert carried an empty
rule_keys_of_interest; the evidence carried the whole event.

It cannot be reached by mapping alone. The evidence's `data` is a
JSON-ENCODED STRING holding {source_data, custom_data}, and JSONata does not
deserialise, so no mapper expression can ever see inside it. The parsing has to
happen in the command, and that is the reason for this change more than the
extra call is.

Once parsed, two views of the same event:

  custom_data  the vendor's normalised fields, prefixed per source
               (Forti_user, Forti_srcip) — 6 keys on the sample
  source_data  the raw line in key="value" form — 25 keys

Both are merged into evidence_fields with the vendor prefix stripped, so a
mapper names `user` once instead of once per vendor, and source_data is overlaid
last because it carries more and is not affected by the quoting artefact below.

Values are cleaned, and that is not cosmetic: custom_data returns
'10.70.20.66"' with a trailing quote left by the vendor parser. Kept as-is it
yields an address matching no regex — an observable nobody can pivot on, and no
error to say why.

The line itself lands on raw_data and the source's own sentence on message,
both of which the layout builder offers. The Fortigate login now maps to
src_endpoint.ip, dst_endpoint.ip, actor.user.name, device.hostname/name/uid,
activity_name "login" and status_detail "success", and indexes four entities
where it indexed none.

Optional (include_evidence, default true) and capped (max_evidence_per_alert,
default 5), one call per alert under the run's existing time budget. An alert
whose evidence cannot be read keeps an evidence_error and still arrives;
evidence_calls and evidence_errors are reported in the run metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:46:17 +02:00
f3nris 45e9cbd269 feat: declare a risk tier on every command in the catalog
467 of the 1592 commands carried no `risk:`. An unset risk is read as
destructive by the agent approval gate, so half the catalog's write side sat
behind the same prompt — and, in HarfangLab's case, 39 pure lookups did too.
A gate that stops everything stops being read as a signal, which is the
argument already made for the toolkit and for sentinelone (df27876).

The catalog is now 1027 read, 261 safe_write, 304 destructive, with nothing
defaulted. The line follows the one sentinelone drew: impact on the estate,
not reversibility — isolate-agent is reversible and is the canonical
destructive example.

  read        only queries; changes nothing outside Riposte
  safe_write  additive or reversible inside the tool's own case/enrichment
              plane: comments, tickets, tags, alert statuses, threat-intel
              records, sandbox submissions, collection jobs, outbound messages
  destructive changes the estate or weakens a control: containment, blocklists
              and allowlists, exclusions and detection rules, every delete,
              identity and credential operations, code on a host, generic
              API escape hatches

Three calls that were not obvious, and are now applied throughout:

- 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. The catalog contradicted itself here — crowdstrike's
  lift_containment was destructive while harfanglab's deisolate_from_threat
  was safe_write — so deisolate_from_threat and unquarantine_file are
  re-tiered to destructive, the only two existing tiers this pass changes.
- Retrieving a live secret is destructive: cyberark get_password, delinea
  get_secret, vault read_secret, beyondtrust get_credential. It is a GET, but
  the tier describes the stake, not the HTTP verb.
- Sending outward to humans stays safe_write — slack, teams, twilio, telegram,
  mail. Irreversible, but it is the collaboration plane, not the estate.

An object created but not yet referenced by a rule is safe_write; editing one
a rule already reads is destructive. That is why fortigate create_address and
panos create_custom_url_category are safe_write while update_address_group and
edit_custom_url_category are not.

The template and README never documented the field, which is how 467 commands
came to omit it. Both now carry the tier table and these rules, so the next
integration declares a tier instead of inheriting one.

111 packages get a MINOR bump and a changelog entry: an instance compares
versions to offer an update, so an unbumped manifest would leave the tiers
sitting in the catalog and never reaching the estates that need them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 12:55:20 +02:00
f3nris c7569fd4d0 fix(rapid7-insightidr): map both ends of a connection (v1.7.0)
An "RDP connection to DC" investigation arrived with device {}, process {} and
two indexed entities, both of them false — the 32-hex tails of its own alert
RRNs, which the IOC regex cannot tell from an MD5. Its four real values were
sitting in the payload, unmapped:

  source_account             vbalbakakis
  destination_account        vassilisadmin
  source_asset_address       sgazswvdip-01.toto.priv
  destination_asset_address  scazsvpdc01.toto.priv

The mappings assumed an alert has one host and one user, which holds for a
process_start_event and not for an auth rule: a connection names both ends and
neither is "the host". So source and destination now land on src_endpoint and
dst_endpoint, the acting account on actor.user and the account acted upon on
user — the distinction that lets a lateral move be read in one line.

That choice is also what makes the fields placeable. The layout builder's field
picker is a closed <select> over the OCSF catalogue in web/src/lib/ocsf.ts;
a path absent from it cannot be put on a layout at all. All four targets are in
that catalogue. `detection`, the open-vocabulary catch-all, is not and cannot
be — it keeps its value in the Context tab, which renders the whole tree, but a
layout cannot reference it, so anything meant for a layout has to be mapped to a
real OCSF field first.

evidences[] is flattened at the same time. It emitted "device": {}, "process":
{} and "attacks": {"technique": {}} whenever those keys were absent — three
empty objects per alert, filling the context and hiding what was populated.

Verified against the RDP investigation: four entities where there were none,
two hosts and two accounts, and no false hash once the incident type's
extraction policy is set to selected/host,ip,user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 10:21:27 +02:00
f3nris eeebbfed3a fix(rapid7-insightidr): a partial region result is not a quiet period (v1.6.0)
An RRN states its region — rrn:alerts:eu:<org>:alert:… — while the base URL is
built from the instance's configured `region`. Following that thread found a
gap that matters more than the naming.

The Alert Triage API is multi-region behind one gateway, and eleven of its
endpoints can answer HTTP 200 with a region_failures list. The spec is explicit:
the presence of items in that field IS the partial failure. Three of the
commands here call such endpoints and all three read only the payload, so a
region the gateway could not reach produced fewer alerts and nothing else — no
error, no count, no log line. For an ingestion feed that is indistinguishable
from a quiet period, which is the one failure mode this integration has now hit
three separate times.

So region_failures is carried into the run metadata by every command that can
receive it, alongside the regions actually seen in the collected RRNs, the
configured region, and any mismatch between them.

The mismatch report is deliberately a report and not a correction. On a
single-region tenant the two always agree. On a multi-customer key, which spans
organizations, they need not — and rewriting the host per resource would be a
guess about how Rapid7 routes these calls, made without a tenant to verify it
against. Naming the discrepancy is what can be done honestly today; if it ever
fires, the metadata says so instead of the alerts silently going missing.

list_alert_fields already passed region_failures through untouched, since it
returns the API response as it stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 09:07:52 +02:00
f3nris 306450a4b6 fix(rapid7-insightidr): rule_keys_of_interest is an open vocabulary (v1.5.0)
Both mappers read that field through a hardcoded list of keys — hostname,
process.cmd_line, parent_process.*, user — taken from the one alert this work
was built against, a process_start_event detection. Every entry is a JSONata
predicate on an exact key, so a rule emitting anything else matches none of
them.

A real alert from a second tenant showed what that costs. It is a custom
ingress_auth rule, and its keys are:

  account             locuz_hma@totogroup.net
  source_ip           183.82.103.101
  geoip_country_code  IN
  result              SUCCESS

Not one appears in the hardcoded list. The alert was fetched, inlined and
mapped, and produced an incident as empty as the ones this integration set out
to fix — with the account and the source IP sitting in the payload, unread.

Two changes:

  - The whole list is flattened verbatim into `detection`, and rule_matching_keys
    into `detection_match`. Keys nobody anticipated now arrive intact, and they
    reach the AI analysis, which reads the mapped context and never `raw`.

  - account, source_ip, destination_ip, geoip_country_code and result gain
    explicit OCSF entries. The explicit mappings are not redundant with the
    flattening: a value under `detection.` is only ever seen by the IOC regex,
    so an account there would be indexed as an email, or not at all, rather than
    as a user. Typing an entity correctly still requires naming its field.

evidences[] carries the flattened keys too, so the same holds for the second and
third alert of an investigation, not only the first.

Verified against both rule shapes end to end. The process rule yields the two
hosts it did before; the authentication rule now yields the source IP and the
account, where it previously yielded nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:35:27 +02:00
f3nris 69e3a3e81c fix(rapid7-insightidr): do not send a bare UUID to /alerts/ops/rrns (v1.4.1)
The run died with HTTP 400 and eight "The provided RRN: [...] is invalid",
every one a plain UUID. Two mistakes compounded.

The alert list under an investigation is not consistent about where the RRN
sits. On the ABA alert this work was built against, `id` held the RRN. On
others it holds a bare UUID. The code read `id` first and `rrn` only as a
fallback, so it picked the UUID whenever both were present.

Worse, the failure was total rather than partial. /alerts/ops/rrns validates the
whole list and rejects the entire request when one entry is malformed, so a
single bad identifier costs every alert in that batch — and with one batch, the
whole run. An investigation feed that stops dead because one alert is shaped
differently is not an acceptable failure mode.

So: read `rrn` first, and drop anything that is not RRN-shaped before the batch
is sent, counting it as alert_ids_without_rrn in the run metadata. The
investigation still arrives, its retrievable alerts still arrive, and what could
not be fetched is a number an operator can see rather than an absence nobody
would notice.

Both commands that walk this list are fixed; the selector is shared and was
checked against the two real shapes plus the empty and null cases.

This turns a total failure into a partial one. It does NOT establish that those
eight alerts are reachable at all: if they carry no RRN anywhere, they are not
in the Alert Triage system and no field_ids or batch call will produce them.
That needs the raw shape of one, which the next run of list_investigation_alerts
on the failing investigation will show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:52:51 +02:00
f3nris 841ae1199f feat(rapid7-insightidr): the investigation, with its alerts inlined (v1.4.0)
The previous command returned the alerts as its result rows, which quietly
changed the unit: an investigation holding three alerts would have become three
incidents. The investigation is what an analyst opens and what the SOC tracks —
it stays the incident, and the alerts are its content.

get_investigations_full walks the same three hops and inverts the shape:
investigations under `data`, each carrying its alerts under `alerts` plus an
alert_count. results_path=data and dedup_key=rrn, so it is a drop-in replacement
for list_investigations in an existing ingestion rule — only the command name
and the mapper change.

A few first-alert values are also lifted to the top level, and that is not
cosmetic. MapFields resolves dotted paths only and cannot index an array, so
`alerts[0].ai_suggested_disposition` in a field_mappings entry reads as nothing
— silently, leaving the custom field empty with no error anywhere. Three custom
fields were empty for exactly that reason until an end-to-end run showed it. The
ocsf section has no such limit: it is evaluated by full JSONata and indexes
freely, which is why the host and process mappings worked while the custom
fields did not.

The mapper maps the investigation first and the first alert after, so the alert
wins wherever the container says nothing. It also lands every alert — not just
alerts[0] — as OCSF-shaped objects under `evidences`, because the extractor
walks arrays and classifies on the path suffix: an investigation aggregating
two alerts on two machines now indexes both hosts, where reading alerts[0]
alone would have indexed one and silently dropped the other.

Verified end to end against the investigation that started this, with a second
alert added on another host: field mapping, OCSF mapping and entity extraction.
Both hosts are indexed. With the incident type's extraction policy set to
selected/host,ip,user, the entity set is exactly those two — the alert RRN's
32-hex suffix, which the IOC regex cannot tell from an MD5, is filtered out
there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:39:45 +02:00
f3nris 220fa75b99 feat(rapid7-insightidr): ingest only the alerts that belong to an investigation (v1.3.0)
Ingesting from search_alerts brings back every alert the tenant produces,
including the operational noise — two of the first three sampled were an agent
reporting it lacked Full Disk Access. What is worth an incident is what Rapid7
or the analyst folded into an investigation.

Filtering search_alerts on investigation_rrn does not work. The term is accepted
— no validation error, HTTP 200 — and matches nothing: zero alerts over fifteen
days, on a tenant where an alert carrying an investigation_rrn provably exists
in that window. The field is not searchable, and the API says so by returning an
empty page rather than by refusing the term. That failure mode is the reason not
to build an ingestion feed on it: a filter that silently stops matching drains
the feed to zero with no error anywhere, and nothing would ever say why.

search_investigation_alerts is guaranteed by construction instead:

  1. the investigations in the window (incremental on their created_time)
  2. their alert RRNs, one call per investigation — there is no bulk form
  3. the alerts themselves via /alerts/ops/rrns, batched 100 at a time

So 1+N+1 calls, and an alert with no investigation is never even named. Its
failure modes are loud: an unreadable investigation is skipped and counted, and
HTTP errors surface as themselves.

Step 2 is the one that grows with the window, so it runs under a time budget
(60s, against the server's 90s ceiling) and reports truncated=true rather than
being killed with the whole run's alerts lost. A partial fetch that says it is
partial beats a silent zero — the same reasoning that rules out the filter.

The output shape matches search_alerts exactly, so the mapper committed in 1.2.0
serves both. It gains five entries for the parent investigation, which resolve
to nothing on the search_alerts feed and are skipped there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:34:57 +02:00
f3nris 7e231c7c78 fix(rapid7-insightidr): name the input when terms_json will not parse (v1.2.1)
json.loads answers a bad value with "Expecting value: line 1 column 1 (char 0)",
which names neither the input at fault nor what it actually received. The
command has three string inputs; nothing in that message says which one broke,
so the operator is left guessing.

The mistake it hides is the obvious one. Documentation shows the input name and
the value together, so `terms_json: [{...}]` gets pasted whole into the value
field, and the parser then chokes on the field name it was handed. Echoing the
first 80 characters back makes that visible immediately.

Also carry a worked example in the manifest — the term that keeps only alerts
attached to an investigation, which is the filter this input mainly exists for —
and say explicitly to paste the array alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:31:27 +02:00
f3nris b6903d4590 feat(rapid7-insightidr): map alerts to OCSF, with real observables (v1.2.0)
An incident built from a Rapid7 investigation indexed zero entities. Nothing to
pivot on, nothing to correlate, and an AI analysis with nothing to read. The
reason turned out not to be the mapper but the feed: an investigation is an
envelope, and neither it nor its /alerts list carries a host, an account, a
process or a MITRE code.

The Alert Triage API does, in rule_keys_of_interest. On the alert behind the
investigation that started this, that field holds:

  hostname                            NBSGIT01-26
  process.name                        bcdedit.exe
  process.cmd_line                    bcdedit  /set {default} recoveryenabled No
  process.exe_path                    C:\WINDOWS\System32\bcdedit.exe
  parent_process.name                 cmd.exe
  parent_process.cmd_line             "C:\WINDOWS\system32\cmd.exe"
  parent_process.exe_file.hashes.md5  e463e36104d22a2479046a7d4f620816

with rule.mitre_tcodes ["TA0040", "T1490"] — Impact / Inhibit System Recovery,
which is what disabling Windows recovery actually is. None of it was reachable
before.

It is a list of {key, values} pairs rather than an object, so every entry reads
through a JSONata predicate — rule_keys_of_interest[key='hostname'].values[0].
A key a rule does not emit resolves to nothing and is skipped, so one file
covers every detection rule.

Two details worth keeping:

  - mitre_tcodes mixes tactics and techniques in one array ("TA0040" beside
    "T1490"). They are split on the "TA" prefix so a tactic never lands in the
    technique field, where it would name a technique that does not exist.

  - rule.name is documented but absent from live ABA alerts, so the analytic
    name falls back to `type`, which repeats the rule's display name.

Rapid7's own status, disposition and AI-suggested disposition ride as custom
fields. They cannot reach the incident's status and verdict columns — MapFields
accepts only title, severity, source, description and custom:* — but an analyst
can at least see that the source already closed the alert and what it decided.
The AI suggestion is exposed as a fact, never as a verdict.

Validated end to end against a live alert: field mapping, OCSF mapping and
entity extraction, which now yields the host and the parent process hash where
it previously yielded nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:07:53 +02:00
350 changed files with 19127 additions and 1023 deletions
+215 -5
View File
@@ -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
+3 -2
View File
@@ -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
+20 -2
View File
@@ -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)" }
+62
View File
@@ -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
+5 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+7 -2
View File
@@ -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" }
+5 -2
View File
@@ -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)" }
+3 -2
View File
@@ -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" }
+4 -2
View File
@@ -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" }
+6 -2
View File
@@ -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)
+5 -2
View File
@@ -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" }
+4 -2
View File
@@ -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)" }
+6 -2
View File
@@ -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" }
+4 -2
View File
@@ -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)
+57
View File
@@ -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)
+5 -2
View File
@@ -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" }
+3 -2
View File
@@ -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
+8 -2
View File
@@ -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" }
+5 -2
View File
@@ -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
+4 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+4 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+4 -2
View File
@@ -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" }
+3 -2
View File
@@ -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
+4 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+4 -2
View File
@@ -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)" }
+3 -2
View File
@@ -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
+7 -2
View File
@@ -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" }
+5 -2
View File
@@ -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" }
+5 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+5 -2
View File
@@ -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" }
+17 -2
View File
@@ -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" }
+3 -2
View File
@@ -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" }
+14 -2
View File
@@ -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 -1
View File
@@ -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():
+20 -1
View File
@@ -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():
+20 -1
View File
@@ -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):
+20 -1
View File
@@ -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():
+20 -1
View File
@@ -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):
+25 -2
View File
@@ -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