splunkctl 0.6.0__tar.gz → 0.7.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 (68) hide show
  1. {splunkctl-0.6.0 → splunkctl-0.7.0}/PKG-INFO +1 -1
  2. {splunkctl-0.6.0 → splunkctl-0.7.0}/pyproject.toml +1 -1
  3. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/__init__.py +1 -1
  4. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/config_cmd.py +88 -14
  5. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/config.py +90 -3
  6. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/errors.py +6 -0
  7. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/guard.py +30 -0
  8. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/main.py +2 -0
  9. splunkctl-0.7.0/splunkctl/soar/__init__.py +0 -0
  10. splunkctl-0.7.0/splunkctl/soar/client.py +377 -0
  11. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl.egg-info/PKG-INFO +1 -1
  12. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl.egg-info/SOURCES.txt +2 -0
  13. {splunkctl-0.6.0 → splunkctl-0.7.0}/tests/test_config.py +195 -0
  14. {splunkctl-0.6.0 → splunkctl-0.7.0}/tests/test_version.py +1 -1
  15. {splunkctl-0.6.0 → splunkctl-0.7.0}/LICENSE +0 -0
  16. {splunkctl-0.6.0 → splunkctl-0.7.0}/README.md +0 -0
  17. {splunkctl-0.6.0 → splunkctl-0.7.0}/setup.cfg +0 -0
  18. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/__main__.py +0 -0
  19. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/client.py +0 -0
  20. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/__init__.py +0 -0
  21. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/alerts.py +0 -0
  22. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/apps.py +0 -0
  23. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/audit.py +0 -0
  24. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/audit_parse.py +0 -0
  25. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/commands_meta.py +0 -0
  26. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/common.py +0 -0
  27. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/conf.py +0 -0
  28. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/conf_ops.py +0 -0
  29. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/dashboards.py +0 -0
  30. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/datamodels.py +0 -0
  31. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/doctor.py +0 -0
  32. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/es.py +0 -0
  33. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/hec.py +0 -0
  34. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/indexes.py +0 -0
  35. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/info.py +0 -0
  36. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/inputs.py +0 -0
  37. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/knowledge.py +0 -0
  38. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/kvstore.py +0 -0
  39. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/lookups.py +0 -0
  40. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/lookups_wiring.py +0 -0
  41. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/mcp_cmd.py +0 -0
  42. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/parsers.py +0 -0
  43. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/parsers_io.py +0 -0
  44. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/rules.py +0 -0
  45. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/rules_io.py +0 -0
  46. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/search.py +0 -0
  47. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/server.py +0 -0
  48. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/server_topology.py +0 -0
  49. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/state.py +0 -0
  50. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/state_io.py +0 -0
  51. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/state_io_blobs.py +0 -0
  52. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/state_io_confs.py +0 -0
  53. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/state_types.py +0 -0
  54. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/commands/users.py +0 -0
  55. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/mcp/__init__.py +0 -0
  56. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/mcp/resources.py +0 -0
  57. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/mcp/server.py +0 -0
  58. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/mcp/tools.py +0 -0
  59. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl/output.py +0 -0
  60. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  61. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl.egg-info/entry_points.txt +0 -0
  62. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl.egg-info/requires.txt +0 -0
  63. {splunkctl-0.6.0 → splunkctl-0.7.0}/splunkctl.egg-info/top_level.txt +0 -0
  64. {splunkctl-0.6.0 → splunkctl-0.7.0}/tests/test_client.py +0 -0
  65. {splunkctl-0.6.0 → splunkctl-0.7.0}/tests/test_guard.py +0 -0
  66. {splunkctl-0.6.0 → splunkctl-0.7.0}/tests/test_main_errors.py +0 -0
  67. {splunkctl-0.6.0 → splunkctl-0.7.0}/tests/test_main_hoisting.py +0 -0
  68. {splunkctl-0.6.0 → splunkctl-0.7.0}/tests/test_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -52,7 +52,7 @@ Repository = "https://github.com/dannyota/splunkctl"
52
52
  Issues = "https://github.com/dannyota/splunkctl/issues"
53
53
 
54
54
  [tool.setuptools]
55
- packages = ["splunkctl", "splunkctl.commands", "splunkctl.mcp"]
55
+ packages = ["splunkctl", "splunkctl.commands", "splunkctl.mcp", "splunkctl.soar"]
56
56
 
57
57
  [tool.setuptools.dynamic]
58
58
  version = { attr = "splunkctl.__version__" }
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise SIEM operations."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -17,17 +17,24 @@ def config_group() -> None:
17
17
 
18
18
 
19
19
  @config_group.command()
20
- @click.option("--host", prompt=True, default="localhost", help="Splunk host.")
21
- @click.option("--port", prompt=True, default=8089, type=int, help="Splunk port.")
22
- @click.option("--username", prompt=True, default="admin", help="Splunk username.")
23
- @click.option("--password", prompt=True, hide_input=True, help="Splunk password.")
20
+ @click.option("--host", default=None, help="Splunk host.")
21
+ @click.option("--port", default=None, type=int, help="Splunk port.")
22
+ @click.option("--username", default=None, help="Splunk username.")
23
+ @click.option("--password", default=None, help="Splunk password.")
24
24
  @click.option(
25
25
  "--scheme",
26
26
  type=click.Choice(["https", "http"]),
27
- default="https",
27
+ default=None,
28
28
  help="Connection scheme.",
29
29
  )
30
- @click.option("--verify/--no-verify", default=False, help="Verify SSL certificate.")
30
+ @click.option("--verify/--no-verify", default=None, help="Verify SSL certificate.")
31
+ @click.option(
32
+ "--soar",
33
+ "soar_mode",
34
+ is_flag=True,
35
+ default=False,
36
+ help="Add SOAR connection settings to the profile.",
37
+ )
31
38
  @click.option(
32
39
  "--profile",
33
40
  "profile_name",
@@ -44,13 +51,16 @@ def config_group() -> None:
44
51
  default=None,
45
52
  help="Config file path (default: ~/.splunkctl/config.yaml).",
46
53
  )
54
+ @click.pass_context
47
55
  def init(
48
- host: str,
49
- port: int,
50
- username: str,
51
- password: str,
52
- scheme: str,
53
- verify: bool,
56
+ ctx: click.Context,
57
+ host: str | None,
58
+ port: int | None,
59
+ username: str | None,
60
+ password: str | None,
61
+ scheme: str | None,
62
+ verify: bool | None,
63
+ soar_mode: bool,
54
64
  profile_name: str | None,
55
65
  path: str | None,
56
66
  ) -> None:
@@ -63,7 +73,28 @@ def init(
63
73
  never clobbers an existing multi-profile file.
64
74
  ``config init --profile <name>`` targets that named profile instead —
65
75
  use ``config use <name>`` afterwards to make it active.
76
+
77
+ ``config init --soar`` prompts for SOAR connection settings and saves
78
+ them as a nested ``soar:`` map within the profile, preserving existing
79
+ SIEM fields.
66
80
  """
81
+ dest = Path(path) if path else None
82
+ if soar_mode:
83
+ _init_soar(profile_name, dest)
84
+ return
85
+
86
+ # Prompt for SIEM fields not supplied via flags.
87
+ host = host or click.prompt("Host", default="localhost")
88
+ port = port or click.prompt("Port", default=8089, type=int)
89
+ username = username or click.prompt("Username", default="admin")
90
+ password = (
91
+ password
92
+ if password is not None
93
+ else click.prompt("Password", default="", hide_input=True)
94
+ )
95
+ scheme = scheme or "https"
96
+ verify = verify if verify is not None else False
97
+
67
98
  cfg: dict[str, Any] = {
68
99
  "host": host,
69
100
  "port": port,
@@ -72,7 +103,6 @@ def init(
72
103
  "scheme": scheme,
73
104
  "verify": verify,
74
105
  }
75
- dest = Path(path) if path else None
76
106
  if profile_name:
77
107
  saved = cfg_mod.save_profile(cfg, profile_name, dest)
78
108
  elif cfg_mod.is_v2_file(dest):
@@ -82,6 +112,40 @@ def init(
82
112
  output.info(f"Config saved to {saved}")
83
113
 
84
114
 
115
+ def _init_soar(
116
+ profile_name: str | None,
117
+ dest: Path | None,
118
+ ) -> None:
119
+ """Prompt for SOAR fields and merge into the target profile."""
120
+ soar_host = click.prompt("SOAR host", type=str)
121
+ soar_port = click.prompt("SOAR port", default=8443, type=int)
122
+ soar_token = click.prompt("SOAR token", default="", type=str)
123
+ soar_user = click.prompt("SOAR username", default="", type=str)
124
+ soar_pass = click.prompt("SOAR password", default="", hide_input=True, type=str)
125
+
126
+ soar_cfg: dict[str, Any] = {"host": soar_host, "port": soar_port}
127
+ if soar_token:
128
+ soar_cfg["token"] = soar_token
129
+ if soar_user:
130
+ soar_cfg["username"] = soar_user
131
+ if soar_pass:
132
+ soar_cfg["password"] = soar_pass
133
+
134
+ # Resolve which profile to update (same precedence as resolve()).
135
+ config_path = dest or cfg_mod.DEFAULT_PATH
136
+ raw = cfg_mod._read_raw(config_path) # noqa: SLF001
137
+ name = cfg_mod._active_profile_name(raw, profile_name) # noqa: SLF001
138
+
139
+ # Load existing SIEM fields (if any) and attach the new soar: map.
140
+ try:
141
+ existing = dict(cfg_mod._profile_config(raw, name)) # noqa: SLF001
142
+ except cfg_mod.ProfileNotFoundError:
143
+ existing = {}
144
+ existing["soar"] = soar_cfg
145
+ saved = cfg_mod.save_profile(existing, name, dest)
146
+ output.info(f"SOAR config saved to {saved}")
147
+
148
+
85
149
  @config_group.command()
86
150
  @click.pass_context
87
151
  def show(ctx: click.Context) -> None:
@@ -97,7 +161,17 @@ def show(ctx: click.Context) -> None:
97
161
  explicit_profile = obj.get("profile")
98
162
 
99
163
  resolved = cfg_mod.resolve(config_path, profile=explicit_profile)
100
- payload = {"profile": resolved["profile"], **cfg_mod.redact(resolved["cfg"])}
164
+ payload: dict[str, Any] = {
165
+ "profile": resolved["profile"],
166
+ **cfg_mod.redact(resolved["cfg"]),
167
+ }
168
+
169
+ # Include redacted SOAR section if the profile has one.
170
+ soar = cfg_mod.resolve_soar(config_path, profile=explicit_profile)
171
+ # Only include soar: if the profile actually configured it (has a host).
172
+ if "host" in soar:
173
+ payload["soar"] = cfg_mod.redact_soar(soar)
174
+
101
175
  output.render(ctx, payload)
102
176
 
103
177
  if explicit_profile is None:
@@ -36,6 +36,17 @@ _ENV_MAP: dict[str, str] = {
36
36
  "SPLUNK_VERIFY": "verify",
37
37
  }
38
38
 
39
+ _SOAR_ENV_MAP: dict[str, str] = {
40
+ "SOAR_HOST": "host",
41
+ "SOAR_PORT": "port",
42
+ "SOAR_TOKEN": "token",
43
+ "SOAR_USER": "username",
44
+ "SOAR_PASS": "password",
45
+ "SOAR_VERIFY": "verify",
46
+ }
47
+
48
+ _SOAR_SECRETS: frozenset[str] = frozenset({"token", "password"})
49
+
39
50
  type ConfigSource = Literal["profile", "env", "flags"]
40
51
 
41
52
 
@@ -68,6 +79,11 @@ def defaults() -> dict[str, Any]:
68
79
  }
69
80
 
70
81
 
82
+ def soar_defaults() -> dict[str, Any]:
83
+ """Return default SOAR configuration values (port and verify only)."""
84
+ return {"port": 8443, "verify": False}
85
+
86
+
71
87
  def _read_raw(path: Path) -> dict[str, Any]:
72
88
  """Read a config file as a plain dict; {} if missing or empty."""
73
89
  if not path.exists():
@@ -94,20 +110,37 @@ def _active_profile_name(raw: dict[str, Any], profile: str | None) -> str:
94
110
 
95
111
 
96
112
  def _profile_config(raw: dict[str, Any], name: str) -> dict[str, Any]:
97
- """Return one profile's fields, or raise if ``name`` is unknown.
113
+ """Return one profile's SIEM fields, or raise if ``name`` is unknown.
98
114
 
99
115
  A v2 file requires an explicit ``profiles.<name>`` entry. A legacy
100
116
  (flat) or missing file only ever has the implicit ``default`` profile.
117
+ The nested ``soar:`` key is stripped — use ``_soar_profile_config``
118
+ to read it.
101
119
  """
102
120
  if _is_v2(raw):
103
121
  profiles = raw.get(PROFILES_KEY) or {}
104
122
  prof = profiles.get(name)
105
123
  if not isinstance(prof, dict):
106
124
  raise ProfileNotFoundError(name)
107
- return prof
125
+ return {k: v for k, v in prof.items() if k != "soar"}
126
+ if name != _DEFAULT_PROFILE:
127
+ raise ProfileNotFoundError(name)
128
+ return {k: v for k, v in raw.items() if k != "soar"}
129
+
130
+
131
+ def _soar_profile_config(raw: dict[str, Any], name: str) -> dict[str, Any]:
132
+ """Return the ``soar:`` nested map from a profile, or {} if absent."""
133
+ if _is_v2(raw):
134
+ profiles = raw.get(PROFILES_KEY) or {}
135
+ prof = profiles.get(name)
136
+ if not isinstance(prof, dict):
137
+ raise ProfileNotFoundError(name)
138
+ soar = prof.get("soar")
139
+ return dict(soar) if isinstance(soar, dict) else {}
108
140
  if name != _DEFAULT_PROFILE:
109
141
  raise ProfileNotFoundError(name)
110
- return raw
142
+ soar = raw.get("soar")
143
+ return dict(soar) if isinstance(soar, dict) else {}
111
144
 
112
145
 
113
146
  def is_v2_file(path: Path | None = None) -> bool:
@@ -262,3 +295,57 @@ def use_profile(name: str, path: Path | None = None) -> Path:
262
295
  def redact(cfg: dict[str, Any]) -> dict[str, Any]:
263
296
  """Return a copy with secret values replaced by '****'."""
264
297
  return {k: "****" if k in _SECRETS and v else v for k, v in cfg.items()}
298
+
299
+
300
+ # --- SOAR config resolution ---
301
+
302
+
303
+ def _apply_soar_env_overlay(cfg: dict[str, Any]) -> bool:
304
+ """Overlay ``SOAR_*`` env vars onto ``cfg``; True if any applied."""
305
+ touched = False
306
+ for env_key, cfg_key in _SOAR_ENV_MAP.items():
307
+ val = os.environ.get(env_key)
308
+ if val is None:
309
+ continue
310
+ if cfg_key == "port":
311
+ try:
312
+ cfg[cfg_key] = int(val)
313
+ except ValueError:
314
+ continue
315
+ elif cfg_key == "verify":
316
+ cfg[cfg_key] = val.lower() in ("1", "true", "yes")
317
+ else:
318
+ cfg[cfg_key] = val
319
+ touched = True
320
+ return touched
321
+
322
+
323
+ def resolve_soar(
324
+ path: Path | None = None,
325
+ *,
326
+ profile: str | None = None,
327
+ overrides: dict[str, Any] | None = None,
328
+ ) -> dict[str, Any]:
329
+ """Resolve effective SOAR config for the active profile.
330
+
331
+ Precedence: ``overrides`` (CLI flags) > ``SOAR_*`` env vars >
332
+ profile ``soar:`` section > built-in SOAR defaults (port, verify).
333
+ Returns a flat dict with only the keys that have values — callers
334
+ check for ``host`` to decide if SOAR is configured.
335
+ """
336
+ cfg = soar_defaults()
337
+ config_path = path or DEFAULT_PATH
338
+ raw = _read_raw(config_path)
339
+ name = _active_profile_name(raw, profile)
340
+ cfg.update(_soar_profile_config(raw, name))
341
+ _apply_soar_env_overlay(cfg)
342
+ if overrides:
343
+ for key, val in overrides.items():
344
+ if val is not None:
345
+ cfg[key] = val
346
+ return cfg
347
+
348
+
349
+ def redact_soar(cfg: dict[str, Any]) -> dict[str, Any]:
350
+ """Return a copy of SOAR config with secrets replaced by '****'."""
351
+ return {k: "****" if k in _SOAR_SECRETS and v else v for k, v in cfg.items()}
@@ -44,6 +44,12 @@ def classify(exc: Exception) -> Classified | None:
44
44
  callers should fall back to their own default handling (e.g. an
45
45
  unclassified ``error`` kind, or re-raising).
46
46
  """
47
+ # SOARError carries kind/http_status natively — pass through.
48
+ from splunkctl.soar.client import SOARError
49
+
50
+ if isinstance(exc, SOARError):
51
+ return Classified(exc.message, exc.kind, exc.http_status)
52
+
47
53
  name = type(exc).__name__
48
54
  if name == "HTTPError":
49
55
  status: int | None = getattr(exc, "status", None)
@@ -8,6 +8,36 @@ import click
8
8
  from splunkctl import config as cfg_mod
9
9
 
10
10
 
11
+ def soar_banner(ctx: click.Context) -> str:
12
+ """Build ``(SOAR @ host:port)`` guard banner for SOAR mutations.
13
+
14
+ Reads SOAR config via :func:`splunkctl.config.resolve_soar` — no
15
+ network I/O. Safe for dry-run previews and ``--yes`` confirmations.
16
+ """
17
+ obj: dict[str, Any] = ctx.obj or {}
18
+ config_path = Path(obj["config"]) if obj.get("config") else None
19
+ cfg = cfg_mod.resolve_soar(config_path, profile=obj.get("profile"))
20
+ host = cfg.get("host", "unknown")
21
+ port = cfg.get("port", 8443)
22
+ return f"(SOAR @ {host}:{port})"
23
+
24
+
25
+ def soar_check(ctx: click.Context, action: str, *, details: str = "") -> bool:
26
+ """SOAR mutation guard — same as :func:`check` but with the SOAR banner."""
27
+ obj: dict[str, Any] = ctx.obj or {}
28
+ tag = soar_banner(ctx)
29
+
30
+ if not obj.get("dry_run", True):
31
+ click.echo(f"Applying: {action} {tag}", err=True)
32
+ return True
33
+
34
+ click.echo(f"[DRY RUN] {action} {tag}", err=True)
35
+ if details:
36
+ click.echo(details, err=True)
37
+ click.echo("Pass --yes to apply.", err=True)
38
+ return False
39
+
40
+
11
41
  def banner(ctx: click.Context, *, overrides: dict[str, Any] | None = None) -> str:
12
42
  """Build the ``(source: name @ host:port)`` guard banner.
13
43
 
@@ -30,6 +30,7 @@ from splunkctl.commands.parsers import parsers_group
30
30
  from splunkctl.commands.rules import rules_group
31
31
  from splunkctl.commands.search import search_group
32
32
  from splunkctl.commands.server import server_group
33
+ from splunkctl.commands.soar import soar_group
33
34
  from splunkctl.commands.state import state_group
34
35
  from splunkctl.commands.users import users_group
35
36
 
@@ -199,6 +200,7 @@ cli.add_command(parsers_group)
199
200
  cli.add_command(rules_group)
200
201
  cli.add_command(search_group)
201
202
  cli.add_command(server_group)
203
+ cli.add_command(soar_group)
202
204
  cli.add_command(mcp_group)
203
205
  cli.add_command(state_group)
204
206
  cli.add_command(tags_group)
File without changes
@@ -0,0 +1,377 @@
1
+ """SOAR REST API client — requests-based, lazy, normalizing.
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
+ 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``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import Iterator
18
+ from typing import Any
19
+
20
+ import requests
21
+
22
+ from splunkctl.errors import kind_for_status
23
+
24
+ # Endpoints with non-standard envelopes.
25
+ _BARE_ARRAY_ENDPOINTS: frozenset[str] = frozenset({"audit"})
26
+ _RESULTS_ENVELOPE_ENDPOINTS: frozenset[str] = frozenset({"search"})
27
+
28
+ # DELETE endpoints that accept token auth (exceptions to the Basic rule).
29
+ _TOKEN_DELETE_OK: frozenset[str] = frozenset({"decided_list"})
30
+
31
+ # Operators where the value is not a string and should not be quoted.
32
+ _UNQUOTED_OPS: frozenset[str] = frozenset({"in", "range"})
33
+
34
+
35
+ class SOARError(Exception):
36
+ """SOAR API error with ``errors.py``-compatible kind and status."""
37
+
38
+ def __init__( # noqa: D107
39
+ self,
40
+ message: str,
41
+ *,
42
+ kind: str = "error",
43
+ http_status: int | None = None,
44
+ data: dict[str, Any] | None = None,
45
+ ) -> None:
46
+ self.message = message
47
+ self.kind = kind
48
+ self.http_status = http_status
49
+ self.data = data or {}
50
+ super().__init__(message)
51
+
52
+
53
+ def build_filters(**kwargs: Any) -> dict[str, str]:
54
+ """Build Django-style query-string filters for a SOAR REST call.
55
+
56
+ Rules:
57
+ - Strings are quoted (``"value"``).
58
+ - Booleans become Python-style ``True``/``False``.
59
+ - Integers/floats are unquoted.
60
+ - ``__in`` lists serialize as ``[1, 2, 3]``.
61
+ - Keys starting with ``_exclude_`` pass through as-is.
62
+ - ``None`` values are skipped.
63
+ """
64
+ params: dict[str, str] = {}
65
+ for key, value in kwargs.items():
66
+ if value is None:
67
+ continue
68
+
69
+ if key.startswith("_exclude_"):
70
+ params[key] = _format_value(key, value)
71
+ continue
72
+
73
+ # Split off operator suffix (e.g. name__icontains -> name, icontains)
74
+ params[f"_filter_{key}"] = _format_value(key, value)
75
+
76
+ return params
77
+
78
+
79
+ def _format_value(key: str, value: Any) -> str:
80
+ """Format a filter value per SOAR conventions."""
81
+ if isinstance(value, bool):
82
+ return "True" if value else "False"
83
+ if isinstance(value, list):
84
+ inner = ", ".join(str(v) for v in value)
85
+ return f"[{inner}]"
86
+ if isinstance(value, int | float):
87
+ return str(value)
88
+ # String values: quote unless it's an __in/__range op
89
+ parts = key.rsplit("__", maxsplit=1)
90
+ op = parts[-1] if len(parts) > 1 else None
91
+ if op in _UNQUOTED_OPS:
92
+ return str(value)
93
+ return f'"{value}"'
94
+
95
+
96
+ class SOARClient:
97
+ """Lazy SOAR REST API client.
98
+
99
+ No I/O happens until the first ``get``/``post``/``delete`` call.
100
+ """
101
+
102
+ def __init__( # noqa: D107
103
+ self,
104
+ *,
105
+ host: str,
106
+ port: int = 8443,
107
+ token: str | None = None,
108
+ username: str | None = None,
109
+ password: str | None = None,
110
+ verify: bool = False,
111
+ ) -> None:
112
+ self._host = host
113
+ self._port = port
114
+ self._token = token
115
+ self._username = username
116
+ self._password = password
117
+ self._verify = verify
118
+ self._session = requests.Session()
119
+ self._session.verify = verify
120
+
121
+ # -- URL helpers --------------------------------------------------------
122
+
123
+ @property
124
+ def _base_url(self) -> str:
125
+ return f"https://{self._host}:{self._port}/rest"
126
+
127
+ def _url(self, path: str) -> str:
128
+ clean = path.lstrip("/")
129
+ return f"{self._base_url}/{clean}"
130
+
131
+ # -- Auth helpers -------------------------------------------------------
132
+
133
+ def _token_headers(self) -> dict[str, str]:
134
+ return {"ph-auth-token": self._token or ""}
135
+
136
+ def _basic_auth(self) -> tuple[str, str]:
137
+ return (self._username or "", self._password or "")
138
+
139
+ def _has_basic(self) -> bool:
140
+ return bool(self._username and self._password)
141
+
142
+ def _auth_kwargs(self, *, method: str, path: str) -> dict[str, Any]:
143
+ """Build auth kwargs for a request.
144
+
145
+ DELETE uses Basic (server refuses tokens) except for decided_list.
146
+ Everything else prefers token, falls back to Basic.
147
+ """
148
+ if method == "DELETE":
149
+ endpoint_root = path.lstrip("/").split("/")[0]
150
+ if endpoint_root not in _TOKEN_DELETE_OK:
151
+ if not self._has_basic():
152
+ raise SOARError(
153
+ "delete requires username/password credentials "
154
+ "(SOAR refuses token auth on DELETE)",
155
+ kind="auth",
156
+ )
157
+ return {"auth": self._basic_auth(), "headers": {}}
158
+ # decided_list DELETE: token OK if available, else Basic
159
+ if self._token:
160
+ return {"headers": self._token_headers()}
161
+ if self._has_basic():
162
+ return {"auth": self._basic_auth(), "headers": {}}
163
+ raise SOARError("No credentials configured", kind="auth")
164
+
165
+ # Non-DELETE: prefer token, fall back to Basic
166
+ if self._token:
167
+ return {"headers": self._token_headers()}
168
+ if self._has_basic():
169
+ return {"auth": self._basic_auth(), "headers": {}}
170
+ raise SOARError("No credentials configured", kind="auth")
171
+
172
+ # -- Request helpers ----------------------------------------------------
173
+
174
+ def _request(
175
+ self,
176
+ method: str,
177
+ path: str,
178
+ *,
179
+ params: dict[str, Any] | None = None,
180
+ data: str | None = None,
181
+ ) -> Any:
182
+ """Execute a request and return the parsed, normalized JSON."""
183
+ auth_kw = self._auth_kwargs(method=method, path=path)
184
+ headers: dict[str, str] = auth_kw.get("headers", {})
185
+ if data is not None:
186
+ headers["Content-Type"] = "application/json"
187
+
188
+ kwargs: dict[str, Any] = {
189
+ "method": method,
190
+ "url": self._url(path),
191
+ "headers": headers,
192
+ "params": params,
193
+ }
194
+ if data is not None:
195
+ kwargs["data"] = data
196
+ if "auth" in auth_kw:
197
+ kwargs["auth"] = auth_kw["auth"]
198
+
199
+ resp: requests.Response = self._session.request(**kwargs)
200
+ return self._handle_response(resp, path=path, method=method)
201
+
202
+ def _handle_response(
203
+ self,
204
+ resp: requests.Response,
205
+ *,
206
+ path: str,
207
+ method: str,
208
+ ) -> Any:
209
+ """Parse JSON, check for errors, normalize envelope."""
210
+ try:
211
+ body: Any = resp.json()
212
+ except (ValueError, json.JSONDecodeError):
213
+ if resp.status_code >= 400:
214
+ raise SOARError(
215
+ f"HTTP {resp.status_code}: {resp.text[:200]}",
216
+ kind=kind_for_status(resp.status_code),
217
+ http_status=resp.status_code,
218
+ ) from None
219
+ return {}
220
+
221
+ # Check for failed:true (can appear on any status code)
222
+ if isinstance(body, dict) and body.get("failed"):
223
+ msg = body.get("message", f"HTTP {resp.status_code}")
224
+ if resp.status_code >= 400:
225
+ kind = kind_for_status(resp.status_code)
226
+ http_status: int | None = resp.status_code
227
+ else:
228
+ # Logical failure on HTTP 200 — not an HTTP error
229
+ kind = "error"
230
+ http_status = None
231
+ raise SOARError(
232
+ msg,
233
+ kind=kind,
234
+ http_status=http_status,
235
+ data=body,
236
+ )
237
+
238
+ # HTTP errors without failed:true
239
+ if resp.status_code >= 400:
240
+ msg = (
241
+ body.get("message", f"HTTP {resp.status_code}")
242
+ if isinstance(body, dict)
243
+ else f"HTTP {resp.status_code}"
244
+ )
245
+ raise SOARError(
246
+ msg,
247
+ kind=kind_for_status(resp.status_code),
248
+ http_status=resp.status_code,
249
+ )
250
+
251
+ # Normalize response envelope for GET list endpoints
252
+ if method == "GET":
253
+ body = self._normalize_get_envelope(body, path)
254
+
255
+ # Normalize POST response keys
256
+ if method == "POST" and isinstance(body, dict):
257
+ body = self._normalize_post_keys(body)
258
+
259
+ return body
260
+
261
+ def _normalize_get_envelope(self, body: Any, path: str) -> Any:
262
+ """Normalize GET list responses to {count, num_pages, data}."""
263
+ endpoint_root = path.lstrip("/").split("/")[0]
264
+
265
+ # Bare array (audit)
266
+ if isinstance(body, list):
267
+ return {"count": len(body), "num_pages": 1, "data": body}
268
+
269
+ if not isinstance(body, dict):
270
+ return body
271
+
272
+ # {results} envelope (search)
273
+ if endpoint_root in _RESULTS_ENVELOPE_ENDPOINTS and "results" in body:
274
+ results = body["results"]
275
+ count = body.get("count", len(results))
276
+ return {"count": count, "num_pages": 1, "data": results}
277
+
278
+ return body
279
+
280
+ @staticmethod
281
+ def _normalize_post_keys(body: dict[str, Any]) -> dict[str, Any]:
282
+ """Normalize ``succeeded`` -> ``success`` and ``action_run_id`` -> ``id``."""
283
+ if "succeeded" in body and "success" not in body:
284
+ body["success"] = body.pop("succeeded")
285
+ if "action_run_id" in body and "id" not in body:
286
+ body["id"] = body.pop("action_run_id")
287
+ return body
288
+
289
+ # -- Public API ---------------------------------------------------------
290
+
291
+ def get(
292
+ self,
293
+ path: str,
294
+ *,
295
+ params: dict[str, Any] | None = None,
296
+ ) -> Any:
297
+ """GET a SOAR REST endpoint. Returns normalized envelope."""
298
+ return self._request("GET", path, params=params)
299
+
300
+ def post(
301
+ self,
302
+ path: str,
303
+ *,
304
+ body: Any,
305
+ params: dict[str, Any] | None = None,
306
+ ) -> Any:
307
+ """POST to a SOAR REST endpoint. Accepts dict or list body."""
308
+ data = json.dumps(body)
309
+ return self._request("POST", path, params=params, data=data)
310
+
311
+ def delete(
312
+ self,
313
+ path: str,
314
+ *,
315
+ params: dict[str, Any] | None = None,
316
+ ) -> Any:
317
+ """DELETE a SOAR REST endpoint. Uses Basic auth (except decided_list)."""
318
+ return self._request("DELETE", path, params=params)
319
+
320
+ def get_bytes(
321
+ self,
322
+ path: str,
323
+ *,
324
+ params: dict[str, Any] | None = None,
325
+ ) -> bytes:
326
+ """GET a SOAR REST endpoint and return raw bytes.
327
+
328
+ Used for binary downloads (e.g. ``download_attachment``).
329
+ Raises :class:`SOARError` on non-2xx responses.
330
+ """
331
+ auth_kw = self._auth_kwargs(method="GET", path=path)
332
+ headers: dict[str, str] = auth_kw.get("headers", {})
333
+
334
+ kwargs: dict[str, Any] = {
335
+ "method": "GET",
336
+ "url": self._url(path),
337
+ "headers": headers,
338
+ "params": params,
339
+ }
340
+ if "auth" in auth_kw:
341
+ kwargs["auth"] = auth_kw["auth"]
342
+
343
+ resp: requests.Response = self._session.request(**kwargs)
344
+ if resp.status_code >= 400:
345
+ raise SOARError(
346
+ f"HTTP {resp.status_code}: {resp.text[:200]}",
347
+ kind=kind_for_status(resp.status_code),
348
+ http_status=resp.status_code,
349
+ )
350
+ return resp.content
351
+
352
+ def iter_pages(
353
+ self,
354
+ path: str,
355
+ *,
356
+ page_size: int = 100,
357
+ params: dict[str, Any] | None = None,
358
+ ) -> Iterator[dict[str, Any]]:
359
+ """Iterate all items across paginated results.
360
+
361
+ Yields individual items from each page, transparently fetching
362
+ subsequent pages as needed.
363
+ """
364
+ page = 0
365
+ while True:
366
+ page_params = dict(params or {})
367
+ page_params["page"] = page
368
+ page_params["page_size"] = page_size
369
+
370
+ result = self.get(path, params=page_params)
371
+ data: list[dict[str, Any]] = result.get("data", [])
372
+ yield from data
373
+
374
+ num_pages = result.get("num_pages", 1)
375
+ page += 1
376
+ if page >= num_pages or not data:
377
+ break
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -55,6 +55,8 @@ splunkctl/mcp/__init__.py
55
55
  splunkctl/mcp/resources.py
56
56
  splunkctl/mcp/server.py
57
57
  splunkctl/mcp/tools.py
58
+ splunkctl/soar/__init__.py
59
+ splunkctl/soar/client.py
58
60
  tests/test_client.py
59
61
  tests/test_config.py
60
62
  tests/test_guard.py
@@ -281,3 +281,198 @@ def test_use_profile_does_not_test_connectivity(
281
281
  config.use_profile("uat", p)
282
282
  raw = yaml.safe_load(p.read_text())
283
283
  assert raw["current"] == "uat"
284
+
285
+
286
+ # --- SOAR profile section (L1) ---
287
+
288
+
289
+ def test_soar_defaults() -> None:
290
+ """soar_defaults returns correct default port and verify."""
291
+ d = config.soar_defaults()
292
+ assert d["port"] == 8443
293
+ assert d["verify"] is False
294
+ # No host/token/username/password defaults — those must be explicit.
295
+ assert "host" not in d
296
+ assert "token" not in d
297
+ assert "username" not in d
298
+ assert "password" not in d
299
+
300
+
301
+ def test_resolve_soar_from_profile(tmp_path: Path) -> None:
302
+ """A profile with a soar: section resolves SOAR fields."""
303
+ p = tmp_path / "config.yaml"
304
+ _write_v2(
305
+ p,
306
+ {"lab": {"host": "siem", "soar": {"host": "soar-host", "token": "tok123"}}},
307
+ current="lab",
308
+ )
309
+ resolved = config.resolve_soar(p)
310
+ assert resolved["host"] == "soar-host"
311
+ assert resolved["token"] == "tok123"
312
+ assert resolved["port"] == 8443 # default
313
+
314
+
315
+ def test_resolve_soar_env_overlay(
316
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
317
+ ) -> None:
318
+ """SOAR_* env vars override profile soar: values."""
319
+ p = tmp_path / "config.yaml"
320
+ _write_v2(
321
+ p,
322
+ {"lab": {"host": "siem", "soar": {"host": "profile-soar"}}},
323
+ current="lab",
324
+ )
325
+ monkeypatch.setenv("SOAR_HOST", "env-soar")
326
+ monkeypatch.setenv("SOAR_PORT", "9443")
327
+ monkeypatch.setenv("SOAR_TOKEN", "env-token")
328
+ monkeypatch.setenv("SOAR_VERIFY", "true")
329
+ resolved = config.resolve_soar(p)
330
+ assert resolved["host"] == "env-soar"
331
+ assert resolved["port"] == 9443
332
+ assert resolved["token"] == "env-token"
333
+ assert resolved["verify"] is True
334
+
335
+
336
+ def test_resolve_soar_env_user_and_pass(
337
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
338
+ ) -> None:
339
+ """SOAR_USER and SOAR_PASS map to username and password."""
340
+ p = tmp_path / "config.yaml"
341
+ _write_v2(
342
+ p,
343
+ {"lab": {"host": "siem", "soar": {"host": "soar-host"}}},
344
+ current="lab",
345
+ )
346
+ monkeypatch.setenv("SOAR_USER", "soar-admin")
347
+ monkeypatch.setenv("SOAR_PASS", "s3cret")
348
+ resolved = config.resolve_soar(p)
349
+ assert resolved["username"] == "soar-admin"
350
+ assert resolved["password"] == "s3cret"
351
+
352
+
353
+ def test_resolve_soar_flags_beat_env(
354
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
355
+ ) -> None:
356
+ """Explicit overrides (flags) beat env vars."""
357
+ p = tmp_path / "config.yaml"
358
+ _write_v2(
359
+ p,
360
+ {"lab": {"host": "siem", "soar": {"host": "profile-soar"}}},
361
+ current="lab",
362
+ )
363
+ monkeypatch.setenv("SOAR_HOST", "env-soar")
364
+ resolved = config.resolve_soar(p, overrides={"host": "flag-soar"})
365
+ assert resolved["host"] == "flag-soar"
366
+
367
+
368
+ def test_resolve_soar_no_soar_section(tmp_path: Path) -> None:
369
+ """A profile without soar: returns only defaults (port, verify)."""
370
+ p = tmp_path / "config.yaml"
371
+ _write_v2(p, {"lab": {"host": "siem"}}, current="lab")
372
+ resolved = config.resolve_soar(p)
373
+ assert resolved["port"] == 8443
374
+ assert resolved["verify"] is False
375
+ assert "host" not in resolved
376
+
377
+
378
+ def test_resolve_soar_respects_profile_flag(tmp_path: Path) -> None:
379
+ """--profile selects which profile's soar: to read."""
380
+ p = tmp_path / "config.yaml"
381
+ _write_v2(
382
+ p,
383
+ {
384
+ "dev": {"host": "dev-siem", "soar": {"host": "dev-soar"}},
385
+ "prod": {"host": "prod-siem", "soar": {"host": "prod-soar"}},
386
+ },
387
+ current="dev",
388
+ )
389
+ resolved = config.resolve_soar(p, profile="prod")
390
+ assert resolved["host"] == "prod-soar"
391
+
392
+
393
+ def test_resolve_soar_invalid_port_env_ignored(
394
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
395
+ ) -> None:
396
+ """Non-numeric SOAR_PORT is silently ignored."""
397
+ p = tmp_path / "config.yaml"
398
+ _write_v2(
399
+ p,
400
+ {"lab": {"host": "siem", "soar": {"host": "soar-host", "port": 9443}}},
401
+ current="lab",
402
+ )
403
+ monkeypatch.setenv("SOAR_PORT", "not-a-number")
404
+ resolved = config.resolve_soar(p)
405
+ assert resolved["port"] == 9443
406
+
407
+
408
+ def test_redact_soar_masks_secrets() -> None:
409
+ """redact_soar masks token and password, keeps others."""
410
+ soar_cfg: dict[str, Any] = {
411
+ "host": "soar-host",
412
+ "port": 8443,
413
+ "token": "tok123",
414
+ "username": "admin",
415
+ "password": "s3cret",
416
+ "verify": False,
417
+ }
418
+ r = config.redact_soar(soar_cfg)
419
+ assert r["host"] == "soar-host"
420
+ assert r["token"] == "****"
421
+ assert r["password"] == "****"
422
+ assert r["username"] == "admin"
423
+ assert r["port"] == 8443
424
+
425
+
426
+ def test_redact_soar_keeps_empty_secrets() -> None:
427
+ """Empty token/password stays empty, not redacted."""
428
+ soar_cfg: dict[str, Any] = {"token": "", "password": ""}
429
+ r = config.redact_soar(soar_cfg)
430
+ assert r["token"] == ""
431
+ assert r["password"] == ""
432
+
433
+
434
+ def test_save_profile_with_soar_roundtrips(tmp_path: Path) -> None:
435
+ """A profile with soar: nested map saves and reloads correctly."""
436
+ p = tmp_path / "config.yaml"
437
+ profile_cfg: dict[str, Any] = {
438
+ "host": "siem-host",
439
+ "port": 8089,
440
+ "soar": {"host": "soar-host", "port": 8443, "token": "tok"},
441
+ }
442
+ config.save_profile(profile_cfg, "lab", p)
443
+ raw = yaml.safe_load(p.read_text())
444
+ assert raw["profiles"]["lab"]["soar"]["host"] == "soar-host"
445
+ assert raw["profiles"]["lab"]["soar"]["token"] == "tok"
446
+
447
+
448
+ def test_resolve_soar_verify_false_string_env(
449
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
450
+ ) -> None:
451
+ """SOAR_VERIFY=false resolves as boolean False."""
452
+ p = tmp_path / "config.yaml"
453
+ _write_v2(p, {"lab": {"host": "siem", "soar": {"host": "s"}}}, current="lab")
454
+ monkeypatch.setenv("SOAR_VERIFY", "false")
455
+ resolved = config.resolve_soar(p)
456
+ assert resolved["verify"] is False
457
+
458
+
459
+ def test_siem_profile_unaffected_by_soar_section(tmp_path: Path) -> None:
460
+ """Adding soar: to a profile does not pollute SIEM resolve."""
461
+ p = tmp_path / "config.yaml"
462
+ _write_v2(
463
+ p,
464
+ {
465
+ "lab": {
466
+ "host": "siem-host",
467
+ "port": 8089,
468
+ "soar": {"host": "soar-host", "token": "tok"},
469
+ }
470
+ },
471
+ current="lab",
472
+ )
473
+ siem = config.resolve(p)
474
+ assert siem["cfg"]["host"] == "siem-host"
475
+ # SIEM resolve must strip the nested soar: map entirely.
476
+ assert "soar" not in siem["cfg"]
477
+ # And SOAR credentials must not leak into the SIEM top-level config.
478
+ assert "token" not in siem["cfg"]
@@ -4,4 +4,4 @@ from splunkctl import __version__
4
4
 
5
5
 
6
6
  def test_version() -> None:
7
- assert __version__ == "0.6.0"
7
+ assert __version__ == "0.7.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes