splunkctl 0.2.0__tar.gz → 0.3.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.2.0 → splunkctl-0.3.0}/PKG-INFO +12 -2
- {splunkctl-0.2.0 → splunkctl-0.3.0}/pyproject.toml +14 -1
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/__init__.py +1 -1
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/client.py +104 -105
- splunkctl-0.3.0/splunkctl/commands/alerts.py +131 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/apps.py +4 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/commands_meta.py +8 -0
- splunkctl-0.3.0/splunkctl/commands/common.py +161 -0
- splunkctl-0.3.0/splunkctl/commands/dashboards.py +467 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/doctor.py +69 -7
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/hec.py +113 -10
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/indexes.py +29 -4
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/inputs.py +5 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/lookups.py +19 -4
- splunkctl-0.3.0/splunkctl/commands/parsers.py +380 -0
- splunkctl-0.3.0/splunkctl/commands/parsers_io.py +228 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/rules.py +140 -11
- splunkctl-0.3.0/splunkctl/commands/rules_io.py +302 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/search.py +75 -14
- splunkctl-0.3.0/splunkctl/commands/server.py +107 -0
- splunkctl-0.3.0/splunkctl/commands/users.py +430 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/guard.py +14 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/main.py +32 -2
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/output.py +47 -4
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/skill/SKILL.md +57 -12
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/PKG-INFO +12 -2
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/SOURCES.txt +4 -0
- splunkctl-0.3.0/splunkctl.egg-info/requires.txt +15 -0
- splunkctl-0.3.0/tests/test_client.py +199 -0
- splunkctl-0.3.0/tests/test_main_hoisting.py +60 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/tests/test_output.py +70 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/tests/test_version.py +1 -1
- splunkctl-0.2.0/splunkctl/commands/alerts.py +0 -94
- splunkctl-0.2.0/splunkctl/commands/dashboards.py +0 -203
- splunkctl-0.2.0/splunkctl/commands/parsers.py +0 -177
- splunkctl-0.2.0/splunkctl/commands/rules_io.py +0 -222
- splunkctl-0.2.0/splunkctl/commands/users.py +0 -245
- splunkctl-0.2.0/splunkctl.egg-info/requires.txt +0 -4
- splunkctl-0.2.0/tests/test_client.py +0 -56
- {splunkctl-0.2.0 → splunkctl-0.3.0}/LICENSE +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/README.md +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/setup.cfg +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/config_cmd.py +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/skill_cmd.py +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/config.py +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/tests/test_config.py +0 -0
- {splunkctl-0.2.0 → splunkctl-0.3.0}/tests/test_guard.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: splunkctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI tool for Splunk Enterprise SIEM operations.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -20,9 +20,19 @@ Requires-Python: >=3.13
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
Requires-Dist: splunk-sdk>=2.0
|
|
23
|
-
Requires-Dist: click>=8.
|
|
23
|
+
Requires-Dist: click>=8.2
|
|
24
24
|
Requires-Dist: pyyaml>=6.0
|
|
25
25
|
Requires-Dist: tabulate>=0.9
|
|
26
|
+
Requires-Dist: requests>=2.32
|
|
27
|
+
Requires-Dist: defusedxml>=0.7
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
32
|
+
Requires-Dist: types-requests; extra == "dev"
|
|
33
|
+
Requires-Dist: types-PyYAML; extra == "dev"
|
|
34
|
+
Requires-Dist: types-tabulate; extra == "dev"
|
|
35
|
+
Requires-Dist: types-defusedxml; extra == "dev"
|
|
26
36
|
Dynamic: license-file
|
|
27
37
|
|
|
28
38
|
# splunkctl
|
|
@@ -24,9 +24,22 @@ classifiers = [
|
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
26
|
"splunk-sdk>=2.0",
|
|
27
|
-
"click>=8.
|
|
27
|
+
"click>=8.2",
|
|
28
28
|
"pyyaml>=6.0",
|
|
29
29
|
"tabulate>=0.9",
|
|
30
|
+
"requests>=2.32",
|
|
31
|
+
"defusedxml>=0.7",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"ruff>=0.6",
|
|
38
|
+
"mypy>=1.11",
|
|
39
|
+
"types-requests",
|
|
40
|
+
"types-PyYAML",
|
|
41
|
+
"types-tabulate",
|
|
42
|
+
"types-defusedxml",
|
|
30
43
|
]
|
|
31
44
|
|
|
32
45
|
[project.scripts]
|
|
@@ -6,17 +6,13 @@ handle remotely (e.g. lookup file upload requires server-side staging).
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
import http.cookiejar
|
|
10
|
-
import json
|
|
11
9
|
import re
|
|
12
|
-
import ssl
|
|
13
10
|
import urllib.parse
|
|
14
|
-
import urllib.request
|
|
15
|
-
import uuid
|
|
16
11
|
from pathlib import Path
|
|
17
12
|
from typing import Any
|
|
18
13
|
|
|
19
14
|
import click
|
|
15
|
+
import requests
|
|
20
16
|
import splunklib.client as splunk_client
|
|
21
17
|
|
|
22
18
|
from splunkctl import config as cfg_mod
|
|
@@ -111,7 +107,10 @@ class SplunkClient:
|
|
|
111
107
|
cfg = cfg_mod.load(self._config_path)
|
|
112
108
|
cfg.update(self._overrides)
|
|
113
109
|
self._web_session = _WebSession(
|
|
114
|
-
self.service,
|
|
110
|
+
self.service,
|
|
111
|
+
verify=bool(cfg.get("verify", False)),
|
|
112
|
+
debug=self._debug,
|
|
113
|
+
timeout=self._timeout or 30,
|
|
115
114
|
)
|
|
116
115
|
return self._web_session
|
|
117
116
|
|
|
@@ -137,11 +136,36 @@ class SplunkClient:
|
|
|
137
136
|
"""Install a .spl/.tar.gz app package via the Splunk Web UI."""
|
|
138
137
|
self._ensure_web_session().install_app(file_path, force=force)
|
|
139
138
|
|
|
139
|
+
def set_acl(self, entity: Any, *, sharing: str, owner: str | None = None) -> None:
|
|
140
|
+
"""Change an entity's sharing level via its ACL endpoint.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
entity: Any SDK entity (saved search, dashboard, conf stanza...).
|
|
144
|
+
sharing: One of user, app, global.
|
|
145
|
+
owner: Defaults to the entity's current owner, else "nobody".
|
|
146
|
+
"""
|
|
147
|
+
acl: dict[str, Any] = dict(entity.access)
|
|
148
|
+
entity.acl_update(sharing=sharing, owner=owner or acl.get("owner", "nobody"))
|
|
149
|
+
|
|
140
150
|
|
|
141
151
|
class _WebSession:
|
|
142
|
-
"""
|
|
152
|
+
"""Authenticated Splunk Web session for form-handler operations.
|
|
153
|
+
|
|
154
|
+
Uses ``requests`` deliberately: the manager form handlers answer a
|
|
155
|
+
plain urllib POST with a 303 that dead-ends in a 404, while a
|
|
156
|
+
keep-alive session receives the JSON result directly (verified
|
|
157
|
+
against Splunk 10.4). TLS verification is on unless the user's
|
|
158
|
+
config explicitly disables it for self-signed dev instances.
|
|
159
|
+
"""
|
|
143
160
|
|
|
144
|
-
def __init__(
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
service: Any,
|
|
164
|
+
*,
|
|
165
|
+
verify: bool = True,
|
|
166
|
+
debug: bool = False,
|
|
167
|
+
timeout: int = 30,
|
|
168
|
+
) -> None:
|
|
145
169
|
self._host: str = service.host
|
|
146
170
|
self._username: str = service.username
|
|
147
171
|
self._password: str = service.password
|
|
@@ -155,15 +179,10 @@ class _WebSession:
|
|
|
155
179
|
self._web_port = int(web_conf["httpport"])
|
|
156
180
|
self._web_ssl = str(web_conf.content.get("enableSplunkWebSSL", "0")) == "1"
|
|
157
181
|
|
|
158
|
-
self.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
ctx.verify_mode = ssl.CERT_NONE # noqa: S501
|
|
163
|
-
self._opener = urllib.request.build_opener(
|
|
164
|
-
urllib.request.HTTPCookieProcessor(self._cookies),
|
|
165
|
-
urllib.request.HTTPSHandler(context=ctx),
|
|
166
|
-
)
|
|
182
|
+
self._debug = debug
|
|
183
|
+
self._timeout = timeout
|
|
184
|
+
self._session = requests.Session()
|
|
185
|
+
self._session.verify = verify
|
|
167
186
|
self._csrf_token: str | None = None
|
|
168
187
|
self._logged_in = False
|
|
169
188
|
|
|
@@ -172,80 +191,47 @@ class _WebSession:
|
|
|
172
191
|
scheme = "https" if self._web_ssl else "http"
|
|
173
192
|
return f"{scheme}://{self._host}:{self._web_port}"
|
|
174
193
|
|
|
194
|
+
def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
|
|
195
|
+
resp = self._session.request(method, url, timeout=self._timeout, **kwargs)
|
|
196
|
+
if self._debug:
|
|
197
|
+
click.echo(f"web {method} {url} -> {resp.status_code}", err=True)
|
|
198
|
+
return resp
|
|
199
|
+
|
|
175
200
|
def _login(self) -> None:
|
|
176
201
|
"""Authenticate to Splunk Web and obtain session cookies."""
|
|
177
202
|
login_url = f"{self._base_url}/en-US/account/login"
|
|
178
|
-
|
|
179
|
-
page = resp.read().decode("utf-8")
|
|
203
|
+
page = self._request("GET", login_url).text
|
|
180
204
|
|
|
181
205
|
m = re.search(r'"cval"\s*:\s*(\d+)', page)
|
|
182
206
|
cval = m.group(1) if m else "0"
|
|
183
207
|
|
|
184
|
-
|
|
185
|
-
|
|
208
|
+
resp = self._request(
|
|
209
|
+
"POST",
|
|
210
|
+
login_url,
|
|
211
|
+
data={
|
|
186
212
|
"username": self._username,
|
|
187
213
|
"password": self._password,
|
|
188
214
|
"cval": cval,
|
|
189
|
-
}
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
try:
|
|
218
|
+
body: dict[str, Any] = resp.json()
|
|
219
|
+
except ValueError:
|
|
220
|
+
raise RuntimeError(
|
|
221
|
+
f"Splunk Web login failed: HTTP {resp.status_code}"
|
|
222
|
+
) from None
|
|
193
223
|
if body.get("status") == "fail":
|
|
194
224
|
msg = body.get("msg", "unknown error")
|
|
195
225
|
raise RuntimeError(f"Splunk Web login failed: {msg}")
|
|
196
226
|
|
|
197
|
-
for cookie in self.
|
|
198
|
-
if cookie.name.startswith("splunkweb_csrf_token"):
|
|
227
|
+
for cookie in self._session.cookies:
|
|
228
|
+
if cookie.name and cookie.name.startswith("splunkweb_csrf_token"):
|
|
199
229
|
self._csrf_token = cookie.value
|
|
200
230
|
break
|
|
201
231
|
if not self._csrf_token:
|
|
202
232
|
raise RuntimeError("Could not obtain CSRF token from Splunk Web")
|
|
203
233
|
self._logged_in = True
|
|
204
234
|
|
|
205
|
-
def _multipart_post(
|
|
206
|
-
self,
|
|
207
|
-
url: str,
|
|
208
|
-
fields: list[tuple[str, str]],
|
|
209
|
-
file_field: str,
|
|
210
|
-
file_name: str,
|
|
211
|
-
file_data: bytes,
|
|
212
|
-
content_type: str = "application/octet-stream",
|
|
213
|
-
) -> bytes:
|
|
214
|
-
"""Build and send a multipart/form-data POST."""
|
|
215
|
-
if not self._logged_in:
|
|
216
|
-
self._login()
|
|
217
|
-
|
|
218
|
-
boundary = uuid.uuid4().hex
|
|
219
|
-
body = b""
|
|
220
|
-
for field_name, value in fields:
|
|
221
|
-
body += f"--{boundary}\r\n".encode()
|
|
222
|
-
body += (
|
|
223
|
-
f'Content-Disposition: form-data; name="{field_name}"\r\n'
|
|
224
|
-
f"\r\n"
|
|
225
|
-
f"{value}\r\n"
|
|
226
|
-
).encode()
|
|
227
|
-
|
|
228
|
-
body += f"--{boundary}\r\n".encode()
|
|
229
|
-
body += (
|
|
230
|
-
f'Content-Disposition: form-data; name="{file_field}";'
|
|
231
|
-
f' filename="{file_name}"\r\n'
|
|
232
|
-
f"Content-Type: {content_type}\r\n"
|
|
233
|
-
f"\r\n"
|
|
234
|
-
).encode()
|
|
235
|
-
body += file_data
|
|
236
|
-
body += f"\r\n--{boundary}--\r\n".encode()
|
|
237
|
-
|
|
238
|
-
req = urllib.request.Request( # noqa: S310
|
|
239
|
-
url,
|
|
240
|
-
data=body,
|
|
241
|
-
headers={
|
|
242
|
-
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
243
|
-
},
|
|
244
|
-
method="POST",
|
|
245
|
-
)
|
|
246
|
-
resp = self._opener.open(req) # noqa: S310
|
|
247
|
-
return bytes(resp.read())
|
|
248
|
-
|
|
249
235
|
def upload_lookup(
|
|
250
236
|
self,
|
|
251
237
|
name: str,
|
|
@@ -255,6 +241,9 @@ class _WebSession:
|
|
|
255
241
|
update: bool = False,
|
|
256
242
|
) -> None:
|
|
257
243
|
"""Upload a lookup file via multipart form POST."""
|
|
244
|
+
if not self._logged_in:
|
|
245
|
+
self._login()
|
|
246
|
+
|
|
258
247
|
if update:
|
|
259
248
|
url = (
|
|
260
249
|
f"{self._base_url}/en-US/manager/{app}"
|
|
@@ -265,27 +254,22 @@ class _WebSession:
|
|
|
265
254
|
url = f"{self._base_url}/en-US/manager/{app}/data/lookup-table-files/_new"
|
|
266
255
|
action = "new"
|
|
267
256
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
257
|
+
data: dict[str, str] = {
|
|
258
|
+
"__action": action,
|
|
259
|
+
"__redirect": "",
|
|
260
|
+
"__ns": app,
|
|
261
|
+
"splunk_form_key": self._csrf_token or "",
|
|
262
|
+
}
|
|
274
263
|
if not update:
|
|
275
|
-
|
|
264
|
+
data["name"] = name
|
|
276
265
|
|
|
277
|
-
|
|
266
|
+
resp = self._request(
|
|
267
|
+
"POST",
|
|
278
268
|
url,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
file_name=name,
|
|
282
|
-
file_data=file_path.read_bytes(),
|
|
283
|
-
content_type="text/csv",
|
|
269
|
+
data=data,
|
|
270
|
+
files={"spl-ctrl_lookupfile": (name, file_path.read_bytes(), "text/csv")},
|
|
284
271
|
)
|
|
285
|
-
|
|
286
|
-
if result.get("status") != "OK":
|
|
287
|
-
msg = result.get("msg", "unknown error")
|
|
288
|
-
raise RuntimeError(f"Lookup upload failed: {msg}")
|
|
272
|
+
_expect_ok(resp, "Lookup upload")
|
|
289
273
|
|
|
290
274
|
def install_app(
|
|
291
275
|
self,
|
|
@@ -298,30 +282,45 @@ class _WebSession:
|
|
|
298
282
|
self._login()
|
|
299
283
|
|
|
300
284
|
upload_url = f"{self._base_url}/en-US/manager/appinstall/_upload"
|
|
301
|
-
|
|
302
|
-
page = resp.read().decode("utf-8")
|
|
285
|
+
page = self._request("GET", upload_url).text
|
|
303
286
|
|
|
304
|
-
m = re.search(
|
|
305
|
-
r'name="state"\s+[^>]*value="([^"]*)"',
|
|
306
|
-
page,
|
|
307
|
-
)
|
|
287
|
+
m = re.search(r'name="state"\s+[^>]*value="([^"]*)"', page)
|
|
308
288
|
state = m.group(1) if m else ""
|
|
309
289
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
290
|
+
data: dict[str, str] = {
|
|
291
|
+
"state": state,
|
|
292
|
+
"splunk_form_key": self._csrf_token or "",
|
|
293
|
+
}
|
|
314
294
|
if force:
|
|
315
|
-
|
|
295
|
+
data["force"] = "1"
|
|
316
296
|
|
|
317
|
-
self.
|
|
297
|
+
resp = self._request(
|
|
298
|
+
"POST",
|
|
318
299
|
upload_url,
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
300
|
+
data=data,
|
|
301
|
+
files={
|
|
302
|
+
"appfile": (
|
|
303
|
+
file_path.name,
|
|
304
|
+
file_path.read_bytes(),
|
|
305
|
+
"application/gzip",
|
|
306
|
+
)
|
|
307
|
+
},
|
|
324
308
|
)
|
|
309
|
+
if resp.status_code >= 400:
|
|
310
|
+
raise RuntimeError(f"App install failed: HTTP {resp.status_code}")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _expect_ok(resp: requests.Response, what: str) -> None:
|
|
314
|
+
"""Raise unless the form handler answered with JSON status OK."""
|
|
315
|
+
try:
|
|
316
|
+
result: dict[str, Any] = resp.json()
|
|
317
|
+
except ValueError:
|
|
318
|
+
raise RuntimeError(
|
|
319
|
+
f"{what} failed: HTTP {resp.status_code} — "
|
|
320
|
+
f"unexpected response: {resp.text[:200]!r}"
|
|
321
|
+
) from None
|
|
322
|
+
if result.get("status") != "OK":
|
|
323
|
+
raise RuntimeError(f"{what} failed: {result.get('msg', 'unknown error')}")
|
|
325
324
|
|
|
326
325
|
|
|
327
326
|
def get_client(ctx: click.Context) -> SplunkClient:
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Alerts commands — fired alerts, alert actions, suppression."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from splunkctl import guard, output
|
|
8
|
+
from splunkctl.client import get_client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _firing_rows(group: Any) -> list[dict[str, Any]]:
|
|
12
|
+
"""One row per triggered alert in a fired-alert group."""
|
|
13
|
+
rows: list[dict[str, Any]] = []
|
|
14
|
+
for firing in group.alerts:
|
|
15
|
+
c: dict[str, Any] = dict(firing.content)
|
|
16
|
+
rows.append(
|
|
17
|
+
{
|
|
18
|
+
"rule": group.name,
|
|
19
|
+
"triggered": c.get("trigger_time_rendered", c.get("trigger_time", "")),
|
|
20
|
+
"severity": c.get("severity", ""),
|
|
21
|
+
"sid": c.get("sid", ""),
|
|
22
|
+
"actions": c.get("actions", ""),
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
return rows
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group("alerts")
|
|
29
|
+
def alerts_group() -> None:
|
|
30
|
+
"""Manage fired alerts and alert actions."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@alerts_group.command("list")
|
|
34
|
+
@click.pass_context
|
|
35
|
+
def list_alerts(ctx: click.Context) -> None:
|
|
36
|
+
"""List fired alerts (one row per firing, with sid for drill-down)."""
|
|
37
|
+
client = get_client(ctx)
|
|
38
|
+
rows: list[dict[str, Any]] = []
|
|
39
|
+
for group in client.service.fired_alerts:
|
|
40
|
+
if group.name == "-":
|
|
41
|
+
continue
|
|
42
|
+
rows.extend(_firing_rows(group))
|
|
43
|
+
output.render(ctx, rows, empty="No fired alerts.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@alerts_group.command("get")
|
|
47
|
+
@click.argument("name")
|
|
48
|
+
@click.pass_context
|
|
49
|
+
def get_alert(ctx: click.Context, name: str) -> None:
|
|
50
|
+
"""Get every firing of a fired-alert group."""
|
|
51
|
+
client = get_client(ctx)
|
|
52
|
+
for group in client.service.fired_alerts:
|
|
53
|
+
if group.name == name:
|
|
54
|
+
rows = _firing_rows(group)
|
|
55
|
+
output.info(f"{name}: {len(rows)} firing(s)")
|
|
56
|
+
output.render(ctx, rows)
|
|
57
|
+
return
|
|
58
|
+
output.error(f"Fired alert not found: {name}")
|
|
59
|
+
ctx.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@alerts_group.command("actions")
|
|
63
|
+
@click.pass_context
|
|
64
|
+
def list_actions(ctx: click.Context) -> None:
|
|
65
|
+
"""List available alert action types."""
|
|
66
|
+
client = get_client(ctx)
|
|
67
|
+
rows: list[dict[str, Any]] = []
|
|
68
|
+
for stanza in client.service.confs["alert_actions"]:
|
|
69
|
+
content: dict[str, Any] = dict(stanza.content)
|
|
70
|
+
rows.append(
|
|
71
|
+
{
|
|
72
|
+
"name": stanza.name,
|
|
73
|
+
"label": content.get("label", ""),
|
|
74
|
+
"description": content.get("description", ""),
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
output.render(ctx, rows)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@alerts_group.command("suppress")
|
|
81
|
+
@guard.guarded
|
|
82
|
+
@click.argument("name")
|
|
83
|
+
@click.option(
|
|
84
|
+
"--duration",
|
|
85
|
+
type=int,
|
|
86
|
+
default=3600,
|
|
87
|
+
help="Throttle window in seconds.",
|
|
88
|
+
)
|
|
89
|
+
@click.pass_context
|
|
90
|
+
def suppress_alert(ctx: click.Context, name: str, duration: int) -> None:
|
|
91
|
+
"""Throttle a rule's alerts by setting alert.suppress on its saved search.
|
|
92
|
+
|
|
93
|
+
Splunk's fired-alerts endpoint cannot be edited; throttling is a
|
|
94
|
+
property of the underlying saved search.
|
|
95
|
+
"""
|
|
96
|
+
details = (
|
|
97
|
+
f"Set alert.suppress=1, alert.suppress.period={duration}s "
|
|
98
|
+
f"on saved search '{name}'"
|
|
99
|
+
)
|
|
100
|
+
if not guard.check(ctx, "Throttle alerts for rule", details=details):
|
|
101
|
+
return
|
|
102
|
+
client = get_client(ctx)
|
|
103
|
+
try:
|
|
104
|
+
ss = client.service.saved_searches[name]
|
|
105
|
+
except KeyError:
|
|
106
|
+
output.error(f"Saved search not found: {name}")
|
|
107
|
+
ctx.exit(1)
|
|
108
|
+
return
|
|
109
|
+
ss.update(
|
|
110
|
+
**{"alert.suppress": "1", "alert.suppress.period": f"{duration}s"}
|
|
111
|
+
).refresh()
|
|
112
|
+
output.info(f"Throttled '{name}' for {duration}s.")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@alerts_group.command("unsuppress")
|
|
116
|
+
@guard.guarded
|
|
117
|
+
@click.argument("name")
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def unsuppress_alert(ctx: click.Context, name: str) -> None:
|
|
120
|
+
"""Remove alert throttling from a rule's saved search."""
|
|
121
|
+
if not guard.check(ctx, f"Remove alert throttling from '{name}'"):
|
|
122
|
+
return
|
|
123
|
+
client = get_client(ctx)
|
|
124
|
+
try:
|
|
125
|
+
ss = client.service.saved_searches[name]
|
|
126
|
+
except KeyError:
|
|
127
|
+
output.error(f"Saved search not found: {name}")
|
|
128
|
+
ctx.exit(1)
|
|
129
|
+
return
|
|
130
|
+
ss.update(**{"alert.suppress": "0"}).refresh()
|
|
131
|
+
output.info(f"Removed throttling from '{name}'.")
|
|
@@ -67,6 +67,7 @@ def get_app(ctx: click.Context, name: str) -> None:
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
@apps_group.command("install")
|
|
70
|
+
@guard.guarded
|
|
70
71
|
@click.option(
|
|
71
72
|
"--path",
|
|
72
73
|
"file_path",
|
|
@@ -100,6 +101,7 @@ def install_app(ctx: click.Context, file_path: str, *, force: bool) -> None:
|
|
|
100
101
|
|
|
101
102
|
|
|
102
103
|
@apps_group.command("uninstall")
|
|
104
|
+
@guard.guarded
|
|
103
105
|
@click.argument("name")
|
|
104
106
|
@click.pass_context
|
|
105
107
|
def uninstall_app(ctx: click.Context, name: str) -> None:
|
|
@@ -125,6 +127,7 @@ def uninstall_app(ctx: click.Context, name: str) -> None:
|
|
|
125
127
|
|
|
126
128
|
|
|
127
129
|
@apps_group.command("update")
|
|
130
|
+
@guard.guarded
|
|
128
131
|
@click.argument("name")
|
|
129
132
|
@click.option("--visible/--hidden", default=None, help="Set app visibility.")
|
|
130
133
|
@click.option("--enabled/--disabled", default=None, help="Enable or disable the app.")
|
|
@@ -173,6 +176,7 @@ def update_app(
|
|
|
173
176
|
|
|
174
177
|
|
|
175
178
|
@apps_group.command("reload")
|
|
179
|
+
@guard.guarded
|
|
176
180
|
@click.pass_context
|
|
177
181
|
def reload_apps(ctx: click.Context) -> None:
|
|
178
182
|
"""Reload all apps."""
|
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
import click
|
|
7
7
|
|
|
8
8
|
from splunkctl import __version__, output
|
|
9
|
+
from splunkctl.guard import is_guarded
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _param_entry(p: click.Parameter) -> dict[str, Any]:
|
|
@@ -45,6 +46,8 @@ def _walk(group: click.Group) -> list[dict[str, Any]]:
|
|
|
45
46
|
if isinstance(cmd, click.Group):
|
|
46
47
|
node["subcommands"] = _walk(cmd)
|
|
47
48
|
else:
|
|
49
|
+
if is_guarded(cmd):
|
|
50
|
+
node["guarded"] = True
|
|
48
51
|
params: list[dict[str, Any]] = []
|
|
49
52
|
for p in cmd.params:
|
|
50
53
|
if p.name in ("help",):
|
|
@@ -65,8 +68,13 @@ def commands_meta(ctx: click.Context) -> None:
|
|
|
65
68
|
output.error("Cannot resolve CLI root.")
|
|
66
69
|
ctx.exit(1)
|
|
67
70
|
return
|
|
71
|
+
global_opts = [
|
|
72
|
+
_param_entry(p) for p in root.command.params if p.name not in ("help",)
|
|
73
|
+
]
|
|
68
74
|
result: dict[str, Any] = {
|
|
69
75
|
"version": __version__,
|
|
76
|
+
"global_options": global_opts,
|
|
77
|
+
"note": "guarded commands are dry-run by default; pass --yes to apply",
|
|
70
78
|
"commands": _walk(root.command),
|
|
71
79
|
}
|
|
72
80
|
click.echo(json.dumps(result, indent=2))
|