splunkctl 0.9.0__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {splunkctl-0.9.0 → splunkctl-0.10.0}/PKG-INFO +6 -4
- {splunkctl-0.9.0 → splunkctl-0.10.0}/README.md +5 -3
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/__init__.py +1 -1
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/client.py +5 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/es.py +1 -1
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/mcp/server.py +49 -8
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/mcp/tools.py +57 -25
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/soar/client.py +20 -8
- splunkctl-0.10.0/splunkctl/soar/web.py +137 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/PKG-INFO +6 -4
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/SOURCES.txt +1 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_guard.py +33 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_version.py +1 -1
- {splunkctl-0.9.0 → splunkctl-0.10.0}/LICENSE +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/pyproject.toml +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/setup.cfg +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/audit.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/audit_parse.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/common.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/conf.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/conf_ops.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/config_cmd.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/dashboards.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/datamodels.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/hec.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/knowledge.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/kvstore.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/lookups.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/lookups_wiring.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/mcp_cmd.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/rules.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/rules_io.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/search.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/server.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/server_topology.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state_io.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state_io_blobs.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state_io_confs.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state_types.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/users.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/config.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/errors.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/guard.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/main.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/mcp/__init__.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/mcp/resources.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/output.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/soar/__init__.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/soar/cimcef.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_client.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_config.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_main_errors.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_main_hoisting.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: splunkctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: CLI tool for Splunk Enterprise SIEM operations.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -101,8 +101,10 @@ ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
|
101
101
|
envelopes.
|
|
102
102
|
- **Playbooks as code** — `soar playbooks export --unpack` / `import`
|
|
103
103
|
round-trips playbook tgz bundles (the first such tool anywhere);
|
|
104
|
-
`soar playbooks
|
|
105
|
-
`soar
|
|
104
|
+
`soar playbooks delete` removes them through the Web UI route (no
|
|
105
|
+
REST deletion exists); `soar playbooks run --wait` drives and polls
|
|
106
|
+
runs to completion; `soar actions run --wait` does the same for
|
|
107
|
+
connector actions.
|
|
106
108
|
- **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
|
|
107
109
|
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
108
110
|
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
@@ -265,7 +267,7 @@ splunkctl mcp install # register in .mcp.json
|
|
|
265
267
|
splunkctl mcp serve # start stdio MCP server
|
|
266
268
|
```
|
|
267
269
|
|
|
268
|
-
The MCP server auto-generates
|
|
270
|
+
The MCP server auto-generates 229 typed tools from the Click command tree
|
|
269
271
|
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
270
272
|
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
271
273
|
schemas per command group. Subgroup-granular focus works for nested groups
|
|
@@ -63,8 +63,10 @@ ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
|
63
63
|
envelopes.
|
|
64
64
|
- **Playbooks as code** — `soar playbooks export --unpack` / `import`
|
|
65
65
|
round-trips playbook tgz bundles (the first such tool anywhere);
|
|
66
|
-
`soar playbooks
|
|
67
|
-
`soar
|
|
66
|
+
`soar playbooks delete` removes them through the Web UI route (no
|
|
67
|
+
REST deletion exists); `soar playbooks run --wait` drives and polls
|
|
68
|
+
runs to completion; `soar actions run --wait` does the same for
|
|
69
|
+
connector actions.
|
|
68
70
|
- **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
|
|
69
71
|
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
70
72
|
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
@@ -227,7 +229,7 @@ splunkctl mcp install # register in .mcp.json
|
|
|
227
229
|
splunkctl mcp serve # start stdio MCP server
|
|
228
230
|
```
|
|
229
231
|
|
|
230
|
-
The MCP server auto-generates
|
|
232
|
+
The MCP server auto-generates 229 typed tools from the Click command tree
|
|
231
233
|
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
232
234
|
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
233
235
|
schemas per command group. Subgroup-granular focus works for nested groups
|
|
@@ -15,6 +15,7 @@ from typing import Any
|
|
|
15
15
|
import click
|
|
16
16
|
import requests
|
|
17
17
|
import splunklib.client as splunk_client
|
|
18
|
+
import urllib3
|
|
18
19
|
|
|
19
20
|
from splunkctl import config as cfg_mod
|
|
20
21
|
|
|
@@ -186,6 +187,10 @@ class _WebSession:
|
|
|
186
187
|
self._timeout = timeout
|
|
187
188
|
self._session = requests.Session()
|
|
188
189
|
self._session.verify = verify
|
|
190
|
+
if not verify:
|
|
191
|
+
# verify=false is an explicit config choice (lab/self-signed);
|
|
192
|
+
# without this every request spams InsecureRequestWarning.
|
|
193
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
189
194
|
self._csrf_token: str | None = None
|
|
190
195
|
self._logged_in = False
|
|
191
196
|
|
|
@@ -57,7 +57,7 @@ def _status_to_int(value: str) -> str:
|
|
|
57
57
|
key = value.strip().lower()
|
|
58
58
|
if key in _STATUS_MAP:
|
|
59
59
|
return _STATUS_MAP[key]
|
|
60
|
-
if value.isdigit():
|
|
60
|
+
if value.isascii() and value.isdigit():
|
|
61
61
|
return value
|
|
62
62
|
known = ", ".join(_STATUS_MAP)
|
|
63
63
|
raise click.BadParameter(f"'{value}' is not a known status ({known}, or 0-5)")
|
|
@@ -26,6 +26,7 @@ from splunkctl.mcp.tools import (
|
|
|
26
26
|
group_summary,
|
|
27
27
|
group_tools,
|
|
28
28
|
has_subgroups,
|
|
29
|
+
leaf_count,
|
|
29
30
|
subgroup_names,
|
|
30
31
|
)
|
|
31
32
|
|
|
@@ -46,21 +47,35 @@ _FORCE_FLAGS = ["--json"]
|
|
|
46
47
|
_STRIP_PARAMS = SKIP_PARAMS
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
def _decode_stream(data: bytes) -> str:
|
|
51
|
+
"""Decode subprocess output, tolerating binary payloads.
|
|
52
|
+
|
|
53
|
+
Commands like ``soar playbooks export`` emit raw bytes (a tgz) on
|
|
54
|
+
stdout when ``--out`` is omitted — surface a hint instead of dying
|
|
55
|
+
on a UTF-8 decode error.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
return data.decode()
|
|
59
|
+
except UnicodeDecodeError:
|
|
60
|
+
return f"(binary output: {len(data)} bytes — pass --out FILE to save it)"
|
|
61
|
+
|
|
62
|
+
|
|
49
63
|
def _exec_cli(args: list[str]) -> str:
|
|
50
64
|
"""Run ``splunkctl <args>`` as a subprocess and return output."""
|
|
51
65
|
cmd = [sys.executable, "-m", "splunkctl", *args, *_FORCE_FLAGS]
|
|
52
66
|
result = subprocess.run( # noqa: S603
|
|
53
67
|
cmd,
|
|
54
68
|
capture_output=True,
|
|
55
|
-
text=True,
|
|
56
69
|
timeout=120,
|
|
57
70
|
)
|
|
58
|
-
out = result.stdout.strip()
|
|
59
|
-
err = result.stderr.strip()
|
|
71
|
+
out = _decode_stream(result.stdout).strip()
|
|
72
|
+
err = _decode_stream(result.stderr).strip()
|
|
60
73
|
if result.returncode != 0:
|
|
61
74
|
return err or out or f"Command failed with exit code {result.returncode}"
|
|
62
75
|
if err and out:
|
|
63
|
-
|
|
76
|
+
# stderr carries the guard banner / info lines — they precede
|
|
77
|
+
# the payload semantically, so keep them first.
|
|
78
|
+
return f"{err}\n\n{out}"
|
|
64
79
|
return out or err or "(no output)"
|
|
65
80
|
|
|
66
81
|
|
|
@@ -103,10 +118,15 @@ def _build_cli_args(entry: ToolEntry, params: dict[str, Any]) -> list[str]:
|
|
|
103
118
|
items = value if isinstance(value, list) else [value]
|
|
104
119
|
positional[pname] = [str(v) for v in items]
|
|
105
120
|
continue
|
|
106
|
-
|
|
121
|
+
# Renamed Click options (--severity → severity_override) make the
|
|
122
|
+
# schema name diverge from the CLI flag; the entry's flag map is
|
|
123
|
+
# authoritative.
|
|
124
|
+
flag = entry.flags.get(pname, f"--{pname.replace('_', '-')}")
|
|
107
125
|
if isinstance(value, bool):
|
|
108
126
|
if value:
|
|
109
127
|
args.append(flag)
|
|
128
|
+
elif pname in entry.neg_flags:
|
|
129
|
+
args.append(entry.neg_flags[pname])
|
|
110
130
|
elif isinstance(value, list):
|
|
111
131
|
for item in value:
|
|
112
132
|
args.extend([flag, str(item)])
|
|
@@ -180,7 +200,7 @@ def create_server() -> FastMCP:
|
|
|
180
200
|
for s in sg:
|
|
181
201
|
sub_cmd = top_cmd.commands[s]
|
|
182
202
|
desc = (sub_cmd.help or "").split("\n")[0]
|
|
183
|
-
n =
|
|
203
|
+
n = leaf_count(sub_cmd) if isinstance(sub_cmd, click.Group) else 0
|
|
184
204
|
lines.append(f" {grp} {s:16s} {desc} ({n} cmds)")
|
|
185
205
|
if dc:
|
|
186
206
|
lines.append(f"\n{grp} direct commands:")
|
|
@@ -342,6 +362,19 @@ def create_server() -> FastMCP:
|
|
|
342
362
|
async def run_tool(command: str, yes: bool = False) -> str:
|
|
343
363
|
"""Execute a raw splunkctl command string."""
|
|
344
364
|
tokens = _split_command(command)
|
|
365
|
+
# The tool's yes parameter is the only mutation switch — a --yes
|
|
366
|
+
# smuggled inside the command string must not bypass the guard.
|
|
367
|
+
# Reject rather than silently strip: after shlex a quoted option
|
|
368
|
+
# VALUE of '-y' is indistinguishable from the flag, and eating
|
|
369
|
+
# it would shift arguments or corrupt the value.
|
|
370
|
+
if any(t in ("--yes", "-y") for t in tokens):
|
|
371
|
+
return (
|
|
372
|
+
"Error: '--yes'/'-y' is not accepted inside the command "
|
|
373
|
+
"string — the tool's yes parameter is the only mutation "
|
|
374
|
+
"switch. Remove it and pass yes=true to apply. If you "
|
|
375
|
+
"need the literal value '-y', use a focused typed tool "
|
|
376
|
+
"instead."
|
|
377
|
+
)
|
|
345
378
|
if yes:
|
|
346
379
|
tokens.append("--yes")
|
|
347
380
|
result = _exec_cli(tokens)
|
|
@@ -352,9 +385,17 @@ def create_server() -> FastMCP:
|
|
|
352
385
|
if tool_name in all_tools and not any(
|
|
353
386
|
tool_name in names for names in focused.values()
|
|
354
387
|
):
|
|
388
|
+
if len(tokens) >= 2 and tokens[1] in subgroup_names(cli, tokens[0]):
|
|
389
|
+
suggestion = f'focus "{tokens[0]} {tokens[1]}"'
|
|
390
|
+
elif has_subgroups(cli, tokens[0]):
|
|
391
|
+
# Bare focus on a nested group is refused; point at usage
|
|
392
|
+
# for direct commands instead.
|
|
393
|
+
suggestion = f'usage "{" ".join(tokens[:2])}"'
|
|
394
|
+
else:
|
|
395
|
+
suggestion = f"focus {tokens[0]}"
|
|
355
396
|
result += (
|
|
356
|
-
f"\n\nTip: run `
|
|
357
|
-
f"with validated
|
|
397
|
+
f"\n\nTip: run `{suggestion}` to load a typed tool "
|
|
398
|
+
f"with a validated schema for this command."
|
|
358
399
|
)
|
|
359
400
|
return result
|
|
360
401
|
|
|
@@ -6,22 +6,10 @@ import click
|
|
|
6
6
|
|
|
7
7
|
from splunkctl.guard import is_guarded
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"use_json",
|
|
14
|
-
"fmt",
|
|
15
|
-
"format",
|
|
16
|
-
"fields",
|
|
17
|
-
"out",
|
|
18
|
-
"debug",
|
|
19
|
-
"timeout",
|
|
20
|
-
"config",
|
|
21
|
-
"profile",
|
|
22
|
-
"yes",
|
|
23
|
-
}
|
|
24
|
-
)
|
|
9
|
+
# Global flags live on the root group only (main._CLI hoists them from
|
|
10
|
+
# the token stream) — leaf commands never carry them, so the only names
|
|
11
|
+
# to skip are Click's own help and the guard's yes (re-added per schema).
|
|
12
|
+
SKIP_PARAMS = frozenset({"help", "yes"})
|
|
25
13
|
|
|
26
14
|
_YES_PROP: dict[str, Any] = {
|
|
27
15
|
"type": "boolean",
|
|
@@ -32,7 +20,7 @@ _YES_PROP: dict[str, Any] = {
|
|
|
32
20
|
|
|
33
21
|
def _param_schema(p: click.Parameter) -> dict[str, Any] | None:
|
|
34
22
|
"""Convert a Click parameter to a JSON Schema property."""
|
|
35
|
-
if p.name in SKIP_PARAMS
|
|
23
|
+
if p.name in SKIP_PARAMS:
|
|
36
24
|
return None
|
|
37
25
|
if isinstance(p, click.Option) and p.hidden:
|
|
38
26
|
return None
|
|
@@ -88,6 +76,8 @@ class ToolEntry:
|
|
|
88
76
|
"guarded",
|
|
89
77
|
"positional",
|
|
90
78
|
"arg_order",
|
|
79
|
+
"flags",
|
|
80
|
+
"neg_flags",
|
|
91
81
|
)
|
|
92
82
|
|
|
93
83
|
def __init__( # noqa: D107
|
|
@@ -100,6 +90,8 @@ class ToolEntry:
|
|
|
100
90
|
guarded: bool = False,
|
|
101
91
|
positional: frozenset[str] = frozenset(),
|
|
102
92
|
arg_order: tuple[str, ...] = (),
|
|
93
|
+
flags: dict[str, str] | None = None,
|
|
94
|
+
neg_flags: dict[str, str] | None = None,
|
|
103
95
|
) -> None:
|
|
104
96
|
self.name = name
|
|
105
97
|
self.description = description
|
|
@@ -108,6 +100,8 @@ class ToolEntry:
|
|
|
108
100
|
self.guarded = guarded
|
|
109
101
|
self.positional = positional
|
|
110
102
|
self.arg_order = arg_order
|
|
103
|
+
self.flags = flags or {}
|
|
104
|
+
self.neg_flags = neg_flags or {}
|
|
111
105
|
|
|
112
106
|
|
|
113
107
|
def _tool_name(path: list[str]) -> str:
|
|
@@ -115,26 +109,51 @@ def _tool_name(path: list[str]) -> str:
|
|
|
115
109
|
return "_".join(seg.replace("-", "_") for seg in path)
|
|
116
110
|
|
|
117
111
|
|
|
112
|
+
def _long_flag(p: click.Option) -> str | None:
|
|
113
|
+
"""Return the option's canonical long CLI flag (``--like-this``)."""
|
|
114
|
+
longs = [o for o in p.opts if o.startswith("--")]
|
|
115
|
+
if longs:
|
|
116
|
+
return longs[0]
|
|
117
|
+
return p.opts[0] if p.opts else None
|
|
118
|
+
|
|
119
|
+
|
|
118
120
|
def _make_entry(cmd: click.Command, path: list[str]) -> ToolEntry:
|
|
119
121
|
"""Build a ToolEntry (schema, positional order, guard) for one command."""
|
|
120
122
|
properties: dict[str, Any] = {}
|
|
121
123
|
required: list[str] = []
|
|
122
124
|
pos: list[str] = []
|
|
125
|
+
flags: dict[str, str] = {}
|
|
126
|
+
neg_flags: dict[str, str] = {}
|
|
123
127
|
for p in cmd.params:
|
|
124
128
|
if p.name is None:
|
|
125
129
|
continue
|
|
126
130
|
prop = _param_schema(p)
|
|
127
131
|
if prop is None:
|
|
128
132
|
continue
|
|
133
|
+
# Schema names come from the Python param name; a trailing
|
|
134
|
+
# underscore (keyword-avoidance like ``wait_``) is stripped for
|
|
135
|
+
# callers — the flag map below keeps invocation correct either way.
|
|
129
136
|
pname = p.name.replace("-", "_")
|
|
130
|
-
|
|
137
|
+
display = pname.rstrip("_") or pname
|
|
138
|
+
if display in properties:
|
|
139
|
+
display = pname
|
|
140
|
+
properties[display] = prop
|
|
131
141
|
if isinstance(p, click.Argument):
|
|
132
|
-
required.append(
|
|
133
|
-
pos.append(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
required.append(display)
|
|
143
|
+
pos.append(display)
|
|
144
|
+
continue
|
|
145
|
+
if isinstance(p, click.Option):
|
|
146
|
+
flag = _long_flag(p)
|
|
147
|
+
if flag is not None:
|
|
148
|
+
flags[display] = flag
|
|
149
|
+
if p.secondary_opts:
|
|
150
|
+
neg = [o for o in p.secondary_opts if o.startswith("--")]
|
|
151
|
+
if neg:
|
|
152
|
+
neg_flags[display] = neg[0]
|
|
153
|
+
if p.required or p.prompt:
|
|
154
|
+
# prompt=True options would hang the MCP subprocess waiting
|
|
155
|
+
# for TTY input, so force callers to always supply them.
|
|
156
|
+
required.append(display)
|
|
138
157
|
|
|
139
158
|
guarded = is_guarded(cmd)
|
|
140
159
|
if guarded:
|
|
@@ -160,6 +179,8 @@ def _make_entry(cmd: click.Command, path: list[str]) -> ToolEntry:
|
|
|
160
179
|
guarded=guarded,
|
|
161
180
|
positional=frozenset(pos),
|
|
162
181
|
arg_order=tuple(pos),
|
|
182
|
+
flags=flags,
|
|
183
|
+
neg_flags=neg_flags,
|
|
163
184
|
)
|
|
164
185
|
|
|
165
186
|
|
|
@@ -235,6 +256,17 @@ def has_subgroups(root: click.Group, group: str) -> bool:
|
|
|
235
256
|
return bool(subgroup_names(root, group))
|
|
236
257
|
|
|
237
258
|
|
|
259
|
+
def leaf_count(cmd: click.Command) -> int:
|
|
260
|
+
"""Count leaf commands, recursing into nested subgroups.
|
|
261
|
+
|
|
262
|
+
Matches the number of typed tools a full focus of the group would
|
|
263
|
+
load — a nested subgroup counts its commands, not itself.
|
|
264
|
+
"""
|
|
265
|
+
if not isinstance(cmd, click.Group):
|
|
266
|
+
return 1
|
|
267
|
+
return sum(leaf_count(c) for c in cmd.commands.values())
|
|
268
|
+
|
|
269
|
+
|
|
238
270
|
def direct_commands(root: click.Group, group: str) -> list[str]:
|
|
239
271
|
"""Return direct (non-group) command names under a top-level group."""
|
|
240
272
|
top = root.commands.get(group)
|
|
@@ -264,7 +296,7 @@ def group_summary(root: click.Group) -> list[dict[str, str]]:
|
|
|
264
296
|
"group": name,
|
|
265
297
|
"description": desc,
|
|
266
298
|
"subcommands": ", ".join(sorted(subs)),
|
|
267
|
-
"count": str(
|
|
299
|
+
"count": str(leaf_count(cmd)),
|
|
268
300
|
}
|
|
269
301
|
sg = subgroup_names(root, name)
|
|
270
302
|
if sg:
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
"""SOAR REST API client — requests-based, lazy, normalizing.
|
|
2
2
|
|
|
3
|
-
Speaks the Django REST API on Splunk SOAR (on-prem / cloud). All
|
|
4
|
-
response-shape quirks (envelope variants, ``succeeded`` vs ``success``,
|
|
5
|
-
``action_run_id`` vs ``id``, ``failed:true`` on HTTP 200) are
|
|
6
|
-
normalized here so command code never sees them.
|
|
7
|
-
|
|
8
3
|
Auth: ``ph-auth-token`` header preferred; automatic Basic fallback for
|
|
9
|
-
DELETE (server refuses tokens on DELETE except ``decided_list``).
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
DELETE (server refuses tokens on DELETE except ``decided_list``).
|
|
5
|
+
Web UI session (cookie-based) used for operations the REST API lacks
|
|
6
|
+
(e.g. playbook deletion).
|
|
12
7
|
"""
|
|
13
8
|
|
|
14
9
|
from __future__ import annotations
|
|
@@ -18,6 +13,7 @@ from collections.abc import Iterator
|
|
|
18
13
|
from typing import Any
|
|
19
14
|
|
|
20
15
|
import requests
|
|
16
|
+
import urllib3
|
|
21
17
|
|
|
22
18
|
from splunkctl.errors import kind_for_status
|
|
23
19
|
|
|
@@ -117,6 +113,14 @@ class SOARClient:
|
|
|
117
113
|
self._verify = verify
|
|
118
114
|
self._session = requests.Session()
|
|
119
115
|
self._session.verify = verify
|
|
116
|
+
if not verify:
|
|
117
|
+
# verify=false is an explicit config choice (lab/self-signed);
|
|
118
|
+
# without this every request spams InsecureRequestWarning.
|
|
119
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
120
|
+
|
|
121
|
+
# Separate session for Django-view-only operations (e.g. delete).
|
|
122
|
+
self._web_session: requests.Session | None = None
|
|
123
|
+
self._web_csrf: str | None = None
|
|
120
124
|
|
|
121
125
|
# -- URL helpers --------------------------------------------------------
|
|
122
126
|
|
|
@@ -296,6 +300,14 @@ class SOARClient:
|
|
|
296
300
|
body["id"] = body.pop("playbook_run_id")
|
|
297
301
|
return body
|
|
298
302
|
|
|
303
|
+
# -- Web UI session (Django views) ----------------------------------------
|
|
304
|
+
|
|
305
|
+
def web_delete_playbooks(self, ids: list[int]) -> dict[str, Any]:
|
|
306
|
+
"""Delete playbooks via the Django Web UI handler (see soar/web.py)."""
|
|
307
|
+
from splunkctl.soar import web
|
|
308
|
+
|
|
309
|
+
return web.delete_playbooks(self, ids)
|
|
310
|
+
|
|
299
311
|
# -- Public API ---------------------------------------------------------
|
|
300
312
|
|
|
301
313
|
def get(
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""SOAR Django Web UI session — operations the REST API lacks.
|
|
2
|
+
|
|
3
|
+
Playbook deletion has no working REST route on SOAR 8.5
|
|
4
|
+
(``DELETE /rest/playbook/<id>`` → 405; ``POST`` with
|
|
5
|
+
``{"delete": true}`` → silent no-op), so the client logs into the Web
|
|
6
|
+
UI the way a browser does (csrftoken cookie + AJAX login) and posts
|
|
7
|
+
the same request the ``/playbooks`` page sends. Requires
|
|
8
|
+
username/password — the Web UI refuses token auth.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from splunkctl.errors import kind_for_status
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from splunkctl.soar.client import SOARClient
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ensure_web_login(client: SOARClient) -> requests.Session:
|
|
25
|
+
"""Authenticate to the SOAR Django Web UI (cookie-based session).
|
|
26
|
+
|
|
27
|
+
The session and CSRF token are cached on *client* — repeated calls
|
|
28
|
+
reuse the login. Raises :class:`SOARError` (kind ``auth``) without
|
|
29
|
+
username/password credentials or on a refused login.
|
|
30
|
+
"""
|
|
31
|
+
from splunkctl.soar.client import SOARError
|
|
32
|
+
|
|
33
|
+
if client._web_session is not None: # noqa: SLF001
|
|
34
|
+
return client._web_session # noqa: SLF001
|
|
35
|
+
|
|
36
|
+
if not client._has_basic(): # noqa: SLF001
|
|
37
|
+
raise SOARError(
|
|
38
|
+
"Playbook deletion requires username/password credentials "
|
|
39
|
+
"(the SOAR Web UI does not accept token auth).",
|
|
40
|
+
kind="auth",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
sess = requests.Session()
|
|
44
|
+
sess.verify = client._verify # noqa: SLF001
|
|
45
|
+
base = f"https://{client._host}:{client._port}" # noqa: SLF001
|
|
46
|
+
|
|
47
|
+
# GET login page to obtain csrftoken cookie.
|
|
48
|
+
sess.get(f"{base}/login", timeout=15)
|
|
49
|
+
csrf = sess.cookies.get("csrftoken")
|
|
50
|
+
if not csrf:
|
|
51
|
+
raise SOARError(
|
|
52
|
+
"Could not obtain CSRF token from SOAR login page.",
|
|
53
|
+
kind="error",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# POST login (AJAX-style).
|
|
57
|
+
resp = sess.post(
|
|
58
|
+
f"{base}/login",
|
|
59
|
+
data={
|
|
60
|
+
"username": client._username, # noqa: SLF001
|
|
61
|
+
"password": client._password, # noqa: SLF001
|
|
62
|
+
},
|
|
63
|
+
headers={
|
|
64
|
+
"X-CSRFToken": csrf,
|
|
65
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
66
|
+
"Referer": f"{base}/login",
|
|
67
|
+
},
|
|
68
|
+
timeout=15,
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
body = resp.json()
|
|
72
|
+
except (ValueError, json.JSONDecodeError):
|
|
73
|
+
raise SOARError(
|
|
74
|
+
f"SOAR Web login failed: HTTP {resp.status_code}",
|
|
75
|
+
kind="auth",
|
|
76
|
+
http_status=resp.status_code,
|
|
77
|
+
) from None
|
|
78
|
+
if not body.get("authenticated"):
|
|
79
|
+
raise SOARError(
|
|
80
|
+
"SOAR Web login failed: invalid credentials.",
|
|
81
|
+
kind="auth",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
client._web_csrf = sess.cookies.get("csrftoken") or csrf # noqa: SLF001
|
|
85
|
+
client._web_session = sess # noqa: SLF001
|
|
86
|
+
return sess
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def delete_playbooks(client: SOARClient, ids: list[int]) -> dict[str, Any]:
|
|
90
|
+
"""Delete playbooks via the Django Web UI handler.
|
|
91
|
+
|
|
92
|
+
Uses the same route the browser calls (``POST /playbooks`` with
|
|
93
|
+
``{ids, delete: true}``). The handler answers with a
|
|
94
|
+
``{done_count, fail_count, changes, errors}`` envelope (captured
|
|
95
|
+
live on SOAR 8.5); anything else raises rather than being misread
|
|
96
|
+
as "nothing deleted".
|
|
97
|
+
"""
|
|
98
|
+
from splunkctl.soar.client import SOARError
|
|
99
|
+
|
|
100
|
+
sess = ensure_web_login(client)
|
|
101
|
+
base = f"https://{client._host}:{client._port}" # noqa: SLF001
|
|
102
|
+
|
|
103
|
+
resp = sess.post(
|
|
104
|
+
f"{base}/playbooks",
|
|
105
|
+
json={"ids": ids, "delete": True},
|
|
106
|
+
headers={
|
|
107
|
+
"X-CSRFToken": client._web_csrf or "", # noqa: SLF001
|
|
108
|
+
"Referer": f"{base}/playbooks",
|
|
109
|
+
},
|
|
110
|
+
timeout=30,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
body: dict[str, Any] = resp.json()
|
|
115
|
+
except (ValueError, json.JSONDecodeError):
|
|
116
|
+
raise SOARError(
|
|
117
|
+
f"Playbook delete failed: HTTP {resp.status_code}",
|
|
118
|
+
kind=kind_for_status(resp.status_code),
|
|
119
|
+
http_status=resp.status_code,
|
|
120
|
+
) from None
|
|
121
|
+
|
|
122
|
+
if resp.status_code >= 400 or body.get("failed"):
|
|
123
|
+
msg = body.get("message", f"HTTP {resp.status_code}")
|
|
124
|
+
is_http_err = resp.status_code >= 400
|
|
125
|
+
raise SOARError(
|
|
126
|
+
msg,
|
|
127
|
+
kind=kind_for_status(resp.status_code) if is_http_err else "error",
|
|
128
|
+
http_status=resp.status_code if is_http_err else None,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if "done_count" not in body and "fail_count" not in body:
|
|
132
|
+
raise SOARError(
|
|
133
|
+
"Unexpected response from the SOAR Web UI delete handler "
|
|
134
|
+
f"(no done_count/fail_count): {str(body)[:200]}",
|
|
135
|
+
kind="error",
|
|
136
|
+
)
|
|
137
|
+
return body
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: splunkctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: CLI tool for Splunk Enterprise SIEM operations.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -101,8 +101,10 @@ ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
|
101
101
|
envelopes.
|
|
102
102
|
- **Playbooks as code** — `soar playbooks export --unpack` / `import`
|
|
103
103
|
round-trips playbook tgz bundles (the first such tool anywhere);
|
|
104
|
-
`soar playbooks
|
|
105
|
-
`soar
|
|
104
|
+
`soar playbooks delete` removes them through the Web UI route (no
|
|
105
|
+
REST deletion exists); `soar playbooks run --wait` drives and polls
|
|
106
|
+
runs to completion; `soar actions run --wait` does the same for
|
|
107
|
+
connector actions.
|
|
106
108
|
- **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
|
|
107
109
|
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
108
110
|
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
@@ -265,7 +267,7 @@ splunkctl mcp install # register in .mcp.json
|
|
|
265
267
|
splunkctl mcp serve # start stdio MCP server
|
|
266
268
|
```
|
|
267
269
|
|
|
268
|
-
The MCP server auto-generates
|
|
270
|
+
The MCP server auto-generates 229 typed tools from the Click command tree
|
|
269
271
|
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
270
272
|
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
271
273
|
schemas per command group. Subgroup-granular focus works for nested groups
|
|
@@ -191,3 +191,36 @@ def test_check_missing_profile_propagates(tmp_path: Path) -> None:
|
|
|
191
191
|
|
|
192
192
|
with pytest.raises(cfg_mod.ProfileNotFoundError):
|
|
193
193
|
guard.check(ctx, "Delete index 'main'")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_every_checked_command_is_marked_guarded() -> None:
|
|
197
|
+
"""A callback that calls the guard check must carry the @guarded marker.
|
|
198
|
+
|
|
199
|
+
The MCP layer derives the tool's `yes` parameter and [guarded] tag from
|
|
200
|
+
the marker alone — a command that dry-runs at runtime but lacks the
|
|
201
|
+
marker can never be applied through its typed MCP tool.
|
|
202
|
+
"""
|
|
203
|
+
import inspect
|
|
204
|
+
import re
|
|
205
|
+
|
|
206
|
+
from splunkctl.main import cli
|
|
207
|
+
|
|
208
|
+
check_call = re.compile(r"\b(?:guard\.check|soar_check|check)\(ctx")
|
|
209
|
+
unmarked: list[str] = []
|
|
210
|
+
|
|
211
|
+
def walk(group: click.Group, path: list[str]) -> None:
|
|
212
|
+
for name, cmd in group.commands.items():
|
|
213
|
+
if isinstance(cmd, click.Group):
|
|
214
|
+
walk(cmd, [*path, name])
|
|
215
|
+
continue
|
|
216
|
+
if cmd.callback is None:
|
|
217
|
+
continue
|
|
218
|
+
try:
|
|
219
|
+
src = inspect.getsource(inspect.unwrap(cmd.callback))
|
|
220
|
+
except (OSError, TypeError):
|
|
221
|
+
continue
|
|
222
|
+
if check_call.search(src) and not guard.is_guarded(cmd):
|
|
223
|
+
unmarked.append(" ".join([*path, name]))
|
|
224
|
+
|
|
225
|
+
walk(cli, [])
|
|
226
|
+
assert not unmarked, f"guard-checked commands missing @guarded: {unmarked}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|