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.
Files changed (151) hide show
  1. {splunkctl-0.12.0/splunkctl.egg-info → splunkctl-0.13.0}/PKG-INFO +5 -1
  2. {splunkctl-0.12.0 → splunkctl-0.13.0}/README.md +2 -0
  3. splunkctl-0.13.0/docs/guides/auth.md +61 -0
  4. splunkctl-0.13.0/docs/guides/keycloak-lab.md +20 -0
  5. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/mcp.md +1 -1
  6. {splunkctl-0.12.0 → splunkctl-0.13.0}/pyproject.toml +3 -0
  7. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/__init__.py +1 -1
  8. splunkctl-0.13.0/splunkctl/auth/__init__.py +1 -0
  9. splunkctl-0.13.0/splunkctl/auth/adapters.py +151 -0
  10. splunkctl-0.13.0/splunkctl/auth/broker.py +115 -0
  11. splunkctl-0.13.0/splunkctl/auth/detector.py +120 -0
  12. splunkctl-0.13.0/splunkctl/auth/session.py +204 -0
  13. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/client.py +62 -5
  14. splunkctl-0.13.0/splunkctl/commands/auth.py +198 -0
  15. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/config_cmd.py +107 -7
  16. splunkctl-0.13.0/splunkctl/commands/soar/_client.py +81 -0
  17. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/errors.py +7 -0
  18. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/main.py +2 -0
  19. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/soar/client.py +11 -0
  20. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/soar/web.py +11 -0
  21. {splunkctl-0.12.0 → splunkctl-0.13.0/splunkctl.egg-info}/PKG-INFO +5 -1
  22. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/SOURCES.txt +8 -0
  23. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/requires.txt +3 -0
  24. splunkctl-0.12.0/splunkctl/commands/soar/_client.py +0 -44
  25. {splunkctl-0.12.0 → splunkctl-0.13.0}/LICENSE +0 -0
  26. {splunkctl-0.12.0 → splunkctl-0.13.0}/MANIFEST.in +0 -0
  27. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/alerts.md +0 -0
  28. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/apps.md +0 -0
  29. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/audit.md +0 -0
  30. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/conf.md +0 -0
  31. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/configure.md +0 -0
  32. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/dashboards.md +0 -0
  33. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/datamodels.md +0 -0
  34. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/doctor.md +0 -0
  35. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/es.md +0 -0
  36. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/exit-codes.md +0 -0
  37. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/hec.md +0 -0
  38. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/indexes.md +0 -0
  39. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/inputs.md +0 -0
  40. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/install.md +0 -0
  41. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/knowledge.md +0 -0
  42. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/kvstore.md +0 -0
  43. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/lookups.md +0 -0
  44. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/parsers.md +0 -0
  45. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/rules.md +0 -0
  46. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/search.md +0 -0
  47. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/server.md +0 -0
  48. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-actions.md +0 -0
  49. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-admin.md +0 -0
  50. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-approvals.md +0 -0
  51. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-apps.md +0 -0
  52. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-automation.md +0 -0
  53. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-cases.md +0 -0
  54. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-functions.md +0 -0
  55. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-indicators.md +0 -0
  56. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-ingest.md +0 -0
  57. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-lists.md +0 -0
  58. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar-playbooks.md +0 -0
  59. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/soar.md +0 -0
  60. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/state.md +0 -0
  61. {splunkctl-0.12.0 → splunkctl-0.13.0}/docs/guides/users.md +0 -0
  62. {splunkctl-0.12.0 → splunkctl-0.13.0}/setup.cfg +0 -0
  63. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/__main__.py +0 -0
  64. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/__init__.py +0 -0
  65. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/alerts.py +0 -0
  66. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/apps.py +0 -0
  67. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/audit.py +0 -0
  68. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/audit_parse.py +0 -0
  69. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/commands_meta.py +0 -0
  70. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/common.py +0 -0
  71. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/conf.py +0 -0
  72. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/conf_ops.py +0 -0
  73. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/dashboards.py +0 -0
  74. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/datamodels.py +0 -0
  75. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/docs_cmd.py +0 -0
  76. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/doctor.py +0 -0
  77. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/es.py +0 -0
  78. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/es_threat_intel.py +0 -0
  79. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/hec.py +0 -0
  80. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/indexes.py +0 -0
  81. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/info.py +0 -0
  82. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/inputs.py +0 -0
  83. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/knowledge.py +0 -0
  84. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/kvstore.py +0 -0
  85. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/lookups.py +0 -0
  86. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/lookups_wiring.py +0 -0
  87. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/mcp_cmd.py +0 -0
  88. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/parsers.py +0 -0
  89. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/parsers_io.py +0 -0
  90. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/rules.py +0 -0
  91. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/rules_io.py +0 -0
  92. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/rules_schedule.py +0 -0
  93. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/search.py +0 -0
  94. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server.py +0 -0
  95. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_auth.py +0 -0
  96. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_deployment.py +0 -0
  97. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_tokens.py +0 -0
  98. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_topology.py +0 -0
  99. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/server_workloads.py +0 -0
  100. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/__init__.py +0 -0
  101. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/_ingest_helpers.py +0 -0
  102. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/actions.py +0 -0
  103. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/admin.py +0 -0
  104. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/admin_roles_audit.py +0 -0
  105. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/admin_views.py +0 -0
  106. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/approvals.py +0 -0
  107. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/apps.py +0 -0
  108. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/artifacts.py +0 -0
  109. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/assets.py +0 -0
  110. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/cases.py +0 -0
  111. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/containers.py +0 -0
  112. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/containers_assign.py +0 -0
  113. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/containers_write.py +0 -0
  114. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/functions.py +0 -0
  115. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/indicators.py +0 -0
  116. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/ingest.py +0 -0
  117. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/lists.py +0 -0
  118. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/notes.py +0 -0
  119. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/playbook_runs.py +0 -0
  120. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/playbooks.py +0 -0
  121. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/playbooks_delete.py +0 -0
  122. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/search.py +0 -0
  123. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/system.py +0 -0
  124. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/vault.py +0 -0
  125. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/soar/workbook_templates.py +0 -0
  126. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state.py +0 -0
  127. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io.py +0 -0
  128. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io_blobs.py +0 -0
  129. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io_confs.py +0 -0
  130. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io_soar.py +0 -0
  131. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_io_soar_assets.py +0 -0
  132. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/state_types.py +0 -0
  133. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/commands/users.py +0 -0
  134. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/config.py +0 -0
  135. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/guard.py +0 -0
  136. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/__init__.py +0 -0
  137. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/dynamic_server.py +0 -0
  138. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/output_cap.py +0 -0
  139. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/prompts.py +0 -0
  140. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/resources.py +0 -0
  141. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/runner.py +0 -0
  142. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/server.py +0 -0
  143. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/tools.py +0 -0
  144. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/mcp/transport.py +0 -0
  145. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/output.py +0 -0
  146. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/py.typed +0 -0
  147. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/soar/__init__.py +0 -0
  148. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl/soar/cimcef.py +0 -0
  149. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  150. {splunkctl-0.12.0 → splunkctl-0.13.0}/splunkctl.egg-info/entry_points.txt +0 -0
  151. {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.12.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 259 tools
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
@@ -42,6 +42,9 @@ dev = [
42
42
  "types-tabulate",
43
43
  "types-defusedxml",
44
44
  ]
45
+ browser = [
46
+ "playwright>=1.45",
47
+ ]
45
48
 
46
49
  [project.scripts]
47
50
  splunkctl = "splunkctl.main:cli"
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise and Splunk SOAR operations."""
2
2
 
3
- __version__ = "0.12.0"
3
+ __version__ = "0.13.0"
@@ -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]