From 14e5e102a980891a704389c85799c254975013a1 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sat, 11 Jul 2026 23:46:36 +0200 Subject: [PATCH] feat(microsoft-defender-o365): new O365 email-remediation integration Microsoft Graph mail APIs, 7 commands: search mailbox messages, get message, list attachments, list folders, move message, hard-delete (purge) message. Azure AD OAuth2 client-credentials auth (Mail.ReadWrite), stdlib-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../microsoft-defender-o365/manifest.yaml | 97 +++++++++++++++++++ .../scripts/delete_message.py | 76 +++++++++++++++ .../scripts/get_message.py | 75 ++++++++++++++ .../scripts/list_attachments.py | 75 ++++++++++++++ .../scripts/list_folders.py | 72 ++++++++++++++ .../scripts/move_message.py | 83 ++++++++++++++++ .../scripts/search_messages.py | 93 ++++++++++++++++++ .../scripts/test_connection.py | 66 +++++++++++++ 8 files changed, 637 insertions(+) create mode 100644 integrations/microsoft-defender-o365/manifest.yaml create mode 100644 integrations/microsoft-defender-o365/scripts/delete_message.py create mode 100644 integrations/microsoft-defender-o365/scripts/get_message.py create mode 100644 integrations/microsoft-defender-o365/scripts/list_attachments.py create mode 100644 integrations/microsoft-defender-o365/scripts/list_folders.py create mode 100644 integrations/microsoft-defender-o365/scripts/move_message.py create mode 100644 integrations/microsoft-defender-o365/scripts/search_messages.py create mode 100644 integrations/microsoft-defender-o365/scripts/test_connection.py diff --git a/integrations/microsoft-defender-o365/manifest.yaml b/integrations/microsoft-defender-o365/manifest.yaml new file mode 100644 index 0000000..8d6fe43 --- /dev/null +++ b/integrations/microsoft-defender-o365/manifest.yaml @@ -0,0 +1,97 @@ +id: microsoft_defender_o365 +name: Microsoft Defender for Office 365 +version: 1.0.0 +description: "Microsoft Defender for Office 365 email remediation (Microsoft Graph mail APIs) — anti-phishing containment: search a mailbox for messages, read a message and its attachments, move a message to a folder, and hard-delete (purge) a phishing message. Azure AD OAuth2 client-credentials authentication; stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: search mailbox messages, get message, list attachments, list folders, move message, delete (purge) message." +category: email + +# Per-instance configuration. Uses application (client-credentials) OAuth2. +# The app registration needs Graph application permission Mail.ReadWrite. +config_schema: + properties: + tenant_id: + type: string + description: "Azure AD tenant ID" + client_id: + type: string + description: "App registration (client) ID" + client_secret: + type: string + description: "App registration client secret" + x-soar-sensitive: true + required: + - tenant_id + - client_id + - client_secret + +commands: + - id: search_messages + name: mdo-search-messages + description: "Search a mailbox for messages by sender, subject, or free-text search." + risk: read + inputs_schema: + properties: + mailbox: { type: string, description: "Target mailbox (user principal name or ID)" } + subject: { type: string, description: "Filter by exact subject" } + from_address: { type: string, description: "Filter by sender address" } + search: { type: string, description: "Free-text $search query (KQL) over the mailbox" } + limit: { type: number, description: "Max messages (default 25)" } + required: [mailbox] + outputs_schema: { properties: {} } + - id: get_message + name: mdo-get-message + description: "Get a single message by ID." + risk: read + inputs_schema: + properties: + mailbox: { type: string, description: "Target mailbox (UPN or ID)" } + message_id: { type: string, description: "Message ID" } + required: [mailbox, message_id] + outputs_schema: { properties: {} } + - id: list_attachments + name: mdo-list-attachments + description: "List a message's attachments." + risk: read + inputs_schema: + properties: + mailbox: { type: string, description: "Target mailbox (UPN or ID)" } + message_id: { type: string, description: "Message ID" } + required: [mailbox, message_id] + outputs_schema: { properties: {} } + - id: list_folders + name: mdo-list-folders + description: "List the mail folders of a mailbox (to obtain destination IDs for move)." + risk: read + inputs_schema: + properties: + mailbox: { type: string, description: "Target mailbox (UPN or ID)" } + required: [mailbox] + outputs_schema: { properties: {} } + - id: move_message + name: mdo-move-message + description: "Move a message to another folder (e.g. to Junk Email or a quarantine folder)." + inputs_schema: + properties: + mailbox: { type: string, description: "Target mailbox (UPN or ID)" } + message_id: { type: string, description: "Message ID" } + destination_id: { type: string, description: "Destination folder ID or well-known name (e.g. junkemail, deleteditems)" } + required: [mailbox, message_id, destination_id] + outputs_schema: { properties: {} } + - id: delete_message + name: mdo-delete-message + description: "Hard-delete (purge) a message from the mailbox." + inputs_schema: + properties: + mailbox: { type: string, description: "Target mailbox (UPN or ID)" } + message_id: { type: string, description: "Message ID" } + required: [mailbox, message_id] + outputs_schema: { properties: {} } + + - id: test_connection + name: mdo-test-connection + description: "Verify connectivity and the app credentials (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/microsoft-defender-o365/scripts/delete_message.py b/integrations/microsoft-defender-o365/scripts/delete_message.py new file mode 100644 index 0000000..b75335d --- /dev/null +++ b/integrations/microsoft-defender-o365/scripts/delete_message.py @@ -0,0 +1,76 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +GRAPH = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _token(cfg): + tenant = str(cfg.get("tenant_id", "")) + url = "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token" + form = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")), + "client_secret": str(cfg.get("client_secret", "")), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def graph(method, path, token, body=None, params=None): + url = GRAPH + 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 " + 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(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(token, inputs): + mailbox = inputs.get("mailbox") + if not mailbox: + raise Exception("mailbox is required") + message_id = inputs.get("message_id") + if not message_id: + raise Exception("message_id is required") + + graph("DELETE", "/users/" + q(mailbox) + "/messages/" + q(message_id), token) + return {"ok": True, "deleted": message_id} + + +_run(main) diff --git a/integrations/microsoft-defender-o365/scripts/get_message.py b/integrations/microsoft-defender-o365/scripts/get_message.py new file mode 100644 index 0000000..cd7c985 --- /dev/null +++ b/integrations/microsoft-defender-o365/scripts/get_message.py @@ -0,0 +1,75 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +GRAPH = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _token(cfg): + tenant = str(cfg.get("tenant_id", "")) + url = "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token" + form = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")), + "client_secret": str(cfg.get("client_secret", "")), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def graph(method, path, token, body=None, params=None): + url = GRAPH + 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 " + 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(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(token, inputs): + mailbox = inputs.get("mailbox") + if not mailbox: + raise Exception("mailbox is required") + message_id = inputs.get("message_id") + if not message_id: + raise Exception("message_id is required") + + return graph("GET", "/users/" + q(mailbox) + "/messages/" + q(message_id), token) + + +_run(main) diff --git a/integrations/microsoft-defender-o365/scripts/list_attachments.py b/integrations/microsoft-defender-o365/scripts/list_attachments.py new file mode 100644 index 0000000..cf652c5 --- /dev/null +++ b/integrations/microsoft-defender-o365/scripts/list_attachments.py @@ -0,0 +1,75 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +GRAPH = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _token(cfg): + tenant = str(cfg.get("tenant_id", "")) + url = "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token" + form = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")), + "client_secret": str(cfg.get("client_secret", "")), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def graph(method, path, token, body=None, params=None): + url = GRAPH + 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 " + 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(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(token, inputs): + mailbox = inputs.get("mailbox") + if not mailbox: + raise Exception("mailbox is required") + message_id = inputs.get("message_id") + if not message_id: + raise Exception("message_id is required") + + return graph("GET", "/users/" + q(mailbox) + "/messages/" + q(message_id) + "/attachments", token) + + +_run(main) diff --git a/integrations/microsoft-defender-o365/scripts/list_folders.py b/integrations/microsoft-defender-o365/scripts/list_folders.py new file mode 100644 index 0000000..7a73369 --- /dev/null +++ b/integrations/microsoft-defender-o365/scripts/list_folders.py @@ -0,0 +1,72 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +GRAPH = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _token(cfg): + tenant = str(cfg.get("tenant_id", "")) + url = "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token" + form = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")), + "client_secret": str(cfg.get("client_secret", "")), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def graph(method, path, token, body=None, params=None): + url = GRAPH + 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 " + 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(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(token, inputs): + mailbox = inputs.get("mailbox") + if not mailbox: + raise Exception("mailbox is required") + + return graph("GET", "/users/" + q(mailbox) + "/mailFolders", token, params={"$top": 100}) + + +_run(main) diff --git a/integrations/microsoft-defender-o365/scripts/move_message.py b/integrations/microsoft-defender-o365/scripts/move_message.py new file mode 100644 index 0000000..292798e --- /dev/null +++ b/integrations/microsoft-defender-o365/scripts/move_message.py @@ -0,0 +1,83 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +GRAPH = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _token(cfg): + tenant = str(cfg.get("tenant_id", "")) + url = "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token" + form = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")), + "client_secret": str(cfg.get("client_secret", "")), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def graph(method, path, token, body=None, params=None): + url = GRAPH + 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 " + 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(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(token, inputs): + mailbox = inputs.get("mailbox") + if not mailbox: + raise Exception("mailbox is required") + message_id = inputs.get("message_id") + if not message_id: + raise Exception("message_id is required") + destination_id = inputs.get("destination_id") + if not destination_id: + raise Exception("destination_id is required") + + return graph( + "POST", + "/users/" + q(mailbox) + "/messages/" + q(message_id) + "/move", + token, + body={"destinationId": destination_id}, + ) + + +_run(main) diff --git a/integrations/microsoft-defender-o365/scripts/search_messages.py b/integrations/microsoft-defender-o365/scripts/search_messages.py new file mode 100644 index 0000000..edb9aa9 --- /dev/null +++ b/integrations/microsoft-defender-o365/scripts/search_messages.py @@ -0,0 +1,93 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +GRAPH = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _token(cfg): + tenant = str(cfg.get("tenant_id", "")) + url = "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token" + form = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")), + "client_secret": str(cfg.get("client_secret", "")), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def graph(method, path, token, body=None, params=None): + url = GRAPH + 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 " + 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(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 _odata_escape(v): + return str(v).replace("'", "''") + + +def main(token, inputs): + mailbox = inputs.get("mailbox") + if not mailbox: + raise Exception("mailbox is required") + + subject = inputs.get("subject") + from_address = inputs.get("from_address") + search = inputs.get("search") + limit = int(inputs.get("limit") or 25) + + if search: + params = {"$search": '"' + search + '"', "$top": limit} + elif subject or from_address: + parts = [] + if subject: + parts.append("subject eq '" + _odata_escape(subject) + "'") + if from_address: + parts.append("from/emailAddress/address eq '" + _odata_escape(from_address) + "'") + params = {"$filter": " and ".join(parts), "$top": limit} + else: + params = {"$top": limit} + + return graph("GET", "/users/" + q(mailbox) + "/messages", token, params=params) + + +_run(main) diff --git a/integrations/microsoft-defender-o365/scripts/test_connection.py b/integrations/microsoft-defender-o365/scripts/test_connection.py new file mode 100644 index 0000000..c89f22b --- /dev/null +++ b/integrations/microsoft-defender-o365/scripts/test_connection.py @@ -0,0 +1,66 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +GRAPH = "https://graph.microsoft.com/v1.0" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _token(cfg): + tenant = str(cfg.get("tenant_id", "")) + url = "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token" + form = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")), + "client_secret": str(cfg.get("client_secret", "")), + "scope": "https://graph.microsoft.com/.default", + }).encode("utf-8") + req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + tok = json.loads(r.read()) + if not tok.get("access_token"): + raise Exception("Token request failed: " + json.dumps(tok)) + return tok["access_token"] + + +def graph(method, path, token, body=None, params=None): + url = GRAPH + 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 " + 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(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(token, inputs): + graph("GET", "/users", token, params={"$top": 1}) + return {"ok": True} + + +_run(main)