Compare commits
39 Commits
9cffefbb36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf273b959a | |||
| ba68d19e51 | |||
| a6e1245c79 | |||
| d06f8ea413 | |||
| 17318d4225 | |||
| c22982c8af | |||
| 1d475a6cf5 | |||
| 93309ac74d | |||
| a5095d3f0f | |||
| cebb3af796 | |||
| ba6bcd19f4 | |||
| aabe1a6837 | |||
| 50358ef8a1 | |||
| 82f22fdbb3 | |||
| f7c5e99cc5 | |||
| 6343a0c952 | |||
| 7efc873867 | |||
| 144089a099 | |||
| a4b5b44157 | |||
| 4ea3af6c1d | |||
| 952dc7efda | |||
| cba746a9f9 | |||
| 5d6f823189 | |||
| 098b90d6f3 | |||
| db902c35bc | |||
| 59e9211c0d | |||
| fcbd2ff135 | |||
| ef60cec0fa | |||
| 6d993f584f | |||
| 6d311740f9 | |||
| f6b8c84285 | |||
| 5ee7352652 | |||
| 534748f16a | |||
| a2c9f998c3 | |||
| ae7c494658 | |||
| 40252dadcb | |||
| ac2203f9a5 | |||
| 8248c60da8 | |||
| 4206b44e78 |
@@ -84,7 +84,7 @@ commands:
|
||||
outputs_schema: { properties: {} }
|
||||
- id: search_alerts
|
||||
name: cbc-search-alerts
|
||||
description: "Search alerts."
|
||||
description: "Search alerts. Used for ingestion: results path = results."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
@@ -92,6 +92,9 @@ commands:
|
||||
rows: { type: number, description: "Max alerts (default 50)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
ingest:
|
||||
results_path: results
|
||||
dedup_key: id
|
||||
- id: ban_hash
|
||||
name: cbc-ban-hash
|
||||
description: "Ban a file by SHA-256 hash (reputation deny-list override)."
|
||||
@@ -119,3 +122,8 @@ commands:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
ingestion:
|
||||
command: search_alerts
|
||||
mapper: search_alerts
|
||||
default_incident_type: "Carbon Black Cloud Alert"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: "VMware Carbon Black Cloud Alerts → OCSF"
|
||||
description: "Exhaustive map of a Carbon Black Cloud Alert Search v7 alert (POST /api/alerts/v7/orgs/{org_key}/alerts/_search, results_path = results) to OCSF. Field paths validated against the CBC Platform Alerts v7 API. The alerting process = actor (actor.*); the child/target process spawned as a result = process.*; parent lineage under actor.process.parent_process.*."
|
||||
field_mappings:
|
||||
title: "reason"
|
||||
severity: "severity >= 9 ? 5 : (severity >= 7 ? 4 : (severity >= 5 ? 3 : (severity >= 3 ? 2 : 1)))"
|
||||
description: "type"
|
||||
# results_path = results; source_path is JSONata over ONE alert object.
|
||||
# Paths absent from a given alert (e.g. no childproc.* on a non-process alert type) are
|
||||
# skipped at ingestion, so extra entries are safe.
|
||||
ocsf:
|
||||
# ── Finding / alert identity ───────────────────────────────────────
|
||||
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
||||
- { source_path: "reason", ocsf_field: "finding_info.title" }
|
||||
- { source_path: "type", ocsf_field: "finding_info.desc" }
|
||||
- { source_path: "first_event_timestamp", ocsf_field: "finding_info.created_time" }
|
||||
- { source_path: "backend_update_timestamp", ocsf_field: "finding_info.modified_time" }
|
||||
- { source_path: "alert_url", ocsf_field: "finding_info.src_url" }
|
||||
- { source_path: "workflow.status", ocsf_field: "status" }
|
||||
- { source_path: "determination.value", ocsf_field: "status_detail" }
|
||||
# ── Endpoint / device ──────────────────────────────────────────────
|
||||
- { source_path: "device_id", ocsf_field: "device.uid" }
|
||||
- { source_path: "device_os_version", ocsf_field: "device.os.build" }
|
||||
- { source_path: "device_name", ocsf_field: "src_endpoint.hostname" }
|
||||
- { source_path: "device_os", ocsf_field: "src_endpoint.os.name" }
|
||||
- { source_path: "device_internal_ip", ocsf_field: "src_endpoint.ip" }
|
||||
- { source_path: "device_username", ocsf_field: "user.name" }
|
||||
- { source_path: "org_key", ocsf_field: "cloud.account.uid" }
|
||||
# ── Triggering process — the actor ─────────────────────────────────
|
||||
- { source_path: "process_name", ocsf_field: "actor.process.name" }
|
||||
- { source_path: "process_pid", ocsf_field: "actor.process.pid" }
|
||||
- { source_path: "process_cmdline", ocsf_field: "actor.process.cmd_line" }
|
||||
- { source_path: "process_sha256", ocsf_field: "actor.process.file.hashes.sha256" }
|
||||
- { source_path: "process_md5", ocsf_field: "actor.process.file.hashes.md5" }
|
||||
- { source_path: "process_guid", ocsf_field: "metadata.original_event_uid" }
|
||||
- { source_path: "process_username", ocsf_field: "actor.user.name" }
|
||||
# ── Parent process ──────────────────────────────────────────────────
|
||||
- { source_path: "parent_name", ocsf_field: "actor.process.parent_process.name" }
|
||||
- { source_path: "parent_pid", ocsf_field: "actor.process.parent_process.pid" }
|
||||
- { source_path: "parent_cmdline", ocsf_field: "actor.process.parent_process.cmd_line" }
|
||||
- { source_path: "parent_sha256", ocsf_field: "actor.process.parent_process.file.hashes.sha256" }
|
||||
# ── Child / target process ──────────────────────────────────────────
|
||||
- { source_path: "childproc_name", ocsf_field: "process.name" }
|
||||
- { source_path: "childproc_pid", ocsf_field: "process.pid" }
|
||||
- { source_path: "childproc_cmdline", ocsf_field: "process.cmd_line" }
|
||||
- { source_path: "childproc_guid", ocsf_field: "process.uid" }
|
||||
# ── Detection rule / watchlist / report ─────────────────────────────
|
||||
- { source_path: "watchlists[0].name", ocsf_field: "rule.name" }
|
||||
- { source_path: "watchlists[0].id", ocsf_field: "rule.uid" }
|
||||
- { source_path: "report_name", ocsf_field: "rule.desc" }
|
||||
# ── IOC / observable ──────────────────────────────────────────────
|
||||
- { source_path: "ioc_hit", ocsf_field: "observables.value" }
|
||||
- { source_path: "ioc_field", ocsf_field: "observables.type" }
|
||||
@@ -62,7 +62,7 @@ commands:
|
||||
outputs_schema: { properties: {} }
|
||||
- id: list_events
|
||||
name: cisco-amp-list-events
|
||||
description: "List events."
|
||||
description: "List events. Used for ingestion: results path = data."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
@@ -70,6 +70,9 @@ commands:
|
||||
limit: { type: number, description: "Max events (default 50)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
ingest:
|
||||
results_path: data
|
||||
dedup_key: id
|
||||
- id: get_trajectory
|
||||
name: cisco-amp-get-trajectory
|
||||
description: "Get a computer's device trajectory."
|
||||
@@ -88,3 +91,8 @@ commands:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
ingestion:
|
||||
command: list_events
|
||||
mapper: list_events
|
||||
default_incident_type: "Cisco Secure Endpoint Event"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: "Cisco Secure Endpoint Events → OCSF"
|
||||
description: "Maps a Cisco AMP for Endpoints event object (GET /v1/events, results_path = data) to OCSF. The detected file is the subject (file.*/malware.*); the process that dropped/ran it (file.parent.*) is the actor (actor.process.*). Field paths validated against the AMP API v1 event schema (Cisco docs + demisto/content CiscoAMP pack README)."
|
||||
field_mappings:
|
||||
title: "detection ? detection : event_type"
|
||||
severity: "severity = 'Critical' ? 5 : (severity = 'High' ? 4 : (severity = 'Medium' ? 3 : 2))"
|
||||
description: "event_type"
|
||||
# results_path = data; source_path is JSONata over ONE event object.
|
||||
# Paths absent from a given event are skipped at ingestion, so extra entries are safe.
|
||||
ocsf:
|
||||
# ── Endpoint / device ──────────────────────────────────────────────
|
||||
- { source_path: "computer.hostname", ocsf_field: "src_endpoint.hostname" }
|
||||
- { source_path: "computer.external_ip", ocsf_field: "src_endpoint.ip" }
|
||||
- { source_path: "computer.connector_guid", ocsf_field: "device.uid" }
|
||||
- { source_path: "computer.user", ocsf_field: "user.name" }
|
||||
# ── Finding / analytic ─────────────────────────────────────────────
|
||||
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
||||
- { source_path: "timestamp", ocsf_field: "finding_info.created_time" }
|
||||
- { source_path: "event_type", ocsf_field: "finding_info.analytic.name" }
|
||||
- { source_path: "event_type_id", ocsf_field: "finding_info.analytic.uid" }
|
||||
- { source_path: "file.disposition", ocsf_field: "disposition" }
|
||||
# ── Malware / detection ────────────────────────────────────────────
|
||||
- { source_path: "detection", ocsf_field: "malware.name" }
|
||||
- { source_path: "detection_id", ocsf_field: "malware.uid" }
|
||||
# ── Detected file — the subject ────────────────────────────────────
|
||||
- { source_path: "file.file_name", ocsf_field: "file.name" }
|
||||
- { source_path: "file.file_path", ocsf_field: "file.path" }
|
||||
- { source_path: "file.identity.sha256", ocsf_field: "file.hashes.sha256" }
|
||||
- { source_path: "file.identity.sha1", ocsf_field: "file.hashes.sha1" }
|
||||
- { source_path: "file.identity.md5", ocsf_field: "file.hashes.md5" }
|
||||
# ── Parent process (dropped/ran the file) — the actor ──────────────
|
||||
- { source_path: "file.parent.file_name", ocsf_field: "actor.process.name" }
|
||||
- { source_path: "file.parent.process_id", ocsf_field: "actor.process.pid" }
|
||||
- { source_path: "file.parent.identity.sha256", ocsf_field: "actor.process.file.hashes.sha256" }
|
||||
- { source_path: "file.parent.identity.sha1", ocsf_field: "actor.process.file.hashes.sha1" }
|
||||
- { source_path: "file.parent.identity.md5", ocsf_field: "actor.process.file.hashes.md5" }
|
||||
@@ -0,0 +1,3 @@
|
||||
name: "Cortex XDR Alert"
|
||||
color: "#ef8354"
|
||||
icon: "alert"
|
||||
@@ -1,8 +1,8 @@
|
||||
id: cortex_xdr
|
||||
name: Cortex XDR
|
||||
version: 1.2.0
|
||||
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."
|
||||
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."
|
||||
version: 1.3.1
|
||||
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.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
|
||||
|
||||
# Per-instance configuration. The base URL is the tenant API root, e.g.
|
||||
@@ -11,7 +11,7 @@ config_schema:
|
||||
properties:
|
||||
url:
|
||||
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:
|
||||
type: string
|
||||
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
|
||||
auth_type:
|
||||
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
|
||||
required:
|
||||
- url
|
||||
@@ -44,7 +44,7 @@ commands:
|
||||
# ── Ingestion ─────────────────────────────────────────────────────────────
|
||||
- id: 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
|
||||
inputs_schema:
|
||||
properties:
|
||||
@@ -57,6 +57,23 @@ commands:
|
||||
results_path: reply.incidents
|
||||
dedup_key: incident_id
|
||||
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
|
||||
name: cortex-xdr-get-incident-extra-data
|
||||
description: "Get full incident data including its alerts and network artifacts by incident ID."
|
||||
@@ -83,15 +100,21 @@ commands:
|
||||
outputs_schema: { properties: {} }
|
||||
- id: 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
|
||||
inputs_schema:
|
||||
properties:
|
||||
severity: { type: string, description: "Comma-separated severities (low, medium, high, critical)" }
|
||||
created_after: { type: string, description: "Lower bound on alert source_insert_ts, epoch ms" }
|
||||
limit: { type: number, description: "Maximum alerts to fetch (default 100)" }
|
||||
severity: { type: string, description: "Comma-separated severities (informational, low, medium, high, critical)" }
|
||||
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). 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: []
|
||||
outputs_schema: { properties: {} }
|
||||
ingest:
|
||||
results_path: reply.alerts
|
||||
dedup_key: internal_id
|
||||
incremental_field: created_after
|
||||
- id: insert_parsed_alerts
|
||||
name: cortex-xdr-insert-parsed-alerts
|
||||
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."
|
||||
field_mappings:
|
||||
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"
|
||||
# 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.
|
||||
@@ -15,6 +18,9 @@ ocsf:
|
||||
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
||||
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
||||
- { source_path: "status", ocsf_field: "status" }
|
||||
# `time` is the MTTD anchor — when XDR opened the incident, as opposed to when
|
||||
# Riposte ingested it. Without it the detection delay column stays empty.
|
||||
- { source_path: "creation_time", ocsf_field: "time" }
|
||||
- { source_path: "alert_count", ocsf_field: "count" }
|
||||
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
|
||||
- { 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():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -3,29 +3,61 @@ from datetime import datetime
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
@@ -44,19 +76,121 @@ def to_ms(v):
|
||||
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():
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
limit = int(inputs.get("limit") or 100)
|
||||
filters = []
|
||||
if inputs.get("severity"):
|
||||
filters.append({"field": "severity", "operator": "in", "value": csv(inputs["severity"])})
|
||||
limit = max(1, int(inputs.get("limit") or 100))
|
||||
conditions = []
|
||||
sev = severity_block(inputs.get("severity"))
|
||||
if sev:
|
||||
conditions.append(sev)
|
||||
created_ms = to_ms(inputs.get("created_after"))
|
||||
if created_ms is not None:
|
||||
filters.append({"field": "source_insert_ts", "operator": "gte", "value": created_ms})
|
||||
rd = {"search_from": 0, "search_to": limit, "sort": {"field": "source_insert_ts", "keyword": "desc"}}
|
||||
if filters:
|
||||
rd["filters"] = filters
|
||||
print(json.dumps(post("/alerts/get_alerts_by_filter_data/", rd)))
|
||||
conditions.append({
|
||||
"SEARCH_FIELD": "source_insert_ts",
|
||||
"SEARCH_TYPE": "RANGE",
|
||||
"SEARCH_VALUE": {"from": created_ms, "to": int(time.time() * 1000) + SKEW_MS},
|
||||
})
|
||||
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:
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -3,29 +3,61 @@ from datetime import datetime
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -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():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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_raw(path, body):
|
||||
base, headers = _client()
|
||||
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")
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
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):
|
||||
|
||||
@@ -2,39 +2,83 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
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:
|
||||
main()
|
||||
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)
|
||||
except Exception as 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():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
||||
|
||||
|
||||
def _client():
|
||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
||||
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", ""))
|
||||
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":
|
||||
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()
|
||||
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()
|
||||
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():
|
||||
|
||||
@@ -31,7 +31,7 @@ config_schema:
|
||||
commands:
|
||||
- id: query_malops
|
||||
name: cybereason-query-malops
|
||||
description: "Query Malops (malicious operations) in a time window."
|
||||
description: "Query Malops (malicious operations) in a time window. Used for ingestion: results path = malops."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
@@ -39,6 +39,10 @@ commands:
|
||||
end_time: { type: number, description: "End time as Unix epoch milliseconds" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
ingest:
|
||||
results_path: malops
|
||||
dedup_key: guid
|
||||
incremental_field: start_time
|
||||
- id: list_sensors
|
||||
name: cybereason-list-sensors
|
||||
description: "List sensors (endpoints)."
|
||||
@@ -93,3 +97,8 @@ commands:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
ingestion:
|
||||
command: query_malops
|
||||
mapper: query_malops
|
||||
default_incident_type: "Cybereason Malop"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
name: "Cybereason Malops → OCSF"
|
||||
description: "Maps the raw items of the 'malops' array returned by POST /rest/detection/inbox (results_path = malops) to OCSF finding fields. Each element is the flat, non-nested Malop-inbox shape (guid, edr, displayName, status, severity, creationTime, machines[], users[]) — not the enriched elementValues/simpleValues shape returned by /rest/crimes/unified."
|
||||
field_mappings:
|
||||
title: "displayName"
|
||||
severity: "severity = 'Critical' ? 5 : (severity = 'High' ? 4 : (severity = 'Medium' ? 3 : (severity = 'Low' ? 2 : 3)))"
|
||||
description: "\"Cybereason Malop — severity: \" & severity & \", status: \" & status"
|
||||
ocsf:
|
||||
- { source_path: "guid", ocsf_field: "finding_info.uid" }
|
||||
- { source_path: "displayName", ocsf_field: "finding_info.title" }
|
||||
- { source_path: "creationTime", ocsf_field: "finding_info.created_time" }
|
||||
- { source_path: "status", ocsf_field: "status" }
|
||||
- { source_path: "machines[0].displayName", ocsf_field: "src_endpoint.hostname" }
|
||||
- { source_path: "$count(machines)", ocsf_field: "count" }
|
||||
@@ -0,0 +1,40 @@
|
||||
id: feed_botvrij
|
||||
name: Botvrij.eu Feed
|
||||
version: 1.0.0
|
||||
description: "Botvrij.eu OSINT feed connector — pull the free community IOC lists (destination IPs, domains, hostnames, URLs, file hashes, filenames, e-mails) and emit normalized IOCs (value + type) for import into the Threat Indicator Manager. Free, no authentication required; stdlib-only, no extra Python dependencies."
|
||||
changelog: "1.0.0 — Initial release: fetch a botvrij.eu IOC list by type."
|
||||
category: feed
|
||||
|
||||
# Botvrij.eu publishes free plain-text IOC lists over HTTPS. No key required.
|
||||
config_schema:
|
||||
properties:
|
||||
list:
|
||||
type: string
|
||||
description: "Which list: ip-dst, domain, hostname, url, md5, sha1, sha256, filename, email-src. Default ip-dst"
|
||||
default: "ip-dst"
|
||||
insecure:
|
||||
type: boolean
|
||||
description: "Trust any TLS certificate (not secure)"
|
||||
default: false
|
||||
required: []
|
||||
|
||||
commands:
|
||||
- id: fetch_indicators
|
||||
name: feed-botvrij-fetch-indicators
|
||||
description: "Fetch a botvrij.eu IOC list and return normalized indicators."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
list: { type: string, description: "ip-dst | domain | hostname | url | md5 | sha1 | sha256 | filename | email-src (overrides config)" }
|
||||
max_indicators: { type: number, description: "Max indicators to return (0 = no limit, default 0)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: test_connection
|
||||
name: feed-botvrij-test-connection
|
||||
description: "Verify the botvrij.eu feed is reachable (used by the Test button)."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
@@ -0,0 +1,74 @@
|
||||
import json, os, sys, ssl, urllib.request, urllib.error
|
||||
|
||||
BASE = "https://www.botvrij.eu/data/ioclist."
|
||||
|
||||
# botvrij list name -> normalized indicator type
|
||||
TYPES = {
|
||||
"ip-dst": "ip",
|
||||
"domain": "domain",
|
||||
"hostname": "domain",
|
||||
"url": "url",
|
||||
"md5": "hash",
|
||||
"sha1": "hash",
|
||||
"sha256": "hash",
|
||||
"filename": "filename",
|
||||
"email-src": "email",
|
||||
}
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Riposte-SOAR"})
|
||||
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
key = str(inputs.get("list") or cfg.get("list") or "ip-dst").lower()
|
||||
if key not in TYPES:
|
||||
key = "ip-dst"
|
||||
typ = TYPES[key]
|
||||
|
||||
raw = _get(BASE + key, cfg).decode("utf-8", "replace")
|
||||
|
||||
maxn = int(inputs.get("max_indicators") or 0)
|
||||
out = []
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
out.append({"value": line, "type": typ, "list": key})
|
||||
if maxn and len(out) >= maxn:
|
||||
break
|
||||
|
||||
return {"source": "botvrij:" + key, "count": len(out), "indicators": out}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,47 @@
|
||||
import json, os, sys, ssl, urllib.request, urllib.error
|
||||
|
||||
BASE = "https://www.botvrij.eu/data/ioclist."
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Riposte-SOAR"})
|
||||
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
key = str(cfg.get("list") or "ip-dst").lower()
|
||||
raw = _get(BASE + key, cfg).decode("utf-8", "replace")
|
||||
n = sum(1 for ln in raw.splitlines() if ln.strip() and not ln.startswith("#"))
|
||||
return {"ok": True, "sample_count": n, "list": key}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,36 @@
|
||||
id: feed_cisa_kev
|
||||
name: CISA KEV Feed
|
||||
version: 1.0.0
|
||||
description: "CISA Known Exploited Vulnerabilities (KEV) feed connector — pull the authoritative catalog of CVEs known to be actively exploited in the wild and emit normalized IOCs (CVE + type, with vendor/product/due-date/ransomware flag) for import into the Threat Indicator Manager. Free, no authentication required; stdlib-only, no extra Python dependencies."
|
||||
changelog: "1.0.0 — Initial release: fetch the CISA KEV catalog."
|
||||
category: feed
|
||||
|
||||
# The CISA KEV catalog is a free JSON file served over HTTPS. No key required.
|
||||
config_schema:
|
||||
properties:
|
||||
insecure:
|
||||
type: boolean
|
||||
description: "Trust any TLS certificate (not secure)"
|
||||
default: false
|
||||
required: []
|
||||
|
||||
commands:
|
||||
- id: fetch_indicators
|
||||
name: feed-cisa-kev-fetch-indicators
|
||||
description: "Fetch the CISA KEV catalog and return normalized CVE indicators."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
ransomware_only: { type: boolean, description: "Only return CVEs linked to known ransomware campaigns (default false)" }
|
||||
max_indicators: { type: number, description: "Max indicators to return (0 = no limit, default 0)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: test_connection
|
||||
name: feed-cisa-kev-test-connection
|
||||
description: "Verify the CISA KEV feed is reachable (used by the Test button)."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
@@ -0,0 +1,80 @@
|
||||
import json, os, sys, ssl, urllib.request, urllib.error
|
||||
|
||||
URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
ransomware_only = bool(inputs.get("ransomware_only"))
|
||||
raw = _get(URL, cfg)
|
||||
data = json.loads(raw) if raw else {}
|
||||
|
||||
maxn = int(inputs.get("max_indicators") or 0)
|
||||
out = []
|
||||
for v in data.get("vulnerabilities", []):
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
cid = v.get("cveID")
|
||||
if not cid:
|
||||
continue
|
||||
ransom = v.get("knownRansomwareCampaignUse")
|
||||
if ransomware_only and str(ransom).lower() != "known":
|
||||
continue
|
||||
out.append({
|
||||
"value": cid,
|
||||
"type": "cve",
|
||||
"vendor": v.get("vendorProject"),
|
||||
"product": v.get("product"),
|
||||
"name": v.get("vulnerabilityName"),
|
||||
"date_added": v.get("dateAdded"),
|
||||
"due_date": v.get("dueDate"),
|
||||
"ransomware": ransom,
|
||||
"tags": ["cisa-kev", "exploited"],
|
||||
})
|
||||
if maxn and len(out) >= maxn:
|
||||
break
|
||||
|
||||
return {
|
||||
"source": "cisa:kev",
|
||||
"count": len(out),
|
||||
"indicators": out,
|
||||
"catalog_version": data.get("catalogVersion"),
|
||||
}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,49 @@
|
||||
import json, os, sys, ssl, urllib.request, urllib.error
|
||||
|
||||
URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
raw = _get(URL, cfg)
|
||||
data = json.loads(raw) if raw else {}
|
||||
n = data.get("count") or len(data.get("vulnerabilities", []))
|
||||
return {"ok": True, "sample_count": n, "catalog_version": data.get("catalogVersion")}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,39 @@
|
||||
id: feed_first_epss
|
||||
name: FIRST EPSS Feed
|
||||
version: 1.0.0
|
||||
description: "FIRST EPSS (Exploit Prediction Scoring System) feed connector — pull the CVEs with the highest probability of being exploited in the next 30 days and emit normalized IOCs (CVE + type, with EPSS score and percentile) for import into the Threat Indicator Manager. Free public API, no authentication required; stdlib-only, no extra Python dependencies."
|
||||
changelog: "1.0.0 — Initial release: fetch top-EPSS CVEs from the FIRST EPSS API."
|
||||
category: feed
|
||||
|
||||
# The FIRST EPSS API is free and served over HTTPS. No key required.
|
||||
config_schema:
|
||||
properties:
|
||||
min_epss:
|
||||
type: string
|
||||
description: "Optional: only return CVEs with EPSS score greater than this (0-1, e.g. 0.5)"
|
||||
insecure:
|
||||
type: boolean
|
||||
description: "Trust any TLS certificate (not secure)"
|
||||
default: false
|
||||
required: []
|
||||
|
||||
commands:
|
||||
- id: fetch_indicators
|
||||
name: feed-first-epss-fetch-indicators
|
||||
description: "Fetch top-EPSS CVEs and return normalized indicators."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
min_epss: { type: string, description: "Only CVEs with EPSS > this value (overrides config)" }
|
||||
max_indicators: { type: number, description: "Max indicators to return (default 100, max 1000)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: test_connection
|
||||
name: feed-first-epss-test-connection
|
||||
description: "Verify the FIRST EPSS API is reachable (used by the Test button)."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
@@ -0,0 +1,73 @@
|
||||
import json, os, sys, ssl, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
BASE = "https://api.first.org/data/v1/epss"
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
limit = int(inputs.get("max_indicators") or 0) or 100
|
||||
limit = max(1, min(limit, 1000))
|
||||
min_epss = inputs.get("min_epss") or cfg.get("min_epss")
|
||||
|
||||
params = {"order": "!epss", "limit": limit}
|
||||
if min_epss not in (None, ""):
|
||||
params["epss-gt"] = str(min_epss)
|
||||
|
||||
url = BASE + "?" + urllib.parse.urlencode(params)
|
||||
raw = _get(url, cfg)
|
||||
data = json.loads(raw) if raw else {}
|
||||
|
||||
out = []
|
||||
for e in data.get("data", []):
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
cve = e.get("cve")
|
||||
if not cve:
|
||||
continue
|
||||
out.append({
|
||||
"value": cve,
|
||||
"type": "cve",
|
||||
"epss": e.get("epss"),
|
||||
"percentile": e.get("percentile"),
|
||||
"date": e.get("date"),
|
||||
})
|
||||
|
||||
return {"source": "first:epss", "count": len(out), "indicators": out}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,49 @@
|
||||
import json, os, sys, ssl, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
BASE = "https://api.first.org/data/v1/epss"
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
url = BASE + "?" + urllib.parse.urlencode({"limit": 1})
|
||||
raw = _get(url, cfg)
|
||||
data = json.loads(raw) if raw else {}
|
||||
return {"ok": True, "status": data.get("status"), "total": data.get("total")}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,45 @@
|
||||
id: feed_nvd
|
||||
name: NVD CVE Feed
|
||||
version: 1.0.0
|
||||
description: "NIST National Vulnerability Database (NVD) feed connector — pull recently-published CVEs from the NVD 2.0 API and emit normalized IOCs (CVE + type, with description/CVSS/status) for import into the Threat Indicator Manager. Works without a key; an optional free NVD API key raises the rate limit. Stdlib-only, no extra Python dependencies."
|
||||
changelog: "1.0.0 — Initial release: fetch recent CVEs from the NVD 2.0 API."
|
||||
category: feed
|
||||
|
||||
# The NVD 2.0 API is free. An optional API key (sent as the apiKey header)
|
||||
# raises the request rate limit.
|
||||
config_schema:
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: "Optional NVD API key (free — raises the rate limit)"
|
||||
x-soar-sensitive: true
|
||||
days:
|
||||
type: number
|
||||
description: "Look-back window in days for recently-published CVEs (default 7, max 120)"
|
||||
default: 7
|
||||
insecure:
|
||||
type: boolean
|
||||
description: "Trust any TLS certificate (not secure)"
|
||||
default: false
|
||||
required: []
|
||||
|
||||
commands:
|
||||
- id: fetch_indicators
|
||||
name: feed-nvd-fetch-indicators
|
||||
description: "Fetch recently-published CVEs from NVD and return normalized indicators."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
days: { type: number, description: "Look-back window in days (overrides config, max 120)" }
|
||||
max_indicators: { type: number, description: "Max indicators to return (0 = API page size 2000, default 0)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: test_connection
|
||||
name: feed-nvd-test-connection
|
||||
description: "Verify the NVD API is reachable (used by the Test button)."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
@@ -0,0 +1,110 @@
|
||||
import json, os, sys, ssl, datetime, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _headers(cfg):
|
||||
h = {"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
|
||||
k = cfg.get("api_key")
|
||||
if k:
|
||||
h["apiKey"] = str(k)
|
||||
return h
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(url, headers=_headers(cfg))
|
||||
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _cvss(metrics):
|
||||
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
|
||||
arr = metrics.get(key)
|
||||
if arr and isinstance(arr, list) and isinstance(arr[0], dict):
|
||||
d = arr[0].get("cvssData", {})
|
||||
return d.get("baseScore"), d.get("baseSeverity") or arr[0].get("baseSeverity")
|
||||
return None, None
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
days = int(inputs.get("days") or cfg.get("days") or 7)
|
||||
days = max(1, min(days, 120))
|
||||
|
||||
end = datetime.datetime.now(datetime.timezone.utc)
|
||||
start = end - datetime.timedelta(days=days)
|
||||
fmt = "%Y-%m-%dT%H:%M:%S.000"
|
||||
|
||||
per = int(inputs.get("max_indicators") or 0) or 2000
|
||||
per = max(1, min(per, 2000))
|
||||
|
||||
params = {
|
||||
"pubStartDate": start.strftime(fmt),
|
||||
"pubEndDate": end.strftime(fmt),
|
||||
"resultsPerPage": per,
|
||||
}
|
||||
url = BASE + "?" + urllib.parse.urlencode(params)
|
||||
raw = _get(url, cfg)
|
||||
data = json.loads(raw) if raw else {}
|
||||
|
||||
maxn = int(inputs.get("max_indicators") or 0)
|
||||
out = []
|
||||
for v in data.get("vulnerabilities", []):
|
||||
cve = v.get("cve", {}) if isinstance(v, dict) else {}
|
||||
cid = cve.get("id")
|
||||
if not cid:
|
||||
continue
|
||||
desc = ""
|
||||
for d in cve.get("descriptions", []):
|
||||
if d.get("lang") == "en":
|
||||
desc = d.get("value")
|
||||
break
|
||||
score, severity = _cvss(cve.get("metrics", {}))
|
||||
out.append({
|
||||
"value": cid,
|
||||
"type": "cve",
|
||||
"description": desc,
|
||||
"cvss": score,
|
||||
"severity": severity,
|
||||
"published": cve.get("published"),
|
||||
"status": cve.get("vulnStatus"),
|
||||
})
|
||||
if maxn and len(out) >= maxn:
|
||||
break
|
||||
|
||||
return {
|
||||
"source": "nvd:recent",
|
||||
"count": len(out),
|
||||
"indicators": out,
|
||||
"total_results": data.get("totalResults"),
|
||||
}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,55 @@
|
||||
import json, os, sys, ssl, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _headers(cfg):
|
||||
h = {"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
|
||||
k = cfg.get("api_key")
|
||||
if k:
|
||||
h["apiKey"] = str(k)
|
||||
return h
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(url, headers=_headers(cfg))
|
||||
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
url = BASE + "?" + urllib.parse.urlencode({"resultsPerPage": 1})
|
||||
raw = _get(url, cfg)
|
||||
data = json.loads(raw) if raw else {}
|
||||
return {"ok": True, "total_results": data.get("totalResults")}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,46 @@
|
||||
id: feed_office365
|
||||
name: Microsoft 365 Endpoints Feed
|
||||
version: 1.0.0
|
||||
description: "Microsoft 365 endpoints feed connector — pull the official published IP ranges and URLs for Microsoft 365 / Office 365 services and emit normalized IOCs (CIDR and domain + type, with service area) for import into the Threat Indicator Manager. Intended as an allowlist / known-infrastructure feed (mark benign in TIM). Free public endpoint, no authentication required; stdlib-only, no extra Python dependencies."
|
||||
changelog: "1.0.0 — Initial release: fetch the Microsoft 365 worldwide endpoints (IPs + URLs)."
|
||||
category: feed
|
||||
|
||||
# The endpoints.office.com service is free. instance selects the cloud
|
||||
# (Worldwide, USGovDoD, USGovGCCHigh, China, Germany).
|
||||
config_schema:
|
||||
properties:
|
||||
instance:
|
||||
type: string
|
||||
description: "Which M365 cloud instance: Worldwide, USGovDoD, USGovGCCHigh, China, Germany (default Worldwide)"
|
||||
default: "Worldwide"
|
||||
include_urls:
|
||||
type: boolean
|
||||
description: "Also emit the published service URLs as domain indicators (default true)"
|
||||
default: true
|
||||
insecure:
|
||||
type: boolean
|
||||
description: "Trust any TLS certificate (not secure)"
|
||||
default: false
|
||||
required: []
|
||||
|
||||
commands:
|
||||
- id: fetch_indicators
|
||||
name: feed-office365-fetch-indicators
|
||||
description: "Fetch the Microsoft 365 endpoints and return normalized indicators."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
instance: { type: string, description: "Cloud instance (overrides config)" }
|
||||
include_urls: { type: boolean, description: "Emit URLs as domain indicators (overrides config)" }
|
||||
max_indicators: { type: number, description: "Max indicators to return (0 = no limit, default 0)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: test_connection
|
||||
name: feed-office365-test-connection
|
||||
description: "Verify the Microsoft 365 endpoints service is reachable (used by the Test button)."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
@@ -0,0 +1,89 @@
|
||||
import json, os, sys, ssl, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
BASE = "https://endpoints.office.com/endpoints/"
|
||||
# A fixed client request id is acceptable for this public, unauthenticated API.
|
||||
CLIENT_ID = "b10c5ed1-bad1-445f-b386-b919946339a7"
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
instance = str(inputs.get("instance") or cfg.get("instance") or "Worldwide")
|
||||
include_urls = inputs.get("include_urls")
|
||||
if include_urls is None:
|
||||
include_urls = cfg.get("include_urls")
|
||||
if include_urls is None:
|
||||
include_urls = True
|
||||
|
||||
url = BASE + urllib.parse.quote(instance, safe="") + "?" + urllib.parse.urlencode({"clientrequestid": CLIENT_ID})
|
||||
raw = _get(url, cfg)
|
||||
data = json.loads(raw) if raw else []
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
|
||||
maxn = int(inputs.get("max_indicators") or 0)
|
||||
seen = set()
|
||||
out = []
|
||||
|
||||
def add(value, typ, area):
|
||||
key = (typ, value)
|
||||
if key in seen:
|
||||
return False
|
||||
seen.add(key)
|
||||
out.append({"value": value, "type": typ, "service_area": area, "provider": "microsoft365"})
|
||||
return not (maxn and len(out) >= maxn)
|
||||
|
||||
for ep in data:
|
||||
if not isinstance(ep, dict):
|
||||
continue
|
||||
area = ep.get("serviceArea")
|
||||
for cidr in ep.get("ips", []) or []:
|
||||
typ = "cidr" if "/" in str(cidr) else "ip"
|
||||
if not add(str(cidr), typ, area):
|
||||
return {"source": "microsoft365:" + instance, "count": len(out), "indicators": out}
|
||||
if include_urls:
|
||||
for u in ep.get("urls", []) or []:
|
||||
dom = str(u).lstrip("*.").strip()
|
||||
if not dom:
|
||||
continue
|
||||
if not add(dom, "domain", area):
|
||||
return {"source": "microsoft365:" + instance, "count": len(out), "indicators": out}
|
||||
|
||||
return {"source": "microsoft365:" + instance, "count": len(out), "indicators": out}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,52 @@
|
||||
import json, os, sys, ssl, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
BASE = "https://endpoints.office.com/endpoints/"
|
||||
CLIENT_ID = "b10c5ed1-bad1-445f-b386-b919946339a7"
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
instance = str(cfg.get("instance") or "Worldwide")
|
||||
url = BASE + urllib.parse.quote(instance, safe="") + "?" + urllib.parse.urlencode({"clientrequestid": CLIENT_ID})
|
||||
raw = _get(url, cfg)
|
||||
data = json.loads(raw) if raw else []
|
||||
n = len(data) if isinstance(data, list) else 0
|
||||
return {"ok": True, "endpoint_sets": n, "instance": instance}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,43 @@
|
||||
id: feed_rss
|
||||
name: RSS/Atom IOC Feed
|
||||
version: 1.0.0
|
||||
description: "Generic RSS/Atom threat-intel feed connector — fetch a security blog or advisory feed and extract indicators (URLs, IPs and file hashes, including common defanged forms like hxxp and 1[.]2[.]3[.]4) from the item titles and bodies, emitting normalized IOCs for import into the Threat Indicator Manager. One connector, many feeds. Optional bearer token; stdlib-only, no extra Python dependencies."
|
||||
changelog: "1.0.0 — Initial release: extract URL/IP/hash IOCs from an RSS or Atom feed."
|
||||
category: feed
|
||||
|
||||
# The feed is fetched over HTTP(S) from feed_url and parsed as RSS or Atom XML.
|
||||
config_schema:
|
||||
properties:
|
||||
feed_url:
|
||||
type: string
|
||||
description: "URL of the RSS/Atom feed"
|
||||
api_token:
|
||||
type: string
|
||||
description: "Optional bearer token (if the feed requires auth)"
|
||||
x-soar-sensitive: true
|
||||
insecure:
|
||||
type: boolean
|
||||
description: "Trust any TLS certificate (not secure)"
|
||||
default: false
|
||||
required:
|
||||
- feed_url
|
||||
|
||||
commands:
|
||||
- id: fetch_indicators
|
||||
name: feed-rss-fetch-indicators
|
||||
description: "Fetch the RSS/Atom feed and return IOCs extracted from its items."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
max_indicators: { type: number, description: "Max indicators to return (0 = no limit, default 0)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: test_connection
|
||||
name: feed-rss-test-connection
|
||||
description: "Verify the feed URL returns parseable XML (used by the Test button)."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
@@ -0,0 +1,108 @@
|
||||
import json, os, sys, re, ssl, urllib.request, urllib.error
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
_URL = re.compile(r"https?://[^\s<>\"'\]\)]+", re.I)
|
||||
_IP = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
||||
_HASH = re.compile(r"\b[a-fA-F0-9]{64}\b|\b[a-fA-F0-9]{40}\b|\b[a-fA-F0-9]{32}\b")
|
||||
|
||||
|
||||
def _defang(t):
|
||||
for a, b in (("[.]", "."), ("(.)", "."), ("{.}", "."), ("[dot]", "."),
|
||||
("(dot)", "."), ("[:]", ":"), ("[://]", "://"),
|
||||
("hxxp", "http"), ("hXXp", "http"), ("hxxps", "https")):
|
||||
t = t.replace(a, b)
|
||||
return t
|
||||
|
||||
|
||||
def _valid_ip(ip):
|
||||
parts = ip.split(".")
|
||||
return len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts)
|
||||
|
||||
|
||||
def _localname(tag):
|
||||
return tag.split("}")[-1] if "}" in tag else tag
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _headers(cfg):
|
||||
h = {"User-Agent": "Riposte-SOAR", "Accept": "application/rss+xml, application/xml, text/xml"}
|
||||
t = cfg.get("api_token")
|
||||
if t:
|
||||
h["Authorization"] = "Bearer " + str(t)
|
||||
return h
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(url, headers=_headers(cfg))
|
||||
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
url = cfg.get("feed_url")
|
||||
if not url:
|
||||
raise Exception("feed_url is required")
|
||||
|
||||
raw = _get(url, cfg)
|
||||
root = ET.fromstring(raw)
|
||||
|
||||
texts = []
|
||||
for el in root.iter():
|
||||
if _localname(el.tag) in ("item", "entry"):
|
||||
texts.append(_defang(" ".join(el.itertext())))
|
||||
blob = " ".join(texts) if texts else _defang(ET.tostring(root, encoding="unicode"))
|
||||
|
||||
maxn = int(inputs.get("max_indicators") or 0)
|
||||
seen = set()
|
||||
out = []
|
||||
|
||||
def add(value, typ):
|
||||
key = (typ, value)
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
out.append({"value": value, "type": typ})
|
||||
|
||||
for m in _URL.finditer(blob):
|
||||
add(m.group(0).rstrip(".,);]’"), "url")
|
||||
for m in _HASH.finditer(blob):
|
||||
add(m.group(0), "hash")
|
||||
for m in _IP.finditer(blob):
|
||||
v = m.group(0)
|
||||
if _valid_ip(v):
|
||||
add(v, "ip")
|
||||
|
||||
if maxn and len(out) > maxn:
|
||||
out = out[:maxn]
|
||||
|
||||
return {"source": url, "count": len(out), "indicators": out}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -0,0 +1,61 @@
|
||||
import json, os, sys, ssl, urllib.request, urllib.error
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def _localname(tag):
|
||||
return tag.split("}")[-1] if "}" in tag else tag
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _inputs():
|
||||
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
|
||||
|
||||
def _ctx(cfg):
|
||||
if cfg.get("insecure"):
|
||||
c = ssl.create_default_context()
|
||||
c.check_hostname = False
|
||||
c.verify_mode = ssl.CERT_NONE
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _headers(cfg):
|
||||
h = {"User-Agent": "Riposte-SOAR", "Accept": "application/rss+xml, application/xml, text/xml"}
|
||||
t = cfg.get("api_token")
|
||||
if t:
|
||||
h["Authorization"] = "Bearer " + str(t)
|
||||
return h
|
||||
|
||||
|
||||
def _get(url, cfg):
|
||||
req = urllib.request.Request(url, headers=_headers(cfg))
|
||||
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def _run(fn):
|
||||
try:
|
||||
print(json.dumps(fn(_cfg(), _inputs())))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
url = cfg.get("feed_url")
|
||||
if not url:
|
||||
raise Exception("feed_url is required")
|
||||
raw = _get(url, cfg)
|
||||
root = ET.fromstring(raw)
|
||||
n = sum(1 for el in root.iter() if _localname(el.tag) in ("item", "entry"))
|
||||
return {"ok": True, "items": n}
|
||||
|
||||
|
||||
_run(main)
|
||||
@@ -1,8 +1,8 @@
|
||||
id: harfanglab
|
||||
name: HarfangLab EDR
|
||||
version: 1.2.2
|
||||
version: 1.4.1
|
||||
description: "HarfangLab EDR — endpoint detection & response: alert/threat ingestion, endpoint enrichment, isolation, threat-intelligence (IOC/whitelist), telemetry hunting and forensic collection jobs."
|
||||
changelog: "1.2.2 — Documentation wording cleanup. 1.2.1 — Corrected the get_threats OCSF mapper to native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics) instead of enriched-only arrays; added agent.osversion to get_security_events. 1.2.0 — Added ingestion commands get_security_events (alerts) and get_threats with bundled OCSF mappers and ingest hints (results_path/dedup/incremental). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Command names prefixed with 'harfanglab-' (e.g. harfanglab-isolate-endpoint) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: endpoint/agent management, isolation, policy assignment, IOC & whitelist management, security-event triage, telemetry hunting (processes, network, DNS, authentications, binaries, event logs), threat hunting by hash, and forensic collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services, processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) with their result retrieval commands. Compatible with HarfangLab EDR 2.13.7+."
|
||||
changelog: "1.4.1 — get_alerts now maps image_name, the one process path present on every alert subtype and the only one on those carrying neither a process object nor an event log (yara, binary, ioc). HarfangLab prefixes it with the matched threat name in brackets, so both halves are split out: the clean path feeds actor.process.file.path and its basename actor.process.name, and the threat name feeds malware.name — which now surfaces on every subtype rather than only on Defender event-log detections. Each half is guarded on the bracket being present so an unprefixed path is never written into malware.name. 1.4.0 — Mapper coverage from real payloads: get_alerts now maps the Windows event-log payload sigma alerts carry (alert_subtype = eventlog), which was entirely unmapped — Defender 1116/1117 yields the malware name, classification, severity and provider, the detected file, the process that touched it and the acting user, plus the log provider and event code. Also added the agent IP, DNS domain, device type, agent version and groups, the tenant, the alert subtype and threat type, the string severity and the event time. get_threats gained the threat level (previously never reaching the OCSF document at all), the event time and the top agent's reachability. 1.3.1 — Flagged get_alerts as ingestion-ready (ingest hints: results_path/dedup/incremental on from_date) so it appears in the recommended ingestion commands when creating an instance. 1.3.0 — Response & triage expansion: kill/dump process, on-demand AV/YARA scans, file/service/scheduled-task deletion, quarantine management, threat-level isolation, job cancel/relaunch/wait; alert search (get_alerts) and full alert/threat triage (tag, comments, notes, status, level, history, details); exhaustive alert OCSF mappers; reworked get_threats mapper (fixed MITRE tactics map handling, full top-agent/user/rule and counts coverage, rule-based incident title/description). 1.2.2 — Documentation wording cleanup. 1.2.1 — Corrected the get_threats OCSF mapper to native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics) instead of enriched-only arrays; added agent.osversion to get_security_events. 1.2.0 — Added ingestion commands get_security_events (alerts) and get_threats with bundled OCSF mappers and ingest hints (results_path/dedup/incremental). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Command names prefixed with 'harfanglab-' (e.g. harfanglab-isolate-endpoint) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: endpoint/agent management, isolation, policy assignment, IOC & whitelist management, security-event triage, telemetry hunting (processes, network, DNS, authentications, binaries, event logs), threat hunting by hash, and forensic collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services, processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) with their result retrieval commands. Compatible with HarfangLab EDR 2.13.7+."
|
||||
category: endpoint
|
||||
|
||||
# Per-instance configuration. Scripts use <url> as the API base and call /api/... paths.
|
||||
@@ -733,6 +733,309 @@ commands:
|
||||
required: [job_id]
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
# ── Response: process ─────────────────────────────────────────────────────
|
||||
- id: kill_process
|
||||
name: harfanglab-kill-process
|
||||
description: "Kill processes on an endpoint matching a criterion (name, path, pid, hash, command line...). Destructive: matching processes are terminated. Returns the created job; poll with harfanglab-wait-for-job."
|
||||
risk: destructive
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
field: { type: string, description: "Criterion field: name, path, username, commandline, parent_name, parent_path, parent_commandline, pid, md5, sha1, sha256 (default name)" }
|
||||
operator: { type: string, description: "Criterion operator: eq, not_eq, contains, not_contains (default eq)" }
|
||||
value: { type: string, description: "Criterion value to match" }
|
||||
required: [agent_id, value]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: dump_process
|
||||
name: harfanglab-dump-process
|
||||
description: "Dump the memory of processes matching a criterion (same criteria as kill-process). Returns the created job."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
field: { type: string, description: "Criterion field: name, path, username, commandline, parent_name, parent_path, parent_commandline, pid, md5, sha1, sha256 (default name)" }
|
||||
operator: { type: string, description: "Criterion operator: eq, not_eq, contains, not_contains (default eq)" }
|
||||
value: { type: string, description: "Criterion value to match" }
|
||||
required: [agent_id, value]
|
||||
outputs_schema: { properties: {} }
|
||||
# ── Response: scans ───────────────────────────────────────────────────────
|
||||
- id: av_scan
|
||||
name: harfanglab-av-scan
|
||||
description: "Start an on-demand antivirus scan on an endpoint (full, quick, or resource with explicit paths). Returns the created job."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
scan_type: { type: string, description: "Scan type: full, quick or resource (default quick)" }
|
||||
paths: { type: string, description: "Comma-separated paths to scan (required for resource scans)" }
|
||||
required: [agent_id]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: yara_scan
|
||||
name: harfanglab-yara-scan
|
||||
description: "Start a YARA scan on an endpoint using named YARA sources. Returns the created job."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
sources: { type: string, description: "Comma-separated YARA source names" }
|
||||
directories: { type: string, description: "Comma-separated directories to scan" }
|
||||
scan_filesystem: { type: boolean, description: "Scan the filesystem (default true)" }
|
||||
scan_processes: { type: boolean, description: "Scan running processes (default false)" }
|
||||
required: [agent_id]
|
||||
outputs_schema: { properties: {} }
|
||||
# ── Response: remediation ─────────────────────────────────────────────────
|
||||
- id: delete_file
|
||||
name: harfanglab-delete-file
|
||||
description: "Delete a file on an endpoint by full path. Destructive. Returns the created job."
|
||||
risk: destructive
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
filepath: { type: string, description: "Full path of the file to delete on the host" }
|
||||
required: [agent_id, filepath]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: delete_service
|
||||
name: harfanglab-delete-service
|
||||
description: "Delete a Windows service on an endpoint by service name. Destructive. Returns the created job."
|
||||
risk: destructive
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
service_name: { type: string, description: "Name of the service to delete" }
|
||||
required: [agent_id, service_name]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: delete_scheduled_task
|
||||
name: harfanglab-delete-scheduled-task
|
||||
description: "Delete a scheduled task on an endpoint by task URI. Destructive. Returns the created job."
|
||||
risk: destructive
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
schtask_uri: { type: string, description: "URI of the scheduled task to delete (e.g. \\Microsoft\\Windows\\...)" }
|
||||
required: [agent_id, schtask_uri]
|
||||
outputs_schema: { properties: {} }
|
||||
# ── Response: quarantine ──────────────────────────────────────────────────
|
||||
- id: quarantine_file
|
||||
name: harfanglab-quarantine-file
|
||||
description: "Move a file on an endpoint into HarfangLab quarantine by full path. Returns the created job."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
file_path: { type: string, description: "Full path of the file to quarantine on the host" }
|
||||
comment: { type: string, description: "Quarantine comment" }
|
||||
required: [agent_id, file_path]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: unquarantine_file
|
||||
name: harfanglab-unquarantine-file
|
||||
description: "Restore a quarantined file to its original location on the endpoint. Returns the created job."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
|
||||
local_id: { type: string, description: "Quarantined item local UUID (from harfanglab-quarantine-search)" }
|
||||
overwrite_existing: { type: boolean, description: "Overwrite an existing file at the original path (default true)" }
|
||||
required: [agent_id, local_id]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: quarantine_search
|
||||
name: harfanglab-quarantine-search
|
||||
description: "Search quarantined files by hash, hostname or agent. Items include their id; once acquired on the manager the file is downloadable at /api/data/quarantine/item/{id}/download/."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
hash: { type: string, description: "File hash (md5, sha1 or sha256 — routed by length)" }
|
||||
agent_hostname: { type: string, description: "Endpoint hostname" }
|
||||
agent_id: { type: string, description: "Agent unique identifier" }
|
||||
search: { type: string, description: "Free-text search term" }
|
||||
limit: { type: number, description: "Max results (default 100)" }
|
||||
offset: { type: number, description: "Pagination offset (default 0)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
- id: quarantine_request_upload
|
||||
name: harfanglab-quarantine-request-upload
|
||||
description: "Request upload of a quarantined file from the endpoint to the manager (returns the upload job). Once done, download at /api/data/quarantine/item/{id}/download/."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
item_id: { type: string, description: "Quarantined item UUID (from harfanglab-quarantine-search)" }
|
||||
required: [item_id]
|
||||
outputs_schema: { properties: {} }
|
||||
# ── Response: threat isolation & job lifecycle ────────────────────────────
|
||||
- id: isolate_from_threat
|
||||
name: harfanglab-isolate-from-threat
|
||||
description: "Network-isolate ALL agents involved in a threat. Destructive: cuts network access on every impacted endpoint."
|
||||
risk: destructive
|
||||
inputs_schema:
|
||||
properties:
|
||||
threat_id: { type: string, description: "Threat identifier" }
|
||||
required: [threat_id]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: deisolate_from_threat
|
||||
name: harfanglab-deisolate-from-threat
|
||||
description: "Remove network isolation from all agents involved in a threat."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
threat_id: { type: string, description: "Threat identifier" }
|
||||
required: [threat_id]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: job_cancel
|
||||
name: harfanglab-job-cancel
|
||||
description: "Cancel a running job by job ID."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
job_id: { type: string, description: "Job ID to cancel" }
|
||||
required: [job_id]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: job_relaunch
|
||||
name: harfanglab-job-relaunch
|
||||
description: "Relaunch a cancelled or errored job by job ID."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
job_id: { type: string, description: "Job ID to relaunch" }
|
||||
required: [job_id]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: wait_for_job
|
||||
name: harfanglab-wait-for-job
|
||||
description: "Poll a job until all its instances finish (done/error/canceled) or the timeout elapses. Returns {done: true|false, job: {...}} — never fails on timeout, so playbooks can branch on done."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
job_id: { type: string, description: "Job ID to wait for" }
|
||||
timeout: { type: number, description: "Max seconds to wait (default 300)" }
|
||||
interval: { type: number, description: "Seconds between polls (default 10)" }
|
||||
required: [job_id]
|
||||
outputs_schema: { properties: {} }
|
||||
# ── Triage: alerts ────────────────────────────────────────────────────────
|
||||
- id: get_alerts
|
||||
name: harfanglab-get-alerts
|
||||
description: "Search security events (alerts) for playbooks: by IDs, hostname, agent, threat, rule name, level/status/type, date range. Returns {count, results}."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_ids: { type: string, description: "Comma-separated alert IDs (fetched individually; other filters ignored)" }
|
||||
hostname: { type: string, description: "Endpoint hostname" }
|
||||
agent_id: { type: string, description: "Agent unique identifier" }
|
||||
threat_id: { type: string, description: "Threat key — alerts aggregated under this threat" }
|
||||
rule_name: { type: string, description: "Detection rule name" }
|
||||
level: { type: string, description: "Comma-separated severities (low, medium, high, critical)" }
|
||||
status: { type: string, description: "Comma-separated statuses (new, investigating, closed, false_positive)" }
|
||||
alert_type: { type: string, description: "Comma-separated alert types (sigma, yara, hlai, vt, ransom, ioc...)" }
|
||||
from_date: { type: string, description: "ISO8601 lower bound on alert_time" }
|
||||
to_date: { type: string, description: "ISO8601 upper bound on alert_time" }
|
||||
search: { type: string, description: "Free-text search term" }
|
||||
ordering: { type: string, description: "Sort field (default -alert_time)" }
|
||||
limit: { type: number, description: "Max results (default 100)" }
|
||||
offset: { type: number, description: "Pagination offset (default 0)" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
ingest:
|
||||
results_path: results
|
||||
dedup_key: id
|
||||
incremental_field: from_date
|
||||
- id: alert_tag
|
||||
name: harfanglab-alert-tag
|
||||
description: "Change the status of one or more alerts in bulk (new, investigating, false_positive, closed), optionally with a comment."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_ids: { type: string, description: "Comma-separated alert IDs" }
|
||||
new_status: { type: string, description: "new | investigating | false_positive | closed" }
|
||||
new_comment: { type: string, description: "Optional comment attached to the status change" }
|
||||
required: [alert_ids, new_status]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: alert_comment
|
||||
name: harfanglab-alert-comment
|
||||
description: "Add a comment to a security event (alert)."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_id: { type: string, description: "Alert ID" }
|
||||
comment: { type: string, description: "Comment text" }
|
||||
required: [alert_id, comment]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: alert_details
|
||||
name: harfanglab-alert-details
|
||||
description: "Get the full details of an alert (raw Sigma/YARA rule, binary availability, process unique id...)."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_id: { type: string, description: "Alert ID" }
|
||||
required: [alert_id]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: alert_history
|
||||
name: harfanglab-alert-history
|
||||
description: "Get the status-change history of an alert."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_id: { type: string, description: "Alert ID" }
|
||||
required: [alert_id]
|
||||
outputs_schema: { properties: {} }
|
||||
# ── Triage: threats ───────────────────────────────────────────────────────
|
||||
- id: get_threat
|
||||
name: harfanglab-get-threat
|
||||
description: "Get a single threat by ID."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
threat_id: { type: string, description: "Threat identifier" }
|
||||
required: [threat_id]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: threat_status
|
||||
name: harfanglab-threat-status
|
||||
description: "Change the status of one or more threats (new, investigating, false_positive, closed), optionally propagating to their security events."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
threat_ids: { type: string, description: "Comma-separated threat IDs" }
|
||||
new_status: { type: string, description: "new | investigating | false_positive | closed" }
|
||||
tag_security_events: { type: boolean, description: "Also apply the status to the threat's security events (default false)" }
|
||||
required: [threat_ids, new_status]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: threat_level
|
||||
name: harfanglab-threat-level
|
||||
description: "Change the severity level of one or more threats (low, medium, high, critical)."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
threat_ids: { type: string, description: "Comma-separated threat IDs" }
|
||||
new_level: { type: string, description: "low | medium | high | critical" }
|
||||
required: [threat_ids, new_level]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: threat_comment
|
||||
name: harfanglab-threat-comment
|
||||
description: "Add a comment to a threat."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
threat_id: { type: string, description: "Threat identifier" }
|
||||
comment: { type: string, description: "Comment text" }
|
||||
required: [threat_id, comment]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: threat_note_set
|
||||
name: harfanglab-threat-note-set
|
||||
description: "Create or update the analyst note of a threat (title + content). Upserts: creates the note, or replaces it if one exists."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
threat_id: { type: string, description: "Threat identifier" }
|
||||
title: { type: string, description: "Note title" }
|
||||
content: { type: string, description: "Note content" }
|
||||
required: [threat_id, title, content]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: threat_history
|
||||
name: harfanglab-threat-history
|
||||
description: "Get the status-change history of a threat."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
threat_id: { type: string, description: "Threat identifier" }
|
||||
required: [threat_id]
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
# ── Connectivity test ─────────────────────────────────────────────────────
|
||||
- id: test_connection
|
||||
name: harfanglab-test-connection
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
name: "HarfangLab Alerts → OCSF"
|
||||
description: "Maps a HarfangLab EDR alert (/api/data/alert/alert/Alert/, results_path = results) to OCSF. The matched process is the actor (actor.*). Detail sub-objects and the eventlog payload are only present for their event kind; absent paths are skipped at ingestion."
|
||||
field_mappings:
|
||||
title: "rule_name"
|
||||
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
||||
description: "alert_type"
|
||||
# results_path = results; source_path is JSONata over ONE alert object.
|
||||
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
||||
ocsf:
|
||||
# ── Endpoint ──────────────────────────────────────────────────────
|
||||
- { source_path: "agent.hostname", ocsf_field: "src_endpoint.hostname" }
|
||||
- { source_path: "agent.domainname", ocsf_field: "src_endpoint.domain" }
|
||||
# Before details_connection.SourceIp below, so a network alert's own source
|
||||
# address wins over the agent's; on every other alert this is the only IP.
|
||||
- { source_path: "agent.ipaddress", ocsf_field: "src_endpoint.ip" }
|
||||
- { source_path: "agent.osproducttype", ocsf_field: "src_endpoint.os.name" }
|
||||
- { source_path: "agent.ostype", ocsf_field: "src_endpoint.os.type" }
|
||||
- { source_path: "agent.osversion", ocsf_field: "device.os.version" }
|
||||
- { source_path: "agent.agentid", ocsf_field: "device.uid" }
|
||||
- { source_path: "agent.ipaddress", ocsf_field: "device.ip" }
|
||||
- { source_path: "agent.dnsdomainname", ocsf_field: "device.domain" }
|
||||
- { source_path: "agent.producttype", ocsf_field: "device.type" } # workstation | server
|
||||
# ── Finding ───────────────────────────────────────────────────────
|
||||
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
||||
- { source_path: "alert_unique_id", ocsf_field: "metadata.uid" }
|
||||
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
|
||||
- { source_path: "msg", ocsf_field: "finding_info.desc" }
|
||||
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
|
||||
- { source_path: "alert_time", ocsf_field: "time" }
|
||||
- { source_path: "detection_date", ocsf_field: "finding_info.first_seen_time" }
|
||||
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
|
||||
- { source_path: "ingestion_date", ocsf_field: "metadata.logged_time" }
|
||||
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" }
|
||||
- { source_path: "rule_id", ocsf_field: "finding_info.analytic.uid" }
|
||||
- { source_path: "rule_content", ocsf_field: "finding_info.analytic.desc" }
|
||||
- { source_path: "detection_origin", ocsf_field: "finding_info.analytic.category" }
|
||||
- { source_path: "alert_subtype", ocsf_field: "unmapped.alert_subtype" } # eventlog | connection | dns | file | registry | url_request
|
||||
- { source_path: "status", ocsf_field: "status" }
|
||||
- { source_path: "maturity", ocsf_field: "status_detail" }
|
||||
- { source_path: "threat_type", ocsf_field: "unmapped.threat_type" }
|
||||
- { source_path: "level", ocsf_field: "severity" } # low | medium | high | critical
|
||||
- { source_path: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
|
||||
- { source_path: "agent.version", ocsf_field: "unmapped.agent_version" }
|
||||
# groups is an array of {id, name}; the [ ] keeps an array shape when a single
|
||||
# group matches, since JSONata collapses a singleton sequence to a scalar.
|
||||
- { source_path: "groups ? [groups.name]", ocsf_field: "unmapped.agent_groups" }
|
||||
- { source_path: "score", ocsf_field: "risk_score" }
|
||||
- { source_path: "confidence_int", ocsf_field: "confidence_score" }
|
||||
- { source_path: "threat_key", ocsf_field: "finding_info.uid_alt" } # threat aggregation key
|
||||
- { source_path: "mitre_cells", ocsf_field: "attacks" } # ["TAxxxx:Tyyyy", ...] best-effort
|
||||
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
||||
- { source_path: "log_type", ocsf_field: "metadata.log_name" }
|
||||
# ── Matched process — the actor ───────────────────────────────────
|
||||
# image_name is the one process path present on EVERY alert subtype, and the
|
||||
# only one on those carrying neither a `process` object nor an event log. When
|
||||
# a threat matched, HarfangLab prefixes it with the threat name in brackets —
|
||||
# "[PUADlManager:Win32/OfferCore] C:\Windows\explorer.exe" — so both halves are
|
||||
# pulled out, each guarded on the bracket actually being there: without the
|
||||
# $contains guard, $substringBefore/$substringAfter fall through on an
|
||||
# unprefixed path and write the whole path into malware.name.
|
||||
# These come first in the block so the more specific sources below (the matched
|
||||
# process object, then the event log's own fields) overwrite them when present.
|
||||
- { source_path: "$contains(image_name, '] ') ? $substringAfter(image_name, '] ') : image_name", ocsf_field: "actor.process.file.path" }
|
||||
- { source_path: "$split($contains(image_name, '] ') ? $substringAfter(image_name, '] ') : image_name, '\\\\')[-1]", ocsf_field: "actor.process.name" }
|
||||
- { source_path: "$contains(image_name, '[') ? $substringBefore($substringAfter(image_name, '['), ']')", ocsf_field: "malware.name" }
|
||||
- { source_path: "process.process_name", ocsf_field: "actor.process.name" }
|
||||
- { source_path: "process.image_name", ocsf_field: "actor.process.file.path" }
|
||||
- { source_path: "process.commandline", ocsf_field: "actor.process.cmd_line" }
|
||||
- { source_path: "process.username", ocsf_field: "actor.user.name" }
|
||||
- { source_path: "process.integrity_level", ocsf_field: "actor.process.integrity" }
|
||||
- { source_path: "process.pid", ocsf_field: "actor.process.pid" }
|
||||
- { source_path: "process.hashes.sha256", ocsf_field: "actor.process.file.hashes.sha256" }
|
||||
- { source_path: "process.hashes.sha1", ocsf_field: "actor.process.file.hashes.sha1" }
|
||||
- { source_path: "process.hashes.md5", ocsf_field: "actor.process.file.hashes.md5" }
|
||||
- { source_path: "process.parent_image", ocsf_field: "actor.process.parent_process.name" }
|
||||
- { source_path: "process.parent_commandline", ocsf_field: "actor.process.parent_process.cmd_line" }
|
||||
# ── Event details: network connection ─────────────────────────────
|
||||
- { source_path: "details_connection.SourceIp", ocsf_field: "src_endpoint.ip" }
|
||||
- { source_path: "details_connection.SourcePort", ocsf_field: "src_endpoint.port" }
|
||||
- { source_path: "details_connection.DestinationIp", ocsf_field: "dst_endpoint.ip" }
|
||||
- { source_path: "details_connection.DestinationPort", ocsf_field: "dst_endpoint.port" }
|
||||
- { source_path: "details_connection.Protocol", ocsf_field: "connection_info.protocol_name" }
|
||||
# ── Event details: DNS ────────────────────────────────────────────
|
||||
- { source_path: "details_dns_resolution.requested_name", ocsf_field: "query.hostname" }
|
||||
- { source_path: "details_dns_resolution.query_type", ocsf_field: "query.type" }
|
||||
# ── Event details: file ───────────────────────────────────────────
|
||||
- { source_path: "details_file.target_filename", ocsf_field: "file.path" }
|
||||
# ── Event details: registry ───────────────────────────────────────
|
||||
- { source_path: "details_registry.target_object", ocsf_field: "reg_key.path" }
|
||||
- { source_path: "details_registry.registry_value_type", ocsf_field: "reg_value.type" }
|
||||
- { source_path: "details_registry.data_string_added", ocsf_field: "reg_value.data" }
|
||||
# ── Event details: URL request ────────────────────────────────────
|
||||
- { source_path: "details_url_request.url", ocsf_field: "url.url_string" }
|
||||
- { source_path: "details_url_request.host", ocsf_field: "url.hostname" }
|
||||
- { source_path: "details_url_request.user_agent", ocsf_field: "http_request.user_agent" }
|
||||
- { source_path: "details_url_request.verb", ocsf_field: "http_request.http_method" }
|
||||
# ── Event details: Windows event log (alert_subtype = eventlog) ───
|
||||
# A sigma rule matching a Windows event log carries the whole record under
|
||||
# `eventlog`, and the provider's own field names — spaces included — under
|
||||
# eventlog.event_data. Backticks quote those names in JSONata. The block below
|
||||
# is Microsoft Defender (events 1116/1117), where the detection names the
|
||||
# malware, the file it was found in, the process that touched it and the user
|
||||
# it ran as. None of it exists on other subtypes, so it is skipped there.
|
||||
- { source_path: "eventlog.source_name", ocsf_field: "metadata.log_provider" }
|
||||
- { source_path: "eventlog.event_id", ocsf_field: "metadata.event_code" }
|
||||
- { source_path: "eventlog.event_data.`Product Name`", ocsf_field: "metadata.product.name" }
|
||||
- { source_path: "eventlog.event_data.`Product Version`", ocsf_field: "metadata.product.version" }
|
||||
- { source_path: "eventlog.event_data.`Threat Name`", ocsf_field: "malware.name" }
|
||||
- { source_path: "eventlog.event_data.`Threat ID`", ocsf_field: "malware.uid" }
|
||||
- { source_path: "eventlog.event_data.`Severity Name`", ocsf_field: "malware.severity" }
|
||||
- { source_path: "eventlog.event_data.`Product Name`", ocsf_field: "malware.provider" }
|
||||
- { source_path: "eventlog.event_data.`Category Name` ? [eventlog.event_data.`Category Name`]", ocsf_field: "malware.classifications" }
|
||||
# Defender prefixes the detected path with "file:_". $substringAfter returns the
|
||||
# whole string when the prefix is absent, so stripping it is safe either way.
|
||||
- { source_path: "$substringAfter(eventlog.event_data.`Path`, 'file:_')", ocsf_field: "file.path" }
|
||||
- { source_path: "$substringAfter(eventlog.event_data.`Path`, 'file:_')", ocsf_field: "malware.path" }
|
||||
- { source_path: "$split($substringAfter(eventlog.event_data.`Path`, 'file:_'), '\\\\')[-1]", ocsf_field: "file.name" }
|
||||
- { source_path: "eventlog.event_data.`Process Name`", ocsf_field: "actor.process.file.path" }
|
||||
- { source_path: "$split(eventlog.event_data.`Process Name`, '\\\\')[-1]", ocsf_field: "actor.process.name" }
|
||||
- { source_path: "eventlog.event_data.`Detection User`", ocsf_field: "actor.user.name" }
|
||||
- { source_path: "eventlog.event_data.`Action Name`", ocsf_field: "action" }
|
||||
- { source_path: "eventlog.event_data.`Detection ID`", ocsf_field: "unmapped.defender.detection_uid" }
|
||||
- { source_path: "eventlog.event_data.`Source Name`", ocsf_field: "unmapped.defender.detection_source" } # real-time protection, scheduled scan…
|
||||
- { source_path: "eventlog.event_data.`Execution Name`", ocsf_field: "unmapped.defender.execution_state" }
|
||||
- { source_path: "eventlog.event_data.`Security intelligence Version`", ocsf_field: "unmapped.defender.signature_version" }
|
||||
# Alert fields deliberately left unmapped: `username` (actor.user.name is taken by the
|
||||
# more specific process.username), remaining details_* sub-objects (no clean OCSF home),
|
||||
# eventlog.threat_process_name (the same prefixed string as image_name, already split
|
||||
# above), `eventlog.user` (the account that WROTE the
|
||||
# record, usually SYSTEM — indexing it would create a junk user entity),
|
||||
# `eventlog.computer_name` (the FQDN of the host already carried by agent.hostname; two
|
||||
# spellings of one machine split correlation), `level_int` / `quarantine` / `execution`
|
||||
# and Defender's Action ID / Severity ID / Category ID (console enums on scales OCSF does
|
||||
# not share), `detection_timestamp` and Defender's Detection Time (duplicates of
|
||||
# detection_date), `confidence` (the string form of confidence_int, already mapped), and
|
||||
# Defender's Error/Status/Origin/Remediation/Unused fields.
|
||||
@@ -15,13 +15,26 @@ ocsf:
|
||||
- { source_path: "agent.osversion", ocsf_field: "device.os.version" }
|
||||
- { source_path: "agent.agentid", ocsf_field: "device.uid" }
|
||||
# ── Finding ───────────────────────────────────────────────────────
|
||||
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
||||
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
|
||||
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
|
||||
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
|
||||
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" }
|
||||
- { source_path: "status", ocsf_field: "status" }
|
||||
- { source_path: "maturity", ocsf_field: "status_detail" }
|
||||
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
||||
- { source_path: "alert_unique_id", ocsf_field: "metadata.uid" }
|
||||
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
|
||||
- { source_path: "msg", ocsf_field: "finding_info.desc" }
|
||||
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
|
||||
- { source_path: "detection_date", ocsf_field: "finding_info.first_seen_time" }
|
||||
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
|
||||
- { source_path: "ingestion_date", ocsf_field: "metadata.logged_time" }
|
||||
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" }
|
||||
- { source_path: "rule_id", ocsf_field: "finding_info.analytic.uid" }
|
||||
- { source_path: "rule_content", ocsf_field: "finding_info.analytic.desc" }
|
||||
- { source_path: "detection_origin", ocsf_field: "finding_info.analytic.category" }
|
||||
- { source_path: "status", ocsf_field: "status" }
|
||||
- { source_path: "maturity", ocsf_field: "status_detail" }
|
||||
- { source_path: "score", ocsf_field: "risk_score" }
|
||||
- { source_path: "confidence_int", ocsf_field: "confidence_score" }
|
||||
- { source_path: "threat_key", ocsf_field: "finding_info.uid_alt" } # threat aggregation key
|
||||
- { source_path: "mitre_cells", ocsf_field: "attacks" } # ["TAxxxx:Tyyyy", ...] best-effort
|
||||
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
||||
- { source_path: "log_type", ocsf_field: "metadata.log_name" }
|
||||
# ── Matched process — the actor ───────────────────────────────────
|
||||
- { source_path: "process.process_name", ocsf_field: "actor.process.name" }
|
||||
- { source_path: "process.image_name", ocsf_field: "actor.process.file.path" }
|
||||
@@ -34,3 +47,25 @@ ocsf:
|
||||
- { source_path: "process.hashes.md5", ocsf_field: "actor.process.file.hashes.md5" }
|
||||
- { source_path: "process.parent_image", ocsf_field: "actor.process.parent_process.name" }
|
||||
- { source_path: "process.parent_commandline", ocsf_field: "actor.process.parent_process.cmd_line" }
|
||||
# ── Event details: network connection ─────────────────────────────
|
||||
- { source_path: "details_connection.SourceIp", ocsf_field: "src_endpoint.ip" }
|
||||
- { source_path: "details_connection.SourcePort", ocsf_field: "src_endpoint.port" }
|
||||
- { source_path: "details_connection.DestinationIp", ocsf_field: "dst_endpoint.ip" }
|
||||
- { source_path: "details_connection.DestinationPort", ocsf_field: "dst_endpoint.port" }
|
||||
- { source_path: "details_connection.Protocol", ocsf_field: "connection_info.protocol_name" }
|
||||
# ── Event details: DNS ────────────────────────────────────────────
|
||||
- { source_path: "details_dns_resolution.requested_name", ocsf_field: "query.hostname" }
|
||||
- { source_path: "details_dns_resolution.query_type", ocsf_field: "query.type" }
|
||||
# ── Event details: file ───────────────────────────────────────────
|
||||
- { source_path: "details_file.target_filename", ocsf_field: "file.path" }
|
||||
# ── Event details: registry ───────────────────────────────────────
|
||||
- { source_path: "details_registry.target_object", ocsf_field: "reg_key.path" }
|
||||
- { source_path: "details_registry.registry_value_type", ocsf_field: "reg_value.type" }
|
||||
- { source_path: "details_registry.data_string_added", ocsf_field: "reg_value.data" }
|
||||
# ── Event details: URL request ────────────────────────────────────
|
||||
- { source_path: "details_url_request.url", ocsf_field: "url.url_string" }
|
||||
- { source_path: "details_url_request.host", ocsf_field: "url.hostname" }
|
||||
- { source_path: "details_url_request.user_agent", ocsf_field: "http_request.user_agent" }
|
||||
- { source_path: "details_url_request.verb", ocsf_field: "http_request.http_method" }
|
||||
# Alert fields deliberately left unmapped: `username` (actor.user.name is taken by the
|
||||
# more specific process.username), remaining details_* sub-objects (no clean OCSF home).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: "HarfangLab Threats → OCSF"
|
||||
description: "Maps a raw HarfangLab EDR threat (/api/data/alert/alert/Threat/, results_path = results) to OCSF. Uses native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics); the agents/impacted_users/rules arrays are only populated by external enrichment, which this connector does not perform."
|
||||
description: "Maps a raw HarfangLab EDR threat (/api/data/alert/alert/Threat/, results_path = results) to OCSF. Uses native threat fields; top_agents/top_impacted_users/top_rules are ranked aggregates — the first (most impacted) entry feeds the endpoint/user/rule fields. mitre_tactics is a map keyed by tactic slug (e.g. attack.persistence); its keys are extracted with $keys()."
|
||||
field_mappings:
|
||||
title: "slug"
|
||||
title: "top_rules[0].rule_name ? top_rules[0].rule_name & ' — ' & slug : slug"
|
||||
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
|
||||
description: "status"
|
||||
description: "top_rules[0].rule_msg ? top_rules[0].rule_msg : status"
|
||||
# results_path = results; source_path is JSONata over ONE threat object.
|
||||
# Paths absent from a given threat are skipped at ingestion, so extra entries are safe.
|
||||
ocsf:
|
||||
@@ -11,13 +11,54 @@ ocsf:
|
||||
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
||||
- { source_path: "slug", ocsf_field: "finding_info.title" }
|
||||
- { 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: "last_seen", ocsf_field: "finding_info.last_seen_time" }
|
||||
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
|
||||
- { source_path: "status", ocsf_field: "status" }
|
||||
# ── MITRE ATT&CK (native mitre_tactics list) ──────────────────────
|
||||
- { source_path: "mitre_tactics[0]", ocsf_field: "attacks.tactic.name" }
|
||||
# ── Top impacted endpoint / user / rule (native aggregates) ───────
|
||||
- { source_path: "top_agents[0].agent_hostname", ocsf_field: "src_endpoint.hostname" }
|
||||
# x ? x guards: these are null on open/mono-tenant threats — null is falsy in
|
||||
# JSONata, so the condition yields undefined and the entry is skipped instead
|
||||
# of writing a literal null into the OCSF document.
|
||||
- { source_path: "closed_date ? closed_date", ocsf_field: "end_time" }
|
||||
- { 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: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
|
||||
# ── Aggregate counts ──────────────────────────────────────────────
|
||||
- { source_path: "total_security_event_count", ocsf_field: "finding_info.related_events_count" }
|
||||
- { source_path: "agent_count", ocsf_field: "unmapped.agent_count" }
|
||||
- { source_path: "impacted_user_count", ocsf_field: "unmapped.impacted_user_count" }
|
||||
- { source_path: "rule_count", ocsf_field: "unmapped.rule_count" }
|
||||
# ── MITRE ATT&CK — mitre_tactics is a MAP {"attack.persistence": {...}} ──
|
||||
# [$keys(...)] keeps an array shape even for a single tactic (JSONata collapses
|
||||
# singleton sequences to a scalar); the guard skips threats without tactics.
|
||||
- { source_path: "mitre_tactics ? [$keys(mitre_tactics)]", ocsf_field: "attacks" }
|
||||
# ── Top impacted endpoint (most security events first) ────────────
|
||||
- { source_path: "top_agents[0].agent_hostname", ocsf_field: "src_endpoint.hostname" }
|
||||
- { source_path: "top_agents[0].agent_id", ocsf_field: "device.uid" }
|
||||
- { source_path: "top_agents[0].agent_osproducttype", ocsf_field: "src_endpoint.os.name" }
|
||||
- { source_path: "top_agents[0].agent_ostype", ocsf_field: "src_endpoint.os.type" }
|
||||
- { source_path: "top_agents[0].agent_osversion", ocsf_field: "device.os.version" }
|
||||
# Whether the agent is still reachable decides what response is even possible
|
||||
# (isolate, collect, kill) — the first thing asked of a threat, so it travels
|
||||
# with the endpoint rather than being left to the console.
|
||||
- { source_path: "top_agents[0].agent_status", ocsf_field: "unmapped.agent_status" }
|
||||
# ── Top impacted user ─────────────────────────────────────────────
|
||||
- { source_path: "top_impacted_users[0].user_name", ocsf_field: "user.name" }
|
||||
- { source_path: "top_rules[0].rule_name", ocsf_field: "rule.name" }
|
||||
- { source_path: "top_impacted_users[0].user_sid", ocsf_field: "user.uid" }
|
||||
# ── Top rule ──────────────────────────────────────────────────────
|
||||
- { source_path: "top_rules[0].rule_name", ocsf_field: "rule.name" }
|
||||
- { source_path: "top_rules[0].rule_id", ocsf_field: "rule.uid" }
|
||||
- { source_path: "top_rules[0].rule_type", ocsf_field: "finding_info.analytic.type" }
|
||||
- { source_path: "top_rules[0].rule_msg", ocsf_field: "finding_info.desc" }
|
||||
# Deliberately unmapped: old_id/origin_stack (legacy/console internals),
|
||||
# top_rules[0].id (numeric console id — rule_id is the stable uid, mapped above),
|
||||
# per-item security_event_count (equals the total while agent_count is 1, and the total
|
||||
# is already mapped), top_rules[0].rule_level (the same value as the threat's own level,
|
||||
# mapped to severity above), rule_os (the same value as the agent's ostype), the rule's
|
||||
# creation_date/last_update (rule bookkeeping, not incident evidence),
|
||||
# mitre_tactics[*].security_event_percentage (the tactic names are what pivot; the
|
||||
# per-tactic weights have no OCSF home), top_rules[0].description (duplicate of rule_msg),
|
||||
# and entries [1..] of the top_* arrays (OCSF detection_finding models a single primary
|
||||
# endpoint/user/rule; counts convey scale).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/comment/"
|
||||
print(json.dumps(request("POST", url, headers, {"comment": inputs.get("comment")})))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,32 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/details/"
|
||||
print(json.dumps(request("GET", url, headers)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,32 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/history/"
|
||||
print(json.dumps(request("GET", url, headers)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,41 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def csv(v):
|
||||
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {
|
||||
"ids": csv(inputs.get("alert_ids")),
|
||||
"new_status": inputs.get("new_status"),
|
||||
}
|
||||
if inputs.get("new_comment"):
|
||||
body["new_comment"] = inputs.get("new_comment")
|
||||
print(json.dumps(request("POST", base + "/api/data/alert/alert/Alert/tag/", headers, body)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,47 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def csv(v):
|
||||
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
scan_type = str(inputs.get("scan_type") or "quick").lower()
|
||||
if scan_type not in ("full", "quick", "resource"):
|
||||
print(json.dumps({"error": "invalid scan_type: " + scan_type + " (expected full, quick or resource)"}))
|
||||
sys.exit(1)
|
||||
params = {"type": scan_type}
|
||||
paths = csv(inputs.get("paths"))
|
||||
if paths:
|
||||
params["paths"] = paths
|
||||
body = {
|
||||
"targets": {"agents": [inputs.get("agent_id")]},
|
||||
"actions": [{"value": "avScan", "params": params}],
|
||||
}
|
||||
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,32 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/deisolation/"
|
||||
print(json.dumps(request("POST", url, headers)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,35 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {
|
||||
"targets": {"agents": [inputs.get("agent_id")]},
|
||||
"actions": [{"value": "filepathDeleter", "params": [{"filepath": inputs.get("filepath")}]}],
|
||||
}
|
||||
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,35 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {
|
||||
"targets": {"agents": [inputs.get("agent_id")]},
|
||||
"actions": [{"value": "deleteScheduledTask", "params": [{"schtask_uri": inputs.get("schtask_uri")}]}],
|
||||
}
|
||||
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,35 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {
|
||||
"targets": {"agents": [inputs.get("agent_id")]},
|
||||
"actions": [{"value": "deleteService", "params": [{"service_name": inputs.get("service_name")}]}],
|
||||
}
|
||||
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,55 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
FIELD_TYPES = {
|
||||
"name": 0, "path": 1, "username": 3, "commandline": 4,
|
||||
"parent_name": 10, "parent_path": 11, "parent_commandline": 14,
|
||||
"pid": 15, "md5": 16, "sha1": 17, "sha256": 18,
|
||||
}
|
||||
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
field = str(inputs.get("field") or "name").lower()
|
||||
operator = str(inputs.get("operator") or "eq").lower()
|
||||
if field not in FIELD_TYPES:
|
||||
print(json.dumps({"error": "invalid field: " + field + " (expected one of " + ", ".join(sorted(FIELD_TYPES)) + ")"}))
|
||||
sys.exit(1)
|
||||
if operator not in OPERATORS:
|
||||
print(json.dumps({"error": "invalid operator: " + operator + " (expected one of eq, not_eq, contains, not_contains)"}))
|
||||
sys.exit(1)
|
||||
criteria = [{
|
||||
"param_type": FIELD_TYPES[field],
|
||||
"param_operator": OPERATORS[operator],
|
||||
"param_value": str(inputs.get("value") or ""),
|
||||
}]
|
||||
body = {
|
||||
"targets": {"agents": [inputs.get("agent_id")]},
|
||||
"actions": [{"value": "searchProcessDumper", "params": {"values": criteria}}],
|
||||
}
|
||||
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,66 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def qs(params):
|
||||
clean = {}
|
||||
for k, v in params.items():
|
||||
if v is None or v == "" or v == []:
|
||||
continue
|
||||
if isinstance(v, bool):
|
||||
v = "true" if v else "false"
|
||||
clean[k] = v
|
||||
return ("?" + urllib.parse.urlencode(clean)) if clean else ""
|
||||
|
||||
|
||||
def csv(v):
|
||||
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
ids = csv(inputs.get("alert_ids"))
|
||||
if ids:
|
||||
results = [request("GET", base + "/api/data/alert/alert/Alert/" + i + "/", headers) for i in ids]
|
||||
print(json.dumps({"count": len(results), "results": results}))
|
||||
return
|
||||
params = {
|
||||
"ordering": inputs.get("ordering") or "-alert_time",
|
||||
"limit": int(inputs.get("limit") or 100),
|
||||
"offset": int(inputs.get("offset") or 0),
|
||||
"search": inputs.get("search"),
|
||||
"agent.hostname": inputs.get("hostname"),
|
||||
"agent.agentid": inputs.get("agent_id"),
|
||||
"threat_key": inputs.get("threat_id"),
|
||||
"rule_name": inputs.get("rule_name"),
|
||||
"level": ",".join(csv(inputs.get("level"))) or None,
|
||||
"status": ",".join(csv(inputs.get("status"))) or None,
|
||||
"alert_type": ",".join(csv(inputs.get("alert_type"))) or None,
|
||||
"alert_time__gte": inputs.get("from_date"),
|
||||
"alert_time__lte": inputs.get("to_date"),
|
||||
}
|
||||
print(json.dumps(request("GET", base + "/api/data/alert/alert/Alert/" + qs(params), headers)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,32 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/"
|
||||
print(json.dumps(request("GET", url, headers)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,32 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
base = secrets.get("url", "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/isolation/"
|
||||
print(json.dumps(request("POST", url, headers)))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user