pivotra 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pivotra-1.0.0/.gitignore +42 -0
- pivotra-1.0.0/PKG-INFO +10 -0
- pivotra-1.0.0/install.sh +68 -0
- pivotra-1.0.0/pivotra/__init__.py +1 -0
- pivotra-1.0.0/pivotra/__main__.py +2 -0
- pivotra-1.0.0/pivotra/api_client.py +561 -0
- pivotra-1.0.0/pivotra/app.py +1861 -0
- pivotra-1.0.0/pivotra/auth.py +171 -0
- pivotra-1.0.0/pivotra/context.py +218 -0
- pivotra-1.0.0/pivotra/display.py +204 -0
- pivotra-1.0.0/pivotra/repl.py +365 -0
- pivotra-1.0.0/pyproject.toml +22 -0
pivotra-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
venv/
|
|
11
|
+
.venv/
|
|
12
|
+
*.bak
|
|
13
|
+
*.bak.*
|
|
14
|
+
|
|
15
|
+
# Secrets — NEVER commit
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
*.pem
|
|
19
|
+
*.key
|
|
20
|
+
*.p12
|
|
21
|
+
*.pfx
|
|
22
|
+
secrets/
|
|
23
|
+
service_account*.json
|
|
24
|
+
google-services.json
|
|
25
|
+
|
|
26
|
+
# Frontend
|
|
27
|
+
node_modules/
|
|
28
|
+
frontend/dist/
|
|
29
|
+
|
|
30
|
+
# Logs
|
|
31
|
+
*.log
|
|
32
|
+
|
|
33
|
+
# OS
|
|
34
|
+
.DS_Store
|
|
35
|
+
Thumbs.db
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.vscode/
|
|
39
|
+
.idea/
|
|
40
|
+
|
|
41
|
+
# K8s generated
|
|
42
|
+
k8s/secrets/
|
pivotra-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pivotra
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pivotra AI Platform CLI — Fleet management + BRO AI
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27.0
|
|
7
|
+
Requires-Dist: prompt-toolkit>=3.0.43
|
|
8
|
+
Requires-Dist: rich>=13.7.0
|
|
9
|
+
Requires-Dist: typer>=0.12.0
|
|
10
|
+
Requires-Dist: websockets>=12.0
|
pivotra-1.0.0/install.sh
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pivotra CLI — one-line installer
|
|
3
|
+
# Usage: curl -sSL https://monitor.kymnscorp.com/install.sh | bash
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
REPO="https://github.com/pivotra-ai/pivotra-cli" # update when published
|
|
8
|
+
INSTALL_DIR="$HOME/.local/bin"
|
|
9
|
+
CLI_DIR="$HOME/.pivotra/cli"
|
|
10
|
+
|
|
11
|
+
echo ""
|
|
12
|
+
echo " Pivotra CLI — installer"
|
|
13
|
+
echo " ───────────────────────"
|
|
14
|
+
echo ""
|
|
15
|
+
|
|
16
|
+
# Check Python 3.10+
|
|
17
|
+
python_cmd=""
|
|
18
|
+
for cmd in python3.12 python3.11 python3.10 python3; do
|
|
19
|
+
if command -v "$cmd" &>/dev/null; then
|
|
20
|
+
ver=$("$cmd" -c "import sys; print(sys.version_info >= (3,10))" 2>/dev/null)
|
|
21
|
+
if [ "$ver" = "True" ]; then
|
|
22
|
+
python_cmd="$cmd"
|
|
23
|
+
break
|
|
24
|
+
fi
|
|
25
|
+
fi
|
|
26
|
+
done
|
|
27
|
+
|
|
28
|
+
if [ -z "$python_cmd" ]; then
|
|
29
|
+
echo " ERROR: Python 3.10+ required. Install from https://python.org"
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
echo " Using $python_cmd ($($python_cmd --version))"
|
|
34
|
+
|
|
35
|
+
# Create venv
|
|
36
|
+
mkdir -p "$CLI_DIR"
|
|
37
|
+
$python_cmd -m venv "$CLI_DIR/venv"
|
|
38
|
+
source "$CLI_DIR/venv/bin/activate"
|
|
39
|
+
|
|
40
|
+
# Install from local source (dev) or PyPI (prod)
|
|
41
|
+
if [ -f "$(dirname "$0")/pyproject.toml" ]; then
|
|
42
|
+
echo " Installing from local source..."
|
|
43
|
+
pip install -q -e "$(dirname "$0")"
|
|
44
|
+
else
|
|
45
|
+
echo " Installing from PyPI..."
|
|
46
|
+
pip install -q pivotra
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# Create wrapper in ~/.local/bin
|
|
50
|
+
mkdir -p "$INSTALL_DIR"
|
|
51
|
+
cat > "$INSTALL_DIR/pivotra" << EOF
|
|
52
|
+
#!/usr/bin/env bash
|
|
53
|
+
source "$CLI_DIR/venv/bin/activate"
|
|
54
|
+
exec pivotra "\$@"
|
|
55
|
+
EOF
|
|
56
|
+
chmod +x "$INSTALL_DIR/pivotra"
|
|
57
|
+
|
|
58
|
+
echo ""
|
|
59
|
+
echo " Installed → $INSTALL_DIR/pivotra"
|
|
60
|
+
echo ""
|
|
61
|
+
echo " Add to PATH if needed:"
|
|
62
|
+
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
|
|
63
|
+
echo ""
|
|
64
|
+
echo " Get started:"
|
|
65
|
+
echo " pivotra login"
|
|
66
|
+
echo " pivotra status"
|
|
67
|
+
echo " pivotra bro"
|
|
68
|
+
echo ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REST client for Pivotra backend API.
|
|
3
|
+
All calls authenticated with JWT stored by auth.py.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from .auth import get_base_url, require_token
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 PivotraCLI/1.0"
|
|
19
|
+
|
|
20
|
+
def _headers() -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"Authorization": f"Bearer {require_token()}",
|
|
23
|
+
"User-Agent": _UA,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _base() -> str:
|
|
28
|
+
return get_base_url()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIError(Exception):
|
|
32
|
+
def __init__(self, status_code: int, message: str):
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.message = message
|
|
35
|
+
super().__init__(f"API error {status_code}: {message}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get(path: str, params: Optional[dict] = None, timeout: int = 10) -> Any:
|
|
39
|
+
url = f"{_base()}{path}"
|
|
40
|
+
try:
|
|
41
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as c:
|
|
42
|
+
r = c.get(url, headers=_headers(), params=params or {})
|
|
43
|
+
if r.status_code == 401:
|
|
44
|
+
console.print("[red]Session expired.[/red] Run [bold]pivotra login[/bold].")
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
r.raise_for_status()
|
|
47
|
+
return r.json()
|
|
48
|
+
except httpx.HTTPStatusError as e:
|
|
49
|
+
raise APIError(e.response.status_code, e.response.text[:200])
|
|
50
|
+
except httpx.RequestError as e:
|
|
51
|
+
raise APIError(0, str(e))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _post(path: str, payload: dict, timeout: int = 15) -> Any:
|
|
55
|
+
url = f"{_base()}{path}"
|
|
56
|
+
try:
|
|
57
|
+
with httpx.Client(timeout=timeout) as c:
|
|
58
|
+
r = c.post(url, headers=_headers(), json=payload)
|
|
59
|
+
if r.status_code == 401:
|
|
60
|
+
console.print("[red]Session expired.[/red] Run [bold]pivotra login[/bold].")
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
r.raise_for_status()
|
|
63
|
+
return r.json()
|
|
64
|
+
except httpx.HTTPStatusError as e:
|
|
65
|
+
console.print(f"[red]API error {e.response.status_code}:[/red] {e.response.text[:200]}")
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
except httpx.RequestError as e:
|
|
68
|
+
console.print(f"[red]Connection error:[/red] {e}")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _delete(path: str, timeout: int = 15) -> Any:
|
|
73
|
+
url = f"{_base()}{path}"
|
|
74
|
+
try:
|
|
75
|
+
with httpx.Client(timeout=timeout) as c:
|
|
76
|
+
r = c.delete(url, headers=_headers())
|
|
77
|
+
if r.status_code == 401:
|
|
78
|
+
console.print("[red]Session expired.[/red] Run [bold]pivotra login[/bold].")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
r.raise_for_status()
|
|
81
|
+
return r.json()
|
|
82
|
+
except httpx.HTTPStatusError as e:
|
|
83
|
+
console.print(f"[red]API error {e.response.status_code}:[/red] {e.response.text[:200]}")
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
except httpx.RequestError as e:
|
|
86
|
+
console.print(f"[red]Connection error:[/red] {e}")
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Agents ────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def list_agents() -> list[dict]:
|
|
93
|
+
return _get("/api/v1/agents/")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_agent(agent_id: str) -> dict:
|
|
97
|
+
return _get(f"/api/v1/agents/{agent_id}/")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_agent_logs(agent_id: str, source: str = "", limit: int = 50) -> list[dict]:
|
|
101
|
+
params = {"source": source, "limit": limit} if source else {"limit": limit}
|
|
102
|
+
return _get(f"/api/v1/agents/{agent_id}/logs", params=params)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_agent_anomalies(agent_id: str, limit: int = 20) -> list[dict]:
|
|
106
|
+
return _get(f"/api/v1/agents/{agent_id}/anomalies", params={"limit": limit})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_agent_processes(agent_id: str) -> list[dict]:
|
|
110
|
+
return _get(f"/api/v1/agents/{agent_id}/processes")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_agent_connections(agent_id: str) -> list[dict]:
|
|
114
|
+
return _get(f"/api/v1/agents/{agent_id}/connections")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_agent_metrics(agent_id: str) -> dict:
|
|
118
|
+
return _get(f"/api/v1/agents/{agent_id}/metrics")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_agent_vulns(agent_id: str) -> list[dict]:
|
|
122
|
+
return _get(f"/api/v1/agents/{agent_id}/vulns")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def send_command(agent_id: str, command_type: str, payload: dict = {}) -> dict:
|
|
126
|
+
return _post(f"/api/v1/agents/{agent_id}/command",
|
|
127
|
+
{"type": command_type, "payload": payload})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def isolate_agent(agent_id: str) -> dict:
|
|
131
|
+
return _post(f"/api/v1/agents/{agent_id}/isolate", {})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def unisolate_agent(agent_id: str) -> dict:
|
|
135
|
+
return _post(f"/api/v1/agents/{agent_id}/unisolate", {})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def block_ip(ip: str, agent_id: Optional[str] = None) -> dict:
|
|
139
|
+
payload = {"ip": ip}
|
|
140
|
+
if agent_id:
|
|
141
|
+
payload["agent_id"] = agent_id
|
|
142
|
+
return _post("/api/v1/agents/broadcast",
|
|
143
|
+
{"type": "block_ip", "payload": payload})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_command(agent_id: str, command: str) -> dict:
|
|
147
|
+
return _post(f"/api/v1/agents/{agent_id}/command",
|
|
148
|
+
{"type": "run_script", "payload": {"command": command}})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def trigger_scan(agent_id: str) -> dict:
|
|
152
|
+
return send_command(agent_id, "scan", {})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ── Orchestration ──────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def generate_key(label: str = "") -> dict:
|
|
158
|
+
payload = {"label": label} if label else {}
|
|
159
|
+
return _post("/api/v1/agents/generate-key", payload)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def spawn_agent(agent_id: str, config: dict = {}) -> dict:
|
|
163
|
+
return _post(f"/api/v1/agents/{agent_id}/spawn", config)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_sub_agents(agent_id: str) -> list[dict]:
|
|
167
|
+
return _get(f"/api/v1/agents/{agent_id}/sub-agents")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def mesh_cmd(agent_id: str, command: str, targets: list[str] = []) -> dict:
|
|
171
|
+
return _post(f"/api/v1/agents/{agent_id}/mesh-cmd",
|
|
172
|
+
{"command": command, "targets": targets})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def broadcast_command(command: str) -> dict:
|
|
176
|
+
return _post("/api/v1/agents/broadcast",
|
|
177
|
+
{"type": "run_script", "payload": {"command": command}})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def deregister_agent(agent_id: str) -> dict:
|
|
181
|
+
url = f"{_base()}/api/v1/agents/{agent_id}"
|
|
182
|
+
try:
|
|
183
|
+
with httpx.Client(timeout=10) as c:
|
|
184
|
+
r = c.delete(url, headers=_headers())
|
|
185
|
+
if r.status_code == 401:
|
|
186
|
+
console.print("[red]Session expired.[/red] Run [bold]pivotra login[/bold].")
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
r.raise_for_status()
|
|
189
|
+
return r.json() if r.content else {"status": "deleted"}
|
|
190
|
+
except httpx.HTTPStatusError as e:
|
|
191
|
+
raise APIError(e.response.status_code, e.response.text[:200])
|
|
192
|
+
except httpx.RequestError as e:
|
|
193
|
+
raise APIError(0, str(e))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def restart_agent(agent_id: str) -> dict:
|
|
197
|
+
return _post(f"/api/v1/agents/{agent_id}/restart", {})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def rotate_key(agent_id: str) -> dict:
|
|
201
|
+
return _post(f"/api/v1/agents/{agent_id}/rotate-key", {})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Alerts ────────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def get_alerts(limit: int = 20) -> list[dict]:
|
|
207
|
+
data = _get("/api/v1/alerts/feed", params={"limit": limit})
|
|
208
|
+
if isinstance(data, list):
|
|
209
|
+
return data
|
|
210
|
+
return data.get("items") or data.get("alerts") or data.get("data") or []
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ── Fleet stats ───────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
def fleet_stats() -> dict:
|
|
216
|
+
return _get("/api/v1/agents/fleet/stats")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_agent_timeline(agent_id: str, limit: int = 30) -> list[dict]:
|
|
220
|
+
data = _get(f"/api/v1/agents/{agent_id}/timeline", params={"limit": limit})
|
|
221
|
+
if isinstance(data, list):
|
|
222
|
+
return data
|
|
223
|
+
return data.get("items") or data.get("events") or []
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def list_group_agents(group_id: str) -> list[dict]:
|
|
227
|
+
data = _get(f"/api/v1/groups/{group_id}/agents")
|
|
228
|
+
if isinstance(data, list):
|
|
229
|
+
return data
|
|
230
|
+
return data.get("items") or data.get("agents") or []
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_2fa_status() -> dict:
|
|
234
|
+
return _get("/api/v1/auth/2fa/status")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def export_resource(resource: str, fmt: str = "json", limit: int = 100,
|
|
238
|
+
agent_id: Optional[str] = None) -> list[dict]:
|
|
239
|
+
endpoint_map = {
|
|
240
|
+
"agents": ("/api/v1/agents/", {}),
|
|
241
|
+
"alerts": ("/api/v1/alerts/", {"limit": limit}),
|
|
242
|
+
"anomalies": ("/api/v1/anomalies/", {"limit": limit}),
|
|
243
|
+
"iocs": ("/api/v1/intel/iocs", {"limit": limit}),
|
|
244
|
+
"audit": ("/api/v1/audit/", {"limit": limit}),
|
|
245
|
+
}
|
|
246
|
+
results = {}
|
|
247
|
+
if resource == "all":
|
|
248
|
+
for name, (path, params) in endpoint_map.items():
|
|
249
|
+
try:
|
|
250
|
+
data = _get(path, params=params)
|
|
251
|
+
results[name] = data if isinstance(data, list) else (data.get("items") or data.get("data") or [data])
|
|
252
|
+
except Exception:
|
|
253
|
+
results[name] = []
|
|
254
|
+
return results # type: ignore
|
|
255
|
+
path, params = endpoint_map.get(resource, (f"/api/v1/{resource}/", {"limit": limit}))
|
|
256
|
+
if agent_id:
|
|
257
|
+
params["agent_id"] = agent_id
|
|
258
|
+
data = _get(path, params=params)
|
|
259
|
+
if isinstance(data, list):
|
|
260
|
+
return data
|
|
261
|
+
return data.get("items") or data.get("data") or []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ── Infra ops (file, docker, psql) ────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
def file_read(agent_id: str, path: str) -> str:
|
|
267
|
+
result = run_command(agent_id, f"cat {path}")
|
|
268
|
+
return result.get("output") or result.get("result") or ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def file_list(agent_id: str, path: str) -> str:
|
|
272
|
+
result = run_command(agent_id, f"ls -la {path}")
|
|
273
|
+
return result.get("output") or result.get("result") or ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def file_write(agent_id: str, path: str, content: str) -> dict:
|
|
277
|
+
import shlex
|
|
278
|
+
safe_content = content.replace("'", "'\\''")
|
|
279
|
+
cmd = f"cat > {shlex.quote(path)} << 'PIVOTRA_EOF'\n{safe_content}\nPIVOTRA_EOF"
|
|
280
|
+
return run_command(agent_id, cmd)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def docker_cmd(agent_id: str, subcmd: str) -> str:
|
|
284
|
+
result = run_command(agent_id, f"docker {subcmd}")
|
|
285
|
+
return result.get("output") or result.get("result") or ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def psql_query(agent_id: str, sql: str, dbname: str = "") -> str:
|
|
289
|
+
db_flag = f"-d {dbname}" if dbname else ""
|
|
290
|
+
result = run_command(agent_id, f'psql {db_flag} -c "{sql}"')
|
|
291
|
+
return result.get("output") or result.get("result") or ""
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── Forensics & Hunt ──────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def run_hunt(query: str) -> dict:
|
|
297
|
+
return _post("/api/v1/hunt/run-adhoc", {"query": query})
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def get_hunt_jobs() -> list[dict]:
|
|
301
|
+
return _get("/api/v1/hunt/jobs")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def get_hunt_results(job_id: str) -> dict:
|
|
305
|
+
return _get(f"/api/v1/hunt/jobs/{job_id}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def get_hunt_artifacts() -> list[dict]:
|
|
309
|
+
return _get("/api/v1/hunt/artifacts")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ── Investigations ─────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
def list_investigations(limit: int = 20) -> list[dict]:
|
|
315
|
+
data = _get("/api/v1/investigations/", params={"limit": limit})
|
|
316
|
+
if isinstance(data, list):
|
|
317
|
+
return data
|
|
318
|
+
return data.get("items") or data.get("data") or []
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_investigation(case_id: str) -> dict:
|
|
322
|
+
return _get(f"/api/v1/investigations/{case_id}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def trigger_investigation(anomaly_id: str) -> dict:
|
|
326
|
+
return _post("/api/v1/investigations/trigger", {"anomaly_id": anomaly_id})
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ── IOCs ───────────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
def lookup_ioc(value: str) -> dict:
|
|
332
|
+
return _get("/api/v1/intel/lookup", params={"q": value})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def list_iocs(limit: int = 20) -> list[dict]:
|
|
336
|
+
data = _get("/api/v1/intel/iocs", params={"limit": limit})
|
|
337
|
+
if isinstance(data, list):
|
|
338
|
+
return data
|
|
339
|
+
return data.get("items") or data.get("data") or []
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def add_ioc(ioc_type: str, value: str, severity: str = "medium") -> dict:
|
|
343
|
+
return _post("/api/v1/intel/iocs", {
|
|
344
|
+
"type": ioc_type, "value": value, "severity": severity
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ── IP Reputation ──────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
def check_ip(ip: str) -> dict:
|
|
351
|
+
return _get(f"/api/v1/ip-reputation/check/{ip}")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ── Reports ────────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
def generate_report(days: int = 7) -> dict:
|
|
357
|
+
return _post("/api/v1/reports/generate", {"days": days})
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def list_reports() -> list[dict]:
|
|
361
|
+
data = _get("/api/v1/reports/runs")
|
|
362
|
+
if isinstance(data, list):
|
|
363
|
+
return data
|
|
364
|
+
return data.get("items") or data.get("data") or []
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def download_report(run_id: str, fmt: str = "pdf") -> bytes:
|
|
368
|
+
url = f"{_base()}/api/v1/reports/runs/{run_id}/export-{fmt}"
|
|
369
|
+
try:
|
|
370
|
+
with httpx.Client(timeout=60) as c:
|
|
371
|
+
r = c.get(url, headers=_headers())
|
|
372
|
+
r.raise_for_status()
|
|
373
|
+
return r.content
|
|
374
|
+
except httpx.HTTPStatusError as e:
|
|
375
|
+
raise APIError(e.response.status_code, e.response.text[:200])
|
|
376
|
+
except httpx.RequestError as e:
|
|
377
|
+
raise APIError(0, str(e))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ── MITRE ATT&CK ──────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
def get_mitre_coverage() -> dict:
|
|
383
|
+
return _get("/api/v1/mitre/coverage")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def get_mitre_stats() -> dict:
|
|
387
|
+
return _get("/api/v1/mitre/stats")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ── EDR ────────────────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
def get_edr_events(agent_id: Optional[str] = None, limit: int = 20) -> list[dict]:
|
|
393
|
+
params: dict = {"limit": limit}
|
|
394
|
+
if agent_id:
|
|
395
|
+
params["agent_id"] = agent_id
|
|
396
|
+
data = _get("/api/v1/edr/events", params=params)
|
|
397
|
+
if isinstance(data, list):
|
|
398
|
+
return data
|
|
399
|
+
return data.get("items") or data.get("data") or []
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def mitigate_edr_event(event_id: str) -> dict:
|
|
403
|
+
url = f"{_base()}/api/v1/edr/events/{event_id}/mitigate"
|
|
404
|
+
try:
|
|
405
|
+
with httpx.Client(timeout=10) as c:
|
|
406
|
+
r = c.patch(url, headers=_headers(), json={})
|
|
407
|
+
r.raise_for_status()
|
|
408
|
+
return r.json()
|
|
409
|
+
except httpx.HTTPStatusError as e:
|
|
410
|
+
raise APIError(e.response.status_code, e.response.text[:200])
|
|
411
|
+
except httpx.RequestError as e:
|
|
412
|
+
raise APIError(0, str(e))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ── Playbooks ─────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
def list_playbooks() -> list[dict]:
|
|
418
|
+
data = _get("/api/v1/playbooks/")
|
|
419
|
+
if isinstance(data, list):
|
|
420
|
+
return data
|
|
421
|
+
return data.get("items") or data.get("data") or []
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def get_playbook_flow(playbook_id: str) -> dict:
|
|
425
|
+
return _get(f"/api/v1/playbooks/{playbook_id}/flow")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def run_playbook(playbook_id: str, target: str = "", dry_run: bool = False) -> dict:
|
|
429
|
+
return _post(f"/api/v1/playbooks/{playbook_id}/run-flow",
|
|
430
|
+
{"target": target, "dry_run": dry_run})
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def get_playbook_log(action_id: str) -> dict:
|
|
434
|
+
return _get(f"/api/v1/playbooks/{action_id}/execution-log")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def get_external_runs(limit: int = 20) -> list[dict]:
|
|
438
|
+
data = _get("/api/v1/playbooks/external-executions", params={"limit": limit})
|
|
439
|
+
if isinstance(data, list):
|
|
440
|
+
return data
|
|
441
|
+
return data.get("items") or data.get("data") or []
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def trigger_external_playbook(action_type: str, target: str,
|
|
445
|
+
reason: str = "", token: str = "") -> dict:
|
|
446
|
+
import httpx as _httpx
|
|
447
|
+
url = f"{_base()}/api/v1/playbooks/webhook-trigger"
|
|
448
|
+
headers = {"X-Webhook-Token": token} if token else {}
|
|
449
|
+
try:
|
|
450
|
+
with _httpx.Client(timeout=15) as c:
|
|
451
|
+
r = c.post(url, headers={**_headers(), **headers},
|
|
452
|
+
json={"action_type": action_type, "target": target, "reason": reason})
|
|
453
|
+
r.raise_for_status()
|
|
454
|
+
return r.json()
|
|
455
|
+
except _httpx.HTTPStatusError as e:
|
|
456
|
+
raise APIError(e.response.status_code, e.response.text[:200])
|
|
457
|
+
except _httpx.RequestError as e:
|
|
458
|
+
raise APIError(0, str(e))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ── Response Queue ─────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
def get_response_queue() -> list[dict]:
|
|
464
|
+
data = _get("/api/v1/response/queue")
|
|
465
|
+
if isinstance(data, list):
|
|
466
|
+
return data
|
|
467
|
+
return data.get("items") or data.get("data") or []
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def approve_action(action_id: str) -> dict:
|
|
471
|
+
return _post(f"/api/v1/response/{action_id}/approve", {})
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def reject_action(action_id: str) -> dict:
|
|
475
|
+
return _post(f"/api/v1/response/{action_id}/reject", {})
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def get_response_stats() -> dict:
|
|
479
|
+
return _get("/api/v1/response/stats")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ── Agent Groups ───────────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
def list_groups() -> list[dict]:
|
|
485
|
+
data = _get("/api/v1/groups")
|
|
486
|
+
if isinstance(data, list):
|
|
487
|
+
return data
|
|
488
|
+
return data.get("items") or data.get("data") or []
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def create_group(name: str, description: str = "") -> dict:
|
|
492
|
+
return _post("/api/v1/agent-groups/", {"name": name, "description": description})
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def assign_to_group(group_id: str, agent_ids: list[str]) -> dict:
|
|
496
|
+
return _post(f"/api/v1/agent-groups/{group_id}/assign", {"agent_ids": agent_ids})
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ── Audit ─────────────────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
def get_audit_logs(limit: int = 20) -> list[dict]:
|
|
502
|
+
data = _get("/api/v1/audit/logs", params={"limit": limit})
|
|
503
|
+
if isinstance(data, list):
|
|
504
|
+
return data
|
|
505
|
+
return data.get("items") or data.get("data") or []
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ── Users & Invitations ────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
def list_users() -> list[dict]:
|
|
511
|
+
data = _get("/api/v1/auth/users")
|
|
512
|
+
if isinstance(data, list):
|
|
513
|
+
return data
|
|
514
|
+
return data.get("items") or data.get("data") or []
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def invite_user(email: str, role: str = "analyst") -> dict:
|
|
518
|
+
return _post("/api/v1/invitations/send", {"email": email, "role": role})
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ── Jobs ───────────────────────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
def list_jobs() -> list[dict]:
|
|
524
|
+
data = _get("/api/v1/jobs/")
|
|
525
|
+
if isinstance(data, list):
|
|
526
|
+
return data
|
|
527
|
+
return data.get("items") or data.get("data") or []
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
def get_dashboard() -> dict:
|
|
533
|
+
return _get("/api/v1/dashboard")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ── Personal API tokens ───────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
def list_tokens() -> list[dict]:
|
|
539
|
+
return _get("/api/v1/tokens") or []
|
|
540
|
+
|
|
541
|
+
def create_token(name: str, expires_days: Optional[int] = None) -> dict:
|
|
542
|
+
body: dict = {"name": name}
|
|
543
|
+
if expires_days:
|
|
544
|
+
body["expires_days"] = expires_days
|
|
545
|
+
return _post("/api/v1/tokens", body)
|
|
546
|
+
|
|
547
|
+
def revoke_token(token_id: int) -> dict:
|
|
548
|
+
return _delete(f"/api/v1/tokens/{token_id}")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ── BRO Chat stream (SSE) ─────────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
def bro_stream(messages: list[dict], context: dict) -> httpx.Response:
|
|
554
|
+
"""Returns a streaming response for BRO chat."""
|
|
555
|
+
url = f"{_base()}/api/v1/cli/bro/stream"
|
|
556
|
+
return httpx.stream(
|
|
557
|
+
"POST", url,
|
|
558
|
+
headers={**_headers(), "Accept": "text/event-stream"},
|
|
559
|
+
json={"messages": messages, "context": context},
|
|
560
|
+
timeout=120,
|
|
561
|
+
)
|