agentsentinel-cli 0.7.1__tar.gz → 0.7.3__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.
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/PKG-INFO +1 -1
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/cli.py +52 -24
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/discover.py +147 -230
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/discover_report.py +13 -5
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/fingerprint.py +42 -12
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/inspect.py +11 -2
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/inspect_report.py +12 -7
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/mcp_client.py +2 -2
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/pyproject.toml +1 -1
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/.gitignore +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/DOCUMENTATION.md +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/LICENSE +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/README.md +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/__init__.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/a2a_report.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/a2a_rules.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/a2a_scanner.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/agent_mode.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/agent_mode_report.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/ai_probe.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/attacks/__init__.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/attacks/library.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/frameworks.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/mcp_report.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/mcp_rules.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/probe.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/probe_report.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/report.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/rules.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/scanner.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/secrets.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/secrets_report.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/secrets_rules.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/supply_chain_ai.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/supply_chain_report.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/supply_chain_rules.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/suppress.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/target.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/tmp/test-mcp-agent/README.md +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/tmp/test-mcp-agent/langchain_agent.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/tmp/test-mcp-agent/mcp_server.py +0 -0
- {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/tmp/test-mcp-agent/requirements.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentsentinel-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: Agentic security CLI — AI analyst with memory, supply chain audit, MCP audit, red-team probing, and agent discovery
|
|
5
5
|
Project-URL: Homepage, https://github.com/jaydenaung/agentsentinel-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/jaydenaung/agentsentinel-cli
|
|
@@ -123,52 +123,82 @@ def _enrich_from_platform(agents, scores_map, connect_url, api_key):
|
|
|
123
123
|
|
|
124
124
|
@main.command()
|
|
125
125
|
@click.option("--process/--no-process", default=True, show_default=True,
|
|
126
|
-
help="Scan running processes for
|
|
126
|
+
help="Scan running processes for MCP servers and agent signals.")
|
|
127
127
|
@click.option("--network/--no-network", default=True, show_default=True,
|
|
128
|
-
help="Probe local ports
|
|
128
|
+
help="Probe local ports — confirmed via MCP protocol handshake.")
|
|
129
129
|
@click.option("--docker/--no-docker", default=False, show_default=True,
|
|
130
|
-
help="Inspect running Docker containers.")
|
|
131
|
-
@click.option("--
|
|
132
|
-
|
|
130
|
+
help="Inspect running Docker containers for MCP/agent patterns.")
|
|
131
|
+
@click.option("--host", default=None, metavar="IP",
|
|
132
|
+
help="Scan a single host, e.g. 10.0.1.45.")
|
|
133
133
|
@click.option("--subnet", default=None, metavar="CIDR",
|
|
134
|
-
help="Scan a CIDR subnet for
|
|
134
|
+
help="Scan a CIDR subnet for MCP servers, e.g. 10.0.0.0/24.")
|
|
135
135
|
@click.option("--ports", default=None, metavar="RANGE",
|
|
136
|
-
help="Custom port range
|
|
136
|
+
help="Custom port range, e.g. 8000-9001. Defaults to common MCP/agent ports.")
|
|
137
|
+
@click.option("--auth-header", "auth_header", default=None, metavar="HEADER",
|
|
138
|
+
help="HTTP auth header for MCP handshakes, e.g. 'Authorization: Bearer token'.")
|
|
137
139
|
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text",
|
|
138
140
|
help="Output format.")
|
|
139
141
|
@click.option("--verbose", "-v", is_flag=True, default=False,
|
|
140
|
-
help="Show full details per discovered
|
|
142
|
+
help="Show full details per discovered server.")
|
|
141
143
|
def discover(
|
|
142
144
|
process: bool,
|
|
143
145
|
network: bool,
|
|
144
146
|
docker: bool,
|
|
145
|
-
|
|
147
|
+
host: str | None,
|
|
146
148
|
subnet: str | None,
|
|
147
149
|
ports: str | None,
|
|
150
|
+
auth_header: str | None,
|
|
148
151
|
fmt: str,
|
|
149
152
|
verbose: bool,
|
|
150
153
|
) -> None:
|
|
151
|
-
"""Find AI
|
|
154
|
+
"""Find MCP servers and AI agent processes in your environment.
|
|
152
155
|
|
|
153
|
-
|
|
154
|
-
containers to surface AI agents — including unmonitored ones.
|
|
156
|
+
Confirms MCP servers via protocol handshake — not just open ports.
|
|
155
157
|
|
|
156
158
|
\b
|
|
157
159
|
Examples:
|
|
158
|
-
sentinel discover
|
|
159
|
-
sentinel discover --
|
|
160
|
-
sentinel discover --
|
|
161
|
-
sentinel discover --subnet 10.0.0.0/24
|
|
162
|
-
|
|
163
|
-
sentinel discover --
|
|
164
|
-
sentinel discover --
|
|
160
|
+
sentinel discover local processes + ports
|
|
161
|
+
sentinel discover --host 10.0.1.45 single remote host
|
|
162
|
+
sentinel discover --subnet 10.0.0.0/24 full subnet scan
|
|
163
|
+
sentinel discover --subnet 10.0.0.0/24 \\
|
|
164
|
+
--auth-header 'Authorization: Bearer token' scan with credentials
|
|
165
|
+
sentinel discover --no-process network only
|
|
166
|
+
sentinel discover --docker include containers
|
|
167
|
+
sentinel discover --ports 8000-9001 custom port range
|
|
168
|
+
sentinel discover --format json machine-readable output
|
|
165
169
|
"""
|
|
166
|
-
from agentsentinel_cli.discover import run_discovery, as_json as discover_json
|
|
170
|
+
from agentsentinel_cli.discover import run_discovery, scan_network, as_json as discover_json
|
|
167
171
|
from agentsentinel_cli.discover_report import print_discover_result, print_subnet_progress
|
|
168
172
|
|
|
169
173
|
# Parse port range
|
|
170
174
|
port_list = _parse_ports(ports) if ports else None
|
|
171
175
|
|
|
176
|
+
# Parse auth header
|
|
177
|
+
extra_headers: dict[str, str] = {}
|
|
178
|
+
if auth_header:
|
|
179
|
+
if ":" not in auth_header:
|
|
180
|
+
console.print("[red]Error:[/red] --auth-header must be 'Header-Name: value' format.")
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
key, _, val = auth_header.partition(":")
|
|
183
|
+
extra_headers[key.strip()] = val.strip()
|
|
184
|
+
|
|
185
|
+
# --host: single-host scan — bypass run_discovery and call scan_network directly
|
|
186
|
+
if host:
|
|
187
|
+
if fmt == "text":
|
|
188
|
+
_warn_missing_deps(False, True)
|
|
189
|
+
agents = scan_network(
|
|
190
|
+
host=host,
|
|
191
|
+
ports=port_list,
|
|
192
|
+
extra_headers=extra_headers or None,
|
|
193
|
+
)
|
|
194
|
+
if fmt == "json":
|
|
195
|
+
click.echo(discover_json(agents))
|
|
196
|
+
return
|
|
197
|
+
print_discover_result(agents, vectors=[f"host ({host})"], verbose=verbose)
|
|
198
|
+
if any(a.risk == "CRITICAL" for a in agents):
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
return
|
|
201
|
+
|
|
172
202
|
# Collect active scan vectors for the header
|
|
173
203
|
vectors = []
|
|
174
204
|
if process:
|
|
@@ -177,14 +207,12 @@ def discover(
|
|
|
177
207
|
vectors.append("network")
|
|
178
208
|
if subnet:
|
|
179
209
|
vectors.append(f"subnet ({subnet})")
|
|
180
|
-
if scan_path:
|
|
181
|
-
vectors.append(f"files ({scan_path})")
|
|
182
210
|
if docker:
|
|
183
211
|
vectors.append("docker")
|
|
184
212
|
|
|
185
213
|
if not vectors:
|
|
186
214
|
console.print("[yellow]No scan vectors selected — use at least one of: "
|
|
187
|
-
"--process, --network, --
|
|
215
|
+
"--process, --network, --host, --subnet, --docker[/yellow]")
|
|
188
216
|
sys.exit(1)
|
|
189
217
|
|
|
190
218
|
if fmt == "text":
|
|
@@ -197,9 +225,9 @@ def discover(
|
|
|
197
225
|
do_process=process,
|
|
198
226
|
do_network=network,
|
|
199
227
|
do_docker=docker,
|
|
200
|
-
scan_path=scan_path,
|
|
201
228
|
ports=port_list,
|
|
202
229
|
subnet=subnet,
|
|
230
|
+
extra_headers=extra_headers or None,
|
|
203
231
|
subnet_progress_cb=progress_cb,
|
|
204
232
|
)
|
|
205
233
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
|
-
sentinel discover —
|
|
2
|
+
sentinel discover — find MCP servers and AI agent processes.
|
|
3
3
|
|
|
4
4
|
Scan vectors:
|
|
5
|
-
process running Python/Node processes
|
|
6
|
-
network open ports
|
|
7
|
-
subnet CIDR subnet
|
|
8
|
-
|
|
9
|
-
docker Docker containers with LLM API keys in their environment
|
|
5
|
+
process running Python/Node processes serving MCP or calling LLM APIs
|
|
6
|
+
network open ports on localhost confirmed as MCP via protocol handshake
|
|
7
|
+
subnet CIDR subnet — TCP sweep then MCP handshake on every open port
|
|
8
|
+
docker Docker containers with MCP server or LLM agent patterns
|
|
10
9
|
"""
|
|
11
10
|
|
|
12
11
|
from __future__ import annotations
|
|
@@ -34,17 +33,19 @@ from agentsentinel_cli.frameworks import (
|
|
|
34
33
|
|
|
35
34
|
@dataclasses.dataclass
|
|
36
35
|
class DiscoveredAgent:
|
|
37
|
-
source: str # process | network | subnet |
|
|
36
|
+
source: str # process | network | subnet | docker
|
|
38
37
|
name: str # human-readable name
|
|
39
|
-
framework: str #
|
|
38
|
+
framework: str # FastMCP | LangChain | AutoGen | etc.
|
|
40
39
|
provider: str # Anthropic | OpenAI | Google | etc.
|
|
41
40
|
model: str # claude-sonnet | gpt-4o | etc. (empty if unknown)
|
|
42
|
-
location: str # pid:1234 | 10.0.1.45:8080 |
|
|
41
|
+
location: str # pid:1234 | 10.0.1.45:8080 | container:name
|
|
43
42
|
api_keys: list[str] # masked keys: ANTHROPIC_API_KEY=sk-ant-...5f3d
|
|
44
43
|
live_connections: list[str] # LLM API hosts this process is talking to
|
|
45
44
|
risk: str # CRITICAL | HIGH | MEDIUM | LOW | UNKNOWN
|
|
46
45
|
risk_reason: str # one-line explanation of the risk level
|
|
47
46
|
next_step: str # suggested follow-up command
|
|
47
|
+
tools: list[str] = dataclasses.field(default_factory=list) # tool names (MCP enumeration)
|
|
48
|
+
transport: str = "" # "http" | "sse" | "" (empty for non-MCP)
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
@dataclasses.dataclass
|
|
@@ -68,10 +69,6 @@ _DEFAULT_PORTS = [
|
|
|
68
69
|
11434, # Ollama
|
|
69
70
|
]
|
|
70
71
|
|
|
71
|
-
_MCP_INDICATOR_PATHS = ["/sse", "/messages/"]
|
|
72
|
-
_OPENAI_COMPAT_PATHS = ["/v1/models"]
|
|
73
|
-
_SENTINEL_PATHS = ["/api/v1/agents", "/health"]
|
|
74
|
-
|
|
75
72
|
# Subnet scan limits — scanning beyond these sizes is impractical
|
|
76
73
|
_MAX_SUBNET_HOSTS_WARN = 1024 # /22 — warn but allow
|
|
77
74
|
_MAX_SUBNET_HOSTS_BLOCK = 65536 # /16 — refuse (too slow, too noisy)
|
|
@@ -221,8 +218,9 @@ def scan_network(
|
|
|
221
218
|
host: str = "127.0.0.1",
|
|
222
219
|
ports: list[int] | None = None,
|
|
223
220
|
timeout: float = 0.5,
|
|
221
|
+
extra_headers: dict[str, str] | None = None,
|
|
224
222
|
) -> list[DiscoveredAgent]:
|
|
225
|
-
"""Probe a single host's ports
|
|
223
|
+
"""Probe a single host's ports — confirms MCP via protocol handshake."""
|
|
226
224
|
if ports is None:
|
|
227
225
|
ports = _DEFAULT_PORTS
|
|
228
226
|
|
|
@@ -230,11 +228,20 @@ def scan_network(
|
|
|
230
228
|
if not open_ports:
|
|
231
229
|
return []
|
|
232
230
|
|
|
231
|
+
# SSE handshake needs much more time than a TCP connect — use a fixed floor
|
|
232
|
+
# of 8 seconds regardless of the port-sweep timeout.
|
|
233
|
+
handshake_timeout = max(timeout * 16, 8.0)
|
|
234
|
+
|
|
233
235
|
found: list[DiscoveredAgent] = []
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
with ThreadPoolExecutor(max_workers=min(10, len(open_ports))) as pool:
|
|
237
|
+
futures = {
|
|
238
|
+
pool.submit(_probe_mcp, host, port, handshake_timeout, extra_headers): port
|
|
239
|
+
for port in open_ports
|
|
240
|
+
}
|
|
241
|
+
for future in as_completed(futures):
|
|
242
|
+
agent = future.result()
|
|
243
|
+
if agent:
|
|
244
|
+
found.append(agent)
|
|
238
245
|
return found
|
|
239
246
|
|
|
240
247
|
|
|
@@ -273,19 +280,24 @@ def scan_subnet(
|
|
|
273
280
|
cidr: str,
|
|
274
281
|
ports: list[int] | None = None,
|
|
275
282
|
timeout: float = 0.3,
|
|
276
|
-
|
|
283
|
+
extra_headers: dict[str, str] | None = None,
|
|
284
|
+
on_progress: Optional[Callable[[int, int, str, str], None]] = None,
|
|
277
285
|
) -> tuple[list[DiscoveredAgent], SubnetScanStats]:
|
|
278
|
-
"""Scan every host in a CIDR subnet for
|
|
286
|
+
"""Scan every host in a CIDR subnet for MCP servers.
|
|
279
287
|
|
|
280
|
-
|
|
281
|
-
Phase 1 — parallel TCP connect across all host:port
|
|
282
|
-
Phase 2 —
|
|
288
|
+
Two phases:
|
|
289
|
+
Phase 1 — parallel TCP connect across all host:port pairs (fast sweep)
|
|
290
|
+
Phase 2 — MCP protocol handshake on every open port (targeted verification)
|
|
291
|
+
|
|
292
|
+
A result in Phase 2 means the MCP initialize exchange completed — not just
|
|
293
|
+
that a port was open.
|
|
283
294
|
|
|
284
295
|
Args:
|
|
285
|
-
cidr:
|
|
286
|
-
ports:
|
|
287
|
-
timeout:
|
|
288
|
-
|
|
296
|
+
cidr: Network range, e.g. "10.0.0.0/24"
|
|
297
|
+
ports: Port list to probe (default: _DEFAULT_PORTS)
|
|
298
|
+
timeout: Per-connection TCP timeout in seconds
|
|
299
|
+
extra_headers: HTTP headers for MCP handshake (auth tokens, etc.)
|
|
300
|
+
on_progress: Optional callback(completed, total, current_ip, phase)
|
|
289
301
|
|
|
290
302
|
Returns:
|
|
291
303
|
(agents, stats) tuple
|
|
@@ -302,7 +314,7 @@ def scan_subnet(
|
|
|
302
314
|
if len(hosts) > _MAX_SUBNET_HOSTS_BLOCK:
|
|
303
315
|
raise ValueError(
|
|
304
316
|
f"{cidr} contains {len(hosts):,} hosts. "
|
|
305
|
-
f"Maximum supported is {_MAX_SUBNET_HOSTS_BLOCK:,}
|
|
317
|
+
f"Maximum supported is {_MAX_SUBNET_HOSTS_BLOCK:,}. "
|
|
306
318
|
"Use a smaller subnet or scan individual host ranges."
|
|
307
319
|
)
|
|
308
320
|
|
|
@@ -310,8 +322,7 @@ def scan_subnet(
|
|
|
310
322
|
total_probes = len(hosts) * len(ports)
|
|
311
323
|
open_targets: list[tuple[str, int]] = []
|
|
312
324
|
|
|
313
|
-
# ── Phase 1: parallel TCP connect
|
|
314
|
-
# High concurrency — most connections refuse immediately, failures are cheap.
|
|
325
|
+
# ── Phase 1: parallel TCP connect ────────────────────────────────────────
|
|
315
326
|
completed = 0
|
|
316
327
|
workers = min(250, total_probes)
|
|
317
328
|
|
|
@@ -323,25 +334,36 @@ def scan_subnet(
|
|
|
323
334
|
return None
|
|
324
335
|
|
|
325
336
|
tasks = [(h, p) for h in hosts for p in ports]
|
|
326
|
-
futures = {}
|
|
327
337
|
|
|
328
338
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
329
|
-
|
|
330
|
-
for future in as_completed(
|
|
339
|
+
futures_p1 = {pool.submit(check_port, h, p): (h, p) for h, p in tasks}
|
|
340
|
+
for future in as_completed(futures_p1):
|
|
331
341
|
result = future.result()
|
|
332
342
|
if result:
|
|
333
343
|
open_targets.append(result)
|
|
334
344
|
completed += 1
|
|
335
345
|
if on_progress:
|
|
336
|
-
host_ip, _ =
|
|
337
|
-
on_progress(completed, total_probes, host_ip)
|
|
346
|
+
host_ip, _ = futures_p1[future]
|
|
347
|
+
on_progress(completed, total_probes, host_ip, "1")
|
|
338
348
|
|
|
339
|
-
# ── Phase 2:
|
|
349
|
+
# ── Phase 2: MCP protocol handshake on open ports ─────────────────────
|
|
340
350
|
found: list[DiscoveredAgent] = []
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
351
|
+
if open_targets:
|
|
352
|
+
p2_workers = min(20, len(open_targets))
|
|
353
|
+
with ThreadPoolExecutor(max_workers=p2_workers) as pool:
|
|
354
|
+
futures_p2 = {
|
|
355
|
+
pool.submit(_probe_mcp, h, p, timeout * 10, extra_headers): (h, p)
|
|
356
|
+
for h, p in open_targets
|
|
357
|
+
}
|
|
358
|
+
p2_done = 0
|
|
359
|
+
for future in as_completed(futures_p2):
|
|
360
|
+
agent = future.result()
|
|
361
|
+
if agent:
|
|
362
|
+
found.append(agent)
|
|
363
|
+
p2_done += 1
|
|
364
|
+
if on_progress:
|
|
365
|
+
h, p = futures_p2[future]
|
|
366
|
+
on_progress(p2_done, len(open_targets), f"{h}:{p}", "2")
|
|
345
367
|
|
|
346
368
|
elapsed = time.monotonic() - started_at
|
|
347
369
|
stats = SubnetScanStats(
|
|
@@ -355,199 +377,96 @@ def scan_subnet(
|
|
|
355
377
|
return found, stats
|
|
356
378
|
|
|
357
379
|
|
|
358
|
-
# ──
|
|
380
|
+
# ── MCP protocol prober ───────────────────────────────────────────────────────
|
|
359
381
|
|
|
360
|
-
def
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
382
|
+
def _probe_mcp(
|
|
383
|
+
host: str,
|
|
384
|
+
port: int,
|
|
385
|
+
timeout: float,
|
|
386
|
+
extra_headers: dict[str, str] | None = None,
|
|
387
|
+
) -> DiscoveredAgent | None:
|
|
388
|
+
"""Confirm a port is an MCP server by completing the initialize handshake.
|
|
389
|
+
|
|
390
|
+
Tries streamable-HTTP (POST) first; falls back to SSE (GET /sse) automatically.
|
|
391
|
+
A non-None return means the MCP protocol exchange succeeded or the server
|
|
392
|
+
explicitly rejected us with 401/403 — both confirm an MCP server is present.
|
|
393
|
+
"""
|
|
394
|
+
from agentsentinel_cli.mcp_client import scan_http, McpAuthRequired, McpError
|
|
366
395
|
|
|
367
396
|
base = f"http://{host}:{port}"
|
|
368
397
|
location = f"{host}:{port}"
|
|
369
398
|
|
|
370
|
-
with httpx.Client(timeout=timeout, follow_redirects=False) 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
399
|
try:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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],
|
|
400
|
+
server = scan_http(base, extra_headers=extra_headers, timeout=timeout)
|
|
401
|
+
except McpAuthRequired:
|
|
402
|
+
# Server is MCP — it understood our handshake but requires credentials
|
|
403
|
+
scan_url = f"{base}/sse" if True else base # SSE is dominant transport
|
|
404
|
+
return DiscoveredAgent(
|
|
405
|
+
source="network",
|
|
406
|
+
name=f"mcp-server@{location}",
|
|
407
|
+
framework="MCP Server",
|
|
408
|
+
provider="",
|
|
409
|
+
model="",
|
|
410
|
+
location=location,
|
|
411
|
+
api_keys=[],
|
|
518
412
|
live_connections=[],
|
|
519
|
-
risk=
|
|
520
|
-
risk_reason=
|
|
521
|
-
next_step=
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
return
|
|
531
|
-
if "@tool decorator" in sources:
|
|
532
|
-
return "LangChain / CrewAI"
|
|
533
|
-
return "Python agent"
|
|
413
|
+
risk="MEDIUM",
|
|
414
|
+
risk_reason="MCP server confirmed — authentication required, tools not enumerated",
|
|
415
|
+
next_step=(
|
|
416
|
+
f"sentinel mcp scan {base}/sse --auth-header 'Authorization: Bearer <token>'"
|
|
417
|
+
),
|
|
418
|
+
tools=[],
|
|
419
|
+
transport="",
|
|
420
|
+
)
|
|
421
|
+
except McpError:
|
|
422
|
+
return None
|
|
423
|
+
except Exception:
|
|
424
|
+
return None
|
|
534
425
|
|
|
426
|
+
# Handshake succeeded — assess risk based on actual tool content
|
|
427
|
+
tool_names = [t.name for t in server.tools]
|
|
428
|
+
has_dangerous = any(t.is_dangerous for t in server.tools)
|
|
429
|
+
has_write = any(t.scope == "write" for t in server.tools)
|
|
430
|
+
auth_present = bool(extra_headers)
|
|
431
|
+
|
|
432
|
+
if not auth_present:
|
|
433
|
+
if has_dangerous or has_write:
|
|
434
|
+
risk = "CRITICAL"
|
|
435
|
+
risk_reason = (
|
|
436
|
+
f"Unauthenticated MCP server with dangerous/write tools: "
|
|
437
|
+
f"{', '.join(t.name for t in server.tools if t.is_dangerous or t.scope == 'write')}"
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
risk = "HIGH"
|
|
441
|
+
risk_reason = (
|
|
442
|
+
f"Unauthenticated MCP server — {len(server.tools)} tool"
|
|
443
|
+
f"{'s' if len(server.tools) != 1 else ''} publicly accessible"
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
risk = "LOW"
|
|
447
|
+
risk_reason = f"MCP server (authenticated) — {len(server.tools)} tool{'s' if len(server.tools) != 1 else ''} enumerated"
|
|
448
|
+
|
|
449
|
+
scan_url = f"{base}/sse" if server.transport == "sse" else base
|
|
450
|
+
auth_flag = (
|
|
451
|
+
f" --auth-header '{next(iter(extra_headers.items()))[0]}: ...'"
|
|
452
|
+
if extra_headers else ""
|
|
453
|
+
)
|
|
535
454
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
455
|
+
return DiscoveredAgent(
|
|
456
|
+
source="network",
|
|
457
|
+
name=server.name if server.name != "unknown" else f"mcp-server@{location}",
|
|
458
|
+
framework=f"MCP Server ({server.transport.upper()})",
|
|
459
|
+
provider="",
|
|
460
|
+
model="",
|
|
461
|
+
location=location,
|
|
462
|
+
api_keys=[],
|
|
463
|
+
live_connections=[],
|
|
464
|
+
risk=risk,
|
|
465
|
+
risk_reason=risk_reason,
|
|
466
|
+
next_step=f"sentinel mcp scan {scan_url}{auth_flag}",
|
|
467
|
+
tools=tool_names,
|
|
468
|
+
transport=server.transport,
|
|
469
|
+
)
|
|
551
470
|
|
|
552
471
|
|
|
553
472
|
# ── Docker scanner ────────────────────────────────────────────────────────────
|
|
@@ -651,10 +570,10 @@ def run_discovery(
|
|
|
651
570
|
do_process: bool = True,
|
|
652
571
|
do_network: bool = True,
|
|
653
572
|
do_docker: bool = False,
|
|
654
|
-
scan_path: Optional[Path] = None,
|
|
655
573
|
ports: list[int] | None = None,
|
|
656
574
|
subnet: Optional[str] = None,
|
|
657
|
-
|
|
575
|
+
extra_headers: dict[str, str] | None = None,
|
|
576
|
+
subnet_progress_cb: Optional[Callable[[int, int, str, str], None]] = None,
|
|
658
577
|
) -> tuple[list[DiscoveredAgent], Optional[SubnetScanStats]]:
|
|
659
578
|
"""Run all requested discovery scanners.
|
|
660
579
|
|
|
@@ -667,19 +586,17 @@ def run_discovery(
|
|
|
667
586
|
results.extend(scan_processes())
|
|
668
587
|
|
|
669
588
|
if do_network:
|
|
670
|
-
results.extend(scan_network(ports=ports))
|
|
589
|
+
results.extend(scan_network(ports=ports, extra_headers=extra_headers))
|
|
671
590
|
|
|
672
591
|
if subnet:
|
|
673
592
|
agents, subnet_stats = scan_subnet(
|
|
674
593
|
cidr=subnet,
|
|
675
594
|
ports=ports,
|
|
595
|
+
extra_headers=extra_headers,
|
|
676
596
|
on_progress=subnet_progress_cb,
|
|
677
597
|
)
|
|
678
598
|
results.extend(agents)
|
|
679
599
|
|
|
680
|
-
if scan_path:
|
|
681
|
-
results.extend(scan_files(scan_path))
|
|
682
|
-
|
|
683
600
|
if do_docker:
|
|
684
601
|
results.extend(scan_docker())
|
|
685
602
|
|
|
@@ -34,11 +34,12 @@ _SOURCE_LABEL = {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def print_subnet_progress(completed: int, total: int, current_ip: str) -> None:
|
|
37
|
+
def print_subnet_progress(completed: int, total: int, current_ip: str, phase: str = "1") -> None:
|
|
38
38
|
"""Inline progress updater for subnet scan — overwrites the same line."""
|
|
39
39
|
pct = int(completed / total * 100) if total else 0
|
|
40
|
+
label = "TCP sweep" if phase == "1" else "MCP handshake"
|
|
40
41
|
console.print(
|
|
41
|
-
f"\r [dim]
|
|
42
|
+
f"\r [dim]Phase {phase} {label}: {current_ip} … {pct}% ({completed}/{total})[/dim]",
|
|
42
43
|
end="",
|
|
43
44
|
highlight=False,
|
|
44
45
|
)
|
|
@@ -71,7 +72,7 @@ def print_discover_result(
|
|
|
71
72
|
f"{subnet_stats.elapsed_seconds:.1f}s[/dim]"
|
|
72
73
|
)
|
|
73
74
|
console.print()
|
|
74
|
-
console.print(" [dim]Tip: use [bold]--
|
|
75
|
+
console.print(" [dim]Tip: use [bold]--subnet 10.0.0.0/24[/bold] to scan a network range, "
|
|
75
76
|
"or [bold]--docker[/bold] to inspect containers.[/dim]")
|
|
76
77
|
console.print()
|
|
77
78
|
return
|
|
@@ -136,6 +137,13 @@ def _print_agent(agent: DiscoveredAgent, verbose: bool) -> None:
|
|
|
136
137
|
hosts = ", ".join(sorted(set(agent.live_connections)))
|
|
137
138
|
console.print(f" {'':>10}[dim]Live connections:[/dim] {hosts}")
|
|
138
139
|
|
|
140
|
+
# Enumerated tools (MCP network findings only)
|
|
141
|
+
if agent.tools:
|
|
142
|
+
tools_str = ", ".join(agent.tools[:8])
|
|
143
|
+
if len(agent.tools) > 8:
|
|
144
|
+
tools_str += f" (+{len(agent.tools) - 8} more)"
|
|
145
|
+
console.print(f" {'':>10}[dim]Tools:[/dim] [cyan]{tools_str}[/cyan]")
|
|
146
|
+
|
|
139
147
|
# Risk reason
|
|
140
148
|
console.print(f" {'':>10}[dim]{agent.risk_reason}[/dim]")
|
|
141
149
|
|
|
@@ -190,8 +198,8 @@ def _print_summary(
|
|
|
190
198
|
if critical or high:
|
|
191
199
|
console.print()
|
|
192
200
|
console.print(
|
|
193
|
-
" [dim]Run [bold]sentinel scan <
|
|
194
|
-
"for a full
|
|
201
|
+
" [dim]Run [bold]sentinel mcp scan <url>[/bold] "
|
|
202
|
+
"for a full tool-by-tool security audit on any MCP server above.[/dim]"
|
|
195
203
|
)
|
|
196
204
|
|
|
197
205
|
if subnet_stats:
|
|
@@ -19,13 +19,18 @@ class AgentFingerprint:
|
|
|
19
19
|
system_prompt_snippet: str = ""
|
|
20
20
|
env_vars: list[str] = dataclasses.field(default_factory=list)
|
|
21
21
|
external_apis: list[str] = dataclasses.field(default_factory=list)
|
|
22
|
-
server_type: str = "agent" # "agent" | "mcp_server"
|
|
22
|
+
server_type: str = "agent" # "agent" | "mcp_server" | "mcp_client"
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
#
|
|
25
|
+
# Only server-side SDK modules — bare "mcp" and "mcp.types" are shared; "mcp.client.*" is a consumer
|
|
26
26
|
_MCP_SERVER_IMPORTS = frozenset({
|
|
27
|
-
"mcp
|
|
28
|
-
|
|
27
|
+
"mcp.server", "mcp.server.fastmcp", "fastmcp",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
# Client-side SDK modules — this file consumes tools from an MCP server
|
|
31
|
+
_MCP_CLIENT_IMPORTS = frozenset({
|
|
32
|
+
"mcp.client", "mcp.client.sse", "mcp.client.stdio",
|
|
33
|
+
"mcp.client.streamable_http", "mcp.client.websocket",
|
|
29
34
|
})
|
|
30
35
|
|
|
31
36
|
# Ordered by specificity — first match wins
|
|
@@ -127,12 +132,23 @@ class _FingerprintVisitor(ast.NodeVisitor):
|
|
|
127
132
|
if val and any(val.startswith(p) for p in _KNOWN_MODELS):
|
|
128
133
|
self.model = val
|
|
129
134
|
|
|
130
|
-
# os.
|
|
131
|
-
if func_name
|
|
135
|
+
# os.getenv("KEY") — direct function call
|
|
136
|
+
if func_name == "getenv" and node.args:
|
|
132
137
|
val = _get_str(node.args[0])
|
|
133
138
|
if val and val not in self.env_vars:
|
|
134
139
|
self.env_vars.append(val)
|
|
135
140
|
|
|
141
|
+
# os.environ.get("KEY") — attribute chain: must be <name>.environ.get(...)
|
|
142
|
+
if func_name == "get" and node.args:
|
|
143
|
+
if (
|
|
144
|
+
isinstance(node.func, ast.Attribute)
|
|
145
|
+
and isinstance(node.func.value, ast.Attribute)
|
|
146
|
+
and node.func.value.attr == "environ"
|
|
147
|
+
):
|
|
148
|
+
val = _get_str(node.args[0])
|
|
149
|
+
if val and val not in self.env_vars:
|
|
150
|
+
self.env_vars.append(val)
|
|
151
|
+
|
|
136
152
|
self.generic_visit(node)
|
|
137
153
|
|
|
138
154
|
def visit_Constant(self, node: ast.Constant) -> None:
|
|
@@ -210,15 +226,29 @@ def fingerprint_file(path: Path) -> AgentFingerprint:
|
|
|
210
226
|
|
|
211
227
|
cloud, deployment = _detect_cloud(v.imports, v.has_lambda_handler)
|
|
212
228
|
|
|
213
|
-
|
|
229
|
+
def _matches(imp: str, prefix: str) -> bool:
|
|
230
|
+
return imp == prefix or imp.startswith(prefix + ".")
|
|
231
|
+
|
|
214
232
|
is_mcp_server = any(
|
|
215
|
-
any(imp
|
|
216
|
-
|
|
233
|
+
any(_matches(imp, p) for imp in v.imports) for p in _MCP_SERVER_IMPORTS
|
|
234
|
+
)
|
|
235
|
+
is_mcp_client = any(
|
|
236
|
+
any(_matches(imp, p) for imp in v.imports) for p in _MCP_CLIENT_IMPORTS
|
|
217
237
|
)
|
|
218
|
-
server_type = "mcp_server" if is_mcp_server else "agent"
|
|
219
238
|
|
|
220
|
-
#
|
|
221
|
-
|
|
239
|
+
# A file with mcp.server.* is a tool provider; mcp.client.* is a tool consumer.
|
|
240
|
+
# If both appear the file is unusual (proxy/relay) — treat as server since it
|
|
241
|
+
# exposes tools. Client-only → mcp_client, neither → agent.
|
|
242
|
+
if is_mcp_server:
|
|
243
|
+
server_type = "mcp_server"
|
|
244
|
+
has_fastmcp = any(_matches(imp, "mcp.server.fastmcp") or _matches(imp, "fastmcp") for imp in v.imports)
|
|
245
|
+
framework = "FastMCP" if has_fastmcp else "MCP Server"
|
|
246
|
+
elif is_mcp_client:
|
|
247
|
+
server_type = "mcp_client"
|
|
248
|
+
framework = _detect_framework(v.imports)
|
|
249
|
+
else:
|
|
250
|
+
server_type = "agent"
|
|
251
|
+
framework = _detect_framework(v.imports)
|
|
222
252
|
|
|
223
253
|
return AgentFingerprint(
|
|
224
254
|
framework=framework,
|
|
@@ -82,7 +82,7 @@ def _template_summary(
|
|
|
82
82
|
) -> str:
|
|
83
83
|
"""Generate a plain English summary without Claude — used as fallback."""
|
|
84
84
|
if fingerprint.server_type == "mcp_server":
|
|
85
|
-
fw = fingerprint.framework if fingerprint.framework
|
|
85
|
+
fw = fingerprint.framework if fingerprint.framework not in ("unknown", "") else "MCP server"
|
|
86
86
|
parts: list[str] = [f"This is a {fw} — a tool provider with no LLM of its own."]
|
|
87
87
|
parts.append("It exposes tools for AI agents to call.")
|
|
88
88
|
if tools:
|
|
@@ -90,7 +90,16 @@ def _template_summary(
|
|
|
90
90
|
parts.append("Run sentinel mcp scan against the live endpoint for a full security audit.")
|
|
91
91
|
return " ".join(parts)
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
if fingerprint.server_type == "mcp_client":
|
|
94
|
+
fw = fingerprint.framework if fingerprint.framework not in ("unknown", "") else "agent"
|
|
95
|
+
model_str = f" using {fingerprint.model}" if fingerprint.model else ""
|
|
96
|
+
parts = [f"This is a {fw} MCP client{model_str}."]
|
|
97
|
+
parts.append("It connects to an external MCP server over the network to access tools.")
|
|
98
|
+
if fingerprint.system_prompt_found:
|
|
99
|
+
parts.append("A system prompt is configured for the LLM.")
|
|
100
|
+
return " ".join(parts)
|
|
101
|
+
|
|
102
|
+
fw = fingerprint.framework if fingerprint.framework not in ("unknown", "") else "AI agent"
|
|
94
103
|
model_str = f" using {fingerprint.model}" if fingerprint.model else ""
|
|
95
104
|
|
|
96
105
|
parts = []
|
|
@@ -55,14 +55,19 @@ def print_inspect_result(report: InspectReport) -> None:
|
|
|
55
55
|
console.print()
|
|
56
56
|
fp = report.fingerprint
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
"[bold yellow]MCP Server[/bold yellow] (tool provider — use sentinel mcp scan for full audit)"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
if fp.server_type == "mcp_server":
|
|
59
|
+
type_label = "[bold yellow]MCP Server[/bold yellow] (tool provider — use sentinel mcp scan for full audit)"
|
|
60
|
+
model_fallback = "[dim]n/a — MCP servers have no LLM[/dim]"
|
|
61
|
+
elif fp.server_type == "mcp_client":
|
|
62
|
+
type_label = "[bold cyan]MCP Client[/bold cyan] (agent that connects to an MCP server for tools)"
|
|
63
|
+
model_fallback = "[dim]not detected[/dim]"
|
|
64
|
+
else:
|
|
65
|
+
type_label = "[bold white]AI Agent[/bold white] (standalone LLM agent)"
|
|
66
|
+
model_fallback = "[dim]not detected[/dim]"
|
|
67
|
+
|
|
63
68
|
_fp_row("Type", type_label)
|
|
64
|
-
_fp_row("Framework", fp.framework if fp.framework
|
|
65
|
-
_fp_row("Model", fp.model or
|
|
69
|
+
_fp_row("Framework", fp.framework if fp.framework not in ("unknown", "") else "[dim]unknown[/dim]")
|
|
70
|
+
_fp_row("Model", fp.model or model_fallback)
|
|
66
71
|
_fp_row("Python", fp.python_version or "[dim]not detected[/dim]")
|
|
67
72
|
_fp_row("Deployment", fp.deployment if fp.deployment != "local" else "[dim]local[/dim]")
|
|
68
73
|
_fp_row("Cloud", fp.cloud if fp.cloud != "unknown" else "[dim]on-prem / unknown[/dim]")
|
|
@@ -108,8 +108,8 @@ def scan_http(
|
|
|
108
108
|
|
|
109
109
|
if resp.status_code in (401, 403):
|
|
110
110
|
raise McpAuthRequired(resp.status_code)
|
|
111
|
-
if resp.status_code
|
|
112
|
-
#
|
|
111
|
+
if resp.status_code in (404, 405):
|
|
112
|
+
# Server may use SSE transport — root path returns 404, /sse returns 405
|
|
113
113
|
return scan_sse(url, extra_headers=extra_headers, timeout=timeout)
|
|
114
114
|
|
|
115
115
|
content_type = resp.headers.get("content-type", "")
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentsentinel-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.3"
|
|
8
8
|
description = "Agentic security CLI — AI analyst with memory, supply chain audit, MCP audit, red-team probing, and agent discovery"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/supply_chain_report.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|