splunkctl 0.8.0__tar.gz → 0.9.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.8.0 → splunkctl-0.9.0}/PKG-INFO +43 -22
- {splunkctl-0.8.0 → splunkctl-0.9.0}/README.md +42 -21
- splunkctl-0.9.0/splunkctl/__init__.py +3 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/mcp/server.py +76 -10
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/mcp/tools.py +51 -11
- splunkctl-0.9.0/splunkctl/soar/cimcef.py +254 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/soar/client.py +12 -4
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl.egg-info/PKG-INFO +43 -22
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl.egg-info/SOURCES.txt +1 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/tests/test_version.py +1 -1
- splunkctl-0.8.0/splunkctl/__init__.py +0 -3
- {splunkctl-0.8.0 → splunkctl-0.9.0}/LICENSE +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/pyproject.toml +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/setup.cfg +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/client.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/audit.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/audit_parse.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/common.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/conf.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/conf_ops.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/config_cmd.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/dashboards.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/datamodels.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/es.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/hec.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/knowledge.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/kvstore.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/lookups.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/lookups_wiring.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/mcp_cmd.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/rules.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/rules_io.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/search.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/server.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/server_topology.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/state.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/state_io.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/state_io_blobs.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/state_io_confs.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/state_types.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/commands/users.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/config.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/errors.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/guard.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/main.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/mcp/__init__.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/mcp/resources.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/output.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl/soar/__init__.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/tests/test_client.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/tests/test_config.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/tests/test_guard.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/tests/test_main_errors.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/tests/test_main_hoisting.py +0 -0
- {splunkctl-0.8.0 → splunkctl-0.9.0}/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.9.0
|
|
4
4
|
Summary: CLI tool for Splunk Enterprise SIEM operations.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -38,11 +38,11 @@ Dynamic: license-file
|
|
|
38
38
|
|
|
39
39
|
<div align="center">
|
|
40
40
|
|
|
41
|
-
<a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise as code" width="600"></a>
|
|
41
|
+
<a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise and Splunk SOAR as code" width="600"></a>
|
|
42
42
|
|
|
43
43
|
# splunkctl
|
|
44
44
|
|
|
45
|
-
**Operate Splunk Enterprise as code — for SOC teams, detection engineers, and AI agents.**
|
|
45
|
+
**Operate Splunk Enterprise and Splunk SOAR as code — for SOC teams, detection engineers, and AI agents.**
|
|
46
46
|
|
|
47
47
|
[Docs](https://splunk.danny.vn) · [Catalog](docs/design/catalog.md) · [Releases](https://github.com/dannyota/splunkctl/releases)
|
|
48
48
|
|
|
@@ -50,15 +50,19 @@ Dynamic: license-file
|
|
|
50
50
|
|
|
51
51
|
---
|
|
52
52
|
|
|
53
|
-
A Python CLI that queries, inspects, and manages
|
|
54
|
-
Enterprise
|
|
53
|
+
A Python CLI that queries, inspects, and manages **remote** Splunk
|
|
54
|
+
Enterprise and Splunk SOAR instances over their REST APIs. SIEM commands
|
|
55
|
+
are built on the
|
|
55
56
|
[splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
|
|
56
|
-
fork with [Click](https://click.palletsprojects.com/)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
fork with [Click](https://click.palletsprojects.com/); SOAR commands use
|
|
58
|
+
`SOARClient`, a requests-based Django REST client with dual auth (token +
|
|
59
|
+
Basic fallback). The SIEM core loop is **pull live state, review the diff,
|
|
60
|
+
push it back** -- one `state` engine covering rules, parsers, macros,
|
|
61
|
+
lookups, and dashboards, with a change-evidence report artifact for change
|
|
62
|
+
tickets. The SOAR surface covers ops visibility, container/artifact
|
|
63
|
+
lifecycle, the first playbooks-as-code loop anywhere, and SIEM-to-SOAR
|
|
64
|
+
ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
65
|
+
`--json` everywhere, structured error envelopes, and a built-in MCP server
|
|
62
66
|
(`splunkctl mcp serve`) with progressive tool discovery.
|
|
63
67
|
|
|
64
68
|
> **Every mutation is dry-run by default.** Nothing changes until you pass
|
|
@@ -90,8 +94,21 @@ structured error envelopes, and a built-in MCP server
|
|
|
90
94
|
`--limit`/`--offset`/`--filter` on every list surface, multi-instance
|
|
91
95
|
profiles with a bank-safety guard banner
|
|
92
96
|
(`(profile: uat @ host:port)`).
|
|
93
|
-
- **
|
|
94
|
-
|
|
97
|
+
- **SOAR ops** — `soar` command tree: containers, artifacts, vault, notes,
|
|
98
|
+
cases/workbooks, approvals, custom lists, indicators, evidence, users,
|
|
99
|
+
roles, audit, and cross-object search. Full lifecycle: create, update,
|
|
100
|
+
close, assign, delete -- with the same dry-run guard and typed error
|
|
101
|
+
envelopes.
|
|
102
|
+
- **Playbooks as code** — `soar playbooks export --unpack` / `import`
|
|
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.
|
|
106
|
+
- **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
|
|
107
|
+
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
108
|
+
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
109
|
+
batching.
|
|
110
|
+
- **Built for agents** — built-in MCP server with 237 auto-generated tools,
|
|
111
|
+
progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
|
|
95
112
|
guard markers on every mutation, dual output (TTY = table, pipe = JSON).
|
|
96
113
|
|
|
97
114
|
## Install
|
|
@@ -119,10 +136,12 @@ splunkctl --version
|
|
|
119
136
|
## Quickstart
|
|
120
137
|
|
|
121
138
|
```bash
|
|
122
|
-
splunkctl config init # interactive setup
|
|
123
|
-
splunkctl
|
|
124
|
-
splunkctl
|
|
125
|
-
splunkctl
|
|
139
|
+
splunkctl config init # interactive SIEM setup
|
|
140
|
+
splunkctl config init --soar # add SOAR credentials
|
|
141
|
+
splunkctl doctor # check SIEM connection
|
|
142
|
+
splunkctl soar test # check SOAR connection
|
|
143
|
+
splunkctl search run 'index=main | head 10' # run a SIEM search
|
|
144
|
+
splunkctl soar containers list # list SOAR containers
|
|
126
145
|
splunkctl commands --json # discover every verb
|
|
127
146
|
```
|
|
128
147
|
|
|
@@ -174,6 +193,7 @@ splunkctl state push --dir config/ --report r.json --yes # deploy + evidence
|
|
|
174
193
|
| `tags` | Tags — list, get |
|
|
175
194
|
| `datamodels` | Data model definitions + acceleration health |
|
|
176
195
|
| `state` | Config-as-code pull/diff/push with change-evidence reports |
|
|
196
|
+
| `soar` | SOAR: containers, artifacts, vault, playbooks, actions, cases, ingest |
|
|
177
197
|
| `commands` | Machine-readable command tree (JSON) |
|
|
178
198
|
| `mcp` | Built-in MCP server for AI agent integration |
|
|
179
199
|
|
|
@@ -245,11 +265,12 @@ splunkctl mcp install # register in .mcp.json
|
|
|
245
265
|
splunkctl mcp serve # start stdio MCP server
|
|
246
266
|
```
|
|
247
267
|
|
|
248
|
-
The MCP server auto-generates
|
|
249
|
-
with progressive discovery
|
|
250
|
-
`usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
251
|
-
command group.
|
|
252
|
-
|
|
268
|
+
The MCP server auto-generates 237 typed tools from the Click command tree
|
|
269
|
+
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
270
|
+
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
271
|
+
schemas per command group. Subgroup-granular focus works for nested groups
|
|
272
|
+
like `soar containers`. 33 guide resources are served as `guide://` URIs.
|
|
273
|
+
Mutations are guarded: `yes=true` to apply (dry-run by default).
|
|
253
274
|
|
|
254
275
|
## License
|
|
255
276
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise as code" width="600"></a>
|
|
3
|
+
<a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise and Splunk SOAR as code" width="600"></a>
|
|
4
4
|
|
|
5
5
|
# splunkctl
|
|
6
6
|
|
|
7
|
-
**Operate Splunk Enterprise as code — for SOC teams, detection engineers, and AI agents.**
|
|
7
|
+
**Operate Splunk Enterprise and Splunk SOAR as code — for SOC teams, detection engineers, and AI agents.**
|
|
8
8
|
|
|
9
9
|
[Docs](https://splunk.danny.vn) · [Catalog](docs/design/catalog.md) · [Releases](https://github.com/dannyota/splunkctl/releases)
|
|
10
10
|
|
|
@@ -12,15 +12,19 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
A Python CLI that queries, inspects, and manages
|
|
16
|
-
Enterprise
|
|
15
|
+
A Python CLI that queries, inspects, and manages **remote** Splunk
|
|
16
|
+
Enterprise and Splunk SOAR instances over their REST APIs. SIEM commands
|
|
17
|
+
are built on the
|
|
17
18
|
[splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
|
|
18
|
-
fork with [Click](https://click.palletsprojects.com/)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
fork with [Click](https://click.palletsprojects.com/); SOAR commands use
|
|
20
|
+
`SOARClient`, a requests-based Django REST client with dual auth (token +
|
|
21
|
+
Basic fallback). The SIEM core loop is **pull live state, review the diff,
|
|
22
|
+
push it back** -- one `state` engine covering rules, parsers, macros,
|
|
23
|
+
lookups, and dashboards, with a change-evidence report artifact for change
|
|
24
|
+
tickets. The SOAR surface covers ops visibility, container/artifact
|
|
25
|
+
lifecycle, the first playbooks-as-code loop anywhere, and SIEM-to-SOAR
|
|
26
|
+
ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
27
|
+
`--json` everywhere, structured error envelopes, and a built-in MCP server
|
|
24
28
|
(`splunkctl mcp serve`) with progressive tool discovery.
|
|
25
29
|
|
|
26
30
|
> **Every mutation is dry-run by default.** Nothing changes until you pass
|
|
@@ -52,8 +56,21 @@ structured error envelopes, and a built-in MCP server
|
|
|
52
56
|
`--limit`/`--offset`/`--filter` on every list surface, multi-instance
|
|
53
57
|
profiles with a bank-safety guard banner
|
|
54
58
|
(`(profile: uat @ host:port)`).
|
|
55
|
-
- **
|
|
56
|
-
|
|
59
|
+
- **SOAR ops** — `soar` command tree: containers, artifacts, vault, notes,
|
|
60
|
+
cases/workbooks, approvals, custom lists, indicators, evidence, users,
|
|
61
|
+
roles, audit, and cross-object search. Full lifecycle: create, update,
|
|
62
|
+
close, assign, delete -- with the same dry-run guard and typed error
|
|
63
|
+
envelopes.
|
|
64
|
+
- **Playbooks as code** — `soar playbooks export --unpack` / `import`
|
|
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.
|
|
68
|
+
- **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
|
|
69
|
+
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
70
|
+
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
71
|
+
batching.
|
|
72
|
+
- **Built for agents** — built-in MCP server with 237 auto-generated tools,
|
|
73
|
+
progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
|
|
57
74
|
guard markers on every mutation, dual output (TTY = table, pipe = JSON).
|
|
58
75
|
|
|
59
76
|
## Install
|
|
@@ -81,10 +98,12 @@ splunkctl --version
|
|
|
81
98
|
## Quickstart
|
|
82
99
|
|
|
83
100
|
```bash
|
|
84
|
-
splunkctl config init # interactive setup
|
|
85
|
-
splunkctl
|
|
86
|
-
splunkctl
|
|
87
|
-
splunkctl
|
|
101
|
+
splunkctl config init # interactive SIEM setup
|
|
102
|
+
splunkctl config init --soar # add SOAR credentials
|
|
103
|
+
splunkctl doctor # check SIEM connection
|
|
104
|
+
splunkctl soar test # check SOAR connection
|
|
105
|
+
splunkctl search run 'index=main | head 10' # run a SIEM search
|
|
106
|
+
splunkctl soar containers list # list SOAR containers
|
|
88
107
|
splunkctl commands --json # discover every verb
|
|
89
108
|
```
|
|
90
109
|
|
|
@@ -136,6 +155,7 @@ splunkctl state push --dir config/ --report r.json --yes # deploy + evidence
|
|
|
136
155
|
| `tags` | Tags — list, get |
|
|
137
156
|
| `datamodels` | Data model definitions + acceleration health |
|
|
138
157
|
| `state` | Config-as-code pull/diff/push with change-evidence reports |
|
|
158
|
+
| `soar` | SOAR: containers, artifacts, vault, playbooks, actions, cases, ingest |
|
|
139
159
|
| `commands` | Machine-readable command tree (JSON) |
|
|
140
160
|
| `mcp` | Built-in MCP server for AI agent integration |
|
|
141
161
|
|
|
@@ -207,11 +227,12 @@ splunkctl mcp install # register in .mcp.json
|
|
|
207
227
|
splunkctl mcp serve # start stdio MCP server
|
|
208
228
|
```
|
|
209
229
|
|
|
210
|
-
The MCP server auto-generates
|
|
211
|
-
with progressive discovery
|
|
212
|
-
`usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
213
|
-
command group.
|
|
214
|
-
|
|
230
|
+
The MCP server auto-generates 237 typed tools from the Click command tree
|
|
231
|
+
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
232
|
+
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
233
|
+
schemas per command group. Subgroup-granular focus works for nested groups
|
|
234
|
+
like `soar containers`. 33 guide resources are served as `guide://` URIs.
|
|
235
|
+
Mutations are guarded: `yes=true` to apply (dry-run by default).
|
|
215
236
|
|
|
216
237
|
## License
|
|
217
238
|
|
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
import sys
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
import click
|
|
11
12
|
from mcp.server.fastmcp import Context, FastMCP
|
|
12
13
|
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
|
|
13
14
|
from mcp.types import ToolAnnotations
|
|
@@ -20,9 +21,12 @@ from splunkctl.mcp.tools import (
|
|
|
20
21
|
ToolEntry,
|
|
21
22
|
ToolIndex,
|
|
22
23
|
build_tool_index,
|
|
24
|
+
direct_commands,
|
|
23
25
|
group_names,
|
|
24
26
|
group_summary,
|
|
25
27
|
group_tools,
|
|
28
|
+
has_subgroups,
|
|
29
|
+
subgroup_names,
|
|
26
30
|
)
|
|
27
31
|
|
|
28
32
|
_INSTRUCTIONS = """\
|
|
@@ -32,7 +36,10 @@ the preferred way to run commands (validated arguments, full schemas). \
|
|
|
32
36
|
`usage <command>` previews one command's schema and auto-loads it as a \
|
|
33
37
|
callable tool. `run` is an escape hatch for raw command strings (no \
|
|
34
38
|
validation). `unfocus` when done to free context. Mutations are guarded: \
|
|
35
|
-
pass yes=true to apply (dry-run by default).
|
|
39
|
+
pass yes=true to apply (dry-run by default). \
|
|
40
|
+
The soar tree has nested subgroups (containers, playbooks, actions, ...); \
|
|
41
|
+
focus at subgroup granularity: `focus "soar containers"`, not `focus soar`. \
|
|
42
|
+
`help soar` lists available subgroups."""
|
|
36
43
|
|
|
37
44
|
_FORCE_FLAGS = ["--json"]
|
|
38
45
|
|
|
@@ -149,11 +156,40 @@ def create_server() -> FastMCP:
|
|
|
149
156
|
"""List command groups, or subcommands within a group."""
|
|
150
157
|
if group is None:
|
|
151
158
|
rows = group_summary(cli)
|
|
152
|
-
lines = [
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
lines: list[str] = []
|
|
160
|
+
for r in rows:
|
|
161
|
+
line = f"{r['group']:16s} {r['description']} ({r['count']} cmds)"
|
|
162
|
+
lines.append(line)
|
|
163
|
+
if "subgroups" in r:
|
|
164
|
+
lines.append(f"{'':16s} subgroups: {r['subgroups']}")
|
|
165
|
+
lines.append(
|
|
166
|
+
f"{'':16s} focus at subgroup level: "
|
|
167
|
+
f'focus "{r["group"]} <subgroup>"'
|
|
168
|
+
)
|
|
155
169
|
return "\n".join(lines)
|
|
156
|
-
|
|
170
|
+
# For groups with subgroups, show the two-level layout
|
|
171
|
+
parts = _split_command(group)
|
|
172
|
+
if len(parts) == 1 and has_subgroups(cli, parts[0]):
|
|
173
|
+
grp = parts[0]
|
|
174
|
+
top_cmd = cli.commands.get(grp)
|
|
175
|
+
if not isinstance(top_cmd, click.Group): # guaranteed by has_subgroups
|
|
176
|
+
raise TypeError(f"expected group for {grp!r}")
|
|
177
|
+
sg = subgroup_names(cli, grp)
|
|
178
|
+
dc = direct_commands(cli, grp)
|
|
179
|
+
lines = [f'{grp} subgroups (focus "{grp} <subgroup>"):']
|
|
180
|
+
for s in sg:
|
|
181
|
+
sub_cmd = top_cmd.commands[s]
|
|
182
|
+
desc = (sub_cmd.help or "").split("\n")[0]
|
|
183
|
+
n = len(sub_cmd.commands) if isinstance(sub_cmd, click.Group) else 0
|
|
184
|
+
lines.append(f" {grp} {s:16s} {desc} ({n} cmds)")
|
|
185
|
+
if dc:
|
|
186
|
+
lines.append(f"\n{grp} direct commands:")
|
|
187
|
+
for c in dc:
|
|
188
|
+
sub_cmd = top_cmd.commands[c]
|
|
189
|
+
desc = (sub_cmd.help or "").split("\n")[0]
|
|
190
|
+
lines.append(f" {grp} {c:16s} {desc}")
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
return _exec_cli([*parts, "--help"])
|
|
157
193
|
|
|
158
194
|
# --- Meta-tool: usage ---
|
|
159
195
|
|
|
@@ -161,7 +197,8 @@ def create_server() -> FastMCP:
|
|
|
161
197
|
name="usage",
|
|
162
198
|
description=(
|
|
163
199
|
"Show flags, args, and description for one command. "
|
|
164
|
-
"Auto-loads it as a callable tool."
|
|
200
|
+
"Auto-loads it as a callable tool. "
|
|
201
|
+
'Accepts nested paths: usage "soar playbooks run".'
|
|
165
202
|
),
|
|
166
203
|
annotations=ToolAnnotations(readOnlyHint=True),
|
|
167
204
|
)
|
|
@@ -193,7 +230,8 @@ def create_server() -> FastMCP:
|
|
|
193
230
|
name="focus",
|
|
194
231
|
description=(
|
|
195
232
|
"Load typed tools for a command group (enables full schemas). "
|
|
196
|
-
"Preferred over run for validated arguments."
|
|
233
|
+
"Preferred over run for validated arguments. "
|
|
234
|
+
'Nested groups like soar require subgroup: focus "soar containers".'
|
|
197
235
|
),
|
|
198
236
|
annotations=ToolAnnotations(readOnlyHint=True),
|
|
199
237
|
)
|
|
@@ -203,6 +241,26 @@ def create_server() -> FastMCP:
|
|
|
203
241
|
count = len(focused[group])
|
|
204
242
|
return f"Group '{group}' already focused ({count} tools)."
|
|
205
243
|
|
|
244
|
+
# Reject bare groups that have nested subgroups (too many tools).
|
|
245
|
+
parts = _split_command(group)
|
|
246
|
+
if len(parts) == 1 and has_subgroups(cli, parts[0]):
|
|
247
|
+
sg = subgroup_names(cli, parts[0])
|
|
248
|
+
dc = direct_commands(cli, parts[0])
|
|
249
|
+
lines = [
|
|
250
|
+
f"'{parts[0]}' has nested subgroups — focus at "
|
|
251
|
+
f"subgroup level to avoid flooding context.",
|
|
252
|
+
"",
|
|
253
|
+
"Subgroups:",
|
|
254
|
+
]
|
|
255
|
+
for s in sg:
|
|
256
|
+
lines.append(f' focus "{parts[0]} {s}"')
|
|
257
|
+
if dc:
|
|
258
|
+
lines.append(
|
|
259
|
+
f"\nDirect commands ({', '.join(dc)}) can be "
|
|
260
|
+
f"loaded individually via usage."
|
|
261
|
+
)
|
|
262
|
+
return "\n".join(lines)
|
|
263
|
+
|
|
206
264
|
entries = group_tools(all_tools, group)
|
|
207
265
|
if not entries:
|
|
208
266
|
available = group_names(cli)
|
|
@@ -249,14 +307,22 @@ def create_server() -> FastMCP:
|
|
|
249
307
|
await ctx.session.send_tool_list_changed()
|
|
250
308
|
return f"Unloaded all focused tools ({removed} total)."
|
|
251
309
|
|
|
252
|
-
|
|
310
|
+
# Try exact key first, then normalized form for subgroup paths
|
|
311
|
+
key = group if group in focused else None
|
|
312
|
+
if key is None:
|
|
313
|
+
norm = group.replace(" ", "_").replace("-", "_")
|
|
314
|
+
for k in focused:
|
|
315
|
+
if k.replace(" ", "_").replace("-", "_") == norm:
|
|
316
|
+
key = k
|
|
317
|
+
break
|
|
318
|
+
if key is None:
|
|
253
319
|
return f"Group '{group}' is not focused."
|
|
254
|
-
for n in focused[
|
|
320
|
+
for n in focused[key]:
|
|
255
321
|
try:
|
|
256
322
|
mcp.remove_tool(n)
|
|
257
323
|
except Exception: # noqa: BLE001, S110
|
|
258
324
|
pass
|
|
259
|
-
count = len(focused.pop(
|
|
325
|
+
count = len(focused.pop(key))
|
|
260
326
|
if ctx is not None:
|
|
261
327
|
await ctx.session.send_tool_list_changed()
|
|
262
328
|
return f"Unloaded {count} tools for '{group}'."
|
|
@@ -207,13 +207,51 @@ def group_names(root: click.Group) -> list[str]:
|
|
|
207
207
|
|
|
208
208
|
|
|
209
209
|
def group_tools(index: ToolIndex, group: str) -> list[ToolEntry]:
|
|
210
|
-
"""Return all tools under a
|
|
211
|
-
|
|
210
|
+
"""Return all tools under a group or subgroup path.
|
|
211
|
+
|
|
212
|
+
Accepts space-separated paths (``"soar containers"``) as well as
|
|
213
|
+
plain group names (``"indexes"``).
|
|
214
|
+
"""
|
|
215
|
+
prefix = group.replace(" ", "_").replace("-", "_") + "_"
|
|
212
216
|
return [t for t in index.values() if t.name.startswith(prefix)]
|
|
213
217
|
|
|
214
218
|
|
|
219
|
+
def subgroup_names(root: click.Group, group: str) -> list[str]:
|
|
220
|
+
"""Return subgroup names within a top-level group.
|
|
221
|
+
|
|
222
|
+
For ``soar`` this yields ``["actions", "apps", "artifacts", ...]`` —
|
|
223
|
+
only the children that are themselves ``click.Group`` instances.
|
|
224
|
+
"""
|
|
225
|
+
top = root.commands.get(group)
|
|
226
|
+
if not isinstance(top, click.Group):
|
|
227
|
+
return []
|
|
228
|
+
return sorted(
|
|
229
|
+
name for name, cmd in top.commands.items() if isinstance(cmd, click.Group)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def has_subgroups(root: click.Group, group: str) -> bool:
|
|
234
|
+
"""Return True if the group has nested subgroups."""
|
|
235
|
+
return bool(subgroup_names(root, group))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def direct_commands(root: click.Group, group: str) -> list[str]:
|
|
239
|
+
"""Return direct (non-group) command names under a top-level group."""
|
|
240
|
+
top = root.commands.get(group)
|
|
241
|
+
if not isinstance(top, click.Group):
|
|
242
|
+
return []
|
|
243
|
+
return sorted(
|
|
244
|
+
name for name, cmd in top.commands.items() if not isinstance(cmd, click.Group)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
215
248
|
def group_summary(root: click.Group) -> list[dict[str, str]]:
|
|
216
|
-
"""Build a summary of command groups with descriptions and counts.
|
|
249
|
+
"""Build a summary of command groups with descriptions and counts.
|
|
250
|
+
|
|
251
|
+
Groups that contain nested subgroups (like ``soar``) include a
|
|
252
|
+
``subgroups`` key with the two-level layout so agents can discover
|
|
253
|
+
subgroup focus paths without flooding context.
|
|
254
|
+
"""
|
|
217
255
|
skip = {"mcp", "commands", "skill", "config", "doctor", "info"}
|
|
218
256
|
rows: list[dict[str, str]] = []
|
|
219
257
|
for name, cmd in sorted(root.commands.items()):
|
|
@@ -222,12 +260,14 @@ def group_summary(root: click.Group) -> list[dict[str, str]]:
|
|
|
222
260
|
if isinstance(cmd, click.Group):
|
|
223
261
|
subs = list(cmd.commands.keys())
|
|
224
262
|
desc = (cmd.help or "").split("\n")[0]
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
263
|
+
row: dict[str, str] = {
|
|
264
|
+
"group": name,
|
|
265
|
+
"description": desc,
|
|
266
|
+
"subcommands": ", ".join(sorted(subs)),
|
|
267
|
+
"count": str(len(subs)),
|
|
268
|
+
}
|
|
269
|
+
sg = subgroup_names(root, name)
|
|
270
|
+
if sg:
|
|
271
|
+
row["subgroups"] = ", ".join(sg)
|
|
272
|
+
rows.append(row)
|
|
233
273
|
return rows
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""CIM-to-CEF field mapping and row-to-artifact transform.
|
|
2
|
+
|
|
3
|
+
Pure functions and data — no I/O. The ``CIM_CEF_MAP`` encodes the official
|
|
4
|
+
Splunk SOAR Connector for Splunk mapping (``bytesIn`` typo fixed).
|
|
5
|
+
``CEF_CONTAINS_MAP`` maps CEF keys to SOAR contains types for artifact
|
|
6
|
+
actionability. ``SEVERITY_MAP`` converts Splunk severity/urgency strings
|
|
7
|
+
to SOAR severity levels.
|
|
8
|
+
|
|
9
|
+
Used by ``soar ingest`` (CLI) and ``soar artifacts create`` (auto cef_types).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# ---- CIM -> CEF default map ------------------------------------------------
|
|
19
|
+
# Source: github.com/splunk-soar-connectors/splunk src/splunk_consts.py
|
|
20
|
+
# Fixed: upstream has ``butesIn`` typo for bytes_in -> ships as ``bytesIn``.
|
|
21
|
+
|
|
22
|
+
CIM_CEF_MAP: dict[str, str] = {
|
|
23
|
+
"action": "act",
|
|
24
|
+
"action_name": "act",
|
|
25
|
+
"app": "app",
|
|
26
|
+
"bytes_in": "bytesIn",
|
|
27
|
+
"bytes_out": "bytesOut",
|
|
28
|
+
"category": "cat",
|
|
29
|
+
"dest": "destinationAddress",
|
|
30
|
+
"dest_ip": "destinationAddress",
|
|
31
|
+
"dest_mac": "destinationMacAddress",
|
|
32
|
+
"dest_nt_domain": "destinationNtDomain",
|
|
33
|
+
"dest_port": "destinationPort",
|
|
34
|
+
"dest_translated_ip": "destinationTranslatedAddress",
|
|
35
|
+
"dest_translated_port": "destinationTranslatedPort",
|
|
36
|
+
"direction": "deviceDirection",
|
|
37
|
+
"dns": "destinationDnsDomain",
|
|
38
|
+
"dvc": "dvc",
|
|
39
|
+
"dvc_ip": "deviceAddress",
|
|
40
|
+
"dvc_mac": "deviceMacAddress",
|
|
41
|
+
"file_create_time": "fileCreateTime",
|
|
42
|
+
"file_hash": "fileHash",
|
|
43
|
+
"file_modify_time": "fileModificationTime",
|
|
44
|
+
"file_name": "fileName",
|
|
45
|
+
"file_path": "filePath",
|
|
46
|
+
"file_size": "fileSize",
|
|
47
|
+
"message": "message",
|
|
48
|
+
"protocol": "transportProtocol",
|
|
49
|
+
"transport": "transportProtocol",
|
|
50
|
+
"request_payload": "request",
|
|
51
|
+
"request_payload_type": "requestMethod",
|
|
52
|
+
"src": "sourceAddress",
|
|
53
|
+
"src_dns": "sourceDnsDomain",
|
|
54
|
+
"src_ip": "sourceAddress",
|
|
55
|
+
"src_mac": "sourceMacAddress",
|
|
56
|
+
"src_nt_domain": "sourceNtDomain",
|
|
57
|
+
"src_port": "sourcePort",
|
|
58
|
+
"src_translated_ip": "sourceTranslatedAddress",
|
|
59
|
+
"src_translated_port": "sourceTranslatedPort",
|
|
60
|
+
"src_user": "sourceUserId",
|
|
61
|
+
"url": "requestURL",
|
|
62
|
+
"user": "destinationUserName",
|
|
63
|
+
"user_id": "destinationUserId",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# ---- CEF contains-type map (canonical) -------------------------------------
|
|
67
|
+
# Maps CEF key -> list of SOAR contains types. Imported by
|
|
68
|
+
# ``splunkctl/commands/soar/artifacts.py`` for auto cef_types on create.
|
|
69
|
+
|
|
70
|
+
CEF_CONTAINS_MAP: dict[str, list[str]] = {
|
|
71
|
+
"destinationAddress": ["ip", "host name"],
|
|
72
|
+
"destinationMacAddress": ["mac address"],
|
|
73
|
+
"destinationNtDomain": ["domain"],
|
|
74
|
+
"destinationPort": ["port"],
|
|
75
|
+
"destinationTranslatedAddress": ["ip"],
|
|
76
|
+
"destinationTranslatedPort": ["port"],
|
|
77
|
+
"destinationDnsDomain": ["domain"],
|
|
78
|
+
"dvc": ["ip", "host name"],
|
|
79
|
+
"deviceAddress": ["ip"],
|
|
80
|
+
"deviceMacAddress": ["mac address"],
|
|
81
|
+
"fileHash": ["hash"],
|
|
82
|
+
"fileName": ["file name"],
|
|
83
|
+
"filePath": ["file path"],
|
|
84
|
+
"sourceAddress": ["ip", "host name"],
|
|
85
|
+
"sourceDnsDomain": ["domain"],
|
|
86
|
+
"sourceMacAddress": ["mac address"],
|
|
87
|
+
"sourceNtDomain": ["domain"],
|
|
88
|
+
"sourcePort": ["port"],
|
|
89
|
+
"sourceTranslatedAddress": ["ip"],
|
|
90
|
+
"sourceTranslatedPort": ["port"],
|
|
91
|
+
"sourceUserId": ["user name"],
|
|
92
|
+
"requestURL": ["url"],
|
|
93
|
+
"destinationUserName": ["user name"],
|
|
94
|
+
"destinationUserId": ["user name"],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# ---- Severity map -----------------------------------------------------------
|
|
98
|
+
# Check ``severity`` first, then ``urgency``; default ``medium``.
|
|
99
|
+
# Multivalue -> highest wins.
|
|
100
|
+
|
|
101
|
+
_SEVERITY_ORDER: dict[str, int] = {
|
|
102
|
+
"informational": 0,
|
|
103
|
+
"low": 1,
|
|
104
|
+
"medium": 2,
|
|
105
|
+
"high": 3,
|
|
106
|
+
"critical": 4,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_SPLUNK_TO_SOAR: dict[str, str] = {
|
|
110
|
+
"informational": "low",
|
|
111
|
+
"low": "low",
|
|
112
|
+
"medium": "medium",
|
|
113
|
+
"high": "high",
|
|
114
|
+
"critical": "high",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def map_severity(row: dict[str, Any]) -> str:
|
|
119
|
+
"""Derive SOAR severity from a Splunk result row.
|
|
120
|
+
|
|
121
|
+
Checks ``severity`` first, then ``urgency``. If both are absent,
|
|
122
|
+
returns ``medium``. When the field contains multiple values
|
|
123
|
+
(comma-separated or list), the highest wins.
|
|
124
|
+
"""
|
|
125
|
+
raw = row.get("severity") or row.get("urgency") or ""
|
|
126
|
+
if isinstance(raw, list):
|
|
127
|
+
values = [str(v).strip().lower() for v in raw if v]
|
|
128
|
+
else:
|
|
129
|
+
values = [v.strip().lower() for v in str(raw).split(",") if v.strip()]
|
|
130
|
+
|
|
131
|
+
if not values:
|
|
132
|
+
return "medium"
|
|
133
|
+
|
|
134
|
+
best_rank = -1
|
|
135
|
+
best_sev = "medium"
|
|
136
|
+
for v in values:
|
|
137
|
+
rank = _SEVERITY_ORDER.get(v, -1)
|
|
138
|
+
if rank > best_rank:
|
|
139
|
+
best_rank = rank
|
|
140
|
+
best_sev = _SPLUNK_TO_SOAR.get(v, "medium")
|
|
141
|
+
return best_sev
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---- Row -> artifact transform ----------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def row_sdi(row: dict[str, Any], sdi_field: str) -> str:
|
|
148
|
+
"""Compute the source_data_identifier for a result row.
|
|
149
|
+
|
|
150
|
+
If ``sdi_field`` names a field present in the row, use its value.
|
|
151
|
+
Otherwise hash the full row (SHA-256).
|
|
152
|
+
"""
|
|
153
|
+
val = row.get(sdi_field)
|
|
154
|
+
if val is not None and str(val).strip():
|
|
155
|
+
return str(val).strip()
|
|
156
|
+
canonical = json.dumps(row, sort_keys=True, default=str)
|
|
157
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def row_to_cef(
|
|
161
|
+
row: dict[str, Any],
|
|
162
|
+
*,
|
|
163
|
+
cim_map: dict[str, str] | None = None,
|
|
164
|
+
include_unmapped: bool = False,
|
|
165
|
+
) -> dict[str, str]:
|
|
166
|
+
"""Map a Splunk result row to a CEF dict.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
row: Single Splunk result dict.
|
|
170
|
+
cim_map: CIM->CEF mapping; defaults to ``CIM_CEF_MAP``.
|
|
171
|
+
include_unmapped: If True, fields not in the map are passed
|
|
172
|
+
through as custom CEF keys.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
CEF dict suitable for ``POST /rest/artifact``.
|
|
176
|
+
"""
|
|
177
|
+
effective = cim_map if cim_map is not None else CIM_CEF_MAP
|
|
178
|
+
cef: dict[str, str] = {}
|
|
179
|
+
mapped_keys: set[str] = set()
|
|
180
|
+
|
|
181
|
+
for cim_field, cef_key in effective.items():
|
|
182
|
+
val = row.get(cim_field)
|
|
183
|
+
if val is not None and str(val).strip():
|
|
184
|
+
cef[cef_key] = str(val)
|
|
185
|
+
mapped_keys.add(cim_field)
|
|
186
|
+
|
|
187
|
+
if include_unmapped:
|
|
188
|
+
skip = {"_raw", "_time", "_si", "_serial", "_sourcetype", "_indextime"}
|
|
189
|
+
for key, val in row.items():
|
|
190
|
+
if key in mapped_keys or key in skip or key.startswith("_"):
|
|
191
|
+
continue
|
|
192
|
+
if key in effective:
|
|
193
|
+
continue
|
|
194
|
+
if val is not None and str(val).strip():
|
|
195
|
+
cef[key] = str(val)
|
|
196
|
+
|
|
197
|
+
return cef
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def auto_cef_types(
|
|
201
|
+
cef: dict[str, Any],
|
|
202
|
+
*,
|
|
203
|
+
contains_map: dict[str, list[str]] | None = None,
|
|
204
|
+
) -> dict[str, list[str]]:
|
|
205
|
+
"""Build cef_types from the contains map for a CEF dict."""
|
|
206
|
+
effective = contains_map if contains_map is not None else CEF_CONTAINS_MAP
|
|
207
|
+
types: dict[str, list[str]] = {}
|
|
208
|
+
for key in cef:
|
|
209
|
+
if key in effective:
|
|
210
|
+
types[key] = effective[key]
|
|
211
|
+
return types
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def container_name_for_row(
|
|
215
|
+
row: dict[str, Any],
|
|
216
|
+
*,
|
|
217
|
+
template: str | None = None,
|
|
218
|
+
name_field: str | None = None,
|
|
219
|
+
) -> str:
|
|
220
|
+
"""Derive a container name for a result row.
|
|
221
|
+
|
|
222
|
+
Priority:
|
|
223
|
+
1. ``name_field`` — use that row field's value.
|
|
224
|
+
2. ``template`` — use as literal name.
|
|
225
|
+
3. ``rule_name`` field (ES notable convention).
|
|
226
|
+
4. Fallback ``"Splunk Log Entry on <_time>: <source>"``.
|
|
227
|
+
"""
|
|
228
|
+
if name_field:
|
|
229
|
+
val = row.get(name_field)
|
|
230
|
+
if val and str(val).strip():
|
|
231
|
+
return str(val).strip()
|
|
232
|
+
|
|
233
|
+
if template:
|
|
234
|
+
return template
|
|
235
|
+
|
|
236
|
+
rule_name = row.get("rule_name") or row.get("search_name")
|
|
237
|
+
if rule_name and str(rule_name).strip():
|
|
238
|
+
return str(rule_name).strip()
|
|
239
|
+
|
|
240
|
+
_time = row.get("_time", "unknown")
|
|
241
|
+
source = row.get("source", "unknown")
|
|
242
|
+
return f"Splunk Log Entry on {_time}: {source}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def artifact_name_for_row(row: dict[str, Any]) -> str:
|
|
246
|
+
"""Derive an artifact name for a result row.
|
|
247
|
+
|
|
248
|
+
Uses ``search_name`` if present, else ``"Field Values"``
|
|
249
|
+
(connector convention).
|
|
250
|
+
"""
|
|
251
|
+
name = row.get("search_name")
|
|
252
|
+
if name and str(name).strip():
|
|
253
|
+
return str(name).strip()
|
|
254
|
+
return "Field Values"
|
|
@@ -269,11 +269,13 @@ class SOARClient:
|
|
|
269
269
|
if not isinstance(body, dict):
|
|
270
270
|
return body
|
|
271
271
|
|
|
272
|
-
# {results} envelope (search)
|
|
272
|
+
# {results} envelope (search) — server paginates (1-based pages)
|
|
273
|
+
# and returns real num_pages; forward it instead of hardcoding 1.
|
|
273
274
|
if endpoint_root in _RESULTS_ENVELOPE_ENDPOINTS and "results" in body:
|
|
274
275
|
results = body["results"]
|
|
275
276
|
count = body.get("count", len(results))
|
|
276
|
-
|
|
277
|
+
num_pages = body.get("num_pages", 1)
|
|
278
|
+
return {"count": count, "num_pages": num_pages, "data": results}
|
|
277
279
|
|
|
278
280
|
return body
|
|
279
281
|
|
|
@@ -368,8 +370,14 @@ class SOARClient:
|
|
|
368
370
|
|
|
369
371
|
Yields individual items from each page, transparently fetching
|
|
370
372
|
subsequent pages as needed.
|
|
373
|
+
|
|
374
|
+
Pagination origin varies by endpoint:
|
|
375
|
+
- ``search`` (``_RESULTS_ENVELOPE_ENDPOINTS``): 1-based pages.
|
|
376
|
+
- All other endpoints: 0-based pages.
|
|
371
377
|
"""
|
|
372
|
-
|
|
378
|
+
endpoint_root = path.lstrip("/").split("/")[0]
|
|
379
|
+
origin = 1 if endpoint_root in _RESULTS_ENVELOPE_ENDPOINTS else 0
|
|
380
|
+
page = origin
|
|
373
381
|
while True:
|
|
374
382
|
page_params = dict(params or {})
|
|
375
383
|
page_params["page"] = page
|
|
@@ -381,5 +389,5 @@ class SOARClient:
|
|
|
381
389
|
|
|
382
390
|
num_pages = result.get("num_pages", 1)
|
|
383
391
|
page += 1
|
|
384
|
-
if page >= num_pages or not data:
|
|
392
|
+
if page - origin >= num_pages or not data:
|
|
385
393
|
break
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: splunkctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: CLI tool for Splunk Enterprise SIEM operations.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -38,11 +38,11 @@ Dynamic: license-file
|
|
|
38
38
|
|
|
39
39
|
<div align="center">
|
|
40
40
|
|
|
41
|
-
<a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise as code" width="600"></a>
|
|
41
|
+
<a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise and Splunk SOAR as code" width="600"></a>
|
|
42
42
|
|
|
43
43
|
# splunkctl
|
|
44
44
|
|
|
45
|
-
**Operate Splunk Enterprise as code — for SOC teams, detection engineers, and AI agents.**
|
|
45
|
+
**Operate Splunk Enterprise and Splunk SOAR as code — for SOC teams, detection engineers, and AI agents.**
|
|
46
46
|
|
|
47
47
|
[Docs](https://splunk.danny.vn) · [Catalog](docs/design/catalog.md) · [Releases](https://github.com/dannyota/splunkctl/releases)
|
|
48
48
|
|
|
@@ -50,15 +50,19 @@ Dynamic: license-file
|
|
|
50
50
|
|
|
51
51
|
---
|
|
52
52
|
|
|
53
|
-
A Python CLI that queries, inspects, and manages
|
|
54
|
-
Enterprise
|
|
53
|
+
A Python CLI that queries, inspects, and manages **remote** Splunk
|
|
54
|
+
Enterprise and Splunk SOAR instances over their REST APIs. SIEM commands
|
|
55
|
+
are built on the
|
|
55
56
|
[splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
|
|
56
|
-
fork with [Click](https://click.palletsprojects.com/)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
fork with [Click](https://click.palletsprojects.com/); SOAR commands use
|
|
58
|
+
`SOARClient`, a requests-based Django REST client with dual auth (token +
|
|
59
|
+
Basic fallback). The SIEM core loop is **pull live state, review the diff,
|
|
60
|
+
push it back** -- one `state` engine covering rules, parsers, macros,
|
|
61
|
+
lookups, and dashboards, with a change-evidence report artifact for change
|
|
62
|
+
tickets. The SOAR surface covers ops visibility, container/artifact
|
|
63
|
+
lifecycle, the first playbooks-as-code loop anywhere, and SIEM-to-SOAR
|
|
64
|
+
ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
65
|
+
`--json` everywhere, structured error envelopes, and a built-in MCP server
|
|
62
66
|
(`splunkctl mcp serve`) with progressive tool discovery.
|
|
63
67
|
|
|
64
68
|
> **Every mutation is dry-run by default.** Nothing changes until you pass
|
|
@@ -90,8 +94,21 @@ structured error envelopes, and a built-in MCP server
|
|
|
90
94
|
`--limit`/`--offset`/`--filter` on every list surface, multi-instance
|
|
91
95
|
profiles with a bank-safety guard banner
|
|
92
96
|
(`(profile: uat @ host:port)`).
|
|
93
|
-
- **
|
|
94
|
-
|
|
97
|
+
- **SOAR ops** — `soar` command tree: containers, artifacts, vault, notes,
|
|
98
|
+
cases/workbooks, approvals, custom lists, indicators, evidence, users,
|
|
99
|
+
roles, audit, and cross-object search. Full lifecycle: create, update,
|
|
100
|
+
close, assign, delete -- with the same dry-run guard and typed error
|
|
101
|
+
envelopes.
|
|
102
|
+
- **Playbooks as code** — `soar playbooks export --unpack` / `import`
|
|
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.
|
|
106
|
+
- **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
|
|
107
|
+
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
108
|
+
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
109
|
+
batching.
|
|
110
|
+
- **Built for agents** — built-in MCP server with 237 auto-generated tools,
|
|
111
|
+
progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
|
|
95
112
|
guard markers on every mutation, dual output (TTY = table, pipe = JSON).
|
|
96
113
|
|
|
97
114
|
## Install
|
|
@@ -119,10 +136,12 @@ splunkctl --version
|
|
|
119
136
|
## Quickstart
|
|
120
137
|
|
|
121
138
|
```bash
|
|
122
|
-
splunkctl config init # interactive setup
|
|
123
|
-
splunkctl
|
|
124
|
-
splunkctl
|
|
125
|
-
splunkctl
|
|
139
|
+
splunkctl config init # interactive SIEM setup
|
|
140
|
+
splunkctl config init --soar # add SOAR credentials
|
|
141
|
+
splunkctl doctor # check SIEM connection
|
|
142
|
+
splunkctl soar test # check SOAR connection
|
|
143
|
+
splunkctl search run 'index=main | head 10' # run a SIEM search
|
|
144
|
+
splunkctl soar containers list # list SOAR containers
|
|
126
145
|
splunkctl commands --json # discover every verb
|
|
127
146
|
```
|
|
128
147
|
|
|
@@ -174,6 +193,7 @@ splunkctl state push --dir config/ --report r.json --yes # deploy + evidence
|
|
|
174
193
|
| `tags` | Tags — list, get |
|
|
175
194
|
| `datamodels` | Data model definitions + acceleration health |
|
|
176
195
|
| `state` | Config-as-code pull/diff/push with change-evidence reports |
|
|
196
|
+
| `soar` | SOAR: containers, artifacts, vault, playbooks, actions, cases, ingest |
|
|
177
197
|
| `commands` | Machine-readable command tree (JSON) |
|
|
178
198
|
| `mcp` | Built-in MCP server for AI agent integration |
|
|
179
199
|
|
|
@@ -245,11 +265,12 @@ splunkctl mcp install # register in .mcp.json
|
|
|
245
265
|
splunkctl mcp serve # start stdio MCP server
|
|
246
266
|
```
|
|
247
267
|
|
|
248
|
-
The MCP server auto-generates
|
|
249
|
-
with progressive discovery
|
|
250
|
-
`usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
251
|
-
command group.
|
|
252
|
-
|
|
268
|
+
The MCP server auto-generates 237 typed tools from the Click command tree
|
|
269
|
+
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
270
|
+
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
271
|
+
schemas per command group. Subgroup-granular focus works for nested groups
|
|
272
|
+
like `soar containers`. 33 guide resources are served as `guide://` URIs.
|
|
273
|
+
Mutations are guarded: `yes=true` to apply (dry-run by default).
|
|
253
274
|
|
|
254
275
|
## License
|
|
255
276
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|