Compare commits
51 Commits
89ffbc3c7c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fbe592421 | |||
| 6d36b4e958 | |||
| 90c6c80843 | |||
| c67343b7cf | |||
| 2bc7601177 | |||
| 2a74bbc436 | |||
| 983414f278 | |||
| 046810c1f6 | |||
| dcf257d887 | |||
| 5085d96628 | |||
| c358814fe7 | |||
| 815db080f5 | |||
| 6b76270cc6 | |||
| ff461464fd | |||
| 88407dd1b4 | |||
| 8eed7ed9fb | |||
| f34c87f0c8 | |||
| 0444e120fa | |||
| 585c06385c | |||
| 725e6921f4 | |||
| e973ccb673 | |||
| 62157e5dee | |||
| 91855fce4f | |||
| 6f51520fb5 | |||
| 80de5399a6 | |||
| fb560cc937 | |||
| 3bfcebdf21 | |||
| ae1effafcd | |||
| 889cf660f6 | |||
| 847f31ce17 | |||
| 3e39f17bfe | |||
| 9605e1f0dc | |||
| 2245d5500e | |||
| 4bd468e671 | |||
| c3b15d1faf | |||
| c5da537434 | |||
| 4a339fda24 | |||
| b9e796e927 | |||
| 856dd2321d | |||
| 94142f4739 | |||
| d2742ec6fb | |||
| 5cf870309f | |||
| 45df291eeb | |||
| db9a45c9bc | |||
| ece2c41340 | |||
| ea04ea586c | |||
| d831d0973a | |||
| 91f6b8158b | |||
| 0f5f4a900e | |||
| 820bab8e5c | |||
| 41aa6723c5 |
@@ -30,8 +30,10 @@ integrations/
|
|||||||
│ └── <command-id>.py
|
│ └── <command-id>.py
|
||||||
├── mappers/ # optional — bundled mapper templates, one per command
|
├── mappers/ # optional — bundled mapper templates, one per command
|
||||||
│ └── <command-id>.yaml
|
│ └── <command-id>.yaml
|
||||||
└── incident-types/ # optional — bundled incident types
|
├── incident-types/ # optional — bundled incident types
|
||||||
└── <anything>.yaml
|
│ └── <anything>.yaml
|
||||||
|
└── agents/ # optional — the operator agent (Riposte 0.302.0+)
|
||||||
|
└── operator.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,6 +115,44 @@ A command is **request-based** when it has a `request:` block, or
|
|||||||
**script-based** when a matching `scripts/<command-id>.py` exists. Prefer
|
**script-based** when a matching `scripts/<command-id>.py` exists. Prefer
|
||||||
request-based commands: they need no sandbox and are easier to audit.
|
request-based commands: they need no sandbox and are easier to audit.
|
||||||
|
|
||||||
|
### Inputs with a list of values
|
||||||
|
|
||||||
|
An input can offer its values in the playbook editor instead of a free-text box.
|
||||||
|
|
||||||
|
- **A fixed list** — `enum:`, when the values are the vendor's own and the same
|
||||||
|
everywhere (a verdict, a status). The editor shows a drop-down, and the
|
||||||
|
command bar refuses a value outside it.
|
||||||
|
- **A list read from the tenant** — `options_from:`, when the values belong to
|
||||||
|
each customer's tool (projects, issue types, priorities). The editor runs the
|
||||||
|
named command on the instance and lists what it returns; a typed value or an
|
||||||
|
expression is still accepted.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
issue_type_name:
|
||||||
|
type: string
|
||||||
|
options_from:
|
||||||
|
command: list_issue_types # a command of this integration, risk: read only
|
||||||
|
path: issue_types # where the rows are in its output (empty: the output itself)
|
||||||
|
value: name # row field written into the input
|
||||||
|
label: name # row field shown in the list (defaults to value)
|
||||||
|
depends_on: [project_key] # sibling inputs passed to the command, when filled with a literal
|
||||||
|
```
|
||||||
|
|
||||||
|
Riposte refuses an `options_from` command that is not `risk: read`: opening a
|
||||||
|
field must never change anything in the customer's tool.
|
||||||
|
|
||||||
|
### Multi-line inputs
|
||||||
|
|
||||||
|
`format: multiline` (or `format: markdown`, which says what the text is) makes
|
||||||
|
the playbook editor show a resizable text area instead of a one-line field
|
||||||
|
(Riposte 0.322+). Use it for a mail body, a comment, a markdown description:
|
||||||
|
a one-line field turns the line breaks of a pasted text into spaces. Older
|
||||||
|
Riposte versions ignore `format` and keep the one-line field.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
description: { type: string, format: markdown, description: "Body in markdown" }
|
||||||
|
```
|
||||||
|
|
||||||
### `risk:` — the agent approval gate
|
### `risk:` — the agent approval gate
|
||||||
|
|
||||||
Every command declares a tier. It is what Riposte's agent approval gate reads:
|
Every command declares a tier. It is what Riposte's agent approval gate reads:
|
||||||
@@ -202,6 +242,48 @@ named by field, evaluated against the rule's last captured samples.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Bundled operator agents (`agents/*.yaml`)
|
||||||
|
|
||||||
|
An integration can ship the **operator agent** that knows how to use it.
|
||||||
|
Installing the integration in Riposte (0.302.0 and later) creates the agent in the
|
||||||
|
crew, named `<handle> · <integration name>`; older versions ignore the folder.
|
||||||
|
The sheet carries what took a real investigation to learn — the vendor's traps,
|
||||||
|
the order to call things in — so nobody has to rebuild it by hand.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
key: operator # stable id within the package; one agent per key
|
||||||
|
handle: Static # an invented netrunner handle, never an existing character
|
||||||
|
title: Rapid7 InsightIDR operator
|
||||||
|
role: analyst_l2 # must hold integrations.execute
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
when_to_delegate: | # the only thing an orchestrator reads to route work to it
|
||||||
|
…
|
||||||
|
needs: |
|
||||||
|
…
|
||||||
|
returns: |
|
||||||
|
…
|
||||||
|
instructions: | # the vendor know-how: procedure and traps
|
||||||
|
…
|
||||||
|
commands: [list_investigation_alerts, get_alert_actors] # command ids of THIS manifest
|
||||||
|
riposte_tools: [get_incident, get_incident_context]
|
||||||
|
```
|
||||||
|
|
||||||
|
Riposte **refuses the sheet** — and still installs the integration — when it:
|
||||||
|
|
||||||
|
- lists a command this manifest does not declare, or one that is not `risk: read`
|
||||||
|
(an omitted risk counts as destructive);
|
||||||
|
- lists a Riposte tool that is not read-tier;
|
||||||
|
- names a role without `integrations.execute`. `viewer`, `auditor`,
|
||||||
|
`mcp_readonly` and `analyst_l1` lack it, and with them every integration
|
||||||
|
command would be invisible to the agent;
|
||||||
|
- has no `key`, `handle`, `role`, `when_to_delegate` or command.
|
||||||
|
|
||||||
|
The model is not part of the sheet: operators borrow Riposte's **Crew** role. A
|
||||||
|
sheet nobody edited follows the package's updates; an edited one keeps its edit
|
||||||
|
and offers the new version for review. Removing a sheet from the package retires
|
||||||
|
its agent on the next install.
|
||||||
|
|
||||||
## Well-known commands: the on-call router
|
## Well-known commands: the on-call router
|
||||||
|
|
||||||
Most command ids are an integration's own business. **Three are a contract**, and
|
Most command ids are an integration's own business. **Three are a contract**, and
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# The AbuseIPDB operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design: it never
|
||||||
|
# reports an IP.
|
||||||
|
key: operator
|
||||||
|
handle: Tally
|
||||||
|
title: AbuseIPDB operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
What AbuseIPDB's community reports say about an IP address or a small
|
||||||
|
network: abuse confidence score, number of reports and reporters, the
|
||||||
|
abuse categories reported, last reported date, ISP, usage type, country,
|
||||||
|
whether it is a Tor exit or whitelisted. IPs and CIDR blocks only. Not for
|
||||||
|
hashes, domains or URLs, and never for reporting an IP.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The IP addresses (or a CIDR block, /24 at most) to check, and optionally
|
||||||
|
how far back reports should count (default 30 days). Or a Riposte alert
|
||||||
|
id, to pick the IPs from it.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Per IP, facts from AbuseIPDB with the command that produced each:
|
||||||
|
abuseConfidenceScore, totalReports, numDistinctUsers, lastReportedAt,
|
||||||
|
isp, usageType, countryCode, isTor, isWhitelisted, and the most frequent
|
||||||
|
report categories when asked. "no reports" when totalReports is 0. The
|
||||||
|
score is attributed to AbuseIPDB, never your own verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. Only if you were given a Riposte alert id: get_incident_context (1 call)
|
||||||
|
and pick the public IPs from it. Otherwise skip.
|
||||||
|
2. Per IP: check_ip(ip=<ip>) with max_age_days 30 (default; at most 365).
|
||||||
|
Add verbose true only when the lead wants the report comments and
|
||||||
|
categories: it makes the answer much longer.
|
||||||
|
3. check_block(network=<CIDR>) only when the lead asks about the
|
||||||
|
neighbourhood of an IP. The free tier accepts /24 at most; write the
|
||||||
|
network as the lead gave it or as x.y.z.0/24 from an IP you were given.
|
||||||
|
|
||||||
|
Traps:
|
||||||
|
- A score of 0 with totalReports 0 means nobody reported it, not that it is
|
||||||
|
safe. isWhitelisted true means AbuseIPDB marks it as a known good
|
||||||
|
service. Report both as AbuseIPDB states them.
|
||||||
|
- usageType (e.g. "Data Center/Web Hosting/Transit") and isp often matter
|
||||||
|
more than the score: always include them.
|
||||||
|
- Private, loopback or link-local IPs are refused or meaningless: skip them
|
||||||
|
and say so.
|
||||||
|
- HTTP 422 means the IP or network was malformed: report it, do not adapt
|
||||||
|
the value. HTTP 429 is the daily quota: report it and stop.
|
||||||
|
- Copy IPs only from what you were given or a previous answer. Report
|
||||||
|
lastReportedAt exactly as the data gives it.
|
||||||
|
- On any other error: report the exact call and error, retry at most once.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- check_ip
|
||||||
|
- check_block
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
id: abuseipdb
|
id: abuseipdb
|
||||||
name: AbuseIPDB
|
name: AbuseIPDB
|
||||||
version: 1.1.0
|
version: 1.1.1
|
||||||
description: "AbuseIPDB (API v2) — check the abuse reputation of an IP, report abusive IPs, pull the blacklist and check a CIDR block. API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "AbuseIPDB (API v2) — check the abuse reputation of an IP, report abusive IPs, pull the blacklist and check a CIDR block. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: IP reputation check, report, blacklist retrieval and CIDR-block check."
|
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: IP reputation check, report, blacklist retrieval and CIDR-block check."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# The Active Directory operator. Installed with the integration by Riposte
|
||||||
|
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
|
||||||
|
# Riposte refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Ledger
|
||||||
|
title: Active Directory operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Who an on-prem Active Directory account or computer is: the user's
|
||||||
|
name, mail, manager, groups, and account flags (disabled, locked out,
|
||||||
|
password never expires...), last logon and password age; a computer's
|
||||||
|
groups and OS; who is in a group. It cannot show sign-in events: no
|
||||||
|
command does. Not for Entra ID (cloud) accounts, and not for changing any
|
||||||
|
account.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The user's sAMAccountName (e.g. jdoe, without DOMAIN\) or e-mail, a
|
||||||
|
computer name, or a group. The Riposte alert id if the user or host must
|
||||||
|
be read from the alert.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Active Directory, each with the command that produced it;
|
||||||
|
"not found" when a lookup came back empty; the exact call and error when
|
||||||
|
one failed. No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. If you were given an alert: get_incident_context (1 call) and read the
|
||||||
|
user and host from it, exactly as written. DOMAIN\jdoe becomes jdoe;
|
||||||
|
jdoe@corp.com is an e-mail or UPN: use the email input.
|
||||||
|
2. ad_get_user (1 call) with username=<sAMAccountName>, and
|
||||||
|
attributes "lastLogonTimestamp,pwdLastSet,whenCreated,lockoutTime"
|
||||||
|
(commas, no spaces).
|
||||||
|
Prefer username or email over a DN. Read userAccountControlFields for
|
||||||
|
the flags (disabled, locked out, password never expires). Report the
|
||||||
|
time attributes exactly as returned; do not convert them.
|
||||||
|
0 users: try once with email or name, then report "not found".
|
||||||
|
3. memberOf lists the groups. Name the privileged ones apart (Domain
|
||||||
|
Admins, Enterprise Admins, Administrators, and any name with "admin").
|
||||||
|
4. ad_get_computer(name=<host>) (1 call) for the host of the alert, with
|
||||||
|
attributes "operatingSystem,lastLogonTimestamp,whenCreated".
|
||||||
|
5. Optional: ad_get_group_members with group-dn copied from a memberOf
|
||||||
|
value (never typed or adapted), limit 50 (1-2 calls). Always set
|
||||||
|
limit: without it the whole group comes back.
|
||||||
|
6. Optional: ad_search for what the other commands cannot ask, with an
|
||||||
|
LDAP filter in the form the manifest shows,
|
||||||
|
(&(objectCategory=person)(objectClass=user)), size-limit 20. Only
|
||||||
|
when you are sure of the filter; otherwise skip it.
|
||||||
|
|
||||||
|
Take DNs and names only from the alert or a previous answer. On an error
|
||||||
|
("LDAP error: ..."): report the exact call and error; retry at most once.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- ad_get_user
|
||||||
|
- ad_get_computer
|
||||||
|
- ad_get_group_members
|
||||||
|
- ad_search
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
id: active_directory
|
id: active_directory
|
||||||
name: Active Directory
|
name: Active Directory
|
||||||
version: 1.1.0
|
version: 1.1.1
|
||||||
description: "Active Directory over LDAP/LDAPS — query and manage users, computers, groups and contacts (search, enable/disable/unlock, set/expire password, group membership, OU moves, create/update/delete). Runs on a remote engine inside the AD network. Requires the Python 'ldap3' library on the engine host (pip install ldap3)."
|
description: "Active Directory over LDAP/LDAPS — query and manage users, computers, groups and contacts (search, enable/disable/unlock, set/expire password, group membership, OU moves, create/update/delete). Runs on a remote engine inside the AD network. Requires the Python 'ldap3' library on the engine host (pip install ldap3)."
|
||||||
changelog: "1.1.0 — Declare the risk tier on the 18 commands that carried none: 3 safe_write, 15 destructive. An unset risk is read as destructive by the agent approval gate, so the 3 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: full directory administration over LDAP (search, get user/computer/group-members, create/update/delete user/contact/group, enable/disable/unlock, set/expire password, password-never-expire, add/remove group membership, move user/computer OU, test credentials)."
|
changelog: "1.1.0 — Declare the risk tier on the 18 commands that carried none: 3 safe_write, 15 destructive. An unset risk is read as destructive by the agent approval gate, so the 3 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: full directory administration over LDAP (search, get user/computer/group-members, create/update/delete user/contact/group, enable/disable/unlock, set/expire password, password-never-expire, add/remove group membership, move user/computer OU, test credentials)."
|
||||||
category: identity
|
category: identity
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
id: anyrun
|
id: anyrun
|
||||||
name: ANY.RUN
|
name: ANY.RUN
|
||||||
version: 1.1.0
|
version: 1.1.1
|
||||||
description: "ANY.RUN (API v1) — interactive malware sandbox: detonate files and URLs on Windows/Linux, poll the analysis report and verdict, list analysis history, read user limits and delete tasks. API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "ANY.RUN (API v1) — interactive malware sandbox: detonate files and URLs on Windows/Linux, poll the analysis report and verdict, list analysis history, read user limits and delete tasks. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none: 2 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: file/URL detonation, report and verdict retrieval, analysis history, user limits and task deletion."
|
changelog: "1.1.0 — Declare the risk tier on the 3 commands that carried none: 2 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: file/URL detonation, report and verdict retrieval, analysis history, user limits and task deletion."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
id: circl_hashlookup
|
||||||
|
name: CIRCL hashlookup
|
||||||
|
version: 1.0.0
|
||||||
|
description: "CIRCL hashlookup — tells whether a file hash belongs to known software: NSRL, Linux distribution packages, Windows updates and other trusted sets (6.5 billion hashes). The fastest way to rule out a false positive on a file alert. Free public service, no API key; the server is open source and can be self-hosted (set base_url). Stdlib-only. As an enrichment rule: command lookup_hash, score field `score`, benign threshold 0 and malicious threshold 100 — a known file reads benign, a record CIRCL flags as KnownMalicious reads malicious, and an unknown hash is an answer (not found), never a failure. Add the summary `Known file ${FileName} (${source})` (Riposte 0.299+) to see which file it is."
|
||||||
|
changelog: "1.0.0 — Initial release: single lookup (MD5, SHA-1, SHA-256), bulk lookup (MD5, SHA-1), test connection."
|
||||||
|
category: enrichment
|
||||||
|
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "hashlookup server (default https://hashlookup.circl.lu — set it to a self-hosted instance to keep hashes in-house)"
|
||||||
|
default: https://hashlookup.circl.lu
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure — a self-hosted instance with a private CA only)"
|
||||||
|
default: false
|
||||||
|
required: []
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: lookup_hash
|
||||||
|
name: hashlookup-lookup-hash
|
||||||
|
description: "Look up one MD5, SHA-1 or SHA-256 hash. Returns the CIRCL record (file name, product, source set, hashlookup:trust) plus `known: true` and `score` (0 = known file, 100 = flagged KnownMalicious). An unknown hash returns HTTP 404, which Riposte's enrichment reads as 'no record'."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
hash: { type: string, description: "MD5 (32 hex), SHA-1 (40 hex) or SHA-256 (64 hex)" }
|
||||||
|
required: [hash]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: bulk_lookup
|
||||||
|
name: hashlookup-bulk-lookup
|
||||||
|
description: "Look up many MD5 / SHA-1 hashes at once (the bulk API does not take SHA-256; those are looked up one by one). Returns `known` (records found) and `unknown` (hashes CIRCL has no record of)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
hashes: { type: string, description: "Hashes separated by commas, spaces or new lines (a JSON array also works)" }
|
||||||
|
required: [hashes]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: hashlookup-test-connection
|
||||||
|
description: "Reach the server's /info endpoint (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import json, os, re, ssl, sys, urllib.error, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://hashlookup.circl.lu"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(cfg, path, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "riposte-soar"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(_base(cfg) + path, data=data, headers=headers, method="POST" if data is not None else "GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
KINDS = {32: "md5", 40: "sha1", 64: "sha256"}
|
||||||
|
|
||||||
|
|
||||||
|
def kind_of(h):
|
||||||
|
h = h.strip().lower()
|
||||||
|
if not re.fullmatch(r"[0-9a-f]+", h) or len(h) not in KINDS:
|
||||||
|
raise Exception("not an MD5, SHA-1 or SHA-256 hash: " + h)
|
||||||
|
return KINDS[len(h)], h
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(rec):
|
||||||
|
# score: 0 for a known file, 100 when CIRCL itself flags the record as
|
||||||
|
# malicious — what an enrichment rule maps with benign 0 / malicious 100.
|
||||||
|
rec = dict(rec)
|
||||||
|
rec["known"] = True
|
||||||
|
rec["score"] = 100 if rec.get("KnownMalicious") else 0
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def parse_list(v):
|
||||||
|
if isinstance(v, list):
|
||||||
|
return [str(x) for x in v]
|
||||||
|
s = str(v or "").strip()
|
||||||
|
if s.startswith("["):
|
||||||
|
return [str(x) for x in json.loads(s)]
|
||||||
|
return [x for x in re.split(r"[\s,;]+", s) if x]
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
groups = {"md5": [], "sha1": [], "sha256": []}
|
||||||
|
for raw in parse_list(inputs.get("hashes")):
|
||||||
|
kind, h = kind_of(raw)
|
||||||
|
if h not in groups[kind]:
|
||||||
|
groups[kind].append(h)
|
||||||
|
known, unknown = [], []
|
||||||
|
for kind in ("md5", "sha1"):
|
||||||
|
hs = groups[kind]
|
||||||
|
for i in range(0, len(hs), 500):
|
||||||
|
chunk = hs[i:i + 500]
|
||||||
|
res = call(cfg, "/bulk/" + kind, {"hashes": chunk})
|
||||||
|
found = set()
|
||||||
|
for rec in res if isinstance(res, list) else []:
|
||||||
|
key = str(rec.get("MD5" if kind == "md5" else "SHA-1", "")).lower()
|
||||||
|
if key and "message" not in rec:
|
||||||
|
found.add(key)
|
||||||
|
known.append(annotate(rec))
|
||||||
|
unknown.extend(h for h in chunk if h not in found)
|
||||||
|
for h in groups["sha256"]:
|
||||||
|
try:
|
||||||
|
known.append(annotate(call(cfg, "/lookup/sha256/" + h)))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code != 404:
|
||||||
|
raise
|
||||||
|
unknown.append(h)
|
||||||
|
return {"known": known, "unknown": unknown, "known_count": len(known), "unknown_count": len(unknown)}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import json, os, re, ssl, sys, urllib.error, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://hashlookup.circl.lu"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(cfg, path, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "riposte-soar"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(_base(cfg) + path, data=data, headers=headers, method="POST" if data is not None else "GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
KINDS = {32: "md5", 40: "sha1", 64: "sha256"}
|
||||||
|
|
||||||
|
|
||||||
|
def kind_of(h):
|
||||||
|
h = h.strip().lower()
|
||||||
|
if not re.fullmatch(r"[0-9a-f]+", h) or len(h) not in KINDS:
|
||||||
|
raise Exception("not an MD5, SHA-1 or SHA-256 hash: " + h)
|
||||||
|
return KINDS[len(h)], h
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(rec):
|
||||||
|
# score: 0 for a known file, 100 when CIRCL itself flags the record as
|
||||||
|
# malicious — what an enrichment rule maps with benign 0 / malicious 100.
|
||||||
|
rec = dict(rec)
|
||||||
|
rec["known"] = True
|
||||||
|
rec["score"] = 100 if rec.get("KnownMalicious") else 0
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
kind, h = kind_of(str(inputs.get("hash", "")))
|
||||||
|
return annotate(call(cfg, "/lookup/%s/%s" % (kind, h)))
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import json, os, re, ssl, sys, urllib.error, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://hashlookup.circl.lu"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(cfg, path, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "riposte-soar"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(_base(cfg) + path, data=data, headers=headers, method="POST" if data is not None else "GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
KINDS = {32: "md5", 40: "sha1", 64: "sha256"}
|
||||||
|
|
||||||
|
|
||||||
|
def kind_of(h):
|
||||||
|
h = h.strip().lower()
|
||||||
|
if not re.fullmatch(r"[0-9a-f]+", h) or len(h) not in KINDS:
|
||||||
|
raise Exception("not an MD5, SHA-1 or SHA-256 hash: " + h)
|
||||||
|
return KINDS[len(h)], h
|
||||||
|
|
||||||
|
|
||||||
|
def annotate(rec):
|
||||||
|
# score: 0 for a known file, 100 when CIRCL itself flags the record as
|
||||||
|
# malicious — what an enrichment rule maps with benign 0 / malicious 100.
|
||||||
|
rec = dict(rec)
|
||||||
|
rec["known"] = True
|
||||||
|
rec["score"] = 100 if rec.get("KnownMalicious") else 0
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
info = call(cfg, "/info")
|
||||||
|
return {"ok": True, "info": info}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# The Cortex XDR operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design: Riposte
|
||||||
|
# refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Synapse
|
||||||
|
title: Cortex XDR operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 12
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Anything Cortex XDR can tell about an endpoint: the incident behind a Cortex
|
||||||
|
alert and every alert in it (process, command line, parent, file and
|
||||||
|
network artifacts, MITRE), the raw events of an alert, the endpoint's
|
||||||
|
details and policy, whether a file is quarantined, the risk score of a user
|
||||||
|
or host, and XQL hunting in xdr_data (same hash, host or user elsewhere).
|
||||||
|
Not for other vendors' alerts, and never for isolation, scripts or file
|
||||||
|
retrieval: it only reads.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id or the Cortex XDR incident id. For a pivot without an
|
||||||
|
alert: the hostname, a SHA-256, a user name, and a time window.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Cortex XDR, each with the command that produced it; "not found"
|
||||||
|
when a lookup came back empty; the exact query and error when one failed.
|
||||||
|
No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 12 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call). Its source_ref is the incident_id (a
|
||||||
|
short number). hosts entries read "<hostname>:<endpoint id>": the
|
||||||
|
hostname is the part before the colon.
|
||||||
|
If the raw fields are ONE alert (internal_id, alert_name), skip 2 and
|
||||||
|
call get_original_alerts(alert_id_list=<internal_id>).
|
||||||
|
2. get_incident_extra_data(incident_id, alerts_limit=50) (1 call): the
|
||||||
|
alerts (agent_hostname, actor process, command line, action_file_sha256,
|
||||||
|
user) and the file/network artifacts.
|
||||||
|
3. get_original_alerts(alert_id_list=<alert_id>) (1 call) only when an
|
||||||
|
alert's own fields do not answer; ids from step 2 only.
|
||||||
|
4. Host: get_endpoints(hostname=<name>) (1 call) — the name, not the id.
|
||||||
|
get_policy(endpoint_id) only if asked.
|
||||||
|
5. get_risk_score(id=<user or host as the data spells it>) (1 call) when a
|
||||||
|
user is involved. 404 or empty = "no risk score", not an error.
|
||||||
|
6. get_quarantine_status(endpoint_id, file_path, file_hash) needs all
|
||||||
|
three from the alert; file_hash is SHA-256.
|
||||||
|
7. ALWAYS keep 2-3 calls for "elsewhere" with xql_query. The manifest's
|
||||||
|
form is:
|
||||||
|
dataset = xdr_data | filter event_type = ENUM.PROCESS | fields agent_hostname, action_process_image_name
|
||||||
|
Change only the filter: action_file_sha256 = "<sha256>" for the hash
|
||||||
|
on other hosts, agent_hostname = "<name>" for the host. Values in
|
||||||
|
double quotes. Keep fields short, limit 50.
|
||||||
|
Window: time_frame as "<number> <unit>" (e.g. "7 days"). Empty means
|
||||||
|
the last 24 hours only, not all of time. Or from_time / to_time copied
|
||||||
|
exactly from the incident's timestamps. Never compute dates.
|
||||||
|
status PENDING is not a failure: call xql_get_query_results with the
|
||||||
|
execution_id it returned. Never rerun the query: each run costs quota.
|
||||||
|
A syntax error: report the exact query and error, one fix at most.
|
||||||
|
|
||||||
|
Ids: endpoint ids are 32 hex characters, alert ids are numbers. Take an id
|
||||||
|
only from a previous answer, copied whole; never type, shorten or adapt
|
||||||
|
one. Prefer hostnames and user names.
|
||||||
|
Time: pass timestamps exactly as the data gives them (epoch ms or ISO8601).
|
||||||
|
On an error: report the exact call and error, try one variant at most.
|
||||||
|
|
||||||
|
In your answer: the incident (name, status, severity, MITRE), each alert's
|
||||||
|
process, command line, user, file and hash, the network artifacts, the
|
||||||
|
host, and what XQL found and in which window — with each fact's command.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- get_incident_extra_data
|
||||||
|
- get_original_alerts
|
||||||
|
- get_endpoints
|
||||||
|
- get_policy
|
||||||
|
- get_risk_score
|
||||||
|
- get_quarantine_status
|
||||||
|
- xql_query
|
||||||
|
- xql_get_query_results
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
File diff suppressed because one or more lines are too long
@@ -23,11 +23,13 @@ ocsf:
|
|||||||
- { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" }
|
- { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" }
|
||||||
- { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" }
|
- { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" }
|
||||||
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
|
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
|
||||||
# ── Detection time: the MTTD anchor ───────────────────────────────
|
# ── Event time (OCSF `time`) ───────────────────────────────────────
|
||||||
# `time` is what Riposte measures detection-to-ingestion against. The grid
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
||||||
# exposes when the tenant took the alert in (source_insert_ts); a sensor-side
|
# starts when Riposte records the alert. It is the alert's own record of when
|
||||||
# detection timestamp, when the tenant sends one, is the better anchor and
|
# the source saw the event.
|
||||||
# comes last so it wins.
|
# The grid exposes when the tenant took the alert in (source_insert_ts); a
|
||||||
|
# sensor-side detection timestamp, when the tenant sends one, is closer to the
|
||||||
|
# event and comes last so it wins.
|
||||||
- { source_path: "source_insert_ts", ocsf_field: "time" }
|
- { source_path: "source_insert_ts", ocsf_field: "time" }
|
||||||
- { source_path: "detection_timestamp", ocsf_field: "time" }
|
- { source_path: "detection_timestamp", ocsf_field: "time" }
|
||||||
# ── Alert state ───────────────────────────────────────────────────
|
# ── Alert state ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ ocsf:
|
|||||||
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
||||||
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
||||||
- { source_path: "status", ocsf_field: "status" }
|
- { source_path: "status", ocsf_field: "status" }
|
||||||
# `time` is the MTTD anchor — when XDR opened the incident, as opposed to when
|
# `time` is the OCSF event time — when XDR opened the incident, as opposed to
|
||||||
# Riposte ingested it. Without it the detection delay column stays empty.
|
# when Riposte ingested it.
|
||||||
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
||||||
|
# starts when Riposte records the alert. It is the alert's own record of when
|
||||||
|
# the source saw the event.
|
||||||
- { source_path: "creation_time", ocsf_field: "time" }
|
- { source_path: "creation_time", ocsf_field: "time" }
|
||||||
- { source_path: "alert_count", ocsf_field: "count" }
|
- { source_path: "alert_count", ocsf_field: "count" }
|
||||||
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
|
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
name: "Cortex XDR Incidents (full) → OCSF"
|
name: "Cortex XDR Incidents (full) → OCSF"
|
||||||
description: "Maps one Cortex XDR incident fetched with its alerts and artifacts (incidents/get_multiple_incidents_extra_data/, results_path = reply.incidents) to OCSF finding fields. The script flattens the record, so incident fields sit at the top level — every expression written against get_incidents keeps working — while alerts[], file_artifacts[] and network_artifacts[] are plain lists beside them. Incident-level values are mapped first and the first alert's equivalents last, so the alert wins wherever the aggregate says nothing."
|
description: "Maps one Cortex XDR incident fetched with its alerts and artifacts (incidents/get_multiple_incidents_extra_data/, results_path = reply.incidents) to OCSF finding fields. The script flattens the record, so incident fields sit at the top level — every expression written against get_incidents keeps working — while alerts[], file_artifacts[] and network_artifacts[] are plain lists beside them. Incident-level values are mapped first and the first alert's equivalents last, so the alert wins wherever the aggregate says nothing."
|
||||||
field_mappings:
|
field_mappings:
|
||||||
# incident_name is null on most tenants (it is only set when someone renames
|
# The title is the rule that fired, the description the sentence XDR writes
|
||||||
# the incident), and a mapping that resolves to nothing leaves the title to
|
# about the incident. Both used to read `description`, so a ticket showed the
|
||||||
# the incident type's fallback. description is the sentence XDR itself shows.
|
# same sentence twice and its title ran to the host and user it already names.
|
||||||
title: "description"
|
# incident_name wins when someone renamed the incident in the console; the
|
||||||
description: "description"
|
# count says the rule is only the first of several detections folded in.
|
||||||
|
title: "jsonata: incident_name ? incident_name : alerts[0].name ? alerts[0].name & (alert_count > 1 ? ' (+' & $string(alert_count - 1) & ' other alerts)' : '') : description"
|
||||||
|
# XDR's sentence first (rule, sensor, host, user), then what each distinct
|
||||||
|
# rule means — the incident sentence names a rule without explaining it.
|
||||||
|
description: "jsonata: $join($distinct([description ? description, alerts[name and description].(name & ' — ' & description)]), '\\n\\n')"
|
||||||
severity: "severity"
|
severity: "severity"
|
||||||
# incident_source, not incident_sources[0]: this mapper reads dotted paths and
|
# incident_source rather than incident_sources[0], kept for the tenants whose
|
||||||
# cannot index a list, so the script lifts the first sensor out for it.
|
# mapper copy predates the jsonata: prefix; the script still lifts it out.
|
||||||
source: "incident_source"
|
source: "incident_source"
|
||||||
# results_path = reply.incidents; source_path is JSONata over ONE flattened
|
# results_path = reply.incidents; source_path is JSONata over ONE flattened
|
||||||
# incident. Paths absent from a given incident are skipped at ingestion, so
|
# incident. Paths absent from a given incident are skipped at ingestion, so
|
||||||
@@ -17,17 +21,24 @@ field_mappings:
|
|||||||
ocsf:
|
ocsf:
|
||||||
# ── Finding ───────────────────────────────────────────────────────
|
# ── Finding ───────────────────────────────────────────────────────
|
||||||
- { source_path: "incident_id", ocsf_field: "finding_info.uid" }
|
- { source_path: "incident_id", ocsf_field: "finding_info.uid" }
|
||||||
- { source_path: "incident_name ? incident_name : description", ocsf_field: "finding_info.title" }
|
- { source_path: "incident_name ? incident_name : alerts[0].name ? alerts[0].name : description", ocsf_field: "finding_info.title" }
|
||||||
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
||||||
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
|
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
|
||||||
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
||||||
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
||||||
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
|
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
|
||||||
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
|
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
|
||||||
|
# What the rule means, and its id — the handle for an exclusion or a tuning
|
||||||
|
# ticket, which the rule name alone does not give.
|
||||||
|
- { source_path: "alerts[0].description", ocsf_field: "finding_info.analytic.desc" }
|
||||||
|
- { source_path: "alerts[0].matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
|
||||||
# The detection that opened the incident, so an analyst reading the aggregate
|
# The detection that opened the incident, so an analyst reading the aggregate
|
||||||
# can still pivot to the single alert in the XDR console.
|
# can still pivot to the single alert in the XDR console.
|
||||||
- { source_path: "alerts[0].alert_id", ocsf_field: "finding_info.uid_alt" }
|
- { source_path: "alerts[0].alert_id", ocsf_field: "finding_info.uid_alt" }
|
||||||
# ── Detection time: the MTTD anchor ───────────────────────────────
|
# ── Event time (OCSF `time`) ───────────────────────────────────────
|
||||||
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
||||||
|
# starts when Riposte records the alert. It is the alert's own record of when
|
||||||
|
# the source saw the event.
|
||||||
# Weakest first, strongest last. detection_time is often null on an XDR
|
# Weakest first, strongest last. detection_time is often null on an XDR
|
||||||
# incident, and then the first alert's own detection timestamp is the honest
|
# incident, and then the first alert's own detection timestamp is the honest
|
||||||
# anchor; incident creation is the last resort.
|
# anchor; incident creation is the last resort.
|
||||||
@@ -41,6 +52,9 @@ ocsf:
|
|||||||
- { source_path: "alert_count", ocsf_field: "count" }
|
- { source_path: "alert_count", ocsf_field: "count" }
|
||||||
- { source_path: "aggregated_score", ocsf_field: "risk_score" }
|
- { source_path: "aggregated_score", ocsf_field: "risk_score" }
|
||||||
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
||||||
|
# Who already holds the incident on the XDR side, so nobody picks it up twice.
|
||||||
|
- { source_path: "assigned_user_pretty_name", ocsf_field: "assignee.name" }
|
||||||
|
- { source_path: "assigned_user_mail", ocsf_field: "assignee.email_addr" }
|
||||||
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
|
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
|
||||||
# What kind of activity the sensor saw ("Process Execution", "Network
|
# What kind of activity the sensor saw ("Process Execution", "Network
|
||||||
# Connection"…). It is what tells an analyst, before opening anything, whether
|
# Connection"…). It is what tells an analyst, before opening anything, whether
|
||||||
@@ -216,16 +230,28 @@ ocsf:
|
|||||||
- { source_path: "alerts[0].action_registry_data", ocsf_field: "reg_value.data" }
|
- { source_path: "alerts[0].action_registry_data", ocsf_field: "reg_value.data" }
|
||||||
- { source_path: "alerts[0].dns_query_name", ocsf_field: "dns_query.hostname" }
|
- { source_path: "alerts[0].dns_query_name", ocsf_field: "dns_query.hostname" }
|
||||||
- { source_path: "alerts[0].user_agent", ocsf_field: "http_request.user_agent" }
|
- { source_path: "alerts[0].user_agent", ocsf_field: "http_request.user_agent" }
|
||||||
|
# The network artifacts come first, as a fallback only: they are the
|
||||||
|
# incident's unordered set of destinations, and network_artifacts[0] used to
|
||||||
|
# come last and overwrite the alert's own flow piecemeal — an artifact's
|
||||||
|
# hostname and IP next to the alert's port, a destination the alert never
|
||||||
|
# connected to.
|
||||||
|
- { source_path: "network_artifacts[0].network_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
||||||
|
- { source_path: "network_artifacts[0].network_remote_port", ocsf_field: "dst_endpoint.port" }
|
||||||
|
- { source_path: "network_artifacts[0].network_domain", ocsf_field: "dst_endpoint.hostname" }
|
||||||
|
- { source_path: "network_artifacts[0].network_country", ocsf_field: "dst_endpoint.location.country" }
|
||||||
- { source_path: "alerts[0].action_local_ip", ocsf_field: "src_endpoint.ip" }
|
- { source_path: "alerts[0].action_local_ip", ocsf_field: "src_endpoint.ip" }
|
||||||
- { source_path: "alerts[0].action_local_port", ocsf_field: "src_endpoint.port" }
|
- { source_path: "alerts[0].action_local_port", ocsf_field: "src_endpoint.port" }
|
||||||
- { source_path: "alerts[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
- { source_path: "alerts[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
||||||
- { source_path: "alerts[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
|
- { source_path: "alerts[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
|
||||||
- { source_path: "alerts[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
|
- { source_path: "alerts[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
|
||||||
|
# The alert's action_country is UNKNOWN on most network alerts too; the
|
||||||
|
# artifact XDR built for the alert's own remote IP carries the geolocation.
|
||||||
|
- { source_path: "($ip := alerts[0].action_remote_ip; $ip ? network_artifacts[network_remote_ip = $ip][0].network_country)", ocsf_field: "dst_endpoint.location.country" }
|
||||||
# XDR writes the literal "UNKNOWN" rather than nothing when an alert has no
|
# XDR writes the literal "UNKNOWN" rather than nothing when an alert has no
|
||||||
# network dimension at all, and mapping it would put a destination country on
|
# network dimension at all, and mapping it would put a destination country on
|
||||||
# a process-execution incident that never had a destination.
|
# a process-execution incident that never had a destination.
|
||||||
- { source_path: 'alerts[0].action_country != ''UNKNOWN'' ? alerts[0].action_country', ocsf_field: "dst_endpoint.location.country" }
|
- { source_path: 'alerts[0].action_country != ''UNKNOWN'' ? alerts[0].action_country', ocsf_field: "dst_endpoint.location.country" }
|
||||||
# ── The artifact the incident is really about ─────────────────────
|
# ── The file the incident is really about ─────────────────────────
|
||||||
# Last, because a file artifact is the incident's verdict on the file, where
|
# Last, because a file artifact is the incident's verdict on the file, where
|
||||||
# the alert only reports what one detection touched.
|
# the alert only reports what one detection touched.
|
||||||
- { source_path: "file_artifacts[0].file_name", ocsf_field: "file.name" }
|
- { source_path: "file_artifacts[0].file_name", ocsf_field: "file.name" }
|
||||||
@@ -233,5 +259,3 @@ ocsf:
|
|||||||
- { source_path: "file_artifacts[0].file_signature_vendor_name", ocsf_field: "file.company_name" }
|
- { source_path: "file_artifacts[0].file_signature_vendor_name", ocsf_field: "file.company_name" }
|
||||||
- { source_path: "file_artifacts[0].file_signature_status", ocsf_field: "file.signature.value" }
|
- { source_path: "file_artifacts[0].file_signature_status", ocsf_field: "file.signature.value" }
|
||||||
- { source_path: "file_artifacts[0].file_wildfire_verdict", ocsf_field: "malware.classifications" }
|
- { source_path: "file_artifacts[0].file_wildfire_verdict", ocsf_field: "malware.classifications" }
|
||||||
- { source_path: "network_artifacts[0].network_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
|
||||||
- { source_path: "network_artifacts[0].network_domain", ocsf_field: "dst_endpoint.hostname" }
|
|
||||||
|
|||||||
@@ -135,14 +135,57 @@ def count(filters):
|
|||||||
return int(reply.get("total_count") or 0)
|
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():
|
def main():
|
||||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
filters = build_filters(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
|
# 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
|
# 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.
|
# 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))
|
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: "name", ocsf_field: "finding_info.title" }
|
||||||
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
||||||
- { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" }
|
- { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" }
|
||||||
# `time` is the MTTD anchor: the ingestion engine reads THIS field and no other
|
# `time` is the OCSF event time: when Falcon raised the alert.
|
||||||
# to stamp detected_at. Without it the incident is anchored at ingestion, so the
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
||||||
# delay between Falcon detecting and Riposte collecting — over a day on a slow
|
# starts when Riposte records the alert. It is the alert's own record of when
|
||||||
# poll — is measured as zero. finding_info.created_time below does NOT serve
|
# the source saw the event.
|
||||||
# this: it is the finding's own record of the same instant, read by nothing.
|
|
||||||
- { source_path: "created_timestamp", ocsf_field: "time" }
|
- { source_path: "created_timestamp", ocsf_field: "time" }
|
||||||
- { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" }
|
- { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" }
|
||||||
- { source_path: "scenario", ocsf_field: "finding_info.analytic.name" }
|
- { source_path: "scenario", ocsf_field: "finding_info.analytic.name" }
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: "CrowdStrike Next-Gen SIEM Detections → OCSF"
|
||||||
|
description: "Map of a Falcon Next-Gen SIEM detection (alerts/entities/alerts/v2 on products 'thirdparty' and 'ngsiem', results_path = resources) to OCSF. Read from real FortiGate IPS detections: a third-party detection carries no sensor process or file block, but names its source product and vendor, the source and target endpoints with ports and protocol, the host Falcon matched it to (enriched_entities) and the vendor's own signature id. It has no description: the incident's is built from the source product, the signature and the connection."
|
||||||
|
field_mappings:
|
||||||
|
title: "name"
|
||||||
|
# Plain path: the ingestion engine reads informational/low/medium/high/critical natively.
|
||||||
|
severity: "severity_name"
|
||||||
|
# Third-party detections carry no description of their own; say what fired,
|
||||||
|
# from which product, on which connection.
|
||||||
|
description: "jsonata: description ? description : $join(source_products, ', ') & ' — ' & name & (source_endpoint_address_ip4 ? ' — ' & source_endpoint_address_ip4 & (local_port ? ':' & local_port : '') & ' → ' & target_endpoint_address_ip4 & (remote_port ? ':' & remote_port : '') & (protocol ? ' (' & protocol & ')' : '') : '')"
|
||||||
|
# results_path = resources; source_path is JSONata over ONE alert object.
|
||||||
|
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
||||||
|
ocsf:
|
||||||
|
# ── Finding / analytic ────────────────────────────────────────────
|
||||||
|
- { source_path: "composite_id", ocsf_field: "finding_info.uid" }
|
||||||
|
- { source_path: "name", ocsf_field: "finding_info.title" }
|
||||||
|
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
||||||
|
- { source_path: "categorization", ocsf_field: "finding_info.types" }
|
||||||
|
- { source_path: "data_domains", ocsf_field: "finding_info.data_sources" }
|
||||||
|
- { source_path: "falcon_host_link", ocsf_field: "finding_info.src_url" }
|
||||||
|
- { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" }
|
||||||
|
- { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" }
|
||||||
|
# `time` is when the source product saw the event (timestamp), ahead of
|
||||||
|
# Falcon's own record of the detection (created_timestamp, ~20 s later).
|
||||||
|
- { source_path: "timestamp", ocsf_field: "time" }
|
||||||
|
# The vendor's signature id (FortiGate IPS 100012) identifies the rule;
|
||||||
|
# pattern_id is Falcon's generic third-party bucket (300000 for all of them).
|
||||||
|
- { source_path: "name", ocsf_field: "finding_info.analytic.name" }
|
||||||
|
- { source_path: "vendor_pattern_id", ocsf_field: "finding_info.analytic.uid" }
|
||||||
|
- { source_path: "confidence", ocsf_field: "confidence" }
|
||||||
|
- { source_path: "priority_value", ocsf_field: "risk_score" }
|
||||||
|
- { source_path: "status", ocsf_field: "status" }
|
||||||
|
# ── Source product — the log the detection came from ──────────────
|
||||||
|
- { source_path: "source_products[0]", ocsf_field: "metadata.product.name" }
|
||||||
|
- { source_path: "source_vendors[0]", ocsf_field: "metadata.product.vendor_name" }
|
||||||
|
- { source_path: "product", ocsf_field: "metadata.product.feature.name" }
|
||||||
|
- { source_path: "cid", ocsf_field: "cloud.account.uid" }
|
||||||
|
# ── Host Falcon matched the detection to ──────────────────────────
|
||||||
|
- { source_path: "agent_id", ocsf_field: "device.uid" }
|
||||||
|
- { source_path: "enriched_entities.hostnames[0]", ocsf_field: "device.hostname" }
|
||||||
|
# ── Connection ────────────────────────────────────────────────────
|
||||||
|
- { source_path: "source_endpoint_address_ip4 ? source_endpoint_address_ip4 : source_ips[0]", ocsf_field: "src_endpoint.ip" }
|
||||||
|
- { source_path: "source_hosts[0]", ocsf_field: "src_endpoint.hostname" }
|
||||||
|
- { source_path: "$number(local_port)", ocsf_field: "src_endpoint.port" }
|
||||||
|
- { source_path: "target_endpoint_address_ip4 ? target_endpoint_address_ip4 : destination_ips[0]", ocsf_field: "dst_endpoint.ip" }
|
||||||
|
- { source_path: "$number(remote_port)", ocsf_field: "dst_endpoint.port" }
|
||||||
|
- { source_path: "protocol", ocsf_field: "connection_info.protocol_name" }
|
||||||
|
- { source_path: "users[0]", ocsf_field: "actor.user.name" }
|
||||||
|
# ── MITRE ATT&CK (correlation rules that declare it) ──────────────
|
||||||
|
- { source_path: "tactic", ocsf_field: "attacks.tactic.name" }
|
||||||
|
- { source_path: "tactic_id", ocsf_field: "attacks.tactic.uid" }
|
||||||
|
- { source_path: "technique", ocsf_field: "attacks.technique.name" }
|
||||||
|
- { source_path: "technique_id", ocsf_field: "attacks.technique.uid" }
|
||||||
@@ -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", "{}"))
|
SECRETS = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
INPUTS = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
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):
|
def literal(v):
|
||||||
# FQL quotes strings with single quotes, so a value carrying one would end
|
# FQL quotes strings with single quotes, so a value carrying one would end
|
||||||
# the literal early and change what the query means.
|
# the literal early and change what the query means.
|
||||||
@@ -82,7 +120,7 @@ def build_fql(inputs):
|
|||||||
for name, prop, operator in RANGE_FILTERS:
|
for name, prop, operator in RANGE_FILTERS:
|
||||||
value = inputs.get(name)
|
value = inputs.get(name)
|
||||||
if value:
|
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
|
# Escape hatch for any property this command does not name explicitly: raw
|
||||||
# FQL, ANDed with the rest.
|
# FQL, ANDed with the rest.
|
||||||
raw = str(inputs.get("filter") or "").strip()
|
raw = str(inputs.get("filter") or "").strip()
|
||||||
@@ -110,9 +148,59 @@ def count(tok, fql):
|
|||||||
return int(total)
|
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():
|
def main():
|
||||||
tok = token()
|
tok = token()
|
||||||
fql = build_fql(INPUTS)
|
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}
|
result = {"count": count(tok, fql), "filter": fql}
|
||||||
# The estate total is the denominator the filtered count is read against: it
|
# 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
|
# turns a bare number into a proportion, and makes a filter that matched the
|
||||||
|
|||||||
@@ -31,17 +31,54 @@ def csv(v):
|
|||||||
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
VERDICTS = ("true_positive", "false_positive", "ignored")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
tok = token()
|
tok = token()
|
||||||
|
ids = csv(INPUTS.get("ids"))
|
||||||
|
verdict = str(INPUTS.get("verdict") or "").strip()
|
||||||
|
if verdict and verdict not in VERDICTS:
|
||||||
|
raise ValueError("verdict must be one of " + ", ".join(VERDICTS) + ", got " + verdict)
|
||||||
action_params = []
|
action_params = []
|
||||||
|
if verdict:
|
||||||
|
# The console's Resolution is read from these tags, and add_tag never
|
||||||
|
# replaces one: drop the other two first, or a false positive turned
|
||||||
|
# true positive would show both. Removing a tag the alert does not
|
||||||
|
# carry is not an error worth stopping for — the add below is the call
|
||||||
|
# that has to land.
|
||||||
|
for other in VERDICTS:
|
||||||
|
if other == verdict:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
call("PATCH", "/alerts/entities/alerts/v3", tok,
|
||||||
|
body={"action_parameters": [{"name": "remove_tag", "value": other}], "composite_ids": ids})
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
pass
|
||||||
|
action_params.append({"name": "add_tag", "value": verdict})
|
||||||
if INPUTS.get("status"):
|
if INPUTS.get("status"):
|
||||||
action_params.append({"name": "update_status", "value": str(INPUTS["status"])})
|
action_params.append({"name": "update_status", "value": str(INPUTS["status"])})
|
||||||
if INPUTS.get("comment"):
|
if INPUTS.get("comment"):
|
||||||
action_params.append({"name": "append_comment", "value": str(INPUTS["comment"])})
|
action_params.append({"name": "append_comment", "value": str(INPUTS["comment"])})
|
||||||
if INPUTS.get("assign_to_uuid"):
|
if INPUTS.get("assign_to_uuid"):
|
||||||
action_params.append({"name": "assign_to_uuid", "value": str(INPUTS["assign_to_uuid"])})
|
action_params.append({"name": "assign_to_uuid", "value": str(INPUTS["assign_to_uuid"])})
|
||||||
body = {"action_parameters": action_params, "composite_ids": csv(INPUTS.get("ids"))}
|
if not action_params:
|
||||||
|
raise ValueError("nothing to change: give a status, a verdict, a comment or an assignee")
|
||||||
|
body = {"action_parameters": action_params, "composite_ids": ids}
|
||||||
result = call("PATCH", "/alerts/entities/alerts/v3", tok, body=body)
|
result = call("PATCH", "/alerts/entities/alerts/v3", tok, body=body)
|
||||||
|
# Falcon answers 200 to an update it refused: the reasons sit in errors and
|
||||||
|
# meta.writes.resources_affected stays at 0. Passing that on as a success
|
||||||
|
# left an incident closed in Riposte and still open in the console.
|
||||||
|
affected = ((result.get("meta") or {}).get("writes") or {}).get("resources_affected")
|
||||||
|
if result.get("errors") or affected == 0:
|
||||||
|
print(json.dumps({
|
||||||
|
"error": "Falcon updated no alert" if affected == 0 else "Falcon refused part of the update",
|
||||||
|
"resources_affected": affected,
|
||||||
|
"errors": result.get("errors") or [],
|
||||||
|
# The PATCH takes composite ids (<cid>:<id>), not the alert's bare id.
|
||||||
|
"composite_ids": ids,
|
||||||
|
}))
|
||||||
|
sys.exit(1)
|
||||||
print(json.dumps(result))
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ def main():
|
|||||||
result = call("POST", "/alerts/entities/alerts/v2", tok, body={"composite_ids": ids})
|
result = call("POST", "/alerts/entities/alerts/v2", tok, body={"composite_ids": ids})
|
||||||
else:
|
else:
|
||||||
result = {"resources": []}
|
result = {"resources": []}
|
||||||
|
# An empty page says nothing on its own: keep what the query answered — the
|
||||||
|
# FQL actually sent, Falcon's total for it and any errors — so a run with no
|
||||||
|
# alerts reads as "matched nothing" rather than as a silent failure.
|
||||||
|
result["query"] = {
|
||||||
|
"filter": fql,
|
||||||
|
"total": ((found.get("meta") or {}).get("pagination") or {}).get("total"),
|
||||||
|
"errors": found.get("errors") or [],
|
||||||
|
}
|
||||||
print(json.dumps(result))
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
SECRETS = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
INPUTS = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
BASE = SECRETS.get("base_url", "https://api.crowdstrike.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
data = urllib.parse.urlencode({"client_id": SECRETS.get("client_id", ""), "client_secret": SECRETS.get("client_secret", "")}).encode()
|
||||||
|
req = urllib.request.Request(BASE + "/oauth2/token", data=data, headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read()).get("access_token", "")
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, tok, params=None, body=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
# params is a list of (key, value) tuples to allow repeated keys (e.g. ids)
|
||||||
|
url += "?" + urllib.parse.urlencode(params)
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + tok, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tok = token()
|
||||||
|
# Next-Gen SIEM files its detections in the same unified Alerts API as the
|
||||||
|
# EDR, under two products: 'thirdparty' for the alerts it raises from ingested
|
||||||
|
# third-party logs (a FortiGate IPS hit, say) and 'ngsiem' for those of the
|
||||||
|
# tenant's own correlation rules. The product clause is always kept, so a
|
||||||
|
# filter narrows these detections and never widens past them.
|
||||||
|
products = [p.strip().strip("'\"") for p in str(INPUTS.get("products") or "thirdparty,ngsiem").split(",") if p.strip()]
|
||||||
|
# ',' is OR in FQL; parenthesised so the group binds before the '+' ANDs.
|
||||||
|
fql = "(" + ",".join("product:'" + p.replace("'", "\\'") + "'" for p in products) + ")"
|
||||||
|
extra = str(INPUTS.get("filter") or "").strip()
|
||||||
|
if extra:
|
||||||
|
fql += "+(" + extra + ")"
|
||||||
|
# The alert rule's watermark lands in created_from, folded into the FQL as
|
||||||
|
# created_timestamp (see search_detections for why it is not the filter input).
|
||||||
|
since = str(INPUTS.get("created_from") or "").strip().strip("'\"")
|
||||||
|
if since:
|
||||||
|
fql += "+created_timestamp:>'" + since + "'"
|
||||||
|
limit = INPUTS.get("limit", 50)
|
||||||
|
params = [("filter", fql), ("limit", limit), ("sort", "created_timestamp.desc")]
|
||||||
|
found = call("GET", "/alerts/queries/alerts/v2", tok, params=params)
|
||||||
|
ids = found.get("resources", []) or []
|
||||||
|
if ids:
|
||||||
|
result = call("POST", "/alerts/entities/alerts/v2", tok, body={"composite_ids": ids})
|
||||||
|
else:
|
||||||
|
result = {"resources": []}
|
||||||
|
# An empty page says nothing on its own: keep what the query answered — the
|
||||||
|
# FQL actually sent, Falcon's total for it and any errors — so a run with no
|
||||||
|
# alerts reads as "matched nothing" rather than as a silent failure.
|
||||||
|
result["query"] = {
|
||||||
|
"filter": fql,
|
||||||
|
"total": ((found.get("meta") or {}).get("pagination") or {}).get("total"),
|
||||||
|
"errors": found.get("errors") or [],
|
||||||
|
}
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
id: crtsh
|
||||||
|
name: crt.sh (Certificate Transparency)
|
||||||
|
version: 1.1.0
|
||||||
|
description: "crt.sh by Sectigo — searches the public Certificate Transparency logs, where every publicly trusted TLS certificate is recorded. Lists a domain's sub-domains and who issued their certificates, shows when a host first got a certificate (a phishing site usually gets one the day it goes live), and finds look-alike domains that start with a client's brand. Free, no API key; stdlib-only. crt.sh is a shared public service: it is slow on big domains and a starts_with search on a short word can time out — keep searches narrow. When crt.sh is down (it often answers 502), sub-domain and exact searches fall back to SSLMate Cert Spotter, which reads the same logs (unexpired certificates only; set a free API key to lift its 10-requests-an-hour limit). As a context enrichment rule (Riposte 0.299+, no score field) on domains: search_certificates with summary `${name_count} names, newest certificate ${certificates.0.not_before}`."
|
||||||
|
changelog: "1.1.0 — Cert Spotter fallback: when crt.sh is down (502/503/504 or no answer), sub-domain and exact searches are answered by SSLMate Cert Spotter, from the same CT logs; the output says which (`source`) and whether it may be partial (`complete`). Every call now fits Riposte's 60 s script limit (crt.sh used to be retried for minutes and killed before it answered). 1.0.0 — Initial release: certificate search (sub-domains, exact name, starts with), test connection."
|
||||||
|
category: enrichment
|
||||||
|
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "crt.sh server (default https://crt.sh)"
|
||||||
|
default: https://crt.sh
|
||||||
|
certspotter_fallback:
|
||||||
|
type: boolean
|
||||||
|
description: "When crt.sh is down, answer sub-domain and exact searches from SSLMate Cert Spotter (same CT logs, unexpired certificates only; no starts_with)"
|
||||||
|
default: true
|
||||||
|
certspotter_api_key:
|
||||||
|
type: string
|
||||||
|
description: "Cert Spotter API key (optional, free account at sslmate.com). Without one Cert Spotter allows about 10 requests an hour and the fallback reads at most 3 pages; with one, 10 pages"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
certspotter_url:
|
||||||
|
type: string
|
||||||
|
description: "Cert Spotter API (default https://api.certspotter.com)"
|
||||||
|
default: https://api.certspotter.com
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure)"
|
||||||
|
default: false
|
||||||
|
required: []
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: search_certificates
|
||||||
|
name: crtsh-search-certificates
|
||||||
|
description: "Certificates logged for a domain. Returns `names` (each DNS name seen, with the first and last time a certificate covered it, newest first, at most 1000 — `name_count` counts them all), `issuers` (certificate count per CA) and `certificates` (the most recent ones, with a crt.sh link), plus `source` (crt.sh, or certspotter when crt.sh was down and the fallback answered) and `complete` (false when the answer may miss certificates — Cert Spotter page cap, or crt.sh too slow for the apex query). mode: subdomains (default — the domain and every name under it), exact (that name only), starts_with (every logged name beginning with the text, any TLD: look-alikes of a brand such as brand-login.com or brand.co; crt.sh no longer searches for text in the middle of a name, so my-brand.com is not found)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
domain: { type: string, description: "Domain, or the brand to look for with mode=starts_with" }
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
description: "subdomains | exact | starts_with (default subdomains)"
|
||||||
|
enum: [subdomains, exact, starts_with]
|
||||||
|
exclude_expired: { type: boolean, description: "Ignore expired certificates (default true)" }
|
||||||
|
since_days: { type: number, description: "Only certificates issued in the last N days (0 = no limit, default 0)" }
|
||||||
|
limit: { type: number, description: "How many certificates to return in `certificates` (default 50, max 500); `names` and `issuers` always count them all" }
|
||||||
|
required: [domain]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: crtsh-test-connection
|
||||||
|
description: "Run a tiny search against crt.sh (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import datetime, json, os, ssl, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://crt.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CERTSPOTTER = "https://api.certspotter.com"
|
||||||
|
|
||||||
|
# Riposte stops an integration script after 60 s, whatever it is doing. Every
|
||||||
|
# network call below takes its timeout from what is left of this budget, so the
|
||||||
|
# script always gets to print an answer (or a clear error) before it is killed.
|
||||||
|
BUDGET = 55.0
|
||||||
|
# Kept back from crt.sh so the Cert Spotter fallback still has time to run.
|
||||||
|
FALLBACK_RESERVE = 15.0
|
||||||
|
_start = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _left():
|
||||||
|
return BUDGET - (time.monotonic() - _start)
|
||||||
|
|
||||||
|
|
||||||
|
class Unavailable(Exception):
|
||||||
|
"""crt.sh did not answer in time or kept answering 5xx."""
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_on(cfg):
|
||||||
|
return cfg.get("certspotter_fallback") not in (False, "false", "False", 0, "0")
|
||||||
|
|
||||||
|
|
||||||
|
def search(cfg, q, exclude_expired):
|
||||||
|
base = (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
params = {"q": q, "output": "json"}
|
||||||
|
if exclude_expired:
|
||||||
|
params["exclude"] = "expired"
|
||||||
|
req = urllib.request.Request(base + "/?" + urllib.parse.urlencode(params),
|
||||||
|
headers={"Accept": "application/json", "User-Agent": "riposte-soar"})
|
||||||
|
reserve = FALLBACK_RESERVE if _fallback_on(cfg) else 0.0
|
||||||
|
# crt.sh's database is shared by the whole internet and its front answers
|
||||||
|
# 502/503/504 when a query queues too long; the same query often goes
|
||||||
|
# through a few seconds later, so those are retried, three tries at most.
|
||||||
|
# A timeout or a refused connection is not: it has already cost its time,
|
||||||
|
# and the fallback is the better use of what is left.
|
||||||
|
for attempt in range(3):
|
||||||
|
timeout = min(45.0, _left() - reserve)
|
||||||
|
if timeout < 5:
|
||||||
|
raise Unavailable("crt.sh unavailable (out of time)")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw.strip() else []
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code not in (502, 503, 504):
|
||||||
|
raise
|
||||||
|
if attempt == 2:
|
||||||
|
raise Unavailable("crt.sh unavailable (HTTP " + str(e.code) + ")")
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||||
|
raise Unavailable("crt.sh unavailable (" + ("timeout" if "timed out" in str(e) else str(getattr(e, "reason", e))) + ")")
|
||||||
|
time.sleep(min(3.0 * (attempt + 1), max(0.0, _left() - reserve - 5)))
|
||||||
|
|
||||||
|
|
||||||
|
def certspotter(cfg, domain, include_subdomains):
|
||||||
|
"""The same Certificate Transparency logs, read through SSLMate's Cert Spotter.
|
||||||
|
|
||||||
|
Its rows are reshaped into crt.sh's, so everything after the search is shared.
|
||||||
|
Cert Spotter only lists certificates that have not expired, and without an
|
||||||
|
API key allows about 10 requests an hour, so the pages read are capped.
|
||||||
|
"""
|
||||||
|
key = str(cfg.get("certspotter_api_key") or "").strip()
|
||||||
|
base = (str(cfg.get("certspotter_url") or "").strip() or DEFAULT_CERTSPOTTER).rstrip("/")
|
||||||
|
params = [("domain", domain), ("expand", "dns_names"), ("expand", "issuer")]
|
||||||
|
if include_subdomains:
|
||||||
|
params.append(("include_subdomains", "true"))
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "riposte-soar"}
|
||||||
|
if key:
|
||||||
|
headers["Authorization"] = "Bearer " + key
|
||||||
|
max_pages = 10 if key else 3
|
||||||
|
rows, after, complete = [], None, False
|
||||||
|
for _ in range(max_pages):
|
||||||
|
timeout = _left() - 2
|
||||||
|
if timeout < 3:
|
||||||
|
break
|
||||||
|
qs = params + ([("after", after)] if after else [])
|
||||||
|
req = urllib.request.Request(base + "/v1/issuances?" + urllib.parse.urlencode(qs), headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_ctx(cfg)) as r:
|
||||||
|
page = json.loads(r.read() or b"[]")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 429 and not rows:
|
||||||
|
raise Exception("crt.sh unavailable and Cert Spotter quota reached"
|
||||||
|
+ ("" if key else " (about 10 requests an hour without an API key — set certspotter_api_key)"))
|
||||||
|
if rows:
|
||||||
|
break
|
||||||
|
raise
|
||||||
|
for c in page:
|
||||||
|
rows.append({
|
||||||
|
"id": "cs-" + str(c.get("id")),
|
||||||
|
"common_name": (c.get("dns_names") or [""])[0],
|
||||||
|
"name_value": "\n".join(c.get("dns_names") or []),
|
||||||
|
"issuer_name": (c.get("issuer") or {}).get("name", ""),
|
||||||
|
# crt.sh dates carry no zone; keep one format for sorting and comparing.
|
||||||
|
"not_before": str(c.get("not_before", "")).rstrip("Z"),
|
||||||
|
"not_after": str(c.get("not_after", "")).rstrip("Z"),
|
||||||
|
"serial_number": "",
|
||||||
|
"cert_sha256": c.get("cert_sha256", ""),
|
||||||
|
})
|
||||||
|
if not page:
|
||||||
|
complete = True
|
||||||
|
break
|
||||||
|
after = page[-1].get("id")
|
||||||
|
return rows, complete
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")[:2000]}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def truthy(v, default):
|
||||||
|
if v is None or v == "":
|
||||||
|
return default
|
||||||
|
return v not in (False, "false", "False", 0, "0")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
domain = str(inputs.get("domain", "")).strip().lower().rstrip(".")
|
||||||
|
if not domain:
|
||||||
|
raise Exception("domain is required")
|
||||||
|
mode = str(inputs.get("mode") or "subdomains").strip().lower()
|
||||||
|
# crt.sh no longer runs a search that starts with a wildcard (%brand% comes
|
||||||
|
# back empty), so a look-alike hunt is a prefix search: brand% finds
|
||||||
|
# brand.co, brand-login.com and brandpay.ch, not my-brand.com.
|
||||||
|
q = {"subdomains": "%." + domain, "exact": domain, "starts_with": domain + "%"}.get(mode)
|
||||||
|
if q is None:
|
||||||
|
raise Exception("mode must be subdomains, exact or starts_with")
|
||||||
|
exclude_expired = truthy(inputs.get("exclude_expired"), True)
|
||||||
|
source, complete = "crt.sh", True
|
||||||
|
try:
|
||||||
|
rows = search(cfg, q, exclude_expired)
|
||||||
|
if mode == "subdomains" and _left() > 10:
|
||||||
|
# %.domain leaves the apex out; it is part of what "the domain" means.
|
||||||
|
# Skipped rather than risked when crt.sh was slow enough to eat the budget.
|
||||||
|
ids = {x.get("id") for x in rows}
|
||||||
|
try:
|
||||||
|
rows += [r for r in search(cfg, domain, exclude_expired) if r.get("id") not in ids]
|
||||||
|
except Unavailable:
|
||||||
|
complete = False
|
||||||
|
except Unavailable:
|
||||||
|
# Cert Spotter cannot search by prefix, so a look-alike hunt has no fallback.
|
||||||
|
if not _fallback_on(cfg):
|
||||||
|
raise
|
||||||
|
if mode == "starts_with":
|
||||||
|
raise Exception(str(sys.exc_info()[1]) + "; no fallback for starts_with — Cert Spotter cannot search by prefix")
|
||||||
|
rows, complete = certspotter(cfg, domain, mode == "subdomains")
|
||||||
|
source = "certspotter"
|
||||||
|
|
||||||
|
since = int(float(inputs.get("since_days") or 0))
|
||||||
|
if since > 0:
|
||||||
|
floor = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=since)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
rows = [r for r in rows if str(r.get("not_before", "")) >= floor]
|
||||||
|
|
||||||
|
# crt.sh lists a certificate once per log entry (precertificate and final):
|
||||||
|
# the same id twice, which would double every count below.
|
||||||
|
seen, certs = set(), []
|
||||||
|
for r in rows:
|
||||||
|
if r.get("id") in seen:
|
||||||
|
continue
|
||||||
|
seen.add(r.get("id"))
|
||||||
|
certs.append(r)
|
||||||
|
certs.sort(key=lambda r: str(r.get("not_before", "")), reverse=True)
|
||||||
|
|
||||||
|
names, issuers = {}, {}
|
||||||
|
for r in certs:
|
||||||
|
nb, na = str(r.get("not_before", "")), str(r.get("not_after", ""))
|
||||||
|
for n in str(r.get("name_value", "")).lower().split("\n"):
|
||||||
|
n = n.strip()
|
||||||
|
if not n:
|
||||||
|
continue
|
||||||
|
e = names.setdefault(n, {"name": n, "first_seen": nb, "last_seen": nb, "valid_until": na, "certificates": 0})
|
||||||
|
e["certificates"] += 1
|
||||||
|
e["first_seen"] = min(e["first_seen"], nb)
|
||||||
|
e["last_seen"] = max(e["last_seen"], nb)
|
||||||
|
e["valid_until"] = max(e["valid_until"], na)
|
||||||
|
issuer = str(r.get("issuer_name", ""))
|
||||||
|
cn = [p.split("=", 1)[1] for p in issuer.split(", ") if p.startswith("CN=")]
|
||||||
|
o = [p.split("=", 1)[1] for p in issuer.split(", ") if p.startswith("O=")]
|
||||||
|
label = " / ".join(x for x in (o[:1] + cn[:1]) if x) or issuer
|
||||||
|
issuers[label] = issuers.get(label, 0) + 1
|
||||||
|
|
||||||
|
limit = max(1, min(500, int(float(inputs.get("limit") or 50))))
|
||||||
|
base = (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
def link(r):
|
||||||
|
if source == "certspotter":
|
||||||
|
return base + "/?q=" + str(r.get("cert_sha256", ""))
|
||||||
|
return base + "/?id=" + str(r.get("id"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": q,
|
||||||
|
# Which service answered: crt.sh, or Cert Spotter when crt.sh was down.
|
||||||
|
"source": source,
|
||||||
|
# False when the answer may miss certificates: Cert Spotter's page cap was
|
||||||
|
# hit, or crt.sh gave up on the apex query. Cert Spotter never lists
|
||||||
|
# expired certificates, whatever exclude_expired says.
|
||||||
|
"complete": complete,
|
||||||
|
"certificate_count": len(certs),
|
||||||
|
"name_count": len(names),
|
||||||
|
# Newest first and capped: a big domain logs thousands of names, and the
|
||||||
|
# recent ones are what an analyst is looking for. name_count counts all.
|
||||||
|
"names": sorted(names.values(), key=lambda e: e["first_seen"], reverse=True)[:1000],
|
||||||
|
"issuers": [{"issuer": k, "certificates": v} for k, v in sorted(issuers.items(), key=lambda kv: -kv[1])],
|
||||||
|
"certificates": [{
|
||||||
|
"id": r.get("id"),
|
||||||
|
"common_name": r.get("common_name", ""),
|
||||||
|
"names": [n for n in str(r.get("name_value", "")).lower().split("\n") if n],
|
||||||
|
"issuer": r.get("issuer_name", ""),
|
||||||
|
"not_before": r.get("not_before", ""),
|
||||||
|
"not_after": r.get("not_after", ""),
|
||||||
|
"serial_number": r.get("serial_number", ""),
|
||||||
|
"url": link(r),
|
||||||
|
} for r in certs[:limit]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import datetime, json, os, ssl, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE = "https://crt.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(cfg):
|
||||||
|
if cfg.get("insecure") in (True, "true", "True", 1, "1"):
|
||||||
|
c = ssl.create_default_context()
|
||||||
|
c.check_hostname = False
|
||||||
|
c.verify_mode = ssl.CERT_NONE
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url, headers, cfg, timeout):
|
||||||
|
req = urllib.request.Request(url, headers=dict({"Accept": "application/json", "User-Agent": "riposte-soar"}, **headers))
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=_ctx(cfg)) as r:
|
||||||
|
return json.loads(r.read() or b"[]")
|
||||||
|
|
||||||
|
|
||||||
|
def _crtsh(cfg):
|
||||||
|
base = (str(cfg.get("base_url") or "").strip() or DEFAULT_BASE).rstrip("/")
|
||||||
|
# Riposte stops the script at 60 s: two tries at most, Cert Spotter keeps the rest.
|
||||||
|
last = ""
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
return len(_get(base + "/?" + urllib.parse.urlencode({"q": "crt.sh", "output": "json", "exclude": "expired"}), {}, cfg, 20)), ""
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last = "HTTP " + str(e.code)
|
||||||
|
if e.code not in (502, 503, 504):
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last = str(getattr(e, "reason", e))
|
||||||
|
if attempt == 0:
|
||||||
|
time.sleep(3)
|
||||||
|
return None, last
|
||||||
|
|
||||||
|
|
||||||
|
def _certspotter(cfg):
|
||||||
|
key = str(cfg.get("certspotter_api_key") or "").strip()
|
||||||
|
base = (str(cfg.get("certspotter_url") or "").strip() or "https://api.certspotter.com").rstrip("/")
|
||||||
|
try:
|
||||||
|
_get(base + "/v1/issuances?domain=crt.sh", {"Authorization": "Bearer " + key} if key else {}, cfg, 15)
|
||||||
|
return True, ""
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return False, "HTTP " + str(e.code) + (" (quota reached)" if e.code == 429 else "")
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(getattr(e, "reason", e))
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")[:2000]}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
count, crt_err = _crtsh(cfg)
|
||||||
|
if count is not None:
|
||||||
|
return {"ok": True, "crtsh": "ok", "certificates": count}
|
||||||
|
if cfg.get("certspotter_fallback") in (False, "false", "False", 0, "0"):
|
||||||
|
raise Exception("crt.sh unavailable (" + crt_err + ") and the Cert Spotter fallback is off")
|
||||||
|
ok, cs_err = _certspotter(cfg)
|
||||||
|
if not ok:
|
||||||
|
raise Exception("crt.sh unavailable (" + crt_err + ") and Cert Spotter unreachable (" + cs_err + ")")
|
||||||
|
# crt.sh being down is common and the fallback answers: the instance works.
|
||||||
|
return {"ok": True, "crtsh": "unavailable (" + crt_err + ")", "certspotter": "ok",
|
||||||
|
"note": "crt.sh is down right now; searches will be answered by Cert Spotter"}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
id: cyberark_pas
|
id: cyberark_pas
|
||||||
name: CyberArk PAS
|
name: CyberArk PAS
|
||||||
version: 1.1.0
|
version: 1.1.1
|
||||||
description: "CyberArk Privileged Access Security (PVWA REST API) — PAM containment and IR: list and read privileged accounts, rotate/verify/reconcile credentials (CPM), retrieve a password for incident response, add/delete accounts, and list safes and vault users. Session (logon) authentication; stdlib-only, no extra Python dependencies."
|
description: "CyberArk Privileged Access Security (PVWA REST API) — PAM containment and IR: list and read privileged accounts, rotate/verify/reconcile credentials (CPM), retrieve a password for incident response, add/delete accounts, and list safes and vault users. Session (logon) authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.1.0 — Declare the risk tier on the 6 commands that carried none: 2 safe_write, 4 destructive. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list/get accounts, change/verify/reconcile credentials, retrieve password, add/delete account, list safes, list users."
|
changelog: "1.1.0 — Declare the risk tier on the 6 commands that carried none: 2 safe_write, 4 destructive. An unset risk is read as destructive by the agent approval gate, so the 2 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: list/get accounts, change/verify/reconcile credentials, retrieve password, add/delete account, list safes, list users."
|
||||||
category: identity
|
category: identity
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# The Darktrace operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design: Riposte
|
||||||
|
# refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Phantom
|
||||||
|
title: Darktrace operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 12
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Anything Darktrace can answer about a device on the network: the model
|
||||||
|
breach behind an alert (model, score, time, triggering components), the
|
||||||
|
device it concerns (hostname, IP, MAC, type), the other model breaches
|
||||||
|
that device raised, and the Antigena (autonomous response) actions
|
||||||
|
applied to it. Not for acknowledging breaches or activating Antigena, not
|
||||||
|
for raw packet or proxy logs, not for other vendors' alerts.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id or the Darktrace model breach id (PBID). Otherwise
|
||||||
|
the device IP (or Darktrace device id) and the time of the activity.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Darktrace, each with the command that produced it: the breach,
|
||||||
|
the device, its other breaches and Antigena actions; "not found" when a
|
||||||
|
lookup came back empty; the exact call and error when one failed. No
|
||||||
|
verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 12 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call): the Riposte alert. Its source_ref and
|
||||||
|
raw fields carry the PBID, the device (did, IP, hostname) and the time.
|
||||||
|
Take every id from there, exactly as written; never type or adapt one.
|
||||||
|
2. get_model_breach(pbid) (1 call): model name, score, time, triggered
|
||||||
|
components, device.
|
||||||
|
3. get_devices (1-2 calls) with ip = the device IP, or did = the device id
|
||||||
|
from step 2. Prefer the IP from the data over a copied id.
|
||||||
|
4. Other breaches of the same device (at most 3 calls). get_model_breaches
|
||||||
|
has NO device filter: it lists every breach since from_time, count 50
|
||||||
|
by default. from_time is "YYYY-MM-DD HH:MM:SS": take the date and time
|
||||||
|
of the alert's first seen exactly as the data gives them, replace the
|
||||||
|
"T" with a space and drop the zone and fractions; do not compute other
|
||||||
|
dates. Raise count to 200 when the first answer is full. Keep only the
|
||||||
|
breaches whose device did matches, and list them (model, score, time).
|
||||||
|
min_score (0-1) narrows a noisy list.
|
||||||
|
5. ALWAYS call get_antigena_actions once (include_cleared true): keep the
|
||||||
|
actions on that device and say whether they are active or cleared.
|
||||||
|
|
||||||
|
Times in Darktrace answers are often epoch milliseconds: report them as
|
||||||
|
given, do not convert them yourself.
|
||||||
|
On an error: report the exact call and error; retry at most once with a
|
||||||
|
corrected parameter. An empty result is "not found", not an error.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- get_model_breach
|
||||||
|
- get_model_breaches
|
||||||
|
- get_devices
|
||||||
|
- get_antigena_actions
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
id: darktrace
|
id: darktrace
|
||||||
name: Darktrace
|
name: Darktrace
|
||||||
version: 1.1.0
|
version: 1.1.1
|
||||||
description: "Darktrace (Threat Visualizer API) — network detection and autonomous response: read model breaches, acknowledge a breach, look up devices, and read/activate Antigena network actions (containment). HMAC-SHA1 signed authentication; stdlib-only, no extra Python dependencies."
|
description: "Darktrace (Threat Visualizer API) — network detection and autonomous response: read model breaches, acknowledge a breach, look up devices, and read/activate Antigena network actions (containment). HMAC-SHA1 signed authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 1 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: get/acknowledge model breaches, get devices, get/activate Antigena actions."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 1 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: get/acknowledge model breaches, get devices, get/activate Antigena actions."
|
||||||
category: ndr
|
category: ndr
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# The Elasticsearch operator. Installed with the integration by Riposte
|
||||||
|
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
|
||||||
|
# Riposte refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Grep
|
||||||
|
title: Elasticsearch operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
What the logs stored in Elasticsearch show for a user, host, IP or other
|
||||||
|
value: which indices exist, how many documents match, the matching
|
||||||
|
documents, and one document read in full. Not for other vendors' alerts,
|
||||||
|
and not for changing anything in Elasticsearch.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The value to look for (user, host, IP, hash, domain), and if known the
|
||||||
|
index or index pattern (e.g. logs-*) and a time window. A ready query
|
||||||
|
from the lead if there is one.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from Elasticsearch, each with the index, query and size that
|
||||||
|
produced it; "not found" when 0 documents matched; the exact query and
|
||||||
|
error when one failed. No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call) for the values to look for (user, host,
|
||||||
|
IP, hash) and the alert time. Take them exactly as written.
|
||||||
|
2. list_indices (1 call) unless the lead named the index. Pick an index
|
||||||
|
name or a pattern ending in * from that answer (e.g. logs-*); never
|
||||||
|
invent one.
|
||||||
|
3. count(index, query_string) first (1 call per value): it tells you
|
||||||
|
whether a search is worth it. query_string = the bare value, nothing
|
||||||
|
else (no field names, no boolean operators) unless the lead gave the
|
||||||
|
query. A value holding : / or a space goes in double quotes, e.g.
|
||||||
|
"fe80::1". For DOMAIN\user, search the user part alone.
|
||||||
|
4. search(index, query_string, size=20) (1-3 calls) where count > 0.
|
||||||
|
Never above size 50.
|
||||||
|
5. Optional: get_document with the _index and _id of a hit, both copied
|
||||||
|
from the search answer. It needs the concrete _index of the hit, not a
|
||||||
|
pattern with *.
|
||||||
|
6. Reserve one count + search for the same user, host or IP elsewhere:
|
||||||
|
the other value from the alert, or a wider pattern (e.g. *).
|
||||||
|
|
||||||
|
Time: these commands have no time parameter. If the lead gave a query_json
|
||||||
|
with a time range, pass it exactly as given. Otherwise say in your answer
|
||||||
|
that the search was not time-bounded, prefer an index whose name carries
|
||||||
|
the alert's date when list_indices shows such names, and keep size small.
|
||||||
|
Report document timestamps exactly as stored (@timestamp).
|
||||||
|
|
||||||
|
On an error: report the exact index, query and error; retry at most once
|
||||||
|
with a simpler query. Do not write query DSL yourself.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- list_indices
|
||||||
|
- count
|
||||||
|
- search
|
||||||
|
- get_document
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
id: elasticsearch
|
id: elasticsearch
|
||||||
name: Elasticsearch
|
name: Elasticsearch
|
||||||
version: 1.1.0
|
version: 1.1.1
|
||||||
description: "Elasticsearch (REST API) — log search and enrichment for security data: run queries, count and read documents, list indices, index a document, and delete by query. API-key authentication; stdlib-only, no extra Python dependencies. Works with Elastic Security indices."
|
description: "Elasticsearch (REST API) — log search and enrichment for security data: run queries, count and read documents, list indices, index a document, and delete by query. API-key authentication; stdlib-only, no extra Python dependencies. Works with Elastic Security indices."
|
||||||
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 1 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: search, count, get document, index document, list indices, delete by query."
|
changelog: "1.1.0 — Declare the risk tier on the 2 commands that carried none: 1 safe_write, 1 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: search, count, get document, index document, list indices, delete by query."
|
||||||
category: siem
|
category: siem
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# The FortiGate operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design: Riposte
|
||||||
|
# refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Rampart
|
||||||
|
title: FortiGate operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
How the FortiGate configuration covers a host or IP: which firewall
|
||||||
|
address objects and address groups contain it, which IPv4 policies use
|
||||||
|
those objects (source, destination, service, accept or deny), and what a
|
||||||
|
custom service object means. This integration has no access to FortiGate
|
||||||
|
traffic logs: it cannot say what traffic a host produced or what was
|
||||||
|
actually blocked. Not for banning IPs or changing objects or policies,
|
||||||
|
not for other vendors' alerts.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id, or the IP or subnet, and the VDOM when the firewall
|
||||||
|
has several.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
The address objects, groups, policies and services that concern the host,
|
||||||
|
each with the command that produced it; "not found" when no object
|
||||||
|
contains it; the exact call and error when one failed. No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call): the Riposte alert. Take the IP, host
|
||||||
|
name and VDOM from its source_ref and raw fields, exactly as written.
|
||||||
|
2. list_addresses (1 call, no name): every address object. Look in the
|
||||||
|
answer yourself for objects whose subnet contains the IP (subnet is
|
||||||
|
"IP netmask"; 255.255.255.255 is that single host) and note their
|
||||||
|
names.
|
||||||
|
3. list_address_groups (1 call, no name): keep the groups whose members
|
||||||
|
include those object names.
|
||||||
|
4. list_policies (1 call, no policy_id): keep the policies whose srcaddr
|
||||||
|
or dstaddr name one of those objects or groups, or "all". Report
|
||||||
|
policyid, name, srcintf, dstintf, srcaddr, dstaddr, service, action
|
||||||
|
and status as the answer gives them.
|
||||||
|
5. list_services (at most 2 calls) only for a service name a kept policy
|
||||||
|
uses that is not a predefined one; pass the name exactly as the policy
|
||||||
|
answer writes it.
|
||||||
|
|
||||||
|
Names are case-sensitive and must be copied from a previous answer, never
|
||||||
|
typed or adapted: a single-object lookup by name answers 404 when the
|
||||||
|
name does not exist. Pass vdom only when the data names one.
|
||||||
|
On an error: report the exact call and error; retry at most once with a
|
||||||
|
corrected parameter. No object containing the IP is "not found".
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- list_addresses
|
||||||
|
- list_address_groups
|
||||||
|
- list_policies
|
||||||
|
- list_services
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
id: fortigate
|
id: fortigate
|
||||||
name: FortiGate
|
name: FortiGate
|
||||||
version: 1.1.0
|
version: 1.1.1
|
||||||
description: "Fortinet FortiGate (FortiOS REST API v2) — network containment: ban/unban IP addresses (quarantine), manage firewall address objects and address groups, and read firewall policies and services. API-token authentication; stdlib-only, no extra Python dependencies. Supports multi-VDOM."
|
description: "Fortinet FortiGate (FortiOS REST API v2) — network containment: ban/unban IP addresses (quarantine), manage firewall address objects and address groups, and read firewall policies and services. API-token authentication; stdlib-only, no extra Python dependencies. Supports multi-VDOM."
|
||||||
changelog: "1.1.0 — Declare the risk tier on the 5 commands that carried none: 1 safe_write, 4 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: ban/unban/list banned IPs, address object create/delete/list, address-group update/list, firewall policy and service listing."
|
changelog: "1.1.0 — Declare the risk tier on the 5 commands that carried none: 1 safe_write, 4 destructive. An unset risk is read as destructive by the agent approval gate, so the 1 that only annotate sat behind the same prompt as the ones that change the estate. | 1.0.0 — Initial release: ban/unban/list banned IPs, address object create/delete/list, address-group update/list, firewall policy and service listing."
|
||||||
category: network
|
category: network
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# The GreyNoise operator. Installed with the integration by Riposte 0.302.0
|
||||||
|
# and later (older versions ignore this folder). Read-only by design.
|
||||||
|
key: operator
|
||||||
|
handle: Hush
|
||||||
|
title: GreyNoise operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 10
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Whether an IP address is internet background noise (a mass scanner seen
|
||||||
|
by GreyNoise sensors) or a known common business service (RIOT: CDNs,
|
||||||
|
Office365, Google DNS…), and what GreyNoise says about it: classification
|
||||||
|
(benign / malicious / unknown), actor, tags, first/last seen, scan
|
||||||
|
timeline. IPs only. Not for hashes, domains or URLs, and not for whether
|
||||||
|
an IP targeted us specifically.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The IP addresses to check (public IPs; private ranges are never in
|
||||||
|
GreyNoise). Or a Riposte alert id, to pick the IPs from it.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Per IP, facts from GreyNoise with the command that produced each: seen or
|
||||||
|
not, classification, actor, tags, RIOT name and category, first/last seen.
|
||||||
|
"not seen by GreyNoise" when it was not observed. Classifications are
|
||||||
|
attributed to GreyNoise, never your own verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 10 calls. Spend it in this order:
|
||||||
|
1. Only if you were given a Riposte alert id: get_incident_context (1 call)
|
||||||
|
and pick the public IPs from it. Otherwise skip.
|
||||||
|
2. Per IP: ip_quick_check(ip=<ip>) first. It tells whether the IP is noise
|
||||||
|
and whether it is RIOT, in one cheap call.
|
||||||
|
3. Then per IP, only one of:
|
||||||
|
- noise true: ip_context(ip=<ip>) for classification, actor, tags,
|
||||||
|
first_seen / last_seen.
|
||||||
|
- riot true: riot(ip=<ip>) for the service name, category and trust
|
||||||
|
level.
|
||||||
|
- neither: stop there for this IP and report "not seen by GreyNoise".
|
||||||
|
4. timeline(ip=<ip>, days 30) only when asked when the scanning started or
|
||||||
|
whether it is still going.
|
||||||
|
5. query and stats (GNQL) only when the lead asks for other IPs sharing a
|
||||||
|
tag or actor. Use a query only if the lead hands it to you or it is a
|
||||||
|
plain value from a previous answer; do not invent GNQL syntax.
|
||||||
|
|
||||||
|
Traps:
|
||||||
|
- "Not seen" is an answer, not an error: an IP absent from GreyNoise that
|
||||||
|
hits us is more likely targeted than background noise. Say only what
|
||||||
|
GreyNoise said; do not conclude for the lead.
|
||||||
|
- riot answers 404 for an IP that is not a known business service: that
|
||||||
|
means "not RIOT", not a failure.
|
||||||
|
- Private, loopback or link-local IPs (10., 172.16-31., 192.168., 127.,
|
||||||
|
169.254.) are never in GreyNoise: skip them and say so.
|
||||||
|
- HTTP 401/403 on ip_context, query or stats can mean the key's plan does
|
||||||
|
not include that endpoint: report the call and error, do not retry.
|
||||||
|
- HTTP 429 is the quota: report it and stop.
|
||||||
|
- Copy IPs only from what you were given or a previous answer. Report
|
||||||
|
dates exactly as the data gives them.
|
||||||
|
- On any other error: report the exact call and error, retry at most once.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- ip_quick_check
|
||||||
|
- ip_context
|
||||||
|
- riot
|
||||||
|
- timeline
|
||||||
|
- query
|
||||||
|
- stats
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
id: greynoise
|
id: greynoise
|
||||||
name: GreyNoise
|
name: GreyNoise
|
||||||
version: 1.0.0
|
version: 1.0.1
|
||||||
description: "GreyNoise (API v2/v3) — tell internet background-noise scanners from targeted activity: IP context and quick classification, RIOT common-business-service lookup, GNQL query and stats, and IP scan timeline. API-key authentication; stdlib-only, no extra Python dependencies."
|
description: "GreyNoise (API v2/v3) — tell internet background-noise scanners from targeted activity: IP context and quick classification, RIOT common-business-service lookup, GNQL query and stats, and IP scan timeline. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
changelog: "1.0.0 — Initial release: IP context, quick check, RIOT, GNQL query and stats, and IP timeline."
|
changelog: "1.0.0 — Initial release: IP context, quick check, RIOT, GNQL query and stats, and IP timeline."
|
||||||
category: enrichment
|
category: enrichment
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# The HarfangLab EDR operator. Installed with the integration by Riposte
|
||||||
|
# 0.302.0 and later (older versions ignore this folder). Read-only by design:
|
||||||
|
# Riposte refuses a sheet that lists a write.
|
||||||
|
key: operator
|
||||||
|
handle: Nyx
|
||||||
|
title: HarfangLab EDR operator
|
||||||
|
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||||
|
approval_threshold: safe_write
|
||||||
|
max_tool_calls: 13
|
||||||
|
|
||||||
|
when_to_delegate: |
|
||||||
|
Anything HarfangLab can tell about an endpoint: the threat behind a
|
||||||
|
HarfangLab alert and its security events (rule, process, command line,
|
||||||
|
user), the process graph, the host's details, its process, network, DNS and
|
||||||
|
Windows logon telemetry around the alert, where else a hash was seen in the
|
||||||
|
fleet, and quarantined files. Not for other vendors' alerts, and never for
|
||||||
|
isolation, jobs or remediation: it only reads.
|
||||||
|
|
||||||
|
needs: |
|
||||||
|
The Riposte alert id or the HarfangLab threat id. For a pivot without an
|
||||||
|
alert: the hostname, a hash, a user, and a time window.
|
||||||
|
|
||||||
|
returns: |
|
||||||
|
Facts from HarfangLab, each with the command that produced it; "not found"
|
||||||
|
when a lookup came back empty; the exact call and error when one failed.
|
||||||
|
No verdict.
|
||||||
|
|
||||||
|
instructions: |
|
||||||
|
Your budget is 13 calls. Spend it in this order:
|
||||||
|
1. get_incident_context (1 call). The raw fields are a HarfangLab threat:
|
||||||
|
id (the threat id), top_agents[0].agent_hostname, top_impacted_users,
|
||||||
|
top_rules, first_seen / last_seen.
|
||||||
|
2. get_threat(threat_id) (1 call), then threat_security_events(threat_id,
|
||||||
|
max_results=50) (1 call): the alerts inside the threat. Say if
|
||||||
|
"truncated" is true.
|
||||||
|
3. alert_details(alert_id) (1 call) on the most severe alert: the rule,
|
||||||
|
the process and its process unique id. Then
|
||||||
|
telemetry_process_graph(process_uuid=<that id>) (1 call) for the tree.
|
||||||
|
4. Host: endpoint_search(hostname=<name>) (1 call) — the name, not an id.
|
||||||
|
5. Telemetry around the alert, at most 4 calls, with hostname=<name> and
|
||||||
|
from_date / to_date: telemetry_processes, telemetry_network,
|
||||||
|
telemetry_dns, telemetry_authentication_windows (for a logon or
|
||||||
|
lateral-movement alert; target_username / source_username to pivot on
|
||||||
|
a user). Dates are YYYY-MM-DDTHH:MM:SS: take them from the threat's
|
||||||
|
first_seen / last_seen and pass the same instants, cut to that form;
|
||||||
|
never add or subtract time yourself. Keep limit at 50.
|
||||||
|
6. ALWAYS keep 2 calls for "elsewhere":
|
||||||
|
- hunt_search_hash(hash) (1 call): where the file hash was seen in the
|
||||||
|
fleet. hunt_search_running_process_hash is NOT listed: running now is
|
||||||
|
not history.
|
||||||
|
- get_alerts(hostname=<name>) (1 call): other alerts on the same host,
|
||||||
|
newest first. Or get_alerts(search=<user or hash>). Never call it
|
||||||
|
without a filter.
|
||||||
|
quarantine_search(hash or agent_hostname) only if asked.
|
||||||
|
|
||||||
|
Ids: alert, threat and agent ids and process uuids are long; take an id
|
||||||
|
only from a previous answer, copied whole; never type, shorten or adapt
|
||||||
|
one. Prefer hostname over agent_id when both are accepted.
|
||||||
|
Hashes: telemetry_processes and quarantine_search take md5, sha1 or
|
||||||
|
sha256; hunt_search_hash any hash.
|
||||||
|
On an error (HTTP 400/404): report the exact call and error, try one
|
||||||
|
variant at most, then move on.
|
||||||
|
|
||||||
|
In your answer: the threat (rules, severity, status, alert count), the
|
||||||
|
process and its tree, command line and user, the host, what the telemetry
|
||||||
|
shows (connections, domains, logons) in which window, and the elsewhere
|
||||||
|
checks — with each fact's command.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- get_threat
|
||||||
|
- threat_security_events
|
||||||
|
- alert_details
|
||||||
|
- telemetry_process_graph
|
||||||
|
- endpoint_search
|
||||||
|
- telemetry_processes
|
||||||
|
- telemetry_network
|
||||||
|
- telemetry_dns
|
||||||
|
- telemetry_authentication_windows
|
||||||
|
- hunt_search_hash
|
||||||
|
- get_alerts
|
||||||
|
- quarantine_search
|
||||||
|
|
||||||
|
riposte_tools:
|
||||||
|
- get_incident
|
||||||
|
- get_incident_context
|
||||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,9 @@ description: "Maps a HarfangLab EDR alert (/api/data/alert/alert/Alert/, results
|
|||||||
field_mappings:
|
field_mappings:
|
||||||
title: "rule_name"
|
title: "rule_name"
|
||||||
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
||||||
description: "alert_type"
|
# msg is the rule's own explanation of what it detects; the alert type
|
||||||
|
# (sigma, yara, ioc…) stands in only on the alerts that carry none.
|
||||||
|
description: "msg ? msg : alert_type"
|
||||||
# results_path = results; source_path is JSONata over ONE alert object.
|
# results_path = results; source_path is JSONata over ONE alert object.
|
||||||
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
||||||
ocsf:
|
ocsf:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ description: "Maps a HarfangLab EDR security event/alert (/api/data/alert/alert/
|
|||||||
field_mappings:
|
field_mappings:
|
||||||
title: "rule_name"
|
title: "rule_name"
|
||||||
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
||||||
description: "alert_type"
|
# msg is the rule's own explanation of what it detects; the alert type
|
||||||
|
# (sigma, yara, ioc…) stands in only on the alerts that carry none.
|
||||||
|
description: "msg ? msg : alert_type"
|
||||||
# results_path = results; source_path is JSONata over ONE alert object.
|
# results_path = results; source_path is JSONata over ONE alert object.
|
||||||
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
||||||
ocsf:
|
ocsf:
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
def csv(v):
|
def csv(v):
|
||||||
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def csv(v):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
FIELD_TYPES = {
|
FIELD_TYPES = {
|
||||||
"name": 0, "path": 1, "username": 3, "commandline": 4,
|
"name": 0, "path": 1, "username": 3, "commandline": 4,
|
||||||
@@ -8,12 +8,31 @@ FIELD_TYPES = {
|
|||||||
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
|
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
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):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
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):
|
def to_bool(v):
|
||||||
if isinstance(v, bool):
|
if isinstance(v, bool):
|
||||||
return v
|
return v
|
||||||
@@ -67,7 +108,13 @@ def main():
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
# === per-command logic ===
|
# === 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:
|
for field in EXACT_FILTERS:
|
||||||
value = inputs.get(field)
|
value = inputs.get(field)
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
@@ -84,17 +131,48 @@ def main():
|
|||||||
if len(kv) == 2 and kv[0].strip():
|
if len(kv) == 2 and kv[0].strip():
|
||||||
filters[kv[0].strip()] = kv[1]
|
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
|
# 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.
|
# of matches, independent of the page size. limit=1 keeps the payload tiny.
|
||||||
agents = base + "/api/data/endpoint/Agent/"
|
agents = base + "/api/data/endpoint/Agent/"
|
||||||
matched = request("GET", agents + qs(dict(filters, limit=1, offset=0)), headers)
|
if bounds:
|
||||||
result = {"count": matched.get("count", 0), "filters": filters}
|
# 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
|
# 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.
|
# filter, which yields the unfiltered total dressed up as a filtered count.
|
||||||
# Returning the fleet total alongside makes that failure visible.
|
# Returning the fleet total alongside makes that failure visible.
|
||||||
if filters:
|
if filters != scope or bounds:
|
||||||
everything = request("GET", agents + qs({"limit": 1, "offset": 0}), headers)
|
everything = request("GET", agents + qs(dict(scope, limit=1, offset=0)), headers)
|
||||||
result["total"] = everything.get("count", 0)
|
result["total"] = everything.get("count", 0)
|
||||||
else:
|
else:
|
||||||
result["total"] = result["count"]
|
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):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
@@ -53,7 +72,11 @@ def main():
|
|||||||
"alert_time__gte": inputs.get("from_date"),
|
"alert_time__gte": inputs.get("from_date"),
|
||||||
"alert_time__lte": inputs.get("to_date"),
|
"alert_time__lte": inputs.get("to_date"),
|
||||||
}
|
}
|
||||||
print(json.dumps(request("GET", base + "/api/data/alert/alert/Alert/" + qs(params), headers)))
|
out = request("GET", base + "/api/data/alert/alert/Alert/" + qs(params), headers)
|
||||||
|
# The list endpoints echo the catalogue of filterable fields (~50 KB) on
|
||||||
|
# every call; it says nothing about the results, so it is not passed on.
|
||||||
|
out.pop("filters", None)
|
||||||
|
print(json.dumps(out))
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
SEVERITIES = ["low", "medium", "high", "critical"]
|
SEVERITIES = ["low", "medium", "high", "critical"]
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers):
|
def request(method, url, headers):
|
||||||
req = urllib.request.Request(url, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=90) as resp:
|
req = urllib.request.Request(url, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
@@ -40,7 +59,11 @@ def main():
|
|||||||
"alert_time__gte": inputs.get("created_after"),
|
"alert_time__gte": inputs.get("created_after"),
|
||||||
}
|
}
|
||||||
url = base + "/api/data/alert/alert/Alert/" + qs(params)
|
url = base + "/api/data/alert/alert/Alert/" + qs(params)
|
||||||
print(json.dumps(request("GET", url, headers)))
|
out = request("GET", url, headers)
|
||||||
|
# The list endpoints echo the catalogue of filterable fields (~50 KB) on
|
||||||
|
# every call; it says nothing about the results, so it is not passed on.
|
||||||
|
out.pop("filters", None)
|
||||||
|
print(json.dumps(out))
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
SEVERITIES = ["low", "medium", "high", "critical"]
|
SEVERITIES = ["low", "medium", "high", "critical"]
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers):
|
def request(method, url, headers):
|
||||||
req = urllib.request.Request(url, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=90) as resp:
|
req = urllib.request.Request(url, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
@@ -39,7 +58,11 @@ def main():
|
|||||||
"creation_date__gte": inputs.get("created_after"),
|
"creation_date__gte": inputs.get("created_after"),
|
||||||
}
|
}
|
||||||
url = base + "/api/data/alert/alert/Threat/" + qs(params)
|
url = base + "/api/data/alert/alert/Threat/" + qs(params)
|
||||||
print(json.dumps(request("GET", url, headers)))
|
out = request("GET", url, headers)
|
||||||
|
# The list endpoints echo the catalogue of filterable fields (~50 KB) on
|
||||||
|
# every call; it says nothing about the results, so it is not passed on.
|
||||||
|
out.pop("filters", None)
|
||||||
|
print(json.dumps(out))
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
FIELD_TYPES = {
|
FIELD_TYPES = {
|
||||||
"name": 0, "path": 1, "username": 3, "commandline": 4,
|
"name": 0, "path": 1, "username": 3, "commandline": 4,
|
||||||
@@ -8,12 +8,31 @@ FIELD_TYPES = {
|
|||||||
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
|
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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():
|
def main():
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def retryable(method, status):
|
||||||
|
# HarfangLab answers a 500 now and then on a call that passes when sent again.
|
||||||
|
# A read is safe to resend; a write only on an answer that says the action was
|
||||||
|
# not run (rate limit, gateway down), so an isolation or a job is never sent twice.
|
||||||
|
if status in (429, 502, 503):
|
||||||
|
return True
|
||||||
|
return method == "GET" and status in (500, 504)
|
||||||
|
|
||||||
|
|
||||||
def request(method, url, headers, body=None):
|
def request(method, url, headers, body=None):
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
for attempt in range(RETRIES + 1):
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
raw = resp.read()
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
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):
|
def qs(params):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user