agentsentinel-cli 0.3.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.
- agentsentinel_cli/__init__.py +3 -0
- agentsentinel_cli/cli.py +338 -0
- agentsentinel_cli/discover.py +691 -0
- agentsentinel_cli/discover_report.py +206 -0
- agentsentinel_cli/frameworks.py +144 -0
- agentsentinel_cli/mcp_client.py +241 -0
- agentsentinel_cli/mcp_report.py +186 -0
- agentsentinel_cli/mcp_rules.py +231 -0
- agentsentinel_cli/report.py +191 -0
- agentsentinel_cli/rules.py +239 -0
- agentsentinel_cli/scanner.py +314 -0
- agentsentinel_cli-0.3.0.dist-info/METADATA +187 -0
- agentsentinel_cli-0.3.0.dist-info/RECORD +15 -0
- agentsentinel_cli-0.3.0.dist-info/WHEEL +4 -0
- agentsentinel_cli-0.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sentinel discover — finds AI agents across processes, network, files, and Docker containers.
|
|
3
|
+
|
|
4
|
+
Scan vectors:
|
|
5
|
+
process running Python/Node processes making LLM API calls
|
|
6
|
+
network open ports serving MCP SSE endpoints or agent APIs
|
|
7
|
+
subnet CIDR subnet scan — finds agents across an internal network
|
|
8
|
+
files Python source files in a directory containing agent patterns
|
|
9
|
+
docker Docker containers with LLM API keys in their environment
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
import ipaddress
|
|
16
|
+
import json
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from agentsentinel_cli.frameworks import (
|
|
25
|
+
LLM_API_HOSTS,
|
|
26
|
+
LLM_ENV_VARS,
|
|
27
|
+
detect_framework,
|
|
28
|
+
detect_model,
|
|
29
|
+
detect_provider_from_env,
|
|
30
|
+
extract_api_keys,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# ── Data model ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
@dataclasses.dataclass
|
|
36
|
+
class DiscoveredAgent:
|
|
37
|
+
source: str # process | network | subnet | file | docker
|
|
38
|
+
name: str # human-readable name
|
|
39
|
+
framework: str # LangChain | OpenAI Agents SDK | MCP | etc.
|
|
40
|
+
provider: str # Anthropic | OpenAI | Google | etc.
|
|
41
|
+
model: str # claude-sonnet | gpt-4o | etc. (empty if unknown)
|
|
42
|
+
location: str # pid:1234 | 10.0.1.45:8080 | /path/file.py | container:name
|
|
43
|
+
api_keys: list[str] # masked keys: ANTHROPIC_API_KEY=sk-ant-...5f3d
|
|
44
|
+
live_connections: list[str] # LLM API hosts this process is talking to
|
|
45
|
+
risk: str # CRITICAL | HIGH | MEDIUM | LOW | UNKNOWN
|
|
46
|
+
risk_reason: str # one-line explanation of the risk level
|
|
47
|
+
next_step: str # suggested follow-up command
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclasses.dataclass
|
|
51
|
+
class SubnetScanStats:
|
|
52
|
+
cidr: str
|
|
53
|
+
total_hosts: int
|
|
54
|
+
hosts_scanned: int
|
|
55
|
+
open_ports_found: int
|
|
56
|
+
agents_found: int
|
|
57
|
+
elapsed_seconds: float
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Default port list ─────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
_DEFAULT_PORTS = [
|
|
63
|
+
3000, 3001, 4000, 5000,
|
|
64
|
+
7860, # Gradio
|
|
65
|
+
8000, 8001, 8002, 8003,
|
|
66
|
+
8080, 8443, 8888,
|
|
67
|
+
9000, 9001,
|
|
68
|
+
11434, # Ollama
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
_MCP_INDICATOR_PATHS = ["/sse", "/messages/"]
|
|
72
|
+
_OPENAI_COMPAT_PATHS = ["/v1/models"]
|
|
73
|
+
_SENTINEL_PATHS = ["/api/v1/agents", "/health"]
|
|
74
|
+
|
|
75
|
+
# Subnet scan limits — scanning beyond these sizes is impractical
|
|
76
|
+
_MAX_SUBNET_HOSTS_WARN = 1024 # /22 — warn but allow
|
|
77
|
+
_MAX_SUBNET_HOSTS_BLOCK = 65536 # /16 — refuse (too slow, too noisy)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── Process scanner ───────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
_PROCESS_SKIP_FRAGMENTS = frozenset({
|
|
83
|
+
"lsp-server", "lsp-runner", "lsp-worker",
|
|
84
|
+
"server.bundle",
|
|
85
|
+
"esbuild", "webpack", "rollup", "vite",
|
|
86
|
+
"typescript-language-server", "tsserver",
|
|
87
|
+
"pylsp", "pyright",
|
|
88
|
+
"gopls", "rust-analyzer", "clangd",
|
|
89
|
+
"code helper", "vmware fusion", "docker desktop",
|
|
90
|
+
"safari", "firefox", "chrome helper",
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
_AGENT_CMDLINE_SIGNALS = frozenset({
|
|
94
|
+
"langchain", "crewai", "autogen", "pyautogen",
|
|
95
|
+
"openai.agents", "agents_sdk",
|
|
96
|
+
"mcp_shim", "mcp-shim", "mcp.server",
|
|
97
|
+
"sentinel_middleware", "agentsentinel",
|
|
98
|
+
"llama_index", "llamaindex",
|
|
99
|
+
"haystack", "pydantic_ai", "semantic_kernel",
|
|
100
|
+
"agent.py", "agents.py",
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scan_processes() -> list[DiscoveredAgent]:
|
|
105
|
+
"""Scan running processes for AI agents using psutil."""
|
|
106
|
+
try:
|
|
107
|
+
import psutil
|
|
108
|
+
except ImportError:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
found: list[DiscoveredAgent] = []
|
|
112
|
+
seen: set[tuple[str, str, str]] = set()
|
|
113
|
+
|
|
114
|
+
for proc in psutil.process_iter(["pid", "name", "cmdline", "status"]):
|
|
115
|
+
try:
|
|
116
|
+
info = proc.as_dict(attrs=["pid", "name", "cmdline", "status"])
|
|
117
|
+
cmdline = info.get("cmdline") or []
|
|
118
|
+
proc_name = (info.get("name") or "").lower()
|
|
119
|
+
|
|
120
|
+
if not cmdline:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if any(skip in proc_name for skip in _PROCESS_SKIP_FRAGMENTS):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
cmd_str = " ".join(str(c) for c in cmdline).lower()
|
|
127
|
+
|
|
128
|
+
if not any(p in cmd_str for p in ("python", "node", "npx", "deno")):
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
has_framework_in_cmd = any(s in cmd_str for s in _AGENT_CMDLINE_SIGNALS)
|
|
132
|
+
|
|
133
|
+
live_connections: list[str] = []
|
|
134
|
+
try:
|
|
135
|
+
for conn in proc.connections(kind="tcp"):
|
|
136
|
+
if conn.raddr and conn.raddr.ip:
|
|
137
|
+
try:
|
|
138
|
+
host = socket.gethostbyaddr(conn.raddr.ip)[0]
|
|
139
|
+
if any(h in host for h in LLM_API_HOSTS):
|
|
140
|
+
live_connections.append(host)
|
|
141
|
+
except (socket.herror, socket.gaierror):
|
|
142
|
+
pass
|
|
143
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
if not has_framework_in_cmd and not live_connections:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
env = proc.environ()
|
|
151
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess):
|
|
152
|
+
env = {}
|
|
153
|
+
|
|
154
|
+
framework, provider = detect_framework(cmd_str)
|
|
155
|
+
if not provider:
|
|
156
|
+
provider = detect_provider_from_env(env)
|
|
157
|
+
|
|
158
|
+
api_keys = extract_api_keys(env)
|
|
159
|
+
model = detect_model(cmd_str) or detect_model(" ".join(env.values()))
|
|
160
|
+
name = _name_from_cmdline(cmdline)
|
|
161
|
+
|
|
162
|
+
dedup_key = (name, framework, api_keys[0] if api_keys else "")
|
|
163
|
+
if dedup_key in seen:
|
|
164
|
+
continue
|
|
165
|
+
seen.add(dedup_key)
|
|
166
|
+
|
|
167
|
+
risk, risk_reason = _assess_process_risk(api_keys, live_connections, framework)
|
|
168
|
+
|
|
169
|
+
found.append(DiscoveredAgent(
|
|
170
|
+
source="process",
|
|
171
|
+
name=name,
|
|
172
|
+
framework=framework,
|
|
173
|
+
provider=provider,
|
|
174
|
+
model=model,
|
|
175
|
+
location=f"pid:{info['pid']}",
|
|
176
|
+
api_keys=api_keys,
|
|
177
|
+
live_connections=live_connections,
|
|
178
|
+
risk=risk,
|
|
179
|
+
risk_reason=risk_reason,
|
|
180
|
+
next_step=f"sentinel scan --pid {info['pid']}",
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
return found
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _name_from_cmdline(cmdline: list[str]) -> str:
|
|
190
|
+
for part in reversed(cmdline):
|
|
191
|
+
p = Path(part)
|
|
192
|
+
if p.suffix in (".py", ".js", ".ts") and p.stem not in ("__main__", "-c"):
|
|
193
|
+
return p.stem.replace("_", "-")
|
|
194
|
+
for part in cmdline:
|
|
195
|
+
if part and not part.startswith("-"):
|
|
196
|
+
return Path(part).stem or part
|
|
197
|
+
return "unknown-agent"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _assess_process_risk(
|
|
201
|
+
api_keys: list[str],
|
|
202
|
+
live_connections: list[str],
|
|
203
|
+
framework: str,
|
|
204
|
+
) -> tuple[str, str]:
|
|
205
|
+
if api_keys:
|
|
206
|
+
return (
|
|
207
|
+
"CRITICAL",
|
|
208
|
+
f"LLM API key{'s' if len(api_keys) > 1 else ''} exposed in process environment "
|
|
209
|
+
f"({', '.join(k.split('=')[0] for k in api_keys)})",
|
|
210
|
+
)
|
|
211
|
+
if live_connections:
|
|
212
|
+
return "HIGH", f"Active connection to LLM API: {', '.join(set(live_connections))}"
|
|
213
|
+
if framework != "Unknown":
|
|
214
|
+
return "MEDIUM", f"{framework} agent detected — run 'sentinel scan' for full analysis"
|
|
215
|
+
return "UNKNOWN", "AI-related process detected — framework not identified"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── Network scanner (single host) ─────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def scan_network(
|
|
221
|
+
host: str = "127.0.0.1",
|
|
222
|
+
ports: list[int] | None = None,
|
|
223
|
+
timeout: float = 0.5,
|
|
224
|
+
) -> list[DiscoveredAgent]:
|
|
225
|
+
"""Probe a single host's ports for AI agent endpoints."""
|
|
226
|
+
if ports is None:
|
|
227
|
+
ports = _DEFAULT_PORTS
|
|
228
|
+
|
|
229
|
+
open_ports = _find_open_ports(host, ports, timeout)
|
|
230
|
+
if not open_ports:
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
found: list[DiscoveredAgent] = []
|
|
234
|
+
for port in open_ports:
|
|
235
|
+
agent = _probe_port(host, port, timeout * 4)
|
|
236
|
+
if agent:
|
|
237
|
+
found.append(agent)
|
|
238
|
+
return found
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _find_open_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
|
242
|
+
"""Fast parallel TCP connect to find open ports on a single host."""
|
|
243
|
+
open_ports: list[int] = []
|
|
244
|
+
|
|
245
|
+
def check(port: int) -> int | None:
|
|
246
|
+
try:
|
|
247
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
248
|
+
return port
|
|
249
|
+
except (OSError, ConnectionRefusedError):
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
with ThreadPoolExecutor(max_workers=min(50, len(ports))) as pool:
|
|
253
|
+
for result in as_completed({pool.submit(check, p): p for p in ports}):
|
|
254
|
+
r = result.result()
|
|
255
|
+
if r is not None:
|
|
256
|
+
open_ports.append(r)
|
|
257
|
+
|
|
258
|
+
return sorted(open_ports)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── Subnet scanner (CIDR range) ───────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def enumerate_hosts(cidr: str) -> list[str]:
|
|
264
|
+
"""Expand a CIDR string into a list of usable host IP strings."""
|
|
265
|
+
network = ipaddress.ip_network(cidr, strict=False)
|
|
266
|
+
# For /31 and /32, include network address too (point-to-point / single host)
|
|
267
|
+
if network.prefixlen >= 31:
|
|
268
|
+
return [str(ip) for ip in network]
|
|
269
|
+
return [str(ip) for ip in network.hosts()]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def scan_subnet(
|
|
273
|
+
cidr: str,
|
|
274
|
+
ports: list[int] | None = None,
|
|
275
|
+
timeout: float = 0.3,
|
|
276
|
+
on_progress: Optional[Callable[[int, int, str], None]] = None,
|
|
277
|
+
) -> tuple[list[DiscoveredAgent], SubnetScanStats]:
|
|
278
|
+
"""Scan every host in a CIDR subnet for AI agent endpoints.
|
|
279
|
+
|
|
280
|
+
Uses a two-phase approach:
|
|
281
|
+
Phase 1 — parallel TCP connect across all host:port combinations (fast)
|
|
282
|
+
Phase 2 — HTTP probe on every open port to identify agent type (targeted)
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
cidr: Network range, e.g. "10.0.0.0/24"
|
|
286
|
+
ports: Port list to probe (default: _DEFAULT_PORTS)
|
|
287
|
+
timeout: Per-connection TCP timeout in seconds
|
|
288
|
+
on_progress: Optional callback(completed, total, current_ip) for progress display
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
(agents, stats) tuple
|
|
292
|
+
"""
|
|
293
|
+
import time
|
|
294
|
+
|
|
295
|
+
if ports is None:
|
|
296
|
+
ports = _DEFAULT_PORTS
|
|
297
|
+
|
|
298
|
+
hosts = enumerate_hosts(cidr)
|
|
299
|
+
if not hosts:
|
|
300
|
+
raise ValueError(f"No usable hosts in {cidr}")
|
|
301
|
+
|
|
302
|
+
if len(hosts) > _MAX_SUBNET_HOSTS_BLOCK:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"{cidr} contains {len(hosts):,} hosts. "
|
|
305
|
+
f"Maximum supported is {_MAX_SUBNET_HOSTS_BLOCK:,} (/{32 - _MAX_SUBNET_HOSTS_BLOCK.bit_length() + 1}). "
|
|
306
|
+
"Use a smaller subnet or scan individual host ranges."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
started_at = time.monotonic()
|
|
310
|
+
total_probes = len(hosts) * len(ports)
|
|
311
|
+
open_targets: list[tuple[str, int]] = []
|
|
312
|
+
|
|
313
|
+
# ── Phase 1: parallel TCP connect across all host:port pairs ─────────────
|
|
314
|
+
# High concurrency — most connections refuse immediately, failures are cheap.
|
|
315
|
+
completed = 0
|
|
316
|
+
workers = min(250, total_probes)
|
|
317
|
+
|
|
318
|
+
def check_port(host: str, port: int) -> tuple[str, int] | None:
|
|
319
|
+
try:
|
|
320
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
321
|
+
return (host, port)
|
|
322
|
+
except (OSError, ConnectionRefusedError, socket.timeout):
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
tasks = [(h, p) for h in hosts for p in ports]
|
|
326
|
+
futures = {}
|
|
327
|
+
|
|
328
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
329
|
+
futures = {pool.submit(check_port, h, p): (h, p) for h, p in tasks}
|
|
330
|
+
for future in as_completed(futures):
|
|
331
|
+
result = future.result()
|
|
332
|
+
if result:
|
|
333
|
+
open_targets.append(result)
|
|
334
|
+
completed += 1
|
|
335
|
+
if on_progress:
|
|
336
|
+
host_ip, _ = futures[future]
|
|
337
|
+
on_progress(completed, total_probes, host_ip)
|
|
338
|
+
|
|
339
|
+
# ── Phase 2: HTTP probe on open ports to identify agent type ─────────────
|
|
340
|
+
found: list[DiscoveredAgent] = []
|
|
341
|
+
for host_ip, port in open_targets:
|
|
342
|
+
agent = _probe_port(host_ip, port, timeout * 8)
|
|
343
|
+
if agent:
|
|
344
|
+
found.append(agent)
|
|
345
|
+
|
|
346
|
+
elapsed = time.monotonic() - started_at
|
|
347
|
+
stats = SubnetScanStats(
|
|
348
|
+
cidr=cidr,
|
|
349
|
+
total_hosts=len(hosts),
|
|
350
|
+
hosts_scanned=len(hosts),
|
|
351
|
+
open_ports_found=len(open_targets),
|
|
352
|
+
agents_found=len(found),
|
|
353
|
+
elapsed_seconds=elapsed,
|
|
354
|
+
)
|
|
355
|
+
return found, stats
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ── Port prober ───────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
def _probe_port(host: str, port: int, timeout: float) -> DiscoveredAgent | None:
|
|
361
|
+
"""Make HTTP requests to an open port and detect what kind of agent it is."""
|
|
362
|
+
try:
|
|
363
|
+
import httpx
|
|
364
|
+
except ImportError:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
base = f"http://{host}:{port}"
|
|
368
|
+
location = f"{host}:{port}"
|
|
369
|
+
|
|
370
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
371
|
+
|
|
372
|
+
# MCP SSE server
|
|
373
|
+
for path in _MCP_INDICATOR_PATHS:
|
|
374
|
+
try:
|
|
375
|
+
r = client.get(f"{base}{path}", headers={"Accept": "text/event-stream"})
|
|
376
|
+
if r.status_code < 500 and (
|
|
377
|
+
"text/event-stream" in r.headers.get("content-type", "")
|
|
378
|
+
or "event-stream" in r.text[:200]
|
|
379
|
+
or r.status_code == 200
|
|
380
|
+
):
|
|
381
|
+
return DiscoveredAgent(
|
|
382
|
+
source="network",
|
|
383
|
+
name=f"mcp-server@{location}",
|
|
384
|
+
framework="MCP Server",
|
|
385
|
+
provider="",
|
|
386
|
+
model="",
|
|
387
|
+
location=location,
|
|
388
|
+
api_keys=[],
|
|
389
|
+
live_connections=[],
|
|
390
|
+
risk="HIGH",
|
|
391
|
+
risk_reason="MCP server with no authentication detected — inspect tools",
|
|
392
|
+
next_step=f"sentinel mcp scan {base}/sse",
|
|
393
|
+
)
|
|
394
|
+
except httpx.RequestError:
|
|
395
|
+
pass
|
|
396
|
+
|
|
397
|
+
# OpenAI-compatible API (Ollama, LiteLLM, vLLM, etc.)
|
|
398
|
+
for path in _OPENAI_COMPAT_PATHS:
|
|
399
|
+
try:
|
|
400
|
+
r = client.get(f"{base}{path}")
|
|
401
|
+
if r.status_code in (200, 401) and _looks_like_openai_api(r):
|
|
402
|
+
model = _extract_model_from_response(r)
|
|
403
|
+
auth_required = r.status_code == 401
|
|
404
|
+
return DiscoveredAgent(
|
|
405
|
+
source="network",
|
|
406
|
+
name=f"llm-api@{location}",
|
|
407
|
+
framework="OpenAI-compatible API",
|
|
408
|
+
provider="",
|
|
409
|
+
model=model,
|
|
410
|
+
location=location,
|
|
411
|
+
api_keys=[],
|
|
412
|
+
live_connections=[],
|
|
413
|
+
risk="LOW" if auth_required else "MEDIUM",
|
|
414
|
+
risk_reason=(
|
|
415
|
+
"OpenAI-compatible API (auth required)"
|
|
416
|
+
if auth_required
|
|
417
|
+
else "OpenAI-compatible API with no authentication — open access"
|
|
418
|
+
),
|
|
419
|
+
next_step=f"sentinel scan --url {base}",
|
|
420
|
+
)
|
|
421
|
+
except httpx.RequestError:
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
# AgentSentinel platform
|
|
425
|
+
try:
|
|
426
|
+
r = client.get(f"{base}/health")
|
|
427
|
+
if r.status_code == 200 and r.text.strip().startswith("{"):
|
|
428
|
+
body = r.json()
|
|
429
|
+
if "status" in body:
|
|
430
|
+
return DiscoveredAgent(
|
|
431
|
+
source="network",
|
|
432
|
+
name=f"agentsentinel@{location}",
|
|
433
|
+
framework="AgentSentinel",
|
|
434
|
+
provider="",
|
|
435
|
+
model="",
|
|
436
|
+
location=location,
|
|
437
|
+
api_keys=[],
|
|
438
|
+
live_connections=[],
|
|
439
|
+
risk="LOW",
|
|
440
|
+
risk_reason="AgentSentinel monitoring platform",
|
|
441
|
+
next_step=f"sentinel scan --connect http://{location}",
|
|
442
|
+
)
|
|
443
|
+
except (httpx.RequestError, Exception):
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
# Generic agent API (LangChain server, FastAPI agent, etc.)
|
|
447
|
+
try:
|
|
448
|
+
r = client.get(f"{base}/api/v1/agents")
|
|
449
|
+
if r.status_code in (200, 401, 403):
|
|
450
|
+
auth_required = r.status_code in (401, 403)
|
|
451
|
+
return DiscoveredAgent(
|
|
452
|
+
source="network",
|
|
453
|
+
name=f"agent-api@{location}",
|
|
454
|
+
framework="Unknown Agent API",
|
|
455
|
+
provider="",
|
|
456
|
+
model="",
|
|
457
|
+
location=location,
|
|
458
|
+
api_keys=[],
|
|
459
|
+
live_connections=[],
|
|
460
|
+
risk="MEDIUM" if not auth_required else "LOW",
|
|
461
|
+
risk_reason=(
|
|
462
|
+
"Agent API endpoint detected (auth required)"
|
|
463
|
+
if auth_required
|
|
464
|
+
else "Agent API endpoint with no authentication"
|
|
465
|
+
),
|
|
466
|
+
next_step=f"sentinel scan --url {base}",
|
|
467
|
+
)
|
|
468
|
+
except httpx.RequestError:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _looks_like_openai_api(response) -> bool:
|
|
475
|
+
try:
|
|
476
|
+
body = response.json()
|
|
477
|
+
return "data" in body or "models" in body or "object" in body or "error" in body
|
|
478
|
+
except Exception:
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _extract_model_from_response(response) -> str:
|
|
483
|
+
try:
|
|
484
|
+
data = response.json().get("data", [])
|
|
485
|
+
if data:
|
|
486
|
+
return data[0].get("id", "")
|
|
487
|
+
except Exception:
|
|
488
|
+
pass
|
|
489
|
+
return ""
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# ── File scanner ──────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
def scan_files(path: Path) -> list[DiscoveredAgent]:
|
|
495
|
+
"""Find Python files in a directory that look like AI agents."""
|
|
496
|
+
from agentsentinel_cli.scanner import scan_path as static_scan
|
|
497
|
+
|
|
498
|
+
agents = static_scan(path)
|
|
499
|
+
found: list[DiscoveredAgent] = []
|
|
500
|
+
|
|
501
|
+
for agent in agents:
|
|
502
|
+
framework, provider = detect_framework(agent.file.read_text(errors="ignore"))
|
|
503
|
+
model = agent.model or detect_model(agent.file.read_text(errors="ignore"))
|
|
504
|
+
tool_count = len(agent.tools)
|
|
505
|
+
has_dangerous = any(t.is_dangerous for t in agent.tools)
|
|
506
|
+
has_creds = bool(agent.hardcoded_creds)
|
|
507
|
+
|
|
508
|
+
risk, risk_reason = _assess_file_risk(has_creds, has_dangerous, tool_count, framework)
|
|
509
|
+
|
|
510
|
+
found.append(DiscoveredAgent(
|
|
511
|
+
source="file",
|
|
512
|
+
name=agent.file.stem.replace("_", "-"),
|
|
513
|
+
framework=framework if framework != "Unknown" else _infer_framework_from_tools(agent),
|
|
514
|
+
provider=provider,
|
|
515
|
+
model=model,
|
|
516
|
+
location=str(agent.file),
|
|
517
|
+
api_keys=[f"HARDCODED: {c}" for c in agent.hardcoded_creds],
|
|
518
|
+
live_connections=[],
|
|
519
|
+
risk=risk,
|
|
520
|
+
risk_reason=risk_reason,
|
|
521
|
+
next_step=f"sentinel scan {agent.file}",
|
|
522
|
+
))
|
|
523
|
+
|
|
524
|
+
return found
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _infer_framework_from_tools(agent) -> str:
|
|
528
|
+
sources = {t.source for t in agent.tools}
|
|
529
|
+
if "BaseTool subclass" in sources or "StructuredTool" in str(sources):
|
|
530
|
+
return "LangChain"
|
|
531
|
+
if "@tool decorator" in sources:
|
|
532
|
+
return "LangChain / CrewAI"
|
|
533
|
+
return "Python agent"
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _assess_file_risk(
|
|
537
|
+
has_creds: bool,
|
|
538
|
+
has_dangerous: bool,
|
|
539
|
+
tool_count: int,
|
|
540
|
+
framework: str,
|
|
541
|
+
) -> tuple[str, str]:
|
|
542
|
+
if has_creds:
|
|
543
|
+
return "CRITICAL", "Hardcoded credentials detected in source code — rotate immediately"
|
|
544
|
+
if has_dangerous:
|
|
545
|
+
return "HIGH", "Agent holds dangerous tool grants — run full scan"
|
|
546
|
+
if tool_count > 10:
|
|
547
|
+
return "MEDIUM", f"{tool_count} tool grants — excessive permissions, high blast radius"
|
|
548
|
+
if tool_count > 0:
|
|
549
|
+
return "LOW", f"{tool_count} tool grant{'s' if tool_count != 1 else ''} detected"
|
|
550
|
+
return "UNKNOWN", "Agent file detected — run full scan for analysis"
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# ── Docker scanner ────────────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
def scan_docker() -> list[DiscoveredAgent]:
|
|
556
|
+
"""Find running Docker containers that look like AI agents."""
|
|
557
|
+
if not _docker_available():
|
|
558
|
+
return []
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
result = subprocess.run(
|
|
562
|
+
["docker", "ps", "--format", "{{json .}}"],
|
|
563
|
+
capture_output=True, text=True, timeout=10,
|
|
564
|
+
)
|
|
565
|
+
if result.returncode != 0:
|
|
566
|
+
return []
|
|
567
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
568
|
+
return []
|
|
569
|
+
|
|
570
|
+
found: list[DiscoveredAgent] = []
|
|
571
|
+
|
|
572
|
+
for line in result.stdout.strip().splitlines():
|
|
573
|
+
if not line.strip():
|
|
574
|
+
continue
|
|
575
|
+
try:
|
|
576
|
+
container = json.loads(line)
|
|
577
|
+
container_id = container.get("ID", "")
|
|
578
|
+
container_name = container.get("Names", "unknown").lstrip("/")
|
|
579
|
+
image = container.get("Image", "")
|
|
580
|
+
|
|
581
|
+
env = _docker_inspect_env(container_id)
|
|
582
|
+
if not env:
|
|
583
|
+
continue
|
|
584
|
+
|
|
585
|
+
has_llm_env = any(var in env for var in LLM_ENV_VARS)
|
|
586
|
+
if not has_llm_env:
|
|
587
|
+
framework, provider = detect_framework(image)
|
|
588
|
+
if framework == "Unknown":
|
|
589
|
+
continue
|
|
590
|
+
else:
|
|
591
|
+
full_text = " ".join([image, container_name, " ".join(env.values())])
|
|
592
|
+
framework, provider = detect_framework(full_text)
|
|
593
|
+
|
|
594
|
+
api_keys = extract_api_keys(env)
|
|
595
|
+
model = detect_model(" ".join(env.values()))
|
|
596
|
+
provider = provider or detect_provider_from_env(env)
|
|
597
|
+
risk, risk_reason = _assess_process_risk(api_keys, [], framework)
|
|
598
|
+
|
|
599
|
+
found.append(DiscoveredAgent(
|
|
600
|
+
source="docker",
|
|
601
|
+
name=container_name,
|
|
602
|
+
framework=framework,
|
|
603
|
+
provider=provider,
|
|
604
|
+
model=model,
|
|
605
|
+
location=f"container:{container_name}",
|
|
606
|
+
api_keys=api_keys,
|
|
607
|
+
live_connections=[],
|
|
608
|
+
risk=risk,
|
|
609
|
+
risk_reason=risk_reason,
|
|
610
|
+
next_step=f"docker exec {container_name} sentinel scan .",
|
|
611
|
+
))
|
|
612
|
+
|
|
613
|
+
except (json.JSONDecodeError, KeyError):
|
|
614
|
+
continue
|
|
615
|
+
|
|
616
|
+
return found
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _docker_available() -> bool:
|
|
620
|
+
try:
|
|
621
|
+
result = subprocess.run(["docker", "info"], capture_output=True, timeout=5)
|
|
622
|
+
return result.returncode == 0
|
|
623
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _docker_inspect_env(container_id: str) -> dict[str, str]:
|
|
628
|
+
try:
|
|
629
|
+
result = subprocess.run(
|
|
630
|
+
["docker", "inspect", "--format",
|
|
631
|
+
"{{range .Config.Env}}{{.}}\n{{end}}", container_id],
|
|
632
|
+
capture_output=True, text=True, timeout=10,
|
|
633
|
+
)
|
|
634
|
+
env: dict[str, str] = {}
|
|
635
|
+
for line in result.stdout.strip().splitlines():
|
|
636
|
+
if "=" in line:
|
|
637
|
+
key, _, value = line.partition("=")
|
|
638
|
+
env[key.strip()] = value.strip()
|
|
639
|
+
return env
|
|
640
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
641
|
+
return {}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# ── Orchestrator ──────────────────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
def run_discovery(
|
|
647
|
+
do_process: bool = True,
|
|
648
|
+
do_network: bool = True,
|
|
649
|
+
do_docker: bool = False,
|
|
650
|
+
scan_path: Optional[Path] = None,
|
|
651
|
+
ports: list[int] | None = None,
|
|
652
|
+
subnet: Optional[str] = None,
|
|
653
|
+
subnet_progress_cb: Optional[Callable[[int, int, str], None]] = None,
|
|
654
|
+
) -> tuple[list[DiscoveredAgent], Optional[SubnetScanStats]]:
|
|
655
|
+
"""Run all requested discovery scanners.
|
|
656
|
+
|
|
657
|
+
Returns (agents, subnet_stats). subnet_stats is None when no subnet scan ran.
|
|
658
|
+
"""
|
|
659
|
+
results: list[DiscoveredAgent] = []
|
|
660
|
+
subnet_stats: Optional[SubnetScanStats] = None
|
|
661
|
+
|
|
662
|
+
if do_process:
|
|
663
|
+
results.extend(scan_processes())
|
|
664
|
+
|
|
665
|
+
if do_network:
|
|
666
|
+
results.extend(scan_network(ports=ports))
|
|
667
|
+
|
|
668
|
+
if subnet:
|
|
669
|
+
agents, subnet_stats = scan_subnet(
|
|
670
|
+
cidr=subnet,
|
|
671
|
+
ports=ports,
|
|
672
|
+
on_progress=subnet_progress_cb,
|
|
673
|
+
)
|
|
674
|
+
results.extend(agents)
|
|
675
|
+
|
|
676
|
+
if scan_path:
|
|
677
|
+
results.extend(scan_files(scan_path))
|
|
678
|
+
|
|
679
|
+
if do_docker:
|
|
680
|
+
results.extend(scan_docker())
|
|
681
|
+
|
|
682
|
+
return results, subnet_stats
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ── JSON serialisation ────────────────────────────────────────────────────────
|
|
686
|
+
|
|
687
|
+
def as_json(agents: list[DiscoveredAgent]) -> str:
|
|
688
|
+
return json.dumps(
|
|
689
|
+
[dataclasses.asdict(a) for a in agents],
|
|
690
|
+
indent=2,
|
|
691
|
+
)
|