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.
Files changed (72) hide show
  1. {splunkctl-0.3.0 → splunkctl-0.4.0}/PKG-INFO +1 -1
  2. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/__init__.py +1 -1
  3. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/client.py +54 -2
  4. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/alerts.py +19 -5
  5. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/apps.py +15 -5
  6. splunkctl-0.4.0/splunkctl/commands/audit.py +196 -0
  7. splunkctl-0.4.0/splunkctl/commands/audit_parse.py +151 -0
  8. splunkctl-0.4.0/splunkctl/commands/common.py +379 -0
  9. splunkctl-0.4.0/splunkctl/commands/conf.py +227 -0
  10. splunkctl-0.4.0/splunkctl/commands/conf_ops.py +136 -0
  11. splunkctl-0.4.0/splunkctl/commands/config_cmd.py +152 -0
  12. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/dashboards.py +16 -5
  13. splunkctl-0.4.0/splunkctl/commands/datamodels.py +340 -0
  14. splunkctl-0.4.0/splunkctl/commands/es.py +239 -0
  15. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/hec.py +20 -8
  16. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/indexes.py +20 -6
  17. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/inputs.py +27 -7
  18. splunkctl-0.4.0/splunkctl/commands/knowledge.py +384 -0
  19. splunkctl-0.4.0/splunkctl/commands/kvstore.py +452 -0
  20. splunkctl-0.4.0/splunkctl/commands/lookups.py +476 -0
  21. splunkctl-0.4.0/splunkctl/commands/lookups_wiring.py +125 -0
  22. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/parsers.py +69 -52
  23. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/rules.py +94 -32
  24. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/rules_io.py +135 -13
  25. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/search.py +22 -6
  26. splunkctl-0.4.0/splunkctl/commands/server.py +302 -0
  27. splunkctl-0.4.0/splunkctl/commands/state.py +274 -0
  28. splunkctl-0.4.0/splunkctl/commands/state_io.py +208 -0
  29. splunkctl-0.4.0/splunkctl/commands/state_io_blobs.py +140 -0
  30. splunkctl-0.4.0/splunkctl/commands/state_io_confs.py +200 -0
  31. splunkctl-0.4.0/splunkctl/commands/state_types.py +137 -0
  32. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/users.py +35 -11
  33. splunkctl-0.4.0/splunkctl/config.py +264 -0
  34. splunkctl-0.4.0/splunkctl/errors.py +76 -0
  35. splunkctl-0.4.0/splunkctl/guard.py +77 -0
  36. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/main.py +35 -13
  37. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/output.py +70 -4
  38. splunkctl-0.4.0/splunkctl/skill/SKILL.md +1045 -0
  39. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/PKG-INFO +1 -1
  40. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/SOURCES.txt +16 -0
  41. {splunkctl-0.3.0 → splunkctl-0.4.0}/tests/test_client.py +90 -0
  42. splunkctl-0.4.0/tests/test_config.py +283 -0
  43. splunkctl-0.4.0/tests/test_guard.py +193 -0
  44. splunkctl-0.4.0/tests/test_main_errors.py +167 -0
  45. {splunkctl-0.3.0 → splunkctl-0.4.0}/tests/test_main_hoisting.py +22 -0
  46. splunkctl-0.4.0/tests/test_output.py +370 -0
  47. {splunkctl-0.3.0 → splunkctl-0.4.0}/tests/test_version.py +1 -1
  48. splunkctl-0.3.0/splunkctl/commands/common.py +0 -161
  49. splunkctl-0.3.0/splunkctl/commands/config_cmd.py +0 -87
  50. splunkctl-0.3.0/splunkctl/commands/lookups.py +0 -197
  51. splunkctl-0.3.0/splunkctl/commands/server.py +0 -107
  52. splunkctl-0.3.0/splunkctl/config.py +0 -82
  53. splunkctl-0.3.0/splunkctl/guard.py +0 -36
  54. splunkctl-0.3.0/splunkctl/skill/SKILL.md +0 -419
  55. splunkctl-0.3.0/tests/test_config.py +0 -80
  56. splunkctl-0.3.0/tests/test_guard.py +0 -40
  57. splunkctl-0.3.0/tests/test_output.py +0 -188
  58. {splunkctl-0.3.0 → splunkctl-0.4.0}/LICENSE +0 -0
  59. {splunkctl-0.3.0 → splunkctl-0.4.0}/README.md +0 -0
  60. {splunkctl-0.3.0 → splunkctl-0.4.0}/pyproject.toml +0 -0
  61. {splunkctl-0.3.0 → splunkctl-0.4.0}/setup.cfg +0 -0
  62. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/__main__.py +0 -0
  63. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/__init__.py +0 -0
  64. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/commands_meta.py +0 -0
  65. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/doctor.py +0 -0
  66. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/info.py +0 -0
  67. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/parsers_io.py +0 -0
  68. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl/commands/skill_cmd.py +0 -0
  69. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  70. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/entry_points.txt +0 -0
  71. {splunkctl-0.3.0 → splunkctl-0.4.0}/splunkctl.egg-info/requires.txt +0 -0
  72. {splunkctl-0.3.0 → splunkctl-0.4.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.0
3
+ Version: 0.4.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise SIEM operations."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.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(ctx: click.Context) -> None:
36
- """List fired alerts (one row per firing, with sid for drill-down)."""
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(ctx: click.Context) -> None:
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 = svc.apps.list()
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}