diff --git a/integrations/servicenow-cmdb/manifest.yaml b/integrations/servicenow-cmdb/manifest.yaml new file mode 100644 index 0000000..0d07455 --- /dev/null +++ b/integrations/servicenow-cmdb/manifest.yaml @@ -0,0 +1,145 @@ +id: servicenow_cmdb +name: ServiceNow CMDB +version: 1.0.0 +description: "ServiceNow CMDB (CMDB Instance API) — query configuration items by CI class, read a record with its attributes and inbound/outbound relations, create and update records with attributes and relations, and add or delete relations between CIs. Basic or OAuth 2.0 (password grant) authentication." +changelog: "1.0.0 — Initial release: records list by class, record get by sys_id (attributes + relations), record create/update with attributes and discovery source, add/delete relations." +category: asset_management + +# Per-instance configuration. The base URL is the instance root, e.g. +# https://company.service-now.com (scripts append /api/now/cmdb/instance). +config_schema: + properties: + url: + type: string + description: "ServiceNow instance URL, e.g. https://company.service-now.com" + auth_type: + type: string + description: "Authentication method: basic (username + password) or oauth (OAuth 2.0 password grant via /oauth_token.do — also requires client_id/client_secret)" + default: basic + username: + type: string + description: "ServiceNow username (needs CMDB read/write ACLs on the target CI classes)" + password: + type: string + description: "ServiceNow password" + x-soar-sensitive: true + client_id: + type: string + description: "OAuth client ID (oauth auth_type only — from an Application Registry entry)" + client_secret: + type: string + description: "OAuth client secret (oauth auth_type only)" + x-soar-sensitive: true + api_version: + type: string + description: "Optional REST API version segment (e.g. v1). Leave empty to use the unversioned endpoints." + test_class: + type: string + description: "CI class queried by the Test button (default cmdb_ci_computer)" + default: cmdb_ci_computer + required: + - url + - username + - password + +# Documented for reference; the bundled scripts build the headers themselves. +# Basic: Authorization: Basic base64(username:password) +# OAuth: POST /oauth_token.do (grant_type=password) then Authorization: Bearer +auth: + - id: basic + type: basic + username_field: username + password_field: password + +commands: + - id: records_list + name: servicenow-cmdb-records-list + description: "List the records of a CMDB CI class, optionally filtered by an encoded sysparm_query." + risk: read + inputs_schema: + properties: + class: { type: string, description: "CI class name (e.g. cmdb_ci_linux_server)" } + query: { type: string, description: "Encoded sysparm_query filter" } + limit: { type: number, description: "Maximum records per page (default 50)" } + offset: { type: number, description: "Starting record index (default 0)" } + required: [class] + outputs_schema: { properties: {} } + - id: get_record + name: servicenow-cmdb-get-record + description: "Get a CI record by sys_id: attributes plus inbound and outbound relations." + risk: read + inputs_schema: + properties: + class: { type: string, description: "CI class name of the record" } + sys_id: { type: string, description: "Record sys_id" } + fields: { type: string, description: "Comma-separated attributes to return (sys_id and name always included)" } + relation_limit: { type: number, description: "Maximum relations returned (default 50)" } + relation_offset: { type: number, description: "Starting relation index (default 0)" } + required: [class, sys_id] + outputs_schema: { properties: {} } + - id: create_record + name: servicenow-cmdb-create-record + description: "Create a CI record with attributes and optional inbound/outbound relations." + inputs_schema: + properties: + class: { type: string, description: "CI class name to create the record in" } + attributes: { type: string, description: "Attributes as name=value;name2=value2 (e.g. name=srv01;ram=1024)" } + source: { type: string, description: "Discovery source (default ServiceNow) — values live in the sys_choice table (element discovery_source, name cmdb_ci)" } + inbound_relations: { type: string, description: "JSON array of inbound relations, each {\"type\": \"\", \"target\": \"\"}" } + outbound_relations: { type: string, description: "JSON array of outbound relations, each {\"type\": \"\", \"target\": \"\"}" } + fields: { type: string, description: "Comma-separated attributes to return (sys_id and name always included)" } + relation_limit: { type: number, description: "Maximum relations returned (default 50)" } + relation_offset: { type: number, description: "Starting relation index (default 0)" } + required: [class, attributes] + outputs_schema: { properties: {} } + - id: update_record + name: servicenow-cmdb-update-record + description: "Update a CI record's attributes." + inputs_schema: + properties: + class: { type: string, description: "CI class name of the record" } + sys_id: { type: string, description: "Record sys_id" } + attributes: { type: string, description: "Attributes as name=value;name2=value2" } + source: { type: string, description: "Discovery source (default ServiceNow)" } + fields: { type: string, description: "Comma-separated attributes to return (sys_id and name always included)" } + relation_limit: { type: number, description: "Maximum relations returned (default 50)" } + relation_offset: { type: number, description: "Starting relation index (default 0)" } + required: [class, sys_id, attributes] + outputs_schema: { properties: {} } + - id: add_relations + name: servicenow-cmdb-add-relations + description: "Add inbound and/or outbound relations to an existing CI record." + inputs_schema: + properties: + class: { type: string, description: "CI class name of the record" } + sys_id: { type: string, description: "Record sys_id" } + source: { type: string, description: "Discovery source (default ServiceNow)" } + inbound_relations: { type: string, description: "JSON array of inbound relations, each {\"type\": \"\", \"target\": \"\"}" } + outbound_relations: { type: string, description: "JSON array of outbound relations, each {\"type\": \"\", \"target\": \"\"}" } + fields: { type: string, description: "Comma-separated attributes to return (sys_id and name always included)" } + relation_limit: { type: number, description: "Maximum relations returned (default 50)" } + relation_offset: { type: number, description: "Starting relation index (default 0)" } + required: [class, sys_id] + outputs_schema: { properties: {} } + - id: delete_relation + name: servicenow-cmdb-delete-relation + description: "Delete a relation from a CI record by the relation's sys_id." + inputs_schema: + properties: + class: { type: string, description: "CI class name of the record" } + sys_id: { type: string, description: "Record sys_id" } + relation_sys_id: { type: string, description: "sys_id of the relation to delete" } + fields: { type: string, description: "Comma-separated attributes to return (sys_id and name always included)" } + relation_limit: { type: number, description: "Maximum relations returned (default 50)" } + relation_offset: { type: number, description: "Starting relation index (default 0)" } + required: [class, sys_id, relation_sys_id] + outputs_schema: { properties: {} } + + - id: test_connection + name: servicenow-cmdb-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/servicenow-cmdb/scripts/add_relations.py b/integrations/servicenow-cmdb/scripts/add_relations.py new file mode 100644 index 0000000..3841f24 --- /dev/null +++ b/integrations/servicenow-cmdb/scripts/add_relations.py @@ -0,0 +1,96 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + 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()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def relation_params(inputs): + fields = str(inputs.get("fields") or "") + if fields: + parts = [p.strip() for p in fields.split(",") if p.strip()] + for required in ("sys_id", "name"): + if required not in parts: + parts.append(required) + fields = ",".join(parts) + return { + "sysparm_fields": fields or None, + "sysparm_relation_limit": int(inputs.get("relation_limit") or 50), + "sysparm_relation_offset": int(inputs.get("relation_offset") or 0), + } + + +def parse_relations(s): + if s in (None, ""): + return None + rel = json.loads(s) if isinstance(s, str) else s + if not isinstance(rel, list): + raise Exception("relations must be a JSON array of {type, target} objects") + return rel + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ci_class = str(inputs.get("class") or "") + sys_id = str(inputs.get("sys_id") or "") + if not ci_class or not sys_id: + raise Exception("class and sys_id are required") + body = {"source": str(inputs.get("source") or "ServiceNow")} + inbound = parse_relations(inputs.get("inbound_relations")) + if inbound is not None: + body["inbound_relations"] = inbound + outbound = parse_relations(inputs.get("outbound_relations")) + if outbound is not None: + body["outbound_relations"] = outbound + if "inbound_relations" not in body and "outbound_relations" not in body: + raise Exception("inbound_relations or outbound_relations is required") + path = "/cmdb/instance/" + urllib.parse.quote(ci_class) + "/" + urllib.parse.quote(sys_id) + "/relation" + print(json.dumps(request("POST", path, params=relation_params(inputs), body=body))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/servicenow-cmdb/scripts/create_record.py b/integrations/servicenow-cmdb/scripts/create_record.py new file mode 100644 index 0000000..f1dfea8 --- /dev/null +++ b/integrations/servicenow-cmdb/scripts/create_record.py @@ -0,0 +1,111 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + 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()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def relation_params(inputs): + fields = str(inputs.get("fields") or "") + if fields: + parts = [p.strip() for p in fields.split(",") if p.strip()] + for required in ("sys_id", "name"): + if required not in parts: + parts.append(required) + fields = ",".join(parts) + return { + "sysparm_fields": fields or None, + "sysparm_relation_limit": int(inputs.get("relation_limit") or 50), + "sysparm_relation_offset": int(inputs.get("relation_offset") or 0), + } + + +def split_attrs(s): + out = {} + for part in str(s or "").split(";"): + if "=" not in part: + continue + k, v = part.split("=", 1) + k = k.strip() + if k: + out[k] = v + return out + + +def parse_relations(s): + if s in (None, ""): + return None + rel = json.loads(s) if isinstance(s, str) else s + if not isinstance(rel, list): + raise Exception("relations must be a JSON array of {type, target} objects") + return rel + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ci_class = str(inputs.get("class") or "") + if not ci_class: + raise Exception("class is required") + attributes = split_attrs(inputs.get("attributes")) + if not attributes: + raise Exception("attributes is required (name=value;name2=value2)") + body = { + "attributes": attributes, + "source": str(inputs.get("source") or "ServiceNow"), + } + inbound = parse_relations(inputs.get("inbound_relations")) + if inbound is not None: + body["inbound_relations"] = inbound + outbound = parse_relations(inputs.get("outbound_relations")) + if outbound is not None: + body["outbound_relations"] = outbound + path = "/cmdb/instance/" + urllib.parse.quote(ci_class) + print(json.dumps(request("POST", path, params=relation_params(inputs), body=body))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/servicenow-cmdb/scripts/delete_relation.py b/integrations/servicenow-cmdb/scripts/delete_relation.py new file mode 100644 index 0000000..1ce6cfd --- /dev/null +++ b/integrations/servicenow-cmdb/scripts/delete_relation.py @@ -0,0 +1,81 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + 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()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def relation_params(inputs): + fields = str(inputs.get("fields") or "") + if fields: + parts = [p.strip() for p in fields.split(",") if p.strip()] + for required in ("sys_id", "name"): + if required not in parts: + parts.append(required) + fields = ",".join(parts) + return { + "sysparm_fields": fields or None, + "sysparm_relation_limit": int(inputs.get("relation_limit") or 50), + "sysparm_relation_offset": int(inputs.get("relation_offset") or 0), + } + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ci_class = str(inputs.get("class") or "") + sys_id = str(inputs.get("sys_id") or "") + rel_sys_id = str(inputs.get("relation_sys_id") or "") + if not ci_class or not sys_id or not rel_sys_id: + raise Exception("class, sys_id and relation_sys_id are required") + path = ("/cmdb/instance/" + urllib.parse.quote(ci_class) + "/" + urllib.parse.quote(sys_id) + + "/relation/" + urllib.parse.quote(rel_sys_id)) + res = request("DELETE", path, params=relation_params(inputs)) + print(json.dumps(res if res else {"ok": True, "relation_sys_id": rel_sys_id})) + + +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) diff --git a/integrations/servicenow-cmdb/scripts/get_record.py b/integrations/servicenow-cmdb/scripts/get_record.py new file mode 100644 index 0000000..f8f1522 --- /dev/null +++ b/integrations/servicenow-cmdb/scripts/get_record.py @@ -0,0 +1,78 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + 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()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def relation_params(inputs): + fields = str(inputs.get("fields") or "") + if fields: + parts = [p.strip() for p in fields.split(",") if p.strip()] + for required in ("sys_id", "name"): + if required not in parts: + parts.append(required) + fields = ",".join(parts) + return { + "sysparm_fields": fields or None, + "sysparm_relation_limit": int(inputs.get("relation_limit") or 50), + "sysparm_relation_offset": int(inputs.get("relation_offset") or 0), + } + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ci_class = str(inputs.get("class") or "") + sys_id = str(inputs.get("sys_id") or "") + if not ci_class or not sys_id: + raise Exception("class and sys_id are required") + path = "/cmdb/instance/" + urllib.parse.quote(ci_class) + "/" + urllib.parse.quote(sys_id) + print(json.dumps(request("GET", path, params=relation_params(inputs)))) + + +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) diff --git a/integrations/servicenow-cmdb/scripts/records_list.py b/integrations/servicenow-cmdb/scripts/records_list.py new file mode 100644 index 0000000..0043710 --- /dev/null +++ b/integrations/servicenow-cmdb/scripts/records_list.py @@ -0,0 +1,65 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + 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()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ci_class = str(inputs.get("class") or "") + if not ci_class: + raise Exception("class is required") + print(json.dumps(request("GET", "/cmdb/instance/" + urllib.parse.quote(ci_class), params={ + "sysparm_query": inputs.get("query"), + "sysparm_limit": int(inputs.get("limit") or 50), + "sysparm_offset": int(inputs.get("offset") or 0), + }))) + + +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) diff --git a/integrations/servicenow-cmdb/scripts/test_connection.py b/integrations/servicenow-cmdb/scripts/test_connection.py new file mode 100644 index 0000000..6f49f87 --- /dev/null +++ b/integrations/servicenow-cmdb/scripts/test_connection.py @@ -0,0 +1,62 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + 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()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def main(): + cfg = _cfg() + ci_class = str(cfg.get("test_class") or "cmdb_ci_computer") + res = request("GET", "/cmdb/instance/" + urllib.parse.quote(ci_class), params={"sysparm_limit": 1}) + if "result" not in res: + raise Exception("Unexpected response: " + json.dumps(res)) + print(json.dumps({"ok": True, "class": ci_class})) + + +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) diff --git a/integrations/servicenow-cmdb/scripts/update_record.py b/integrations/servicenow-cmdb/scripts/update_record.py new file mode 100644 index 0000000..1fe849c --- /dev/null +++ b/integrations/servicenow-cmdb/scripts/update_record.py @@ -0,0 +1,97 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _headers(cfg): + h = {"Accept": "application/json", "Content-Type": "application/json"} + if str(cfg.get("auth_type") or "basic").lower() == "oauth": + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": cfg.get("client_id", ""), + "client_secret": cfg.get("client_secret", ""), + "username": cfg.get("username", ""), + "password": cfg.get("password", ""), + }).encode("utf-8") + req = urllib.request.Request( + cfg.get("url", "").rstrip("/") + "/oauth_token.do", data=data, + 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()) + if not tok.get("access_token"): + raise Exception("OAuth token request failed: " + json.dumps(tok)) + h["Authorization"] = "Bearer " + tok["access_token"] + else: + cred = (cfg.get("username", "") + ":" + cfg.get("password", "")).encode("utf-8") + h["Authorization"] = "Basic " + base64.b64encode(cred).decode("ascii") + return h + + +def request(method, path, params=None, body=None, root="/api/now", versioned=True): + cfg = _cfg() + v = str(cfg.get("api_version") or "").strip().strip("/") + url = cfg.get("url", "").rstrip("/") + root + ("/" + v if v and versioned else "") + path + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += "?" + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=_headers(cfg), method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def relation_params(inputs): + fields = str(inputs.get("fields") or "") + if fields: + parts = [p.strip() for p in fields.split(",") if p.strip()] + for required in ("sys_id", "name"): + if required not in parts: + parts.append(required) + fields = ",".join(parts) + return { + "sysparm_fields": fields or None, + "sysparm_relation_limit": int(inputs.get("relation_limit") or 50), + "sysparm_relation_offset": int(inputs.get("relation_offset") or 0), + } + + +def split_attrs(s): + out = {} + for part in str(s or "").split(";"): + if "=" not in part: + continue + k, v = part.split("=", 1) + k = k.strip() + if k: + out[k] = v + return out + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ci_class = str(inputs.get("class") or "") + sys_id = str(inputs.get("sys_id") or "") + if not ci_class or not sys_id: + raise Exception("class and sys_id are required") + attributes = split_attrs(inputs.get("attributes")) + if not attributes: + raise Exception("attributes is required (name=value;name2=value2)") + body = { + "attributes": attributes, + "source": str(inputs.get("source") or "ServiceNow"), + } + path = "/cmdb/instance/" + urllib.parse.quote(ci_class) + "/" + urllib.parse.quote(sys_id) + print(json.dumps(request("PATCH", path, params=relation_params(inputs), body=body))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)