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.
- {splunkctl-0.3.0 → splunkctl-0.3.1}/PKG-INFO +1 -1
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/__init__.py +1 -1
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/common.py +35 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/rules.py +84 -16
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/rules_io.py +134 -12
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/search.py +2 -1
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/server.py +8 -5
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/output.py +5 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/skill/SKILL.md +119 -4
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/PKG-INFO +1 -1
- {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_version.py +1 -1
- {splunkctl-0.3.0 → splunkctl-0.3.1}/LICENSE +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/README.md +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/pyproject.toml +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/setup.cfg +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/__main__.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/client.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/config_cmd.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/dashboards.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/hec.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/lookups.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/skill_cmd.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/commands/users.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/config.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/guard.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl/main.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/SOURCES.txt +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_client.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_config.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_guard.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_main_hoisting.py +0 -0
- {splunkctl-0.3.0 → splunkctl-0.3.1}/tests/test_output.py +0 -0
|
@@ -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
|
|
16
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
226
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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":
|
|
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":
|
|
102
|
-
"port":
|
|
103
|
-
"version":
|
|
104
|
-
"storage_engine":
|
|
105
|
-
"db_path":
|
|
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
|
|
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
|
|
282
|
-
splunkctl search
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|