splunkctl 0.3.0__tar.gz → 0.4.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.3.0 → splunkctl-0.4.0}/PKG-INFO +1 -1
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/__init__.py +1 -1
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/client.py +54 -2
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/alerts.py +19 -5
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/apps.py +15 -5
- splunkctl-0.4.0/splunkctl/commands/audit.py +196 -0
- splunkctl-0.4.0/splunkctl/commands/audit_parse.py +151 -0
- splunkctl-0.4.0/splunkctl/commands/common.py +379 -0
- splunkctl-0.4.0/splunkctl/commands/conf.py +227 -0
- splunkctl-0.4.0/splunkctl/commands/conf_ops.py +136 -0
- splunkctl-0.4.0/splunkctl/commands/config_cmd.py +152 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/dashboards.py +16 -5
- splunkctl-0.4.0/splunkctl/commands/datamodels.py +340 -0
- splunkctl-0.4.0/splunkctl/commands/es.py +239 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/hec.py +20 -8
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/indexes.py +20 -6
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/inputs.py +27 -7
- splunkctl-0.4.0/splunkctl/commands/knowledge.py +384 -0
- splunkctl-0.4.0/splunkctl/commands/kvstore.py +452 -0
- splunkctl-0.4.0/splunkctl/commands/lookups.py +476 -0
- splunkctl-0.4.0/splunkctl/commands/lookups_wiring.py +125 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/parsers.py +69 -52
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/rules.py +94 -32
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/rules_io.py +135 -13
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/search.py +22 -6
- splunkctl-0.4.0/splunkctl/commands/server.py +302 -0
- splunkctl-0.4.0/splunkctl/commands/state.py +274 -0
- splunkctl-0.4.0/splunkctl/commands/state_io.py +208 -0
- splunkctl-0.4.0/splunkctl/commands/state_io_blobs.py +140 -0
- splunkctl-0.4.0/splunkctl/commands/state_io_confs.py +200 -0
- splunkctl-0.4.0/splunkctl/commands/state_types.py +137 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/users.py +35 -11
- splunkctl-0.4.0/splunkctl/config.py +264 -0
- splunkctl-0.4.0/splunkctl/errors.py +76 -0
- splunkctl-0.4.0/splunkctl/guard.py +77 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/main.py +35 -13
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/output.py +70 -4
- splunkctl-0.4.0/splunkctl/skill/SKILL.md +1045 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/PKG-INFO +1 -1
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/SOURCES.txt +16 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/tests/test_client.py +90 -0
- splunkctl-0.4.0/tests/test_config.py +283 -0
- splunkctl-0.4.0/tests/test_guard.py +193 -0
- splunkctl-0.4.0/tests/test_main_errors.py +167 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/tests/test_main_hoisting.py +22 -0
- splunkctl-0.4.0/tests/test_output.py +370 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/tests/test_version.py +1 -1
- splunkctl-0.3.0/splunkctl/commands/common.py +0 -161
- splunkctl-0.3.0/splunkctl/commands/config_cmd.py +0 -87
- splunkctl-0.3.0/splunkctl/commands/lookups.py +0 -197
- splunkctl-0.3.0/splunkctl/commands/server.py +0 -107
- splunkctl-0.3.0/splunkctl/config.py +0 -82
- splunkctl-0.3.0/splunkctl/guard.py +0 -36
- splunkctl-0.3.0/splunkctl/skill/SKILL.md +0 -419
- splunkctl-0.3.0/tests/test_config.py +0 -80
- splunkctl-0.3.0/tests/test_guard.py +0 -40
- splunkctl-0.3.0/tests/test_output.py +0 -188
- {splunkctl-0.3.0 → splunkctl-0.4.0}/LICENSE +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/README.md +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/pyproject.toml +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/setup.cfg +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/skill_cmd.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/top_level.txt +0 -0
|
@@ -6,6 +6,7 @@ handle remotely (e.g. lookup file upload requires server-side staging).
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import json
|
|
9
10
|
import re
|
|
10
11
|
import urllib.parse
|
|
11
12
|
from pathlib import Path
|
|
@@ -29,6 +30,7 @@ class SplunkClient:
|
|
|
29
30
|
self,
|
|
30
31
|
*,
|
|
31
32
|
config_path: Path | None = None,
|
|
33
|
+
profile: str | None = None,
|
|
32
34
|
host: str | None = None,
|
|
33
35
|
port: int | None = None,
|
|
34
36
|
username: str | None = None,
|
|
@@ -42,6 +44,7 @@ class SplunkClient:
|
|
|
42
44
|
debug: bool = False,
|
|
43
45
|
) -> None:
|
|
44
46
|
self._config_path = config_path
|
|
47
|
+
self._profile = profile
|
|
45
48
|
self._overrides: dict[str, Any] = {
|
|
46
49
|
k: v
|
|
47
50
|
for k, v in {
|
|
@@ -72,7 +75,7 @@ class SplunkClient:
|
|
|
72
75
|
logging.basicConfig(level=logging.DEBUG)
|
|
73
76
|
logging.getLogger("splunklib").setLevel(logging.DEBUG)
|
|
74
77
|
|
|
75
|
-
cfg = cfg_mod.load(self._config_path)
|
|
78
|
+
cfg = cfg_mod.load(self._config_path, profile=self._profile)
|
|
76
79
|
cfg.update(self._overrides)
|
|
77
80
|
|
|
78
81
|
connect_args: dict[str, Any] = {
|
|
@@ -104,7 +107,7 @@ class SplunkClient:
|
|
|
104
107
|
|
|
105
108
|
def _ensure_web_session(self) -> _WebSession:
|
|
106
109
|
if self._web_session is None:
|
|
107
|
-
cfg = cfg_mod.load(self._config_path)
|
|
110
|
+
cfg = cfg_mod.load(self._config_path, profile=self._profile)
|
|
108
111
|
cfg.update(self._overrides)
|
|
109
112
|
self._web_session = _WebSession(
|
|
110
113
|
self.service,
|
|
@@ -323,11 +326,60 @@ def _expect_ok(resp: requests.Response, what: str) -> None:
|
|
|
323
326
|
raise RuntimeError(f"{what} failed: {result.get('msg', 'unknown error')}")
|
|
324
327
|
|
|
325
328
|
|
|
329
|
+
def rest_get_json(
|
|
330
|
+
svc: Any,
|
|
331
|
+
path: str,
|
|
332
|
+
*,
|
|
333
|
+
owner: str | None = None,
|
|
334
|
+
app: str | None = None,
|
|
335
|
+
**query: Any,
|
|
336
|
+
) -> Any:
|
|
337
|
+
"""GET a REST path via the SDK's authenticated session and parse JSON.
|
|
338
|
+
|
|
339
|
+
Always passes ``output_mode=json`` so endpoints that default to Atom
|
|
340
|
+
XML (e.g. ``storage/collections/config``) come back as JSON just like
|
|
341
|
+
the KV store data endpoints already do. Any raised ``HTTPError`` (or
|
|
342
|
+
connection failure) is left to propagate — callers rely on
|
|
343
|
+
``splunkctl.errors.classify`` at the top level to turn it into a
|
|
344
|
+
clean error envelope.
|
|
345
|
+
"""
|
|
346
|
+
resp = svc.get(path, owner=owner, app=app, output_mode="json", **query)
|
|
347
|
+
return json.loads(resp.body.read())
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def rest_post_json(
|
|
351
|
+
svc: Any,
|
|
352
|
+
path: str,
|
|
353
|
+
body: Any,
|
|
354
|
+
*,
|
|
355
|
+
owner: str | None = None,
|
|
356
|
+
app: str | None = None,
|
|
357
|
+
) -> Any:
|
|
358
|
+
"""POST a JSON-encoded body to a REST path and parse the JSON response.
|
|
359
|
+
|
|
360
|
+
The KV store data API (``storage/collections/data/...``) requires
|
|
361
|
+
``Content-Type: application/json`` on the request body and always
|
|
362
|
+
responds with JSON — unlike most Splunk config endpoints, which use
|
|
363
|
+
``x-www-form-urlencoded`` params via plain keyword arguments. Returns
|
|
364
|
+
``None`` for an empty response body (e.g. some KV store writes).
|
|
365
|
+
"""
|
|
366
|
+
resp = svc.post(
|
|
367
|
+
path,
|
|
368
|
+
owner=owner,
|
|
369
|
+
app=app,
|
|
370
|
+
headers=[("Content-Type", "application/json")],
|
|
371
|
+
body=json.dumps(body),
|
|
372
|
+
)
|
|
373
|
+
raw = resp.body.read()
|
|
374
|
+
return json.loads(raw) if raw else None
|
|
375
|
+
|
|
376
|
+
|
|
326
377
|
def get_client(ctx: click.Context) -> SplunkClient:
|
|
327
378
|
"""Build a SplunkClient from Click context. Does not connect."""
|
|
328
379
|
obj: dict[str, Any] = ctx.ensure_object(dict)
|
|
329
380
|
return SplunkClient(
|
|
330
381
|
config_path=Path(obj["config"]) if obj.get("config") else None,
|
|
382
|
+
profile=obj.get("profile"),
|
|
331
383
|
timeout=obj.get("timeout", 30),
|
|
332
384
|
debug=obj.get("debug", False),
|
|
333
385
|
)
|
|
@@ -6,6 +6,7 @@ import click
|
|
|
6
6
|
|
|
7
7
|
from splunkctl import guard, output
|
|
8
8
|
from splunkctl.client import get_client
|
|
9
|
+
from splunkctl.commands.common import filter_by_name, list_options, page_slice
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _firing_rows(group: Any) -> list[dict[str, Any]]:
|
|
@@ -31,15 +32,28 @@ def alerts_group() -> None:
|
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
@alerts_group.command("list")
|
|
35
|
+
@list_options
|
|
34
36
|
@click.pass_context
|
|
35
|
-
def list_alerts(
|
|
36
|
-
|
|
37
|
+
def list_alerts(
|
|
38
|
+
ctx: click.Context,
|
|
39
|
+
*,
|
|
40
|
+
limit: int | None,
|
|
41
|
+
offset: int,
|
|
42
|
+
name_filter: str | None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""List fired alerts (one row per firing, with sid for drill-down).
|
|
45
|
+
|
|
46
|
+
--filter matches the rule name; --limit/--offset page the firing rows
|
|
47
|
+
client-side (a row is one firing, not one rule).
|
|
48
|
+
"""
|
|
37
49
|
client = get_client(ctx)
|
|
38
50
|
rows: list[dict[str, Any]] = []
|
|
39
51
|
for group in client.service.fired_alerts:
|
|
40
52
|
if group.name == "-":
|
|
41
53
|
continue
|
|
42
54
|
rows.extend(_firing_rows(group))
|
|
55
|
+
rows = filter_by_name(rows, name_filter, name_of=lambda r: str(r["rule"]))
|
|
56
|
+
rows = page_slice(rows, limit=limit, offset=offset)
|
|
43
57
|
output.render(ctx, rows, empty="No fired alerts.")
|
|
44
58
|
|
|
45
59
|
|
|
@@ -55,7 +69,7 @@ def get_alert(ctx: click.Context, name: str) -> None:
|
|
|
55
69
|
output.info(f"{name}: {len(rows)} firing(s)")
|
|
56
70
|
output.render(ctx, rows)
|
|
57
71
|
return
|
|
58
|
-
output.error(f"Fired alert not found: {name}")
|
|
72
|
+
output.error(f"Fired alert not found: {name}", kind="not_found")
|
|
59
73
|
ctx.exit(1)
|
|
60
74
|
|
|
61
75
|
|
|
@@ -103,7 +117,7 @@ def suppress_alert(ctx: click.Context, name: str, duration: int) -> None:
|
|
|
103
117
|
try:
|
|
104
118
|
ss = client.service.saved_searches[name]
|
|
105
119
|
except KeyError:
|
|
106
|
-
output.error(f"Saved search not found: {name}")
|
|
120
|
+
output.error(f"Saved search not found: {name}", kind="not_found")
|
|
107
121
|
ctx.exit(1)
|
|
108
122
|
return
|
|
109
123
|
ss.update(
|
|
@@ -124,7 +138,7 @@ def unsuppress_alert(ctx: click.Context, name: str) -> None:
|
|
|
124
138
|
try:
|
|
125
139
|
ss = client.service.saved_searches[name]
|
|
126
140
|
except KeyError:
|
|
127
|
-
output.error(f"Saved search not found: {name}")
|
|
141
|
+
output.error(f"Saved search not found: {name}", kind="not_found")
|
|
128
142
|
ctx.exit(1)
|
|
129
143
|
return
|
|
130
144
|
ss.update(**{"alert.suppress": "0"}).refresh()
|
|
@@ -7,6 +7,7 @@ import click
|
|
|
7
7
|
|
|
8
8
|
from splunkctl import guard, output
|
|
9
9
|
from splunkctl.client import get_client
|
|
10
|
+
from splunkctl.commands.common import fetch_page, list_options
|
|
10
11
|
|
|
11
12
|
_APP_FIELDS = ("label", "version", "visible", "disabled", "author", "description")
|
|
12
13
|
|
|
@@ -37,12 +38,21 @@ def apps_group() -> None:
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
@apps_group.command("list")
|
|
41
|
+
@list_options
|
|
40
42
|
@click.pass_context
|
|
41
|
-
def list_apps(
|
|
43
|
+
def list_apps(
|
|
44
|
+
ctx: click.Context,
|
|
45
|
+
*,
|
|
46
|
+
limit: int | None,
|
|
47
|
+
offset: int,
|
|
48
|
+
name_filter: str | None,
|
|
49
|
+
) -> None:
|
|
42
50
|
"""List installed apps."""
|
|
43
51
|
client = get_client(ctx)
|
|
44
52
|
svc = client.service
|
|
45
|
-
apps =
|
|
53
|
+
apps = fetch_page(
|
|
54
|
+
svc.apps.list, limit=limit, offset=offset, name_filter=name_filter
|
|
55
|
+
)
|
|
46
56
|
if not apps:
|
|
47
57
|
output.info("No apps found.")
|
|
48
58
|
return
|
|
@@ -60,7 +70,7 @@ def get_app(ctx: click.Context, name: str) -> None:
|
|
|
60
70
|
try:
|
|
61
71
|
app = svc.apps[name]
|
|
62
72
|
except KeyError:
|
|
63
|
-
output.error(f"App '{name}' not found.")
|
|
73
|
+
output.error(f"App '{name}' not found.", kind="not_found")
|
|
64
74
|
ctx.exit(1)
|
|
65
75
|
return
|
|
66
76
|
output.render(ctx, _app_row(app))
|
|
@@ -116,7 +126,7 @@ def uninstall_app(ctx: click.Context, name: str) -> None:
|
|
|
116
126
|
app = svc.apps[name]
|
|
117
127
|
app.delete()
|
|
118
128
|
except KeyError:
|
|
119
|
-
output.error(f"App '{name}' not found.")
|
|
129
|
+
output.error(f"App '{name}' not found.", kind="not_found")
|
|
120
130
|
ctx.exit(1)
|
|
121
131
|
return
|
|
122
132
|
except Exception as exc:
|
|
@@ -165,7 +175,7 @@ def update_app(
|
|
|
165
175
|
app = svc.apps[name]
|
|
166
176
|
app.update(**kwargs)
|
|
167
177
|
except KeyError:
|
|
168
|
-
output.error(f"App '{name}' not found.")
|
|
178
|
+
output.error(f"App '{name}' not found.", kind="not_found")
|
|
169
179
|
ctx.exit(1)
|
|
170
180
|
return
|
|
171
181
|
except Exception as exc:
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""``audit`` group — change audit and RBAC attestation, for regulator evidence.
|
|
2
|
+
|
|
3
|
+
Both subcommands are read-only (no ``guard``).
|
|
4
|
+
|
|
5
|
+
``changes`` wraps ``index=_audit`` without ever composing user input into
|
|
6
|
+
SPL: the dispatched search is always the constant ``search index=_audit``.
|
|
7
|
+
Time bounds go through the oneshot job's ``earliest_time``/``latest_time``
|
|
8
|
+
kwargs, and ``--user``/``--action``/``--object-type`` filter the
|
|
9
|
+
already-normalized rows client-side, after the fetch.
|
|
10
|
+
|
|
11
|
+
``rbac`` joins users/roles/capabilities/index-restrictions via SDK reads
|
|
12
|
+
only, for periodic access recertification evidence.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from splunkctl import output
|
|
20
|
+
from splunkctl.client import get_client
|
|
21
|
+
from splunkctl.commands.audit_parse import parse_event
|
|
22
|
+
from splunkctl.commands.common import read_results
|
|
23
|
+
|
|
24
|
+
_AUDIT_SPL = "search index=_audit"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _as_list(value: Any) -> list[str]:
|
|
28
|
+
"""Normalize a Splunk multi-value field that may come back as a bare string.
|
|
29
|
+
|
|
30
|
+
The REST API returns a plain string instead of a one-item list when a
|
|
31
|
+
multi-value field has exactly one value.
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(value, list):
|
|
34
|
+
return [str(v) for v in value]
|
|
35
|
+
if isinstance(value, str) and value:
|
|
36
|
+
return [value]
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _transitive_roles(
|
|
41
|
+
direct: list[str], role_map: dict[str, dict[str, Any]]
|
|
42
|
+
) -> list[str]:
|
|
43
|
+
"""Full ``imported_roles`` closure of ``direct`` (self included, cycle-safe)."""
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
stack = list(direct)
|
|
46
|
+
while stack:
|
|
47
|
+
name = stack.pop()
|
|
48
|
+
if name in seen or name not in role_map:
|
|
49
|
+
continue
|
|
50
|
+
seen.add(name)
|
|
51
|
+
stack.extend(_as_list(role_map[name].get("imported_roles", [])))
|
|
52
|
+
return sorted(seen)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _aggregate(
|
|
56
|
+
names: list[str], role_map: dict[str, dict[str, Any]], field: str
|
|
57
|
+
) -> str:
|
|
58
|
+
r"""Union ``field`` across ``names``, deduplicated, sorted, semicolon-joined.
|
|
59
|
+
|
|
60
|
+
``;`` (never ``\n``) so aggregated multi-value cells stay one CSV field.
|
|
61
|
+
"""
|
|
62
|
+
values: set[str] = set()
|
|
63
|
+
for name in names:
|
|
64
|
+
content = role_map.get(name)
|
|
65
|
+
if content is not None:
|
|
66
|
+
values.update(_as_list(content.get(field, [])))
|
|
67
|
+
return ";".join(sorted(values))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _user_row(user: Any, role_map: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
|
71
|
+
content: dict[str, Any] = user.content
|
|
72
|
+
direct = _as_list(content.get("roles", []))
|
|
73
|
+
effective = _transitive_roles(direct, role_map)
|
|
74
|
+
return {
|
|
75
|
+
"user": user.name,
|
|
76
|
+
"email": str(content.get("email") or ""),
|
|
77
|
+
"roles": ";".join(sorted(direct)),
|
|
78
|
+
"capabilities": _aggregate(effective, role_map, "capabilities"),
|
|
79
|
+
"srch_indexes_allowed": _aggregate(effective, role_map, "srchIndexesAllowed"),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _role_row(name: str, role_map: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
|
84
|
+
imported = _as_list(role_map[name].get("imported_roles", []))
|
|
85
|
+
effective = _transitive_roles([name], role_map)
|
|
86
|
+
return {
|
|
87
|
+
"role": name,
|
|
88
|
+
"imported_roles": ";".join(sorted(imported)),
|
|
89
|
+
"capabilities": _aggregate(effective, role_map, "capabilities"),
|
|
90
|
+
"srch_indexes_allowed": _aggregate(effective, role_map, "srchIndexesAllowed"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.group("audit")
|
|
95
|
+
def audit_group() -> None:
|
|
96
|
+
"""Change audit and RBAC attestation — regulator evidence, read-only."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@audit_group.command("changes")
|
|
100
|
+
@click.option("--since", default="-24h", help="Earliest time (default -24h).")
|
|
101
|
+
@click.option("--until", default="now", help="Latest time (default now).")
|
|
102
|
+
@click.option(
|
|
103
|
+
"--user",
|
|
104
|
+
"user_filter",
|
|
105
|
+
default=None,
|
|
106
|
+
help="Filter by user (case-insensitive).",
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"--action",
|
|
110
|
+
"action_filter",
|
|
111
|
+
default=None,
|
|
112
|
+
help="Filter by action, substring (case-insensitive).",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--object-type",
|
|
116
|
+
"object_type_filter",
|
|
117
|
+
default=None,
|
|
118
|
+
help="Filter by object type (case-insensitive).",
|
|
119
|
+
)
|
|
120
|
+
@click.option(
|
|
121
|
+
"--limit",
|
|
122
|
+
type=click.IntRange(min=1),
|
|
123
|
+
default=500,
|
|
124
|
+
help="Max results after client-side filtering (default 500).",
|
|
125
|
+
)
|
|
126
|
+
@click.pass_context
|
|
127
|
+
def changes(
|
|
128
|
+
ctx: click.Context,
|
|
129
|
+
since: str,
|
|
130
|
+
until: str,
|
|
131
|
+
user_filter: str | None,
|
|
132
|
+
action_filter: str | None,
|
|
133
|
+
object_type_filter: str | None,
|
|
134
|
+
limit: int,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Normalized index=_audit change events (both legacy and JSON shapes).
|
|
137
|
+
|
|
138
|
+
SECURITY: the dispatched SPL is always the constant
|
|
139
|
+
``search index=_audit``. --since/--until go through the search job's
|
|
140
|
+
time-range kwargs; --user/--action/--object-type filter the
|
|
141
|
+
normalized rows client-side. No flag value is ever composed into the
|
|
142
|
+
SPL string.
|
|
143
|
+
"""
|
|
144
|
+
client = get_client(ctx)
|
|
145
|
+
svc = client.service
|
|
146
|
+
|
|
147
|
+
output.info(f"Searching: {_AUDIT_SPL}")
|
|
148
|
+
stream: Any = svc.jobs.oneshot(
|
|
149
|
+
_AUDIT_SPL,
|
|
150
|
+
output_mode="json",
|
|
151
|
+
count=0,
|
|
152
|
+
earliest_time=since,
|
|
153
|
+
latest_time=until,
|
|
154
|
+
)
|
|
155
|
+
rows = [parse_event(r) for r in read_results(stream)]
|
|
156
|
+
|
|
157
|
+
if user_filter is not None:
|
|
158
|
+
rows = [r for r in rows if r["user"].lower() == user_filter.lower()]
|
|
159
|
+
if action_filter is not None:
|
|
160
|
+
needle = action_filter.lower()
|
|
161
|
+
rows = [r for r in rows if needle in r["action"].lower()]
|
|
162
|
+
if object_type_filter is not None:
|
|
163
|
+
obj_type_lower = object_type_filter.lower()
|
|
164
|
+
rows = [r for r in rows if r["object_type"].lower() == obj_type_lower]
|
|
165
|
+
|
|
166
|
+
rows.sort(key=lambda r: r["time"], reverse=True)
|
|
167
|
+
output.render(ctx, rows[:limit], empty="No audit events found.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@audit_group.command("rbac")
|
|
171
|
+
@click.option(
|
|
172
|
+
"--roles-only",
|
|
173
|
+
is_flag=True,
|
|
174
|
+
help="One row per role instead of per user (role-centric attestation view).",
|
|
175
|
+
)
|
|
176
|
+
@click.pass_context
|
|
177
|
+
def rbac(ctx: click.Context, *, roles_only: bool) -> None:
|
|
178
|
+
"""Users x roles x capabilities x index-restrictions — recertification evidence.
|
|
179
|
+
|
|
180
|
+
Capabilities and srch_indexes_allowed are aggregated across each
|
|
181
|
+
principal's direct roles AND the full transitive closure of their
|
|
182
|
+
imported roles, deduplicated and sorted.
|
|
183
|
+
"""
|
|
184
|
+
client = get_client(ctx)
|
|
185
|
+
svc = client.service
|
|
186
|
+
|
|
187
|
+
role_map: dict[str, dict[str, Any]] = {r.name: dict(r.content) for r in svc.roles}
|
|
188
|
+
|
|
189
|
+
if roles_only:
|
|
190
|
+
rows = [_role_row(name, role_map) for name in sorted(role_map)]
|
|
191
|
+
output.render(ctx, rows, empty="No roles found.")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
users = sorted(svc.users, key=lambda u: str(u.name))
|
|
195
|
+
user_rows = [_user_row(u, role_map) for u in users]
|
|
196
|
+
output.render(ctx, user_rows, empty="No users found.")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Normalize both index=_audit event shapes into one six-key schema.
|
|
2
|
+
|
|
3
|
+
Splunk ships two incompatible shapes in the same ``_audit`` index, mixed
|
|
4
|
+
in one stream:
|
|
5
|
+
|
|
6
|
+
- ``audittrail`` (legacy): a plain-text line,
|
|
7
|
+
``Audit:[timestamp=..., user=..., action=..., <tail>]`` where
|
|
8
|
+
``<tail>`` is an action-specific, comma-separated list of extra fields
|
|
9
|
+
(e.g. ``info=granted, cap=1`` for capability checks,
|
|
10
|
+
``modified_settings=[...]`` for ``edit_index``, or no ``info=`` at all
|
|
11
|
+
for some system actions like ``artifact_deleted``/file-integrity
|
|
12
|
+
``update`` — confirmed live). Only ``timestamp``/``user``/``action``
|
|
13
|
+
are structurally guaranteed; there is no field that consistently
|
|
14
|
+
identifies "the object" across every action. Many admin-capability
|
|
15
|
+
checks (``edit_user``, ``edit_roles``, ...) do carry a literal
|
|
16
|
+
``object="..."`` marker, so that's used when present.
|
|
17
|
+
- ``audittrailv2`` (structured): one JSON object per line, with
|
|
18
|
+
``actor.name``, a top-level ``action``, and a ``data`` object shaped
|
|
19
|
+
``{name, type, ownership: {app, owner}, attributes}`` for
|
|
20
|
+
object/system-category events (config changes), or just
|
|
21
|
+
``{type, attributes}`` / ``{attributes}`` for action/authn/check
|
|
22
|
+
events (searches, logins, capability checks). There is no literal
|
|
23
|
+
``data.object``/``data.object_type`` key on real events — ``data.name``
|
|
24
|
+
and ``data.type`` are the nearest equivalents and are used here.
|
|
25
|
+
|
|
26
|
+
Both are normalized into the same six-key schema so callers never
|
|
27
|
+
hand-write shape-specific parsing. An event matching neither shape is
|
|
28
|
+
never dropped — it comes back with ``action: "unparsed"`` and the raw
|
|
29
|
+
line preserved in ``object``, so audit evidence stays complete.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
SCHEMA_KEYS: tuple[str, ...] = (
|
|
37
|
+
"time",
|
|
38
|
+
"user",
|
|
39
|
+
"action",
|
|
40
|
+
"object",
|
|
41
|
+
"object_type",
|
|
42
|
+
"app",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_LEGACY_RE = re.compile(
|
|
46
|
+
r"^Audit:\[timestamp=(?P<timestamp>[^,]*),\s*"
|
|
47
|
+
r"user=(?P<user>[^,]*),\s*"
|
|
48
|
+
r"action=(?P<action>[^,\]]*)"
|
|
49
|
+
)
|
|
50
|
+
_OBJECT_RE = re.compile(r'object="(?P<value>[^"]*)"')
|
|
51
|
+
_APP_RE = re.compile(r'app=(?:"(?P<quoted>[^"]*)"|(?P<bare>[^,\]]+))')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _as_str(value: Any) -> str:
|
|
55
|
+
"""Coerce a JSON scalar (or ``None``) to ``str`` for the output schema."""
|
|
56
|
+
return "" if value is None else str(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _event_time(row: dict[str, Any]) -> str:
|
|
60
|
+
"""Splunk's own indexed ``_time`` — present on every event regardless of shape.
|
|
61
|
+
|
|
62
|
+
Used instead of re-parsing the two different embedded timestamp
|
|
63
|
+
formats: it's always populated (Splunk's own ``TIME_FORMAT``
|
|
64
|
+
extraction already did that work at index time) and gives one
|
|
65
|
+
consistent, sortable representation across both shapes.
|
|
66
|
+
"""
|
|
67
|
+
return _as_str(row.get("_time"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_json_shape(raw: str) -> dict[str, str] | None:
|
|
71
|
+
"""Parse the structured JSON shape (sourcetype ``audittrailv2``).
|
|
72
|
+
|
|
73
|
+
Returns ``None`` when ``raw`` isn't valid JSON or doesn't look like an
|
|
74
|
+
audittrailv2 event (missing ``actor``/``action``), so the caller can
|
|
75
|
+
fall back to the legacy parser.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
obj: Any = json.loads(raw)
|
|
79
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
if not isinstance(obj, dict) or "action" not in obj or "actor" not in obj:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
actor = obj.get("actor")
|
|
85
|
+
user = actor.get("name") if isinstance(actor, dict) else None
|
|
86
|
+
|
|
87
|
+
data = obj.get("data")
|
|
88
|
+
data = data if isinstance(data, dict) else {}
|
|
89
|
+
ownership = data.get("ownership")
|
|
90
|
+
ownership = ownership if isinstance(ownership, dict) else {}
|
|
91
|
+
attributes = data.get("attributes")
|
|
92
|
+
attributes = attributes if isinstance(attributes, dict) else {}
|
|
93
|
+
app = ownership.get("app") or attributes.get("app")
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"user": _as_str(user),
|
|
97
|
+
"action": _as_str(obj.get("action")),
|
|
98
|
+
"object": _as_str(data.get("name")),
|
|
99
|
+
"object_type": _as_str(data.get("type")),
|
|
100
|
+
"app": _as_str(app),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_legacy_shape(raw: str) -> dict[str, str] | None:
|
|
105
|
+
"""Parse the legacy plain-text shape (sourcetype ``audittrail``).
|
|
106
|
+
|
|
107
|
+
Returns ``None`` when ``raw`` doesn't match the
|
|
108
|
+
``Audit:[timestamp=..., user=..., action=...`` prefix, so the caller
|
|
109
|
+
can fall back to the unparsed case.
|
|
110
|
+
"""
|
|
111
|
+
m = _LEGACY_RE.match(raw)
|
|
112
|
+
if m is None:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
obj_m = _OBJECT_RE.search(raw)
|
|
116
|
+
app_m = _APP_RE.search(raw)
|
|
117
|
+
app = ""
|
|
118
|
+
if app_m is not None:
|
|
119
|
+
app = (
|
|
120
|
+
app_m.group("quoted")
|
|
121
|
+
if app_m.group("quoted") is not None
|
|
122
|
+
else app_m.group("bare")
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"user": m.group("user").strip(),
|
|
127
|
+
"action": m.group("action").strip(),
|
|
128
|
+
"object": obj_m.group("value") if obj_m is not None else "",
|
|
129
|
+
"object_type": "",
|
|
130
|
+
"app": app.strip(),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_event(row: dict[str, Any]) -> dict[str, str]:
|
|
135
|
+
"""Normalize one raw ``_audit`` result row into the six-key schema.
|
|
136
|
+
|
|
137
|
+
Tries the JSON shape, then the legacy text shape; anything matching
|
|
138
|
+
neither becomes an ``action: "unparsed"`` row with the full raw line
|
|
139
|
+
in ``object`` — never silently dropped.
|
|
140
|
+
"""
|
|
141
|
+
raw = _as_str(row.get("_raw"))
|
|
142
|
+
parsed = _parse_json_shape(raw) or _parse_legacy_shape(raw)
|
|
143
|
+
if parsed is None:
|
|
144
|
+
parsed = {
|
|
145
|
+
"user": "",
|
|
146
|
+
"action": "unparsed",
|
|
147
|
+
"object": raw,
|
|
148
|
+
"object_type": "",
|
|
149
|
+
"app": "",
|
|
150
|
+
}
|
|
151
|
+
return {"time": _event_time(row), **parsed}
|