Compare commits

..

60 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
240 changed files with 12064 additions and 673 deletions
+84 -2
View File
@@ -30,8 +30,10 @@ integrations/
│ └── <command-id>.py │ └── <command-id>.py
├── mappers/ # optional — bundled mapper templates, one per command ├── mappers/ # optional — bundled mapper templates, one per command
│ └── <command-id>.yaml │ └── <command-id>.yaml
└── incident-types/ # optional — bundled incident types ├── incident-types/ # optional — bundled incident types
└── <anything>.yaml │ └── <anything>.yaml
└── agents/ # optional — the operator agent (Riposte 0.302.0+)
└── operator.yaml
``` ```
--- ---
@@ -113,6 +115,44 @@ 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 ### `risk:` — the agent approval gate
Every command declares a tier. It is what Riposte's agent approval gate reads: Every command declares a tier. It is what Riposte's agent approval gate reads:
@@ -202,6 +242,48 @@ 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 ## Well-known commands: the on-call router
Most command ids are an integration's own business. **Three are a contract**, and Most command ids are an integration's own business. **Three are a contract**, and
@@ -0,0 +1,61 @@
# The AbuseIPDB operator. Installed with the integration by Riposte 0.302.0
# and later (older versions ignore this folder). Read-only by design: it never
# reports an IP.
key: operator
handle: Tally
title: AbuseIPDB operator
role: analyst_l2 # needs integrations.execute — viewer would hide every command
approval_threshold: safe_write
max_tool_calls: 10
when_to_delegate: |
What AbuseIPDB's community reports say about an IP address or a small
network: abuse confidence score, number of reports and reporters, the
abuse categories reported, last reported date, ISP, usage type, country,
whether it is a Tor exit or whitelisted. IPs and CIDR blocks only. Not for
hashes, domains or URLs, and never for reporting an IP.
needs: |
The IP addresses (or a CIDR block, /24 at most) to check, and optionally
how far back reports should count (default 30 days). Or a Riposte alert
id, to pick the IPs from it.
returns: |
Per IP, facts from AbuseIPDB with the command that produced each:
abuseConfidenceScore, totalReports, numDistinctUsers, lastReportedAt,
isp, usageType, countryCode, isTor, isWhitelisted, and the most frequent
report categories when asked. "no reports" when totalReports is 0. The
score is attributed to AbuseIPDB, never your own verdict.
instructions: |
Your budget is 10 calls. Spend it in this order:
1. Only if you were given a Riposte alert id: get_incident_context (1 call)
and pick the public IPs from it. Otherwise skip.
2. Per IP: check_ip(ip=<ip>) with max_age_days 30 (default; at most 365).
Add verbose true only when the lead wants the report comments and
categories: it makes the answer much longer.
3. check_block(network=<CIDR>) only when the lead asks about the
neighbourhood of an IP. The free tier accepts /24 at most; write the
network as the lead gave it or as x.y.z.0/24 from an IP you were given.
Traps:
- A score of 0 with totalReports 0 means nobody reported it, not that it is
safe. isWhitelisted true means AbuseIPDB marks it as a known good
service. Report both as AbuseIPDB states them.
- usageType (e.g. "Data Center/Web Hosting/Transit") and isp often matter
more than the score: always include them.
- Private, loopback or link-local IPs are refused or meaningless: skip them
and say so.
- HTTP 422 means the IP or network was malformed: report it, do not adapt
the value. HTTP 429 is the daily quota: report it and stop.
- Copy IPs only from what you were given or a previous answer. Report
lastReportedAt exactly as the data gives it.
- On any other error: report the exact call and error, retry at most once.
commands:
- check_ip
- check_block
riposte_tools:
- get_incident
- get_incident_context
+1 -1
View File
@@ -1,6 +1,6 @@
id: abuseipdb id: abuseipdb
name: AbuseIPDB name: AbuseIPDB
version: 1.1.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.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." 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
@@ -0,0 +1,64 @@
# The Active Directory operator. Installed with the integration by Riposte
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
# Riposte refuses a sheet that lists a write.
key: operator
handle: Ledger
title: Active Directory operator
role: analyst_l2 # needs integrations.execute — viewer would hide every command
approval_threshold: safe_write
max_tool_calls: 10
when_to_delegate: |
Who an on-prem Active Directory account or computer is: the user's
name, mail, manager, groups, and account flags (disabled, locked out,
password never expires...), last logon and password age; a computer's
groups and OS; who is in a group. It cannot show sign-in events: no
command does. Not for Entra ID (cloud) accounts, and not for changing any
account.
needs: |
The user's sAMAccountName (e.g. jdoe, without DOMAIN\) or e-mail, a
computer name, or a group. The Riposte alert id if the user or host must
be read from the alert.
returns: |
Facts from Active Directory, each with the command that produced it;
"not found" when a lookup came back empty; the exact call and error when
one failed. No verdict.
instructions: |
Your budget is 10 calls. Spend it in this order:
1. If you were given an alert: get_incident_context (1 call) and read the
user and host from it, exactly as written. DOMAIN\jdoe becomes jdoe;
jdoe@corp.com is an e-mail or UPN: use the email input.
2. ad_get_user (1 call) with username=<sAMAccountName>, and
attributes "lastLogonTimestamp,pwdLastSet,whenCreated,lockoutTime"
(commas, no spaces).
Prefer username or email over a DN. Read userAccountControlFields for
the flags (disabled, locked out, password never expires). Report the
time attributes exactly as returned; do not convert them.
0 users: try once with email or name, then report "not found".
3. memberOf lists the groups. Name the privileged ones apart (Domain
Admins, Enterprise Admins, Administrators, and any name with "admin").
4. ad_get_computer(name=<host>) (1 call) for the host of the alert, with
attributes "operatingSystem,lastLogonTimestamp,whenCreated".
5. Optional: ad_get_group_members with group-dn copied from a memberOf
value (never typed or adapted), limit 50 (1-2 calls). Always set
limit: without it the whole group comes back.
6. Optional: ad_search for what the other commands cannot ask, with an
LDAP filter in the form the manifest shows,
(&(objectCategory=person)(objectClass=user)), size-limit 20. Only
when you are sure of the filter; otherwise skip it.
Take DNs and names only from the alert or a previous answer. On an error
("LDAP error: ..."): report the exact call and error; retry at most once.
commands:
- ad_get_user
- ad_get_computer
- ad_get_group_members
- ad_search
riposte_tools:
- get_incident
- get_incident_context
+1 -1
View File
@@ -1,6 +1,6 @@
id: active_directory id: active_directory
name: Active Directory name: Active Directory
version: 1.1.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.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)." 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
+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
+1 -1
View File
@@ -1,6 +1,6 @@
id: anyrun id: anyrun
name: ANY.RUN name: ANY.RUN
version: 1.1.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.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." 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
@@ -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)
@@ -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)
@@ -0,0 +1,61 @@
# The CyberArk PAS operator. Installed with the integration by Riposte
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
# Riposte refuses a sheet that lists a write — and get_password, which
# reveals a secret, is left out.
key: operator
handle: Lockjaw
title: CyberArk PAS operator
role: analyst_l2 # needs integrations.execute — viewer would hide every command
approval_threshold: safe_write
max_tool_calls: 10
when_to_delegate: |
Whether an account or a machine is under CyberArk privileged access
management: the vaulted accounts matching a username or address, their
safe, platform and credential management status (last change, last
verification, failures), the safes, and the vault users. It cannot show
privileged sessions or recordings, nor reveal a password: no command
does. Not for changing or rotating any credential.
needs: |
The account username, the target machine address or host name, or a
safe name. The Riposte alert id if they must be read from the alert.
returns: |
Facts from CyberArk, each with the command that produced it; "not found"
when a search came back empty; the exact call and error when one failed.
No verdict.
instructions: |
Your budget is 10 calls. Spend it in this order:
1. If you were given an alert: get_incident_context (1 call) and read the
user and host from it, exactly as written. DOMAIN\jdoe becomes jdoe.
2. list_accounts(search=<username>, limit=20) (1 call). search matches
username, address and name. 0 results means the account is not vaulted:
say so, it is a finding.
3. Reserve one call for the machine: list_accounts(search=<host or IP>,
limit=20): the other privileged accounts on the same target.
4. get_account_details(account_id=<id>) for the accounts that matter, at
most 4 calls. The account id looks like 12_34: copy it from the
list_accounts answer, never type or adapt it. Read safeName,
platformId, address, userName, and secretManagement (automatic
management on or off, status, lastModifiedTime, lastReconciledTime).
Report times exactly as given; do not convert them.
5. Optional: list_safes(search=<safe name>) (1 call); list_users(search=
<username>) (1 call) to see whether the person is a vault user.
6. filter takes the form shown in the manifest, e.g. safeName eq MySafe
(list_accounts) or userType eq EPVUser (list_users). Use a value copied
from an answer.
On an error: report the exact call and error; retry at most once. A 401
means the logon session failed: report it and stop.
commands:
- list_accounts
- get_account_details
- list_safes
- list_users
riposte_tools:
- get_incident
- get_incident_context
+1 -1
View File
@@ -1,6 +1,6 @@
id: cyberark_pas id: cyberark_pas
name: CyberArk PAS name: CyberArk PAS
version: 1.1.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.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." 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
@@ -0,0 +1,62 @@
# The Darktrace operator. Installed with the integration by Riposte 0.302.0
# and later (older versions ignore this folder). Read-only by design: Riposte
# refuses a sheet that lists a write.
key: operator
handle: Phantom
title: Darktrace operator
role: analyst_l2 # needs integrations.execute — viewer would hide every command
approval_threshold: safe_write
max_tool_calls: 12
when_to_delegate: |
Anything Darktrace can answer about a device on the network: the model
breach behind an alert (model, score, time, triggering components), the
device it concerns (hostname, IP, MAC, type), the other model breaches
that device raised, and the Antigena (autonomous response) actions
applied to it. Not for acknowledging breaches or activating Antigena, not
for raw packet or proxy logs, not for other vendors' alerts.
needs: |
The Riposte alert id or the Darktrace model breach id (PBID). Otherwise
the device IP (or Darktrace device id) and the time of the activity.
returns: |
Facts from Darktrace, each with the command that produced it: the breach,
the device, its other breaches and Antigena actions; "not found" when a
lookup came back empty; the exact call and error when one failed. No
verdict.
instructions: |
Your budget is 12 calls. Spend it in this order:
1. get_incident_context (1 call): the Riposte alert. Its source_ref and
raw fields carry the PBID, the device (did, IP, hostname) and the time.
Take every id from there, exactly as written; never type or adapt one.
2. get_model_breach(pbid) (1 call): model name, score, time, triggered
components, device.
3. get_devices (1-2 calls) with ip = the device IP, or did = the device id
from step 2. Prefer the IP from the data over a copied id.
4. Other breaches of the same device (at most 3 calls). get_model_breaches
has NO device filter: it lists every breach since from_time, count 50
by default. from_time is "YYYY-MM-DD HH:MM:SS": take the date and time
of the alert's first seen exactly as the data gives them, replace the
"T" with a space and drop the zone and fractions; do not compute other
dates. Raise count to 200 when the first answer is full. Keep only the
breaches whose device did matches, and list them (model, score, time).
min_score (0-1) narrows a noisy list.
5. ALWAYS call get_antigena_actions once (include_cleared true): keep the
actions on that device and say whether they are active or cleared.
Times in Darktrace answers are often epoch milliseconds: report them as
given, do not convert them yourself.
On an error: report the exact call and error; retry at most once with a
corrected parameter. An empty result is "not found", not an error.
commands:
- get_model_breach
- get_model_breaches
- get_devices
- get_antigena_actions
riposte_tools:
- get_incident
- get_incident_context
+1 -1
View File
@@ -1,6 +1,6 @@
id: darktrace id: darktrace
name: Darktrace name: Darktrace
version: 1.1.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.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." 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
@@ -0,0 +1,64 @@
# The Elasticsearch operator. Installed with the integration by Riposte
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
# Riposte refuses a sheet that lists a write.
key: operator
handle: Grep
title: Elasticsearch operator
role: analyst_l2 # needs integrations.execute — viewer would hide every command
approval_threshold: safe_write
max_tool_calls: 10
when_to_delegate: |
What the logs stored in Elasticsearch show for a user, host, IP or other
value: which indices exist, how many documents match, the matching
documents, and one document read in full. Not for other vendors' alerts,
and not for changing anything in Elasticsearch.
needs: |
The value to look for (user, host, IP, hash, domain), and if known the
index or index pattern (e.g. logs-*) and a time window. A ready query
from the lead if there is one.
returns: |
Facts from Elasticsearch, each with the index, query and size that
produced it; "not found" when 0 documents matched; the exact query and
error when one failed. No verdict.
instructions: |
Your budget is 10 calls. Spend it in this order:
1. get_incident_context (1 call) for the values to look for (user, host,
IP, hash) and the alert time. Take them exactly as written.
2. list_indices (1 call) unless the lead named the index. Pick an index
name or a pattern ending in * from that answer (e.g. logs-*); never
invent one.
3. count(index, query_string) first (1 call per value): it tells you
whether a search is worth it. query_string = the bare value, nothing
else (no field names, no boolean operators) unless the lead gave the
query. A value holding : / or a space goes in double quotes, e.g.
"fe80::1". For DOMAIN\user, search the user part alone.
4. search(index, query_string, size=20) (1-3 calls) where count > 0.
Never above size 50.
5. Optional: get_document with the _index and _id of a hit, both copied
from the search answer. It needs the concrete _index of the hit, not a
pattern with *.
6. Reserve one count + search for the same user, host or IP elsewhere:
the other value from the alert, or a wider pattern (e.g. *).
Time: these commands have no time parameter. If the lead gave a query_json
with a time range, pass it exactly as given. Otherwise say in your answer
that the search was not time-bounded, prefer an index whose name carries
the alert's date when list_indices shows such names, and keep size small.
Report document timestamps exactly as stored (@timestamp).
On an error: report the exact index, query and error; retry at most once
with a simpler query. Do not write query DSL yourself.
commands:
- list_indices
- count
- search
- get_document
riposte_tools:
- get_incident
- get_incident_context
+1 -1
View File
@@ -1,6 +1,6 @@
id: elasticsearch id: elasticsearch
name: Elasticsearch name: Elasticsearch
version: 1.1.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.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." 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
@@ -0,0 +1,61 @@
# The FortiGate operator. Installed with the integration by Riposte 0.302.0
# and later (older versions ignore this folder). Read-only by design: Riposte
# refuses a sheet that lists a write.
key: operator
handle: Rampart
title: FortiGate operator
role: analyst_l2 # needs integrations.execute — viewer would hide every command
approval_threshold: safe_write
max_tool_calls: 10
when_to_delegate: |
How the FortiGate configuration covers a host or IP: which firewall
address objects and address groups contain it, which IPv4 policies use
those objects (source, destination, service, accept or deny), and what a
custom service object means. This integration has no access to FortiGate
traffic logs: it cannot say what traffic a host produced or what was
actually blocked. Not for banning IPs or changing objects or policies,
not for other vendors' alerts.
needs: |
The Riposte alert id, or the IP or subnet, and the VDOM when the firewall
has several.
returns: |
The address objects, groups, policies and services that concern the host,
each with the command that produced it; "not found" when no object
contains it; the exact call and error when one failed. No verdict.
instructions: |
Your budget is 10 calls. Spend it in this order:
1. get_incident_context (1 call): the Riposte alert. Take the IP, host
name and VDOM from its source_ref and raw fields, exactly as written.
2. list_addresses (1 call, no name): every address object. Look in the
answer yourself for objects whose subnet contains the IP (subnet is
"IP netmask"; 255.255.255.255 is that single host) and note their
names.
3. list_address_groups (1 call, no name): keep the groups whose members
include those object names.
4. list_policies (1 call, no policy_id): keep the policies whose srcaddr
or dstaddr name one of those objects or groups, or "all". Report
policyid, name, srcintf, dstintf, srcaddr, dstaddr, service, action
and status as the answer gives them.
5. list_services (at most 2 calls) only for a service name a kept policy
uses that is not a predefined one; pass the name exactly as the policy
answer writes it.
Names are case-sensitive and must be copied from a previous answer, never
typed or adapted: a single-object lookup by name answers 404 when the
name does not exist. Pass vdom only when the data names one.
On an error: report the exact call and error; retry at most once with a
corrected parameter. No object containing the IP is "not found".
commands:
- list_addresses
- list_address_groups
- list_policies
- list_services
riposte_tools:
- get_incident
- get_incident_context
+1 -1
View File
@@ -1,6 +1,6 @@
id: fortigate id: fortigate
name: FortiGate name: FortiGate
version: 1.1.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.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." 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
@@ -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):
@@ -1,13 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error import json, os, sys, time, urllib.request, urllib.parse, urllib.error
SEVERITIES = ["low", "medium", "high", "critical"] SEVERITIES = ["low", "medium", "high", "critical"]
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): def request(method, url, headers):
for attempt in range(RETRIES + 1):
req = urllib.request.Request(url, headers=headers, method=method) req = urllib.request.Request(url, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=90) as resp: with urllib.request.urlopen(req, timeout=90) 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):
@@ -40,7 +59,11 @@ def main():
"alert_time__gte": inputs.get("created_after"), "alert_time__gte": inputs.get("created_after"),
} }
url = base + "/api/data/alert/alert/Alert/" + qs(params) url = base + "/api/data/alert/alert/Alert/" + qs(params)
print(json.dumps(request("GET", url, headers))) out = request("GET", url, 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:
+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():
+25 -2
View File
@@ -1,13 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error import json, os, sys, time, urllib.request, urllib.parse, urllib.error
SEVERITIES = ["low", "medium", "high", "critical"] SEVERITIES = ["low", "medium", "high", "critical"]
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): def request(method, url, headers):
for attempt in range(RETRIES + 1):
req = urllib.request.Request(url, headers=headers, method=method) req = urllib.request.Request(url, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=90) as resp: with urllib.request.urlopen(req, timeout=90) 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):
@@ -39,7 +58,11 @@ def main():
"creation_date__gte": inputs.get("created_after"), "creation_date__gte": inputs.get("created_after"),
} }
url = base + "/api/data/alert/alert/Threat/" + qs(params) url = base + "/api/data/alert/alert/Threat/" + qs(params)
print(json.dumps(request("GET", url, headers))) out = request("GET", url, 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):
@@ -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 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 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 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 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 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 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):
+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):
+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):
@@ -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 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 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 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 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 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 qs(params): def qs(params):
@@ -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():
@@ -1,12 +1,31 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error import json, os, sys, time, urllib.request, urllib.parse, urllib.error
RETRIES = 3
def retryable(method, status):
# HarfangLab answers a 500 now and then on a call that passes when sent again.
# A read is safe to resend; a write only on an answer that says the action was
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
if status in (429, 502, 503):
return True
return method == "GET" and status in (500, 504)
def request(method, url, headers, body=None): def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None data = json.dumps(body).encode("utf-8") if body is not None else None
for attempt in range(RETRIES + 1):
req = urllib.request.Request(url, data=data, headers=headers, method=method) req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp: with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read() raw = resp.read()
return json.loads(raw) if raw else {} return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
if attempt == RETRIES or not retryable(method, e.code):
raise
e.close()
time.sleep(2 ** attempt)
def main(): def main():
@@ -1,12 +1,31 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error import json, os, sys, time, urllib.request, urllib.parse, urllib.error
RETRIES = 3
def retryable(method, status):
# HarfangLab answers a 500 now and then on a call that passes when sent again.
# A read is safe to resend; a write only on an answer that says the action was
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
if status in (429, 502, 503):
return True
return method == "GET" and status in (500, 504)
def request(method, url, headers, body=None): def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None data = json.dumps(body).encode("utf-8") if body is not None else None
for attempt in range(RETRIES + 1):
req = urllib.request.Request(url, data=data, headers=headers, method=method) req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp: with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read() raw = resp.read()
return json.loads(raw) if raw else {} return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
if attempt == RETRIES or not retryable(method, e.code):
raise
e.close()
time.sleep(2 ** attempt)
def main(): def main():
@@ -1,12 +1,31 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error import json, os, sys, time, urllib.request, urllib.parse, urllib.error
RETRIES = 3
def retryable(method, status):
# HarfangLab answers a 500 now and then on a call that passes when sent again.
# A read is safe to resend; a write only on an answer that says the action was
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
if status in (429, 502, 503):
return True
return method == "GET" and status in (500, 504)
def request(method, url, headers, body=None): def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None data = json.dumps(body).encode("utf-8") if body is not None else None
for attempt in range(RETRIES + 1):
req = urllib.request.Request(url, data=data, headers=headers, method=method) req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp: with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read() raw = resp.read()
return json.loads(raw) if raw else {} return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
if attempt == RETRIES or not retryable(method, e.code):
raise
e.close()
time.sleep(2 ** attempt)
def qs(params): def qs(params):

Some files were not shown because too many files have changed in this diff Show More