splunkctl 0.3.0__tar.gz → 0.3.1__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 (46) hide show
  1. {splunkctl-0.3.0 → splunkctl-0.3.1}/PKG-INFO +1 -1
  2. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/__init__.py +1 -1
  3. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/common.py +35 -0
  4. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/rules.py +84 -16
  5. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/rules_io.py +134 -12
  6. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/search.py +2 -1
  7. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/server.py +8 -5
  8. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/output.py +5 -0
  9. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/skill/SKILL.md +119 -4
  10. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/PKG-INFO +1 -1
  11. {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_version.py +1 -1
  12. {splunkctl-0.3.0 → splunkctl-0.3.1}/LICENSE +0 -0
  13. {splunkctl-0.3.0 → splunkctl-0.3.1}/README.md +0 -0
  14. {splunkctl-0.3.0 → splunkctl-0.3.1}/pyproject.toml +0 -0
  15. {splunkctl-0.3.0 → splunkctl-0.3.1}/setup.cfg +0 -0
  16. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/__main__.py +0 -0
  17. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/client.py +0 -0
  18. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/__init__.py +0 -0
  19. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/alerts.py +0 -0
  20. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/apps.py +0 -0
  21. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/commands_meta.py +0 -0
  22. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/config_cmd.py +0 -0
  23. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/dashboards.py +0 -0
  24. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/doctor.py +0 -0
  25. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/hec.py +0 -0
  26. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/indexes.py +0 -0
  27. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/info.py +0 -0
  28. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/inputs.py +0 -0
  29. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/lookups.py +0 -0
  30. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/parsers.py +0 -0
  31. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/parsers_io.py +0 -0
  32. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/skill_cmd.py +0 -0
  33. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/users.py +0 -0
  34. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/config.py +0 -0
  35. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/guard.py +0 -0
  36. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/main.py +0 -0
  37. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/SOURCES.txt +0 -0
  38. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/dependency_links.txt +0 -0
  39. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/entry_points.txt +0 -0
  40. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/requires.txt +0 -0
  41. {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/top_level.txt +0 -0
  42. {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_client.py +0 -0
  43. {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_config.py +0 -0
  44. {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_guard.py +0 -0
  45. {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_main_hoisting.py +0 -0
  46. {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.3.0
3
+ Version: 0.3.1
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.3.1"
@@ -5,6 +5,8 @@ from typing import Any
5
5
 
6
6
  import click
7
7
 
8
+ from splunkctl import output
9
+
8
10
  _ALERT_TYPES = (
9
11
  "custom",
10
12
  "number of events",
@@ -159,3 +161,36 @@ def alert_kwargs(
159
161
  if schedule_window is not None:
160
162
  kwargs["schedule_window"] = schedule_window
161
163
  return kwargs
164
+
165
+
166
+ # Known alert actions and the companion field Splunk rejects --yes without.
167
+ # One-line adds only — this is advisory, not exhaustive.
168
+ _REQUIRED_ACTION_FIELDS: dict[str, str] = {
169
+ "email": "action.email.to",
170
+ "webhook": "action.webhook.param.url",
171
+ }
172
+
173
+
174
+ def warn_missing_action_fields(
175
+ actions: str,
176
+ kwargs: dict[str, Any],
177
+ existing: dict[str, Any] | None = None,
178
+ ) -> None:
179
+ """Warn on stderr for enabled actions missing a known-required field.
180
+
181
+ Advisory only — the server remains the authority. Checks ``kwargs``
182
+ (explicit flags plus ``--set`` pairs) for the companion field; for
183
+ updates, ``existing`` (the saved search's current server-side content)
184
+ also satisfies the check. Actions outside ``_REQUIRED_ACTION_FIELDS``
185
+ are silently skipped.
186
+ """
187
+ for act in (a.strip() for a in actions.split(",") if a.strip()):
188
+ field = _REQUIRED_ACTION_FIELDS.get(act)
189
+ if field is None or kwargs.get(field):
190
+ continue
191
+ if existing is not None and existing.get(field):
192
+ continue
193
+ output.warning(
194
+ f"action '{act}' requires {field} — the server will reject "
195
+ "--yes without it."
196
+ )
@@ -12,21 +12,41 @@ from splunkctl.commands.common import read_results
12
12
  from splunkctl.commands.rules_io import export_rules, import_rules
13
13
 
14
14
 
15
- def _resolve_rule(ctx: click.Context, client: Any, name: str, app: str | None) -> Any:
16
- """Fetch a saved search, optionally within a specific app namespace."""
15
+ def _quoted(value: str) -> str:
16
+ """Quote a value for an SPL ``field=value`` search filter."""
17
+ return '"' + value.replace('"', '\\"') + '"'
18
+
19
+
20
+ def _resolve_rule(
21
+ ctx: click.Context,
22
+ client: Any,
23
+ name: str,
24
+ app: str | None,
25
+ owner: str | None = None,
26
+ ) -> Any:
27
+ """Fetch a saved search, optionally scoped to an app/owner namespace."""
17
28
  svc = client.service
18
- if app is None:
29
+ if app is None and owner is None:
19
30
  try:
20
31
  return svc.saved_searches[name]
21
32
  except KeyError:
22
33
  output.error(f"Saved search not found: {name}")
23
34
  ctx.exit(1)
24
35
  raise
25
- matches = svc.saved_searches.list(search=f"name={name}", app=app, count=10)
36
+ scope_kwargs: dict[str, str] = {}
37
+ if app is not None:
38
+ scope_kwargs["app"] = app
39
+ scope_kwargs["owner"] = owner if owner is not None else "-"
40
+ elif owner is not None:
41
+ scope_kwargs["owner"] = owner
42
+ matches = svc.saved_searches.list(
43
+ search=f"name={_quoted(name)}", count=10, **scope_kwargs
44
+ )
26
45
  for m in matches:
27
46
  if m.name == name:
28
47
  return m
29
- output.error(f"Saved search not found in app '{app}': {name}")
48
+ scope = ", ".join(f"{k}={v}" for k, v in scope_kwargs.items())
49
+ output.error(f"Saved search not found ({scope}): {name}")
30
50
  ctx.exit(1)
31
51
  raise KeyError(name)
32
52
 
@@ -71,6 +91,18 @@ _DETAIL_FIELDS = (
71
91
  )
72
92
 
73
93
 
94
+ def _action_params(c: dict[str, Any]) -> dict[str, Any]:
95
+ """Non-empty ``action.<name>.*`` content keys for each enabled action."""
96
+ actions = str(c.get("actions", "") or "")
97
+ params: dict[str, Any] = {}
98
+ for act in (a.strip() for a in actions.split(",") if a.strip()):
99
+ prefix = f"action.{act}."
100
+ for key, val in c.items():
101
+ if key.startswith(prefix) and val not in ("", None):
102
+ params[key] = val
103
+ return params
104
+
105
+
74
106
  def _detail(ss: Any) -> dict[str, Any]:
75
107
  c: dict[str, Any] = ss.content
76
108
  acl: dict[str, Any] = ss.access
@@ -81,6 +113,7 @@ def _detail(ss: Any) -> dict[str, Any]:
81
113
  "sharing": acl.get("sharing", ""),
82
114
  }
83
115
  row.update({f: c.get(f, "") for f in _DETAIL_FIELDS})
116
+ row.update(_action_params(c))
84
117
  return row
85
118
 
86
119
 
@@ -100,11 +133,34 @@ rules_group.add_command(import_rules)
100
133
  default=None,
101
134
  help="Case-insensitive name substring filter.",
102
135
  )
136
+ @click.option(
137
+ "--app",
138
+ default=None,
139
+ help="Only saved searches in this app (default: current namespace, which "
140
+ "may miss app-private rules — pass --app to see them).",
141
+ )
142
+ @click.option(
143
+ "--owner",
144
+ default=None,
145
+ help="Only saved searches owned by this user (default: current namespace).",
146
+ )
103
147
  @click.pass_context
104
- def list_rules(ctx: click.Context, name_filter: str | None) -> None:
148
+ def list_rules(
149
+ ctx: click.Context,
150
+ name_filter: str | None,
151
+ *,
152
+ app: str | None,
153
+ owner: str | None,
154
+ ) -> None:
105
155
  """List all saved searches."""
106
156
  client = get_client(ctx)
107
- items = client.service.saved_searches.list()
157
+ kwargs: dict[str, str] = {}
158
+ if app is not None:
159
+ kwargs["app"] = app
160
+ kwargs["owner"] = owner if owner is not None else "-"
161
+ elif owner is not None:
162
+ kwargs["owner"] = owner
163
+ items = client.service.saved_searches.list(**kwargs)
108
164
  if name_filter:
109
165
  needle = name_filter.lower()
110
166
  items = [ss for ss in items if needle in ss.name.lower()]
@@ -114,16 +170,17 @@ def list_rules(ctx: click.Context, name_filter: str | None) -> None:
114
170
 
115
171
  @rules_group.command()
116
172
  @click.argument("name")
173
+ @click.option(
174
+ "--app",
175
+ default=None,
176
+ help="Splunk app context (needed to resolve app-private saved searches).",
177
+ )
178
+ @click.option("--owner", default=None, help="Splunk owner context.")
117
179
  @click.pass_context
118
- def get(ctx: click.Context, name: str) -> None:
180
+ def get(ctx: click.Context, name: str, *, app: str | None, owner: str | None) -> None:
119
181
  """Get a saved search by name."""
120
182
  client = get_client(ctx)
121
- try:
122
- ss = client.service.saved_searches[name]
123
- except KeyError:
124
- output.error(f"Saved search not found: {name}")
125
- ctx.exit(1)
126
- return
183
+ ss = _resolve_rule(ctx, client, name, app, owner)
127
184
  output.render(ctx, _detail(ss))
128
185
 
129
186
 
@@ -160,6 +217,7 @@ def create(
160
217
  kwargs["description"] = description
161
218
  if actions is not None:
162
219
  kwargs["actions"] = actions
220
+ common.warn_missing_action_fields(actions, kwargs)
163
221
  if disabled:
164
222
  kwargs["disabled"] = "1"
165
223
  if app is not None:
@@ -225,12 +283,22 @@ def update(
225
283
  ctx.exit(1)
226
284
  return
227
285
 
286
+ # Only fetch the existing rule up front when a required-field check
287
+ # against server-side state is needed — keeps other dry-runs offline.
288
+ client: Any = None
289
+ ss: Any = None
290
+ if actions is not None:
291
+ client = get_client(ctx)
292
+ ss = _resolve_rule(ctx, client, name, app)
293
+ common.warn_missing_action_fields(actions, kwargs, ss.content)
294
+
228
295
  detail = "\n".join(f" {k}: {v}" for k, v in kwargs.items())
229
296
  if not guard.check(ctx, f"Update saved search '{name}'", details=detail):
230
297
  return
231
298
 
232
- client = get_client(ctx)
233
- ss = _resolve_rule(ctx, client, name, app)
299
+ if ss is None:
300
+ client = get_client(ctx)
301
+ ss = _resolve_rule(ctx, client, name, app)
234
302
  ss.update(**kwargs).refresh()
235
303
  output.info(f"Updated saved search '{name}'.")
236
304
 
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Any
6
+ from typing import Any, NotRequired, TypedDict
7
7
 
8
8
  import click
9
9
  import yaml
@@ -51,7 +51,38 @@ _TRUNC = 60
51
51
 
52
52
 
53
53
  def _trunc(val: str) -> str:
54
- return val if len(val) <= _TRUNC else val[: _TRUNC - 1] + "…"
54
+ """Truncate for the text preview, with an explicit hidden-length marker.
55
+
56
+ Never emits a bare ``…`` — a truncated value always says how many
57
+ characters were hidden, e.g. ``foo… [+57 chars]``.
58
+ """
59
+ if len(val) <= _TRUNC:
60
+ return val
61
+ kept = val[: _TRUNC - 1]
62
+ hidden = len(val) - len(kept)
63
+ return f"{kept}… [+{hidden} chars]"
64
+
65
+
66
+ class FieldChange(TypedDict):
67
+ """One field's before/after value in a rule diff."""
68
+
69
+ field: str
70
+ old: str | None
71
+ new: str
72
+
73
+
74
+ class RuleDiff(TypedDict):
75
+ """Full, untruncated diff classification for one YAML rule doc.
76
+
77
+ ``kind`` is the granular text-preview classification (create / update /
78
+ unchanged / exists / skip). JSON output collapses ``exists`` into
79
+ ``unchanged`` — see ``_json_row``.
80
+ """
81
+
82
+ name: str | None
83
+ kind: str
84
+ changes: list[FieldChange]
85
+ reason: NotRequired[str]
55
86
 
56
87
 
57
88
  def _rule_to_dict(ss: Any) -> dict[str, Any]:
@@ -154,6 +185,94 @@ def _changes(ss: Any, spl: str, kwargs: dict[str, Any]) -> dict[str, tuple[str,
154
185
  return diff
155
186
 
156
187
 
188
+ def _field_changes(diff: dict[str, tuple[str, str]]) -> list[FieldChange]:
189
+ return [{"field": k, "old": old, "new": new} for k, (old, new) in diff.items()]
190
+
191
+
192
+ def _create_changes(spl: str, kwargs: dict[str, Any]) -> list[FieldChange]:
193
+ """Full set of fields a new rule would be created with (old is None)."""
194
+ changes: list[FieldChange] = [{"field": "search", "old": None, "new": str(spl)}]
195
+ changes.extend({"field": k, "old": None, "new": str(v)} for k, v in kwargs.items())
196
+ return changes
197
+
198
+
199
+ def _rule_diff(svc: Any, rule: Any, *, update: bool) -> RuleDiff:
200
+ """Classify one YAML doc and compute its full, untruncated field diff.
201
+
202
+ This is the dry-run-only counterpart to ``_plan_rule`` (which drives
203
+ the ``--yes`` apply path and is left untouched): a single pass that
204
+ feeds both the text preview and the structured JSON diff.
205
+ """
206
+ if not isinstance(rule, dict) or "name" not in rule:
207
+ return {
208
+ "name": None,
209
+ "kind": "skip",
210
+ "changes": [],
211
+ "reason": "invalid entry: no name",
212
+ }
213
+ name = rule["name"]
214
+ spl = rule.get("search", "")
215
+ if not spl:
216
+ return {
217
+ "name": name,
218
+ "kind": "skip",
219
+ "changes": [],
220
+ "reason": "no search field",
221
+ }
222
+ kwargs = _import_kwargs(rule)
223
+ try:
224
+ ss = svc.saved_searches[name]
225
+ except KeyError:
226
+ return {"name": name, "kind": "create", "changes": _create_changes(spl, kwargs)}
227
+ if not update:
228
+ return {"name": name, "kind": "exists", "changes": []}
229
+ diff = _changes(ss, spl, kwargs)
230
+ if not diff:
231
+ return {"name": name, "kind": "unchanged", "changes": []}
232
+ return {"name": name, "kind": "update", "changes": _field_changes(diff)}
233
+
234
+
235
+ def _preview_lines(d: RuleDiff) -> list[str]:
236
+ """Render one rule's diff as the human-readable text preview lines."""
237
+ name = d["name"] or ""
238
+ kind = d["kind"]
239
+ if kind == "skip":
240
+ reason = d.get("reason", "")
241
+ label = f"{name}: {reason}" if name else reason
242
+ return [f" skip: {label}"]
243
+ if kind == "update":
244
+ lines = [f" update: {name}"]
245
+ lines.extend(
246
+ f" {c['field']}: {_trunc(str(c['old']))} -> {_trunc(c['new'])}"
247
+ for c in d["changes"]
248
+ )
249
+ return lines
250
+ return [f" {kind}: {name}"]
251
+
252
+
253
+ # no-update-mode "exists" is reported to JSON consumers as "unchanged" — it
254
+ # matches the CLI's own summary counts, which already fold the two together.
255
+ _JSON_ACTIONS = {
256
+ "create": "create",
257
+ "update": "update",
258
+ "unchanged": "unchanged",
259
+ "exists": "unchanged",
260
+ "skip": "skip",
261
+ }
262
+
263
+
264
+ def _json_row(d: RuleDiff) -> dict[str, Any]:
265
+ """Build one rule's JSON diff row: ``{name, action, changes[, reason]}``."""
266
+ row: dict[str, Any] = {
267
+ "name": d["name"],
268
+ "action": _JSON_ACTIONS[d["kind"]],
269
+ "changes": d["changes"],
270
+ }
271
+ if d["kind"] == "skip":
272
+ row["reason"] = d.get("reason", "")
273
+ return row
274
+
275
+
157
276
  def _plan_rule(svc: Any, rule: Any, *, update: bool) -> tuple[str, list[str]]:
158
277
  """Classify one YAML doc: (status line, per-field diff lines)."""
159
278
  if not isinstance(rule, dict) or "name" not in rule:
@@ -222,8 +341,13 @@ def import_rules(
222
341
  """Import saved searches from a YAML file.
223
342
 
224
343
  Dry-run previews create/update/unchanged per rule with field-level
225
- diffs. Exits non-zero when any rule is skipped or fails, so CI
226
- pipelines cannot silently pass a broken detections file.
344
+ diffs on stderr. Pass ``--json``/``--format json`` to also get a
345
+ full, untruncated JSON diff array on stdout: one object per rule,
346
+ ``{"name", "action", "changes": [{"field", "old", "new"}], "reason"}``
347
+ (on skip only). Action is one of ``create`` | ``update`` | ``unchanged``
348
+ | ``skip``; ``old`` is null for ``create``, ``changes`` is empty for
349
+ ``unchanged`` and ``skip``. Exits non-zero when any rule is skipped or
350
+ fails, so CI pipelines cannot silently pass a broken detections file.
227
351
  """
228
352
  p = Path(file_path)
229
353
  try:
@@ -241,15 +365,10 @@ def import_rules(
241
365
  client = get_client(ctx)
242
366
  svc = client.service
243
367
 
368
+ diffs = [_rule_diff(svc, rule, update=update) for rule in docs]
244
369
  plan_lines: list[str] = []
245
- planned_skips = 0
246
- for rule in docs:
247
- status, diff_lines = _plan_rule(svc, rule, update=update)
248
- kind, _, label = status.partition(":")
249
- plan_lines.append(f" {kind}: {label}")
250
- plan_lines.extend(f" {line}" for line in diff_lines)
251
- if kind == "skip":
252
- planned_skips += 1
370
+ for d in diffs:
371
+ plan_lines.extend(_preview_lines(d))
253
372
  detail = f" update existing: {update}\n" + "\n".join(plan_lines)
254
373
 
255
374
  if not guard.check(
@@ -257,6 +376,9 @@ def import_rules(
257
376
  f"Import {len(docs)} rule(s) from {p.name}",
258
377
  details=detail,
259
378
  ):
379
+ obj: dict[str, Any] = ctx.obj or {}
380
+ if obj.get("json") or obj.get("format") == "json":
381
+ output.render(ctx, [_json_row(d) for d in diffs])
260
382
  return
261
383
 
262
384
  results: list[str] = []
@@ -204,6 +204,7 @@ def list_jobs(ctx: click.Context) -> None:
204
204
  rows: list[dict[str, Any]] = []
205
205
  for job in svc.jobs:
206
206
  content: dict[str, Any] = dict(job.content)
207
+ acl: dict[str, Any] = job.access
207
208
  dur = content.get("runDuration", "")
208
209
  if isinstance(dur, (float, str)):
209
210
  try:
@@ -215,7 +216,7 @@ def list_jobs(ctx: click.Context) -> None:
215
216
  {
216
217
  "sid": job.sid,
217
218
  "status": content.get("dispatchState", ""),
218
- "owner": content.get("author", ""),
219
+ "owner": acl.get("owner", ""),
219
220
  "spl": spl[:60] + ("…" if len(spl) > 60 else ""),
220
221
  "event_count": content.get("eventCount", 0),
221
222
  "run_duration": dur,
@@ -97,11 +97,14 @@ def kvstore_status(ctx: click.Context) -> None:
97
97
  return
98
98
 
99
99
  c: dict[str, Any] = entries[0].get("content", {})
100
+ current: dict[str, Any] = c.get("current") or {}
101
+ status_raw = current.get("status")
102
+ status = "unknown" if not status_raw else str(status_raw).lower()
100
103
  row: dict[str, Any] = {
101
- "status": c.get("current.status", ""),
102
- "port": c.get("current.port", ""),
103
- "version": c.get("current.version", ""),
104
- "storage_engine": c.get("current.storageEngine", ""),
105
- "db_path": c.get("current.dbPath", ""),
104
+ "status": status,
105
+ "port": current.get("port", ""),
106
+ "version": current.get("version", ""),
107
+ "storage_engine": current.get("storageEngine", ""),
108
+ "db_path": current.get("dbPath", ""),
106
109
  }
107
110
  output.render(ctx, row)
@@ -103,6 +103,11 @@ def info(msg: str) -> None:
103
103
  click.echo(msg, err=True)
104
104
 
105
105
 
106
+ def warning(msg: str) -> None:
107
+ """Print an advisory warning to stderr. Does not affect exit code."""
108
+ click.echo(f"Warning: {msg}", err=True)
109
+
110
+
106
111
  def _csv(rows: Rows) -> str:
107
112
  if not rows:
108
113
  return ""
@@ -3,6 +3,15 @@
3
3
  You are operating a Splunk Enterprise instance via `splunkctl`. This guide
4
4
  tells you how to authenticate, run commands, and handle common workflows.
5
5
 
6
+ ## Scope
7
+
8
+ `splunkctl` targets Splunk Enterprise core over the REST API. There is no
9
+ dedicated `es` command group yet (planned for a later phase). Enterprise
10
+ Security capabilities — notables, risk, correlation-search actions,
11
+ asset/identity lookups — are reachable today through the generic search,
12
+ rules, and lookups commands documented below; see **ES recipes** under
13
+ Workflow patterns.
14
+
6
15
  ## Auth
7
16
 
8
17
  Set up credentials once — all commands inherit them automatically.
@@ -47,7 +56,9 @@ Token auth: set `SPLUNK_TOKEN` for service-account access without a password.
47
56
  - **stdout**: data payload only (table, JSON, CSV, JSONL)
48
57
  - **stderr**: info messages, errors, dry-run previews
49
58
  - **JSON format**: always a JSON array of objects, even for single items
50
- - **Exit codes**: 0 = success, 1 = error. Dry-run exits 0.
59
+ - **Exit codes**: 0 = success, 1 = application error (not found, server
60
+ rejected the request, etc.), 2 = usage error (missing/invalid flags or
61
+ arguments — Click rejects before any request is made). Dry-run exits 0.
51
62
 
52
63
  ## Commands
53
64
 
@@ -79,12 +90,16 @@ splunkctl search upload --path threats.csv --index threat_intel --yes
79
90
  SPL is auto-normalized: bare keywords get `search` prepended; pipe-leading
80
91
  and generating commands (`makeresults`, `inputlookup`, `tstats`, etc.) are
81
92
  passed through unchanged. Use `--app` to scope searches to a specific app.
93
+ The `owner` column in `search jobs` is the real submitting user (job ACL,
94
+ not the job's internal `author` field, which is always blank).
82
95
 
83
96
  ### Rules (saved searches)
84
97
 
85
98
  ```bash
86
99
  splunkctl rules list
100
+ splunkctl rules list --app Splunk_Security_Essentials # include app-private rules
87
101
  splunkctl rules get 'My Rule'
102
+ splunkctl rules get 'My Rule' --app Splunk_Security_Essentials --owner nobody
88
103
  splunkctl rules create --name 'New Rule' --search 'index=main error' \
89
104
  --cron '*/5 * * * *' --actions email --description 'Alert on errors' --yes
90
105
  splunkctl rules update 'My Rule' --search 'index=main fail' --yes
@@ -101,6 +116,41 @@ splunkctl rules import --path detections.yml --yes
101
116
  splunkctl rules import --path detections.yml --no-update --yes
102
117
  ```
103
118
 
119
+ `list`/`get` default to the current namespace, which misses saved
120
+ searches private to another app — e.g. detections shipped inside
121
+ Splunk_Security_Essentials are invisible to a plain `rules list`. Always
122
+ pass `--app` when auditing detection coverage: it automatically
123
+ wildcards the owner (`owner="-"`) so app-private rules owned by any user
124
+ are included, not just the caller's own. Add `--owner` only to narrow
125
+ the audit to a single user's rules — omitting `--app` entirely still
126
+ leaves a blind spot. `get` also surfaces every enabled action's
127
+ non-empty `action.<name>.*` params (e.g. `action.notable.param.severity`,
128
+ `action.risk.param._risk_score`) inline — no export needed just to see
129
+ what a correlation search's actions are configured to do.
130
+
131
+ `create`/`update --actions email` or `--actions webhook` warn on stderr
132
+ during dry-run if the action's required companion field is missing
133
+ (`action.email.to` for email, `action.webhook.param.url` for webhook) —
134
+ the server otherwise 400s on `--yes`. Supply it with `--set`:
135
+
136
+ ```bash
137
+ splunkctl rules create --name 'New Rule' --search 'index=main error' \
138
+ --actions email --set action.email.to=soc@bank.example --yes
139
+ ```
140
+
141
+ `--set KEY=VALUE` (repeatable, `create`/`update` only) sets any raw
142
+ saved-search REST field — the generic escape hatch for anything without
143
+ a first-class flag, including ES action params:
144
+
145
+ ```bash
146
+ splunkctl rules update 'ES Correlation Search' \
147
+ --set action.notable.param.severity=high \
148
+ --set action.notable.param.security_domain=access \
149
+ --set action.risk.param._risk_score=80 \
150
+ --set action.risk.param._risk_object_type=user \
151
+ --yes
152
+ ```
153
+
104
154
  ### Alerts
105
155
 
106
156
  ```bash
@@ -248,6 +298,10 @@ splunkctl server license # license pool usage
248
298
  splunkctl server kvstore # KV store status
249
299
  ```
250
300
 
301
+ `kvstore` always reports an explicit status word (`ready`, `failed`,
302
+ `starting`, `unknown`, ...) — never a blank field, so a down KV store
303
+ can't be mistaken for a healthy empty result.
304
+
251
305
  ### Config
252
306
 
253
307
  ```bash
@@ -276,10 +330,25 @@ splunkctl skill install # install to ~/.claude/skills/
276
330
 
277
331
  ### Investigate an alert
278
332
 
333
+ Pivot on the firing's `sid` to pull the exact triggering events — don't
334
+ re-run the detection's SPL broadly, that returns whatever matches now,
335
+ not what actually fired.
336
+
279
337
  ```bash
280
- splunkctl alerts list
281
- splunkctl rules get 'Alert Rule Name'
282
- splunkctl search run 'index=main error' --earliest -7d --limit 1000
338
+ splunkctl alerts list # each firing has a sid
339
+ splunkctl alerts get 'Alert Rule Name' # all firings for one rule, with sid
340
+ splunkctl search job <sid> # the exact triggering results
341
+ splunkctl search job <sid> --events # raw events instead of stats/table rows
342
+ ```
343
+
344
+ Fallback only: if the job's TTL has expired (`search job <sid>` errors
345
+ not-found), recover the SPL from the rule and re-run it over the firing's
346
+ time window instead:
347
+
348
+ ```bash
349
+ splunkctl rules get 'Alert Rule Name' # recover the SPL
350
+ splunkctl search run '<SPL from rules get>' \
351
+ --earliest -7d --latest now --limit 1000
283
352
  ```
284
353
 
285
354
  ### Audit detection coverage
@@ -287,6 +356,43 @@ splunkctl search run 'index=main error' --earliest -7d --limit 1000
287
356
  ```bash
288
357
  splunkctl rules list --json | jq '[.[] | select(.is_scheduled == "1")]'
289
358
  splunkctl rules list --json | jq '[.[] | select(.disabled == "1")]'
359
+ # Repeat with --app for every app that ships detections, or app-private
360
+ # rules (e.g. Splunk_Security_Essentials) are silently excluded:
361
+ splunkctl rules list --app Splunk_Security_Essentials --json | jq '[.[] | select(.disabled == "1")]'
362
+ ```
363
+
364
+ ### ES recipes
365
+
366
+ No dedicated `es` command group yet (see Scope) — these generic
367
+ recipes cover the common Enterprise Security workflows in the meantime.
368
+
369
+ ```bash
370
+ # Read notables
371
+ splunkctl search run 'index=notable' --earliest -24h --latest now --limit 100
372
+ splunkctl search run 'index=notable status_label!=Closed' --earliest -7d --limit 200
373
+
374
+ # Read risk — aggregate risk score per object
375
+ splunkctl search run 'index=risk | stats sum(risk_score) by risk_object' \
376
+ --earliest -24h --latest now
377
+
378
+ # Author/tune a correlation search's notable + risk actions via --set
379
+ splunkctl rules update 'ES Correlation Search Name' \
380
+ --set action.notable.param.severity=high \
381
+ --set action.notable.param.security_domain=access \
382
+ --set action.risk.param._risk_score=80 \
383
+ --set action.risk.param._risk_object=user \
384
+ --set action.risk.param._risk_object_type=user \
385
+ --yes
386
+ # Audit what a correlation search's actions are already set to:
387
+ splunkctl rules get 'ES Correlation Search Name' # inlines action.notable.*/action.risk.* params
388
+
389
+ # Asset/identity CSVs — managed like any other lookup table (exact
390
+ # filename/app vary by ES version, e.g. assets_by_str.csv /
391
+ # identities_lookup_by_str.csv under SplunkEnterpriseSecuritySuite):
392
+ splunkctl lookups list --app SplunkEnterpriseSecuritySuite
393
+ splunkctl lookups download assets_by_str.csv --app SplunkEnterpriseSecuritySuite
394
+ splunkctl lookups update assets_by_str.csv --file assets.csv \
395
+ --app SplunkEnterpriseSecuritySuite --yes
290
396
  ```
291
397
 
292
398
  ### Detection rule lifecycle
@@ -319,8 +425,17 @@ splunkctl rules import --path detections.yml
319
425
  splunkctl rules import --path detections.yml --yes
320
426
  # Import without updating existing rules
321
427
  splunkctl rules import --path detections.yml --no-update --yes
428
+ # Machine-readable dry-run diff, to verify programmatically before --yes
429
+ splunkctl rules import --path detections.yml --json
322
430
  ```
323
431
 
432
+ `import --json` (dry-run only, apply path is unaffected) emits a full,
433
+ untruncated diff array on stdout instead of applying anything: one
434
+ object per rule, `{"name", "action", "changes": [{"field", "old",
435
+ "new"}], "reason"}`. `action` is `create` | `update` | `unchanged` |
436
+ `skip`; `reason` is present only when `action` is `skip`; `old` is
437
+ `null` for `create`; `changes` is `[]` for `unchanged` and `skip`.
438
+
324
439
  ### Parsers-as-code
325
440
 
326
441
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -4,4 +4,4 @@ from splunkctl import __version__
4
4
 
5
5
 
6
6
  def test_version() -> None:
7
- assert __version__ == "0.3.0"
7
+ assert __version__ == "0.3.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes