splunkctl 0.6.0__tar.gz → 0.8.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.6.0 → splunkctl-0.8.0}/PKG-INFO +1 -1
- {splunkctl-0.6.0 → splunkctl-0.8.0}/pyproject.toml +1 -1
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/__init__.py +1 -1
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/config_cmd.py +88 -14
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/config.py +90 -3
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/errors.py +6 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/guard.py +30 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/main.py +2 -0
- splunkctl-0.8.0/splunkctl/soar/__init__.py +0 -0
- splunkctl-0.8.0/splunkctl/soar/client.py +385 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl.egg-info/PKG-INFO +1 -1
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl.egg-info/SOURCES.txt +2 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/tests/test_config.py +195 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/tests/test_version.py +1 -1
- {splunkctl-0.6.0 → splunkctl-0.8.0}/LICENSE +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/README.md +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/setup.cfg +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/client.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/audit.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/audit_parse.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/common.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/conf.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/conf_ops.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/dashboards.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/datamodels.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/es.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/hec.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/knowledge.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/kvstore.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/lookups.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/lookups_wiring.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/mcp_cmd.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/rules.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/rules_io.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/search.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/server.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/server_topology.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/state.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/state_io.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/state_io_blobs.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/state_io_confs.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/state_types.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/commands/users.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/mcp/__init__.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/mcp/resources.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/mcp/server.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/mcp/tools.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl/output.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/tests/test_client.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/tests/test_guard.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/tests/test_main_errors.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/tests/test_main_hoisting.py +0 -0
- {splunkctl-0.6.0 → splunkctl-0.8.0}/tests/test_output.py +0 -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__" }
|
|
@@ -17,17 +17,24 @@ def config_group() -> None:
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
@config_group.command()
|
|
20
|
-
@click.option("--host",
|
|
21
|
-
@click.option("--port",
|
|
22
|
-
@click.option("--username",
|
|
23
|
-
@click.option("--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=
|
|
27
|
+
default=None,
|
|
28
28
|
help="Connection scheme.",
|
|
29
29
|
)
|
|
30
|
-
@click.option("--verify/--no-verify", default=
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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,385 @@
|
|
|
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 response keys to canonical forms.
|
|
283
|
+
|
|
284
|
+
Mappings:
|
|
285
|
+
``succeeded`` -> ``success``
|
|
286
|
+
``action_run_id`` -> ``id``
|
|
287
|
+
``playbook_run_id`` -> ``id``
|
|
288
|
+
"""
|
|
289
|
+
if "succeeded" in body and "success" not in body:
|
|
290
|
+
body["success"] = body.pop("succeeded")
|
|
291
|
+
if "action_run_id" in body and "id" not in body:
|
|
292
|
+
body["id"] = body.pop("action_run_id")
|
|
293
|
+
if "playbook_run_id" in body and "id" not in body:
|
|
294
|
+
body["id"] = body.pop("playbook_run_id")
|
|
295
|
+
return body
|
|
296
|
+
|
|
297
|
+
# -- Public API ---------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def get(
|
|
300
|
+
self,
|
|
301
|
+
path: str,
|
|
302
|
+
*,
|
|
303
|
+
params: dict[str, Any] | None = None,
|
|
304
|
+
) -> Any:
|
|
305
|
+
"""GET a SOAR REST endpoint. Returns normalized envelope."""
|
|
306
|
+
return self._request("GET", path, params=params)
|
|
307
|
+
|
|
308
|
+
def post(
|
|
309
|
+
self,
|
|
310
|
+
path: str,
|
|
311
|
+
*,
|
|
312
|
+
body: Any,
|
|
313
|
+
params: dict[str, Any] | None = None,
|
|
314
|
+
) -> Any:
|
|
315
|
+
"""POST to a SOAR REST endpoint. Accepts dict or list body."""
|
|
316
|
+
data = json.dumps(body)
|
|
317
|
+
return self._request("POST", path, params=params, data=data)
|
|
318
|
+
|
|
319
|
+
def delete(
|
|
320
|
+
self,
|
|
321
|
+
path: str,
|
|
322
|
+
*,
|
|
323
|
+
params: dict[str, Any] | None = None,
|
|
324
|
+
) -> Any:
|
|
325
|
+
"""DELETE a SOAR REST endpoint. Uses Basic auth (except decided_list)."""
|
|
326
|
+
return self._request("DELETE", path, params=params)
|
|
327
|
+
|
|
328
|
+
def get_bytes(
|
|
329
|
+
self,
|
|
330
|
+
path: str,
|
|
331
|
+
*,
|
|
332
|
+
params: dict[str, Any] | None = None,
|
|
333
|
+
) -> bytes:
|
|
334
|
+
"""GET a SOAR REST endpoint and return raw bytes.
|
|
335
|
+
|
|
336
|
+
Used for binary downloads (e.g. ``download_attachment``).
|
|
337
|
+
Raises :class:`SOARError` on non-2xx responses.
|
|
338
|
+
"""
|
|
339
|
+
auth_kw = self._auth_kwargs(method="GET", path=path)
|
|
340
|
+
headers: dict[str, str] = auth_kw.get("headers", {})
|
|
341
|
+
|
|
342
|
+
kwargs: dict[str, Any] = {
|
|
343
|
+
"method": "GET",
|
|
344
|
+
"url": self._url(path),
|
|
345
|
+
"headers": headers,
|
|
346
|
+
"params": params,
|
|
347
|
+
}
|
|
348
|
+
if "auth" in auth_kw:
|
|
349
|
+
kwargs["auth"] = auth_kw["auth"]
|
|
350
|
+
|
|
351
|
+
resp: requests.Response = self._session.request(**kwargs)
|
|
352
|
+
if resp.status_code >= 400:
|
|
353
|
+
raise SOARError(
|
|
354
|
+
f"HTTP {resp.status_code}: {resp.text[:200]}",
|
|
355
|
+
kind=kind_for_status(resp.status_code),
|
|
356
|
+
http_status=resp.status_code,
|
|
357
|
+
)
|
|
358
|
+
return resp.content
|
|
359
|
+
|
|
360
|
+
def iter_pages(
|
|
361
|
+
self,
|
|
362
|
+
path: str,
|
|
363
|
+
*,
|
|
364
|
+
page_size: int = 100,
|
|
365
|
+
params: dict[str, Any] | None = None,
|
|
366
|
+
) -> Iterator[dict[str, Any]]:
|
|
367
|
+
"""Iterate all items across paginated results.
|
|
368
|
+
|
|
369
|
+
Yields individual items from each page, transparently fetching
|
|
370
|
+
subsequent pages as needed.
|
|
371
|
+
"""
|
|
372
|
+
page = 0
|
|
373
|
+
while True:
|
|
374
|
+
page_params = dict(params or {})
|
|
375
|
+
page_params["page"] = page
|
|
376
|
+
page_params["page_size"] = page_size
|
|
377
|
+
|
|
378
|
+
result = self.get(path, params=page_params)
|
|
379
|
+
data: list[dict[str, Any]] = result.get("data", [])
|
|
380
|
+
yield from data
|
|
381
|
+
|
|
382
|
+
num_pages = result.get("num_pages", 1)
|
|
383
|
+
page += 1
|
|
384
|
+
if page >= num_pages or not data:
|
|
385
|
+
break
|
|
@@ -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"]
|
|
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
|