agentsentinel-cli 0.8.3__tar.gz → 0.9.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.
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/PKG-INFO +14 -2
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/README.md +13 -1
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/cli.py +70 -0
- agentsentinel_cli-0.9.0/agentsentinel_cli/host_report.py +275 -0
- agentsentinel_cli-0.9.0/agentsentinel_cli/host_rules.py +488 -0
- agentsentinel_cli-0.9.0/agentsentinel_cli/host_scanner.py +418 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/pyproject.toml +1 -1
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/.gitignore +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/DOCUMENTATION.md +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/LICENSE +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/__init__.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/a2a_report.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/a2a_rules.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/a2a_scanner.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/discover.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/discover_report.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/fingerprint.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/frameworks.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/inspect.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/inspect_report.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/mcp_client.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/mcp_report.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/mcp_rules.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/report.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/rules.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/scanner.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/secrets.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/secrets_report.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/secrets_rules.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/supply_chain_ai.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/supply_chain_report.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/supply_chain_rules.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/suppress.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/note.md +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/test-mcp-agent/README.md +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/test-mcp-agent/langchain_agent.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/test-mcp-agent/mcp_server.py +0 -0
- {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/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.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: AI agent and MCP server security scanner — discovery, static analysis, supply chain audit, and multi-agent trust analysis
|
|
5
5
|
Project-URL: Homepage, https://github.com/jaydenaung/agentsentinel-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/jaydenaung/agentsentinel-cli
|
|
@@ -221,6 +221,12 @@ sentinel scan ./agents/ --format json
|
|
|
221
221
|
sentinel scan ./agents/ --ignore-rule DANGEROUS_GRANTS # suppress accepted finding
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
+
**Detects tools defined via:**
|
|
225
|
+
- `@tool` decorator · `BaseTool` / `StructuredTool` subclasses
|
|
226
|
+
- `StructuredTool.from_function(name=...)` · `Tool(name=...)`
|
|
227
|
+
- `bind_tools([...])` · `create_react_agent(llm, tools)` · `create_agent(llm, tools)`
|
|
228
|
+
- `AgentExecutor(tools=[...])` · direct Anthropic/OpenAI API `messages.create(tools=[...])`
|
|
229
|
+
|
|
224
230
|
**Rules:**
|
|
225
231
|
|
|
226
232
|
| Rule | Severity | Trigger |
|
|
@@ -283,7 +289,7 @@ With `ANTHROPIC_API_KEY` set, generates a plain English security summary.
|
|
|
283
289
|
|
|
284
290
|
Builds a call graph from Python agent source and audits trust boundaries. Detects injection propagation across agent boundaries, unbounded spawning, and code-execution agents accepting unverified delegations.
|
|
285
291
|
|
|
286
|
-
Supports **LangChain / LangGraph**, **AutoGen**, and **
|
|
292
|
+
Supports **LangChain / LangGraph**, **AutoGen**, **CrewAI**, and **MCP client → server connections**.
|
|
287
293
|
|
|
288
294
|
```bash
|
|
289
295
|
sentinel a2a ./agents/
|
|
@@ -292,6 +298,12 @@ sentinel a2a . --fail-on HIGH
|
|
|
292
298
|
sentinel a2a . --format json
|
|
293
299
|
```
|
|
294
300
|
|
|
301
|
+
**Detected patterns:**
|
|
302
|
+
- LangGraph `StateGraph.add_node` / `add_edge` / `add_conditional_edges`
|
|
303
|
+
- AutoGen `initiate_chat`, `GroupChat`, `GroupChatManager`
|
|
304
|
+
- CrewAI `Crew(agents=[...], process=Process.hierarchical)`
|
|
305
|
+
- MCP client connections: `sse_client(url)`, `streamablehttp_client(url)` — surfaces agent → MCP server edges with URL resolution from constants
|
|
306
|
+
|
|
295
307
|
**Rules:**
|
|
296
308
|
|
|
297
309
|
| Rule | Severity | What it catches |
|
|
@@ -187,6 +187,12 @@ sentinel scan ./agents/ --format json
|
|
|
187
187
|
sentinel scan ./agents/ --ignore-rule DANGEROUS_GRANTS # suppress accepted finding
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
+
**Detects tools defined via:**
|
|
191
|
+
- `@tool` decorator · `BaseTool` / `StructuredTool` subclasses
|
|
192
|
+
- `StructuredTool.from_function(name=...)` · `Tool(name=...)`
|
|
193
|
+
- `bind_tools([...])` · `create_react_agent(llm, tools)` · `create_agent(llm, tools)`
|
|
194
|
+
- `AgentExecutor(tools=[...])` · direct Anthropic/OpenAI API `messages.create(tools=[...])`
|
|
195
|
+
|
|
190
196
|
**Rules:**
|
|
191
197
|
|
|
192
198
|
| Rule | Severity | Trigger |
|
|
@@ -249,7 +255,7 @@ With `ANTHROPIC_API_KEY` set, generates a plain English security summary.
|
|
|
249
255
|
|
|
250
256
|
Builds a call graph from Python agent source and audits trust boundaries. Detects injection propagation across agent boundaries, unbounded spawning, and code-execution agents accepting unverified delegations.
|
|
251
257
|
|
|
252
|
-
Supports **LangChain / LangGraph**, **AutoGen**, and **
|
|
258
|
+
Supports **LangChain / LangGraph**, **AutoGen**, **CrewAI**, and **MCP client → server connections**.
|
|
253
259
|
|
|
254
260
|
```bash
|
|
255
261
|
sentinel a2a ./agents/
|
|
@@ -258,6 +264,12 @@ sentinel a2a . --fail-on HIGH
|
|
|
258
264
|
sentinel a2a . --format json
|
|
259
265
|
```
|
|
260
266
|
|
|
267
|
+
**Detected patterns:**
|
|
268
|
+
- LangGraph `StateGraph.add_node` / `add_edge` / `add_conditional_edges`
|
|
269
|
+
- AutoGen `initiate_chat`, `GroupChat`, `GroupChatManager`
|
|
270
|
+
- CrewAI `Crew(agents=[...], process=Process.hierarchical)`
|
|
271
|
+
- MCP client connections: `sse_client(url)`, `streamablehttp_client(url)` — surfaces agent → MCP server edges with URL resolution from constants
|
|
272
|
+
|
|
261
273
|
**Rules:**
|
|
262
274
|
|
|
263
275
|
| Rule | Severity | What it catches |
|
|
@@ -864,6 +864,76 @@ def a2a(
|
|
|
864
864
|
sys.exit(1)
|
|
865
865
|
|
|
866
866
|
|
|
867
|
+
# ── sentinel host ─────────────────────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
@main.command(name="host")
|
|
870
|
+
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text",
|
|
871
|
+
help="Output format.")
|
|
872
|
+
@click.option("--fail-on", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]),
|
|
873
|
+
default=None, help="Exit with code 1 if findings at or above this severity exist.")
|
|
874
|
+
@click.option("--ignore-rule", "ignore_rules", multiple=True, metavar="RULE_ID",
|
|
875
|
+
help="Suppress a finding by rule ID. Repeatable. Also reads .sentinelignore.")
|
|
876
|
+
def host(
|
|
877
|
+
fmt: str,
|
|
878
|
+
fail_on: str | None,
|
|
879
|
+
ignore_rules: tuple[str, ...],
|
|
880
|
+
) -> None:
|
|
881
|
+
"""Audit your local AI security posture.
|
|
882
|
+
|
|
883
|
+
Checks Claude Code and Desktop configurations, MCP server permissions,
|
|
884
|
+
shell credential exposure, macOS privacy permissions (Full Disk Access,
|
|
885
|
+
Screen Recording, Accessibility), system security (SIP, FileVault,
|
|
886
|
+
Gatekeeper), and AI processes exposed on the network.
|
|
887
|
+
|
|
888
|
+
No network calls — all checks are local and read-only.
|
|
889
|
+
|
|
890
|
+
\b
|
|
891
|
+
Examples:
|
|
892
|
+
sentinel host
|
|
893
|
+
sentinel host --format json
|
|
894
|
+
sentinel host --fail-on HIGH
|
|
895
|
+
sentinel host --ignore-rule HOST_LARGE_MEMORY
|
|
896
|
+
"""
|
|
897
|
+
from agentsentinel_cli.host_scanner import scan_host
|
|
898
|
+
from agentsentinel_cli.host_rules import run_host_rules, host_posture_score
|
|
899
|
+
from agentsentinel_cli.host_report import print_host_result, as_host_json
|
|
900
|
+
from agentsentinel_cli import suppress as _suppress
|
|
901
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
902
|
+
|
|
903
|
+
_ctx_holder: list = []
|
|
904
|
+
|
|
905
|
+
with Progress(
|
|
906
|
+
SpinnerColumn(),
|
|
907
|
+
TextColumn("[dim]{task.description}[/dim]"),
|
|
908
|
+
TimeElapsedColumn(),
|
|
909
|
+
console=console,
|
|
910
|
+
transient=True,
|
|
911
|
+
) as progress:
|
|
912
|
+
progress.add_task("Scanning host AI security posture…", total=None)
|
|
913
|
+
_ctx_holder.append(scan_host())
|
|
914
|
+
|
|
915
|
+
ctx = _ctx_holder[0]
|
|
916
|
+
findings = run_host_rules(ctx)
|
|
917
|
+
|
|
918
|
+
sup_rules = _suppress.merge(_suppress.load_ignore_file(Path.cwd()), ignore_rules)
|
|
919
|
+
findings, suppressed = _suppress.apply(findings, sup_rules)
|
|
920
|
+
score = host_posture_score(findings)
|
|
921
|
+
|
|
922
|
+
if fmt == "json":
|
|
923
|
+
click.echo(as_host_json(ctx, findings, score))
|
|
924
|
+
else:
|
|
925
|
+
print_host_result(ctx, findings, score)
|
|
926
|
+
msg = _suppress.notice(suppressed)
|
|
927
|
+
if msg:
|
|
928
|
+
console.print(f" {msg}\n")
|
|
929
|
+
|
|
930
|
+
if fail_on:
|
|
931
|
+
_rank = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
932
|
+
threshold = _rank.get(fail_on, 0)
|
|
933
|
+
if any(_rank.get(f.severity, 0) >= threshold for f in findings):
|
|
934
|
+
sys.exit(1)
|
|
935
|
+
|
|
936
|
+
|
|
867
937
|
def _parse_ports(ports_str: str) -> list[int]:
|
|
868
938
|
"""Parse '8000-9001' or '8000,8080,9000' into a list of ints."""
|
|
869
939
|
ports: list[int] = []
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Rich terminal and JSON output for host AI security posture scans."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from agentsentinel_cli.host_rules import HostFinding, host_posture_score
|
|
12
|
+
from agentsentinel_cli.host_scanner import HostContext
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
_SEVERITY_COLOR = {
|
|
17
|
+
"CRITICAL": "bold red",
|
|
18
|
+
"HIGH": "bold orange1",
|
|
19
|
+
"MEDIUM": "bold yellow",
|
|
20
|
+
"LOW": "bold cyan",
|
|
21
|
+
}
|
|
22
|
+
_STATUS_COLOR = {
|
|
23
|
+
"TRUSTED": "bold green",
|
|
24
|
+
"WATCH": "bold yellow",
|
|
25
|
+
"ALERT": "bold orange1",
|
|
26
|
+
"CRITICAL": "bold red",
|
|
27
|
+
}
|
|
28
|
+
_CATEGORY_LABEL = {
|
|
29
|
+
"config": "Configuration",
|
|
30
|
+
"data_exposure": "Data Exposure",
|
|
31
|
+
"permissions": "Permissions",
|
|
32
|
+
"system": "System Security",
|
|
33
|
+
"network": "Network Exposure",
|
|
34
|
+
}
|
|
35
|
+
_CATEGORY_ORDER = ["config", "data_exposure", "permissions", "system", "network"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _status_label(score: int) -> str:
|
|
39
|
+
if score >= 80:
|
|
40
|
+
return "TRUSTED"
|
|
41
|
+
if score >= 60:
|
|
42
|
+
return "WATCH"
|
|
43
|
+
if score >= 40:
|
|
44
|
+
return "ALERT"
|
|
45
|
+
return "CRITICAL"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bool_tag(val: bool | None, true_label: str, false_label: str) -> str:
|
|
49
|
+
if val is True:
|
|
50
|
+
return f"[green]{true_label}[/green]"
|
|
51
|
+
if val is False:
|
|
52
|
+
return f"[red]{false_label}[/red]"
|
|
53
|
+
return "[dim]unknown[/dim]"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def print_host_result(ctx: HostContext, findings: list[HostFinding], score: int) -> None:
|
|
57
|
+
"""Render the full host posture report to the terminal."""
|
|
58
|
+
status = _status_label(score)
|
|
59
|
+
status_color = _STATUS_COLOR[status]
|
|
60
|
+
|
|
61
|
+
console.print()
|
|
62
|
+
console.print(Panel.fit(
|
|
63
|
+
"[bold white]AgentSentinel — Host AI Security Posture[/bold white]\n"
|
|
64
|
+
"[dim]Local AI tools · macOS privacy permissions · system security[/dim]",
|
|
65
|
+
border_style="bright_blue",
|
|
66
|
+
padding=(0, 2),
|
|
67
|
+
))
|
|
68
|
+
|
|
69
|
+
# ── Discovery summary ─────────────────────────────────────────────────────
|
|
70
|
+
console.print()
|
|
71
|
+
|
|
72
|
+
if ctx.claude_code:
|
|
73
|
+
n_allowed = len(ctx.claude_code.allowed_tools)
|
|
74
|
+
n_hooks = len(ctx.claude_code.hooks)
|
|
75
|
+
n_mcp = len(ctx.claude_code.mcp_servers)
|
|
76
|
+
console.print(
|
|
77
|
+
f" [bold white]Claude Code[/bold white] [dim]{ctx.claude_code.path}[/dim]\n"
|
|
78
|
+
f" [dim] {n_allowed} allowed tool(s) · "
|
|
79
|
+
f"{n_mcp} MCP server(s) · {n_hooks} hook(s)[/dim]"
|
|
80
|
+
)
|
|
81
|
+
if ctx.claude_code.allowed_tools:
|
|
82
|
+
tools_str = ", ".join(ctx.claude_code.allowed_tools[:8])
|
|
83
|
+
console.print(f" [dim] allowedTools: {tools_str}[/dim]")
|
|
84
|
+
else:
|
|
85
|
+
console.print(" [dim]Claude Code settings not found (~/.claude/settings.json)[/dim]")
|
|
86
|
+
|
|
87
|
+
console.print()
|
|
88
|
+
if ctx.claude_desktop:
|
|
89
|
+
n_mcp = len(ctx.claude_desktop.mcp_servers)
|
|
90
|
+
console.print(
|
|
91
|
+
f" [bold white]Claude Desktop[/bold white] [dim]{ctx.claude_desktop.path}[/dim]\n"
|
|
92
|
+
f" [dim] {n_mcp} MCP server(s)[/dim]"
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
console.print(" [dim]Claude Desktop config not found[/dim]")
|
|
96
|
+
|
|
97
|
+
# MCP servers table
|
|
98
|
+
all_servers = []
|
|
99
|
+
if ctx.claude_code:
|
|
100
|
+
all_servers.extend((s, "Claude Code") for s in ctx.claude_code.mcp_servers)
|
|
101
|
+
if ctx.claude_desktop:
|
|
102
|
+
all_servers.extend((s, "Desktop") for s in ctx.claude_desktop.mcp_servers)
|
|
103
|
+
|
|
104
|
+
if all_servers:
|
|
105
|
+
console.print()
|
|
106
|
+
tbl = Table(box=box.SIMPLE, show_header=True, header_style="dim", padding=(0, 1))
|
|
107
|
+
tbl.add_column("MCP Server", style="bold white", min_width=18)
|
|
108
|
+
tbl.add_column("Source", style="dim", width=12)
|
|
109
|
+
tbl.add_column("Network", width=8)
|
|
110
|
+
tbl.add_column("FS Paths", style="dim", max_width=44)
|
|
111
|
+
|
|
112
|
+
for srv, src in all_servers:
|
|
113
|
+
net = Text("yes", style="yellow") if srv.has_network_access else Text("no", style="dim green")
|
|
114
|
+
paths = ", ".join(srv.filesystem_paths[:2]) if srv.filesystem_paths else "—"
|
|
115
|
+
tbl.add_row(srv.name, src, net, paths)
|
|
116
|
+
console.print(tbl)
|
|
117
|
+
|
|
118
|
+
# System security status
|
|
119
|
+
console.print(
|
|
120
|
+
f" [bold white]macOS Security[/bold white]\n"
|
|
121
|
+
f" [dim] SIP: [/dim]{_bool_tag(ctx.sip_enabled, '✓ Enabled', '✗ Disabled')}\n"
|
|
122
|
+
f" [dim] FileVault: [/dim]{_bool_tag(ctx.filevault_enabled, '✓ On', '✗ Off')}\n"
|
|
123
|
+
f" [dim] Gatekeeper: [/dim]{_bool_tag(ctx.gatekeeper_enabled,'✓ Enabled', '✗ Disabled')}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# TCC permissions (granted only)
|
|
127
|
+
granted_tcc = [p for p in ctx.tcc_permissions if p.granted]
|
|
128
|
+
if granted_tcc:
|
|
129
|
+
console.print()
|
|
130
|
+
console.print(" [bold white]App Privacy Permissions (TCC)[/bold white]")
|
|
131
|
+
for p in granted_tcc:
|
|
132
|
+
service_label = p.service.replace("_", " ").title()
|
|
133
|
+
console.print(f" [dim] {p.app_name:28} {service_label}[/dim]")
|
|
134
|
+
|
|
135
|
+
# Memory files
|
|
136
|
+
if ctx.memory_file_count > 0:
|
|
137
|
+
mb = ctx.memory_total_bytes / (1024 * 1024)
|
|
138
|
+
console.print(
|
|
139
|
+
f"\n [dim]Memory (~/.claude/projects/): "
|
|
140
|
+
f"{ctx.memory_file_count} files, {mb:.1f} MB[/dim]"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Shell key findings
|
|
144
|
+
if ctx.shell_key_findings:
|
|
145
|
+
console.print()
|
|
146
|
+
console.print(" [bold yellow]⚠ AI API keys detected in shell config files[/bold yellow]")
|
|
147
|
+
for key_type, file_path, redacted in ctx.shell_key_findings[:4]:
|
|
148
|
+
console.print(f" [dim] {key_type} in {file_path}: {redacted}[/dim]")
|
|
149
|
+
if len(ctx.shell_key_findings) > 4:
|
|
150
|
+
console.print(f" [dim] … and {len(ctx.shell_key_findings) - 4} more[/dim]")
|
|
151
|
+
|
|
152
|
+
# Scan errors / info notes
|
|
153
|
+
if ctx.scan_errors:
|
|
154
|
+
console.print()
|
|
155
|
+
for err in ctx.scan_errors:
|
|
156
|
+
console.print(f" [dim yellow]ℹ {err}[/dim yellow]")
|
|
157
|
+
|
|
158
|
+
# ── Findings ──────────────────────────────────────────────────────────────
|
|
159
|
+
console.print()
|
|
160
|
+
if findings:
|
|
161
|
+
cats: dict[str, list[HostFinding]] = {}
|
|
162
|
+
for f in findings:
|
|
163
|
+
cats.setdefault(f.category, []).append(f)
|
|
164
|
+
|
|
165
|
+
for cat in _CATEGORY_ORDER:
|
|
166
|
+
if cat not in cats:
|
|
167
|
+
continue
|
|
168
|
+
label = _CATEGORY_LABEL.get(cat, cat.title())
|
|
169
|
+
console.rule(f"[dim]{label}[/dim]", style="dim")
|
|
170
|
+
for f in cats[cat]:
|
|
171
|
+
color = _SEVERITY_COLOR.get(f.severity, "white")
|
|
172
|
+
console.print(
|
|
173
|
+
f"\n [{color}]● {f.severity:<8}[/{color}] [bold white]{f.rule_id}[/bold white]"
|
|
174
|
+
)
|
|
175
|
+
console.print(f" [dim] {f.message}[/dim]")
|
|
176
|
+
if f.detail:
|
|
177
|
+
for line in f.detail.split("\n"):
|
|
178
|
+
console.print(f" [dim] {line.strip()}[/dim]")
|
|
179
|
+
if f.remediation:
|
|
180
|
+
console.print(f" [dim cyan] → {f.remediation}[/dim cyan]")
|
|
181
|
+
console.print()
|
|
182
|
+
else:
|
|
183
|
+
console.print(" [green]✓ No security findings[/green]\n")
|
|
184
|
+
|
|
185
|
+
# ── Footer ────────────────────────────────────────────────────────────────
|
|
186
|
+
bar_filled = int(score / 5)
|
|
187
|
+
bar = "█" * bar_filled + "░" * (20 - bar_filled)
|
|
188
|
+
console.print(
|
|
189
|
+
f" Posture Score [{status_color}]{score:>3}/100[/{status_color}] "
|
|
190
|
+
f"[dim]{bar}[/dim] [{status_color}]{status}[/{status_color}]"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
n_critical = sum(1 for f in findings if f.severity == "CRITICAL")
|
|
194
|
+
n_high = sum(1 for f in findings if f.severity == "HIGH")
|
|
195
|
+
total = len(findings)
|
|
196
|
+
|
|
197
|
+
console.print()
|
|
198
|
+
console.rule(style="bright_blue")
|
|
199
|
+
parts = [f"[bold white]{total}[/bold white] finding{'s' if total != 1 else ''}"]
|
|
200
|
+
if n_critical:
|
|
201
|
+
parts.append(f"[bold red]{n_critical} CRITICAL[/bold red]")
|
|
202
|
+
if n_high:
|
|
203
|
+
parts.append(f"[bold orange1]{n_high} HIGH[/bold orange1]")
|
|
204
|
+
console.print(" " + " · ".join(parts))
|
|
205
|
+
console.print()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def as_host_json(ctx: HostContext, findings: list[HostFinding], score: int) -> str:
|
|
209
|
+
"""Serialize host posture results as JSON."""
|
|
210
|
+
all_mcp: list[dict] = []
|
|
211
|
+
if ctx.claude_code:
|
|
212
|
+
for s in ctx.claude_code.mcp_servers:
|
|
213
|
+
all_mcp.append({
|
|
214
|
+
"name": s.name, "source": "claude_code",
|
|
215
|
+
"has_network_access": s.has_network_access,
|
|
216
|
+
"filesystem_paths": s.filesystem_paths,
|
|
217
|
+
"env_keys": s.env_keys,
|
|
218
|
+
})
|
|
219
|
+
if ctx.claude_desktop:
|
|
220
|
+
for s in ctx.claude_desktop.mcp_servers:
|
|
221
|
+
all_mcp.append({
|
|
222
|
+
"name": s.name, "source": "claude_desktop",
|
|
223
|
+
"has_network_access": s.has_network_access,
|
|
224
|
+
"filesystem_paths": s.filesystem_paths,
|
|
225
|
+
"env_keys": s.env_keys,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return json.dumps({
|
|
229
|
+
"scan_type": "host",
|
|
230
|
+
"claude_code": {
|
|
231
|
+
"found": ctx.claude_code is not None,
|
|
232
|
+
"allowed_tools": ctx.claude_code.allowed_tools if ctx.claude_code else [],
|
|
233
|
+
"disallowed_tools": ctx.claude_code.disallowed_tools if ctx.claude_code else [],
|
|
234
|
+
"hook_count": len(ctx.claude_code.hooks) if ctx.claude_code else 0,
|
|
235
|
+
},
|
|
236
|
+
"claude_desktop": {
|
|
237
|
+
"found": ctx.claude_desktop is not None,
|
|
238
|
+
},
|
|
239
|
+
"mcp_servers": all_mcp,
|
|
240
|
+
"memory": {
|
|
241
|
+
"file_count": ctx.memory_file_count,
|
|
242
|
+
"total_bytes": ctx.memory_total_bytes,
|
|
243
|
+
},
|
|
244
|
+
"shell_key_findings": [
|
|
245
|
+
{"key_type": k, "file": f, "redacted": s}
|
|
246
|
+
for k, f, s in ctx.shell_key_findings
|
|
247
|
+
],
|
|
248
|
+
"tcc_permissions": [
|
|
249
|
+
{"app": p.app_name, "bundle_id": p.bundle_id, "service": p.service, "granted": p.granted}
|
|
250
|
+
for p in ctx.tcc_permissions if p.granted
|
|
251
|
+
],
|
|
252
|
+
"system_security": {
|
|
253
|
+
"sip_enabled": ctx.sip_enabled,
|
|
254
|
+
"filevault_enabled": ctx.filevault_enabled,
|
|
255
|
+
"gatekeeper_enabled": ctx.gatekeeper_enabled,
|
|
256
|
+
},
|
|
257
|
+
"exposed_processes": [
|
|
258
|
+
{"pid": p.pid, "name": p.name, "address": p.address, "port": p.port}
|
|
259
|
+
for p in ctx.exposed_processes
|
|
260
|
+
],
|
|
261
|
+
"findings": [
|
|
262
|
+
{
|
|
263
|
+
"severity": f.severity,
|
|
264
|
+
"rule_id": f.rule_id,
|
|
265
|
+
"category": f.category,
|
|
266
|
+
"message": f.message,
|
|
267
|
+
"detail": f.detail,
|
|
268
|
+
"remediation": f.remediation,
|
|
269
|
+
}
|
|
270
|
+
for f in findings
|
|
271
|
+
],
|
|
272
|
+
"posture_score": score,
|
|
273
|
+
"status": _status_label(score),
|
|
274
|
+
"scan_errors": ctx.scan_errors,
|
|
275
|
+
}, indent=2)
|