Compare commits

..

46 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
233 changed files with 10545 additions and 638 deletions
+58 -2
View File
@@ -30,8 +30,10 @@ integrations/
│ └── <command-id>.py
├── mappers/ # optional — bundled mapper templates, one per command
│ └── <command-id>.yaml
└── incident-types/ # optional — bundled incident types
└── <anything>.yaml
├── incident-types/ # optional — bundled incident types
│ └── <anything>.yaml
└── agents/ # optional — the operator agent (Riposte 0.302.0+)
└── operator.yaml
```
---
@@ -139,6 +141,18 @@ issue_type_name:
Riposte refuses an `options_from` command that is not `risk: read`: opening a
field must never change anything in the customer's tool.
### Multi-line inputs
`format: multiline` (or `format: markdown`, which says what the text is) makes
the playbook editor show a resizable text area instead of a one-line field
(Riposte 0.322+). Use it for a mail body, a comment, a markdown description:
a one-line field turns the line breaks of a pasted text into spaces. Older
Riposte versions ignore `format` and keep the one-line field.
```yaml
description: { type: string, format: markdown, description: "Body in markdown" }
```
### `risk:` — the agent approval gate
Every command declares a tier. It is what Riposte's agent approval gate reads:
@@ -228,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
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
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."
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
@@ -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
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)."
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
+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
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."
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
@@ -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_name", ocsf_field: "finding_info.analytic.name" }
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
# ── Detection time: the MTTD anchor ───────────────────────────────
# `time` is what Riposte measures detection-to-ingestion against. The grid
# exposes when the tenant took the alert in (source_insert_ts); a sensor-side
# detection timestamp, when the tenant sends one, is the better anchor and
# comes last so it wins.
# ── 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.
# 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: "detection_timestamp", ocsf_field: "time" }
# ── Alert state ───────────────────────────────────────────────────
@@ -18,8 +18,11 @@ ocsf:
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
- { source_path: "status", ocsf_field: "status" }
# `time` is the MTTD anchor — when XDR opened the incident, as opposed to when
# Riposte ingested it. Without it the detection delay column stays empty.
# `time` is the OCSF event time — when XDR opened the incident, as opposed to
# 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: "alert_count", ocsf_field: "count" }
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
@@ -35,7 +35,10 @@ ocsf:
# The detection that opened the incident, so an analyst reading the aggregate
# can still pivot to the single alert in the XDR console.
- { 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
# incident, and then the first alert's own detection timestamp is the honest
# anchor; incident creation is the last resort.
@@ -135,14 +135,57 @@ def count(filters):
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 = {"count": count(filters), "filters": filters}
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 else result["count"]
result["total"] = count([]) if filters or tests else result["count"]
print(json.dumps(result))
@@ -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: "description", ocsf_field: "finding_info.desc" }
- { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" }
# `time` is the MTTD anchor: the ingestion engine reads THIS field and no other
# to stamp detected_at. Without it the incident is anchored at ingestion, so the
# delay between Falcon detecting and Riposte collecting — over a day on a slow
# poll — is measured as zero. finding_info.created_time below does NOT serve
# this: it is the finding's own record of the same instant, read by nothing.
# `time` is the OCSF event time: when Falcon raised the alert.
# 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: "created_timestamp", ocsf_field: "time" }
- { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" }
- { 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" }
@@ -1,4 +1,4 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
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", "{}"))
@@ -61,6 +61,44 @@ RANGE_FILTERS = [
]
# 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.
@@ -82,7 +120,7 @@ def build_fql(inputs):
for name, prop, operator in RANGE_FILTERS:
value = inputs.get(name)
if value:
clauses.append(prop + ":" + operator + literal(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()
@@ -110,9 +148,59 @@ def count(tok, fql):
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
@@ -62,8 +62,23 @@ def main():
action_params.append({"name": "append_comment", "value": str(INPUTS["comment"])})
if INPUTS.get("assign_to_uuid"):
action_params.append({"name": "assign_to_uuid", "value": str(INPUTS["assign_to_uuid"])})
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)
# 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))
@@ -51,6 +51,14 @@ def main():
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))
@@ -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
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."
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
@@ -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
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."
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
@@ -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
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."
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
@@ -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
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."
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
@@ -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
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."
changelog: "1.0.0 — Initial release: IP context, quick check, RIOT, GNQL query and stats, and IP timeline."
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:
title: "rule_name"
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.
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
ocsf:
@@ -3,7 +3,9 @@ description: "Maps a HarfangLab EDR security event/alert (/api/data/alert/alert/
field_mappings:
title: "rule_name"
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.
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
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):
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):
@@ -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):
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 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):
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 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):
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 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):
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):
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 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):
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):
@@ -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):
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):
+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):
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 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):
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):
@@ -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):
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):
@@ -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):
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 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):
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 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):
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):
@@ -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):
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 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):
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 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 = {
"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}
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 main():
@@ -1,12 +1,31 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
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):
@@ -51,6 +70,28 @@ RANGE_FILTERS = {
}
# 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
@@ -67,7 +108,13 @@ def main():
"Content-Type": "application/json",
}
# === per-command logic ===
filters = {}
# 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 == "":
@@ -84,17 +131,48 @@ def main():
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:
everything = request("GET", agents + qs({"limit": 1, "offset": 0}), headers)
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"]
@@ -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):
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):
+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):
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):
@@ -53,7 +72,11 @@ def main():
"alert_time__gte": inputs.get("from_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:
@@ -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):
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):
@@ -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"]
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):
for attempt in range(RETRIES + 1):
req = urllib.request.Request(url, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=90) 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):
@@ -40,7 +59,11 @@ def main():
"alert_time__gte": inputs.get("created_after"),
}
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:
+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):
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 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"]
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):
for attempt in range(RETRIES + 1):
req = urllib.request.Request(url, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=90) 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):
@@ -39,7 +58,11 @@ def main():
"creation_date__gte": inputs.get("created_after"),
}
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:
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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 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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
+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):
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 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):
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):
+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):
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):
+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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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 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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
@@ -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):
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):
+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):
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):
@@ -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 = {
"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}
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 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):
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 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):
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 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):
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):

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