Compare commits
18 Commits
a4b5b44157
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf273b959a | |||
| ba68d19e51 | |||
| a6e1245c79 | |||
| d06f8ea413 | |||
| 17318d4225 | |||
| c22982c8af | |||
| 1d475a6cf5 | |||
| 93309ac74d | |||
| a5095d3f0f | |||
| cebb3af796 | |||
| ba6bcd19f4 | |||
| aabe1a6837 | |||
| 50358ef8a1 | |||
| 82f22fdbb3 | |||
| f7c5e99cc5 | |||
| 6343a0c952 | |||
| 7efc873867 | |||
| 144089a099 |
@@ -0,0 +1,3 @@
|
|||||||
|
name: "Cortex XDR Alert"
|
||||||
|
color: "#ef8354"
|
||||||
|
icon: "alert"
|
||||||
@@ -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" }
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 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):
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id: harfanglab
|
id: harfanglab
|
||||||
name: HarfangLab EDR
|
name: HarfangLab EDR
|
||||||
version: 1.3.0
|
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.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+."
|
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.
|
||||||
@@ -931,6 +931,10 @@ commands:
|
|||||||
offset: { type: number, description: "Pagination offset (default 0)" }
|
offset: { type: number, description: "Pagination offset (default 0)" }
|
||||||
required: []
|
required: []
|
||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
|
ingest:
|
||||||
|
results_path: results
|
||||||
|
dedup_key: id
|
||||||
|
incremental_field: from_date
|
||||||
- id: alert_tag
|
- id: alert_tag
|
||||||
name: harfanglab-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."
|
description: "Change the status of one or more alerts in bulk (new, investigating, false_positive, closed), optionally with a comment."
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: "HarfangLab Alerts → OCSF"
|
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 are only present for their event kind; absent paths are skipped at ingestion."
|
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:
|
field_mappings:
|
||||||
title: "rule_name"
|
title: "rule_name"
|
||||||
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
||||||
@@ -10,16 +10,23 @@ ocsf:
|
|||||||
# ── Endpoint ──────────────────────────────────────────────────────
|
# ── Endpoint ──────────────────────────────────────────────────────
|
||||||
- { source_path: "agent.hostname", ocsf_field: "src_endpoint.hostname" }
|
- { source_path: "agent.hostname", ocsf_field: "src_endpoint.hostname" }
|
||||||
- { source_path: "agent.domainname", ocsf_field: "src_endpoint.domain" }
|
- { 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.osproducttype", ocsf_field: "src_endpoint.os.name" }
|
||||||
- { source_path: "agent.ostype", ocsf_field: "src_endpoint.os.type" }
|
- { source_path: "agent.ostype", ocsf_field: "src_endpoint.os.type" }
|
||||||
- { 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" }
|
||||||
|
- { 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 ───────────────────────────────────────────────────────
|
# ── Finding ───────────────────────────────────────────────────────
|
||||||
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
||||||
- { source_path: "alert_unique_id", ocsf_field: "metadata.uid" }
|
- { source_path: "alert_unique_id", ocsf_field: "metadata.uid" }
|
||||||
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
|
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
|
||||||
- { source_path: "msg", ocsf_field: "finding_info.desc" }
|
- { source_path: "msg", ocsf_field: "finding_info.desc" }
|
||||||
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
|
- { 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: "detection_date", ocsf_field: "finding_info.first_seen_time" }
|
||||||
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
|
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
|
||||||
- { source_path: "ingestion_date", ocsf_field: "metadata.logged_time" }
|
- { source_path: "ingestion_date", ocsf_field: "metadata.logged_time" }
|
||||||
@@ -27,8 +34,16 @@ ocsf:
|
|||||||
- { source_path: "rule_id", ocsf_field: "finding_info.analytic.uid" }
|
- { source_path: "rule_id", ocsf_field: "finding_info.analytic.uid" }
|
||||||
- { source_path: "rule_content", ocsf_field: "finding_info.analytic.desc" }
|
- { source_path: "rule_content", ocsf_field: "finding_info.analytic.desc" }
|
||||||
- { source_path: "detection_origin", ocsf_field: "finding_info.analytic.category" }
|
- { 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: "status", ocsf_field: "status" }
|
||||||
- { source_path: "maturity", ocsf_field: "status_detail" }
|
- { 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: "score", ocsf_field: "risk_score" }
|
||||||
- { source_path: "confidence_int", ocsf_field: "confidence_score" }
|
- { source_path: "confidence_int", ocsf_field: "confidence_score" }
|
||||||
- { source_path: "threat_key", ocsf_field: "finding_info.uid_alt" } # threat aggregation key
|
- { source_path: "threat_key", ocsf_field: "finding_info.uid_alt" } # threat aggregation key
|
||||||
@@ -36,6 +51,18 @@ ocsf:
|
|||||||
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
||||||
- { source_path: "log_type", ocsf_field: "metadata.log_name" }
|
- { source_path: "log_type", ocsf_field: "metadata.log_name" }
|
||||||
# ── Matched process — the actor ───────────────────────────────────
|
# ── 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.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" }
|
||||||
- { source_path: "process.commandline", ocsf_field: "actor.process.cmd_line" }
|
- { source_path: "process.commandline", ocsf_field: "actor.process.cmd_line" }
|
||||||
@@ -67,5 +94,43 @@ ocsf:
|
|||||||
- { source_path: "details_url_request.host", ocsf_field: "url.hostname" }
|
- { 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.user_agent", ocsf_field: "http_request.user_agent" }
|
||||||
- { source_path: "details_url_request.verb", ocsf_field: "http_request.http_method" }
|
- { 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
|
# 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).
|
# 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.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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" }
|
||||||
# x ? x guards: these are null on open/mono-tenant threats — null is falsy in
|
# x ? x guards: these are null on open/mono-tenant threats — null is falsy in
|
||||||
@@ -18,6 +19,10 @@ ocsf:
|
|||||||
# of writing a literal null into the OCSF document.
|
# of writing a literal null into the OCSF document.
|
||||||
- { source_path: "closed_date ? closed_date", ocsf_field: "end_time" }
|
- { source_path: "closed_date ? closed_date", ocsf_field: "end_time" }
|
||||||
- { source_path: "status", ocsf_field: "status" }
|
- { source_path: "status", ocsf_field: "status" }
|
||||||
|
# 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: "linked_threat ? linked_threat", ocsf_field: "finding_info.uid_alt" }
|
||||||
- { source_path: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
|
- { source_path: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
|
||||||
# ── Aggregate counts ──────────────────────────────────────────────
|
# ── Aggregate counts ──────────────────────────────────────────────
|
||||||
@@ -35,6 +40,10 @@ ocsf:
|
|||||||
- { source_path: "top_agents[0].agent_osproducttype", ocsf_field: "src_endpoint.os.name" }
|
- { 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_ostype", ocsf_field: "src_endpoint.os.type" }
|
||||||
- { source_path: "top_agents[0].agent_osversion", ocsf_field: "device.os.version" }
|
- { 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 ─────────────────────────────────────────────
|
# ── 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_impacted_users[0].user_sid", ocsf_field: "user.uid" }
|
- { source_path: "top_impacted_users[0].user_sid", ocsf_field: "user.uid" }
|
||||||
@@ -43,8 +52,13 @@ ocsf:
|
|||||||
- { source_path: "top_rules[0].rule_id", ocsf_field: "rule.uid" }
|
- { 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_type", ocsf_field: "finding_info.analytic.type" }
|
||||||
- { source_path: "top_rules[0].rule_msg", ocsf_field: "finding_info.desc" }
|
- { source_path: "top_rules[0].rule_msg", ocsf_field: "finding_info.desc" }
|
||||||
# Deliberately unmapped: old_id/origin_stack (legacy/console internals), agent_status,
|
# Deliberately unmapped: old_id/origin_stack (legacy/console internals),
|
||||||
# top_rules[0].id (numeric console id — rule_id is the stable uid, mapped above),
|
# top_rules[0].id (numeric console id — rule_id is the stable uid, mapped above),
|
||||||
# per-item security_event_count, rule_os/rule_level/rule creation dates (no OCSF home),
|
# per-item security_event_count (equals the total while agent_count is 1, and the total
|
||||||
# top_rules[0].description (duplicate of rule_msg), and entries [1..] of the top_* arrays
|
# is already mapped), top_rules[0].rule_level (the same value as the threat's own level,
|
||||||
# (OCSF detection_finding models a single primary endpoint/user/rule; counts convey scale).
|
# 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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)))
|
||||||
|
|||||||
Reference in New Issue
Block a user