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.
Files changed (70) hide show
  1. {splunkctl-0.9.0 → splunkctl-0.10.0}/PKG-INFO +6 -4
  2. {splunkctl-0.9.0 → splunkctl-0.10.0}/README.md +5 -3
  3. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/__init__.py +1 -1
  4. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/client.py +5 -0
  5. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/es.py +1 -1
  6. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/mcp/server.py +49 -8
  7. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/mcp/tools.py +57 -25
  8. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/soar/client.py +20 -8
  9. splunkctl-0.10.0/splunkctl/soar/web.py +137 -0
  10. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/PKG-INFO +6 -4
  11. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/SOURCES.txt +1 -0
  12. {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_guard.py +33 -0
  13. {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_version.py +1 -1
  14. {splunkctl-0.9.0 → splunkctl-0.10.0}/LICENSE +0 -0
  15. {splunkctl-0.9.0 → splunkctl-0.10.0}/pyproject.toml +0 -0
  16. {splunkctl-0.9.0 → splunkctl-0.10.0}/setup.cfg +0 -0
  17. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/__main__.py +0 -0
  18. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/__init__.py +0 -0
  19. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/alerts.py +0 -0
  20. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/apps.py +0 -0
  21. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/audit.py +0 -0
  22. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/audit_parse.py +0 -0
  23. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/commands_meta.py +0 -0
  24. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/common.py +0 -0
  25. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/conf.py +0 -0
  26. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/conf_ops.py +0 -0
  27. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/config_cmd.py +0 -0
  28. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/dashboards.py +0 -0
  29. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/datamodels.py +0 -0
  30. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/doctor.py +0 -0
  31. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/hec.py +0 -0
  32. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/indexes.py +0 -0
  33. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/info.py +0 -0
  34. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/inputs.py +0 -0
  35. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/knowledge.py +0 -0
  36. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/kvstore.py +0 -0
  37. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/lookups.py +0 -0
  38. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/lookups_wiring.py +0 -0
  39. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/mcp_cmd.py +0 -0
  40. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/parsers.py +0 -0
  41. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/parsers_io.py +0 -0
  42. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/rules.py +0 -0
  43. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/rules_io.py +0 -0
  44. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/search.py +0 -0
  45. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/server.py +0 -0
  46. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/server_topology.py +0 -0
  47. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state.py +0 -0
  48. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state_io.py +0 -0
  49. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state_io_blobs.py +0 -0
  50. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state_io_confs.py +0 -0
  51. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/state_types.py +0 -0
  52. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/commands/users.py +0 -0
  53. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/config.py +0 -0
  54. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/errors.py +0 -0
  55. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/guard.py +0 -0
  56. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/main.py +0 -0
  57. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/mcp/__init__.py +0 -0
  58. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/mcp/resources.py +0 -0
  59. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/output.py +0 -0
  60. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/soar/__init__.py +0 -0
  61. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl/soar/cimcef.py +0 -0
  62. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  63. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/entry_points.txt +0 -0
  64. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/requires.txt +0 -0
  65. {splunkctl-0.9.0 → splunkctl-0.10.0}/splunkctl.egg-info/top_level.txt +0 -0
  66. {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_client.py +0 -0
  67. {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_config.py +0 -0
  68. {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_main_errors.py +0 -0
  69. {splunkctl-0.9.0 → splunkctl-0.10.0}/tests/test_main_hoisting.py +0 -0
  70. {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.9.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 run --wait` drives and polls runs to completion;
105
- `soar actions run --wait` does the same for connector actions.
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 237 typed tools from the Click command tree
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 run --wait` drives and polls runs to completion;
67
- `soar actions run --wait` does the same for connector actions.
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 237 typed tools from the Click command tree
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
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise and Splunk SOAR operations."""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.10.0"
@@ -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
- return f"{out}\n\n{err}"
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
- flag = f"--{pname.replace('_', '-')}"
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 = len(sub_cmd.commands) if isinstance(sub_cmd, click.Group) else 0
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 `focus {tokens[0]}` to load typed tools "
357
- f"with validated schemas for this group."
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
- SKIP_PARAMS = frozenset(
10
- {
11
- "help",
12
- "json",
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 or p.name == "help":
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
- properties[pname] = prop
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(pname)
133
- pos.append(pname)
134
- elif isinstance(p, click.Option) and (p.required or p.prompt):
135
- # prompt=True options would hang the MCP subprocess waiting
136
- # for TTY input, so force callers to always supply them.
137
- required.append(pname)
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(len(subs)),
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``). A
10
- token-only config attempting a non-decided_list DELETE raises
11
- :class:`SOARError` with kind ``auth``.
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.9.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 run --wait` drives and polls runs to completion;
105
- `soar actions run --wait` does the same for connector actions.
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 237 typed tools from the Click command tree
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
@@ -58,6 +58,7 @@ splunkctl/mcp/tools.py
58
58
  splunkctl/soar/__init__.py
59
59
  splunkctl/soar/cimcef.py
60
60
  splunkctl/soar/client.py
61
+ splunkctl/soar/web.py
61
62
  tests/test_client.py
62
63
  tests/test_config.py
63
64
  tests/test_guard.py
@@ -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}"
@@ -4,4 +4,4 @@ from splunkctl import __version__
4
4
 
5
5
 
6
6
  def test_version() -> None:
7
- assert __version__ == "0.9.0"
7
+ assert __version__ == "0.10.0"
File without changes
File without changes
File without changes
File without changes
File without changes