Compare commits

...

30 Commits

Author SHA1 Message Date
f3nris bf273b959a feat(cortex-xdr): watermark incidents on modification_time, not creation
An XDR incident is not finished when it is created. Alerts keep joining it, an
analyst changes its status, its severity is raised. A creation_time watermark
fetches it once, the watermark moves past it, and nothing that happens
afterwards ever reaches Riposte — which is precisely the content the full fetch
exists to bring in.

modified_after filters and sorts on modification_time instead, so an incident
comes back on every change and dedup on incident_id turns the second visit into
an enrichment of the incident already there. It is now what the ingest hint
prefills; created_after stays for a one-shot backfill.

Worth knowing about that enrichment: it merges context and can fill a detection
anchor that was missing, but it does not restate the incident's severity or
status. An incident XDR later raises to critical stays at the severity it was
ingested with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:50:24 +02:00
f3nris ba68d19e51 refactor(cortex-xdr): name the full fetch get-incidents-full (v1.3.1)
"fetch" said nothing next to a list of commands that all start with "get". The
command sits beside cortex-xdr-get-incidents in the picker, and the only thing
an operator needs to read there is which of the two carries everything — so the
name says it: get-incidents-full.

The id moves with it (fetch_incidents -> get_incidents_full), since the script
and the bundled mapper are bound to a command by filename. Anyone who created a
rule against the old id in the few minutes 1.3.0 was up has to point it at the
new command; the changelog says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:47:17 +02:00
f3nris a6e1245c79 feat(cortex-xdr): fetch incidents with their alerts, not a 21-field summary
incidents/get_incidents/ answers with a summary and nothing else: 21 fields,
no hosts, no users, no MITRE, no tags, and not one of the alerts the incident
aggregates. Ingesting through it leaves an incident whose raw payload says
almost nothing about what happened — and the shipped mapper had been written
for a richer shape than the endpoint ever returns, mapping hosts[0], users[0]
and mitre_* that simply are not in that response.

incidents/get_multiple_incidents_extra_data/ returns the same incidents with
39 fields, every alert in full — 156 fields each — and the file and network
artifacts. It is what the reference client fetches through (demisto/content,
CortexXDRIR.get_multiple_incidents_extra_data), and full_alert_fields must be
set or the nested alerts come back trimmed to a handful of fields.

Records arrive as {incident, alerts, network_artifacts, file_artifacts} with
each nested block wrapped as {total_count, data}. The script flattens them, so
every expression written against get_incidents keeps working — the summary's
21 fields are a subset of these 39 — while the alerts and artifacts land beside
them as plain lists, and their total_count says when a list is a sample rather
than the whole set. incident_sources is lifted into a scalar for the same
reason severity was on the alerts side: the incident-field mapper reads dotted
paths and cannot index a list.

get_incidents stays, for cheap polling, and now says in its description what it
does and does not carry.

The mapper maps the aggregate first and the first alert last, so the alert
fills in whatever the aggregate leaves silent — including the detection anchor,
since an XDR incident's detection_time is usually null while its alerts carry
theirs. Verified against the vendor's recorded response
(test_data/get_multiple_incidents_extra_data.json): 33 of 52 entries resolve,
severity critical lands on 5, source reads "XDR Agent", and the anchor falls
through to the alert's detection timestamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:41:27 +02:00
f3nris d06f8ea413 fix(cortex-xdr): the alerts endpoint never answered — wrong request dialect
get_alerts posted the incidents body, {filters, search_from, search_to, sort},
to alerts/get_alerts_by_filter_data/. That endpoint serves the alerts GRID and
speaks another dialect entirely, so every call — before this branch as much as
after it — came back a bare HTTP 500 with no hint as to why.

Shape taken from the reference client (demisto/content, Packs/ApiModules/
Scripts/CoreIRApiModule, get_alerts_by_filter_command):

  request_data.filter_data = {
    sort:   [{FIELD, ORDER}],          # a list, uppercase keys
    paging: {from, to},                # not search_from/search_to
    filter: {AND: [{SEARCH_FIELD, SEARCH_TYPE, SEARCH_VALUE}]},
  }

Severity is an enum there (SEV_040_HIGH), and several severities are OR'd, not
passed as a list. The watermark is a RANGE, since the grid has no gte operator;
its upper bound carries five minutes of slack, because our clock and the
tenant's are not the same clock. A filterless query is bounded to the last
thirty days rather than sent empty — the reference client refuses one outright,
and the grid is not meant to be asked for a whole retention.

The response needed as much work as the request. Rows arrive wrapped as
{alert_fields, incident_fields}, and mapping through that wrapper would put an
alert_fields. prefix on every expression an operator writes, so each row is
unwrapped. Two of its fields cannot be mapped as they stand: severity is the
enum code, and status.progress carries a dot INSIDE the key, which no mapping
path can express. Both are derived into severity_name and status_progress.

The mapper follows the grid's own vocabulary — internal_id, alert_name,
agent_hostname, agent_ip_addresses — and dedup moves to internal_id, since
alert_id belongs to the other API. case_id is kept as the correlation UID: it
is the join back to the incident feed.

Verified end to end against the vendor's own recorded response
(test_data/get_alerts_by_filter_results.json): 33 of 54 OCSF entries resolve on
it, severity lands on 3, the detection anchor is set, and the paging walks
0-100, 100-200, 200-250 with the truncation flag raised only when the ceiling,
not the window, ended the fetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:34:26 +02:00
f3nris 17318d4225 feat(cortex-xdr): ingest alerts, not only incidents (v1.3.0)
An XDR incident is an aggregate; the SOC works the detections under it. The
alerts endpoint was already exposed as a read command, but nothing could feed
an alert rule with it — no results path, no dedup key, no watermark, no mapper,
no incident type. All five are here now, so an alert rule can be pointed at
reply.alerts the same way it is pointed at reply.incidents.

get_alerts pages past the API's 100-results-per-call ceiling: an alert feed
carries far more than a hundred detections between two polls, and whatever a
single page leaves behind is never fetched again, because the next run's
watermark has already moved past it. On an incremental fetch it also sorts
oldest first, so a window larger than the limit drops its most recent alerts —
the only ones the next poll can still see — and says so via `truncated`.

Two fixes to the incident side while in the same files:

- The severity expression compared strings, which the mapping engine cannot do
  (it reads numeric comparisons only). Every test read as false, so every
  ingested incident silently took the alert rule's default severity. The bare
  field works: Riposte maps critical/high/medium/low onto 1-5 itself.
- The incident mapper carried no `time`, so the detection anchor was missing
  and MTTD stayed empty for the whole feed. creation_time fills it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:26:31 +02:00
f3nris c22982c8af chore: retire the Riposte Sextant integration
It pushed agent readings over HTTP into a sibling product. That product was
absorbed into the SOAR in 0.112.0, and the readings now go through the built-in
SOC Reporting integration, which writes into the same database the server
already owns — no base URL, no ingestion token, no second deployment to keep
reachable. This integration is the seam the absorption spent five phases
removing.

Nothing here is worth keeping behind: the counters, the rule that an omitted
counter stays unknown rather than zero, and the client-listing command all
exist on the other side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:02:43 +02:00
f3nris 1d475a6cf5 fix(sentinelone): count-agents can be asked about the whole fleet again
Declared as a boolean, isActive rendered as a checkbox in the toolbox,
and a checkbox has no empty state: the count was forced to one half of
the fleet or the other, with no way to ask for both. As text, an empty
field means the filter is not sent.

true/yes/1 and false/no/0 are read in any case, a real JSON boolean from
a playbook still works, and anything else is refused rather than folded
into false — that would answer a different question than the one asked.
2026-08-14 23:48:40 +02:00
f3nris 93309ac74d feat(sentinelone): count-agents can be scoped to an account
A multi-tenant console answered one number for the whole estate.
accountIds narrows the count to the accounts asked for, the way
get_threats and get_alerts already do at fetch time.
2026-08-14 23:40:13 +02:00
f3nris a5095d3f0f feat(sentinelone): count-agents can tell the active fleet from the dormant one
isActive splits a count that until now lumped both together. It is sent
only when the caller sets it: an unset boolean must not reach the console
as isActive=false and quietly count the dormant agents instead of all of
them.
2026-08-14 23:30:47 +02:00
f3nris cebb3af796 feat(sentinelone): count-agents can ask about versions, state and hardware
The count only knew where an agent was, not what it was running or what
it runs on. Four filters carry the console's own vocabulary through:
agentVersion__gt to count the fleet still below a target build,
networkStatuses to keep only the endpoints in a given connection state,
operationalStatesNin to leave states out of the tally, and machineTypes
to count servers apart from laptops.

The list filters are split and re-joined so a hand-typed
"connected, disconnected" does not reach the API with the space inside
the value.
2026-08-14 22:39:29 +02:00
f3nris ba6bcd19f4 fix(cortex-xdr): say what went wrong when the API answers something other than JSON (v1.2.1)
A tenant URL that is not the API host answers 200 with the console's HTML
shell, and the client turned that into "Expecting value: line 1 column 1
(char 0)" — a JSON parse error that names neither the URL nor the response.

The shared client now reports the status, content type and first bytes of any
non-JSON reply, and points at the API host the operator was meant to copy.
Around it, the URL is reduced to scheme + host so a pasted /public_api/v1 or
console path cannot double the API root, a missing key or key ID is refused
before the request, and 401/403 recalls the three usual causes (auth mode,
revoked key, clock skew).

Nonce and timestamp now ride along in standard mode too, matching the
reference client, and test_connection probes get_incidents — the read that
ingestion actually depends on — instead of the distribution versions.
2026-08-13 14:35:54 +02:00
f3nris aabe1a6837 feat(riposte-sextant): list the clients, and the identifier to deposit under (v1.1.0)
The refusal you get on the first run is "no Sextant client is paired with this
SOAR client", and it names the identifier it did not recognise — but nothing
told you which ones it would have recognised. This does.

Clients with no pairing appear with an empty identifier, and are surfaced again
under `unpaired`: they are the whole reason somebody runs this command twice,
and spotting them in a list is exactly what nobody does.

Verified against a running Sextant: the paired client comes back with its
identifier, and the route refuses a caller with no ingestion token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:04:30 +02:00
f3nris 50358ef8a1 feat(riposte-sextant): push agent readings into the SOC steering file
Sextant counts nothing itself: it receives. Until now the opposite was true —
it reached into this SOAR to run a command, which meant SOAR had to host a
whitelist table, a screen to fill it, a dedicated permission and a catalogue
route. Configuration belonging to another product, living here.

As an integration, none of that is needed: an operator creates an instance with
a URL and a token like for any other tool, and a scheduled playbook counts on
the vendor console, maps, then calls push_agent_stats.

The mapping lives in that playbook, which is where it belongs — next to the
connector that produced the numbers, and per client rather than per product.

WHAT THE SCRIPT REFUSES TO DO, and why it matters more than what it does: a
counter left empty is OMITTED, never sent as zero. "We did not measure how many
agents are in error" and "no agent is in error" are opposite pieces of news, and
the second reassures wrongly — the whole steering file exists not to say it. A
counter that is present but not a number is refused instead, naming the field:
that is a broken mapping in the calling playbook, and dropping it silently would
look exactly like "not measured".

Verified against a running Sextant rather than assumed: the deposit files under
the paired client with the empty counter absent, a broken mapping is refused by
name, and an unpaired client comes back saying which identifier was not
recognised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:46:10 +02:00
f3nris 82f22fdbb3 feat(harfanglab): split image_name into the process path and the threat name (v1.4.1)
image_name was left unmapped because HarfangLab prefixes it with the
matched threat name — "[PUADlManager:Win32/OfferCore] C:\Windows\
explorer.exe" — and the clean path was available from the event data.
That reasoning only held for event-log alerts. On the yara, binary and
ioc subtypes there is neither a `process` object nor an `eventlog`
block, so those alerts reached the incident with no process at all.

Both halves are now pulled out, each guarded on the bracket actually
being there: $substringBefore and $substringAfter return the whole
string when the pattern is absent, so without the guard an unprefixed
path would land verbatim in malware.name.

The three entries lead the process block so the more specific sources
below — the matched process object, then the event log's own fields —
still overwrite them when present. malware.name consequently surfaces on
every subtype instead of only where Defender is the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:15:44 +02:00
f3nris f7c5e99cc5 feat(harfanglab): map the fields the alert and threat payloads actually carry (v1.4.0)
Replayed a real get_alerts and a real get_threats response through the
bundled mappers to see what came out.

get_alerts: a sigma alert matching a Windows event log carries the whole
record under `eventlog`, and none of it was mapped. On a Defender 1116
detection that left the malware, the file, the acting user and the
detecting product invisible — the incident indexed a hostname and
nothing else. The event data uses the provider's own field names, spaces
included, so the new entries quote them with JSONata backticks, and
Defender's "file:_" path prefix is stripped. Also added the agent IP
(placed before details_connection.SourceIp so a network alert's own
source still wins), the DNS domain, device type, agent version and
groups, the tenant, the alert subtype and threat type, the string
severity and the event time.

get_threats: `level` had no entry at all, so a threat's severity never
reached the OCSF document even though field_mappings derived the
incident's 1-5 from it. Added it, the event time, and the top agent's
reachability — whether the endpoint is online decides what response is
possible, and it was excluded without a reason.

Both trailers now record why each remaining field is left out, and the
event log's own SYSTEM writer and FQDN spelling are called out: indexing
them would create a junk user entity and a second spelling of one host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:30:01 +02:00
f3nris 6343a0c952 feat(ipinfo): support the Lite API for free/Lite plan tokens (v1.1.0)
The integration only exposed the Core API (/lookup/*), which returns 403
for accounts on the free/Lite plan — including the instance Test button.

Add the Lite endpoints: lite_lookup_ip (GET /lite/{ip}), lite_lookup_me
(GET /lite/me) and lite_batch (POST /batch/lite, code-first because the
endpoint takes a raw JSON array; chunked at the documented 1000-IP cap).

Point test_connection at /lite/me: every plan can reach it, so the token
check now succeeds on Lite-only accounts while still validating Core ones.
Core commands keep their paths and state the plan requirement up front.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:01:43 +02:00
Guillaume BOURGEOIS 7efc873867 feat(sentinelone): scope threat and alert fetches to given accounts (v1.4.0)
A multi-tenant console returns every account's threats and alerts at once.
get_threats and get_alerts now take an optional account_ids input, passed
through as the accountIds query parameter, so an ingestion can be pinned to
the accounts the SOC actually watches.

The input is optional, so existing instances and running ingestions keep
their current behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:56:47 +02:00
f3nris 144089a099 fix(harfanglab): flag get_alerts as ingestion-ready (v1.3.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 23:19:58 +02:00
f3nris a4b5b44157 fix(harfanglab): null guards and stable attacks array in get_threats mapper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:52:11 +02:00
f3nris 4ea3af6c1d fix(harfanglab): rework get_threats OCSF mapper from live threat sample
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:49:31 +02:00
f3nris 952dc7efda feat(harfanglab): v1.3.0 — response & triage expansion (27 commands + alert mappers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:33:59 +02:00
f3nris cba746a9f9 feat(harfanglab): exhaustive alert OCSF mappers (get_alerts + upgraded get_security_events)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:26:19 +02:00
f3nris 5d6f823189 feat(harfanglab): threat triage commands (status, level, comment, note, history)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:20:54 +02:00
f3nris 098b90d6f3 feat(harfanglab): alert triage commands (tag, comment, details, history)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:18:01 +02:00
f3nris db902c35bc feat(harfanglab): alert search and single-threat read commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:14:25 +02:00
f3nris 59e9211c0d feat(harfanglab): threat-level isolation and job lifecycle commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:09:56 +02:00
f3nris fcbd2ff135 feat(harfanglab): quarantine management commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:04:47 +02:00
f3nris ef60cec0fa feat(harfanglab): file/service/scheduled-task deletion commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:01:22 +02:00
f3nris 6d993f584f feat(harfanglab): on-demand AV and YARA scan commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:57:37 +02:00
f3nris 6d311740f9 feat(harfanglab): kill/dump process response commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:52:39 +02:00
96 changed files with 4378 additions and 476 deletions
@@ -0,0 +1,3 @@
name: "Cortex XDR Alert"
color: "#ef8354"
icon: "alert"
+33 -10
View File
@@ -1,8 +1,8 @@
id: cortex_xdr id: cortex_xdr
name: Cortex XDR name: Cortex XDR
version: 1.2.0 version: 1.3.1
description: "Palo Alto Cortex XDR (public API v1) — incident ingestion + write-back, endpoint isolation/scan/delete/tagging, RTR scripts, hash block/allow lists, file quarantine/restore/retrieval, alert exclusions, external alert push (parsed/CEF), device-control violations, audits, distributions and RBAC/risk." description: "Palo Alto Cortex XDR (public API v1) — incident and alert ingestion + write-back, endpoint isolation/scan/delete/tagging, RTR scripts, hash block/allow lists, file quarantine/restore/retrieval, alert exclusions, external alert push (parsed/CEF), device-control violations, audits, distributions and RBAC/risk."
changelog: "1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication." changelog: "1.3.1 — get_incidents_full can watermark on modification_time (modified_after), which is what ingestion wants: an XDR incident keeps growing after creation, and a creation_time watermark fetches it once and never looks again, so every alert that joins it afterwards is lost. The full-incident fetch command is named cortex-xdr-get-incidents-full (id get_incidents_full), not cortex-xdr-fetch-incidents: sitting next to cortex-xdr-get-incidents in the command list, it now reads as what it is — the same call, everything included. A rule created against the old id must be pointed at the new one. 1.3.0 — Richer incident ingestion (get_incidents_full, on get_multiple_incidents_extra_data): incidents now arrive with their alerts in full and their file/network artifacts, where get_incidents only ever answered a 21-field summary carrying neither hosts, users, MITRE nor a single alert. Alert ingestion, and the alerts endpoint answers at last: get_alerts was sending the incidents dialect ({filters, search_from, search_to, sort}) to a grid endpoint that speaks request_data.filter_data (SEARCH_FIELD/SEARCH_TYPE/SEARCH_VALUE blocks, paging.from/to, sort as a list), and every call came back HTTP 500. Body rebuilt from the reference client, rows unwrapped out of their alert_fields envelope, severity code and the dotted status.progress key derived into readable fields. Alert ingestion: get_alerts is now a fetch command (results path reply.alerts, dedup on alert_id, incremental on source_insert_ts) with a bundled OCSF mapper and a Cortex XDR Alert incident type, so detections can be ingested alongside — or instead of — incidents. The incident mapper is fixed on the way past: its severity expression compared strings, which the mapping engine cannot do, so every ingested incident silently took the rule's default severity; it also now carries a detection anchor so MTTD is measurable. It pages past the API's 100-results-per-call ceiling, and sorts oldest-first on an incremental fetch so a truncated window drops the alerts the next poll can still see. 1.2.1 — Connection troubleshooting: the URL is normalised to the tenant host (a pasted /public_api/v1 or console path no longer breaks the call), a non-JSON reply reports the status, content type and body instead of a bare JSON parse error, missing key/key ID is caught up front, nonce and timestamp are sent in both auth modes as the reference client does, and test_connection now probes get_incidents. 1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication."
category: endpoint category: endpoint
# Per-instance configuration. The base URL is the tenant API root, e.g. # Per-instance configuration. The base URL is the tenant API root, e.g.
@@ -11,7 +11,7 @@ config_schema:
properties: properties:
url: url:
type: string type: string
description: "Cortex XDR API base URL, e.g. https://api-<tenant>.xdr.<region>.paloaltonetworks.com" description: "Tenant API host — Settings → Configurations → API Keys → Copy URL, e.g. https://api-<tenant>.xdr.<region>.paloaltonetworks.com. Not the console URL you log into (that one answers with HTML, not JSON)."
api_key_id: api_key_id:
type: string type: string
description: "API Key ID (the integer ID shown next to the API key in Settings → API Keys)" description: "API Key ID (the integer ID shown next to the API key in Settings → API Keys)"
@@ -21,7 +21,7 @@ config_schema:
x-soar-sensitive: true x-soar-sensitive: true
auth_type: auth_type:
type: string type: string
description: "Authentication method: standard (key sent as-is) or advanced (nonce + timestamp SHA-256 hash)" description: "How the key was created in Cortex XDR: standard (key sent as-is) or advanced (nonce + timestamp SHA-256 hash). A mismatch here is rejected with 401."
default: standard default: standard
required: required:
- url - url
@@ -44,7 +44,7 @@ commands:
# ── Ingestion ───────────────────────────────────────────────────────────── # ── Ingestion ─────────────────────────────────────────────────────────────
- id: get_incidents - id: get_incidents
name: cortex-xdr-get-incidents name: cortex-xdr-get-incidents
description: "Fetch Cortex XDR incidents for ingestion. Returns {reply:{incidents:[...]}}; use reply.incidents as the alert rule results path." description: "List Cortex XDR incidents as a 21-field summary (no hosts, no users, no MITRE, no alerts). Cheap to poll, but for ingestion prefer cortex-xdr-get-incidents-full, which returns the same incidents with their alerts and artifacts. Returns {reply:{incidents:[...]}}."
risk: read risk: read
inputs_schema: inputs_schema:
properties: properties:
@@ -57,6 +57,23 @@ commands:
results_path: reply.incidents results_path: reply.incidents
dedup_key: incident_id dedup_key: incident_id
incremental_field: created_after incremental_field: created_after
- id: get_incidents_full
name: cortex-xdr-get-incidents-full
description: "Fetch incidents WITH their alerts and artifacts (get_multiple_incidents_extra_data) — the ingestion command to prefer. get_incidents answers with a 21-field summary carrying no hosts, no users, no MITRE and none of the alerts; this one returns 39 incident fields, every alert in full (156 fields each) and the file/network artifacts. Records are flattened, so mapping expressions written against get_incidents keep working and alerts[], file_artifacts[], network_artifacts[] sit beside them. Returns {reply:{incidents:[...]}}."
risk: read
inputs_schema:
properties:
status: { type: string, description: "Comma-separated statuses to keep (new, under_investigation, resolved_threat_handled…)" }
created_after: { type: string, description: "Lower bound on creation_time, ISO8601 or epoch ms. Watermarking on this fetches each incident once and never revisits it — alerts joining it later never arrive." }
modified_after: { type: string, description: "Lower bound on modification_time, ISO8601 or epoch ms. The watermark to prefer for ingestion: an incident comes back whenever it changes, and dedup on incident_id turns the second visit into an enrichment." }
limit: { type: number, description: "Maximum incidents to fetch (default 50, paged 50 at a time). A full incident weighs a few KB and up to a few hundred with its alerts, so raise this knowingly." }
exclude_artifacts: { type: boolean, description: "Drop the file and network artifact blocks, keeping the alerts (lighter payload)" }
required: []
outputs_schema: { properties: {} }
ingest:
results_path: reply.incidents
dedup_key: incident_id
incremental_field: modified_after
- id: get_incident_extra_data - id: get_incident_extra_data
name: cortex-xdr-get-incident-extra-data name: cortex-xdr-get-incident-extra-data
description: "Get full incident data including its alerts and network artifacts by incident ID." description: "Get full incident data including its alerts and network artifacts by incident ID."
@@ -83,15 +100,21 @@ commands:
outputs_schema: { properties: {} } outputs_schema: { properties: {} }
- id: get_alerts - id: get_alerts
name: cortex-xdr-get-alerts name: cortex-xdr-get-alerts
description: "Retrieve alerts using a custom filter (get_alerts_by_filter_data). Returns rich alert objects." description: "Fetch Cortex XDR alerts for ingestion (get_alerts_by_filter_data). Returns {reply:{alerts:[...]}}; use reply.alerts as the alert rule results path. Each row is unwrapped out of the API's alert_fields envelope and carries a readable severity_name and status_progress, so alerts-grid field names (internal_id, alert_name, agent_hostname) are what mapping expressions see. Alerts are the detection layer under incidents: ingest them alongside get_incidents when the SOC works detections, not only aggregates."
risk: read risk: read
inputs_schema: inputs_schema:
properties: properties:
severity: { type: string, description: "Comma-separated severities (low, medium, high, critical)" } severity: { type: string, description: "Comma-separated severities (informational, low, medium, high, critical)" }
created_after: { type: string, description: "Lower bound on alert source_insert_ts, epoch ms" } created_after: { type: string, description: "Lower bound on alert source_insert_ts, ISO8601 or epoch ms (incremental fetch watermark)" }
limit: { type: number, description: "Maximum alerts to fetch (default 100)" } limit: { type: number, description: "Maximum alerts to fetch (default 100). The API serves 100 per call at most; above that the script pages until the limit is reached." }
# Left unfiltered, the call is bounded to the last 30 days: the alerts
# grid is not meant to be asked for a tenant's whole retention.
required: [] required: []
outputs_schema: { properties: {} } outputs_schema: { properties: {} }
ingest:
results_path: reply.alerts
dedup_key: internal_id
incremental_field: created_after
- id: insert_parsed_alerts - id: insert_parsed_alerts
name: cortex-xdr-insert-parsed-alerts name: cortex-xdr-insert-parsed-alerts
description: "Push external alerts (parsed JSON objects) into Cortex XDR for correlation." description: "Push external alerts (parsed JSON objects) into Cortex XDR for correlation."
@@ -0,0 +1,87 @@
name: "Cortex XDR Alerts → OCSF"
description: "Maps one Cortex XDR alert (alerts/get_alerts_by_filter_data/, results_path = reply.alerts) to OCSF Detection Finding fields. Field names are the alerts-grid ones (internal_id, alert_name, agent_hostname…), not the incident ones; the script unwraps the API's alert_fields envelope and derives severity_name and status_progress, which the raw payload cannot express. Alerts are the detection layer under incidents: a tenant ingesting both feeds holds each detection twice, once inside an aggregate and once on its own."
field_mappings:
title: "alert_name"
description: "alert_description"
# severity_name, not severity: the API sends an enum code (SEV_040_HIGH) that
# no severity scale can read, so the script carries the plain name alongside it.
severity: "severity_name"
# Which sensor fired: "XDR Agent", "PAN NGFW", "XDR Analytics"…
source: "alert_source"
# results_path = reply.alerts; source_path is JSONata over ONE alert object.
# Paths absent from a given alert are skipped at ingestion, so entries for fields
# a tenant never emits are safe. Where two entries target the same OCSF field,
# the LAST non-empty one wins — that is how the events[] fallbacks are ordered.
ocsf:
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "internal_id", ocsf_field: "finding_info.uid" }
- { source_path: "external_id", ocsf_field: "finding_info.uid_alt" }
- { source_path: "alert_name", ocsf_field: "finding_info.title" }
- { source_path: "alert_description", ocsf_field: "finding_info.desc" }
- { source_path: "source_insert_ts", ocsf_field: "finding_info.created_time" }
- { source_path: "local_insert_ts", ocsf_field: "finding_info.modified_time" }
- { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" }
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
# ── Detection time: the MTTD anchor ───────────────────────────────
# `time` is what Riposte measures detection-to-ingestion against. The grid
# exposes when the tenant took the alert in (source_insert_ts); a sensor-side
# detection timestamp, when the tenant sends one, is the better anchor and
# comes last so it wins.
- { source_path: "source_insert_ts", ocsf_field: "time" }
- { source_path: "detection_timestamp", ocsf_field: "time" }
# ── Alert state ───────────────────────────────────────────────────
- { source_path: "severity_name", ocsf_field: "severity" }
- { source_path: "alert_domain", ocsf_field: "activity_name" }
- { source_path: "alert_action_status", ocsf_field: "action" }
- { source_path: "status_progress", ocsf_field: "status" }
- { source_path: "matching_status", ocsf_field: "status_detail" }
- { source_path: "events_length", ocsf_field: "count" }
# The XDR case this alert was folded into — the join back to the incident feed.
- { source_path: "case_id", ocsf_field: "metadata.correlation_uid" }
# ── Product identity ──────────────────────────────────────────────
- { source_path: "'Cortex XDR'", ocsf_field: "metadata.product.name" }
- { source_path: "'Palo Alto Networks'", ocsf_field: "metadata.product.vendor_name" }
- { source_path: "alert_source", ocsf_field: "metadata.log_source" }
# ── MITRE ATT&CK ──────────────────────────────────────────────────
# Both fields arrive as a list on most tenants and as a bare string on some;
# [0] reads the first element either way.
- { source_path: "mitre_tactic_id_and_name[0]", ocsf_field: "attacks.tactic.name" }
- { source_path: "mitre_technique_id_and_name[0]", ocsf_field: "attacks.technique.name" }
# ── Affected endpoint ─────────────────────────────────────────────
- { source_path: "agent_hostname", ocsf_field: "device.hostname" }
- { source_path: "agent_ip_addresses[0]", ocsf_field: "device.ip" }
- { source_path: "agent_id", ocsf_field: "device.uid" }
- { source_path: "agent_os_type", ocsf_field: "device.os.type" }
# Mirrored onto src_endpoint so routers and pre-processing rules written for
# the incident feed (which maps hosts there) match alerts unchanged.
- { source_path: "agent_hostname", ocsf_field: "src_endpoint.hostname" }
- { source_path: "agent_ip_addresses[0]", ocsf_field: "src_endpoint.ip" }
- { source_path: "actor_effective_username", ocsf_field: "user.name" }
# ── What actually happened ────────────────────────────────────────
# Grid columns first, then the same detail read off the first event, which is
# where a tenant that does not flatten these columns puts them.
- { source_path: "actor_process_image_name", ocsf_field: "process.name" }
- { source_path: "actor_process_command_line", ocsf_field: "process.cmd_line" }
- { source_path: "actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
- { source_path: "causality_actor_process_command_line", ocsf_field: "process.parent_process.cmd_line" }
- { source_path: "action_file_path", ocsf_field: "file.path" }
- { source_path: "action_file_sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "action_file_md5", ocsf_field: "file.hashes.md5" }
- { source_path: "action_registry_key_name", ocsf_field: "reg_key.path" }
- { source_path: "action_registry_data", ocsf_field: "reg_value.data" }
- { source_path: "action_local_ip", ocsf_field: "src_endpoint.ip" }
- { source_path: "action_local_port", ocsf_field: "src_endpoint.port" }
- { source_path: "action_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "action_remote_port", ocsf_field: "dst_endpoint.port" }
- { source_path: "dst_action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
- { source_path: "events[0].actor_process_image_name", ocsf_field: "process.name" }
- { source_path: "events[0].actor_process_command_line", ocsf_field: "process.cmd_line" }
- { source_path: "events[0].actor_process_image_path", ocsf_field: "process.path" }
- { source_path: "events[0].actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
- { source_path: "events[0].causality_actor_process_image_name", ocsf_field: "process.parent_process.name" }
- { source_path: "events[0].action_file_path", ocsf_field: "file.path" }
- { source_path: "events[0].action_file_sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "events[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "events[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
- { source_path: "events[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
@@ -2,7 +2,10 @@ name: "Cortex XDR Incidents → OCSF"
description: "Maps a Cortex XDR incident (incidents/get_incidents/, results_path = reply.incidents) to OCSF finding fields. Incidents are aggregates; use get_incident_extra_data for per-alert detail." description: "Maps a Cortex XDR incident (incidents/get_incidents/, results_path = reply.incidents) to OCSF finding fields. Incidents are aggregates; use get_incident_extra_data for per-alert detail."
field_mappings: field_mappings:
title: "incident_name" title: "incident_name"
severity: "severity = 'critical' ? 5 : (severity = 'high' ? 4 : (severity = 'medium' ? 3 : 2))" # The raw string, not a ternary: the mapping engine compares numbers only, so
# every string test read as false and every incident landed on the rule's
# default severity. Riposte reads critical/high/medium/low onto 1-5 itself.
severity: "severity"
description: "description" description: "description"
# results_path = reply.incidents; source_path is JSONata over ONE incident object. # results_path = reply.incidents; source_path is JSONata over ONE incident object.
# Paths absent from a given incident are skipped at ingestion, so extra entries are safe. # Paths absent from a given incident are skipped at ingestion, so extra entries are safe.
@@ -15,6 +18,9 @@ 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
# Riposte ingested it. Without it the detection delay column stays empty.
- { 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) ──────────────
- { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" } - { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" }
@@ -0,0 +1,83 @@
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."
field_mappings:
# incident_name is null on most tenants (it is only set when someone renames
# the incident), and a mapping that resolves to nothing leaves the title to
# the incident type's fallback. description is the sentence XDR itself shows.
title: "description"
description: "description"
severity: "severity"
# incident_source, not incident_sources[0]: this mapper reads dotted paths and
# cannot index a list, so the script lifts the first sensor out for it.
source: "incident_source"
# results_path = reply.incidents; source_path is JSONata over ONE flattened
# incident. Paths absent from a given incident are skipped at ingestion, so
# entries for fields a tenant never emits are safe. Where two entries target the
# same OCSF field, the LAST non-empty one wins.
ocsf:
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "incident_id", ocsf_field: "finding_info.uid" }
- { source_path: "incident_name ? incident_name : description", ocsf_field: "finding_info.title" }
- { source_path: "description", ocsf_field: "finding_info.desc" }
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
# ── Detection time: the MTTD anchor ───────────────────────────────
# Weakest first, strongest last. detection_time is often null on an XDR
# incident, and then the first alert's own detection timestamp is the honest
# anchor; incident creation is the last resort.
- { source_path: "creation_time", ocsf_field: "time" }
- { source_path: "alerts[0].detection_timestamp", ocsf_field: "time" }
- { source_path: "detection_time", ocsf_field: "time" }
# ── Incident state ────────────────────────────────────────────────
- { source_path: "severity", ocsf_field: "severity" }
- { source_path: "status", ocsf_field: "status" }
- { source_path: "resolve_comment", ocsf_field: "status_detail" }
- { source_path: "alert_count", ocsf_field: "count" }
- { source_path: "aggregated_score", ocsf_field: "risk_score" }
- { source_path: "tags", ocsf_field: "metadata.labels" }
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
# ── Product identity ──────────────────────────────────────────────
- { source_path: "'Cortex XDR'", ocsf_field: "metadata.product.name" }
- { source_path: "'Palo Alto Networks'", ocsf_field: "metadata.product.vendor_name" }
- { source_path: "incident_sources[0]", ocsf_field: "metadata.log_source" }
# ── MITRE ATT&CK: the aggregate, else the first alert ─────────────
- { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" }
- { source_path: "mitre_techniques_ids_and_names[0]", ocsf_field: "attacks.technique.name" }
- { source_path: "alerts[0].mitre_tactic_id_and_name[0]", ocsf_field: "attacks.tactic.name" }
- { source_path: "alerts[0].mitre_technique_id_and_name[0]", ocsf_field: "attacks.technique.name" }
# ── Affected host / user ──────────────────────────────────────────
# An incident's hosts are 'hostname:agent_id' strings; an alert names them plainly.
- { source_path: "$split(hosts[0], ':')[0]", ocsf_field: "src_endpoint.hostname" }
- { source_path: "$split(hosts[0], ':')[0]", ocsf_field: "device.hostname" }
- { source_path: "alerts[0].host_name", ocsf_field: "src_endpoint.hostname" }
- { source_path: "alerts[0].host_name", ocsf_field: "device.hostname" }
- { source_path: "alerts[0].host_ip[0]", ocsf_field: "device.ip" }
- { source_path: "alerts[0].host_ip[0]", ocsf_field: "src_endpoint.ip" }
- { source_path: "alerts[0].endpoint_id", ocsf_field: "device.uid" }
- { source_path: "alerts[0].agent_os_type", ocsf_field: "device.os.type" }
- { source_path: "users[0]", ocsf_field: "user.name" }
- { source_path: "alerts[0].user_name", ocsf_field: "user.name" }
# ── What the first alert actually saw ─────────────────────────────
- { source_path: "alerts[0].actor_process_image_name", ocsf_field: "process.name" }
- { source_path: "alerts[0].actor_process_command_line", ocsf_field: "process.cmd_line" }
- { source_path: "alerts[0].actor_process_image_path", ocsf_field: "process.path" }
- { source_path: "alerts[0].actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
- { source_path: "alerts[0].causality_actor_process_image_name", ocsf_field: "process.parent_process.name" }
- { source_path: "alerts[0].action_file_path", ocsf_field: "file.path" }
- { source_path: "alerts[0].action_file_name", ocsf_field: "file.name" }
- { source_path: "alerts[0].action_file_sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "alerts[0].action_file_md5", ocsf_field: "file.hashes.md5" }
- { 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_external_hostname", ocsf_field: "dst_endpoint.hostname" }
# ── The artifact the incident is really about ─────────────────────
# Last, because a file artifact is the incident's verdict on the file, where
# 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_sha256", ocsf_field: "file.hashes.sha256" }
- { 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" }
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
+151 -17
View File
@@ -3,29 +3,61 @@ from datetime import datetime
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -44,19 +76,121 @@ def to_ms(v):
return None return None
# This endpoint speaks the alerts-grid dialect, NOT the incidents one: a body of
# {filters, search_from, search_to, sort} — what incidents/get_incidents/ takes —
# is answered with a bare HTTP 500. It wants request_data.filter_data with
# SEARCH_FIELD/SEARCH_TYPE/SEARCH_VALUE blocks, paging.from/to and a sort LIST.
# Shape taken from the reference client (demisto/content,
# Packs/ApiModules/Scripts/CoreIRApiModule — get_alerts_by_filter_command).
PAGE = 100
# Severity travels as an enum code both ways. Riposte reads plain names onto its
# 1-5 scale, so alerts carry `severity_name` alongside the raw code.
SEVERITY_CODE_TO_NAME = {
"SEV_010_INFO": "informational",
"SEV_020_LOW": "low",
"SEV_030_MEDIUM": "medium",
"SEV_040_HIGH": "high",
"SEV_050_CRITICAL": "critical",
}
SEVERITY_NAME_TO_CODE = dict((v, k) for k, v in SEVERITY_CODE_TO_NAME.items())
SEVERITY_NAME_TO_CODE["info"] = "SEV_010_INFO"
# Our clock and the tenant's are not the same clock. A range that ends exactly
# now silently drops alerts the tenant stamped a few seconds ahead of us.
SKEW_MS = 5 * 60 * 1000
# Window applied when the caller passes no filter at all — see main().
DEFAULT_LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000
def severity_block(value):
"""One EQ block per severity, OR'd together (the reference client's array rule)."""
blocks = [
{"SEARCH_FIELD": "severity", "SEARCH_TYPE": "EQ",
"SEARCH_VALUE": SEVERITY_NAME_TO_CODE.get(s.lower(), s.upper())}
for s in csv(value)
]
if not blocks:
return None
return blocks[0] if len(blocks) == 1 else {"OR": blocks}
def flatten(item):
"""One grid row -> one flat alert.
The API wraps every row as {alert_fields, incident_fields}. Mapping through
that wrapper would put an `alert_fields.` prefix on every expression an
operator writes, so the row is unwrapped here and the two fields Riposte
cannot express are derived: `status.progress` carries a dot INSIDE the key
(unusable as a mapping path) and severity is an enum code.
"""
fields = item.get("alert_fields")
alert = dict(fields) if isinstance(fields, dict) else dict(item)
alert.pop("incident_fields", None)
if "status.progress" in alert:
alert["status_progress"] = alert.pop("status.progress")
name = SEVERITY_CODE_TO_NAME.get(alert.get("severity"))
if name:
alert["severity_name"] = name
incident = item.get("incident_fields")
if isinstance(incident, dict):
alert["incident_fields"] = incident
return alert
def main(): def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
limit = int(inputs.get("limit") or 100) limit = max(1, int(inputs.get("limit") or 100))
filters = [] conditions = []
if inputs.get("severity"): sev = severity_block(inputs.get("severity"))
filters.append({"field": "severity", "operator": "in", "value": csv(inputs["severity"])}) if sev:
conditions.append(sev)
created_ms = to_ms(inputs.get("created_after")) created_ms = to_ms(inputs.get("created_after"))
if created_ms is not None: if created_ms is not None:
filters.append({"field": "source_insert_ts", "operator": "gte", "value": created_ms}) conditions.append({
rd = {"search_from": 0, "search_to": limit, "sort": {"field": "source_insert_ts", "keyword": "desc"}} "SEARCH_FIELD": "source_insert_ts",
if filters: "SEARCH_TYPE": "RANGE",
rd["filters"] = filters "SEARCH_VALUE": {"from": created_ms, "to": int(time.time() * 1000) + SKEW_MS},
print(json.dumps(post("/alerts/get_alerts_by_filter_data/", rd))) })
if not conditions:
# The reference client refuses a filterless query outright, and an
# unbounded scan of the whole alerts grid is not what the API is for.
# A recent window is a better default than an empty filter the tenant
# may well answer with a 500.
now_ms = int(time.time() * 1000)
conditions.append({
"SEARCH_FIELD": "source_insert_ts",
"SEARCH_TYPE": "RANGE",
"SEARCH_VALUE": {"from": now_ms - DEFAULT_LOOKBACK_MS, "to": now_ms + SKEW_MS},
})
# Oldest first on an incremental fetch, so that a window holding more alerts
# than `limit` drops its most RECENT ones — the only ones the next poll can
# still see. Newest first otherwise, which is what an operator running the
# command by hand is asking for.
order = "ASC" if created_ms is not None else "DESC"
alerts, truncated = [], False
while len(alerts) < limit:
rd = {"filter_data": {
"sort": [{"FIELD": "source_insert_ts", "ORDER": order}],
"paging": {"from": len(alerts), "to": min(len(alerts) + PAGE, limit)},
"filter": {"AND": conditions},
}}
reply = (post("/alerts/get_alerts_by_filter_data/", rd) or {}).get("reply") or {}
page = reply.get("alerts") or []
alerts.extend(flatten(a) for a in page)
if len(page) < PAGE:
break
# Stopped on the ceiling rather than on an exhausted window: whatever is
# left is not coming back on the next poll, and a silent cap reads like
# a quiet feed.
truncated = len(alerts) >= limit
out = {"result_count": len(alerts), "alerts": alerts}
if truncated:
out["truncated"] = True
print(json.dumps({"reply": out}))
try: try:
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -3,29 +3,61 @@ from datetime import datetime
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def to_ms(v): def to_ms(v):
@@ -0,0 +1,184 @@
import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.error
from datetime import datetime
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def to_ms(v):
if v in (None, ""):
return None
s = str(v)
if s.isdigit():
return int(s)
try:
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000)
except Exception:
return None
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
# incidents/get_incidents/ answers with a 21-field summary: no hosts, no users,
# no MITRE, and above all not one of the alerts the incident aggregates. This
# endpoint returns the same incident with 39 fields, its alerts in full (156
# fields each) and its file/network artifacts — which is why the reference
# client fetches through it and not through get_incidents (demisto/content,
# CortexXDRIR.get_multiple_incidents_extra_data).
PAGE = 50
# Artifacts are dropped by name, not by omission — the API only understands
# being told which blocks to leave out.
ARTIFACT_BLOCKS = ["network_artifacts", "file_artifacts"]
def flatten(item):
"""One record -> one incident.
Records arrive as {incident, alerts, network_artifacts, file_artifacts},
each nested block wrapped as {total_count, data}. Flattening the incident to
the top level keeps every expression written against get_incidents working
unchanged — the summary's 21 fields are a subset of these 39 — while the
alerts and artifacts land beside them as plain lists.
"""
incident = dict(item.get("incident") or {})
for key in ("alerts", "network_artifacts", "file_artifacts"):
block = item.get(key)
if not isinstance(block, dict):
continue
incident[key] = block.get("data") or []
if block.get("total_count") is not None:
# The tenant caps alerts per incident (50 by default), so the count
# says when the list is a sample rather than the whole set.
incident[key + "_total_count"] = block["total_count"]
# The producing sensor is a list here, and the incident-field mapper reads
# dotted paths only — no array indexing — so the first source is lifted out
# for it. The list itself stays, for expressions that can index.
sources = incident.get("incident_sources")
if isinstance(sources, list) and sources:
incident["incident_source"] = sources[0]
return incident
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
limit = max(1, int(inputs.get("limit") or 50))
filters = []
if inputs.get("status"):
statuses = csv(inputs["status"])
filters.append({"field": "status", "operator": "in", "value": statuses})
created_ms = to_ms(inputs.get("created_after"))
if created_ms is not None:
filters.append({"field": "creation_time", "operator": "gte", "value": created_ms})
# An XDR incident keeps growing after it is created: alerts join it, an
# analyst changes its status. Watermarking on creation_time fetches it once
# and never looks again, so everything that happened afterwards is lost.
# Watermarking on modification_time brings it back on every change, where
# dedup on incident_id turns the second visit into an enrichment.
modified_ms = to_ms(inputs.get("modified_after"))
if modified_ms is not None:
filters.append({"field": "modification_time", "operator": "gte", "value": modified_ms})
incremental = created_ms is not None or modified_ms is not None
# Oldest first on an incremental fetch, so that a window holding more
# incidents than `limit` drops its most RECENT ones — the only ones the next
# poll can still see. Newest first otherwise, for a hand-run command.
sort_field = "modification_time" if modified_ms is not None else "creation_time"
keyword = "asc" if incremental else "desc"
exclude = str(inputs.get("exclude_artifacts") or "").lower() in ("1", "true", "yes")
incidents, total = [], None
while len(incidents) < limit:
rd = {
"search_from": len(incidents),
"search_to": min(len(incidents) + PAGE, limit),
"sort": {"field": sort_field, "keyword": keyword},
# Without this the nested alerts come back trimmed to a handful of
# fields — the very thing this command exists to avoid.
"full_alert_fields": True,
}
if filters:
rd["filters"] = filters
if exclude:
rd["fields_to_exclude"] = ARTIFACT_BLOCKS
reply = (post("/incidents/get_multiple_incidents_extra_data/", rd) or {}).get("reply") or {}
page = reply.get("incidents") or []
if total is None:
total = reply.get("total_count")
incidents.extend(flatten(i) for i in page)
if len(page) < PAGE:
break
out = {"result_count": len(incidents), "incidents": incidents}
if total is not None:
out["total_count"] = total
# Say it when the window held more than the limit: those incidents are
# not coming back on the next poll.
out["truncated"] = total > len(incidents)
print(json.dumps({"reply": out}))
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)
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v): def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body): def post_raw(path, body):
base, headers = _client() base, headers = _client()
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(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None): def post(path, request_data=None):
@@ -2,39 +2,83 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
print(json.dumps(post("/distributions/get_versions/", {}))) # Same probe as the reference client: reading one incident proves the URL,
# the key pair and the "View Cases and Issues" right ingestion depends on.
reply = post("/incidents/get_incidents/", {"search_from": 0, "search_to": 1}).get("reply", {})
print(json.dumps({"ok": True, "total_count": reply.get("total_count")}))
try: try:
main() main()
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) detail = e.read().decode("utf-8", "replace")
if e.code in (401, 403):
# The two ways a valid-looking key still gets rejected: the wrong auth
# mode for the key, or a host clock too far from the tenant's.
detail += (
" — check auth_type matches how the key was created in Cortex XDR"
" (Standard vs Advanced), that the key has not been revoked, and that"
" this host's clock is in sync"
)
print(json.dumps({"error": "HTTP " + str(e.code), "detail": detail}))
sys.exit(1) sys.exit(1)
except Exception as e: except Exception as e:
print(json.dumps({"error": str(e)})) print(json.dumps({"error": str(e)}))
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client(): def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
base = s.get("url", "").rstrip("/") + "/public_api/v1" raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "") key = s.get("api_key", "")
kid = str(s.get("api_key_id", "")) kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"} if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced": if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest() headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else: else:
headers["Authorization"] = key headers["Authorization"] = key
return base, headers return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data): def post(path, request_data):
base, headers = _client() base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8") data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST") req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r: with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read() raw = r.read()
return json.loads(raw) if raw else {} if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main(): def main():
+305 -2
View File
@@ -1,8 +1,8 @@
id: harfanglab id: harfanglab
name: HarfangLab EDR name: HarfangLab EDR
version: 1.2.2 version: 1.4.1
description: "HarfangLab EDR — endpoint detection & response: alert/threat ingestion, endpoint enrichment, isolation, threat-intelligence (IOC/whitelist), telemetry hunting and forensic collection jobs." description: "HarfangLab EDR — endpoint detection & response: alert/threat ingestion, endpoint enrichment, isolation, threat-intelligence (IOC/whitelist), telemetry hunting and forensic collection jobs."
changelog: "1.2.2 — Documentation wording cleanup. 1.2.1 — Corrected the get_threats OCSF mapper to native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics) instead of enriched-only arrays; added agent.osversion to get_security_events. 1.2.0 — Added ingestion commands get_security_events (alerts) and get_threats with bundled OCSF mappers and ingest hints (results_path/dedup/incremental). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Command names prefixed with 'harfanglab-' (e.g. harfanglab-isolate-endpoint) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: endpoint/agent management, isolation, policy assignment, IOC & whitelist management, security-event triage, telemetry hunting (processes, network, DNS, authentications, binaries, event logs), threat hunting by hash, and forensic collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services, processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) with their result retrieval commands. Compatible with HarfangLab EDR 2.13.7+." changelog: "1.4.1 — get_alerts now maps image_name, the one process path present on every alert subtype and the only one on those carrying neither a process object nor an event log (yara, binary, ioc). HarfangLab prefixes it with the matched threat name in brackets, so both halves are split out: the clean path feeds actor.process.file.path and its basename actor.process.name, and the threat name feeds malware.name — which now surfaces on every subtype rather than only on Defender event-log detections. Each half is guarded on the bracket being present so an unprefixed path is never written into malware.name. 1.4.0 — Mapper coverage from real payloads: get_alerts now maps the Windows event-log payload sigma alerts carry (alert_subtype = eventlog), which was entirely unmapped — Defender 1116/1117 yields the malware name, classification, severity and provider, the detected file, the process that touched it and the acting user, plus the log provider and event code. Also added the agent IP, DNS domain, device type, agent version and groups, the tenant, the alert subtype and threat type, the string severity and the event time. get_threats gained the threat level (previously never reaching the OCSF document at all), the event time and the top agent's reachability. 1.3.1 — Flagged get_alerts as ingestion-ready (ingest hints: results_path/dedup/incremental on from_date) so it appears in the recommended ingestion commands when creating an instance. 1.3.0 — Response & triage expansion: kill/dump process, on-demand AV/YARA scans, file/service/scheduled-task deletion, quarantine management, threat-level isolation, job cancel/relaunch/wait; alert search (get_alerts) and full alert/threat triage (tag, comments, notes, status, level, history, details); exhaustive alert OCSF mappers; reworked get_threats mapper (fixed MITRE tactics map handling, full top-agent/user/rule and counts coverage, rule-based incident title/description). 1.2.2 — Documentation wording cleanup. 1.2.1 — Corrected the get_threats OCSF mapper to native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics) instead of enriched-only arrays; added agent.osversion to get_security_events. 1.2.0 — Added ingestion commands get_security_events (alerts) and get_threats with bundled OCSF mappers and ingest hints (results_path/dedup/incremental). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Command names prefixed with 'harfanglab-' (e.g. harfanglab-isolate-endpoint) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: endpoint/agent management, isolation, policy assignment, IOC & whitelist management, security-event triage, telemetry hunting (processes, network, DNS, authentications, binaries, event logs), threat hunting by hash, and forensic collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services, processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) with their result retrieval commands. Compatible with HarfangLab EDR 2.13.7+."
category: endpoint category: endpoint
# Per-instance configuration. Scripts use <url> as the API base and call /api/... paths. # Per-instance configuration. Scripts use <url> as the API base and call /api/... paths.
@@ -733,6 +733,309 @@ commands:
required: [job_id] required: [job_id]
outputs_schema: { properties: {} } outputs_schema: { properties: {} }
# ── Response: process ─────────────────────────────────────────────────────
- id: kill_process
name: harfanglab-kill-process
description: "Kill processes on an endpoint matching a criterion (name, path, pid, hash, command line...). Destructive: matching processes are terminated. Returns the created job; poll with harfanglab-wait-for-job."
risk: destructive
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
field: { type: string, description: "Criterion field: name, path, username, commandline, parent_name, parent_path, parent_commandline, pid, md5, sha1, sha256 (default name)" }
operator: { type: string, description: "Criterion operator: eq, not_eq, contains, not_contains (default eq)" }
value: { type: string, description: "Criterion value to match" }
required: [agent_id, value]
outputs_schema: { properties: {} }
- id: dump_process
name: harfanglab-dump-process
description: "Dump the memory of processes matching a criterion (same criteria as kill-process). Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
field: { type: string, description: "Criterion field: name, path, username, commandline, parent_name, parent_path, parent_commandline, pid, md5, sha1, sha256 (default name)" }
operator: { type: string, description: "Criterion operator: eq, not_eq, contains, not_contains (default eq)" }
value: { type: string, description: "Criterion value to match" }
required: [agent_id, value]
outputs_schema: { properties: {} }
# ── Response: scans ───────────────────────────────────────────────────────
- id: av_scan
name: harfanglab-av-scan
description: "Start an on-demand antivirus scan on an endpoint (full, quick, or resource with explicit paths). Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
scan_type: { type: string, description: "Scan type: full, quick or resource (default quick)" }
paths: { type: string, description: "Comma-separated paths to scan (required for resource scans)" }
required: [agent_id]
outputs_schema: { properties: {} }
- id: yara_scan
name: harfanglab-yara-scan
description: "Start a YARA scan on an endpoint using named YARA sources. Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
sources: { type: string, description: "Comma-separated YARA source names" }
directories: { type: string, description: "Comma-separated directories to scan" }
scan_filesystem: { type: boolean, description: "Scan the filesystem (default true)" }
scan_processes: { type: boolean, description: "Scan running processes (default false)" }
required: [agent_id]
outputs_schema: { properties: {} }
# ── Response: remediation ─────────────────────────────────────────────────
- id: delete_file
name: harfanglab-delete-file
description: "Delete a file on an endpoint by full path. Destructive. Returns the created job."
risk: destructive
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
filepath: { type: string, description: "Full path of the file to delete on the host" }
required: [agent_id, filepath]
outputs_schema: { properties: {} }
- id: delete_service
name: harfanglab-delete-service
description: "Delete a Windows service on an endpoint by service name. Destructive. Returns the created job."
risk: destructive
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
service_name: { type: string, description: "Name of the service to delete" }
required: [agent_id, service_name]
outputs_schema: { properties: {} }
- id: delete_scheduled_task
name: harfanglab-delete-scheduled-task
description: "Delete a scheduled task on an endpoint by task URI. Destructive. Returns the created job."
risk: destructive
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
schtask_uri: { type: string, description: "URI of the scheduled task to delete (e.g. \\Microsoft\\Windows\\...)" }
required: [agent_id, schtask_uri]
outputs_schema: { properties: {} }
# ── Response: quarantine ──────────────────────────────────────────────────
- id: quarantine_file
name: harfanglab-quarantine-file
description: "Move a file on an endpoint into HarfangLab quarantine by full path. Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
file_path: { type: string, description: "Full path of the file to quarantine on the host" }
comment: { type: string, description: "Quarantine comment" }
required: [agent_id, file_path]
outputs_schema: { properties: {} }
- id: unquarantine_file
name: harfanglab-unquarantine-file
description: "Restore a quarantined file to its original location on the endpoint. Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
local_id: { type: string, description: "Quarantined item local UUID (from harfanglab-quarantine-search)" }
overwrite_existing: { type: boolean, description: "Overwrite an existing file at the original path (default true)" }
required: [agent_id, local_id]
outputs_schema: { properties: {} }
- id: quarantine_search
name: harfanglab-quarantine-search
description: "Search quarantined files by hash, hostname or agent. Items include their id; once acquired on the manager the file is downloadable at /api/data/quarantine/item/{id}/download/."
risk: read
inputs_schema:
properties:
hash: { type: string, description: "File hash (md5, sha1 or sha256 — routed by length)" }
agent_hostname: { type: string, description: "Endpoint hostname" }
agent_id: { type: string, description: "Agent unique identifier" }
search: { type: string, description: "Free-text search term" }
limit: { type: number, description: "Max results (default 100)" }
offset: { type: number, description: "Pagination offset (default 0)" }
required: []
outputs_schema: { properties: {} }
- id: quarantine_request_upload
name: harfanglab-quarantine-request-upload
description: "Request upload of a quarantined file from the endpoint to the manager (returns the upload job). Once done, download at /api/data/quarantine/item/{id}/download/."
risk: safe_write
inputs_schema:
properties:
item_id: { type: string, description: "Quarantined item UUID (from harfanglab-quarantine-search)" }
required: [item_id]
outputs_schema: { properties: {} }
# ── Response: threat isolation & job lifecycle ────────────────────────────
- id: isolate_from_threat
name: harfanglab-isolate-from-threat
description: "Network-isolate ALL agents involved in a threat. Destructive: cuts network access on every impacted endpoint."
risk: destructive
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
required: [threat_id]
outputs_schema: { properties: {} }
- id: deisolate_from_threat
name: harfanglab-deisolate-from-threat
description: "Remove network isolation from all agents involved in a threat."
risk: safe_write
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
required: [threat_id]
outputs_schema: { properties: {} }
- id: job_cancel
name: harfanglab-job-cancel
description: "Cancel a running job by job ID."
risk: safe_write
inputs_schema:
properties:
job_id: { type: string, description: "Job ID to cancel" }
required: [job_id]
outputs_schema: { properties: {} }
- id: job_relaunch
name: harfanglab-job-relaunch
description: "Relaunch a cancelled or errored job by job ID."
risk: safe_write
inputs_schema:
properties:
job_id: { type: string, description: "Job ID to relaunch" }
required: [job_id]
outputs_schema: { properties: {} }
- id: wait_for_job
name: harfanglab-wait-for-job
description: "Poll a job until all its instances finish (done/error/canceled) or the timeout elapses. Returns {done: true|false, job: {...}} — never fails on timeout, so playbooks can branch on done."
risk: read
inputs_schema:
properties:
job_id: { type: string, description: "Job ID to wait for" }
timeout: { type: number, description: "Max seconds to wait (default 300)" }
interval: { type: number, description: "Seconds between polls (default 10)" }
required: [job_id]
outputs_schema: { properties: {} }
# ── Triage: alerts ────────────────────────────────────────────────────────
- id: get_alerts
name: harfanglab-get-alerts
description: "Search security events (alerts) for playbooks: by IDs, hostname, agent, threat, rule name, level/status/type, date range. Returns {count, results}."
risk: read
inputs_schema:
properties:
alert_ids: { type: string, description: "Comma-separated alert IDs (fetched individually; other filters ignored)" }
hostname: { type: string, description: "Endpoint hostname" }
agent_id: { type: string, description: "Agent unique identifier" }
threat_id: { type: string, description: "Threat key — alerts aggregated under this threat" }
rule_name: { type: string, description: "Detection rule name" }
level: { type: string, description: "Comma-separated severities (low, medium, high, critical)" }
status: { type: string, description: "Comma-separated statuses (new, investigating, closed, false_positive)" }
alert_type: { type: string, description: "Comma-separated alert types (sigma, yara, hlai, vt, ransom, ioc...)" }
from_date: { type: string, description: "ISO8601 lower bound on alert_time" }
to_date: { type: string, description: "ISO8601 upper bound on alert_time" }
search: { type: string, description: "Free-text search term" }
ordering: { type: string, description: "Sort field (default -alert_time)" }
limit: { type: number, description: "Max results (default 100)" }
offset: { type: number, description: "Pagination offset (default 0)" }
required: []
outputs_schema: { properties: {} }
ingest:
results_path: results
dedup_key: id
incremental_field: from_date
- id: alert_tag
name: harfanglab-alert-tag
description: "Change the status of one or more alerts in bulk (new, investigating, false_positive, closed), optionally with a comment."
risk: safe_write
inputs_schema:
properties:
alert_ids: { type: string, description: "Comma-separated alert IDs" }
new_status: { type: string, description: "new | investigating | false_positive | closed" }
new_comment: { type: string, description: "Optional comment attached to the status change" }
required: [alert_ids, new_status]
outputs_schema: { properties: {} }
- id: alert_comment
name: harfanglab-alert-comment
description: "Add a comment to a security event (alert)."
risk: safe_write
inputs_schema:
properties:
alert_id: { type: string, description: "Alert ID" }
comment: { type: string, description: "Comment text" }
required: [alert_id, comment]
outputs_schema: { properties: {} }
- id: alert_details
name: harfanglab-alert-details
description: "Get the full details of an alert (raw Sigma/YARA rule, binary availability, process unique id...)."
risk: read
inputs_schema:
properties:
alert_id: { type: string, description: "Alert ID" }
required: [alert_id]
outputs_schema: { properties: {} }
- id: alert_history
name: harfanglab-alert-history
description: "Get the status-change history of an alert."
risk: read
inputs_schema:
properties:
alert_id: { type: string, description: "Alert ID" }
required: [alert_id]
outputs_schema: { properties: {} }
# ── Triage: threats ───────────────────────────────────────────────────────
- id: get_threat
name: harfanglab-get-threat
description: "Get a single threat by ID."
risk: read
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
required: [threat_id]
outputs_schema: { properties: {} }
- id: threat_status
name: harfanglab-threat-status
description: "Change the status of one or more threats (new, investigating, false_positive, closed), optionally propagating to their security events."
risk: safe_write
inputs_schema:
properties:
threat_ids: { type: string, description: "Comma-separated threat IDs" }
new_status: { type: string, description: "new | investigating | false_positive | closed" }
tag_security_events: { type: boolean, description: "Also apply the status to the threat's security events (default false)" }
required: [threat_ids, new_status]
outputs_schema: { properties: {} }
- id: threat_level
name: harfanglab-threat-level
description: "Change the severity level of one or more threats (low, medium, high, critical)."
risk: safe_write
inputs_schema:
properties:
threat_ids: { type: string, description: "Comma-separated threat IDs" }
new_level: { type: string, description: "low | medium | high | critical" }
required: [threat_ids, new_level]
outputs_schema: { properties: {} }
- id: threat_comment
name: harfanglab-threat-comment
description: "Add a comment to a threat."
risk: safe_write
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
comment: { type: string, description: "Comment text" }
required: [threat_id, comment]
outputs_schema: { properties: {} }
- id: threat_note_set
name: harfanglab-threat-note-set
description: "Create or update the analyst note of a threat (title + content). Upserts: creates the note, or replaces it if one exists."
risk: safe_write
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
title: { type: string, description: "Note title" }
content: { type: string, description: "Note content" }
required: [threat_id, title, content]
outputs_schema: { properties: {} }
- id: threat_history
name: harfanglab-threat-history
description: "Get the status-change history of a threat."
risk: read
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
required: [threat_id]
outputs_schema: { properties: {} }
# ── Connectivity test ───────────────────────────────────────────────────── # ── Connectivity test ─────────────────────────────────────────────────────
- id: test_connection - id: test_connection
name: harfanglab-test-connection name: harfanglab-test-connection
@@ -0,0 +1,136 @@
name: "HarfangLab Alerts → OCSF"
description: "Maps a HarfangLab EDR alert (/api/data/alert/alert/Alert/, results_path = results) to OCSF. The matched process is the actor (actor.*). Detail sub-objects and the eventlog payload are only present for their event kind; absent paths are skipped at ingestion."
field_mappings:
title: "rule_name"
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
description: "alert_type"
# results_path = results; source_path is JSONata over ONE alert object.
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
ocsf:
# ── Endpoint ──────────────────────────────────────────────────────
- { source_path: "agent.hostname", ocsf_field: "src_endpoint.hostname" }
- { source_path: "agent.domainname", ocsf_field: "src_endpoint.domain" }
# Before details_connection.SourceIp below, so a network alert's own source
# address wins over the agent's; on every other alert this is the only IP.
- { source_path: "agent.ipaddress", ocsf_field: "src_endpoint.ip" }
- { source_path: "agent.osproducttype", ocsf_field: "src_endpoint.os.name" }
- { source_path: "agent.ostype", ocsf_field: "src_endpoint.os.type" }
- { source_path: "agent.osversion", ocsf_field: "device.os.version" }
- { source_path: "agent.agentid", ocsf_field: "device.uid" }
- { source_path: "agent.ipaddress", ocsf_field: "device.ip" }
- { source_path: "agent.dnsdomainname", ocsf_field: "device.domain" }
- { source_path: "agent.producttype", ocsf_field: "device.type" } # workstation | server
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "alert_unique_id", ocsf_field: "metadata.uid" }
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
- { source_path: "msg", ocsf_field: "finding_info.desc" }
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
- { source_path: "alert_time", ocsf_field: "time" }
- { source_path: "detection_date", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
- { source_path: "ingestion_date", ocsf_field: "metadata.logged_time" }
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "rule_id", ocsf_field: "finding_info.analytic.uid" }
- { source_path: "rule_content", ocsf_field: "finding_info.analytic.desc" }
- { source_path: "detection_origin", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alert_subtype", ocsf_field: "unmapped.alert_subtype" } # eventlog | connection | dns | file | registry | url_request
- { source_path: "status", ocsf_field: "status" }
- { source_path: "maturity", ocsf_field: "status_detail" }
- { source_path: "threat_type", ocsf_field: "unmapped.threat_type" }
- { source_path: "level", ocsf_field: "severity" } # low | medium | high | critical
- { source_path: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
- { source_path: "agent.version", ocsf_field: "unmapped.agent_version" }
# groups is an array of {id, name}; the [ ] keeps an array shape when a single
# group matches, since JSONata collapses a singleton sequence to a scalar.
- { source_path: "groups ? [groups.name]", ocsf_field: "unmapped.agent_groups" }
- { source_path: "score", ocsf_field: "risk_score" }
- { source_path: "confidence_int", ocsf_field: "confidence_score" }
- { source_path: "threat_key", ocsf_field: "finding_info.uid_alt" } # threat aggregation key
- { source_path: "mitre_cells", ocsf_field: "attacks" } # ["TAxxxx:Tyyyy", ...] best-effort
- { source_path: "tags", ocsf_field: "metadata.labels" }
- { source_path: "log_type", ocsf_field: "metadata.log_name" }
# ── Matched process — the actor ───────────────────────────────────
# image_name is the one process path present on EVERY alert subtype, and the
# only one on those carrying neither a `process` object nor an event log. When
# a threat matched, HarfangLab prefixes it with the threat name in brackets —
# "[PUADlManager:Win32/OfferCore] C:\Windows\explorer.exe" — so both halves are
# pulled out, each guarded on the bracket actually being there: without the
# $contains guard, $substringBefore/$substringAfter fall through on an
# unprefixed path and write the whole path into malware.name.
# These come first in the block so the more specific sources below (the matched
# process object, then the event log's own fields) overwrite them when present.
- { source_path: "$contains(image_name, '] ') ? $substringAfter(image_name, '] ') : image_name", ocsf_field: "actor.process.file.path" }
- { source_path: "$split($contains(image_name, '] ') ? $substringAfter(image_name, '] ') : image_name, '\\\\')[-1]", ocsf_field: "actor.process.name" }
- { source_path: "$contains(image_name, '[') ? $substringBefore($substringAfter(image_name, '['), ']')", ocsf_field: "malware.name" }
- { source_path: "process.process_name", ocsf_field: "actor.process.name" }
- { source_path: "process.image_name", ocsf_field: "actor.process.file.path" }
- { source_path: "process.commandline", ocsf_field: "actor.process.cmd_line" }
- { source_path: "process.username", ocsf_field: "actor.user.name" }
- { source_path: "process.integrity_level", ocsf_field: "actor.process.integrity" }
- { source_path: "process.pid", ocsf_field: "actor.process.pid" }
- { source_path: "process.hashes.sha256", ocsf_field: "actor.process.file.hashes.sha256" }
- { source_path: "process.hashes.sha1", ocsf_field: "actor.process.file.hashes.sha1" }
- { source_path: "process.hashes.md5", ocsf_field: "actor.process.file.hashes.md5" }
- { source_path: "process.parent_image", ocsf_field: "actor.process.parent_process.name" }
- { source_path: "process.parent_commandline", ocsf_field: "actor.process.parent_process.cmd_line" }
# ── Event details: network connection ─────────────────────────────
- { source_path: "details_connection.SourceIp", ocsf_field: "src_endpoint.ip" }
- { source_path: "details_connection.SourcePort", ocsf_field: "src_endpoint.port" }
- { source_path: "details_connection.DestinationIp", ocsf_field: "dst_endpoint.ip" }
- { source_path: "details_connection.DestinationPort", ocsf_field: "dst_endpoint.port" }
- { source_path: "details_connection.Protocol", ocsf_field: "connection_info.protocol_name" }
# ── Event details: DNS ────────────────────────────────────────────
- { source_path: "details_dns_resolution.requested_name", ocsf_field: "query.hostname" }
- { source_path: "details_dns_resolution.query_type", ocsf_field: "query.type" }
# ── Event details: file ───────────────────────────────────────────
- { source_path: "details_file.target_filename", ocsf_field: "file.path" }
# ── Event details: registry ───────────────────────────────────────
- { source_path: "details_registry.target_object", ocsf_field: "reg_key.path" }
- { source_path: "details_registry.registry_value_type", ocsf_field: "reg_value.type" }
- { source_path: "details_registry.data_string_added", ocsf_field: "reg_value.data" }
# ── Event details: URL request ────────────────────────────────────
- { source_path: "details_url_request.url", ocsf_field: "url.url_string" }
- { source_path: "details_url_request.host", ocsf_field: "url.hostname" }
- { source_path: "details_url_request.user_agent", ocsf_field: "http_request.user_agent" }
- { source_path: "details_url_request.verb", ocsf_field: "http_request.http_method" }
# ── Event details: Windows event log (alert_subtype = eventlog) ───
# A sigma rule matching a Windows event log carries the whole record under
# `eventlog`, and the provider's own field names — spaces included — under
# eventlog.event_data. Backticks quote those names in JSONata. The block below
# is Microsoft Defender (events 1116/1117), where the detection names the
# malware, the file it was found in, the process that touched it and the user
# it ran as. None of it exists on other subtypes, so it is skipped there.
- { source_path: "eventlog.source_name", ocsf_field: "metadata.log_provider" }
- { source_path: "eventlog.event_id", ocsf_field: "metadata.event_code" }
- { source_path: "eventlog.event_data.`Product Name`", ocsf_field: "metadata.product.name" }
- { source_path: "eventlog.event_data.`Product Version`", ocsf_field: "metadata.product.version" }
- { source_path: "eventlog.event_data.`Threat Name`", ocsf_field: "malware.name" }
- { source_path: "eventlog.event_data.`Threat ID`", ocsf_field: "malware.uid" }
- { source_path: "eventlog.event_data.`Severity Name`", ocsf_field: "malware.severity" }
- { source_path: "eventlog.event_data.`Product Name`", ocsf_field: "malware.provider" }
- { source_path: "eventlog.event_data.`Category Name` ? [eventlog.event_data.`Category Name`]", ocsf_field: "malware.classifications" }
# Defender prefixes the detected path with "file:_". $substringAfter returns the
# whole string when the prefix is absent, so stripping it is safe either way.
- { source_path: "$substringAfter(eventlog.event_data.`Path`, 'file:_')", ocsf_field: "file.path" }
- { source_path: "$substringAfter(eventlog.event_data.`Path`, 'file:_')", ocsf_field: "malware.path" }
- { source_path: "$split($substringAfter(eventlog.event_data.`Path`, 'file:_'), '\\\\')[-1]", ocsf_field: "file.name" }
- { source_path: "eventlog.event_data.`Process Name`", ocsf_field: "actor.process.file.path" }
- { source_path: "$split(eventlog.event_data.`Process Name`, '\\\\')[-1]", ocsf_field: "actor.process.name" }
- { source_path: "eventlog.event_data.`Detection User`", ocsf_field: "actor.user.name" }
- { source_path: "eventlog.event_data.`Action Name`", ocsf_field: "action" }
- { source_path: "eventlog.event_data.`Detection ID`", ocsf_field: "unmapped.defender.detection_uid" }
- { source_path: "eventlog.event_data.`Source Name`", ocsf_field: "unmapped.defender.detection_source" } # real-time protection, scheduled scan…
- { source_path: "eventlog.event_data.`Execution Name`", ocsf_field: "unmapped.defender.execution_state" }
- { source_path: "eventlog.event_data.`Security intelligence Version`", ocsf_field: "unmapped.defender.signature_version" }
# Alert fields deliberately left unmapped: `username` (actor.user.name is taken by the
# more specific process.username), remaining details_* sub-objects (no clean OCSF home),
# eventlog.threat_process_name (the same prefixed string as image_name, already split
# above), `eventlog.user` (the account that WROTE the
# record, usually SYSTEM — indexing it would create a junk user entity),
# `eventlog.computer_name` (the FQDN of the host already carried by agent.hostname; two
# spellings of one machine split correlation), `level_int` / `quarantine` / `execution`
# and Defender's Action ID / Severity ID / Category ID (console enums on scales OCSF does
# not share), `detection_timestamp` and Defender's Detection Time (duplicates of
# detection_date), `confidence` (the string form of confidence_int, already mapped), and
# Defender's Error/Status/Origin/Remediation/Unused fields.
@@ -15,13 +15,26 @@ ocsf:
- { source_path: "agent.osversion", ocsf_field: "device.os.version" } - { source_path: "agent.osversion", ocsf_field: "device.os.version" }
- { source_path: "agent.agentid", ocsf_field: "device.uid" } - { source_path: "agent.agentid", ocsf_field: "device.uid" }
# ── Finding ─────────────────────────────────────────────────────── # ── Finding ───────────────────────────────────────────────────────
- { source_path: "id", ocsf_field: "finding_info.uid" } - { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "rule_name", ocsf_field: "finding_info.title" } - { source_path: "alert_unique_id", ocsf_field: "metadata.uid" }
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" } - { source_path: "rule_name", ocsf_field: "finding_info.title" }
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" } - { source_path: "msg", ocsf_field: "finding_info.desc" }
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" } - { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
- { source_path: "status", ocsf_field: "status" } - { source_path: "detection_date", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "maturity", ocsf_field: "status_detail" } - { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
- { source_path: "ingestion_date", ocsf_field: "metadata.logged_time" }
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "rule_id", ocsf_field: "finding_info.analytic.uid" }
- { source_path: "rule_content", ocsf_field: "finding_info.analytic.desc" }
- { source_path: "detection_origin", ocsf_field: "finding_info.analytic.category" }
- { source_path: "status", ocsf_field: "status" }
- { source_path: "maturity", ocsf_field: "status_detail" }
- { source_path: "score", ocsf_field: "risk_score" }
- { source_path: "confidence_int", ocsf_field: "confidence_score" }
- { source_path: "threat_key", ocsf_field: "finding_info.uid_alt" } # threat aggregation key
- { source_path: "mitre_cells", ocsf_field: "attacks" } # ["TAxxxx:Tyyyy", ...] best-effort
- { source_path: "tags", ocsf_field: "metadata.labels" }
- { source_path: "log_type", ocsf_field: "metadata.log_name" }
# ── Matched process — the actor ─────────────────────────────────── # ── Matched process — the actor ───────────────────────────────────
- { source_path: "process.process_name", ocsf_field: "actor.process.name" } - { source_path: "process.process_name", ocsf_field: "actor.process.name" }
- { source_path: "process.image_name", ocsf_field: "actor.process.file.path" } - { source_path: "process.image_name", ocsf_field: "actor.process.file.path" }
@@ -34,3 +47,25 @@ ocsf:
- { source_path: "process.hashes.md5", ocsf_field: "actor.process.file.hashes.md5" } - { source_path: "process.hashes.md5", ocsf_field: "actor.process.file.hashes.md5" }
- { source_path: "process.parent_image", ocsf_field: "actor.process.parent_process.name" } - { source_path: "process.parent_image", ocsf_field: "actor.process.parent_process.name" }
- { source_path: "process.parent_commandline", ocsf_field: "actor.process.parent_process.cmd_line" } - { source_path: "process.parent_commandline", ocsf_field: "actor.process.parent_process.cmd_line" }
# ── Event details: network connection ─────────────────────────────
- { source_path: "details_connection.SourceIp", ocsf_field: "src_endpoint.ip" }
- { source_path: "details_connection.SourcePort", ocsf_field: "src_endpoint.port" }
- { source_path: "details_connection.DestinationIp", ocsf_field: "dst_endpoint.ip" }
- { source_path: "details_connection.DestinationPort", ocsf_field: "dst_endpoint.port" }
- { source_path: "details_connection.Protocol", ocsf_field: "connection_info.protocol_name" }
# ── Event details: DNS ────────────────────────────────────────────
- { source_path: "details_dns_resolution.requested_name", ocsf_field: "query.hostname" }
- { source_path: "details_dns_resolution.query_type", ocsf_field: "query.type" }
# ── Event details: file ───────────────────────────────────────────
- { source_path: "details_file.target_filename", ocsf_field: "file.path" }
# ── Event details: registry ───────────────────────────────────────
- { source_path: "details_registry.target_object", ocsf_field: "reg_key.path" }
- { source_path: "details_registry.registry_value_type", ocsf_field: "reg_value.type" }
- { source_path: "details_registry.data_string_added", ocsf_field: "reg_value.data" }
# ── Event details: URL request ────────────────────────────────────
- { source_path: "details_url_request.url", ocsf_field: "url.url_string" }
- { source_path: "details_url_request.host", ocsf_field: "url.hostname" }
- { source_path: "details_url_request.user_agent", ocsf_field: "http_request.user_agent" }
- { source_path: "details_url_request.verb", ocsf_field: "http_request.http_method" }
# Alert fields deliberately left unmapped: `username` (actor.user.name is taken by the
# more specific process.username), remaining details_* sub-objects (no clean OCSF home).
@@ -1,9 +1,9 @@
name: "HarfangLab Threats → OCSF" name: "HarfangLab Threats → OCSF"
description: "Maps a raw HarfangLab EDR threat (/api/data/alert/alert/Threat/, results_path = results) to OCSF. Uses native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics); the agents/impacted_users/rules arrays are only populated by external enrichment, which this connector does not perform." description: "Maps a raw HarfangLab EDR threat (/api/data/alert/alert/Threat/, results_path = results) to OCSF. Uses native threat fields; top_agents/top_impacted_users/top_rules are ranked aggregates — the first (most impacted) entry feeds the endpoint/user/rule fields. mitre_tactics is a map keyed by tactic slug (e.g. attack.persistence); its keys are extracted with $keys()."
field_mappings: field_mappings:
title: "slug" title: "top_rules[0].rule_name ? top_rules[0].rule_name & ' — ' & slug : slug"
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))" severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
description: "status" description: "top_rules[0].rule_msg ? top_rules[0].rule_msg : status"
# results_path = results; source_path is JSONata over ONE threat object. # results_path = results; source_path is JSONata over ONE threat object.
# Paths absent from a given threat are skipped at ingestion, so extra entries are safe. # Paths absent from a given threat are skipped at ingestion, so extra entries are safe.
ocsf: ocsf:
@@ -11,13 +11,54 @@ ocsf:
- { source_path: "id", ocsf_field: "finding_info.uid" } - { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "slug", ocsf_field: "finding_info.title" } - { source_path: "slug", ocsf_field: "finding_info.title" }
- { source_path: "creation_date", ocsf_field: "finding_info.created_time" } - { source_path: "creation_date", ocsf_field: "finding_info.created_time" }
- { source_path: "creation_date", ocsf_field: "time" }
- { source_path: "first_seen", ocsf_field: "finding_info.first_seen_time" } - { source_path: "first_seen", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "last_seen", ocsf_field: "finding_info.last_seen_time" } - { source_path: "last_seen", ocsf_field: "finding_info.last_seen_time" }
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" } # x ? x guards: these are null on open/mono-tenant threats — null is falsy in
- { source_path: "status", ocsf_field: "status" } # JSONata, so the condition yields undefined and the entry is skipped instead
# ── MITRE ATT&CK (native mitre_tactics list) ────────────────────── # of writing a literal null into the OCSF document.
- { source_path: "mitre_tactics[0]", ocsf_field: "attacks.tactic.name" } - { source_path: "closed_date ? closed_date", ocsf_field: "end_time" }
# ── Top impacted endpoint / user / rule (native aggregates) ─────── - { source_path: "status", ocsf_field: "status" }
- { source_path: "top_agents[0].agent_hostname", ocsf_field: "src_endpoint.hostname" } # field_mappings.severity turns this into the incident's 1-5 number; the string
# itself has no other home, so without this entry the threat's own level never
# reaches the OCSF document at all.
- { source_path: "level", ocsf_field: "severity" } # low | medium | high | critical
- { source_path: "linked_threat ? linked_threat", ocsf_field: "finding_info.uid_alt" }
- { source_path: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
# ── Aggregate counts ──────────────────────────────────────────────
- { source_path: "total_security_event_count", ocsf_field: "finding_info.related_events_count" }
- { source_path: "agent_count", ocsf_field: "unmapped.agent_count" }
- { source_path: "impacted_user_count", ocsf_field: "unmapped.impacted_user_count" }
- { source_path: "rule_count", ocsf_field: "unmapped.rule_count" }
# ── MITRE ATT&CK — mitre_tactics is a MAP {"attack.persistence": {...}} ──
# [$keys(...)] keeps an array shape even for a single tactic (JSONata collapses
# singleton sequences to a scalar); the guard skips threats without tactics.
- { source_path: "mitre_tactics ? [$keys(mitre_tactics)]", ocsf_field: "attacks" }
# ── Top impacted endpoint (most security events first) ────────────
- { source_path: "top_agents[0].agent_hostname", ocsf_field: "src_endpoint.hostname" }
- { source_path: "top_agents[0].agent_id", ocsf_field: "device.uid" }
- { source_path: "top_agents[0].agent_osproducttype", ocsf_field: "src_endpoint.os.name" }
- { source_path: "top_agents[0].agent_ostype", ocsf_field: "src_endpoint.os.type" }
- { source_path: "top_agents[0].agent_osversion", ocsf_field: "device.os.version" }
# Whether the agent is still reachable decides what response is even possible
# (isolate, collect, kill) — the first thing asked of a threat, so it travels
# with the endpoint rather than being left to the console.
- { source_path: "top_agents[0].agent_status", ocsf_field: "unmapped.agent_status" }
# ── Top impacted user ─────────────────────────────────────────────
- { source_path: "top_impacted_users[0].user_name", ocsf_field: "user.name" } - { source_path: "top_impacted_users[0].user_name", ocsf_field: "user.name" }
- { source_path: "top_rules[0].rule_name", ocsf_field: "rule.name" } - { source_path: "top_impacted_users[0].user_sid", ocsf_field: "user.uid" }
# ── Top rule ──────────────────────────────────────────────────────
- { source_path: "top_rules[0].rule_name", ocsf_field: "rule.name" }
- { source_path: "top_rules[0].rule_id", ocsf_field: "rule.uid" }
- { source_path: "top_rules[0].rule_type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "top_rules[0].rule_msg", ocsf_field: "finding_info.desc" }
# Deliberately unmapped: old_id/origin_stack (legacy/console internals),
# top_rules[0].id (numeric console id — rule_id is the stable uid, mapped above),
# per-item security_event_count (equals the total while agent_count is 1, and the total
# is already mapped), top_rules[0].rule_level (the same value as the threat's own level,
# mapped to severity above), rule_os (the same value as the agent's ostype), the rule's
# creation_date/last_update (rule bookkeeping, not incident evidence),
# mitre_tactics[*].security_event_percentage (the tactic names are what pivot; the
# per-tactic weights have no OCSF home), top_rules[0].description (duplicate of rule_msg),
# and entries [1..] of the top_* arrays (OCSF detection_finding models a single primary
# endpoint/user/rule; counts convey scale).
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/comment/"
print(json.dumps(request("POST", url, headers, {"comment": inputs.get("comment")})))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/details/"
print(json.dumps(request("GET", url, headers)))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/history/"
print(json.dumps(request("GET", url, headers)))
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,41 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"ids": csv(inputs.get("alert_ids")),
"new_status": inputs.get("new_status"),
}
if inputs.get("new_comment"):
body["new_comment"] = inputs.get("new_comment")
print(json.dumps(request("POST", base + "/api/data/alert/alert/Alert/tag/", headers, body)))
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,47 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.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():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
scan_type = str(inputs.get("scan_type") or "quick").lower()
if scan_type not in ("full", "quick", "resource"):
print(json.dumps({"error": "invalid scan_type: " + scan_type + " (expected full, quick or resource)"}))
sys.exit(1)
params = {"type": scan_type}
paths = csv(inputs.get("paths"))
if paths:
params["paths"] = paths
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "avScan", "params": params}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/deisolation/"
print(json.dumps(request("POST", url, headers)))
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,35 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "filepathDeleter", "params": [{"filepath": inputs.get("filepath")}]}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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,35 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "deleteScheduledTask", "params": [{"schtask_uri": inputs.get("schtask_uri")}]}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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,35 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "deleteService", "params": [{"service_name": inputs.get("service_name")}]}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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,55 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
FIELD_TYPES = {
"name": 0, "path": 1, "username": 3, "commandline": 4,
"parent_name": 10, "parent_path": 11, "parent_commandline": 14,
"pid": 15, "md5": 16, "sha1": 17, "sha256": 18,
}
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
field = str(inputs.get("field") or "name").lower()
operator = str(inputs.get("operator") or "eq").lower()
if field not in FIELD_TYPES:
print(json.dumps({"error": "invalid field: " + field + " (expected one of " + ", ".join(sorted(FIELD_TYPES)) + ")"}))
sys.exit(1)
if operator not in OPERATORS:
print(json.dumps({"error": "invalid operator: " + operator + " (expected one of eq, not_eq, contains, not_contains)"}))
sys.exit(1)
criteria = [{
"param_type": FIELD_TYPES[field],
"param_operator": OPERATORS[operator],
"param_value": str(inputs.get("value") or ""),
}]
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "searchProcessDumper", "params": {"values": criteria}}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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,66 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def qs(params):
clean = {}
for k, v in params.items():
if v is None or v == "" or v == []:
continue
if isinstance(v, bool):
v = "true" if v else "false"
clean[k] = v
return ("?" + urllib.parse.urlencode(clean)) if clean else ""
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
ids = csv(inputs.get("alert_ids"))
if ids:
results = [request("GET", base + "/api/data/alert/alert/Alert/" + i + "/", headers) for i in ids]
print(json.dumps({"count": len(results), "results": results}))
return
params = {
"ordering": inputs.get("ordering") or "-alert_time",
"limit": int(inputs.get("limit") or 100),
"offset": int(inputs.get("offset") or 0),
"search": inputs.get("search"),
"agent.hostname": inputs.get("hostname"),
"agent.agentid": inputs.get("agent_id"),
"threat_key": inputs.get("threat_id"),
"rule_name": inputs.get("rule_name"),
"level": ",".join(csv(inputs.get("level"))) or None,
"status": ",".join(csv(inputs.get("status"))) or None,
"alert_type": ",".join(csv(inputs.get("alert_type"))) or None,
"alert_time__gte": inputs.get("from_date"),
"alert_time__lte": inputs.get("to_date"),
}
print(json.dumps(request("GET", base + "/api/data/alert/alert/Alert/" + qs(params), headers)))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/"
print(json.dumps(request("GET", url, headers)))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/isolation/"
print(json.dumps(request("POST", url, headers)))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/Job/" + str(inputs.get("job_id")) + "/cancel/"
print(json.dumps(request("POST", url, headers)))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/Job/" + str(inputs.get("job_id")) + "/relaunch/"
print(json.dumps(request("POST", url, headers)))
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,55 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
FIELD_TYPES = {
"name": 0, "path": 1, "username": 3, "commandline": 4,
"parent_name": 10, "parent_path": 11, "parent_commandline": 14,
"pid": 15, "md5": 16, "sha1": 17, "sha256": 18,
}
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
field = str(inputs.get("field") or "name").lower()
operator = str(inputs.get("operator") or "eq").lower()
if field not in FIELD_TYPES:
print(json.dumps({"error": "invalid field: " + field + " (expected one of " + ", ".join(sorted(FIELD_TYPES)) + ")"}))
sys.exit(1)
if operator not in OPERATORS:
print(json.dumps({"error": "invalid operator: " + operator + " (expected one of eq, not_eq, contains, not_contains)"}))
sys.exit(1)
criteria = [{
"param_type": FIELD_TYPES[field],
"param_operator": OPERATORS[operator],
"param_value": str(inputs.get("value") or ""),
}]
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "wildcardProcessFinderKiller", "params": {"values": criteria}}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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,38 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
params = {"values": [{"file_path": inputs.get("file_path")}]}
if inputs.get("comment"):
params["comment"] = inputs.get("comment")
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "quarantineAdd", "params": params}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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,33 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
item_id = inputs.get("item_id")
url = base + "/api/data/quarantine/item/" + str(item_id) + "/request_upload/"
print(json.dumps(request("POST", url, headers)))
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 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def qs(params):
clean = {}
for k, v in params.items():
if v is None or v == "" or v == []:
continue
if isinstance(v, bool):
v = "true" if v else "false"
clean[k] = v
return ("?" + urllib.parse.urlencode(clean)) if clean else ""
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
params = {
"limit": int(inputs.get("limit") or 100),
"offset": int(inputs.get("offset") or 0),
"search": inputs.get("search"),
"agent.hostname": inputs.get("agent_hostname"),
"agent.id": inputs.get("agent_id"),
}
h = str(inputs.get("hash") or "").strip().lower()
if h:
key = {32: "item_md5", 40: "item_sha1", 64: "item_sha256"}.get(len(h))
if not key:
print(json.dumps({"error": "unrecognized hash length (expected md5, sha1 or sha256)"}))
sys.exit(1)
params[key] = h
out = request("GET", base + "/api/data/quarantine/item/" + qs(params), headers)
print(json.dumps(out))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/comment/"
print(json.dumps(request("POST", url, headers, {"comment": inputs.get("comment")})))
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,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/history/"
print(json.dumps(request("GET", url, headers)))
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,40 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"threat_ids": csv(inputs.get("threat_ids")),
"new_level": inputs.get("new_level"),
"update_by_query": False,
}
print(json.dumps(request("PATCH", base + "/api/data/alert/alert/Threat/level/", headers, body)))
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,36 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/note/"
body = {"title": inputs.get("title"), "content": inputs.get("content")}
try:
print(json.dumps(request("POST", url, headers, body)))
except urllib.error.HTTPError:
print(json.dumps(request("PUT", url, headers, body)))
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,41 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"threat_ids": csv(inputs.get("threat_ids")),
"new_status": inputs.get("new_status"),
"update_by_query": False,
"tag_security_events": bool(inputs.get("tag_security_events", False)),
}
print(json.dumps(request("PATCH", base + "/api/data/alert/alert/Threat/status/", headers, body)))
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,38 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "quarantineRestore", "params": [{"values": [{
"local_id": inputs.get("local_id"),
"overwrite_existing": bool(inputs.get("overwrite_existing", True)),
}]}]}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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,45 @@
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
job_id = str(inputs.get("job_id"))
timeout = int(inputs.get("timeout") or 300)
interval = max(1, int(inputs.get("interval") or 10))
deadline = time.time() + timeout
job = {}
finished = False
while True:
job = request("GET", base + "/api/data/Job/" + job_id + "/", headers)
total = int(job.get("instance") or 0)
settled = int(job.get("done") or 0) + int(job.get("error") or 0) + int(job.get("canceled") or 0)
finished = total > 0 and settled >= total
if finished or time.time() >= deadline:
break
time.sleep(interval)
print(json.dumps({"done": finished, "job": job}))
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,49 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=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)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.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():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
params = {
"scanFilesystem": bool(inputs.get("scan_filesystem", True)),
"scanProcesses": bool(inputs.get("scan_processes", False)),
}
sources = csv(inputs.get("sources"))
if sources:
params["sources"] = sources
directories = csv(inputs.get("directories"))
if directories:
params["directoriesToScan"] = directories
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "yaraScan", "params": params}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
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)
+52 -9
View File
@@ -1,15 +1,15 @@
id: ipinfo id: ipinfo
name: IPinfo name: IPinfo
version: 1.0.1 version: 1.1.0
description: "IPinfo Core API — IP address enrichment: geolocation, ASN / network details, and privacy/network flags (hosting, mobile, anycast, satellite)." description: "IPinfo — IP address enrichment. Lite API (all plans, unlimited): ASN / AS name / AS domain / country / continent. Core API (paid Core plan): geolocation, network details and privacy flags (hosting, mobile, anycast, satellite)."
changelog: "1.0.1 — Re-publish to regenerate form-based command scripts (fixes 'script not found' on install). 1.0.0 — Initial release: IP lookup, single-field lookup, own IP, batch, and connectivity test." changelog: "1.1.0 — Lite API support for accounts on the free/Lite plan: lite_lookup_ip, lite_lookup_me and lite_batch (POST /batch/lite, chunked at 1000 IPs). test_connection now calls /lite/me, which every plan can reach, so the Test button no longer fails on Lite-only tokens. Core commands renamed in their description to state the Core plan requirement. 1.0.1 — Re-publish to regenerate form-based command scripts (fixes 'script not found' on install). 1.0.0 — Initial release: IP lookup, single-field lookup, own IP, batch, and connectivity test."
category: enrichment category: enrichment
config_schema: config_schema:
properties: properties:
base_url: base_url:
type: string type: string
description: IPinfo Core API base URL description: "IPinfo API base URL (shared by the Lite and Core endpoints)"
default: https://api.ipinfo.io default: https://api.ipinfo.io
token: token:
type: string type: string
@@ -28,9 +28,50 @@ auth:
secret_field: token secret_field: token
commands: commands:
# ── Lite API — available on every plan, no daily/monthly quota ─────────────
- id: lite_lookup_ip
name: ipinfo-lite-lookup-ip
description: "Lite enrichment for an IP — ASN, AS name, AS domain, country and continent. Works on every plan, including free/Lite."
risk: read
inputs_schema:
properties:
ip: { type: string, description: "IPv4 or IPv6 address" }
required: [ip]
outputs_schema: { properties: {} }
request:
method: GET
path: /lite/{ip}
auth_ref: apikey
- id: lite_lookup_me
name: ipinfo-lite-lookup-me
description: "Lite details for the IP address making the request (ASN, country, continent)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
request:
method: GET
path: /lite/me
auth_ref: apikey
# Code-first: POST /batch/lite takes a raw JSON array body, which the
# form-based generator (object body) cannot produce. See scripts/lite_batch.py.
- id: lite_batch
name: ipinfo-lite-batch
description: "Lite lookup for many IPs in one call. Chunked at the API limit of 1000 IPs per request; returns one object keyed by IP."
risk: read
inputs_schema:
properties:
ips: { type: string, description: "Comma-separated IP addresses, e.g. '8.8.8.8,1.1.1.1'" }
required: [ips]
outputs_schema: { properties: {} }
# ── Core API — requires a paid Core plan (403 on Lite/free tokens) ─────────
- id: lookup_ip - id: lookup_ip
name: ipinfo-lookup-ip name: ipinfo-lookup-ip
description: Full enrichment for an IP — geolocation, ASN/network, and privacy/network flags. description: "Core plan required. Full enrichment for an IP — geolocation, ASN/network, and privacy/network flags."
risk: read risk: read
inputs_schema: inputs_schema:
properties: properties:
@@ -44,7 +85,7 @@ commands:
- id: lookup_field - id: lookup_field
name: ipinfo-lookup-field name: ipinfo-lookup-field
description: A single field for an IP (e.g. city, country, hostname, as). description: "Core plan required. A single field for an IP (e.g. city, country, hostname, as)."
risk: read risk: read
inputs_schema: inputs_schema:
properties: properties:
@@ -59,7 +100,7 @@ commands:
- id: lookup_me - id: lookup_me
name: ipinfo-lookup-me name: ipinfo-lookup-me
description: Details for the IP address making the request. description: "Core plan required. Details for the IP address making the request."
risk: read risk: read
inputs_schema: inputs_schema:
properties: {} properties: {}
@@ -74,7 +115,7 @@ commands:
# form-based generator (object body) cannot produce. See scripts/batch.py. # form-based generator (object body) cannot produce. See scripts/batch.py.
- id: batch - id: batch
name: ipinfo-batch name: ipinfo-batch
description: Look up multiple IPs (or IP/field paths) in a single request. description: "Core plan required. Look up multiple IPs (or IP/field paths) in a single request."
risk: read risk: read
inputs_schema: inputs_schema:
properties: properties:
@@ -82,6 +123,8 @@ commands:
required: [ips] required: [ips]
outputs_schema: { properties: {} } outputs_schema: { properties: {} }
# Uses the Lite endpoint on purpose: it validates the token on every plan,
# whereas /lookup/me returns 403 for Lite-only accounts.
- id: test_connection - id: test_connection
name: ipinfo-test-connection name: ipinfo-test-connection
description: "Verify connectivity and credentials (used by the Test button)." description: "Verify connectivity and credentials (used by the Test button)."
@@ -92,5 +135,5 @@ commands:
outputs_schema: { properties: {} } outputs_schema: { properties: {} }
request: request:
method: GET method: GET
path: /lookup/me path: /lite/me
auth_ref: apikey auth_ref: apikey
+48
View File
@@ -0,0 +1,48 @@
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.ipinfo.io").rstrip("/")
# IPinfo caps a single Lite batch call at 1000 addresses.
CHUNK = 1000
def post_chunk(url, ips):
# POST /batch/lite expects a raw JSON array of IPs, not an object.
data = json.dumps(ips).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def main():
token = SECRETS.get("token", "")
seen = set()
ips = []
for x in str(INPUTS.get("ips", "")).split(","):
x = x.strip()
if x and x not in seen:
seen.add(x)
ips.append(x)
if not ips:
print(json.dumps({"error": "ips is required"}))
sys.exit(1)
url = BASE + "/batch/lite?" + urllib.parse.urlencode({"token": token})
result = {}
for i in range(0, len(ips), CHUNK):
result.update(post_chunk(url, ips[i:i + CHUNK]))
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)
+10 -2
View File
@@ -1,8 +1,8 @@
id: sentinelone id: sentinelone
name: SentinelOne name: SentinelOne
version: 1.3.0 version: 1.7.1
description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan." description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan."
changelog: "1.3.0 — Exhaustive OCSF mappers: get_threats (38 fields) and get_alerts (58 fields) now cover device, finding, malware, actor/target process, file, registry, network, indicators and container. 1.2.4 — Re-modelled the OCSF mappers to OCSF actor/target semantics: the initiating process maps to actor.* (actor.process, actor.user), and the process/file acted upon maps to the target (process.*, file.*, user.*). 1.2.3 — Expanded the OCSF mappers (get_threats, get_alerts) with more fields: file hashes (sha1/md5), file ext/size, finding/rule ids, confidence, status, parent process and device/user identity. 1.2.2 — Added pre-built OCSF mappers (get_threats, get_alerts) for the mapper library. 1.2.1 — Added test_connection for the instance Test button. 1.2.0 — Added 13 commands: threat-analysis, threat-download-from-cloud, abort-endpoint-scan, endpoint-fetch-logs, fetch-file, get-remote-script-task-status/results, get-service-users, list-installed-singularity-marketplace-applications, update-uam-alert-status/verdict, run-powerquery and create-tag-rule (83 commands total). 1.1.0 — Command names prefixed with 'sentinelone-' (e.g. sentinelone-isolate-agent) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: 70 commands covering agents, threats, alerts, blocklist/exclusions, IOCs, STAR rules, Deep Visibility, remote scripts, tags, firewall and network discovery based on the SentinelOne API v2.1." changelog: "1.7.1 — count_agents' isActive filter is a text field again: declared as a boolean it rendered as a checkbox, which has no empty state, so the count could never be asked for both the active and the inactive agents. Left empty the filter is not sent. 1.7.0 — count_agents takes an accountIds filter, so a multi-tenant console can be counted one account at a time. 1.6.0 — count_agents takes an isActive filter, to count the active agents apart from the inactive ones; left unset, the count still covers both. 1.5.0 — count_agents takes four more filters: agentVersion__gt to count the fleet still below a target build, networkStatuses to count only the endpoints in a given connection state, operationalStatesNin to leave states out of the count, and machineTypes to count servers apart from laptops. 1.4.0 — get_threats and get_alerts accept an optional account_ids filter (accountIds), so a multi-tenant console can be scoped to one or more accounts at fetch time. 1.3.0 — Exhaustive OCSF mappers: get_threats (38 fields) and get_alerts (58 fields) now cover device, finding, malware, actor/target process, file, registry, network, indicators and container. 1.2.4 — Re-modelled the OCSF mappers to OCSF actor/target semantics: the initiating process maps to actor.* (actor.process, actor.user), and the process/file acted upon maps to the target (process.*, file.*, user.*). 1.2.3 — Expanded the OCSF mappers (get_threats, get_alerts) with more fields: file hashes (sha1/md5), file ext/size, finding/rule ids, confidence, status, parent process and device/user identity. 1.2.2 — Added pre-built OCSF mappers (get_threats, get_alerts) for the mapper library. 1.2.1 — Added test_connection for the instance Test button. 1.2.0 — Added 13 commands: threat-analysis, threat-download-from-cloud, abort-endpoint-scan, endpoint-fetch-logs, fetch-file, get-remote-script-task-status/results, get-service-users, list-installed-singularity-marketplace-applications, update-uam-alert-status/verdict, run-powerquery and create-tag-rule (83 commands total). 1.1.0 — Command names prefixed with 'sentinelone-' (e.g. sentinelone-isolate-agent) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: 70 commands covering agents, threats, alerts, blocklist/exclusions, IOCs, STAR rules, Deep Visibility, remote scripts, tags, firewall and network discovery based on the SentinelOne API v2.1."
category: endpoint category: endpoint
# Per-instance configuration. The scripts build the API base as <url>/web/api/v2.1. # Per-instance configuration. The scripts build the API base as <url>/web/api/v2.1.
@@ -41,6 +41,7 @@ commands:
query: { type: string, description: "Free-text (hash, file, computer name, uuid)" } query: { type: string, description: "Free-text (hash, file, computer name, uuid)" }
threat_ids: { type: string, description: "Comma-separated threat IDs" } threat_ids: { type: string, description: "Comma-separated threat IDs" }
created_after: { type: string, description: "ISO8601 lower bound on createdAt" } created_after: { type: string, description: "ISO8601 lower bound on createdAt" }
account_ids: { type: string, description: "Optional comma-separated account IDs to scope the search to." }
required: [] required: []
outputs_schema: { properties: {} } outputs_schema: { properties: {} }
ingest: ingest:
@@ -186,6 +187,12 @@ commands:
scan_status: { type: string, description: "Filter by scan status" } scan_status: { type: string, description: "Filter by scan status" }
siteIds: { type: string, description: "Comma-separated site IDs" } siteIds: { type: string, description: "Comma-separated site IDs" }
groupIds: { type: string, description: "Comma-separated group IDs" } groupIds: { type: string, description: "Comma-separated group IDs" }
accountIds: { type: string, description: "Comma-separated account IDs, to scope the count to one or more accounts" }
agentVersion__gt: { type: string, description: "Only agents on a version strictly greater than this one, e.g. 23.4.2.6" }
networkStatuses: { type: string, description: "Comma-separated network statuses: connected | connecting | disconnected | disconnecting" }
operationalStatesNin: { type: string, description: "Comma-separated operational states to exclude, e.g. na" }
machineTypes: { type: string, description: "Comma-separated machine types: desktop | laptop | server | kubernetes node | storage | unknown" }
isActive: { type: string, description: "true to count only active agents, false for only the inactive ones; leave empty to count both" }
required: [] required: []
outputs_schema: { properties: {} } outputs_schema: { properties: {} }
- id: get_agent_mac - id: get_agent_mac
@@ -289,6 +296,7 @@ commands:
analystVerdict: { type: string, description: "Filter by analyst verdict." } analystVerdict: { type: string, description: "Filter by analyst verdict." }
alert_ids: { type: string, description: "Comma-separated alert IDs." } alert_ids: { type: string, description: "Comma-separated alert IDs." }
site_ids: { type: string, description: "Comma-separated site IDs." } site_ids: { type: string, description: "Comma-separated site IDs." }
account_ids: { type: string, description: "Optional comma-separated account IDs to scope the search to." }
limit: { type: number, description: "Max results (default 100)." } limit: { type: number, description: "Max results (default 100)." }
required: [created_from] required: [created_from]
outputs_schema: { properties: {} } outputs_schema: { properties: {} }
@@ -13,6 +13,10 @@ 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()]
TRUE_WORDS = {"true", "yes", "1"}
FALSE_WORDS = {"false", "no", "0"}
def main(): def main():
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", "{}"))
@@ -29,7 +33,26 @@ def main():
"scan_status": inputs.get("scan_status"), "scan_status": inputs.get("scan_status"),
"siteIds": inputs.get("siteIds"), "siteIds": inputs.get("siteIds"),
"groupIds": inputs.get("groupIds"), "groupIds": inputs.get("groupIds"),
# Scopes the count to one or more accounts of a multi-tenant console.
"accountIds": ",".join(csv(inputs.get("accountIds"))),
# Agents strictly newer than the given version, e.g. 23.4.2.6 — the console
# answers with the fleet still trailing behind a target build.
"agentVersion__gt": inputs.get("agentVersion__gt"),
# Comma-separated lists are re-joined so a hand-typed "connected, disconnected"
# does not reach the API with the space inside the value.
"networkStatuses": ",".join(csv(inputs.get("networkStatuses"))),
"operationalStatesNin": ",".join(csv(inputs.get("operationalStatesNin"))),
"machineTypes": ",".join(csv(inputs.get("machineTypes"))),
} }
# Text rather than a checkbox, which has no empty state: left blank the filter
# is not sent at all and the count covers active and inactive agents alike.
# A value we cannot read is refused rather than folded into false, which would
# answer a different question than the one asked.
active = str(inputs.get("isActive", "")).strip().lower()
if active:
if active not in TRUE_WORDS | FALSE_WORDS:
raise ValueError("isActive must be true or false, got: " + repr(inputs["isActive"]))
qs["isActive"] = "true" if active in TRUE_WORDS else "false"
url = base + "/agents/count?" + urllib.parse.urlencode({k: v for k, v in qs.items() if v not in (None, "")}) url = base + "/agents/count?" + urllib.parse.urlencode({k: v for k, v in qs.items() if v not in (None, "")})
print(json.dumps(request("GET", url, headers))) print(json.dumps(request("GET", url, headers)))
# === END === # === END ===
@@ -31,6 +31,7 @@ def main():
"analystVerdict": inputs.get("analystVerdict"), "analystVerdict": inputs.get("analystVerdict"),
"ids": inputs.get("alert_ids"), "ids": inputs.get("alert_ids"),
"siteIds": inputs.get("site_ids"), "siteIds": inputs.get("site_ids"),
"accountIds": inputs.get("account_ids"),
"limit": int(inputs.get("limit") or 100), "limit": int(inputs.get("limit") or 100),
} }
url = base + "/cloud-detection/alerts?" + urllib.parse.urlencode({k: v for k, v in qs.items() if v not in (None, "")}) url = base + "/cloud-detection/alerts?" + urllib.parse.urlencode({k: v for k, v in qs.items() if v not in (None, "")})
@@ -27,6 +27,8 @@ def main():
qs["ids"] = str(inputs["threat_ids"]) qs["ids"] = str(inputs["threat_ids"])
if inputs.get("created_after"): if inputs.get("created_after"):
qs["createdAt__gt"] = str(inputs["created_after"]) qs["createdAt__gt"] = str(inputs["created_after"])
if inputs.get("account_ids"):
qs["accountIds"] = str(inputs["account_ids"])
url = base + "/threats?" + urllib.parse.urlencode(qs) url = base + "/threats?" + urllib.parse.urlencode(qs)
print(json.dumps(request("GET", url, headers))) print(json.dumps(request("GET", url, headers)))