feat(cortex-xdr): incident write-back and external alert push
Add three commands to the Cortex XDR integration (v1.2.0): - update_incident: change status, severity, assignment and resolve comment on an incident (closes the SOAR ingest→action loop). - insert_parsed_alerts: push external parsed alerts into Cortex XDR. - insert_cef_alerts: push external CEF-format alerts. Scripts follow the existing self-contained standard/advanced auth pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
id: cortex_xdr
|
id: cortex_xdr
|
||||||
name: Cortex XDR
|
name: Cortex XDR
|
||||||
version: 1.1.0
|
version: 1.2.0
|
||||||
description: "Palo Alto Cortex XDR (public API v1) — incident ingestion, endpoint isolation/scan/delete/tagging, RTR scripts, hash block/allow lists, file quarantine/restore/retrieval, alert exclusions, device-control violations, audits, distributions and RBAC/risk."
|
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.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication."
|
changelog: "1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication."
|
||||||
category: endpoint
|
category: endpoint
|
||||||
|
|
||||||
# Per-instance configuration. The base URL is the tenant API root, e.g.
|
# Per-instance configuration. The base URL is the tenant API root, e.g.
|
||||||
@@ -67,6 +67,20 @@ commands:
|
|||||||
alerts_limit: { type: number, description: "Max alerts to return (default 1000)" }
|
alerts_limit: { type: number, description: "Max alerts to return (default 1000)" }
|
||||||
required: [incident_id]
|
required: [incident_id]
|
||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
|
- id: update_incident
|
||||||
|
name: cortex-xdr-update-incident
|
||||||
|
description: "Update an incident's status, severity, assignment and resolve comment (write-back / close the loop)."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
incident_id: { type: string, description: "Incident ID to update" }
|
||||||
|
status: { type: string, description: "New status (new, under_investigation, resolved_threat_handled, resolved_known_issue, resolved_duplicate, resolved_false_positive, resolved_true_positive, resolved_security_testing, resolved_auto)" }
|
||||||
|
manual_severity: { type: string, description: "Override severity (low, medium, high, critical)" }
|
||||||
|
assigned_user_mail: { type: string, description: "Assignee email" }
|
||||||
|
assigned_user_pretty_name: { type: string, description: "Assignee display name" }
|
||||||
|
unassign: { type: boolean, description: "Clear the assignment when true" }
|
||||||
|
resolve_comment: { type: string, description: "Resolution comment (shown when resolving)" }
|
||||||
|
required: [incident_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
- id: get_alerts
|
- id: get_alerts
|
||||||
name: cortex-xdr-get-alerts
|
name: cortex-xdr-get-alerts
|
||||||
description: "Retrieve alerts using a custom filter (get_alerts_by_filter_data). Returns rich alert objects."
|
description: "Retrieve alerts using a custom filter (get_alerts_by_filter_data). Returns rich alert objects."
|
||||||
@@ -78,6 +92,22 @@ commands:
|
|||||||
limit: { type: number, description: "Maximum alerts to fetch (default 100)" }
|
limit: { type: number, description: "Maximum alerts to fetch (default 100)" }
|
||||||
required: []
|
required: []
|
||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
|
- id: insert_parsed_alerts
|
||||||
|
name: cortex-xdr-insert-parsed-alerts
|
||||||
|
description: "Push external alerts (parsed JSON objects) into Cortex XDR for correlation."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
alerts: { type: string, description: "JSON array of parsed alert objects (each with product, vendor, severity, alert_name, etc.)" }
|
||||||
|
required: [alerts]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: insert_cef_alerts
|
||||||
|
name: cortex-xdr-insert-cef-alerts
|
||||||
|
description: "Push external alerts in CEF format into Cortex XDR for correlation."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
alerts: { type: string, description: "CEF alert lines: a JSON array of CEF strings, or newline-separated CEF lines" }
|
||||||
|
required: [alerts]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||||
- id: get_endpoints
|
- id: get_endpoints
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
base = s.get("url", "").rstrip("/") + "/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 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 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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
raw = inputs.get("alerts")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
alerts = raw
|
||||||
|
else:
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
try:
|
||||||
|
# A JSON array of CEF strings, when provided that way.
|
||||||
|
parsed = json.loads(text)
|
||||||
|
alerts = parsed if isinstance(parsed, list) else [text]
|
||||||
|
except Exception:
|
||||||
|
# Otherwise treat the input as newline-separated CEF lines.
|
||||||
|
alerts = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||||
|
print(json.dumps(post("/alerts/insert_cef_alerts/", {"alerts": alerts})))
|
||||||
|
|
||||||
|
|
||||||
|
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,46 @@
|
|||||||
|
import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
base = s.get("url", "").rstrip("/") + "/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 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 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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
raw = inputs.get("alerts")
|
||||||
|
alerts = raw if isinstance(raw, list) else json.loads(raw or "[]")
|
||||||
|
if not isinstance(alerts, list):
|
||||||
|
alerts = [alerts]
|
||||||
|
print(json.dumps(post("/alerts/insert_parsed_alerts/", {"alerts": alerts})))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
base = s.get("url", "").rstrip("/") + "/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 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 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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
update_data = {}
|
||||||
|
if inputs.get("status"):
|
||||||
|
update_data["status"] = inputs["status"]
|
||||||
|
if inputs.get("manual_severity"):
|
||||||
|
update_data["manual_severity"] = inputs["manual_severity"]
|
||||||
|
if inputs.get("assigned_user_mail"):
|
||||||
|
update_data["assigned_user_mail"] = inputs["assigned_user_mail"]
|
||||||
|
if inputs.get("assigned_user_pretty_name"):
|
||||||
|
update_data["assigned_user_pretty_name"] = inputs["assigned_user_pretty_name"]
|
||||||
|
if str(inputs.get("unassign")).lower() in ("true", "1", "yes"):
|
||||||
|
update_data["assigned_user_mail"] = None
|
||||||
|
update_data["assigned_user_pretty_name"] = None
|
||||||
|
if inputs.get("resolve_comment"):
|
||||||
|
update_data["resolve_comment"] = inputs["resolve_comment"]
|
||||||
|
rd = {"incident_id": inputs.get("incident_id"), "update_data": update_data}
|
||||||
|
print(json.dumps(post("/incidents/update_incident/", rd)))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user