splunkctl 0.12.0__tar.gz → 0.13.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.12.0/splunkctl.egg-info → splunkctl-0.13.0}/PKG-INFO +5 -1
- {splunkctl-0.12.0 → splunkctl-0.13.0}/README.md +2 -0
- splunkctl-0.13.0/docs/guides/auth.md +61 -0
- splunkctl-0.13.0/docs/guides/keycloak-lab.md +20 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/mcp.md +1 -1
- {splunkctl-0.12.0 → splunkctl-0.13.0}/pyproject.toml +3 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/__init__.py +1 -1
- splunkctl-0.13.0/splunkctl/auth/__init__.py +1 -0
- splunkctl-0.13.0/splunkctl/auth/adapters.py +151 -0
- splunkctl-0.13.0/splunkctl/auth/broker.py +115 -0
- splunkctl-0.13.0/splunkctl/auth/detector.py +120 -0
- splunkctl-0.13.0/splunkctl/auth/session.py +204 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/client.py +62 -5
- splunkctl-0.13.0/splunkctl/commands/auth.py +198 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/config_cmd.py +107 -7
- splunkctl-0.13.0/splunkctl/commands/soar/_client.py +81 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/errors.py +7 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/main.py +2 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/soar/client.py +11 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/soar/web.py +11 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0/splunkctl.egg-info}/PKG-INFO +5 -1
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/SOURCES.txt +8 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/requires.txt +3 -0
- splunkctl-0.12.0/splunkctl/commands/soar/_client.py +0 -44
- {splunkctl-0.12.0 → splunkctl-0.13.0}/LICENSE +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/MANIFEST.in +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/alerts.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/apps.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/audit.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/conf.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/configure.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/dashboards.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/datamodels.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/doctor.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/es.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/exit-codes.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/hec.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/indexes.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/inputs.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/install.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/knowledge.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/kvstore.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/lookups.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/parsers.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/rules.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/search.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/server.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-actions.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-admin.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-approvals.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-apps.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-automation.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-cases.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-functions.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-indicators.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-ingest.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-lists.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-playbooks.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/state.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/users.md +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/setup.cfg +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/audit.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/audit_parse.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/common.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/conf.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/conf_ops.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/dashboards.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/datamodels.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/docs_cmd.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/es.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/es_threat_intel.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/hec.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/knowledge.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/kvstore.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/lookups.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/lookups_wiring.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/mcp_cmd.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/rules.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/rules_io.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/rules_schedule.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/search.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_auth.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_deployment.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_tokens.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_topology.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_workloads.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/__init__.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/_ingest_helpers.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/actions.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/admin.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/admin_roles_audit.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/admin_views.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/approvals.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/apps.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/artifacts.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/assets.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/cases.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/containers.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/containers_assign.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/containers_write.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/functions.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/indicators.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/ingest.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/lists.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/notes.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/playbook_runs.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/playbooks.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/playbooks_delete.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/search.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/system.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/vault.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/workbook_templates.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io_blobs.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io_confs.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io_soar.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io_soar_assets.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_types.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/users.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/config.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/guard.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/__init__.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/dynamic_server.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/output_cap.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/prompts.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/resources.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/runner.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/server.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/tools.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/transport.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/output.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/py.typed +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/soar/__init__.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/soar/cimcef.py +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: splunkctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Open-source MCP server and CLI for Splunk Enterprise SIEM and Splunk SOAR.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -36,6 +36,8 @@ Requires-Dist: types-requests; extra == "dev"
|
|
|
36
36
|
Requires-Dist: types-PyYAML; extra == "dev"
|
|
37
37
|
Requires-Dist: types-tabulate; extra == "dev"
|
|
38
38
|
Requires-Dist: types-defusedxml; extra == "dev"
|
|
39
|
+
Provides-Extra: browser
|
|
40
|
+
Requires-Dist: playwright>=1.45; extra == "browser"
|
|
39
41
|
Dynamic: license-file
|
|
40
42
|
|
|
41
43
|
<div align="center">
|
|
@@ -114,6 +116,7 @@ splunkctl rules disable 'My Rule'
|
|
|
114
116
|
# Add and verify Splunk SOAR when needed
|
|
115
117
|
splunkctl config init --soar
|
|
116
118
|
splunkctl soar test
|
|
119
|
+
splunkctl auth login --target siem # browser SAML/MFA login
|
|
117
120
|
splunkctl soar containers list
|
|
118
121
|
```
|
|
119
122
|
|
|
@@ -192,6 +195,7 @@ streamable HTTP configuration.
|
|
|
192
195
|
|---|---|
|
|
193
196
|
| `doctor` | Check connection, authentication, health, and permissions |
|
|
194
197
|
| `config` | Configure profiles and test connectivity |
|
|
198
|
+
| `auth` | Browser SAML login, session status, and logout |
|
|
195
199
|
| `info` | Show server version, operating system, and license details |
|
|
196
200
|
| `search` | Run, export, upload, and manage search jobs |
|
|
197
201
|
| `rules` | Manage detection rules and YAML import/export |
|
|
@@ -74,6 +74,7 @@ splunkctl rules disable 'My Rule'
|
|
|
74
74
|
# Add and verify Splunk SOAR when needed
|
|
75
75
|
splunkctl config init --soar
|
|
76
76
|
splunkctl soar test
|
|
77
|
+
splunkctl auth login --target siem # browser SAML/MFA login
|
|
77
78
|
splunkctl soar containers list
|
|
78
79
|
```
|
|
79
80
|
|
|
@@ -152,6 +153,7 @@ streamable HTTP configuration.
|
|
|
152
153
|
|---|---|
|
|
153
154
|
| `doctor` | Check connection, authentication, health, and permissions |
|
|
154
155
|
| `config` | Configure profiles and test connectivity |
|
|
156
|
+
| `auth` | Browser SAML login, session status, and logout |
|
|
155
157
|
| `info` | Show server version, operating system, and license details |
|
|
156
158
|
| `search` | Run, export, upload, and manage search jobs |
|
|
157
159
|
| `rules` | Manage detection rules and YAML import/export |
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Browser SAML authentication
|
|
2
|
+
|
|
3
|
+
splunkctl can complete Splunk SAML single sign-on (including a second factor)
|
|
4
|
+
in a headed Chromium window and then reuse the resulting product session until
|
|
5
|
+
it expires. The browser handles the identity provider; splunkctl never reads or
|
|
6
|
+
stores TOTP secrets or codes.
|
|
7
|
+
|
|
8
|
+
## How it works
|
|
9
|
+
|
|
10
|
+
1. `splunkctl config init` probes the public login route. When it sees a
|
|
11
|
+
redirect to an external identity provider, it records `auth_mode: browser`
|
|
12
|
+
and stores the product `web_url`.
|
|
13
|
+
2. `splunkctl auth login --target <siem|soar>` opens Chromium at the product
|
|
14
|
+
login route. You complete the IdP prompts, including any TOTP code.
|
|
15
|
+
3. splunkctl extracts only the product session (Splunk `splunkd_<port>` cookie,
|
|
16
|
+
or SOAR `sessionid` + `csrftoken`) and proves it against the product API.
|
|
17
|
+
4. Normal commands reuse the cached session whenever the active profile selects
|
|
18
|
+
browser mode. A missing or expired session never opens a browser; the command
|
|
19
|
+
prints the exact login instruction instead.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
splunkctl auth login --target siem
|
|
25
|
+
splunkctl auth login --target soar
|
|
26
|
+
splunkctl auth status [--target siem|soar|both]
|
|
27
|
+
splunkctl auth logout [--target siem|soar|both]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`login` requires exactly one target; two back-to-back browser flows are easy to
|
|
31
|
+
confuse. `status` reports `missing`, `valid`, `expired`, or `unreachable`.
|
|
32
|
+
`logout` always removes the local session, even if the remote endpoint refuses.
|
|
33
|
+
|
|
34
|
+
## Optional dependency
|
|
35
|
+
|
|
36
|
+
Browser login needs Playwright and Chromium:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install 'splunkctl[browser]'
|
|
40
|
+
python -m playwright install chromium
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Configuration still succeeds without them. `splunkctl auth login` prints the
|
|
44
|
+
exact commands when they are missing.
|
|
45
|
+
|
|
46
|
+
## Session storage and security
|
|
47
|
+
|
|
48
|
+
Sessions are stored under `~/.splunkctl/sessions/<profile>/<target>.json` with
|
|
49
|
+
directory mode `0700` and file mode `0600`. A record holds only the target,
|
|
50
|
+
profile, product origin, product session values, and validation timestamps. It
|
|
51
|
+
never stores passwords, Playwright storage state, identity-provider cookies, or
|
|
52
|
+
TOTP secrets. Session values are redacted from all output.
|
|
53
|
+
|
|
54
|
+
## What is and is not supported
|
|
55
|
+
|
|
56
|
+
Supported: SAML browser login for SIEM and SOAR, one target per login, session
|
|
57
|
+
status and logout, automatic SAML detection, and a reusable Keycloak lab.
|
|
58
|
+
|
|
59
|
+
Not supported in this release: reading or storing TOTP secrets, native Splunk
|
|
60
|
+
Duo or RSA flows, OpenID Connect or OAuth, logging in to both products in one
|
|
61
|
+
command, and a local splunkctl callback redirect.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Keycloak SAML lab
|
|
2
|
+
|
|
3
|
+
The lab runs Keycloak in rootless Podman as an external identity provider for
|
|
4
|
+
the Splunk SIEM and SOAR VMs. It lets you exercise the browser SAML flow,
|
|
5
|
+
including manual TOTP entry, without giving splunkctl access to the TOTP secret.
|
|
6
|
+
|
|
7
|
+
See `lab/keycloak/README.md` in the source tree for topology, setup, reset, and
|
|
8
|
+
recovery. The short version:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
cd lab/keycloak
|
|
12
|
+
cp .env.example .env # set the two *_PASSWORD secrets
|
|
13
|
+
./labctl.sh start
|
|
14
|
+
./labctl.sh configure
|
|
15
|
+
./labctl.sh verify
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then run `splunkctl config init` against a product that uses SAML, or configure
|
|
19
|
+
the products to point at `http://100.65.1.1:8080` as their IdP, and log in with
|
|
20
|
+
`splunkctl auth login --target siem`.
|
|
@@ -5,7 +5,7 @@ server to give AI agents — Claude Code, Claude Desktop, Cursor, Windsurf, or
|
|
|
5
5
|
any MCP client — direct access to your Splunk Enterprise SIEM and Splunk SOAR
|
|
6
6
|
instances.
|
|
7
7
|
|
|
8
|
-
The server uses **dynamic tool loading**: instead of exposing all
|
|
8
|
+
The server uses **dynamic tool loading**: instead of exposing all 262 tools
|
|
9
9
|
upfront, it starts with 5 meta-tools and loads group-specific typed tools on
|
|
10
10
|
demand. This keeps the agent's context window small while covering every
|
|
11
11
|
command. Version 0.12 uses MCP Python SDK 2 and publishes tool-list changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Browser SAML authentication — session store, detector, adapters, broker."""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Product session adapters — convert browser cookies into API sessions.
|
|
2
|
+
|
|
3
|
+
SIEM extracts the Splunk Web ``splunkd_<port>`` session value and validates it
|
|
4
|
+
against the management API. SOAR keeps the Django ``sessionid`` and
|
|
5
|
+
``csrftoken`` cookies and validates against ``/rest/version``. Logout is
|
|
6
|
+
best-effort: the local session is always removed even if the remote endpoint
|
|
7
|
+
rejects the request.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Literal, Protocol
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from splunkctl.auth.session import Target
|
|
17
|
+
|
|
18
|
+
type ValidationStatus = Literal["valid", "expired", "unreachable"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExtractError(RuntimeError):
|
|
22
|
+
"""Browser cookies did not contain the expected product session values."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SessionAdapter(Protocol):
|
|
26
|
+
"""A product's cookie->session mapping plus validation and logout."""
|
|
27
|
+
|
|
28
|
+
target: Target
|
|
29
|
+
|
|
30
|
+
def extract(self, cookies: dict[str, str]) -> dict[str, str]:
|
|
31
|
+
"""Map browser cookies to the product's API session values."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def validate(
|
|
35
|
+
self, values: dict[str, str], *, api_base: str, verify: bool, timeout: int
|
|
36
|
+
) -> ValidationStatus:
|
|
37
|
+
"""Check ``values`` and return their current validation status."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def logout(
|
|
41
|
+
self, values: dict[str, str], *, web_url: str, verify: bool, timeout: int
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Best-effort remote logout for the given session values."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SIEMAdapter:
|
|
48
|
+
"""Splunk Enterprise SIEM adapter."""
|
|
49
|
+
|
|
50
|
+
target: Target = "siem"
|
|
51
|
+
|
|
52
|
+
def extract(self, cookies: dict[str, str]) -> dict[str, str]:
|
|
53
|
+
"""Extract the Splunk session key from the ``splunkd_*`` cookie."""
|
|
54
|
+
for name, value in cookies.items():
|
|
55
|
+
if name.startswith("splunkd_"):
|
|
56
|
+
return {"session_key": value, "cookie": name}
|
|
57
|
+
raise ExtractError(
|
|
58
|
+
"SIEM browser login did not produce a Splunk session cookie "
|
|
59
|
+
"(splunkd_<port>). The identity-provider flow may not have "
|
|
60
|
+
"completed."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def validate(
|
|
64
|
+
self, values: dict[str, str], *, api_base: str, verify: bool, timeout: int
|
|
65
|
+
) -> ValidationStatus:
|
|
66
|
+
"""Validate the session key against the management API."""
|
|
67
|
+
try:
|
|
68
|
+
resp = requests.get(
|
|
69
|
+
f"{api_base.rstrip('/')}/services/server/info",
|
|
70
|
+
params={"output_mode": "json"},
|
|
71
|
+
headers={"Authorization": f"Splunk {values['session_key']}"},
|
|
72
|
+
verify=verify,
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
)
|
|
75
|
+
except (requests.RequestException, OSError):
|
|
76
|
+
return "unreachable"
|
|
77
|
+
if resp.status_code == 200:
|
|
78
|
+
return "valid"
|
|
79
|
+
if resp.status_code in (401, 403):
|
|
80
|
+
return "expired"
|
|
81
|
+
return "unreachable"
|
|
82
|
+
|
|
83
|
+
def logout(
|
|
84
|
+
self, values: dict[str, str], *, web_url: str, verify: bool, timeout: int
|
|
85
|
+
) -> None:
|
|
86
|
+
"""GET the Splunk Web logout route to end the session."""
|
|
87
|
+
requests.get(
|
|
88
|
+
f"{web_url.rstrip('/')}/en-US/account/logout",
|
|
89
|
+
cookies={values["cookie"]: values["session_key"]},
|
|
90
|
+
verify=verify,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SOARAdapter:
|
|
96
|
+
"""Splunk SOAR adapter."""
|
|
97
|
+
|
|
98
|
+
target: Target = "soar"
|
|
99
|
+
|
|
100
|
+
def extract(self, cookies: dict[str, str]) -> dict[str, str]:
|
|
101
|
+
"""Extract the Django ``sessionid`` and ``csrftoken`` cookies."""
|
|
102
|
+
session_id = cookies.get("sessionid")
|
|
103
|
+
csrf = cookies.get("csrftoken")
|
|
104
|
+
if not session_id or not csrf:
|
|
105
|
+
raise ExtractError(
|
|
106
|
+
"SOAR browser login did not produce sessionid and csrftoken "
|
|
107
|
+
"cookies. The identity-provider flow may not have completed."
|
|
108
|
+
)
|
|
109
|
+
return {"sessionid": session_id, "csrftoken": csrf}
|
|
110
|
+
|
|
111
|
+
def validate(
|
|
112
|
+
self, values: dict[str, str], *, api_base: str, verify: bool, timeout: int
|
|
113
|
+
) -> ValidationStatus:
|
|
114
|
+
"""Validate the cookies against ``/rest/version``."""
|
|
115
|
+
try:
|
|
116
|
+
resp = requests.get(
|
|
117
|
+
f"{api_base.rstrip('/')}/rest/version",
|
|
118
|
+
cookies=values,
|
|
119
|
+
verify=verify,
|
|
120
|
+
timeout=timeout,
|
|
121
|
+
)
|
|
122
|
+
except (requests.RequestException, OSError):
|
|
123
|
+
return "unreachable"
|
|
124
|
+
if resp.status_code == 200:
|
|
125
|
+
return "valid"
|
|
126
|
+
if resp.status_code in (401, 403):
|
|
127
|
+
return "expired"
|
|
128
|
+
return "unreachable"
|
|
129
|
+
|
|
130
|
+
def logout(
|
|
131
|
+
self, values: dict[str, str], *, web_url: str, verify: bool, timeout: int
|
|
132
|
+
) -> None:
|
|
133
|
+
"""POST the SOAR logout route to end the session."""
|
|
134
|
+
requests.post(
|
|
135
|
+
f"{web_url.rstrip('/')}/logout",
|
|
136
|
+
cookies=values,
|
|
137
|
+
headers={"X-CSRFToken": values["csrftoken"]},
|
|
138
|
+
verify=verify,
|
|
139
|
+
timeout=timeout,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_ADAPTERS: dict[Target, SessionAdapter] = {
|
|
144
|
+
"siem": SIEMAdapter(),
|
|
145
|
+
"soar": SOARAdapter(),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_adapter(target: Target) -> SessionAdapter:
|
|
150
|
+
"""Return the session adapter for a product target."""
|
|
151
|
+
return _ADAPTERS[target]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Headed-Chromium SAML login via Playwright (lazy import).
|
|
2
|
+
|
|
3
|
+
Playwright and its Chromium build are optional and only imported here. The
|
|
4
|
+
browser context is temporary and isolated: identity-provider cookies are
|
|
5
|
+
discarded when the context closes, and only the final product-origin cookies
|
|
6
|
+
are returned.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BrokerError(RuntimeError):
|
|
18
|
+
"""A browser-login failure with an ``errors.py``-compatible kind."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, kind: str = "auth") -> None: # noqa: D107
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.kind = kind
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sync_playwright() -> Any:
|
|
27
|
+
"""Import the sync Playwright API lazily; raises ImportError when absent."""
|
|
28
|
+
return importlib.import_module("playwright.sync_api").sync_playwright()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def browser_available() -> bool:
|
|
32
|
+
"""True when the Playwright package imports."""
|
|
33
|
+
try:
|
|
34
|
+
_sync_playwright()
|
|
35
|
+
return True
|
|
36
|
+
except ImportError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def install_hint() -> str:
|
|
41
|
+
"""The exact optional install commands, shown when the browser is missing."""
|
|
42
|
+
return (
|
|
43
|
+
"Install the optional browser support with: "
|
|
44
|
+
"pip install 'splunkctl[browser]' && python -m playwright install chromium"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _origin_of(url: str) -> tuple[str, str, int | None]:
|
|
49
|
+
parts = urlsplit(url)
|
|
50
|
+
port = parts.port
|
|
51
|
+
if port is None:
|
|
52
|
+
port = 443 if parts.scheme == "https" else 80
|
|
53
|
+
return parts.scheme, parts.hostname or "", port
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_login(
|
|
57
|
+
*,
|
|
58
|
+
login_url: str,
|
|
59
|
+
expected_origin: str,
|
|
60
|
+
verify: bool,
|
|
61
|
+
timeout: int,
|
|
62
|
+
) -> dict[str, str]:
|
|
63
|
+
"""Open headed Chromium, follow SAML to ``expected_origin``, return cookies.
|
|
64
|
+
|
|
65
|
+
Raises :class:`BrokerError` on timeout, wrong final origin, or early close.
|
|
66
|
+
"""
|
|
67
|
+
if not browser_available():
|
|
68
|
+
raise BrokerError(
|
|
69
|
+
"Playwright or Chromium is not installed. " + install_hint(),
|
|
70
|
+
kind="usage",
|
|
71
|
+
)
|
|
72
|
+
with _sync_playwright() as playwright:
|
|
73
|
+
browser = playwright.chromium.launch(headless=False)
|
|
74
|
+
context = browser.new_context(ignore_https_errors=not verify)
|
|
75
|
+
page = context.new_page()
|
|
76
|
+
deadline = time.monotonic() + timeout
|
|
77
|
+
try:
|
|
78
|
+
page.goto(login_url)
|
|
79
|
+
expected = _origin_of(expected_origin)
|
|
80
|
+
# The login route is often on the product origin and only bounces to
|
|
81
|
+
# the IdP after a JS auto-submit or server redirect. Wait for the
|
|
82
|
+
# browser to leave the product origin first, then wait for it to
|
|
83
|
+
# return once the user completes the identity-provider flow.
|
|
84
|
+
while _origin_of(page.url) == expected:
|
|
85
|
+
if page.is_closed():
|
|
86
|
+
raise BrokerError(
|
|
87
|
+
"The browser was closed before login finished.", kind="auth"
|
|
88
|
+
)
|
|
89
|
+
if time.monotonic() > deadline:
|
|
90
|
+
raise BrokerError(
|
|
91
|
+
f"Timed out waiting for the identity provider to take over "
|
|
92
|
+
f"from {login_url}.",
|
|
93
|
+
kind="timeout",
|
|
94
|
+
)
|
|
95
|
+
page.wait_for_timeout(500)
|
|
96
|
+
while _origin_of(page.url) != expected:
|
|
97
|
+
if page.is_closed():
|
|
98
|
+
raise BrokerError(
|
|
99
|
+
"The browser was closed before login finished.", kind="auth"
|
|
100
|
+
)
|
|
101
|
+
if time.monotonic() > deadline:
|
|
102
|
+
raise BrokerError(
|
|
103
|
+
f"Timed out waiting for login to reach the expected origin "
|
|
104
|
+
f"({expected_origin}).",
|
|
105
|
+
kind="timeout",
|
|
106
|
+
)
|
|
107
|
+
page.wait_for_timeout(500)
|
|
108
|
+
cookies = {}
|
|
109
|
+
for cookie in context.cookies(urls=[expected_origin]):
|
|
110
|
+
if cookie.get("name") and cookie.get("value"):
|
|
111
|
+
cookies[cookie["name"]] = cookie["value"]
|
|
112
|
+
return cookies
|
|
113
|
+
finally:
|
|
114
|
+
context.close()
|
|
115
|
+
browser.close()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Public login-route classification for SAML vs password authentication.
|
|
2
|
+
|
|
3
|
+
The detector only reads public login pages; it never uses stored credentials
|
|
4
|
+
and never treats an ordinary HTTP 401 or a local login form as MFA. A 3xx to a
|
|
5
|
+
different host is the reliable SAML signal (the identity provider is external);
|
|
6
|
+
SAML/SSO text markers are a fallback for providers that serve a local
|
|
7
|
+
interstitial before redirecting.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import Literal
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
type AuthMode = Literal["password", "browser", "unknown"]
|
|
20
|
+
|
|
21
|
+
_REDIRECT = (301, 302, 303, 307, 308)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _different_host(location: str, login_url: str) -> bool:
|
|
25
|
+
"""True when a redirect Location targets a different host than login_url.
|
|
26
|
+
|
|
27
|
+
A relative or protocol-relative Location stays on the login host and is
|
|
28
|
+
not an external-IdP signal; a scheme/port change on the same host is
|
|
29
|
+
likewise not a different host.
|
|
30
|
+
"""
|
|
31
|
+
host = urlsplit(location).hostname
|
|
32
|
+
if host is None:
|
|
33
|
+
return False
|
|
34
|
+
return host != urlsplit(login_url).hostname
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def classify(
|
|
38
|
+
*,
|
|
39
|
+
login_url: str,
|
|
40
|
+
status: int,
|
|
41
|
+
headers: Mapping[str, str],
|
|
42
|
+
body: str,
|
|
43
|
+
) -> AuthMode:
|
|
44
|
+
"""Classify a login-route response without credentials."""
|
|
45
|
+
if status in _REDIRECT:
|
|
46
|
+
location = headers.get("Location", headers.get("location", ""))
|
|
47
|
+
return (
|
|
48
|
+
"browser"
|
|
49
|
+
if location and _different_host(location, login_url)
|
|
50
|
+
else "password"
|
|
51
|
+
)
|
|
52
|
+
if status == 401:
|
|
53
|
+
return "password"
|
|
54
|
+
lowered = body.lower()
|
|
55
|
+
if any(
|
|
56
|
+
marker in lowered for marker in ("saml", "single sign-on", "sign in with sso")
|
|
57
|
+
):
|
|
58
|
+
return "browser"
|
|
59
|
+
if (
|
|
60
|
+
'type="password"' in lowered
|
|
61
|
+
or 'name="password"' in lowered
|
|
62
|
+
or "name='password'" in lowered
|
|
63
|
+
):
|
|
64
|
+
return "password"
|
|
65
|
+
return "unknown"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def probe(login_url: str, *, verify: bool, timeout: int) -> AuthMode:
|
|
69
|
+
"""GET the login route without following redirects and classify it."""
|
|
70
|
+
try:
|
|
71
|
+
resp = requests.get(
|
|
72
|
+
login_url, allow_redirects=False, verify=verify, timeout=timeout
|
|
73
|
+
)
|
|
74
|
+
except requests.RequestException:
|
|
75
|
+
return "unknown"
|
|
76
|
+
return classify(
|
|
77
|
+
login_url=login_url,
|
|
78
|
+
status=resp.status_code,
|
|
79
|
+
headers=dict(resp.headers),
|
|
80
|
+
body=resp.text,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def siem_login_url(web_url: str) -> str:
|
|
85
|
+
"""Splunk Web login route (SAML auto-redirects when configured)."""
|
|
86
|
+
return f"{web_url.rstrip('/')}/en-US/account/login"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def soar_login_url(web_url: str) -> str:
|
|
90
|
+
"""SOAR Django login route (SAML auto-redirects when configured)."""
|
|
91
|
+
return f"{web_url.rstrip('/')}/login"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_SAML_SSO_SUFFIX = "/protocol/saml"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def siem_idp_issuer(web_url: str, *, verify: bool, timeout: int) -> str | None:
|
|
98
|
+
"""Return the SAML IdP issuer the SIEM is configured against, or ``None``.
|
|
99
|
+
|
|
100
|
+
Splunk's SAML login route renders a form that auto-submits to the IdP (its
|
|
101
|
+
``action`` is the IdP SSO URL); strip the ``/protocol/saml`` suffix to get
|
|
102
|
+
the issuer. Used to derive another product's SSO URL from the same IdP.
|
|
103
|
+
Returns ``None`` when the login route is unreachable, not SAML, or does not
|
|
104
|
+
reveal an issuer.
|
|
105
|
+
"""
|
|
106
|
+
login_url = siem_login_url(web_url)
|
|
107
|
+
try:
|
|
108
|
+
resp = requests.get(
|
|
109
|
+
login_url, allow_redirects=False, verify=verify, timeout=timeout
|
|
110
|
+
)
|
|
111
|
+
except requests.RequestException:
|
|
112
|
+
return None
|
|
113
|
+
if resp.status_code in _REDIRECT:
|
|
114
|
+
target = resp.headers.get("Location", "")
|
|
115
|
+
else:
|
|
116
|
+
match = re.search(r'action="([^"]*)"', resp.text)
|
|
117
|
+
target = match.group(1) if match else ""
|
|
118
|
+
if _SAML_SSO_SUFFIX not in target:
|
|
119
|
+
return None
|
|
120
|
+
return target.split(_SAML_SSO_SUFFIX, 1)[0]
|