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 ADDED
@@ -0,0 +1,3 @@
1
+ """CLI tool for Splunk Enterprise SIEM operations."""
2
+
3
+ __version__ = "0.1.0"
splunkctl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m splunkctl`."""
2
+
3
+ from splunkctl.main import cli
4
+
5
+ cli()
splunkctl/client.py ADDED
@@ -0,0 +1,103 @@
1
+ """SDK wrapper — lazy connection and auth resolution."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import click
7
+ import splunklib.client as splunk_client
8
+
9
+ from splunkctl import config as cfg_mod
10
+
11
+
12
+ class SplunkClient:
13
+ """Lazy-initializing Splunk SDK client.
14
+
15
+ Connection is established on first access to ``.service``.
16
+ Help, config, and offline commands never trigger auth.
17
+ """
18
+
19
+ def __init__( # noqa: D107
20
+ self,
21
+ *,
22
+ config_path: Path | None = None,
23
+ host: str | None = None,
24
+ port: int | None = None,
25
+ username: str | None = None,
26
+ password: str | None = None,
27
+ token: str | None = None,
28
+ scheme: str | None = None,
29
+ app: str | None = None,
30
+ owner: str | None = None,
31
+ verify: bool | None = None,
32
+ timeout: int = 30,
33
+ debug: bool = False,
34
+ ) -> None:
35
+ self._config_path = config_path
36
+ self._overrides: dict[str, Any] = {
37
+ k: v
38
+ for k, v in {
39
+ "host": host,
40
+ "port": port,
41
+ "username": username,
42
+ "password": password,
43
+ "token": token,
44
+ "scheme": scheme,
45
+ "app": app,
46
+ "owner": owner,
47
+ "verify": verify,
48
+ }.items()
49
+ if v is not None
50
+ }
51
+ self._timeout = timeout
52
+ self._debug = debug
53
+ self._service: Any = None
54
+
55
+ @property
56
+ def service(self) -> Any:
57
+ """Connect on first access."""
58
+ if self._service is None:
59
+ if self._debug:
60
+ import logging
61
+
62
+ logging.basicConfig(level=logging.DEBUG)
63
+ logging.getLogger("splunklib").setLevel(logging.DEBUG)
64
+
65
+ cfg = cfg_mod.load(self._config_path)
66
+ cfg.update(self._overrides)
67
+
68
+ connect_args: dict[str, Any] = {
69
+ "host": cfg.get("host", "localhost"),
70
+ "port": int(cfg.get("port", 8089)),
71
+ "scheme": cfg.get("scheme", "https"),
72
+ }
73
+
74
+ if cfg.get("token"):
75
+ connect_args["splunkToken"] = cfg["token"]
76
+ else:
77
+ connect_args["username"] = cfg.get("username", "")
78
+ connect_args["password"] = cfg.get("password", "")
79
+
80
+ if cfg.get("app"):
81
+ connect_args["app"] = cfg["app"]
82
+ if cfg.get("owner"):
83
+ connect_args["owner"] = cfg["owner"]
84
+
85
+ if not cfg.get("verify", False):
86
+ connect_args["verify"] = False
87
+
88
+ if self._timeout:
89
+ connect_args["timeout"] = self._timeout
90
+
91
+ self._service = splunk_client.connect(**connect_args)
92
+
93
+ return self._service
94
+
95
+
96
+ def get_client(ctx: click.Context) -> SplunkClient:
97
+ """Build a SplunkClient from Click context. Does not connect."""
98
+ obj: dict[str, Any] = ctx.ensure_object(dict)
99
+ return SplunkClient(
100
+ config_path=Path(obj["config"]) if obj.get("config") else None,
101
+ timeout=obj.get("timeout", 30),
102
+ debug=obj.get("debug", False),
103
+ )
File without changes
@@ -0,0 +1,94 @@
1
+ """Alerts commands — fired alerts, alert actions, suppression."""
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ import click
7
+
8
+ from splunkctl import output
9
+ from splunkctl.client import get_client
10
+ from splunkctl.guard import check
11
+
12
+
13
+ @click.group("alerts")
14
+ def alerts_group() -> None:
15
+ """Manage fired alerts and alert actions."""
16
+
17
+
18
+ @alerts_group.command("list")
19
+ @click.pass_context
20
+ def list_alerts(ctx: click.Context) -> None:
21
+ """List fired alerts."""
22
+ client = get_client(ctx)
23
+ rows: list[dict[str, Any]] = []
24
+ for alert in client.service.fired_alerts:
25
+ if alert.name == "-":
26
+ continue
27
+ content: dict[str, Any] = dict(alert.content)
28
+ rows.append(
29
+ {
30
+ "name": alert.name,
31
+ "count": alert.count,
32
+ "triggered_time": content.get("triggered_time", ""),
33
+ "severity": content.get("severity", ""),
34
+ }
35
+ )
36
+ if not rows:
37
+ output.info("No fired alerts.")
38
+ return
39
+ output.render(ctx, rows)
40
+
41
+
42
+ @alerts_group.command("get")
43
+ @click.argument("name")
44
+ @click.pass_context
45
+ def get_alert(ctx: click.Context, name: str) -> None:
46
+ """Get details of a specific fired alert group."""
47
+ client = get_client(ctx)
48
+ try:
49
+ alert = client.service.fired_alerts[name]
50
+ except KeyError:
51
+ output.error(f"Fired alert not found: {name}")
52
+ ctx.exit(1)
53
+ return
54
+ row: dict[str, Any] = {"name": alert.name, "count": alert.count}
55
+ row.update(dict(alert.content))
56
+ output.render(ctx, row)
57
+
58
+
59
+ @alerts_group.command("actions")
60
+ @click.pass_context
61
+ def list_actions(ctx: click.Context) -> None:
62
+ """List available alert action types."""
63
+ client = get_client(ctx)
64
+ rows: list[dict[str, Any]] = []
65
+ for stanza in client.service.confs["alert_actions"]:
66
+ content: dict[str, Any] = dict(stanza.content)
67
+ rows.append(
68
+ {
69
+ "name": stanza.name,
70
+ "label": content.get("label", ""),
71
+ "description": content.get("description", ""),
72
+ }
73
+ )
74
+ output.render(ctx, rows)
75
+
76
+
77
+ @alerts_group.command("suppress")
78
+ @click.argument("name")
79
+ @click.option(
80
+ "--duration",
81
+ type=int,
82
+ default=3600,
83
+ help="Suppression duration in seconds.",
84
+ )
85
+ @click.pass_context
86
+ def suppress_alert(ctx: click.Context, name: str, duration: int) -> None:
87
+ """Suppress a fired alert (guarded)."""
88
+ details = f"Suppress '{name}' for {duration}s"
89
+ if not check(ctx, "suppress fired alert", details=details):
90
+ return
91
+ client = get_client(ctx)
92
+ path = f"/services/alerts/fired_alerts/{quote(name, safe='')}"
93
+ client.service.post(path, suppress="1", expiration=str(duration))
94
+ output.info(f"Suppressed: {name} for {duration}s")
@@ -0,0 +1,189 @@
1
+ """App management commands — list, get, install, uninstall, update, reload."""
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
+ _APP_FIELDS = ("label", "version", "visible", "disabled", "author", "description")
11
+
12
+
13
+ def _app_row(app: Any) -> dict[str, Any]:
14
+ content: dict[str, Any] = dict(app.content)
15
+ return {
16
+ "name": app.name,
17
+ **{f: content.get(f, "") for f in _APP_FIELDS},
18
+ }
19
+
20
+
21
+ def _list_fields(app: Any) -> dict[str, Any]:
22
+ content: dict[str, Any] = dict(app.content)
23
+ return {
24
+ "name": app.name,
25
+ "label": content.get("label", ""),
26
+ "version": content.get("version", ""),
27
+ "visible": content.get("visible", ""),
28
+ "disabled": content.get("disabled", ""),
29
+ "author": content.get("author", ""),
30
+ }
31
+
32
+
33
+ @click.group("apps")
34
+ def apps_group() -> None:
35
+ """Manage Splunk apps."""
36
+
37
+
38
+ @apps_group.command("list")
39
+ @click.pass_context
40
+ def list_apps(ctx: click.Context) -> None:
41
+ """List installed apps."""
42
+ client = get_client(ctx)
43
+ svc = client.service
44
+ apps = svc.apps.list()
45
+ if not apps:
46
+ output.info("No apps found.")
47
+ return
48
+ rows = [_list_fields(a) for a in apps]
49
+ output.render(ctx, rows)
50
+
51
+
52
+ @apps_group.command("get")
53
+ @click.argument("name")
54
+ @click.pass_context
55
+ def get_app(ctx: click.Context, name: str) -> None:
56
+ """Get app details."""
57
+ client = get_client(ctx)
58
+ svc = client.service
59
+ try:
60
+ app = svc.apps[name]
61
+ except KeyError:
62
+ output.error(f"App '{name}' not found.")
63
+ ctx.exit(1)
64
+ return
65
+ output.render(ctx, _app_row(app))
66
+
67
+
68
+ @apps_group.command("install")
69
+ @click.option("--name", required=True, help="App name.")
70
+ @click.option(
71
+ "--path",
72
+ "file_path",
73
+ required=True,
74
+ type=click.Path(exists=True),
75
+ help="Path to .tar.gz or .spl package.",
76
+ )
77
+ @click.pass_context
78
+ def install_app(ctx: click.Context, name: str, file_path: str) -> None:
79
+ """Install an app from a .tar.gz or .spl file."""
80
+ details = f"Install app '{name}' from '{file_path}'"
81
+ if not guard.check(ctx, details):
82
+ return
83
+
84
+ client = get_client(ctx)
85
+ svc = client.service
86
+ try:
87
+ svc.post(
88
+ "/services/apps/local",
89
+ name=name,
90
+ update=True,
91
+ filename=True,
92
+ **{"appfile": file_path},
93
+ )
94
+ except Exception as exc:
95
+ output.error(f"Install failed: {exc}")
96
+ ctx.exit(1)
97
+ return
98
+ output.info(f"Installed app '{name}'.")
99
+
100
+
101
+ @apps_group.command("uninstall")
102
+ @click.argument("name")
103
+ @click.pass_context
104
+ def uninstall_app(ctx: click.Context, name: str) -> None:
105
+ """Uninstall an app."""
106
+ details = f"Uninstall app '{name}'"
107
+ if not guard.check(ctx, details):
108
+ return
109
+
110
+ client = get_client(ctx)
111
+ svc = client.service
112
+ try:
113
+ app = svc.apps[name]
114
+ app.delete()
115
+ except KeyError:
116
+ output.error(f"App '{name}' not found.")
117
+ ctx.exit(1)
118
+ return
119
+ except Exception as exc:
120
+ output.error(f"Uninstall failed: {exc}")
121
+ ctx.exit(1)
122
+ return
123
+ output.info(f"Uninstalled app '{name}'.")
124
+
125
+
126
+ @apps_group.command("update")
127
+ @click.argument("name")
128
+ @click.option("--visible/--hidden", default=None, help="Set app visibility.")
129
+ @click.option("--enabled/--disabled", default=None, help="Enable or disable the app.")
130
+ @click.pass_context
131
+ def update_app(
132
+ ctx: click.Context,
133
+ name: str,
134
+ visible: bool | None,
135
+ enabled: bool | None,
136
+ ) -> None:
137
+ """Update app settings."""
138
+ kwargs: dict[str, Any] = {}
139
+ if visible is not None:
140
+ kwargs["visible"] = visible
141
+ if enabled is not None:
142
+ kwargs["disabled"] = not enabled
143
+ if not kwargs:
144
+ msg = "No settings specified. Use --visible/--hidden or --enabled/--disabled."
145
+ output.error(msg)
146
+ ctx.exit(1)
147
+ return
148
+
149
+ parts: list[str] = []
150
+ if visible is not None:
151
+ parts.append(f"visible={'true' if visible else 'false'}")
152
+ if enabled is not None:
153
+ parts.append(f"disabled={'false' if enabled else 'true'}")
154
+ details = f"Update app '{name}': {', '.join(parts)}"
155
+ if not guard.check(ctx, details):
156
+ return
157
+
158
+ client = get_client(ctx)
159
+ svc = client.service
160
+ try:
161
+ app = svc.apps[name]
162
+ app.update(**kwargs)
163
+ except KeyError:
164
+ output.error(f"App '{name}' not found.")
165
+ ctx.exit(1)
166
+ return
167
+ except Exception as exc:
168
+ output.error(f"Update failed: {exc}")
169
+ ctx.exit(1)
170
+ return
171
+ output.info(f"Updated app '{name}'.")
172
+
173
+
174
+ @apps_group.command("reload")
175
+ @click.pass_context
176
+ def reload_apps(ctx: click.Context) -> None:
177
+ """Reload all apps."""
178
+ if not guard.check(ctx, "Reload all apps"):
179
+ return
180
+
181
+ client = get_client(ctx)
182
+ svc = client.service
183
+ try:
184
+ svc.get("/services/apps/local/_reload")
185
+ except Exception as exc:
186
+ output.error(f"Reload failed: {exc}")
187
+ ctx.exit(1)
188
+ return
189
+ output.info("Apps reloaded.")
@@ -0,0 +1,72 @@
1
+ """Self-discovery — machine-readable command tree for agents."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import click
7
+
8
+ from splunkctl import __version__, output
9
+
10
+
11
+ def _param_entry(p: click.Parameter) -> dict[str, Any]:
12
+ """Build a single parameter descriptor."""
13
+ entry: dict[str, Any] = {
14
+ "name": p.name or "",
15
+ "type": p.type.name if hasattr(p.type, "name") else str(p.type),
16
+ }
17
+ if isinstance(p, click.Argument):
18
+ entry["kind"] = "argument"
19
+ elif isinstance(p, click.Option):
20
+ entry["kind"] = "option"
21
+ if p.opts:
22
+ entry["flags"] = p.opts
23
+ if p.is_flag:
24
+ entry["type"] = "flag"
25
+ if p.required:
26
+ entry["required"] = True
27
+ if p.help:
28
+ entry["help"] = p.help
29
+ if isinstance(p.type, click.Choice):
30
+ entry["choices"] = list(p.type.choices)
31
+ if not (isinstance(p, click.Option) and p.is_flag):
32
+ if isinstance(p.default, (str, int, float)) and p.default is not None:
33
+ entry["default"] = p.default
34
+ return entry
35
+
36
+
37
+ def _walk(group: click.Group) -> list[dict[str, Any]]:
38
+ """Recursively build the command tree."""
39
+ nodes: list[dict[str, Any]] = []
40
+ for name, cmd in sorted(group.commands.items()):
41
+ node: dict[str, Any] = {
42
+ "name": name,
43
+ "help": (cmd.help or "").split("\n")[0],
44
+ }
45
+ if isinstance(cmd, click.Group):
46
+ node["subcommands"] = _walk(cmd)
47
+ else:
48
+ params: list[dict[str, Any]] = []
49
+ for p in cmd.params:
50
+ if p.name in ("help",):
51
+ continue
52
+ params.append(_param_entry(p))
53
+ if params:
54
+ node["params"] = params
55
+ nodes.append(node)
56
+ return nodes
57
+
58
+
59
+ @click.command("commands")
60
+ @click.pass_context
61
+ def commands_meta(ctx: click.Context) -> None:
62
+ """Print the command tree as JSON (for agent discovery)."""
63
+ root = ctx.parent
64
+ if root is None or not isinstance(root.command, click.Group):
65
+ output.error("Cannot resolve CLI root.")
66
+ ctx.exit(1)
67
+ return
68
+ result: dict[str, Any] = {
69
+ "version": __version__,
70
+ "commands": _walk(root.command),
71
+ }
72
+ click.echo(json.dumps(result, indent=2))
@@ -0,0 +1,87 @@
1
+ """Config commands — init, show, test."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import click
7
+
8
+ from splunkctl import config as cfg_mod
9
+ from splunkctl import output
10
+ from splunkctl.client import SplunkClient
11
+
12
+
13
+ @click.group("config")
14
+ def config_group() -> None:
15
+ """Manage splunkctl configuration."""
16
+
17
+
18
+ @config_group.command()
19
+ @click.option("--host", prompt=True, default="localhost", help="Splunk host.")
20
+ @click.option("--port", prompt=True, default=8089, type=int, help="Splunk port.")
21
+ @click.option("--username", prompt=True, default="admin", help="Splunk username.")
22
+ @click.option("--password", prompt=True, hide_input=True, help="Splunk password.")
23
+ @click.option(
24
+ "--scheme",
25
+ type=click.Choice(["https", "http"]),
26
+ default="https",
27
+ help="Connection scheme.",
28
+ )
29
+ @click.option("--verify/--no-verify", default=False, help="Verify SSL certificate.")
30
+ @click.option(
31
+ "--path",
32
+ type=click.Path(),
33
+ default=None,
34
+ help="Config file path (default: ~/.splunkctl/config.yaml).",
35
+ )
36
+ def init(
37
+ host: str,
38
+ port: int,
39
+ username: str,
40
+ password: str,
41
+ scheme: str,
42
+ verify: bool,
43
+ path: str | None,
44
+ ) -> None:
45
+ """Interactive setup — create or overwrite config."""
46
+ cfg: dict[str, Any] = {
47
+ "host": host,
48
+ "port": port,
49
+ "username": username,
50
+ "password": password,
51
+ "scheme": scheme,
52
+ "verify": verify,
53
+ }
54
+ dest = Path(path) if path else None
55
+ saved = cfg_mod.save(cfg, dest)
56
+ output.info(f"Config saved to {saved}")
57
+
58
+
59
+ @config_group.command()
60
+ @click.pass_context
61
+ def show(ctx: click.Context) -> None:
62
+ """Display current config (secrets redacted)."""
63
+ cfg_path = ctx.obj.get("config")
64
+ cfg = cfg_mod.load(Path(cfg_path) if cfg_path else None)
65
+ output.render(ctx, cfg_mod.redact(cfg))
66
+
67
+
68
+ @config_group.command()
69
+ @click.pass_context
70
+ def test(ctx: click.Context) -> None:
71
+ """Verify connectivity and auth against the Splunk instance."""
72
+ cfg_path = ctx.obj.get("config")
73
+ config_path = Path(cfg_path) if cfg_path else None
74
+ cfg = cfg_mod.load(config_path)
75
+
76
+ output.info(
77
+ f"Connecting to {cfg.get('scheme', 'https')}://"
78
+ f"{cfg.get('host', 'localhost')}:{cfg.get('port', 8089)} ..."
79
+ )
80
+
81
+ try:
82
+ client = SplunkClient(config_path=config_path)
83
+ svc_info = client.service.info
84
+ output.info(f"OK — {svc_info['serverName']} (Splunk {svc_info['version']})")
85
+ except Exception as exc:
86
+ output.error(str(exc))
87
+ ctx.exit(1)