splunkctl 0.9.0__tar.gz → 0.10.1__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 (95) hide show
  1. {splunkctl-0.9.0 → splunkctl-0.10.1}/PKG-INFO +6 -4
  2. {splunkctl-0.9.0 → splunkctl-0.10.1}/README.md +5 -3
  3. {splunkctl-0.9.0 → splunkctl-0.10.1}/pyproject.toml +4 -2
  4. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/__init__.py +1 -1
  5. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/client.py +5 -0
  6. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/es.py +1 -1
  7. splunkctl-0.10.1/splunkctl/commands/soar/__init__.py +65 -0
  8. splunkctl-0.10.1/splunkctl/commands/soar/_client.py +44 -0
  9. splunkctl-0.10.1/splunkctl/commands/soar/_ingest_helpers.py +260 -0
  10. splunkctl-0.10.1/splunkctl/commands/soar/actions.py +416 -0
  11. splunkctl-0.10.1/splunkctl/commands/soar/admin.py +492 -0
  12. splunkctl-0.10.1/splunkctl/commands/soar/admin_views.py +195 -0
  13. splunkctl-0.10.1/splunkctl/commands/soar/approvals.py +152 -0
  14. splunkctl-0.10.1/splunkctl/commands/soar/apps.py +103 -0
  15. splunkctl-0.10.1/splunkctl/commands/soar/artifacts.py +419 -0
  16. splunkctl-0.10.1/splunkctl/commands/soar/assets.py +475 -0
  17. splunkctl-0.10.1/splunkctl/commands/soar/cases.py +378 -0
  18. splunkctl-0.10.1/splunkctl/commands/soar/containers.py +275 -0
  19. splunkctl-0.10.1/splunkctl/commands/soar/containers_assign.py +166 -0
  20. splunkctl-0.10.1/splunkctl/commands/soar/containers_write.py +397 -0
  21. splunkctl-0.10.1/splunkctl/commands/soar/functions.py +291 -0
  22. splunkctl-0.10.1/splunkctl/commands/soar/indicators.py +303 -0
  23. splunkctl-0.10.1/splunkctl/commands/soar/ingest.py +386 -0
  24. splunkctl-0.10.1/splunkctl/commands/soar/lists.py +478 -0
  25. splunkctl-0.10.1/splunkctl/commands/soar/notes.py +229 -0
  26. splunkctl-0.10.1/splunkctl/commands/soar/playbook_runs.py +354 -0
  27. splunkctl-0.10.1/splunkctl/commands/soar/playbooks.py +478 -0
  28. splunkctl-0.10.1/splunkctl/commands/soar/playbooks_delete.py +79 -0
  29. splunkctl-0.10.1/splunkctl/commands/soar/search.py +71 -0
  30. splunkctl-0.10.1/splunkctl/commands/soar/system.py +192 -0
  31. splunkctl-0.10.1/splunkctl/commands/soar/vault.py +206 -0
  32. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/mcp/server.py +49 -8
  33. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/mcp/tools.py +57 -25
  34. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/soar/client.py +20 -8
  35. splunkctl-0.10.1/splunkctl/soar/web.py +137 -0
  36. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/PKG-INFO +6 -4
  37. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/SOURCES.txt +26 -0
  38. {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_guard.py +33 -0
  39. {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_version.py +1 -1
  40. {splunkctl-0.9.0 → splunkctl-0.10.1}/LICENSE +0 -0
  41. {splunkctl-0.9.0 → splunkctl-0.10.1}/setup.cfg +0 -0
  42. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/__main__.py +0 -0
  43. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/__init__.py +0 -0
  44. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/alerts.py +0 -0
  45. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/apps.py +0 -0
  46. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/audit.py +0 -0
  47. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/audit_parse.py +0 -0
  48. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/commands_meta.py +0 -0
  49. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/common.py +0 -0
  50. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/conf.py +0 -0
  51. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/conf_ops.py +0 -0
  52. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/config_cmd.py +0 -0
  53. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/dashboards.py +0 -0
  54. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/datamodels.py +0 -0
  55. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/doctor.py +0 -0
  56. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/hec.py +0 -0
  57. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/indexes.py +0 -0
  58. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/info.py +0 -0
  59. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/inputs.py +0 -0
  60. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/knowledge.py +0 -0
  61. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/kvstore.py +0 -0
  62. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/lookups.py +0 -0
  63. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/lookups_wiring.py +0 -0
  64. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/mcp_cmd.py +0 -0
  65. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/parsers.py +0 -0
  66. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/parsers_io.py +0 -0
  67. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/rules.py +0 -0
  68. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/rules_io.py +0 -0
  69. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/search.py +0 -0
  70. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/server.py +0 -0
  71. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/server_topology.py +0 -0
  72. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state.py +0 -0
  73. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state_io.py +0 -0
  74. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state_io_blobs.py +0 -0
  75. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state_io_confs.py +0 -0
  76. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state_types.py +0 -0
  77. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/users.py +0 -0
  78. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/config.py +0 -0
  79. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/errors.py +0 -0
  80. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/guard.py +0 -0
  81. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/main.py +0 -0
  82. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/mcp/__init__.py +0 -0
  83. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/mcp/resources.py +0 -0
  84. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/output.py +0 -0
  85. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/soar/__init__.py +0 -0
  86. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/soar/cimcef.py +0 -0
  87. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/dependency_links.txt +0 -0
  88. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/entry_points.txt +0 -0
  89. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/requires.txt +0 -0
  90. {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/top_level.txt +0 -0
  91. {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_client.py +0 -0
  92. {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_config.py +0 -0
  93. {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_main_errors.py +0 -0
  94. {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_main_hoisting.py +0 -0
  95. {splunkctl-0.9.0 → splunkctl-0.10.1}/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.1
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
@@ -51,8 +51,10 @@ Homepage = "https://github.com/dannyota/splunkctl"
51
51
  Repository = "https://github.com/dannyota/splunkctl"
52
52
  Issues = "https://github.com/dannyota/splunkctl/issues"
53
53
 
54
- [tool.setuptools]
55
- packages = ["splunkctl", "splunkctl.commands", "splunkctl.mcp", "splunkctl.soar"]
54
+ # Auto-discover subpackages — a hardcoded list silently dropped
55
+ # splunkctl.commands.soar from every wheel (v0.7.0–v0.10.0).
56
+ [tool.setuptools.packages.find]
57
+ include = ["splunkctl*"]
56
58
 
57
59
  [tool.setuptools.dynamic]
58
60
  version = { attr = "splunkctl.__version__" }
@@ -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.1"
@@ -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)")
@@ -0,0 +1,65 @@
1
+ """SOAR command group — ``splunkctl soar <subcommand>``."""
2
+
3
+ import click
4
+
5
+ from splunkctl.commands.soar import (
6
+ containers_write as _cw, # noqa: F401 # register writes
7
+ )
8
+ from splunkctl.commands.soar import (
9
+ playbook_runs as _pr, # noqa: F401 # register run/runs
10
+ )
11
+ from splunkctl.commands.soar import (
12
+ playbooks_delete as _pd, # noqa: F401 # register delete
13
+ )
14
+ from splunkctl.commands.soar.actions import actions_group
15
+ from splunkctl.commands.soar.admin import audit_cmd, roles_group, users_group
16
+ from splunkctl.commands.soar.admin_views import meta, settings, stats
17
+ from splunkctl.commands.soar.approvals import approvals_group
18
+ from splunkctl.commands.soar.apps import apps_group
19
+ from splunkctl.commands.soar.artifacts import artifacts_group
20
+ from splunkctl.commands.soar.assets import assets_group, ingest_status_cmd
21
+ from splunkctl.commands.soar.cases import cases_group
22
+ from splunkctl.commands.soar.containers import containers_group
23
+ from splunkctl.commands.soar.functions import functions_group
24
+ from splunkctl.commands.soar.indicators import evidence_group, indicators_group
25
+ from splunkctl.commands.soar.ingest import ingest_cmd
26
+ from splunkctl.commands.soar.lists import lists_group
27
+ from splunkctl.commands.soar.notes import notes_group
28
+ from splunkctl.commands.soar.playbooks import playbooks_group
29
+ from splunkctl.commands.soar.search import search
30
+ from splunkctl.commands.soar.system import health, info, license_cmd, test
31
+ from splunkctl.commands.soar.vault import vault_group
32
+
33
+
34
+ @click.group("soar")
35
+ def soar_group() -> None:
36
+ """Splunk SOAR operations — platform reads, containers, playbooks."""
37
+
38
+
39
+ soar_group.add_command(test)
40
+ soar_group.add_command(info)
41
+ soar_group.add_command(health)
42
+ soar_group.add_command(license_cmd)
43
+ soar_group.add_command(settings)
44
+ soar_group.add_command(stats)
45
+ soar_group.add_command(meta)
46
+ soar_group.add_command(containers_group)
47
+ soar_group.add_command(cases_group)
48
+ soar_group.add_command(notes_group)
49
+ soar_group.add_command(vault_group)
50
+ soar_group.add_command(artifacts_group)
51
+ soar_group.add_command(apps_group)
52
+ soar_group.add_command(assets_group)
53
+ soar_group.add_command(ingest_status_cmd)
54
+ soar_group.add_command(playbooks_group)
55
+ soar_group.add_command(actions_group)
56
+ soar_group.add_command(functions_group)
57
+ soar_group.add_command(approvals_group)
58
+ soar_group.add_command(lists_group)
59
+ soar_group.add_command(indicators_group)
60
+ soar_group.add_command(evidence_group)
61
+ soar_group.add_command(users_group)
62
+ soar_group.add_command(roles_group)
63
+ soar_group.add_command(audit_cmd)
64
+ soar_group.add_command(search)
65
+ soar_group.add_command(ingest_cmd)
@@ -0,0 +1,44 @@
1
+ """Shared SOAR client construction for command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ from splunkctl import config as cfg_mod
11
+ from splunkctl import output
12
+ from splunkctl.soar.client import SOARClient
13
+
14
+
15
+ def get_soar_client(ctx: click.Context) -> SOARClient:
16
+ """Build a SOARClient from the resolved SOAR config.
17
+
18
+ Raises ``SystemExit(1)`` with a ``usage`` error envelope when no
19
+ SOAR host is configured.
20
+ """
21
+ obj: dict[str, Any] = ctx.obj or {}
22
+ cfg_path = obj.get("config")
23
+ profile = obj.get("profile")
24
+ config_path = Path(cfg_path) if cfg_path else None
25
+ cfg = cfg_mod.resolve_soar(config_path, profile=profile)
26
+
27
+ host = cfg.get("host")
28
+ if not host:
29
+ output.error(
30
+ "No SOAR host configured. Run 'splunkctl config init --soar' "
31
+ "or set SOAR_HOST.",
32
+ kind="usage",
33
+ )
34
+ ctx.exit(1)
35
+ raise SystemExit(1) # unreachable after ctx.exit in real Click
36
+
37
+ return SOARClient(
38
+ host=host,
39
+ port=int(cfg.get("port", 8443)),
40
+ token=cfg.get("token"),
41
+ username=cfg.get("username"),
42
+ password=cfg.get("password"),
43
+ verify=bool(cfg.get("verify", False)),
44
+ )
@@ -0,0 +1,260 @@
1
+ """Ingest helpers — SIEM search, map-file loading, dedup checks, preview."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+ import yaml
12
+ from splunklib.results import JSONResultsReader
13
+
14
+ from splunkctl import output
15
+ from splunkctl.client import get_client
16
+ from splunkctl.soar.cimcef import row_to_cef
17
+ from splunkctl.soar.client import SOARError
18
+
19
+ _GENERATING = (
20
+ "abstract",
21
+ "bucket",
22
+ "datamodel",
23
+ "dbinspect",
24
+ "eventcount",
25
+ "inputcsv",
26
+ "inputlookup",
27
+ "loadjob",
28
+ "makeresults",
29
+ "mcollect",
30
+ "metadata",
31
+ "metasearch",
32
+ "mstats",
33
+ "pivot",
34
+ "rest",
35
+ "savedsearch",
36
+ "tstats",
37
+ "typeahead",
38
+ )
39
+
40
+
41
+ def normalize_spl(spl: str) -> str:
42
+ """Auto-prepend ``search`` when the query needs it."""
43
+ stripped = spl.strip()
44
+ if stripped.startswith("|"):
45
+ return stripped
46
+ first_word = stripped.split()[0].lower() if stripped else ""
47
+ if first_word == "search" or first_word in _GENERATING:
48
+ return stripped
49
+ return f"search {stripped}"
50
+
51
+
52
+ def read_results(stream: Any) -> list[dict[str, Any]]:
53
+ """Parse a Splunk results stream into a list of dicts."""
54
+ reader: Any = JSONResultsReader(stream)
55
+ rows: list[dict[str, Any]] = []
56
+ for item in reader:
57
+ if isinstance(item, dict):
58
+ rows.append(item)
59
+ return rows
60
+
61
+
62
+ def fetch_spl_results(
63
+ ctx: click.Context,
64
+ spl: str,
65
+ *,
66
+ earliest: str | None,
67
+ latest: str | None,
68
+ app: str | None,
69
+ ) -> list[dict[str, Any]]:
70
+ """Run an SPL search on SIEM and return result rows."""
71
+ client = get_client(ctx)
72
+ svc = client.service
73
+ if app:
74
+ svc.namespace["app"] = app
75
+ query = normalize_spl(spl)
76
+
77
+ kwargs: dict[str, Any] = {"exec_mode": "normal"}
78
+ if earliest:
79
+ kwargs["earliest_time"] = earliest
80
+ if latest:
81
+ kwargs["latest_time"] = latest
82
+
83
+ output.info(f"Running SIEM search: {query}")
84
+ job: Any = svc.jobs.create(query, **kwargs)
85
+
86
+ timeout: int = ctx.obj.get("timeout", 30)
87
+ deadline = time.monotonic() + timeout
88
+ while not job.is_done():
89
+ if time.monotonic() > deadline:
90
+ job.cancel()
91
+ output.error(f"SIEM search timed out after {timeout}s.", kind="timeout")
92
+ ctx.exit(1)
93
+ raise SystemExit(1)
94
+ time.sleep(0.5)
95
+ job.refresh()
96
+
97
+ return read_results(job.results(output_mode="json", count=0))
98
+
99
+
100
+ def fetch_sid_results(
101
+ ctx: click.Context,
102
+ sid: str,
103
+ ) -> list[dict[str, Any]]:
104
+ """Fetch results from an existing SIEM search job."""
105
+ client = get_client(ctx)
106
+ svc = client.service
107
+ try:
108
+ job: Any = svc.jobs[sid]
109
+ except KeyError:
110
+ output.error(f"Job '{sid}' not found on SIEM.", kind="not_found")
111
+ ctx.exit(1)
112
+ raise SystemExit(1) from None
113
+
114
+ job.refresh()
115
+ if not job.is_done():
116
+ output.error(f"Job '{sid}' is still running.", kind="error")
117
+ ctx.exit(1)
118
+ raise SystemExit(1)
119
+
120
+ return read_results(job.results(output_mode="json", count=0))
121
+
122
+
123
+ def load_map_file(
124
+ path: str,
125
+ ) -> tuple[dict[str, str], dict[str, list[str]], bool]:
126
+ """Load a YAML map file and return (cim_map, contains_map, include_unmapped).
127
+
128
+ Format::
129
+
130
+ mappings:
131
+ src: {cef: sourceAddress, contains: [ip]}
132
+ custom_score: {cef: riskScore}
133
+ unmapped: drop # or "pass"
134
+ """
135
+ data = yaml.safe_load(Path(path).read_text())
136
+ mappings: dict[str, Any] = data.get("mappings", {})
137
+ cim_map: dict[str, str] = {}
138
+ contains_map: dict[str, list[str]] = {}
139
+ for cim_field, spec in mappings.items():
140
+ if isinstance(spec, dict):
141
+ cef_key = spec.get("cef", cim_field)
142
+ cim_map[cim_field] = cef_key
143
+ contains = spec.get("contains")
144
+ if isinstance(contains, list) and contains:
145
+ contains_map[cef_key] = [str(c) for c in contains]
146
+ else:
147
+ cim_map[cim_field] = str(spec)
148
+ unmapped = data.get("unmapped", "drop")
149
+ return cim_map, contains_map, unmapped == "pass"
150
+
151
+
152
+ def validate_label(soar: Any, label: str) -> bool:
153
+ """Validate that a label exists on the SOAR instance."""
154
+ try:
155
+ result = soar.get("container_options", params={})
156
+ labels: list[str] = []
157
+ if isinstance(result, dict):
158
+ for item in result.get("label", []):
159
+ if isinstance(item, str):
160
+ labels.append(item)
161
+ elif isinstance(item, dict):
162
+ labels.append(str(item.get("name", item.get("label", ""))))
163
+ if labels and label not in labels:
164
+ output.warning(
165
+ f"Label '{label}' not found on SOAR "
166
+ f"(available: {', '.join(labels)}). "
167
+ f"Container creation may fail."
168
+ )
169
+ return False
170
+ except SOARError:
171
+ pass
172
+ return True
173
+
174
+
175
+ def dedup_check_container(soar: Any, sdi: str) -> int | None:
176
+ """Check if a container with this SDI already exists."""
177
+ try:
178
+ result = soar.get(
179
+ "container",
180
+ params={"_filter_source_data_identifier": json.dumps(sdi)},
181
+ )
182
+ data = result.get("data", []) if isinstance(result, dict) else []
183
+ if data:
184
+ return int(data[0].get("id", 0))
185
+ except SOARError:
186
+ pass
187
+ return None
188
+
189
+
190
+ def dedup_check_artifact(soar: Any, sdi: str, container_id: int) -> int | None:
191
+ """Check if an artifact with this SDI already exists."""
192
+ try:
193
+ result = soar.get(
194
+ "artifact",
195
+ params={
196
+ "_filter_source_data_identifier": json.dumps(sdi),
197
+ "_filter_container": container_id,
198
+ },
199
+ )
200
+ data = result.get("data", []) if isinstance(result, dict) else []
201
+ if data:
202
+ return int(data[0].get("id", 0))
203
+ except SOARError:
204
+ pass
205
+ return None
206
+
207
+
208
+ class ContainerGroup:
209
+ """Accumulator for rows destined for one container."""
210
+
211
+ __slots__ = ("name", "rows", "severity", "sdi")
212
+
213
+ def __init__(self, name: str, severity: str, sdi: str) -> None:
214
+ self.name = name
215
+ self.rows: list[dict[str, Any]] = []
216
+ self.severity = severity
217
+ self.sdi = sdi
218
+
219
+
220
+ def build_preview(
221
+ groups: dict[str, ContainerGroup],
222
+ cim_map: dict[str, str],
223
+ *,
224
+ include_unmapped: bool = False,
225
+ ) -> str:
226
+ """Build the dry-run preview string."""
227
+ lines: list[str] = []
228
+ lines.append(f"Containers: {len(groups)}")
229
+ for grp in groups.values():
230
+ # The dict key is name::sdi (an internal dedup key) — render the
231
+ # name and SDI separately; SDIs often contain colons themselves.
232
+ sdi_note = f" (sdi: {grp.sdi})" if grp.sdi else ""
233
+ lines.append(f" {grp.name}{sdi_note}: {len(grp.rows)} artifact(s)")
234
+
235
+ lines.append("")
236
+ lines.append("CIM -> CEF mapping (active):")
237
+ shown = 0
238
+ for cim, cef in sorted(cim_map.items()):
239
+ lines.append(f" {cim} -> {cef}")
240
+ shown += 1
241
+ if shown >= 10:
242
+ remaining = len(cim_map) - shown
243
+ if remaining > 0:
244
+ lines.append(f" ... and {remaining} more")
245
+ break
246
+
247
+ if groups:
248
+ first_grp = next(iter(groups.values()))
249
+ if first_grp.rows:
250
+ sample_cef = row_to_cef(
251
+ first_grp.rows[0],
252
+ cim_map=cim_map,
253
+ include_unmapped=include_unmapped,
254
+ )
255
+ if sample_cef:
256
+ lines.append("")
257
+ lines.append("Sample CEF payload:")
258
+ lines.append(json.dumps(sample_cef, indent=2, default=str))
259
+
260
+ return "\n".join(lines)