From a4241a5a5de2847f393e6eb87598ea6255ad2b98 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sun, 12 Jul 2026 21:59:07 +0200 Subject: [PATCH] feat(cisco-ise): new Cisco ISE NAC integration ISE ERS + ANC API, 6 commands: list/get endpoints, apply/clear ANC policy (quarantine containment), list ANC policies. HTTP Basic auth, stdlib-only. py_compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/cisco-ise/manifest.yaml | 83 +++++++++++++++++++ .../cisco-ise/scripts/apply_anc_policy.py | 77 +++++++++++++++++ .../cisco-ise/scripts/clear_anc_policy.py | 77 +++++++++++++++++ .../cisco-ise/scripts/get_endpoint.py | 63 ++++++++++++++ .../cisco-ise/scripts/list_anc_policies.py | 60 ++++++++++++++ .../cisco-ise/scripts/list_endpoints.py | 62 ++++++++++++++ .../cisco-ise/scripts/test_connection.py | 61 ++++++++++++++ 7 files changed, 483 insertions(+) create mode 100644 integrations/cisco-ise/manifest.yaml create mode 100644 integrations/cisco-ise/scripts/apply_anc_policy.py create mode 100644 integrations/cisco-ise/scripts/clear_anc_policy.py create mode 100644 integrations/cisco-ise/scripts/get_endpoint.py create mode 100644 integrations/cisco-ise/scripts/list_anc_policies.py create mode 100644 integrations/cisco-ise/scripts/list_endpoints.py create mode 100644 integrations/cisco-ise/scripts/test_connection.py diff --git a/integrations/cisco-ise/manifest.yaml b/integrations/cisco-ise/manifest.yaml new file mode 100644 index 0000000..395dec9 --- /dev/null +++ b/integrations/cisco-ise/manifest.yaml @@ -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: {} } diff --git a/integrations/cisco-ise/scripts/apply_anc_policy.py b/integrations/cisco-ise/scripts/apply_anc_policy.py new file mode 100644 index 0000000..5516d94 --- /dev/null +++ b/integrations/cisco-ise/scripts/apply_anc_policy.py @@ -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) diff --git a/integrations/cisco-ise/scripts/clear_anc_policy.py b/integrations/cisco-ise/scripts/clear_anc_policy.py new file mode 100644 index 0000000..46b782b --- /dev/null +++ b/integrations/cisco-ise/scripts/clear_anc_policy.py @@ -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) diff --git a/integrations/cisco-ise/scripts/get_endpoint.py b/integrations/cisco-ise/scripts/get_endpoint.py new file mode 100644 index 0000000..ecbcd01 --- /dev/null +++ b/integrations/cisco-ise/scripts/get_endpoint.py @@ -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) diff --git a/integrations/cisco-ise/scripts/list_anc_policies.py b/integrations/cisco-ise/scripts/list_anc_policies.py new file mode 100644 index 0000000..90df8a7 --- /dev/null +++ b/integrations/cisco-ise/scripts/list_anc_policies.py @@ -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) diff --git a/integrations/cisco-ise/scripts/list_endpoints.py b/integrations/cisco-ise/scripts/list_endpoints.py new file mode 100644 index 0000000..722670a --- /dev/null +++ b/integrations/cisco-ise/scripts/list_endpoints.py @@ -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) diff --git a/integrations/cisco-ise/scripts/test_connection.py b/integrations/cisco-ise/scripts/test_connection.py new file mode 100644 index 0000000..7547d9c --- /dev/null +++ b/integrations/cisco-ise/scripts/test_connection.py @@ -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)