splunkctl 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- splunkctl/__main__.py +5 -0
- splunkctl/client.py +103 -0
- splunkctl/commands/__init__.py +0 -0
- splunkctl/commands/alerts.py +94 -0
- splunkctl/commands/apps.py +189 -0
- splunkctl/commands/commands_meta.py +72 -0
- splunkctl/commands/config_cmd.py +87 -0
- splunkctl/commands/dashboards.py +216 -0
- splunkctl/commands/indexes.py +199 -0
- splunkctl/commands/info.py +29 -0
- splunkctl/commands/inputs.py +204 -0
- splunkctl/commands/lookups.py +217 -0
- splunkctl/commands/parsers.py +177 -0
- splunkctl/commands/rules.py +266 -0
- splunkctl/commands/search.py +270 -0
- splunkctl/commands/skill_cmd.py +43 -0
- splunkctl/commands/users.py +211 -0
- splunkctl/config.py +82 -0
- splunkctl/guard.py +22 -0
- splunkctl/main.py +149 -0
- splunkctl/output.py +75 -0
- splunkctl/skill/SKILL.md +325 -0
- splunkctl-0.1.0.dist-info/METADATA +118 -0
- splunkctl-0.1.0.dist-info/RECORD +29 -0
- splunkctl-0.1.0.dist-info/WHEEL +5 -0
- splunkctl-0.1.0.dist-info/entry_points.txt +2 -0
- splunkctl-0.1.0.dist-info/licenses/LICENSE +190 -0
- splunkctl-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Lookup table commands — raw REST (SDK gap)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from splunkctl import guard, output
|
|
11
|
+
from splunkctl.client import get_client
|
|
12
|
+
|
|
13
|
+
_READ_BASE = "/servicesNS/-/{app}/data/lookup-table-files"
|
|
14
|
+
_WRITE_BASE = "/servicesNS/nobody/{app}/data/lookup-table-files"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_path(app: str, name: str | None = None) -> str:
|
|
18
|
+
base = _READ_BASE.format(app=quote(app, safe=""))
|
|
19
|
+
return f"{base}/{quote(name, safe='')}" if name else base
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write_path(app: str, name: str | None = None) -> str:
|
|
23
|
+
base = _WRITE_BASE.format(app=quote(app, safe=""))
|
|
24
|
+
return f"{base}/{quote(name, safe='')}" if name else base
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_entries(body: bytes) -> list[dict[str, Any]]:
|
|
28
|
+
"""Extract flat rows from Splunk REST JSON response."""
|
|
29
|
+
data: dict[str, Any] = json.loads(body)
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
"name": entry.get("name", ""),
|
|
33
|
+
"app": entry.get("acl", {}).get("app", ""),
|
|
34
|
+
"owner": entry.get("acl", {}).get("owner", ""),
|
|
35
|
+
"disabled": entry.get("content", {}).get("disabled", ""),
|
|
36
|
+
"eai:type": entry.get("content", {}).get("eai:type", ""),
|
|
37
|
+
"updated": entry.get("updated", ""),
|
|
38
|
+
}
|
|
39
|
+
for entry in data.get("entry", [])
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_entry(body: bytes) -> dict[str, Any]:
|
|
44
|
+
"""Extract a single entry from Splunk REST JSON response."""
|
|
45
|
+
data: dict[str, Any] = json.loads(body)
|
|
46
|
+
entries: list[dict[str, Any]] = data.get("entry", [])
|
|
47
|
+
if not entries:
|
|
48
|
+
return {}
|
|
49
|
+
entry = entries[0]
|
|
50
|
+
content: dict[str, Any] = entry.get("content", {})
|
|
51
|
+
return {
|
|
52
|
+
"name": entry.get("name", ""),
|
|
53
|
+
"app": entry.get("acl", {}).get("app", ""),
|
|
54
|
+
"owner": entry.get("acl", {}).get("owner", ""),
|
|
55
|
+
"disabled": content.get("disabled", ""),
|
|
56
|
+
"eai:type": content.get("eai:type", ""),
|
|
57
|
+
"eai:data": content.get("eai:data", ""),
|
|
58
|
+
"updated": entry.get("updated", ""),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@click.group("lookups")
|
|
63
|
+
def lookups_group() -> None:
|
|
64
|
+
"""Manage lookup table files."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@lookups_group.command("list")
|
|
68
|
+
@click.option("--app", default="-", help="Splunk app context (default: all).")
|
|
69
|
+
@click.pass_context
|
|
70
|
+
def list_lookups(ctx: click.Context, *, app: str) -> None:
|
|
71
|
+
"""List lookup table files."""
|
|
72
|
+
client = get_client(ctx)
|
|
73
|
+
svc = client.service
|
|
74
|
+
resp = svc.get(_read_path(app), output_mode="json")
|
|
75
|
+
body: bytes = resp.body.read()
|
|
76
|
+
rows = _parse_entries(body)
|
|
77
|
+
if not rows:
|
|
78
|
+
output.info("No lookup tables found.")
|
|
79
|
+
return
|
|
80
|
+
output.render(ctx, rows)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@lookups_group.command("get")
|
|
84
|
+
@click.argument("name")
|
|
85
|
+
@click.option("--app", default="-", help="Splunk app context (default: all).")
|
|
86
|
+
@click.pass_context
|
|
87
|
+
def get_lookup(ctx: click.Context, name: str, *, app: str) -> None:
|
|
88
|
+
"""Get metadata for a lookup file."""
|
|
89
|
+
client = get_client(ctx)
|
|
90
|
+
svc = client.service
|
|
91
|
+
try:
|
|
92
|
+
resp = svc.get(_read_path(app, name), output_mode="json")
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
output.error(f"Lookup '{name}' not found: {exc}")
|
|
95
|
+
ctx.exit(1)
|
|
96
|
+
return
|
|
97
|
+
body: bytes = resp.body.read()
|
|
98
|
+
row = _parse_entry(body)
|
|
99
|
+
if not row:
|
|
100
|
+
output.error(f"Lookup '{name}' not found.")
|
|
101
|
+
ctx.exit(1)
|
|
102
|
+
return
|
|
103
|
+
output.render(ctx, row)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@lookups_group.command("upload")
|
|
107
|
+
@click.argument("name")
|
|
108
|
+
@click.option(
|
|
109
|
+
"--file",
|
|
110
|
+
"file_path",
|
|
111
|
+
required=True,
|
|
112
|
+
type=click.Path(exists=True),
|
|
113
|
+
help="CSV file to upload.",
|
|
114
|
+
)
|
|
115
|
+
@click.option("--app", default="search", help="Target app (default: search).")
|
|
116
|
+
@click.pass_context
|
|
117
|
+
def upload_lookup(ctx: click.Context, name: str, file_path: str, *, app: str) -> None:
|
|
118
|
+
"""Upload a CSV file as a new lookup table."""
|
|
119
|
+
path = Path(file_path)
|
|
120
|
+
details = f"Upload '{path.name}' as lookup '{name}' in app '{app}'"
|
|
121
|
+
if not guard.check(ctx, details):
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
client = get_client(ctx)
|
|
125
|
+
svc = client.service
|
|
126
|
+
csv_data = path.read_text(encoding="utf-8")
|
|
127
|
+
try:
|
|
128
|
+
svc.post(
|
|
129
|
+
_write_path(app),
|
|
130
|
+
name=name,
|
|
131
|
+
**{"eai:data": csv_data},
|
|
132
|
+
)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
output.error(f"Upload failed: {exc}")
|
|
135
|
+
ctx.exit(1)
|
|
136
|
+
return
|
|
137
|
+
output.info(f"Uploaded lookup '{name}' to app '{app}'.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@lookups_group.command("download")
|
|
141
|
+
@click.argument("name")
|
|
142
|
+
@click.option("--app", default="search", help="Splunk app context.")
|
|
143
|
+
@click.option("--out", type=click.Path(), default=None, help="Write CSV to file.")
|
|
144
|
+
@click.pass_context
|
|
145
|
+
def download_lookup(
|
|
146
|
+
ctx: click.Context, name: str, *, app: str, out: str | None
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Download a lookup table as CSV."""
|
|
149
|
+
client = get_client(ctx)
|
|
150
|
+
svc = client.service
|
|
151
|
+
try:
|
|
152
|
+
stream = svc.jobs.oneshot(f"| inputlookup {name}", output_mode="csv", app=app)
|
|
153
|
+
csv_content = stream.read().decode("utf-8")
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
output.error(f"Download failed: {exc}")
|
|
156
|
+
ctx.exit(1)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if out:
|
|
160
|
+
Path(out).write_text(csv_content, encoding="utf-8")
|
|
161
|
+
output.info(f"Written to {out}")
|
|
162
|
+
else:
|
|
163
|
+
click.echo(csv_content, nl=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@lookups_group.command("update")
|
|
167
|
+
@click.argument("name")
|
|
168
|
+
@click.option(
|
|
169
|
+
"--file",
|
|
170
|
+
"file_path",
|
|
171
|
+
required=True,
|
|
172
|
+
type=click.Path(exists=True),
|
|
173
|
+
help="CSV file to upload.",
|
|
174
|
+
)
|
|
175
|
+
@click.option("--app", default="search", help="Target app (default: search).")
|
|
176
|
+
@click.pass_context
|
|
177
|
+
def update_lookup(ctx: click.Context, name: str, file_path: str, *, app: str) -> None:
|
|
178
|
+
"""Overwrite an existing lookup table with new CSV data."""
|
|
179
|
+
path = Path(file_path)
|
|
180
|
+
details = f"Overwrite lookup '{name}' in app '{app}' with '{path.name}'"
|
|
181
|
+
if not guard.check(ctx, details):
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
client = get_client(ctx)
|
|
185
|
+
svc = client.service
|
|
186
|
+
csv_data = path.read_text(encoding="utf-8")
|
|
187
|
+
try:
|
|
188
|
+
svc.post(
|
|
189
|
+
_write_path(app, name),
|
|
190
|
+
**{"eai:data": csv_data},
|
|
191
|
+
)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
output.error(f"Update failed: {exc}")
|
|
194
|
+
ctx.exit(1)
|
|
195
|
+
return
|
|
196
|
+
output.info(f"Updated lookup '{name}' in app '{app}'.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@lookups_group.command("delete")
|
|
200
|
+
@click.argument("name")
|
|
201
|
+
@click.option("--app", default="search", help="Target app (default: search).")
|
|
202
|
+
@click.pass_context
|
|
203
|
+
def delete_lookup(ctx: click.Context, name: str, *, app: str) -> None:
|
|
204
|
+
"""Delete a lookup table file."""
|
|
205
|
+
details = f"Delete lookup '{name}' from app '{app}'"
|
|
206
|
+
if not guard.check(ctx, details):
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
client = get_client(ctx)
|
|
210
|
+
svc = client.service
|
|
211
|
+
try:
|
|
212
|
+
svc.delete(_write_path(app, name))
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
output.error(f"Delete failed: {exc}")
|
|
215
|
+
ctx.exit(1)
|
|
216
|
+
return
|
|
217
|
+
output.info(f"Deleted lookup '{name}' from app '{app}'.")
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Parser / sourcetype commands — props.conf and transforms.conf via confs API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from splunkctl import guard, output
|
|
8
|
+
from splunkctl.client import get_client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group("parsers")
|
|
12
|
+
def parsers_group() -> None:
|
|
13
|
+
"""Manage source types and field extractions."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@parsers_group.command("sourcetypes")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def sourcetypes(ctx: click.Context) -> None:
|
|
19
|
+
"""List source types from props.conf."""
|
|
20
|
+
client = get_client(ctx)
|
|
21
|
+
conf = client.service.confs["props"]
|
|
22
|
+
rows: list[dict[str, Any]] = [
|
|
23
|
+
{
|
|
24
|
+
"name": s.name,
|
|
25
|
+
"category": s.content.get("category", ""),
|
|
26
|
+
"description": s.content.get("description", ""),
|
|
27
|
+
"TRANSFORMS": s.content.get("TRANSFORMS", ""),
|
|
28
|
+
}
|
|
29
|
+
for s in conf.list()
|
|
30
|
+
]
|
|
31
|
+
output.render(ctx, rows)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@parsers_group.command("get")
|
|
35
|
+
@click.argument("sourcetype")
|
|
36
|
+
@click.pass_context
|
|
37
|
+
def get_sourcetype(ctx: click.Context, sourcetype: str) -> None:
|
|
38
|
+
"""Show full props.conf settings for a sourcetype."""
|
|
39
|
+
client = get_client(ctx)
|
|
40
|
+
conf = client.service.confs["props"]
|
|
41
|
+
try:
|
|
42
|
+
stanza = conf[sourcetype]
|
|
43
|
+
except KeyError:
|
|
44
|
+
output.error(f"Sourcetype '{sourcetype}' not found.")
|
|
45
|
+
ctx.exit(1)
|
|
46
|
+
return
|
|
47
|
+
row: dict[str, Any] = {"name": stanza.name, **dict(stanza.content)}
|
|
48
|
+
output.render(ctx, row)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@parsers_group.command("extractions")
|
|
52
|
+
@click.option("--sourcetype", default=None, help="Filter by name substring.")
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def extractions(ctx: click.Context, sourcetype: str | None) -> None:
|
|
55
|
+
"""List field extractions from transforms.conf."""
|
|
56
|
+
client = get_client(ctx)
|
|
57
|
+
conf = client.service.confs["transforms"]
|
|
58
|
+
rows: list[dict[str, Any]] = []
|
|
59
|
+
for stanza in conf.list():
|
|
60
|
+
if sourcetype and sourcetype not in stanza.name:
|
|
61
|
+
continue
|
|
62
|
+
rows.append(
|
|
63
|
+
{
|
|
64
|
+
"name": stanza.name,
|
|
65
|
+
"REGEX": stanza.content.get("REGEX", ""),
|
|
66
|
+
"FORMAT": stanza.content.get("FORMAT", ""),
|
|
67
|
+
"DEST_KEY": stanza.content.get("DEST_KEY", ""),
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
output.render(ctx, rows)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@parsers_group.command("create")
|
|
74
|
+
@click.option("--sourcetype", required=True, help="Sourcetype name.")
|
|
75
|
+
@click.option("--category", default=None, help="Category value.")
|
|
76
|
+
@click.option(
|
|
77
|
+
"--transforms",
|
|
78
|
+
"transforms_val",
|
|
79
|
+
default=None,
|
|
80
|
+
help="TRANSFORMS value.",
|
|
81
|
+
)
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def create_sourcetype(
|
|
84
|
+
ctx: click.Context,
|
|
85
|
+
sourcetype: str,
|
|
86
|
+
category: str | None,
|
|
87
|
+
transforms_val: str | None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Create a props.conf stanza."""
|
|
90
|
+
kwargs: dict[str, str] = {}
|
|
91
|
+
if category:
|
|
92
|
+
kwargs["category"] = category
|
|
93
|
+
if transforms_val:
|
|
94
|
+
kwargs["TRANSFORMS"] = transforms_val
|
|
95
|
+
|
|
96
|
+
details = f" sourcetype: {sourcetype}"
|
|
97
|
+
for k, v in kwargs.items():
|
|
98
|
+
details += f"\n {k}: {v}"
|
|
99
|
+
|
|
100
|
+
if not guard.check(ctx, f"Create sourcetype '{sourcetype}'", details=details):
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
client = get_client(ctx)
|
|
104
|
+
conf = client.service.confs["props"]
|
|
105
|
+
conf.create(sourcetype, **kwargs)
|
|
106
|
+
output.info(f"Created sourcetype '{sourcetype}'.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@parsers_group.command("update")
|
|
110
|
+
@click.argument("sourcetype")
|
|
111
|
+
@click.option("--category", default=None, help="Category value.")
|
|
112
|
+
@click.option(
|
|
113
|
+
"--transforms",
|
|
114
|
+
"transforms_val",
|
|
115
|
+
default=None,
|
|
116
|
+
help="TRANSFORMS value.",
|
|
117
|
+
)
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def update_sourcetype(
|
|
120
|
+
ctx: click.Context,
|
|
121
|
+
sourcetype: str,
|
|
122
|
+
category: str | None,
|
|
123
|
+
transforms_val: str | None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Update a props.conf stanza."""
|
|
126
|
+
kwargs: dict[str, str] = {}
|
|
127
|
+
if category:
|
|
128
|
+
kwargs["category"] = category
|
|
129
|
+
if transforms_val:
|
|
130
|
+
kwargs["TRANSFORMS"] = transforms_val
|
|
131
|
+
|
|
132
|
+
if not kwargs:
|
|
133
|
+
output.error("Nothing to update — pass at least one option.")
|
|
134
|
+
ctx.exit(1)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
details = f" sourcetype: {sourcetype}"
|
|
138
|
+
for k, v in kwargs.items():
|
|
139
|
+
details += f"\n {k}: {v}"
|
|
140
|
+
|
|
141
|
+
if not guard.check(ctx, f"Update sourcetype '{sourcetype}'", details=details):
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
client = get_client(ctx)
|
|
145
|
+
conf = client.service.confs["props"]
|
|
146
|
+
try:
|
|
147
|
+
stanza = conf[sourcetype]
|
|
148
|
+
except KeyError:
|
|
149
|
+
output.error(f"Sourcetype '{sourcetype}' not found.")
|
|
150
|
+
ctx.exit(1)
|
|
151
|
+
return
|
|
152
|
+
stanza.update(**kwargs).refresh()
|
|
153
|
+
output.info(f"Updated sourcetype '{sourcetype}'.")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@parsers_group.command("delete")
|
|
157
|
+
@click.argument("sourcetype")
|
|
158
|
+
@click.pass_context
|
|
159
|
+
def delete_sourcetype(ctx: click.Context, sourcetype: str) -> None:
|
|
160
|
+
"""Delete a props.conf stanza."""
|
|
161
|
+
if not guard.check(
|
|
162
|
+
ctx,
|
|
163
|
+
f"Delete sourcetype '{sourcetype}'",
|
|
164
|
+
details=f" sourcetype: {sourcetype}",
|
|
165
|
+
):
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
client = get_client(ctx)
|
|
169
|
+
conf = client.service.confs["props"]
|
|
170
|
+
try:
|
|
171
|
+
stanza = conf[sourcetype]
|
|
172
|
+
except KeyError:
|
|
173
|
+
output.error(f"Sourcetype '{sourcetype}' not found.")
|
|
174
|
+
ctx.exit(1)
|
|
175
|
+
return
|
|
176
|
+
stanza.delete()
|
|
177
|
+
output.info(f"Deleted sourcetype '{sourcetype}'.")
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Saved searches / detection rules."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from splunkctl import guard, output
|
|
8
|
+
from splunkctl.client import get_client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _summarize(ss: Any) -> dict[str, Any]:
|
|
12
|
+
c: dict[str, Any] = ss.content
|
|
13
|
+
acl: dict[str, Any] = ss.access
|
|
14
|
+
return {
|
|
15
|
+
"name": ss.name,
|
|
16
|
+
"app": acl.get("app", ""),
|
|
17
|
+
"is_scheduled": c.get("is_scheduled", "0"),
|
|
18
|
+
"cron": c.get("cron_schedule", ""),
|
|
19
|
+
"next_scheduled": c.get("next_scheduled_time", ""),
|
|
20
|
+
"disabled": c.get("disabled", "0"),
|
|
21
|
+
"actions": c.get("actions", ""),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_DETAIL_FIELDS = (
|
|
26
|
+
"search",
|
|
27
|
+
"description",
|
|
28
|
+
"cron_schedule",
|
|
29
|
+
"is_scheduled",
|
|
30
|
+
"next_scheduled_time",
|
|
31
|
+
"disabled",
|
|
32
|
+
"actions",
|
|
33
|
+
"alert_type",
|
|
34
|
+
"alert.severity",
|
|
35
|
+
"alert.suppress",
|
|
36
|
+
"dispatch.earliest_time",
|
|
37
|
+
"dispatch.latest_time",
|
|
38
|
+
"max_concurrent",
|
|
39
|
+
"realtime_schedule",
|
|
40
|
+
"request.ui_dispatch_app",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _detail(ss: Any) -> dict[str, Any]:
|
|
45
|
+
c: dict[str, Any] = ss.content
|
|
46
|
+
row: dict[str, Any] = {"name": ss.name}
|
|
47
|
+
row.update({f: c.get(f, "") for f in _DETAIL_FIELDS})
|
|
48
|
+
return row
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.group("rules")
|
|
52
|
+
def rules_group() -> None:
|
|
53
|
+
"""Manage detection rules (saved searches)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@rules_group.command("list")
|
|
57
|
+
@click.pass_context
|
|
58
|
+
def list_rules(ctx: click.Context) -> None:
|
|
59
|
+
"""List all saved searches."""
|
|
60
|
+
client = get_client(ctx)
|
|
61
|
+
items = client.service.saved_searches.list()
|
|
62
|
+
rows = [_summarize(ss) for ss in items]
|
|
63
|
+
output.render(ctx, rows)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@rules_group.command()
|
|
67
|
+
@click.argument("name")
|
|
68
|
+
@click.pass_context
|
|
69
|
+
def get(ctx: click.Context, name: str) -> None:
|
|
70
|
+
"""Get a saved search by name."""
|
|
71
|
+
client = get_client(ctx)
|
|
72
|
+
try:
|
|
73
|
+
ss = client.service.saved_searches[name]
|
|
74
|
+
except KeyError:
|
|
75
|
+
output.error(f"Saved search not found: {name}")
|
|
76
|
+
ctx.exit(1)
|
|
77
|
+
return
|
|
78
|
+
output.render(ctx, _detail(ss))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@rules_group.command()
|
|
82
|
+
@click.option("--name", required=True, help="Saved search name.")
|
|
83
|
+
@click.option("--search", "spl", required=True, help="SPL query.")
|
|
84
|
+
@click.option("--cron", default=None, help="Cron schedule.")
|
|
85
|
+
@click.option("--app", default=None, help="Splunk app context.")
|
|
86
|
+
@click.option("--description", default=None, help="Description.")
|
|
87
|
+
@click.option("--actions", default=None, help="Alert actions (comma-separated).")
|
|
88
|
+
@click.option("--disabled", is_flag=True, default=False, help="Create disabled.")
|
|
89
|
+
@click.pass_context
|
|
90
|
+
def create(
|
|
91
|
+
ctx: click.Context,
|
|
92
|
+
*,
|
|
93
|
+
name: str,
|
|
94
|
+
spl: str,
|
|
95
|
+
cron: str | None,
|
|
96
|
+
app: str | None,
|
|
97
|
+
description: str | None,
|
|
98
|
+
actions: str | None,
|
|
99
|
+
disabled: bool,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Create a saved search."""
|
|
102
|
+
kwargs: dict[str, Any] = {}
|
|
103
|
+
if cron is not None:
|
|
104
|
+
kwargs["cron_schedule"] = cron
|
|
105
|
+
kwargs["is_scheduled"] = "1"
|
|
106
|
+
if description is not None:
|
|
107
|
+
kwargs["description"] = description
|
|
108
|
+
if actions is not None:
|
|
109
|
+
kwargs["actions"] = actions
|
|
110
|
+
if disabled:
|
|
111
|
+
kwargs["disabled"] = "1"
|
|
112
|
+
if app is not None:
|
|
113
|
+
kwargs["app"] = app
|
|
114
|
+
|
|
115
|
+
detail = f" name: {name}\n search: {spl}"
|
|
116
|
+
if kwargs:
|
|
117
|
+
detail += "\n " + "\n ".join(f"{k}: {v}" for k, v in kwargs.items())
|
|
118
|
+
|
|
119
|
+
if not guard.check(ctx, f"Create saved search '{name}'", details=detail):
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
client = get_client(ctx)
|
|
123
|
+
client.service.saved_searches.create(name, search=spl, **kwargs)
|
|
124
|
+
output.info(f"Created saved search '{name}'.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@rules_group.command()
|
|
128
|
+
@click.argument("name")
|
|
129
|
+
@click.option("--search", "spl", default=None, help="SPL query.")
|
|
130
|
+
@click.option("--cron", default=None, help="Cron schedule.")
|
|
131
|
+
@click.option("--description", default=None, help="Description.")
|
|
132
|
+
@click.option("--actions", default=None, help="Alert actions (comma-separated).")
|
|
133
|
+
@click.option(
|
|
134
|
+
"--enabled/--disabled",
|
|
135
|
+
default=None,
|
|
136
|
+
help="Enable or disable scheduling.",
|
|
137
|
+
)
|
|
138
|
+
@click.pass_context
|
|
139
|
+
def update(
|
|
140
|
+
ctx: click.Context,
|
|
141
|
+
name: str,
|
|
142
|
+
*,
|
|
143
|
+
spl: str | None,
|
|
144
|
+
cron: str | None,
|
|
145
|
+
description: str | None,
|
|
146
|
+
actions: str | None,
|
|
147
|
+
enabled: bool | None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Update a saved search."""
|
|
150
|
+
kwargs: dict[str, Any] = {}
|
|
151
|
+
if spl is not None:
|
|
152
|
+
kwargs["search"] = spl
|
|
153
|
+
if cron is not None:
|
|
154
|
+
kwargs["cron_schedule"] = cron
|
|
155
|
+
if description is not None:
|
|
156
|
+
kwargs["description"] = description
|
|
157
|
+
if actions is not None:
|
|
158
|
+
kwargs["actions"] = actions
|
|
159
|
+
if enabled is not None:
|
|
160
|
+
kwargs["disabled"] = "0" if enabled else "1"
|
|
161
|
+
if enabled:
|
|
162
|
+
kwargs["is_scheduled"] = "1"
|
|
163
|
+
|
|
164
|
+
if not kwargs:
|
|
165
|
+
output.error("No changes specified.")
|
|
166
|
+
ctx.exit(1)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
detail = "\n".join(f" {k}: {v}" for k, v in kwargs.items())
|
|
170
|
+
if not guard.check(ctx, f"Update saved search '{name}'", details=detail):
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
client = get_client(ctx)
|
|
174
|
+
try:
|
|
175
|
+
ss = client.service.saved_searches[name]
|
|
176
|
+
except KeyError:
|
|
177
|
+
output.error(f"Saved search not found: {name}")
|
|
178
|
+
ctx.exit(1)
|
|
179
|
+
return
|
|
180
|
+
ss.update(**kwargs).refresh()
|
|
181
|
+
output.info(f"Updated saved search '{name}'.")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@rules_group.command()
|
|
185
|
+
@click.argument("name")
|
|
186
|
+
@click.pass_context
|
|
187
|
+
def delete(ctx: click.Context, name: str) -> None:
|
|
188
|
+
"""Delete a saved search."""
|
|
189
|
+
if not guard.check(ctx, f"Delete saved search '{name}'"):
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
client = get_client(ctx)
|
|
193
|
+
try:
|
|
194
|
+
ss = client.service.saved_searches[name]
|
|
195
|
+
except KeyError:
|
|
196
|
+
output.error(f"Saved search not found: {name}")
|
|
197
|
+
ctx.exit(1)
|
|
198
|
+
return
|
|
199
|
+
ss.delete()
|
|
200
|
+
output.info(f"Deleted saved search '{name}'.")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@rules_group.command()
|
|
204
|
+
@click.argument("name")
|
|
205
|
+
@click.pass_context
|
|
206
|
+
def enable(ctx: click.Context, name: str) -> None:
|
|
207
|
+
"""Enable scheduling for a saved search."""
|
|
208
|
+
if not guard.check(ctx, f"Enable saved search '{name}'"):
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
client = get_client(ctx)
|
|
212
|
+
try:
|
|
213
|
+
ss = client.service.saved_searches[name]
|
|
214
|
+
except KeyError:
|
|
215
|
+
output.error(f"Saved search not found: {name}")
|
|
216
|
+
ctx.exit(1)
|
|
217
|
+
return
|
|
218
|
+
ss.update(disabled="0", is_scheduled="1").refresh()
|
|
219
|
+
output.info(f"Enabled saved search '{name}'.")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@rules_group.command()
|
|
223
|
+
@click.argument("name")
|
|
224
|
+
@click.pass_context
|
|
225
|
+
def disable(ctx: click.Context, name: str) -> None:
|
|
226
|
+
"""Disable scheduling for a saved search."""
|
|
227
|
+
if not guard.check(ctx, f"Disable saved search '{name}'"):
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
client = get_client(ctx)
|
|
231
|
+
try:
|
|
232
|
+
ss = client.service.saved_searches[name]
|
|
233
|
+
except KeyError:
|
|
234
|
+
output.error(f"Saved search not found: {name}")
|
|
235
|
+
ctx.exit(1)
|
|
236
|
+
return
|
|
237
|
+
ss.update(disabled="1").refresh()
|
|
238
|
+
output.info(f"Disabled saved search '{name}'.")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@rules_group.command()
|
|
242
|
+
@click.argument("name")
|
|
243
|
+
@click.pass_context
|
|
244
|
+
def history(ctx: click.Context, name: str) -> None:
|
|
245
|
+
"""Show run history for a saved search."""
|
|
246
|
+
client = get_client(ctx)
|
|
247
|
+
try:
|
|
248
|
+
ss = client.service.saved_searches[name]
|
|
249
|
+
except KeyError:
|
|
250
|
+
output.error(f"Saved search not found: {name}")
|
|
251
|
+
ctx.exit(1)
|
|
252
|
+
return
|
|
253
|
+
jobs = ss.history()
|
|
254
|
+
rows: list[dict[str, Any]] = []
|
|
255
|
+
for job in jobs:
|
|
256
|
+
c: dict[str, Any] = job.content
|
|
257
|
+
rows.append(
|
|
258
|
+
{
|
|
259
|
+
"sid": job.sid,
|
|
260
|
+
"dispatch_state": c.get("dispatchState", ""),
|
|
261
|
+
"run_duration": c.get("runDuration", ""),
|
|
262
|
+
"event_count": c.get("eventCount", ""),
|
|
263
|
+
"result_count": c.get("resultCount", ""),
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
output.render(ctx, rows)
|