From 16deec38f83c7237b0fb0ebc107cb8a908e911f9 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sun, 12 Jul 2026 15:08:50 +0200 Subject: [PATCH] feat(graylog): new Graylog log-search integration Graylog REST API, 5 commands: relative message search, list/get streams, get message. API-token (Basic) auth, stdlib-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/graylog/manifest.yaml | 70 +++++++++++++++++++ integrations/graylog/scripts/get_message.py | 56 +++++++++++++++ integrations/graylog/scripts/get_stream.py | 52 ++++++++++++++ integrations/graylog/scripts/list_streams.py | 45 ++++++++++++ .../graylog/scripts/search_messages.py | 60 ++++++++++++++++ .../graylog/scripts/test_connection.py | 46 ++++++++++++ 6 files changed, 329 insertions(+) create mode 100644 integrations/graylog/manifest.yaml create mode 100644 integrations/graylog/scripts/get_message.py create mode 100644 integrations/graylog/scripts/get_stream.py create mode 100644 integrations/graylog/scripts/list_streams.py create mode 100644 integrations/graylog/scripts/search_messages.py create mode 100644 integrations/graylog/scripts/test_connection.py diff --git a/integrations/graylog/manifest.yaml b/integrations/graylog/manifest.yaml new file mode 100644 index 0000000..9eb4dc4 --- /dev/null +++ b/integrations/graylog/manifest.yaml @@ -0,0 +1,70 @@ +id: graylog +name: Graylog +version: 1.0.0 +description: "Graylog (REST API) — log search and investigation: run a relative message search, list and read streams, and fetch a single message. API-token (Basic) authentication; stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: search messages, list/get streams, get message." +category: siem + +# Per-instance configuration. Auth is HTTP Basic using the API token as the +# username and the literal 'token' as the password. +config_schema: + properties: + base_url: + type: string + description: "Graylog URL (e.g. https://graylog.example.com)" + token: + type: string + description: "Graylog API token" + x-soar-sensitive: true + required: + - base_url + - token + +commands: + - id: search_messages + name: graylog-search-messages + description: "Run a relative message search (last N seconds)." + risk: read + inputs_schema: + properties: + query: { type: string, description: "Search query (e.g. 'source:web01 AND level:3')" } + range: { type: number, description: "Relative range in seconds (default 3600)" } + limit: { type: number, description: "Max messages (default 50)" } + required: [query] + outputs_schema: { properties: {} } + - id: list_streams + name: graylog-list-streams + description: "List streams." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } + - id: get_stream + name: graylog-get-stream + description: "Get a single stream by ID." + risk: read + inputs_schema: + properties: + stream_id: { type: string, description: "Stream ID" } + required: [stream_id] + outputs_schema: { properties: {} } + - id: get_message + name: graylog-get-message + description: "Get a single message by index and ID." + risk: read + inputs_schema: + properties: + index: { type: string, description: "Elasticsearch index name" } + message_id: { type: string, description: "Message ID" } + required: [index, message_id] + outputs_schema: { properties: {} } + + - id: test_connection + name: graylog-test-connection + description: "Verify connectivity and the token (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/graylog/scripts/get_message.py b/integrations/graylog/scripts/get_message.py new file mode 100644 index 0000000..39e6b1a --- /dev/null +++ b/integrations/graylog/scripts/get_message.py @@ -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 _auth(cfg): + raw = str(cfg.get("token", "")) + ":token" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, params=None): + url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + headers = {"Authorization": _auth(cfg), "Accept": "application/json", "X-Requested-By": "riposte"} + req = urllib.request.Request(url, 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): + index = inputs.get("index") + if not index: + raise Exception("index is required") + + message_id = inputs.get("message_id") + if not message_id: + raise Exception("message_id is required") + + return request("GET", "/messages/" + q(index) + "/" + q(message_id), cfg) + + +_run(main) diff --git a/integrations/graylog/scripts/get_stream.py b/integrations/graylog/scripts/get_stream.py new file mode 100644 index 0000000..94df641 --- /dev/null +++ b/integrations/graylog/scripts/get_stream.py @@ -0,0 +1,52 @@ +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 _auth(cfg): + raw = str(cfg.get("token", "")) + ":token" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, params=None): + url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + headers = {"Authorization": _auth(cfg), "Accept": "application/json", "X-Requested-By": "riposte"} + req = urllib.request.Request(url, 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): + stream_id = inputs.get("stream_id") + if not stream_id: + raise Exception("stream_id is required") + + return request("GET", "/streams/" + q(stream_id), cfg) + + +_run(main) diff --git a/integrations/graylog/scripts/list_streams.py b/integrations/graylog/scripts/list_streams.py new file mode 100644 index 0000000..dd305f1 --- /dev/null +++ b/integrations/graylog/scripts/list_streams.py @@ -0,0 +1,45 @@ +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 _auth(cfg): + raw = str(cfg.get("token", "")) + ":token" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, params=None): + url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + headers = {"Authorization": _auth(cfg), "Accept": "application/json", "X-Requested-By": "riposte"} + req = urllib.request.Request(url, 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): + return request("GET", "/streams", cfg) + + +_run(main) diff --git a/integrations/graylog/scripts/search_messages.py b/integrations/graylog/scripts/search_messages.py new file mode 100644 index 0000000..72b3d7b --- /dev/null +++ b/integrations/graylog/scripts/search_messages.py @@ -0,0 +1,60 @@ +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 _auth(cfg): + raw = str(cfg.get("token", "")) + ":token" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, params=None): + url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + headers = {"Authorization": _auth(cfg), "Accept": "application/json", "X-Requested-By": "riposte"} + req = urllib.request.Request(url, 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") + + range_secs = inputs.get("range") + range_secs = int(range_secs) if range_secs not in (None, "") else 3600 + + limit = inputs.get("limit") + limit = int(limit) if limit not in (None, "") else 50 + + return request( + "GET", + "/search/universal/relative", + cfg, + params={"query": query, "range": range_secs, "limit": limit}, + ) + + +_run(main) diff --git a/integrations/graylog/scripts/test_connection.py b/integrations/graylog/scripts/test_connection.py new file mode 100644 index 0000000..5ca8b9d --- /dev/null +++ b/integrations/graylog/scripts/test_connection.py @@ -0,0 +1,46 @@ +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 _auth(cfg): + raw = str(cfg.get("token", "")) + ":token" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, params=None): + url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + headers = {"Authorization": _auth(cfg), "Accept": "application/json", "X-Requested-By": "riposte"} + req = urllib.request.Request(url, 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", "/system", cfg) + return {"ok": True} + + +_run(main)