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.
- {splunkctl-0.9.0 → splunkctl-0.10.1}/PKG-INFO +6 -4
- {splunkctl-0.9.0 → splunkctl-0.10.1}/README.md +5 -3
- {splunkctl-0.9.0 → splunkctl-0.10.1}/pyproject.toml +4 -2
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/__init__.py +1 -1
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/client.py +5 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/es.py +1 -1
- splunkctl-0.10.1/splunkctl/commands/soar/__init__.py +65 -0
- splunkctl-0.10.1/splunkctl/commands/soar/_client.py +44 -0
- splunkctl-0.10.1/splunkctl/commands/soar/_ingest_helpers.py +260 -0
- splunkctl-0.10.1/splunkctl/commands/soar/actions.py +416 -0
- splunkctl-0.10.1/splunkctl/commands/soar/admin.py +492 -0
- splunkctl-0.10.1/splunkctl/commands/soar/admin_views.py +195 -0
- splunkctl-0.10.1/splunkctl/commands/soar/approvals.py +152 -0
- splunkctl-0.10.1/splunkctl/commands/soar/apps.py +103 -0
- splunkctl-0.10.1/splunkctl/commands/soar/artifacts.py +419 -0
- splunkctl-0.10.1/splunkctl/commands/soar/assets.py +475 -0
- splunkctl-0.10.1/splunkctl/commands/soar/cases.py +378 -0
- splunkctl-0.10.1/splunkctl/commands/soar/containers.py +275 -0
- splunkctl-0.10.1/splunkctl/commands/soar/containers_assign.py +166 -0
- splunkctl-0.10.1/splunkctl/commands/soar/containers_write.py +397 -0
- splunkctl-0.10.1/splunkctl/commands/soar/functions.py +291 -0
- splunkctl-0.10.1/splunkctl/commands/soar/indicators.py +303 -0
- splunkctl-0.10.1/splunkctl/commands/soar/ingest.py +386 -0
- splunkctl-0.10.1/splunkctl/commands/soar/lists.py +478 -0
- splunkctl-0.10.1/splunkctl/commands/soar/notes.py +229 -0
- splunkctl-0.10.1/splunkctl/commands/soar/playbook_runs.py +354 -0
- splunkctl-0.10.1/splunkctl/commands/soar/playbooks.py +478 -0
- splunkctl-0.10.1/splunkctl/commands/soar/playbooks_delete.py +79 -0
- splunkctl-0.10.1/splunkctl/commands/soar/search.py +71 -0
- splunkctl-0.10.1/splunkctl/commands/soar/system.py +192 -0
- splunkctl-0.10.1/splunkctl/commands/soar/vault.py +206 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/mcp/server.py +49 -8
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/mcp/tools.py +57 -25
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/soar/client.py +20 -8
- splunkctl-0.10.1/splunkctl/soar/web.py +137 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/PKG-INFO +6 -4
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/SOURCES.txt +26 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_guard.py +33 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_version.py +1 -1
- {splunkctl-0.9.0 → splunkctl-0.10.1}/LICENSE +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/setup.cfg +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/__main__.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/audit.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/audit_parse.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/common.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/conf.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/conf_ops.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/config_cmd.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/dashboards.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/datamodels.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/hec.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/knowledge.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/kvstore.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/lookups.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/lookups_wiring.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/mcp_cmd.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/rules.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/rules_io.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/search.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/server.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/server_topology.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state_io.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state_io_blobs.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state_io_confs.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/state_types.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/commands/users.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/config.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/errors.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/guard.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/main.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/mcp/__init__.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/mcp/resources.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/output.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/soar/__init__.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl/soar/cimcef.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_client.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_config.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_main_errors.py +0 -0
- {splunkctl-0.9.0 → splunkctl-0.10.1}/tests/test_main_hoisting.py +0 -0
- {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.
|
|
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
|
|
105
|
-
`soar
|
|
104
|
+
`soar playbooks delete` removes them through the Web UI route (no
|
|
105
|
+
REST deletion exists); `soar playbooks run --wait` drives and polls
|
|
106
|
+
runs to completion; `soar actions run --wait` does the same for
|
|
107
|
+
connector actions.
|
|
106
108
|
- **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
|
|
107
109
|
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
108
110
|
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
@@ -265,7 +267,7 @@ splunkctl mcp install # register in .mcp.json
|
|
|
265
267
|
splunkctl mcp serve # start stdio MCP server
|
|
266
268
|
```
|
|
267
269
|
|
|
268
|
-
The MCP server auto-generates
|
|
270
|
+
The MCP server auto-generates 229 typed tools from the Click command tree
|
|
269
271
|
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
270
272
|
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
271
273
|
schemas per command group. Subgroup-granular focus works for nested groups
|
|
@@ -63,8 +63,10 @@ ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
|
63
63
|
envelopes.
|
|
64
64
|
- **Playbooks as code** — `soar playbooks export --unpack` / `import`
|
|
65
65
|
round-trips playbook tgz bundles (the first such tool anywhere);
|
|
66
|
-
`soar playbooks
|
|
67
|
-
`soar
|
|
66
|
+
`soar playbooks delete` removes them through the Web UI route (no
|
|
67
|
+
REST deletion exists); `soar playbooks run --wait` drives and polls
|
|
68
|
+
runs to completion; `soar actions run --wait` does the same for
|
|
69
|
+
connector actions.
|
|
68
70
|
- **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
|
|
69
71
|
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
70
72
|
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
@@ -227,7 +229,7 @@ splunkctl mcp install # register in .mcp.json
|
|
|
227
229
|
splunkctl mcp serve # start stdio MCP server
|
|
228
230
|
```
|
|
229
231
|
|
|
230
|
-
The MCP server auto-generates
|
|
232
|
+
The MCP server auto-generates 229 typed tools from the Click command tree
|
|
231
233
|
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
232
234
|
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
233
235
|
schemas per command group. Subgroup-granular focus works for nested groups
|
|
@@ -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
|
-
|
|
55
|
-
|
|
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__" }
|
|
@@ -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)
|