Compare commits
132 Commits
aca1db5cad
...
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 | |||
| 9cffefbb36 | |||
| f7791402a3 | |||
| c42a9cef03 | |||
| f6ff018f09 | |||
| c091800434 | |||
| 929b4166d6 | |||
| 381b42b6c7 | |||
| 5715f21603 | |||
| 7543a95711 | |||
| 7187e7b607 | |||
| 5bb465eb18 | |||
| a4dcd00cc7 | |||
| cf923301a7 | |||
| f833bfe3a5 | |||
| d6129f5d79 | |||
| c779c38dab | |||
| 4f94b59e17 | |||
| f09d00114f | |||
| a4241a5a5d | |||
| 232e310f77 | |||
| b231b648bc | |||
| 087b9abd19 | |||
| e91febd319 | |||
| 374e4184ff | |||
| 82353947d6 | |||
| ac47315b30 | |||
| 631292fcc7 | |||
| d8bb41b4c5 | |||
| 855f1b132c | |||
| 74bf6126fb | |||
| dd574e550b | |||
| 8a7e5a33a2 | |||
| 3c348b7601 | |||
| 2475c4b56e | |||
| 3be14f2f00 | |||
| 3a4d06441c | |||
| ce319c2fea | |||
| 8f3d9607df | |||
| a2f2b8efa0 | |||
| 3c282e8e53 | |||
| 771c90ef0d | |||
| 9fb5b65e4a | |||
| 16fe51ee99 | |||
| 5089e4466d | |||
| 8fa1ade945 | |||
| 54b077bfc9 | |||
| 6d38896892 | |||
| 223dfd34e3 | |||
| 83c1736d47 | |||
| bc100eef67 | |||
| 5f2e946c0a | |||
| 86fdd8483a | |||
| cd02c26b45 | |||
| ab68b3ae71 | |||
| 046fb79008 | |||
| 6548740890 | |||
| 16deec38f8 | |||
| cf950cf6e4 | |||
| 1949c98cf7 | |||
| addaeadae5 | |||
| b137e49be4 | |||
| c5699dd01a | |||
| 015131a2d1 | |||
| 82ad3a9369 | |||
| df8a2c1187 | |||
| 088694fdc2 | |||
| c1867a62a3 | |||
| c2cc4da675 | |||
| 2b5e7f37b4 | |||
| d469ed1d8f | |||
| 1ec065e91b | |||
| ee58358330 | |||
| 5bf679cfe0 | |||
| 70c66f7386 | |||
| 4020e13df9 | |||
| 2a1eb73115 | |||
| 81e39e8148 | |||
| d06ea406d3 | |||
| fe7dae8bd3 | |||
| 52356523c9 | |||
| a567558341 | |||
| 041cadade2 | |||
| 41e8aa8e7e | |||
| 0775500b15 | |||
| 9be28a2ff7 | |||
| 1292a8c798 | |||
| b6af7d1b68 | |||
| d5b7e38dd7 | |||
| 6de01f85bb | |||
| 86e7f11227 | |||
| 9f88891457 | |||
| 9892f2cf06 | |||
| be8bf9f822 |
@@ -0,0 +1,70 @@
|
|||||||
|
id: armis
|
||||||
|
name: Armis
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Armis (API v1) — device and asset visibility: search devices and alerts with AQL, read a device, and update an alert's status. Secret-key (token exchange) authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: search devices/alerts, get device, update alert status."
|
||||||
|
category: asset_management
|
||||||
|
|
||||||
|
# Per-instance configuration. The secret key is exchanged for a short-lived
|
||||||
|
# access token (sent as the 'Authorization' header).
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "Armis instance URL (e.g. https://yourtenant.armis.com)"
|
||||||
|
secret_key:
|
||||||
|
type: string
|
||||||
|
description: "Armis secret key"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
required:
|
||||||
|
- base_url
|
||||||
|
- secret_key
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: search_devices
|
||||||
|
name: armis-search-devices
|
||||||
|
description: "Search devices with an AQL expression."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
aql: { type: string, description: "AQL filter appended to 'in:devices' (e.g. riskLevel:High)" }
|
||||||
|
length: { type: number, description: "Max results (default 50)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_device
|
||||||
|
name: armis-get-device
|
||||||
|
description: "Get a single device by ID."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
device_id: { type: string, description: "Device ID" }
|
||||||
|
required: [device_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: search_alerts
|
||||||
|
name: armis-search-alerts
|
||||||
|
description: "Search alerts with an AQL expression."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
aql: { type: string, description: "AQL filter appended to 'in:alerts' (e.g. status:Unhandled)" }
|
||||||
|
length: { type: number, description: "Max results (default 50)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: update_alert
|
||||||
|
name: armis-update-alert
|
||||||
|
description: "Update an alert's status."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
alert_id: { type: string, description: "Alert ID" }
|
||||||
|
status: { type: string, description: "New status (UNHANDLED, SUPPRESSED, or RESOLVED)" }
|
||||||
|
required: [alert_id, status]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: armis-test-connection
|
||||||
|
description: "Verify the secret key via the token exchange (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _token(cfg):
|
||||||
|
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
tok = json.loads(r.read())
|
||||||
|
at = (tok.get("data") or {}).get("access_token")
|
||||||
|
if not at:
|
||||||
|
raise Exception("Token request failed: " + json.dumps(tok))
|
||||||
|
return at
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, token, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": token, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
token = _token(cfg)
|
||||||
|
print(json.dumps(fn(cfg, token, 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)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, token, inputs):
|
||||||
|
device_id = inputs.get("device_id")
|
||||||
|
if not device_id:
|
||||||
|
raise Exception("device_id is required")
|
||||||
|
return request("GET", "/devices/" + q(device_id) + "/", cfg, token)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _token(cfg):
|
||||||
|
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
tok = json.loads(r.read())
|
||||||
|
at = (tok.get("data") or {}).get("access_token")
|
||||||
|
if not at:
|
||||||
|
raise Exception("Token request failed: " + json.dumps(tok))
|
||||||
|
return at
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, token, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": token, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
token = _token(cfg)
|
||||||
|
print(json.dumps(fn(cfg, token, 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, token, inputs):
|
||||||
|
aql = inputs.get("aql")
|
||||||
|
length = inputs.get("length")
|
||||||
|
aql_str = "in:alerts" + ((" " + aql) if aql else "")
|
||||||
|
return request("GET", "/search/", cfg, token, params={"aql": aql_str, "length": int(length or 50)})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _token(cfg):
|
||||||
|
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
tok = json.loads(r.read())
|
||||||
|
at = (tok.get("data") or {}).get("access_token")
|
||||||
|
if not at:
|
||||||
|
raise Exception("Token request failed: " + json.dumps(tok))
|
||||||
|
return at
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, token, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": token, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
token = _token(cfg)
|
||||||
|
print(json.dumps(fn(cfg, token, 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, token, inputs):
|
||||||
|
aql = inputs.get("aql")
|
||||||
|
length = inputs.get("length")
|
||||||
|
aql_str = "in:devices" + ((" " + aql) if aql else "")
|
||||||
|
return request("GET", "/search/", cfg, token, params={"aql": aql_str, "length": int(length or 50)})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _token(cfg):
|
||||||
|
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
tok = json.loads(r.read())
|
||||||
|
at = (tok.get("data") or {}).get("access_token")
|
||||||
|
if not at:
|
||||||
|
raise Exception("Token request failed: " + json.dumps(tok))
|
||||||
|
return at
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, token, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": token, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
token = _token(cfg)
|
||||||
|
print(json.dumps(fn(cfg, token, 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, token, inputs):
|
||||||
|
request("GET", "/search/", cfg, token, params={"aql": "in:devices", "length": 1})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _token(cfg):
|
||||||
|
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
tok = json.loads(r.read())
|
||||||
|
at = (tok.get("data") or {}).get("access_token")
|
||||||
|
if not at:
|
||||||
|
raise Exception("Token request failed: " + json.dumps(tok))
|
||||||
|
return at
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, token, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": token, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
token = _token(cfg)
|
||||||
|
print(json.dumps(fn(cfg, token, 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)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, token, inputs):
|
||||||
|
alert_id = inputs.get("alert_id")
|
||||||
|
status = inputs.get("status")
|
||||||
|
if not alert_id:
|
||||||
|
raise Exception("alert_id is required")
|
||||||
|
if not status:
|
||||||
|
raise Exception("status is required")
|
||||||
|
resp = request("PATCH", "/alerts/" + q(alert_id) + "/", cfg, token, body={"status": status})
|
||||||
|
if not resp:
|
||||||
|
return {"ok": True, "alert_id": alert_id}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
id: automox
|
||||||
|
name: Automox
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Automox (API) — endpoint patch and configuration management: list and read devices, list policies, and queue a command (e.g. install updates or run a policy) on a device. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: list/get devices, list policies, run device command."
|
||||||
|
category: endpoint
|
||||||
|
|
||||||
|
# Per-instance configuration. Auth header 'Authorization: Bearer <api_key>'.
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
api_key:
|
||||||
|
type: string
|
||||||
|
description: "Automox API key"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
org_id:
|
||||||
|
type: string
|
||||||
|
description: "Organization ID"
|
||||||
|
required:
|
||||||
|
- api_key
|
||||||
|
- org_id
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: list_devices
|
||||||
|
name: automox-list-devices
|
||||||
|
description: "List devices."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
limit: { type: number, description: "Max devices (default 50)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_device
|
||||||
|
name: automox-get-device
|
||||||
|
description: "Get a single device by ID."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
device_id: { type: string, description: "Device (server) ID" }
|
||||||
|
required: [device_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_policies
|
||||||
|
name: automox-list-policies
|
||||||
|
description: "List policies."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: run_command
|
||||||
|
name: automox-run-command
|
||||||
|
description: "Queue a command on a device (e.g. InstallUpdate, Reboot)."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
device_id: { type: string, description: "Device (server) ID" }
|
||||||
|
command_type: { type: string, description: "Command type (e.g. InstallUpdate, Reboot, GetOS)" }
|
||||||
|
required: [device_id, command_type]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: automox-test-connection
|
||||||
|
description: "Verify the API key (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://console.automox.com/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
|
||||||
|
p["o"] = str(cfg.get("org_id", ""))
|
||||||
|
url = BASE + path + "?" + urllib.parse.urlencode(p)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
device_id = inputs.get("device_id")
|
||||||
|
if not device_id:
|
||||||
|
raise Exception("device_id is required")
|
||||||
|
return request("GET", "/servers/" + q(device_id), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://console.automox.com/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
|
||||||
|
p["o"] = str(cfg.get("org_id", ""))
|
||||||
|
url = BASE + path + "?" + urllib.parse.urlencode(p)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
limit = int(limit) if limit not in (None, "") else 50
|
||||||
|
return request("GET", "/servers", cfg, params={"limit": limit})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://console.automox.com/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
|
||||||
|
p["o"] = str(cfg.get("org_id", ""))
|
||||||
|
url = BASE + path + "?" + urllib.parse.urlencode(p)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
return request("GET", "/policies", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://console.automox.com/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
|
||||||
|
p["o"] = str(cfg.get("org_id", ""))
|
||||||
|
url = BASE + path + "?" + urllib.parse.urlencode(p)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
device_id = inputs.get("device_id")
|
||||||
|
if not device_id:
|
||||||
|
raise Exception("device_id is required")
|
||||||
|
command_type = inputs.get("command_type")
|
||||||
|
if not command_type:
|
||||||
|
raise Exception("command_type is required")
|
||||||
|
resp = request("POST", "/servers/" + q(device_id) + "/queues", cfg, body={"command_type_name": command_type})
|
||||||
|
if not resp:
|
||||||
|
return {"ok": True, "device_id": device_id, "command_type": command_type}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://console.automox.com/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
|
||||||
|
p["o"] = str(cfg.get("org_id", ""))
|
||||||
|
url = BASE + path + "?" + urllib.parse.urlencode(p)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
request("GET", "/servers", cfg, params={"limit": 1})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
id: axonius
|
||||||
|
name: Axonius
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Axonius (REST API) — cybersecurity asset management: query devices and users with AQL filters, get a device by ID, and count devices matching a filter. API-key + API-secret authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: list/get devices, device count, list users."
|
||||||
|
category: asset_management
|
||||||
|
|
||||||
|
# Per-instance configuration. Auth uses the 'api-key' and 'api-secret' headers.
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "Axonius instance URL (e.g. https://axonius.example.com)"
|
||||||
|
api_key:
|
||||||
|
type: string
|
||||||
|
description: "API key"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
api_secret:
|
||||||
|
type: string
|
||||||
|
description: "API secret"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
required:
|
||||||
|
- base_url
|
||||||
|
- api_key
|
||||||
|
- api_secret
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: list_devices
|
||||||
|
name: axonius-list-devices
|
||||||
|
description: "Query devices with an optional AQL filter."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
filter: { type: string, description: "AQL filter (e.g. specific_data.data.hostname == \"host01\")" }
|
||||||
|
limit: { type: number, description: "Max devices (default 50)" }
|
||||||
|
offset: { type: number, description: "Offset (default 0)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_device
|
||||||
|
name: axonius-get-device
|
||||||
|
description: "Get a single device by its internal Axonius ID."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
device_id: { type: string, description: "Internal Axonius device ID" }
|
||||||
|
required: [device_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: device_count
|
||||||
|
name: axonius-device-count
|
||||||
|
description: "Count devices matching an AQL filter."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
filter: { type: string, description: "AQL filter (empty = all devices)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_users
|
||||||
|
name: axonius-list-users
|
||||||
|
description: "Query users with an optional AQL filter."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
filter: { type: string, description: "AQL filter" }
|
||||||
|
limit: { type: number, description: "Max users (default 50)" }
|
||||||
|
offset: { type: number, description: "Offset (default 0)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: axonius-test-connection
|
||||||
|
description: "Verify connectivity and credentials (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {
|
||||||
|
"api-key": str(cfg.get("api_key", "")),
|
||||||
|
"api-secret": str(cfg.get("api_secret", "")),
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _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)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
filter_ = inputs.get("filter")
|
||||||
|
|
||||||
|
body = {"data": {}}
|
||||||
|
if filter_:
|
||||||
|
body["data"]["filter"] = filter_
|
||||||
|
|
||||||
|
return request("POST", "/devices/count", cfg, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {
|
||||||
|
"api-key": str(cfg.get("api_key", "")),
|
||||||
|
"api-secret": str(cfg.get("api_secret", "")),
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _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)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
device_id = inputs.get("device_id")
|
||||||
|
if not device_id:
|
||||||
|
raise Exception("device_id is required")
|
||||||
|
|
||||||
|
return request("GET", "/devices/" + q(device_id), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {
|
||||||
|
"api-key": str(cfg.get("api_key", "")),
|
||||||
|
"api-secret": str(cfg.get("api_secret", "")),
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _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)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
filter_ = inputs.get("filter")
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
offset = inputs.get("offset")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"data": {
|
||||||
|
"page": {
|
||||||
|
"limit": int(limit or 50),
|
||||||
|
"offset": int(offset or 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filter_:
|
||||||
|
body["data"]["filter"] = filter_
|
||||||
|
|
||||||
|
return request("POST", "/devices", cfg, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {
|
||||||
|
"api-key": str(cfg.get("api_key", "")),
|
||||||
|
"api-secret": str(cfg.get("api_secret", "")),
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _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)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
filter_ = inputs.get("filter")
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
offset = inputs.get("offset")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"data": {
|
||||||
|
"page": {
|
||||||
|
"limit": int(limit or 50),
|
||||||
|
"offset": int(offset or 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filter_:
|
||||||
|
body["data"]["filter"] = filter_
|
||||||
|
|
||||||
|
return request("POST", "/users", cfg, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {
|
||||||
|
"api-key": str(cfg.get("api_key", "")),
|
||||||
|
"api-secret": str(cfg.get("api_secret", "")),
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _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)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
request("POST", "/devices/count", cfg, body={"data": {}})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
id: beyondtrust_password_safe
|
||||||
|
name: BeyondTrust Password Safe
|
||||||
|
version: 1.0.0
|
||||||
|
description: "BeyondTrust Password Safe (Secrets Safe REST API v3) — privileged access and credential retrieval: list managed accounts and systems, request a credential release, and retrieve the credential. API-key (PS-Auth) session authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: list managed accounts/systems, create release request, get credential."
|
||||||
|
category: identity
|
||||||
|
|
||||||
|
# Per-instance configuration. Auth signs in with an API key + runas user
|
||||||
|
# (header 'Authorization: PS-Auth key=<api_key>; runas=<runas_user>;'), which
|
||||||
|
# establishes a session reused for the request.
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "BeyondTrust URL (e.g. https://beyondtrust.example.com)"
|
||||||
|
api_key:
|
||||||
|
type: string
|
||||||
|
description: "API registration key"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
runas_user:
|
||||||
|
type: string
|
||||||
|
description: "Username to run as"
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure)"
|
||||||
|
default: false
|
||||||
|
required:
|
||||||
|
- base_url
|
||||||
|
- api_key
|
||||||
|
- runas_user
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: list_managed_accounts
|
||||||
|
name: beyondtrust-list-managed-accounts
|
||||||
|
description: "List managed accounts."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
account_name: { type: string, description: "Optional account name filter" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_managed_systems
|
||||||
|
name: beyondtrust-list-managed-systems
|
||||||
|
description: "List managed systems."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: create_release_request
|
||||||
|
name: beyondtrust-create-release-request
|
||||||
|
description: "Request a credential release for a managed account."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
system_id: { type: string, description: "Managed system ID" }
|
||||||
|
account_id: { type: string, description: "Managed account ID" }
|
||||||
|
duration_minutes: { type: number, description: "Access duration in minutes (default 30)" }
|
||||||
|
reason: { type: string, description: "Reason for the request" }
|
||||||
|
required: [system_id, account_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_credential
|
||||||
|
name: beyondtrust-get-credential
|
||||||
|
description: "Retrieve the credential for an approved request."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
request_id: { type: string, description: "Request ID (from create-release-request)" }
|
||||||
|
required: [request_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: beyondtrust-test-connection
|
||||||
|
description: "Verify the sign-in (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import json, os, sys, ssl, http.cookiejar
|
||||||
|
import urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
|
||||||
|
ctx = _ctx(cfg)
|
||||||
|
self.opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
|
||||||
|
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth_header(self):
|
||||||
|
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
|
||||||
|
|
||||||
|
def signin(self):
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
|
||||||
|
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def call(self, method, path, body=None, params=None):
|
||||||
|
url = self.base + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def signout(self):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
|
||||||
|
self.opener.open(req, timeout=30).read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
client = Client(cfg)
|
||||||
|
client.signin()
|
||||||
|
try:
|
||||||
|
result = fn(client, inputs)
|
||||||
|
finally:
|
||||||
|
client.signout()
|
||||||
|
print(json.dumps(result))
|
||||||
|
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(client, inputs):
|
||||||
|
system_id = inputs.get("system_id")
|
||||||
|
account_id = inputs.get("account_id")
|
||||||
|
duration_minutes = inputs.get("duration_minutes")
|
||||||
|
reason = inputs.get("reason")
|
||||||
|
if not system_id:
|
||||||
|
raise Exception("system_id is required")
|
||||||
|
if not account_id:
|
||||||
|
raise Exception("account_id is required")
|
||||||
|
body = {
|
||||||
|
"SystemId": int(system_id),
|
||||||
|
"AccountId": int(account_id),
|
||||||
|
"DurationMinutes": int(duration_minutes) if duration_minutes else 30,
|
||||||
|
"Reason": reason or "Riposte SOAR",
|
||||||
|
"AccessType": "View",
|
||||||
|
}
|
||||||
|
return client.call("POST", "/Requests", body=body)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import json, os, sys, ssl, http.cookiejar
|
||||||
|
import urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
|
||||||
|
ctx = _ctx(cfg)
|
||||||
|
self.opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
|
||||||
|
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth_header(self):
|
||||||
|
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
|
||||||
|
|
||||||
|
def signin(self):
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
|
||||||
|
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def call(self, method, path, body=None, params=None):
|
||||||
|
url = self.base + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def signout(self):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
|
||||||
|
self.opener.open(req, timeout=30).read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
client = Client(cfg)
|
||||||
|
client.signin()
|
||||||
|
try:
|
||||||
|
result = fn(client, inputs)
|
||||||
|
finally:
|
||||||
|
client.signout()
|
||||||
|
print(json.dumps(result))
|
||||||
|
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(client, inputs):
|
||||||
|
request_id = inputs.get("request_id")
|
||||||
|
if not request_id:
|
||||||
|
raise Exception("request_id is required")
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
response = client.call("GET", "/Credentials/" + q(request_id))
|
||||||
|
return {"request_id": request_id, "credential": response}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import json, os, sys, ssl, http.cookiejar
|
||||||
|
import urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
|
||||||
|
ctx = _ctx(cfg)
|
||||||
|
self.opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
|
||||||
|
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth_header(self):
|
||||||
|
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
|
||||||
|
|
||||||
|
def signin(self):
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
|
||||||
|
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def call(self, method, path, body=None, params=None):
|
||||||
|
url = self.base + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def signout(self):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
|
||||||
|
self.opener.open(req, timeout=30).read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
client = Client(cfg)
|
||||||
|
client.signin()
|
||||||
|
try:
|
||||||
|
result = fn(client, inputs)
|
||||||
|
finally:
|
||||||
|
client.signout()
|
||||||
|
print(json.dumps(result))
|
||||||
|
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(client, inputs):
|
||||||
|
account_name = inputs.get("account_name")
|
||||||
|
return client.call("GET", "/ManagedAccounts", params={"accountName": account_name})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import json, os, sys, ssl, http.cookiejar
|
||||||
|
import urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
|
||||||
|
ctx = _ctx(cfg)
|
||||||
|
self.opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
|
||||||
|
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth_header(self):
|
||||||
|
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
|
||||||
|
|
||||||
|
def signin(self):
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
|
||||||
|
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def call(self, method, path, body=None, params=None):
|
||||||
|
url = self.base + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def signout(self):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
|
||||||
|
self.opener.open(req, timeout=30).read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
client = Client(cfg)
|
||||||
|
client.signin()
|
||||||
|
try:
|
||||||
|
result = fn(client, inputs)
|
||||||
|
finally:
|
||||||
|
client.signout()
|
||||||
|
print(json.dumps(result))
|
||||||
|
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(client, inputs):
|
||||||
|
return client.call("GET", "/ManagedSystems")
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import json, os, sys, ssl, http.cookiejar
|
||||||
|
import urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
|
||||||
|
ctx = _ctx(cfg)
|
||||||
|
self.opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
|
||||||
|
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth_header(self):
|
||||||
|
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
|
||||||
|
|
||||||
|
def signin(self):
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
|
||||||
|
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def call(self, method, path, body=None, params=None):
|
||||||
|
url = self.base + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with self.opener.open(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
def signout(self):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
|
||||||
|
self.opener.open(req, timeout=30).read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
inputs = _inputs()
|
||||||
|
client = Client(cfg)
|
||||||
|
client.signin()
|
||||||
|
try:
|
||||||
|
result = fn(client, inputs)
|
||||||
|
finally:
|
||||||
|
client.signout()
|
||||||
|
print(json.dumps(result))
|
||||||
|
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(client, inputs):
|
||||||
|
client.call("GET", "/ManagedSystems")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
id: binaryedge
|
||||||
|
name: BinaryEdge
|
||||||
|
version: 1.0.0
|
||||||
|
description: "BinaryEdge (API v2) — internet exposure intelligence: query current and historical open ports/services for an IP, run a search, enumerate a domain's subdomains, check an email against data leaks, and read the subscription quota. API-key authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: host lookup (current/historical), search, domain subdomains, data-leak email check, subscription."
|
||||||
|
category: enrichment
|
||||||
|
|
||||||
|
# Per-instance configuration. The API key is sent as the 'X-Key' header.
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
api_key:
|
||||||
|
type: string
|
||||||
|
description: "BinaryEdge API key"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
required:
|
||||||
|
- api_key
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: host
|
||||||
|
name: binaryedge-host
|
||||||
|
description: "Get the most recent open ports and services for an IP."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
ip: { type: string, description: "IP address" }
|
||||||
|
required: [ip]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: host_historical
|
||||||
|
name: binaryedge-host-historical
|
||||||
|
description: "Get historical open ports and services for an IP."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
ip: { type: string, description: "IP address" }
|
||||||
|
required: [ip]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: search
|
||||||
|
name: binaryedge-search
|
||||||
|
description: "Search hosts/services by a BinaryEdge query."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
query: { type: string, description: "BinaryEdge search query (e.g. type:elasticsearch)" }
|
||||||
|
page: { type: number, description: "Page number (default 1)" }
|
||||||
|
required: [query]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: domain_subdomains
|
||||||
|
name: binaryedge-domain-subdomains
|
||||||
|
description: "List a domain's known subdomains."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
domain: { type: string, description: "Domain name" }
|
||||||
|
page: { type: number, description: "Page number (default 1)" }
|
||||||
|
required: [domain]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: dataleaks_email
|
||||||
|
name: binaryedge-dataleaks-email
|
||||||
|
description: "Check whether an email appears in known data leaks."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
email: { type: string, description: "Email address" }
|
||||||
|
required: [email]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: binaryedge-test-connection
|
||||||
|
description: "Verify the API key via the subscription endpoint (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.binaryedge.io/v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(path, cfg, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
email = inputs.get("email")
|
||||||
|
if not email:
|
||||||
|
raise Exception("email is required")
|
||||||
|
return request("/query/dataleaks/email/" + q(email), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.binaryedge.io/v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(path, cfg, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
domain = inputs.get("domain")
|
||||||
|
if not domain:
|
||||||
|
raise Exception("domain is required")
|
||||||
|
page = inputs.get("page")
|
||||||
|
return request("/query/domains/subdomain/" + q(domain), cfg, params={"page": int(page or 1)})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.binaryedge.io/v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(path, cfg, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
ip = inputs.get("ip")
|
||||||
|
if not ip:
|
||||||
|
raise Exception("ip is required")
|
||||||
|
return request("/query/ip/" + q(ip), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.binaryedge.io/v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(path, cfg, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
ip = inputs.get("ip")
|
||||||
|
if not ip:
|
||||||
|
raise Exception("ip is required")
|
||||||
|
return request("/query/ip/historical/" + q(ip), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.binaryedge.io/v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(path, cfg, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
query = inputs.get("query")
|
||||||
|
if not query:
|
||||||
|
raise Exception("query is required")
|
||||||
|
page = inputs.get("page")
|
||||||
|
return request("/query/search", cfg, params={"query": query, "page": int(page or 1)})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.binaryedge.io/v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(path, cfg, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
request("/user/subscription", cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
id: box
|
||||||
|
name: Box
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Box (Content API v2) — evidence and file handling: search files, read file/folder metadata, list a folder's items, and create a shared link. Bearer-token authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: search, get file/folder info, list folder items, create shared link."
|
||||||
|
category: productivity
|
||||||
|
|
||||||
|
# Per-instance configuration. Auth header 'Authorization: Bearer <access_token>'.
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
access_token:
|
||||||
|
type: string
|
||||||
|
description: "Box access token (developer token or OAuth2/JWT-issued token)"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
required:
|
||||||
|
- access_token
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: search
|
||||||
|
name: box-search
|
||||||
|
description: "Search for files and folders by keyword."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
query: { type: string, description: "Search query" }
|
||||||
|
limit: { type: number, description: "Max results (default 30)" }
|
||||||
|
required: [query]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_file_info
|
||||||
|
name: box-get-file-info
|
||||||
|
description: "Get a file's metadata."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
file_id: { type: string, description: "File ID" }
|
||||||
|
required: [file_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_folder_info
|
||||||
|
name: box-get-folder-info
|
||||||
|
description: "Get a folder's metadata."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
folder_id: { type: string, description: "Folder ID (0 = root)" }
|
||||||
|
required: [folder_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_folder_items
|
||||||
|
name: box-list-folder-items
|
||||||
|
description: "List the items inside a folder."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
folder_id: { type: string, description: "Folder ID (0 = root)" }
|
||||||
|
limit: { type: number, description: "Max items (default 100)" }
|
||||||
|
required: [folder_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: create_shared_link
|
||||||
|
name: box-create-shared-link
|
||||||
|
description: "Create a shared link for a file."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
file_id: { type: string, description: "File ID" }
|
||||||
|
access: { type: string, description: "open, company, or collaborators (default company)" }
|
||||||
|
required: [file_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: box-test-connection
|
||||||
|
description: "Verify the access token (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.box.com/2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
file_id = inputs.get("file_id")
|
||||||
|
if not file_id:
|
||||||
|
raise Exception("file_id is required")
|
||||||
|
access = inputs.get("access")
|
||||||
|
return request(
|
||||||
|
"PUT",
|
||||||
|
"/files/" + q(file_id),
|
||||||
|
cfg,
|
||||||
|
body={"shared_link": {"access": (access or "company")}},
|
||||||
|
params={"fields": "shared_link"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.box.com/2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
file_id = inputs.get("file_id")
|
||||||
|
if not file_id:
|
||||||
|
raise Exception("file_id is required")
|
||||||
|
return request("GET", "/files/" + q(file_id), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.box.com/2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
folder_id = inputs.get("folder_id")
|
||||||
|
if not folder_id:
|
||||||
|
raise Exception("folder_id is required")
|
||||||
|
return request("GET", "/folders/" + q(folder_id), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.box.com/2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
folder_id = inputs.get("folder_id")
|
||||||
|
if not folder_id:
|
||||||
|
raise Exception("folder_id is required")
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
return request("GET", "/folders/" + q(folder_id) + "/items", cfg, params={"limit": int(limit or 100)})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.box.com/2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
query = inputs.get("query")
|
||||||
|
if not query:
|
||||||
|
raise Exception("query is required")
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
return request("GET", "/search", cfg, params={"query": query, "limit": int(limit or 30)})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "https://api.box.com/2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
resp = request("GET", "/users/me", cfg)
|
||||||
|
return {"ok": True, "login": resp.get("login")}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
id: cape
|
||||||
|
name: CAPE Sandbox
|
||||||
|
version: 1.0.0
|
||||||
|
description: "CAPE Sandbox (APIv2) — dynamic malware analysis and config extraction: submit files and URLs, read task status and reports, and list tasks. Token authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: submit file/URL, get task, get report, list tasks."
|
||||||
|
category: enrichment
|
||||||
|
|
||||||
|
# Per-instance configuration. The token is sent as 'Authorization: Token <api_token>'.
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "CAPE URL (e.g. https://cape.example.com)"
|
||||||
|
api_token:
|
||||||
|
type: string
|
||||||
|
description: "CAPE API token"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure)"
|
||||||
|
default: false
|
||||||
|
required:
|
||||||
|
- base_url
|
||||||
|
- api_token
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: submit_file
|
||||||
|
name: cape-submit-file
|
||||||
|
description: "Submit a file (base64) for analysis."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
file_name: { type: string, description: "File name" }
|
||||||
|
content_base64: { type: string, description: "File content, base64-encoded" }
|
||||||
|
required: [file_name, content_base64]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: submit_url
|
||||||
|
name: cape-submit-url
|
||||||
|
description: "Submit a URL for analysis."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
url: { type: string, description: "URL to detonate" }
|
||||||
|
required: [url]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_task
|
||||||
|
name: cape-get-task
|
||||||
|
description: "Get a task's status and metadata."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
task_id: { type: string, description: "Task ID" }
|
||||||
|
required: [task_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_report
|
||||||
|
name: cape-get-report
|
||||||
|
description: "Get a task's full analysis report (JSON)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
task_id: { type: string, description: "Task ID" }
|
||||||
|
required: [task_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_tasks
|
||||||
|
name: cape-list-tasks
|
||||||
|
description: "List recent tasks."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
limit: { type: number, description: "Max tasks (default 50)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: cape-test-connection
|
||||||
|
description: "Verify connectivity and the token (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import json, os, sys, base64, ssl, uuid, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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, extra=None):
|
||||||
|
h = {"Accept": "application/json", "Authorization": "Token " + str(cfg.get("api_token", ""))}
|
||||||
|
if extra:
|
||||||
|
h.update(extra)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, form=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data = None
|
||||||
|
extra = {}
|
||||||
|
if form is not None:
|
||||||
|
data = urllib.parse.urlencode(form).encode("utf-8")
|
||||||
|
extra["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, extra), method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def multipart(fields, file_field, file_name, file_bytes):
|
||||||
|
boundary = "----riposte" + uuid.uuid4().hex
|
||||||
|
parts = []
|
||||||
|
for k, v in (fields or {}).items():
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + str(v) + "\r\n").encode("utf-8"))
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + file_field + '"; filename="' + file_name + '"\r\n'
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8"))
|
||||||
|
parts.append(file_bytes)
|
||||||
|
parts.append(("\r\n--" + boundary + "--\r\n").encode("utf-8"))
|
||||||
|
return b"".join(parts), "multipart/form-data; boundary=" + boundary
|
||||||
|
|
||||||
|
|
||||||
|
def request_multipart(path, cfg, fields, file_field, file_name, file_bytes):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data, content_type = multipart(fields, file_field, file_name, file_bytes)
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, {"Content-Type": content_type}), method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
task_id = inputs.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
raise Exception("task_id is required")
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
return request("GET", "/apiv2/tasks/get/report/" + q(task_id) + "/", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import json, os, sys, base64, ssl, uuid, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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, extra=None):
|
||||||
|
h = {"Accept": "application/json", "Authorization": "Token " + str(cfg.get("api_token", ""))}
|
||||||
|
if extra:
|
||||||
|
h.update(extra)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, form=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data = None
|
||||||
|
extra = {}
|
||||||
|
if form is not None:
|
||||||
|
data = urllib.parse.urlencode(form).encode("utf-8")
|
||||||
|
extra["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, extra), method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def multipart(fields, file_field, file_name, file_bytes):
|
||||||
|
boundary = "----riposte" + uuid.uuid4().hex
|
||||||
|
parts = []
|
||||||
|
for k, v in (fields or {}).items():
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + str(v) + "\r\n").encode("utf-8"))
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + file_field + '"; filename="' + file_name + '"\r\n'
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8"))
|
||||||
|
parts.append(file_bytes)
|
||||||
|
parts.append(("\r\n--" + boundary + "--\r\n").encode("utf-8"))
|
||||||
|
return b"".join(parts), "multipart/form-data; boundary=" + boundary
|
||||||
|
|
||||||
|
|
||||||
|
def request_multipart(path, cfg, fields, file_field, file_name, file_bytes):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data, content_type = multipart(fields, file_field, file_name, file_bytes)
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, {"Content-Type": content_type}), method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
task_id = inputs.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
raise Exception("task_id is required")
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
return request("GET", "/apiv2/tasks/view/" + q(task_id) + "/", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import json, os, sys, base64, ssl, uuid, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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, extra=None):
|
||||||
|
h = {"Accept": "application/json", "Authorization": "Token " + str(cfg.get("api_token", ""))}
|
||||||
|
if extra:
|
||||||
|
h.update(extra)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, form=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data = None
|
||||||
|
extra = {}
|
||||||
|
if form is not None:
|
||||||
|
data = urllib.parse.urlencode(form).encode("utf-8")
|
||||||
|
extra["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, extra), method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def multipart(fields, file_field, file_name, file_bytes):
|
||||||
|
boundary = "----riposte" + uuid.uuid4().hex
|
||||||
|
parts = []
|
||||||
|
for k, v in (fields or {}).items():
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + str(v) + "\r\n").encode("utf-8"))
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + file_field + '"; filename="' + file_name + '"\r\n'
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8"))
|
||||||
|
parts.append(file_bytes)
|
||||||
|
parts.append(("\r\n--" + boundary + "--\r\n").encode("utf-8"))
|
||||||
|
return b"".join(parts), "multipart/form-data; boundary=" + boundary
|
||||||
|
|
||||||
|
|
||||||
|
def request_multipart(path, cfg, fields, file_field, file_name, file_bytes):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data, content_type = multipart(fields, file_field, file_name, file_bytes)
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, {"Content-Type": content_type}), method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
return request("GET", "/apiv2/tasks/list/" + str(int(limit or 50)) + "/", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import json, os, sys, base64, ssl, uuid, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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, extra=None):
|
||||||
|
h = {"Accept": "application/json", "Authorization": "Token " + str(cfg.get("api_token", ""))}
|
||||||
|
if extra:
|
||||||
|
h.update(extra)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, form=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data = None
|
||||||
|
extra = {}
|
||||||
|
if form is not None:
|
||||||
|
data = urllib.parse.urlencode(form).encode("utf-8")
|
||||||
|
extra["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, extra), method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def multipart(fields, file_field, file_name, file_bytes):
|
||||||
|
boundary = "----riposte" + uuid.uuid4().hex
|
||||||
|
parts = []
|
||||||
|
for k, v in (fields or {}).items():
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + str(v) + "\r\n").encode("utf-8"))
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + file_field + '"; filename="' + file_name + '"\r\n'
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8"))
|
||||||
|
parts.append(file_bytes)
|
||||||
|
parts.append(("\r\n--" + boundary + "--\r\n").encode("utf-8"))
|
||||||
|
return b"".join(parts), "multipart/form-data; boundary=" + boundary
|
||||||
|
|
||||||
|
|
||||||
|
def request_multipart(path, cfg, fields, file_field, file_name, file_bytes):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data, content_type = multipart(fields, file_field, file_name, file_bytes)
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, {"Content-Type": content_type}), method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
file_name = inputs.get("file_name")
|
||||||
|
content_base64 = inputs.get("content_base64")
|
||||||
|
if not file_name:
|
||||||
|
raise Exception("file_name is required")
|
||||||
|
if not content_base64:
|
||||||
|
raise Exception("content_base64 is required")
|
||||||
|
return request_multipart(
|
||||||
|
"/apiv2/tasks/create/file/",
|
||||||
|
cfg,
|
||||||
|
{},
|
||||||
|
"file",
|
||||||
|
file_name,
|
||||||
|
base64.b64decode(content_base64),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import json, os, sys, base64, ssl, uuid, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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, extra=None):
|
||||||
|
h = {"Accept": "application/json", "Authorization": "Token " + str(cfg.get("api_token", ""))}
|
||||||
|
if extra:
|
||||||
|
h.update(extra)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, form=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data = None
|
||||||
|
extra = {}
|
||||||
|
if form is not None:
|
||||||
|
data = urllib.parse.urlencode(form).encode("utf-8")
|
||||||
|
extra["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, extra), method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def multipart(fields, file_field, file_name, file_bytes):
|
||||||
|
boundary = "----riposte" + uuid.uuid4().hex
|
||||||
|
parts = []
|
||||||
|
for k, v in (fields or {}).items():
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + str(v) + "\r\n").encode("utf-8"))
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + file_field + '"; filename="' + file_name + '"\r\n'
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8"))
|
||||||
|
parts.append(file_bytes)
|
||||||
|
parts.append(("\r\n--" + boundary + "--\r\n").encode("utf-8"))
|
||||||
|
return b"".join(parts), "multipart/form-data; boundary=" + boundary
|
||||||
|
|
||||||
|
|
||||||
|
def request_multipart(path, cfg, fields, file_field, file_name, file_bytes):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data, content_type = multipart(fields, file_field, file_name, file_bytes)
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, {"Content-Type": content_type}), method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
url = inputs.get("url")
|
||||||
|
if not url:
|
||||||
|
raise Exception("url is required")
|
||||||
|
return request("POST", "/apiv2/tasks/create/url/", cfg, form={"url": url})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import json, os, sys, base64, ssl, uuid, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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, extra=None):
|
||||||
|
h = {"Accept": "application/json", "Authorization": "Token " + str(cfg.get("api_token", ""))}
|
||||||
|
if extra:
|
||||||
|
h.update(extra)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, form=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data = None
|
||||||
|
extra = {}
|
||||||
|
if form is not None:
|
||||||
|
data = urllib.parse.urlencode(form).encode("utf-8")
|
||||||
|
extra["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, extra), method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def multipart(fields, file_field, file_name, file_bytes):
|
||||||
|
boundary = "----riposte" + uuid.uuid4().hex
|
||||||
|
parts = []
|
||||||
|
for k, v in (fields or {}).items():
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + k + '"\r\n\r\n' + str(v) + "\r\n").encode("utf-8"))
|
||||||
|
parts.append(("--" + boundary + "\r\n"
|
||||||
|
'Content-Disposition: form-data; name="' + file_field + '"; filename="' + file_name + '"\r\n'
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8"))
|
||||||
|
parts.append(file_bytes)
|
||||||
|
parts.append(("\r\n--" + boundary + "--\r\n").encode("utf-8"))
|
||||||
|
return b"".join(parts), "multipart/form-data; boundary=" + boundary
|
||||||
|
|
||||||
|
|
||||||
|
def request_multipart(path, cfg, fields, file_field, file_name, file_bytes):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + path
|
||||||
|
data, content_type = multipart(fields, file_field, file_name, file_bytes)
|
||||||
|
req = urllib.request.Request(url, data=data, headers=_headers(cfg, {"Content-Type": content_type}), method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=180, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
request("GET", "/apiv2/cuckoo/status/", cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -84,7 +84,7 @@ commands:
|
|||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
- id: search_alerts
|
- id: search_alerts
|
||||||
name: cbc-search-alerts
|
name: cbc-search-alerts
|
||||||
description: "Search alerts."
|
description: "Search alerts. Used for ingestion: results path = results."
|
||||||
risk: read
|
risk: read
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
@@ -92,6 +92,9 @@ commands:
|
|||||||
rows: { type: number, description: "Max alerts (default 50)" }
|
rows: { type: number, description: "Max alerts (default 50)" }
|
||||||
required: []
|
required: []
|
||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
|
ingest:
|
||||||
|
results_path: results
|
||||||
|
dedup_key: id
|
||||||
- id: ban_hash
|
- id: ban_hash
|
||||||
name: cbc-ban-hash
|
name: cbc-ban-hash
|
||||||
description: "Ban a file by SHA-256 hash (reputation deny-list override)."
|
description: "Ban a file by SHA-256 hash (reputation deny-list override)."
|
||||||
@@ -119,3 +122,8 @@ commands:
|
|||||||
properties: {}
|
properties: {}
|
||||||
required: []
|
required: []
|
||||||
outputs_schema: { properties: {} }
|
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" }
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
id: cisco_ise
|
||||||
|
name: Cisco ISE
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Cisco Identity Services Engine (ERS + ANC API) — network access containment: list and read endpoints, apply an Adaptive Network Control (ANC) quarantine policy to an endpoint, and clear it. HTTP Basic authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: list/get endpoints, apply/clear ANC policy, list ANC policies."
|
||||||
|
category: network
|
||||||
|
|
||||||
|
# Per-instance configuration. HTTP Basic auth against the ERS API (port 9060).
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "ISE URL including the ERS port (e.g. https://ise.example.com:9060)"
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: "ERS admin username"
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: "ERS admin password"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
insecure:
|
||||||
|
type: boolean
|
||||||
|
description: "Trust any TLS certificate (not secure)"
|
||||||
|
default: false
|
||||||
|
required:
|
||||||
|
- base_url
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: list_endpoints
|
||||||
|
name: ise-list-endpoints
|
||||||
|
description: "List endpoints (optionally filter by MAC)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
mac: { type: string, description: "Optional MAC address filter" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_endpoint
|
||||||
|
name: ise-get-endpoint
|
||||||
|
description: "Get an endpoint by ID."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
endpoint_id: { type: string, description: "Endpoint ID" }
|
||||||
|
required: [endpoint_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: apply_anc_policy
|
||||||
|
name: ise-apply-anc-policy
|
||||||
|
description: "Apply an ANC policy to an endpoint by MAC (quarantine — containment)."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
mac: { type: string, description: "Endpoint MAC address" }
|
||||||
|
policy_name: { type: string, description: "ANC policy name (e.g. Quarantine)" }
|
||||||
|
required: [mac, policy_name]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: clear_anc_policy
|
||||||
|
name: ise-clear-anc-policy
|
||||||
|
description: "Clear the ANC policy from an endpoint by MAC."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
mac: { type: string, description: "Endpoint MAC address" }
|
||||||
|
policy_name: { type: string, description: "ANC policy name currently applied" }
|
||||||
|
required: [mac, policy_name]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_anc_policies
|
||||||
|
name: ise-list-anc-policies
|
||||||
|
description: "List ANC policies."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: ise-test-connection
|
||||||
|
description: "Verify connectivity and credentials (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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 _auth(cfg):
|
||||||
|
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
mac = inputs.get("mac")
|
||||||
|
policy_name = inputs.get("policy_name")
|
||||||
|
if not mac:
|
||||||
|
raise Exception("mac is required")
|
||||||
|
if not policy_name:
|
||||||
|
raise Exception("policy_name is required")
|
||||||
|
body = {
|
||||||
|
"OperationAdditionalData": {
|
||||||
|
"additionalData": [
|
||||||
|
{"name": "macAddress", "value": mac},
|
||||||
|
{"name": "policyName", "value": policy_name},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = request("PUT", "/ancendpoint/apply", cfg, body=body)
|
||||||
|
if not result:
|
||||||
|
return {"ok": True, "mac": mac, "policy": policy_name, "action": "apply"}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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 _auth(cfg):
|
||||||
|
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
mac = inputs.get("mac")
|
||||||
|
policy_name = inputs.get("policy_name")
|
||||||
|
if not mac:
|
||||||
|
raise Exception("mac is required")
|
||||||
|
if not policy_name:
|
||||||
|
raise Exception("policy_name is required")
|
||||||
|
body = {
|
||||||
|
"OperationAdditionalData": {
|
||||||
|
"additionalData": [
|
||||||
|
{"name": "macAddress", "value": mac},
|
||||||
|
{"name": "policyName", "value": policy_name},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = request("PUT", "/ancendpoint/clear", cfg, body=body)
|
||||||
|
if not result:
|
||||||
|
return {"ok": True, "mac": mac, "policy": policy_name, "action": "clear"}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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 _auth(cfg):
|
||||||
|
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
endpoint_id = inputs.get("endpoint_id")
|
||||||
|
if not endpoint_id:
|
||||||
|
raise Exception("endpoint_id is required")
|
||||||
|
return request("GET", "/endpoint/" + q(endpoint_id), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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 _auth(cfg):
|
||||||
|
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
return request("GET", "/ancpolicy", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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 _auth(cfg):
|
||||||
|
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
mac = inputs.get("mac")
|
||||||
|
params = {"filter": "mac.EQ." + mac} if mac else {}
|
||||||
|
return request("GET", "/endpoint", cfg, params=params)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
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 _auth(cfg):
|
||||||
|
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
request("GET", "/endpoint", cfg, params={"size": 1})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
id: cisco_secure_endpoint
|
||||||
|
name: Cisco Secure Endpoint
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Cisco Secure Endpoint (AMP for Endpoints API v1) — endpoint containment: list and read computers, isolate/unisolate a host, list events, and read a computer's trajectory. HTTP Basic authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: list/get computers, isolate/stop-isolation, list events, get trajectory."
|
||||||
|
category: endpoint
|
||||||
|
|
||||||
|
# Per-instance configuration. HTTP Basic auth with the API client ID + key.
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "AMP API host (region-specific)"
|
||||||
|
default: "https://api.amp.cisco.com"
|
||||||
|
client_id:
|
||||||
|
type: string
|
||||||
|
description: "API client ID"
|
||||||
|
api_key:
|
||||||
|
type: string
|
||||||
|
description: "API key"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
required:
|
||||||
|
- client_id
|
||||||
|
- api_key
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: list_computers
|
||||||
|
name: cisco-amp-list-computers
|
||||||
|
description: "List computers (endpoints)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
hostname: { type: string, description: "Optional hostname filter" }
|
||||||
|
limit: { type: number, description: "Max computers (default 50)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_computer
|
||||||
|
name: cisco-amp-get-computer
|
||||||
|
description: "Get a single computer by connector GUID."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
connector_guid: { type: string, description: "Connector GUID" }
|
||||||
|
required: [connector_guid]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: isolate_computer
|
||||||
|
name: cisco-amp-isolate-computer
|
||||||
|
description: "Isolate a computer from the network (containment)."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
connector_guid: { type: string, description: "Connector GUID" }
|
||||||
|
comment: { type: string, description: "Optional isolation comment" }
|
||||||
|
required: [connector_guid]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: stop_isolation
|
||||||
|
name: cisco-amp-stop-isolation
|
||||||
|
description: "Stop isolation on a computer."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
connector_guid: { type: string, description: "Connector GUID" }
|
||||||
|
required: [connector_guid]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_events
|
||||||
|
name: cisco-amp-list-events
|
||||||
|
description: "List events. Used for ingestion: results path = data."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
connector_guid: { type: string, description: "Optional connector GUID filter" }
|
||||||
|
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."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
connector_guid: { type: string, description: "Connector GUID" }
|
||||||
|
required: [connector_guid]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: cisco-amp-test-connection
|
||||||
|
description: "Verify connectivity and credentials (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
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,58 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
connector_guid = inputs.get("connector_guid")
|
||||||
|
if not connector_guid:
|
||||||
|
raise Exception("connector_guid is required")
|
||||||
|
return request("GET", "/computers/" + q(connector_guid), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
connector_guid = inputs.get("connector_guid")
|
||||||
|
if not connector_guid:
|
||||||
|
raise Exception("connector_guid is required")
|
||||||
|
return request("GET", "/computers/" + q(connector_guid) + "/trajectory", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
connector_guid = inputs.get("connector_guid")
|
||||||
|
if not connector_guid:
|
||||||
|
raise Exception("connector_guid is required")
|
||||||
|
comment = inputs.get("comment")
|
||||||
|
return request(
|
||||||
|
"PUT",
|
||||||
|
"/computers/" + q(connector_guid) + "/isolation",
|
||||||
|
cfg,
|
||||||
|
body=({"comment": comment} if comment else {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
hostname = inputs.get("hostname")
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
"/computers",
|
||||||
|
cfg,
|
||||||
|
params={"hostname[]": hostname, "limit": int(limit or 50)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
connector_guid = inputs.get("connector_guid")
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
"/events",
|
||||||
|
cfg,
|
||||||
|
params={"connector_guid[]": connector_guid, "limit": int(limit or 50)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
connector_guid = inputs.get("connector_guid")
|
||||||
|
if not connector_guid:
|
||||||
|
raise Exception("connector_guid is required")
|
||||||
|
return request("DELETE", "/computers/" + q(connector_guid) + "/isolation", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
request("GET", "/version", cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
id: confluence
|
||||||
|
name: Confluence
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Atlassian Confluence (REST API) — runbook and knowledge handling: search content (CQL), read a page, create and update pages, and list spaces. Basic (email + API token) authentication; stdlib-only, no extra Python dependencies."
|
||||||
|
changelog: "1.0.0 — Initial release: search content, get page, create/update page, list spaces."
|
||||||
|
category: productivity
|
||||||
|
|
||||||
|
# Per-instance configuration. HTTP Basic auth with an email + API token (Cloud)
|
||||||
|
# or username + password (Server/DC).
|
||||||
|
config_schema:
|
||||||
|
properties:
|
||||||
|
base_url:
|
||||||
|
type: string
|
||||||
|
description: "Confluence base URL (e.g. https://your.atlassian.net/wiki)"
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: "Account email (Cloud) or username (Server)"
|
||||||
|
api_token:
|
||||||
|
type: string
|
||||||
|
description: "API token (Cloud) or password (Server)"
|
||||||
|
x-soar-sensitive: true
|
||||||
|
required:
|
||||||
|
- base_url
|
||||||
|
- email
|
||||||
|
- api_token
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- id: search
|
||||||
|
name: confluence-search
|
||||||
|
description: "Search content using CQL."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
cql: { type: string, description: "CQL query (e.g. text ~ \"phishing runbook\")" }
|
||||||
|
limit: { type: number, description: "Max results (default 25)" }
|
||||||
|
required: [cql]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: get_page
|
||||||
|
name: confluence-get-page
|
||||||
|
description: "Get a page by ID (with body)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
page_id: { type: string, description: "Page (content) ID" }
|
||||||
|
required: [page_id]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: create_page
|
||||||
|
name: confluence-create-page
|
||||||
|
description: "Create a page in a space."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
space_key: { type: string, description: "Space key" }
|
||||||
|
title: { type: string, description: "Page title" }
|
||||||
|
body: { type: string, description: "Page body (storage/HTML format)" }
|
||||||
|
parent_id: { type: string, description: "Optional parent page ID" }
|
||||||
|
required: [space_key, title, body]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: update_page
|
||||||
|
name: confluence-update-page
|
||||||
|
description: "Update a page's title/body (increments the version)."
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
page_id: { type: string, description: "Page ID" }
|
||||||
|
title: { type: string, description: "New title" }
|
||||||
|
body: { type: string, description: "New body (storage/HTML)" }
|
||||||
|
version: { type: number, description: "New version number (current version + 1)" }
|
||||||
|
required: [page_id, title, body, version]
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
- id: list_spaces
|
||||||
|
name: confluence-list-spaces
|
||||||
|
description: "List spaces."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
limit: { type: number, description: "Max spaces (default 25)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: test_connection
|
||||||
|
name: confluence-test-connection
|
||||||
|
description: "Verify connectivity and credentials (used by the Test button)."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
space_key = inputs.get("space_key")
|
||||||
|
if not space_key:
|
||||||
|
raise Exception("space_key is required")
|
||||||
|
title = inputs.get("title")
|
||||||
|
if not title:
|
||||||
|
raise Exception("title is required")
|
||||||
|
body = inputs.get("body")
|
||||||
|
if not body:
|
||||||
|
raise Exception("body is required")
|
||||||
|
parent_id = inputs.get("parent_id")
|
||||||
|
|
||||||
|
page = {
|
||||||
|
"type": "page",
|
||||||
|
"title": title,
|
||||||
|
"space": {"key": space_key},
|
||||||
|
"body": {"storage": {"value": body, "representation": "storage"}},
|
||||||
|
}
|
||||||
|
if parent_id not in (None, ""):
|
||||||
|
page["ancestors"] = [{"id": parent_id}]
|
||||||
|
|
||||||
|
return request("POST", "/content", cfg, body=page)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
page_id = inputs.get("page_id")
|
||||||
|
if not page_id:
|
||||||
|
raise Exception("page_id is required")
|
||||||
|
return request("GET", "/content/" + q(page_id), cfg, params={"expand": "body.storage,version,space"})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
return request("GET", "/space", cfg, params={"limit": int(limit or 25)})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
cql = inputs.get("cql")
|
||||||
|
if not cql:
|
||||||
|
raise Exception("cql is required")
|
||||||
|
limit = inputs.get("limit")
|
||||||
|
return request("GET", "/content/search", cfg, params={"cql": cql, "limit": int(limit or 25)})
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
request("GET", "/space", cfg, params={"limit": 1})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs():
|
||||||
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(cfg):
|
||||||
|
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
|
||||||
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, cfg, body=None, params=None):
|
||||||
|
url = _base(cfg) + path
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
||||||
|
if clean:
|
||||||
|
url += "?" + urllib.parse.urlencode(clean)
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(fn):
|
||||||
|
try:
|
||||||
|
print(json.dumps(fn(_cfg(), _inputs())))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
q = lambda v: urllib.parse.quote(str(v), safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def main(cfg, inputs):
|
||||||
|
page_id = inputs.get("page_id")
|
||||||
|
if not page_id:
|
||||||
|
raise Exception("page_id is required")
|
||||||
|
title = inputs.get("title")
|
||||||
|
if not title:
|
||||||
|
raise Exception("title is required")
|
||||||
|
body = inputs.get("body")
|
||||||
|
if not body:
|
||||||
|
raise Exception("body is required")
|
||||||
|
version = inputs.get("version")
|
||||||
|
if version in (None, ""):
|
||||||
|
raise Exception("version is required")
|
||||||
|
|
||||||
|
return request(
|
||||||
|
"PUT",
|
||||||
|
"/content/" + q(page_id),
|
||||||
|
cfg,
|
||||||
|
body={
|
||||||
|
"type": "page",
|
||||||
|
"title": title,
|
||||||
|
"version": {"number": int(version)},
|
||||||
|
"body": {"storage": {"value": body, "representation": "storage"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_run(main)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
name: "Cortex XDR Alert"
|
||||||
|
color: "#ef8354"
|
||||||
|
icon: "alert"
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: cortex_xdr
|
id: cortex_xdr
|
||||||
name: Cortex XDR
|
name: Cortex XDR
|
||||||
version: 1.2.0
|
version: 1.3.1
|
||||||
description: "Palo Alto Cortex XDR (public API v1) — incident ingestion + write-back, endpoint isolation/scan/delete/tagging, RTR scripts, hash block/allow lists, file quarantine/restore/retrieval, alert exclusions, external alert push (parsed/CEF), device-control violations, audits, distributions and RBAC/risk."
|
description: "Palo Alto Cortex XDR (public API v1) — incident and alert ingestion + write-back, endpoint isolation/scan/delete/tagging, RTR scripts, hash block/allow lists, file quarantine/restore/retrieval, alert exclusions, external alert push (parsed/CEF), device-control violations, audits, distributions and RBAC/risk."
|
||||||
changelog: "1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication."
|
changelog: "1.3.1 — get_incidents_full can watermark on modification_time (modified_after), which is what ingestion wants: an XDR incident keeps growing after creation, and a creation_time watermark fetches it once and never looks again, so every alert that joins it afterwards is lost. The full-incident fetch command is named cortex-xdr-get-incidents-full (id get_incidents_full), not cortex-xdr-fetch-incidents: sitting next to cortex-xdr-get-incidents in the command list, it now reads as what it is — the same call, everything included. A rule created against the old id must be pointed at the new one. 1.3.0 — Richer incident ingestion (get_incidents_full, on get_multiple_incidents_extra_data): incidents now arrive with their alerts in full and their file/network artifacts, where get_incidents only ever answered a 21-field summary carrying neither hosts, users, MITRE nor a single alert. Alert ingestion, and the alerts endpoint answers at last: get_alerts was sending the incidents dialect ({filters, search_from, search_to, sort}) to a grid endpoint that speaks request_data.filter_data (SEARCH_FIELD/SEARCH_TYPE/SEARCH_VALUE blocks, paging.from/to, sort as a list), and every call came back HTTP 500. Body rebuilt from the reference client, rows unwrapped out of their alert_fields envelope, severity code and the dotted status.progress key derived into readable fields. Alert ingestion: get_alerts is now a fetch command (results path reply.alerts, dedup on alert_id, incremental on source_insert_ts) with a bundled OCSF mapper and a Cortex XDR Alert incident type, so detections can be ingested alongside — or instead of — incidents. The incident mapper is fixed on the way past: its severity expression compared strings, which the mapping engine cannot do, so every ingested incident silently took the rule's default severity; it also now carries a detection anchor so MTTD is measurable. It pages past the API's 100-results-per-call ceiling, and sorts oldest-first on an incremental fetch so a truncated window drops the alerts the next poll can still see. 1.2.1 — Connection troubleshooting: the URL is normalised to the tenant host (a pasted /public_api/v1 or console path no longer breaks the call), a non-JSON reply reports the status, content type and body instead of a bare JSON parse error, missing key/key ID is caught up front, nonce and timestamp are sent in both auth modes as the reference client does, and test_connection now probes get_incidents. 1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication."
|
||||||
category: endpoint
|
category: endpoint
|
||||||
|
|
||||||
# Per-instance configuration. The base URL is the tenant API root, e.g.
|
# Per-instance configuration. The base URL is the tenant API root, e.g.
|
||||||
@@ -11,7 +11,7 @@ config_schema:
|
|||||||
properties:
|
properties:
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
description: "Cortex XDR API base URL, e.g. https://api-<tenant>.xdr.<region>.paloaltonetworks.com"
|
description: "Tenant API host — Settings → Configurations → API Keys → Copy URL, e.g. https://api-<tenant>.xdr.<region>.paloaltonetworks.com. Not the console URL you log into (that one answers with HTML, not JSON)."
|
||||||
api_key_id:
|
api_key_id:
|
||||||
type: string
|
type: string
|
||||||
description: "API Key ID (the integer ID shown next to the API key in Settings → API Keys)"
|
description: "API Key ID (the integer ID shown next to the API key in Settings → API Keys)"
|
||||||
@@ -21,7 +21,7 @@ config_schema:
|
|||||||
x-soar-sensitive: true
|
x-soar-sensitive: true
|
||||||
auth_type:
|
auth_type:
|
||||||
type: string
|
type: string
|
||||||
description: "Authentication method: standard (key sent as-is) or advanced (nonce + timestamp SHA-256 hash)"
|
description: "How the key was created in Cortex XDR: standard (key sent as-is) or advanced (nonce + timestamp SHA-256 hash). A mismatch here is rejected with 401."
|
||||||
default: standard
|
default: standard
|
||||||
required:
|
required:
|
||||||
- url
|
- url
|
||||||
@@ -44,7 +44,7 @@ commands:
|
|||||||
# ── Ingestion ─────────────────────────────────────────────────────────────
|
# ── Ingestion ─────────────────────────────────────────────────────────────
|
||||||
- id: get_incidents
|
- id: get_incidents
|
||||||
name: cortex-xdr-get-incidents
|
name: cortex-xdr-get-incidents
|
||||||
description: "Fetch Cortex XDR incidents for ingestion. Returns {reply:{incidents:[...]}}; use reply.incidents as the alert rule results path."
|
description: "List Cortex XDR incidents as a 21-field summary (no hosts, no users, no MITRE, no alerts). Cheap to poll, but for ingestion prefer cortex-xdr-get-incidents-full, which returns the same incidents with their alerts and artifacts. Returns {reply:{incidents:[...]}}."
|
||||||
risk: read
|
risk: read
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
@@ -57,6 +57,23 @@ commands:
|
|||||||
results_path: reply.incidents
|
results_path: reply.incidents
|
||||||
dedup_key: incident_id
|
dedup_key: incident_id
|
||||||
incremental_field: created_after
|
incremental_field: created_after
|
||||||
|
- id: get_incidents_full
|
||||||
|
name: cortex-xdr-get-incidents-full
|
||||||
|
description: "Fetch incidents WITH their alerts and artifacts (get_multiple_incidents_extra_data) — the ingestion command to prefer. get_incidents answers with a 21-field summary carrying no hosts, no users, no MITRE and none of the alerts; this one returns 39 incident fields, every alert in full (156 fields each) and the file/network artifacts. Records are flattened, so mapping expressions written against get_incidents keep working and alerts[], file_artifacts[], network_artifacts[] sit beside them. Returns {reply:{incidents:[...]}}."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
status: { type: string, description: "Comma-separated statuses to keep (new, under_investigation, resolved_threat_handled…)" }
|
||||||
|
created_after: { type: string, description: "Lower bound on creation_time, ISO8601 or epoch ms. Watermarking on this fetches each incident once and never revisits it — alerts joining it later never arrive." }
|
||||||
|
modified_after: { type: string, description: "Lower bound on modification_time, ISO8601 or epoch ms. The watermark to prefer for ingestion: an incident comes back whenever it changes, and dedup on incident_id turns the second visit into an enrichment." }
|
||||||
|
limit: { type: number, description: "Maximum incidents to fetch (default 50, paged 50 at a time). A full incident weighs a few KB and up to a few hundred with its alerts, so raise this knowingly." }
|
||||||
|
exclude_artifacts: { type: boolean, description: "Drop the file and network artifact blocks, keeping the alerts (lighter payload)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
ingest:
|
||||||
|
results_path: reply.incidents
|
||||||
|
dedup_key: incident_id
|
||||||
|
incremental_field: modified_after
|
||||||
- id: get_incident_extra_data
|
- id: get_incident_extra_data
|
||||||
name: cortex-xdr-get-incident-extra-data
|
name: cortex-xdr-get-incident-extra-data
|
||||||
description: "Get full incident data including its alerts and network artifacts by incident ID."
|
description: "Get full incident data including its alerts and network artifacts by incident ID."
|
||||||
@@ -83,15 +100,21 @@ commands:
|
|||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
- id: get_alerts
|
- id: get_alerts
|
||||||
name: cortex-xdr-get-alerts
|
name: cortex-xdr-get-alerts
|
||||||
description: "Retrieve alerts using a custom filter (get_alerts_by_filter_data). Returns rich alert objects."
|
description: "Fetch Cortex XDR alerts for ingestion (get_alerts_by_filter_data). Returns {reply:{alerts:[...]}}; use reply.alerts as the alert rule results path. Each row is unwrapped out of the API's alert_fields envelope and carries a readable severity_name and status_progress, so alerts-grid field names (internal_id, alert_name, agent_hostname) are what mapping expressions see. Alerts are the detection layer under incidents: ingest them alongside get_incidents when the SOC works detections, not only aggregates."
|
||||||
risk: read
|
risk: read
|
||||||
inputs_schema:
|
inputs_schema:
|
||||||
properties:
|
properties:
|
||||||
severity: { type: string, description: "Comma-separated severities (low, medium, high, critical)" }
|
severity: { type: string, description: "Comma-separated severities (informational, low, medium, high, critical)" }
|
||||||
created_after: { type: string, description: "Lower bound on alert source_insert_ts, epoch ms" }
|
created_after: { type: string, description: "Lower bound on alert source_insert_ts, ISO8601 or epoch ms (incremental fetch watermark)" }
|
||||||
limit: { type: number, description: "Maximum alerts to fetch (default 100)" }
|
limit: { type: number, description: "Maximum alerts to fetch (default 100). The API serves 100 per call at most; above that the script pages until the limit is reached." }
|
||||||
|
# Left unfiltered, the call is bounded to the last 30 days: the alerts
|
||||||
|
# grid is not meant to be asked for a tenant's whole retention.
|
||||||
required: []
|
required: []
|
||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
|
ingest:
|
||||||
|
results_path: reply.alerts
|
||||||
|
dedup_key: internal_id
|
||||||
|
incremental_field: created_after
|
||||||
- id: insert_parsed_alerts
|
- id: insert_parsed_alerts
|
||||||
name: cortex-xdr-insert-parsed-alerts
|
name: cortex-xdr-insert-parsed-alerts
|
||||||
description: "Push external alerts (parsed JSON objects) into Cortex XDR for correlation."
|
description: "Push external alerts (parsed JSON objects) into Cortex XDR for correlation."
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: "Cortex XDR Alerts → OCSF"
|
||||||
|
description: "Maps one Cortex XDR alert (alerts/get_alerts_by_filter_data/, results_path = reply.alerts) to OCSF Detection Finding fields. Field names are the alerts-grid ones (internal_id, alert_name, agent_hostname…), not the incident ones; the script unwraps the API's alert_fields envelope and derives severity_name and status_progress, which the raw payload cannot express. Alerts are the detection layer under incidents: a tenant ingesting both feeds holds each detection twice, once inside an aggregate and once on its own."
|
||||||
|
field_mappings:
|
||||||
|
title: "alert_name"
|
||||||
|
description: "alert_description"
|
||||||
|
# severity_name, not severity: the API sends an enum code (SEV_040_HIGH) that
|
||||||
|
# no severity scale can read, so the script carries the plain name alongside it.
|
||||||
|
severity: "severity_name"
|
||||||
|
# Which sensor fired: "XDR Agent", "PAN NGFW", "XDR Analytics"…
|
||||||
|
source: "alert_source"
|
||||||
|
# results_path = reply.alerts; source_path is JSONata over ONE alert object.
|
||||||
|
# Paths absent from a given alert are skipped at ingestion, so entries for fields
|
||||||
|
# a tenant never emits are safe. Where two entries target the same OCSF field,
|
||||||
|
# the LAST non-empty one wins — that is how the events[] fallbacks are ordered.
|
||||||
|
ocsf:
|
||||||
|
# ── Finding ───────────────────────────────────────────────────────
|
||||||
|
- { source_path: "internal_id", ocsf_field: "finding_info.uid" }
|
||||||
|
- { source_path: "external_id", ocsf_field: "finding_info.uid_alt" }
|
||||||
|
- { source_path: "alert_name", ocsf_field: "finding_info.title" }
|
||||||
|
- { source_path: "alert_description", ocsf_field: "finding_info.desc" }
|
||||||
|
- { source_path: "source_insert_ts", ocsf_field: "finding_info.created_time" }
|
||||||
|
- { source_path: "local_insert_ts", ocsf_field: "finding_info.modified_time" }
|
||||||
|
- { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" }
|
||||||
|
- { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" }
|
||||||
|
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
|
||||||
|
# ── Detection time: the MTTD anchor ───────────────────────────────
|
||||||
|
# `time` is what Riposte measures detection-to-ingestion against. The grid
|
||||||
|
# exposes when the tenant took the alert in (source_insert_ts); a sensor-side
|
||||||
|
# detection timestamp, when the tenant sends one, is the better anchor and
|
||||||
|
# comes last so it wins.
|
||||||
|
- { source_path: "source_insert_ts", ocsf_field: "time" }
|
||||||
|
- { source_path: "detection_timestamp", ocsf_field: "time" }
|
||||||
|
# ── Alert state ───────────────────────────────────────────────────
|
||||||
|
- { source_path: "severity_name", ocsf_field: "severity" }
|
||||||
|
- { source_path: "alert_domain", ocsf_field: "activity_name" }
|
||||||
|
- { source_path: "alert_action_status", ocsf_field: "action" }
|
||||||
|
- { source_path: "status_progress", ocsf_field: "status" }
|
||||||
|
- { source_path: "matching_status", ocsf_field: "status_detail" }
|
||||||
|
- { source_path: "events_length", ocsf_field: "count" }
|
||||||
|
# The XDR case this alert was folded into — the join back to the incident feed.
|
||||||
|
- { source_path: "case_id", ocsf_field: "metadata.correlation_uid" }
|
||||||
|
# ── Product identity ──────────────────────────────────────────────
|
||||||
|
- { source_path: "'Cortex XDR'", ocsf_field: "metadata.product.name" }
|
||||||
|
- { source_path: "'Palo Alto Networks'", ocsf_field: "metadata.product.vendor_name" }
|
||||||
|
- { source_path: "alert_source", ocsf_field: "metadata.log_source" }
|
||||||
|
# ── MITRE ATT&CK ──────────────────────────────────────────────────
|
||||||
|
# Both fields arrive as a list on most tenants and as a bare string on some;
|
||||||
|
# [0] reads the first element either way.
|
||||||
|
- { source_path: "mitre_tactic_id_and_name[0]", ocsf_field: "attacks.tactic.name" }
|
||||||
|
- { source_path: "mitre_technique_id_and_name[0]", ocsf_field: "attacks.technique.name" }
|
||||||
|
# ── Affected endpoint ─────────────────────────────────────────────
|
||||||
|
- { source_path: "agent_hostname", ocsf_field: "device.hostname" }
|
||||||
|
- { source_path: "agent_ip_addresses[0]", ocsf_field: "device.ip" }
|
||||||
|
- { source_path: "agent_id", ocsf_field: "device.uid" }
|
||||||
|
- { source_path: "agent_os_type", ocsf_field: "device.os.type" }
|
||||||
|
# Mirrored onto src_endpoint so routers and pre-processing rules written for
|
||||||
|
# the incident feed (which maps hosts there) match alerts unchanged.
|
||||||
|
- { source_path: "agent_hostname", ocsf_field: "src_endpoint.hostname" }
|
||||||
|
- { source_path: "agent_ip_addresses[0]", ocsf_field: "src_endpoint.ip" }
|
||||||
|
- { source_path: "actor_effective_username", ocsf_field: "user.name" }
|
||||||
|
# ── What actually happened ────────────────────────────────────────
|
||||||
|
# Grid columns first, then the same detail read off the first event, which is
|
||||||
|
# where a tenant that does not flatten these columns puts them.
|
||||||
|
- { source_path: "actor_process_image_name", ocsf_field: "process.name" }
|
||||||
|
- { source_path: "actor_process_command_line", ocsf_field: "process.cmd_line" }
|
||||||
|
- { source_path: "actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
|
||||||
|
- { source_path: "causality_actor_process_command_line", ocsf_field: "process.parent_process.cmd_line" }
|
||||||
|
- { source_path: "action_file_path", ocsf_field: "file.path" }
|
||||||
|
- { source_path: "action_file_sha256", ocsf_field: "file.hashes.sha256" }
|
||||||
|
- { source_path: "action_file_md5", ocsf_field: "file.hashes.md5" }
|
||||||
|
- { source_path: "action_registry_key_name", ocsf_field: "reg_key.path" }
|
||||||
|
- { source_path: "action_registry_data", ocsf_field: "reg_value.data" }
|
||||||
|
- { source_path: "action_local_ip", ocsf_field: "src_endpoint.ip" }
|
||||||
|
- { source_path: "action_local_port", ocsf_field: "src_endpoint.port" }
|
||||||
|
- { source_path: "action_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
||||||
|
- { source_path: "action_remote_port", ocsf_field: "dst_endpoint.port" }
|
||||||
|
- { source_path: "dst_action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
|
||||||
|
- { source_path: "events[0].actor_process_image_name", ocsf_field: "process.name" }
|
||||||
|
- { source_path: "events[0].actor_process_command_line", ocsf_field: "process.cmd_line" }
|
||||||
|
- { source_path: "events[0].actor_process_image_path", ocsf_field: "process.path" }
|
||||||
|
- { source_path: "events[0].actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
|
||||||
|
- { source_path: "events[0].causality_actor_process_image_name", ocsf_field: "process.parent_process.name" }
|
||||||
|
- { source_path: "events[0].action_file_path", ocsf_field: "file.path" }
|
||||||
|
- { source_path: "events[0].action_file_sha256", ocsf_field: "file.hashes.sha256" }
|
||||||
|
- { source_path: "events[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
||||||
|
- { source_path: "events[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
|
||||||
|
- { source_path: "events[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
|
||||||
@@ -2,7 +2,10 @@ name: "Cortex XDR Incidents → OCSF"
|
|||||||
description: "Maps a Cortex XDR incident (incidents/get_incidents/, results_path = reply.incidents) to OCSF finding fields. Incidents are aggregates; use get_incident_extra_data for per-alert detail."
|
description: "Maps a Cortex XDR incident (incidents/get_incidents/, results_path = reply.incidents) to OCSF finding fields. Incidents are aggregates; use get_incident_extra_data for per-alert detail."
|
||||||
field_mappings:
|
field_mappings:
|
||||||
title: "incident_name"
|
title: "incident_name"
|
||||||
severity: "severity = 'critical' ? 5 : (severity = 'high' ? 4 : (severity = 'medium' ? 3 : 2))"
|
# The raw string, not a ternary: the mapping engine compares numbers only, so
|
||||||
|
# every string test read as false and every incident landed on the rule's
|
||||||
|
# default severity. Riposte reads critical/high/medium/low onto 1-5 itself.
|
||||||
|
severity: "severity"
|
||||||
description: "description"
|
description: "description"
|
||||||
# results_path = reply.incidents; source_path is JSONata over ONE incident object.
|
# results_path = reply.incidents; source_path is JSONata over ONE incident object.
|
||||||
# Paths absent from a given incident are skipped at ingestion, so extra entries are safe.
|
# Paths absent from a given incident are skipped at ingestion, so extra entries are safe.
|
||||||
@@ -15,6 +18,9 @@ ocsf:
|
|||||||
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
||||||
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
||||||
- { source_path: "status", ocsf_field: "status" }
|
- { source_path: "status", ocsf_field: "status" }
|
||||||
|
# `time` is the MTTD anchor — when XDR opened the incident, as opposed to when
|
||||||
|
# Riposte ingested it. Without it the detection delay column stays empty.
|
||||||
|
- { source_path: "creation_time", ocsf_field: "time" }
|
||||||
- { source_path: "alert_count", ocsf_field: "count" }
|
- { source_path: "alert_count", ocsf_field: "count" }
|
||||||
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
|
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
|
||||||
- { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" }
|
- { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" }
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: "Cortex XDR Incidents (full) → OCSF"
|
||||||
|
description: "Maps one Cortex XDR incident fetched with its alerts and artifacts (incidents/get_multiple_incidents_extra_data/, results_path = reply.incidents) to OCSF finding fields. The script flattens the record, so incident fields sit at the top level — every expression written against get_incidents keeps working — while alerts[], file_artifacts[] and network_artifacts[] are plain lists beside them. Incident-level values are mapped first and the first alert's equivalents last, so the alert wins wherever the aggregate says nothing."
|
||||||
|
field_mappings:
|
||||||
|
# incident_name is null on most tenants (it is only set when someone renames
|
||||||
|
# the incident), and a mapping that resolves to nothing leaves the title to
|
||||||
|
# the incident type's fallback. description is the sentence XDR itself shows.
|
||||||
|
title: "description"
|
||||||
|
description: "description"
|
||||||
|
severity: "severity"
|
||||||
|
# incident_source, not incident_sources[0]: this mapper reads dotted paths and
|
||||||
|
# cannot index a list, so the script lifts the first sensor out for it.
|
||||||
|
source: "incident_source"
|
||||||
|
# results_path = reply.incidents; source_path is JSONata over ONE flattened
|
||||||
|
# incident. Paths absent from a given incident are skipped at ingestion, so
|
||||||
|
# entries for fields a tenant never emits are safe. Where two entries target the
|
||||||
|
# same OCSF field, the LAST non-empty one wins.
|
||||||
|
ocsf:
|
||||||
|
# ── Finding ───────────────────────────────────────────────────────
|
||||||
|
- { source_path: "incident_id", ocsf_field: "finding_info.uid" }
|
||||||
|
- { source_path: "incident_name ? incident_name : description", ocsf_field: "finding_info.title" }
|
||||||
|
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
||||||
|
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
|
||||||
|
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
|
||||||
|
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
|
||||||
|
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
|
||||||
|
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
|
||||||
|
# ── Detection time: the MTTD anchor ───────────────────────────────
|
||||||
|
# Weakest first, strongest last. detection_time is often null on an XDR
|
||||||
|
# incident, and then the first alert's own detection timestamp is the honest
|
||||||
|
# anchor; incident creation is the last resort.
|
||||||
|
- { source_path: "creation_time", ocsf_field: "time" }
|
||||||
|
- { source_path: "alerts[0].detection_timestamp", ocsf_field: "time" }
|
||||||
|
- { source_path: "detection_time", ocsf_field: "time" }
|
||||||
|
# ── Incident state ────────────────────────────────────────────────
|
||||||
|
- { source_path: "severity", ocsf_field: "severity" }
|
||||||
|
- { source_path: "status", ocsf_field: "status" }
|
||||||
|
- { source_path: "resolve_comment", ocsf_field: "status_detail" }
|
||||||
|
- { source_path: "alert_count", ocsf_field: "count" }
|
||||||
|
- { source_path: "aggregated_score", ocsf_field: "risk_score" }
|
||||||
|
- { source_path: "tags", ocsf_field: "metadata.labels" }
|
||||||
|
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
|
||||||
|
# ── Product identity ──────────────────────────────────────────────
|
||||||
|
- { source_path: "'Cortex XDR'", ocsf_field: "metadata.product.name" }
|
||||||
|
- { source_path: "'Palo Alto Networks'", ocsf_field: "metadata.product.vendor_name" }
|
||||||
|
- { source_path: "incident_sources[0]", ocsf_field: "metadata.log_source" }
|
||||||
|
# ── MITRE ATT&CK: the aggregate, else the first alert ─────────────
|
||||||
|
- { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" }
|
||||||
|
- { source_path: "mitre_techniques_ids_and_names[0]", ocsf_field: "attacks.technique.name" }
|
||||||
|
- { source_path: "alerts[0].mitre_tactic_id_and_name[0]", ocsf_field: "attacks.tactic.name" }
|
||||||
|
- { source_path: "alerts[0].mitre_technique_id_and_name[0]", ocsf_field: "attacks.technique.name" }
|
||||||
|
# ── Affected host / user ──────────────────────────────────────────
|
||||||
|
# An incident's hosts are 'hostname:agent_id' strings; an alert names them plainly.
|
||||||
|
- { source_path: "$split(hosts[0], ':')[0]", ocsf_field: "src_endpoint.hostname" }
|
||||||
|
- { source_path: "$split(hosts[0], ':')[0]", ocsf_field: "device.hostname" }
|
||||||
|
- { source_path: "alerts[0].host_name", ocsf_field: "src_endpoint.hostname" }
|
||||||
|
- { source_path: "alerts[0].host_name", ocsf_field: "device.hostname" }
|
||||||
|
- { source_path: "alerts[0].host_ip[0]", ocsf_field: "device.ip" }
|
||||||
|
- { source_path: "alerts[0].host_ip[0]", ocsf_field: "src_endpoint.ip" }
|
||||||
|
- { source_path: "alerts[0].endpoint_id", ocsf_field: "device.uid" }
|
||||||
|
- { source_path: "alerts[0].agent_os_type", ocsf_field: "device.os.type" }
|
||||||
|
- { source_path: "users[0]", ocsf_field: "user.name" }
|
||||||
|
- { source_path: "alerts[0].user_name", ocsf_field: "user.name" }
|
||||||
|
# ── What the first alert actually saw ─────────────────────────────
|
||||||
|
- { source_path: "alerts[0].actor_process_image_name", ocsf_field: "process.name" }
|
||||||
|
- { source_path: "alerts[0].actor_process_command_line", ocsf_field: "process.cmd_line" }
|
||||||
|
- { source_path: "alerts[0].actor_process_image_path", ocsf_field: "process.path" }
|
||||||
|
- { source_path: "alerts[0].actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
|
||||||
|
- { source_path: "alerts[0].causality_actor_process_image_name", ocsf_field: "process.parent_process.name" }
|
||||||
|
- { source_path: "alerts[0].action_file_path", ocsf_field: "file.path" }
|
||||||
|
- { source_path: "alerts[0].action_file_name", ocsf_field: "file.name" }
|
||||||
|
- { source_path: "alerts[0].action_file_sha256", ocsf_field: "file.hashes.sha256" }
|
||||||
|
- { source_path: "alerts[0].action_file_md5", ocsf_field: "file.hashes.md5" }
|
||||||
|
- { source_path: "alerts[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
||||||
|
- { source_path: "alerts[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
|
||||||
|
- { source_path: "alerts[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
|
||||||
|
# ── The artifact the incident is really about ─────────────────────
|
||||||
|
# Last, because a file artifact is the incident's verdict on the file, where
|
||||||
|
# the alert only reports what one detection touched.
|
||||||
|
- { source_path: "file_artifacts[0].file_name", ocsf_field: "file.name" }
|
||||||
|
- { source_path: "file_artifacts[0].file_sha256", ocsf_field: "file.hashes.sha256" }
|
||||||
|
- { source_path: "file_artifacts[0].file_wildfire_verdict", ocsf_field: "malware.classifications" }
|
||||||
|
- { source_path: "network_artifacts[0].network_remote_ip", ocsf_field: "dst_endpoint.ip" }
|
||||||
|
- { source_path: "network_artifacts[0].network_domain", ocsf_field: "dst_endpoint.hostname" }
|
||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def csv(v):
|
def csv(v):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def csv(v):
|
def csv(v):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -3,29 +3,61 @@ from datetime import datetime
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def csv(v):
|
def csv(v):
|
||||||
@@ -44,19 +76,121 @@ def to_ms(v):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# This endpoint speaks the alerts-grid dialect, NOT the incidents one: a body of
|
||||||
|
# {filters, search_from, search_to, sort} — what incidents/get_incidents/ takes —
|
||||||
|
# is answered with a bare HTTP 500. It wants request_data.filter_data with
|
||||||
|
# SEARCH_FIELD/SEARCH_TYPE/SEARCH_VALUE blocks, paging.from/to and a sort LIST.
|
||||||
|
# Shape taken from the reference client (demisto/content,
|
||||||
|
# Packs/ApiModules/Scripts/CoreIRApiModule — get_alerts_by_filter_command).
|
||||||
|
PAGE = 100
|
||||||
|
|
||||||
|
# Severity travels as an enum code both ways. Riposte reads plain names onto its
|
||||||
|
# 1-5 scale, so alerts carry `severity_name` alongside the raw code.
|
||||||
|
SEVERITY_CODE_TO_NAME = {
|
||||||
|
"SEV_010_INFO": "informational",
|
||||||
|
"SEV_020_LOW": "low",
|
||||||
|
"SEV_030_MEDIUM": "medium",
|
||||||
|
"SEV_040_HIGH": "high",
|
||||||
|
"SEV_050_CRITICAL": "critical",
|
||||||
|
}
|
||||||
|
SEVERITY_NAME_TO_CODE = dict((v, k) for k, v in SEVERITY_CODE_TO_NAME.items())
|
||||||
|
SEVERITY_NAME_TO_CODE["info"] = "SEV_010_INFO"
|
||||||
|
|
||||||
|
# Our clock and the tenant's are not the same clock. A range that ends exactly
|
||||||
|
# now silently drops alerts the tenant stamped a few seconds ahead of us.
|
||||||
|
SKEW_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
# Window applied when the caller passes no filter at all — see main().
|
||||||
|
DEFAULT_LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def severity_block(value):
|
||||||
|
"""One EQ block per severity, OR'd together (the reference client's array rule)."""
|
||||||
|
blocks = [
|
||||||
|
{"SEARCH_FIELD": "severity", "SEARCH_TYPE": "EQ",
|
||||||
|
"SEARCH_VALUE": SEVERITY_NAME_TO_CODE.get(s.lower(), s.upper())}
|
||||||
|
for s in csv(value)
|
||||||
|
]
|
||||||
|
if not blocks:
|
||||||
|
return None
|
||||||
|
return blocks[0] if len(blocks) == 1 else {"OR": blocks}
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(item):
|
||||||
|
"""One grid row -> one flat alert.
|
||||||
|
|
||||||
|
The API wraps every row as {alert_fields, incident_fields}. Mapping through
|
||||||
|
that wrapper would put an `alert_fields.` prefix on every expression an
|
||||||
|
operator writes, so the row is unwrapped here and the two fields Riposte
|
||||||
|
cannot express are derived: `status.progress` carries a dot INSIDE the key
|
||||||
|
(unusable as a mapping path) and severity is an enum code.
|
||||||
|
"""
|
||||||
|
fields = item.get("alert_fields")
|
||||||
|
alert = dict(fields) if isinstance(fields, dict) else dict(item)
|
||||||
|
alert.pop("incident_fields", None)
|
||||||
|
if "status.progress" in alert:
|
||||||
|
alert["status_progress"] = alert.pop("status.progress")
|
||||||
|
name = SEVERITY_CODE_TO_NAME.get(alert.get("severity"))
|
||||||
|
if name:
|
||||||
|
alert["severity_name"] = name
|
||||||
|
incident = item.get("incident_fields")
|
||||||
|
if isinstance(incident, dict):
|
||||||
|
alert["incident_fields"] = incident
|
||||||
|
return alert
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
limit = int(inputs.get("limit") or 100)
|
limit = max(1, int(inputs.get("limit") or 100))
|
||||||
filters = []
|
conditions = []
|
||||||
if inputs.get("severity"):
|
sev = severity_block(inputs.get("severity"))
|
||||||
filters.append({"field": "severity", "operator": "in", "value": csv(inputs["severity"])})
|
if sev:
|
||||||
|
conditions.append(sev)
|
||||||
created_ms = to_ms(inputs.get("created_after"))
|
created_ms = to_ms(inputs.get("created_after"))
|
||||||
if created_ms is not None:
|
if created_ms is not None:
|
||||||
filters.append({"field": "source_insert_ts", "operator": "gte", "value": created_ms})
|
conditions.append({
|
||||||
rd = {"search_from": 0, "search_to": limit, "sort": {"field": "source_insert_ts", "keyword": "desc"}}
|
"SEARCH_FIELD": "source_insert_ts",
|
||||||
if filters:
|
"SEARCH_TYPE": "RANGE",
|
||||||
rd["filters"] = filters
|
"SEARCH_VALUE": {"from": created_ms, "to": int(time.time() * 1000) + SKEW_MS},
|
||||||
print(json.dumps(post("/alerts/get_alerts_by_filter_data/", rd)))
|
})
|
||||||
|
if not conditions:
|
||||||
|
# The reference client refuses a filterless query outright, and an
|
||||||
|
# unbounded scan of the whole alerts grid is not what the API is for.
|
||||||
|
# A recent window is a better default than an empty filter the tenant
|
||||||
|
# may well answer with a 500.
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
conditions.append({
|
||||||
|
"SEARCH_FIELD": "source_insert_ts",
|
||||||
|
"SEARCH_TYPE": "RANGE",
|
||||||
|
"SEARCH_VALUE": {"from": now_ms - DEFAULT_LOOKBACK_MS, "to": now_ms + SKEW_MS},
|
||||||
|
})
|
||||||
|
# Oldest first on an incremental fetch, so that a window holding more alerts
|
||||||
|
# than `limit` drops its most RECENT ones — the only ones the next poll can
|
||||||
|
# still see. Newest first otherwise, which is what an operator running the
|
||||||
|
# command by hand is asking for.
|
||||||
|
order = "ASC" if created_ms is not None else "DESC"
|
||||||
|
|
||||||
|
alerts, truncated = [], False
|
||||||
|
while len(alerts) < limit:
|
||||||
|
rd = {"filter_data": {
|
||||||
|
"sort": [{"FIELD": "source_insert_ts", "ORDER": order}],
|
||||||
|
"paging": {"from": len(alerts), "to": min(len(alerts) + PAGE, limit)},
|
||||||
|
"filter": {"AND": conditions},
|
||||||
|
}}
|
||||||
|
reply = (post("/alerts/get_alerts_by_filter_data/", rd) or {}).get("reply") or {}
|
||||||
|
page = reply.get("alerts") or []
|
||||||
|
alerts.extend(flatten(a) for a in page)
|
||||||
|
if len(page) < PAGE:
|
||||||
|
break
|
||||||
|
# Stopped on the ceiling rather than on an exhausted window: whatever is
|
||||||
|
# left is not coming back on the next poll, and a silent cap reads like
|
||||||
|
# a quiet feed.
|
||||||
|
truncated = len(alerts) >= limit
|
||||||
|
|
||||||
|
out = {"result_count": len(alerts), "alerts": alerts}
|
||||||
|
if truncated:
|
||||||
|
out["truncated"] = True
|
||||||
|
print(json.dumps({"reply": out}))
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def csv(v):
|
def csv(v):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -3,29 +3,61 @@ from datetime import datetime
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def to_ms(v):
|
def to_ms(v):
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.error
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
|
key = s.get("api_key", "")
|
||||||
|
kid = str(s.get("api_key_id", ""))
|
||||||
|
if not key or not kid:
|
||||||
|
raise ValueError("api_key and api_key_id are both required")
|
||||||
|
# Nonce and timestamp ride along in both modes, as the reference client does.
|
||||||
|
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
|
||||||
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
|
ts = str(int(time.time()) * 1000)
|
||||||
|
headers = {
|
||||||
|
"x-xdr-auth-id": kid,
|
||||||
|
"x-xdr-nonce": nonce,
|
||||||
|
"x-xdr-timestamp": ts,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
||||||
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = key
|
||||||
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, request_data):
|
||||||
|
base, headers = _client()
|
||||||
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
|
def to_ms(v):
|
||||||
|
if v in (None, ""):
|
||||||
|
return None
|
||||||
|
s = str(v)
|
||||||
|
if s.isdigit():
|
||||||
|
return int(s)
|
||||||
|
try:
|
||||||
|
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# incidents/get_incidents/ answers with a 21-field summary: no hosts, no users,
|
||||||
|
# no MITRE, and above all not one of the alerts the incident aggregates. This
|
||||||
|
# endpoint returns the same incident with 39 fields, its alerts in full (156
|
||||||
|
# fields each) and its file/network artifacts — which is why the reference
|
||||||
|
# client fetches through it and not through get_incidents (demisto/content,
|
||||||
|
# CortexXDRIR.get_multiple_incidents_extra_data).
|
||||||
|
PAGE = 50
|
||||||
|
|
||||||
|
# Artifacts are dropped by name, not by omission — the API only understands
|
||||||
|
# being told which blocks to leave out.
|
||||||
|
ARTIFACT_BLOCKS = ["network_artifacts", "file_artifacts"]
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(item):
|
||||||
|
"""One record -> one incident.
|
||||||
|
|
||||||
|
Records arrive as {incident, alerts, network_artifacts, file_artifacts},
|
||||||
|
each nested block wrapped as {total_count, data}. Flattening the incident to
|
||||||
|
the top level keeps every expression written against get_incidents working
|
||||||
|
unchanged — the summary's 21 fields are a subset of these 39 — while the
|
||||||
|
alerts and artifacts land beside them as plain lists.
|
||||||
|
"""
|
||||||
|
incident = dict(item.get("incident") or {})
|
||||||
|
for key in ("alerts", "network_artifacts", "file_artifacts"):
|
||||||
|
block = item.get(key)
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
incident[key] = block.get("data") or []
|
||||||
|
if block.get("total_count") is not None:
|
||||||
|
# The tenant caps alerts per incident (50 by default), so the count
|
||||||
|
# says when the list is a sample rather than the whole set.
|
||||||
|
incident[key + "_total_count"] = block["total_count"]
|
||||||
|
# The producing sensor is a list here, and the incident-field mapper reads
|
||||||
|
# dotted paths only — no array indexing — so the first source is lifted out
|
||||||
|
# for it. The list itself stays, for expressions that can index.
|
||||||
|
sources = incident.get("incident_sources")
|
||||||
|
if isinstance(sources, list) and sources:
|
||||||
|
incident["incident_source"] = sources[0]
|
||||||
|
return incident
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
limit = max(1, int(inputs.get("limit") or 50))
|
||||||
|
filters = []
|
||||||
|
if inputs.get("status"):
|
||||||
|
statuses = csv(inputs["status"])
|
||||||
|
filters.append({"field": "status", "operator": "in", "value": statuses})
|
||||||
|
created_ms = to_ms(inputs.get("created_after"))
|
||||||
|
if created_ms is not None:
|
||||||
|
filters.append({"field": "creation_time", "operator": "gte", "value": created_ms})
|
||||||
|
# An XDR incident keeps growing after it is created: alerts join it, an
|
||||||
|
# analyst changes its status. Watermarking on creation_time fetches it once
|
||||||
|
# and never looks again, so everything that happened afterwards is lost.
|
||||||
|
# Watermarking on modification_time brings it back on every change, where
|
||||||
|
# dedup on incident_id turns the second visit into an enrichment.
|
||||||
|
modified_ms = to_ms(inputs.get("modified_after"))
|
||||||
|
if modified_ms is not None:
|
||||||
|
filters.append({"field": "modification_time", "operator": "gte", "value": modified_ms})
|
||||||
|
incremental = created_ms is not None or modified_ms is not None
|
||||||
|
# Oldest first on an incremental fetch, so that a window holding more
|
||||||
|
# incidents than `limit` drops its most RECENT ones — the only ones the next
|
||||||
|
# poll can still see. Newest first otherwise, for a hand-run command.
|
||||||
|
sort_field = "modification_time" if modified_ms is not None else "creation_time"
|
||||||
|
keyword = "asc" if incremental else "desc"
|
||||||
|
exclude = str(inputs.get("exclude_artifacts") or "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
incidents, total = [], None
|
||||||
|
while len(incidents) < limit:
|
||||||
|
rd = {
|
||||||
|
"search_from": len(incidents),
|
||||||
|
"search_to": min(len(incidents) + PAGE, limit),
|
||||||
|
"sort": {"field": sort_field, "keyword": keyword},
|
||||||
|
# Without this the nested alerts come back trimmed to a handful of
|
||||||
|
# fields — the very thing this command exists to avoid.
|
||||||
|
"full_alert_fields": True,
|
||||||
|
}
|
||||||
|
if filters:
|
||||||
|
rd["filters"] = filters
|
||||||
|
if exclude:
|
||||||
|
rd["fields_to_exclude"] = ARTIFACT_BLOCKS
|
||||||
|
reply = (post("/incidents/get_multiple_incidents_extra_data/", rd) or {}).get("reply") or {}
|
||||||
|
page = reply.get("incidents") or []
|
||||||
|
if total is None:
|
||||||
|
total = reply.get("total_count")
|
||||||
|
incidents.extend(flatten(i) for i in page)
|
||||||
|
if len(page) < PAGE:
|
||||||
|
break
|
||||||
|
|
||||||
|
out = {"result_count": len(incidents), "incidents": incidents}
|
||||||
|
if total is not None:
|
||||||
|
out["total_count"] = total
|
||||||
|
# Say it when the window held more than the limit: those incidents are
|
||||||
|
# not coming back on the next poll.
|
||||||
|
out["truncated"] = total > len(incidents)
|
||||||
|
print(json.dumps({"reply": out}))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data):
|
def post(path, request_data):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
base = s.get("url", "").rstrip("/") + "/public_api/v1"
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
key = s.get("api_key", "")
|
key = s.get("api_key", "")
|
||||||
kid = str(s.get("api_key_id", ""))
|
kid = str(s.get("api_key_id", ""))
|
||||||
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
|
if not key or not kid:
|
||||||
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
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))
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
ts = str(int(time.time()) * 1000)
|
ts = str(int(time.time()) * 1000)
|
||||||
headers["x-xdr-nonce"] = nonce
|
headers = {
|
||||||
headers["x-xdr-timestamp"] = ts
|
"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()
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = key
|
headers["Authorization"] = key
|
||||||
return base, headers
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_raw(path, body):
|
def post_raw(path, body):
|
||||||
base, headers = _client()
|
base, headers = _client()
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=90) as r:
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
raw = r.read()
|
raw = r.read()
|
||||||
return json.loads(raw) if raw else {}
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
def post(path, request_data=None):
|
def post(path, request_data=None):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user