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,186 @@
|
|
|
1
|
+
"""Rich terminal and JSON output for MCP server security 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.mcp_rules import McpContext, McpFinding, mcp_posture_score
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
_SEVERITY_COLOR = {
|
|
16
|
+
"CRITICAL": "bold red",
|
|
17
|
+
"HIGH": "bold orange1",
|
|
18
|
+
"MEDIUM": "bold yellow",
|
|
19
|
+
"LOW": "bold cyan",
|
|
20
|
+
}
|
|
21
|
+
_STATUS_COLOR = {
|
|
22
|
+
"TRUSTED": "bold green",
|
|
23
|
+
"WATCH": "bold yellow",
|
|
24
|
+
"ALERT": "bold orange1",
|
|
25
|
+
"CRITICAL": "bold red",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _status_label(score: int) -> str:
|
|
30
|
+
if score >= 80:
|
|
31
|
+
return "TRUSTED"
|
|
32
|
+
if score >= 60:
|
|
33
|
+
return "WATCH"
|
|
34
|
+
if score >= 40:
|
|
35
|
+
return "ALERT"
|
|
36
|
+
return "CRITICAL"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def print_mcp_result(
|
|
40
|
+
ctx: McpContext,
|
|
41
|
+
findings: list[McpFinding],
|
|
42
|
+
score: int,
|
|
43
|
+
target: str,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Render the full MCP scan report to the terminal."""
|
|
46
|
+
server = ctx.server
|
|
47
|
+
status = _status_label(score)
|
|
48
|
+
status_color = _STATUS_COLOR[status]
|
|
49
|
+
|
|
50
|
+
console.print()
|
|
51
|
+
console.print(Panel.fit(
|
|
52
|
+
f"[bold white]AgentSentinel MCP Security Scan[/bold white]\n"
|
|
53
|
+
f"[dim]Target: {target}[/dim]",
|
|
54
|
+
border_style="bright_blue",
|
|
55
|
+
padding=(0, 2),
|
|
56
|
+
))
|
|
57
|
+
|
|
58
|
+
auth_tag = (
|
|
59
|
+
"[bold red]✗ No auth required[/bold red]"
|
|
60
|
+
if not ctx.auth_required
|
|
61
|
+
else "[dim green]✓ Authenticated[/dim green]"
|
|
62
|
+
)
|
|
63
|
+
transport_tag = f"[dim]{server.transport.upper()}[/dim]"
|
|
64
|
+
console.print(
|
|
65
|
+
f"\n Server [bold white]{server.name}[/bold white] [dim]v{server.version}[/dim]\n"
|
|
66
|
+
f" Transport {transport_tag} Auth {auth_tag}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not server.tools:
|
|
70
|
+
console.print("\n [yellow]No tools found on this server.[/yellow]")
|
|
71
|
+
_print_footer(findings, score, status_color, server.tools, ctx)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# Tools table
|
|
75
|
+
console.print()
|
|
76
|
+
table = Table(box=box.SIMPLE, show_header=True, header_style="dim", padding=(0, 1))
|
|
77
|
+
table.add_column("Tool", style="bold white", min_width=22)
|
|
78
|
+
table.add_column("Category", style="dim", width=16)
|
|
79
|
+
table.add_column("Scope", width=6)
|
|
80
|
+
table.add_column("", width=13)
|
|
81
|
+
table.add_column("Description", style="dim", max_width=52)
|
|
82
|
+
|
|
83
|
+
for tool in sorted(server.tools, key=lambda t: t.name):
|
|
84
|
+
scope_text = (
|
|
85
|
+
Text("write", style="yellow") if tool.scope == "write"
|
|
86
|
+
else Text("read", style="green")
|
|
87
|
+
)
|
|
88
|
+
danger_tag = Text("⚠ dangerous", style="bold red") if tool.is_dangerous else Text("")
|
|
89
|
+
desc = (tool.description[:50] + "…") if len(tool.description) > 52 else tool.description
|
|
90
|
+
table.add_row(tool.name, tool.category, scope_text, danger_tag, desc)
|
|
91
|
+
|
|
92
|
+
console.print(table)
|
|
93
|
+
|
|
94
|
+
# Findings
|
|
95
|
+
if findings:
|
|
96
|
+
for f in findings:
|
|
97
|
+
color = _SEVERITY_COLOR.get(f.severity, "white")
|
|
98
|
+
console.print(
|
|
99
|
+
f" [{color}]● {f.severity:<8}[/{color}] [bold white]{f.rule_id}[/bold white]"
|
|
100
|
+
)
|
|
101
|
+
console.print(f" [dim] {f.message}[/dim]")
|
|
102
|
+
if f.detail:
|
|
103
|
+
console.print(f" [dim] {f.detail}[/dim]")
|
|
104
|
+
console.print()
|
|
105
|
+
else:
|
|
106
|
+
console.print(" [green]✓ No security findings[/green]\n")
|
|
107
|
+
|
|
108
|
+
_print_footer(findings, score, status_color, server.tools, ctx)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _print_footer(
|
|
112
|
+
findings: list[McpFinding],
|
|
113
|
+
score: int,
|
|
114
|
+
status_color: str,
|
|
115
|
+
tools: list,
|
|
116
|
+
ctx: McpContext,
|
|
117
|
+
) -> None:
|
|
118
|
+
status = _status_label(score)
|
|
119
|
+
bar_filled = int(score / 5)
|
|
120
|
+
bar = "█" * bar_filled + "░" * (20 - bar_filled)
|
|
121
|
+
console.print(
|
|
122
|
+
f" Posture Score [{status_color}]{score:>3}/100[/{status_color}] "
|
|
123
|
+
f"[dim]{bar}[/dim] [{status_color}]{status}[/{status_color}]"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
n_critical = sum(1 for f in findings if f.severity == "CRITICAL")
|
|
127
|
+
n_high = sum(1 for f in findings if f.severity == "HIGH")
|
|
128
|
+
total = len(findings)
|
|
129
|
+
|
|
130
|
+
console.print()
|
|
131
|
+
console.rule(style="bright_blue")
|
|
132
|
+
parts = [f"[bold white]{len(tools)}[/bold white] tools enumerated"]
|
|
133
|
+
parts.append(f"[bold white]{total}[/bold white] finding{'s' if total != 1 else ''}")
|
|
134
|
+
if n_critical:
|
|
135
|
+
parts.append(f"[bold red]{n_critical} CRITICAL[/bold red]")
|
|
136
|
+
if n_high:
|
|
137
|
+
parts.append(f"[bold orange1]{n_high} HIGH[/bold orange1]")
|
|
138
|
+
console.print(" " + " · ".join(parts))
|
|
139
|
+
|
|
140
|
+
if not ctx.auth_required and any(f.rule_id == "NO_AUTH" for f in findings):
|
|
141
|
+
console.print(
|
|
142
|
+
"\n [bold red]⚠ This server requires no authentication.[/bold red] "
|
|
143
|
+
"[dim]Any process with network access can enumerate and invoke all tools.[/dim]"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
console.print()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def as_mcp_json(
|
|
150
|
+
ctx: McpContext,
|
|
151
|
+
findings: list[McpFinding],
|
|
152
|
+
score: int,
|
|
153
|
+
target: str,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Serialize MCP scan results as JSON."""
|
|
156
|
+
server = ctx.server
|
|
157
|
+
return json.dumps({
|
|
158
|
+
"target": target,
|
|
159
|
+
"transport": server.transport,
|
|
160
|
+
"server_name": server.name,
|
|
161
|
+
"server_version": server.version,
|
|
162
|
+
"auth_required": ctx.auth_required,
|
|
163
|
+
"tool_count": len(server.tools),
|
|
164
|
+
"tools": [
|
|
165
|
+
{
|
|
166
|
+
"name": t.name,
|
|
167
|
+
"description": t.description,
|
|
168
|
+
"scope": t.scope,
|
|
169
|
+
"is_dangerous": t.is_dangerous,
|
|
170
|
+
"category": t.category,
|
|
171
|
+
"has_input_schema": bool(t.input_schema.get("properties")),
|
|
172
|
+
}
|
|
173
|
+
for t in server.tools
|
|
174
|
+
],
|
|
175
|
+
"findings": [
|
|
176
|
+
{
|
|
177
|
+
"severity": f.severity,
|
|
178
|
+
"rule_id": f.rule_id,
|
|
179
|
+
"message": f.message,
|
|
180
|
+
"detail": f.detail,
|
|
181
|
+
}
|
|
182
|
+
for f in findings
|
|
183
|
+
],
|
|
184
|
+
"posture_score": score,
|
|
185
|
+
"status": _status_label(score),
|
|
186
|
+
}, indent=2)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Security rules for MCP server audits.
|
|
2
|
+
|
|
3
|
+
Each rule maps to one or more OWASP LLM Top 10 categories (noted in docstrings).
|
|
4
|
+
Rules operate on McpContext so they have access to both server info and scan metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
from agentsentinel_cli.mcp_client import McpServerInfo, McpToolInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass
|
|
12
|
+
class McpContext:
|
|
13
|
+
"""Full context for a single MCP scan — passed to every rule."""
|
|
14
|
+
|
|
15
|
+
server: McpServerInfo
|
|
16
|
+
auth_required: bool # True = credentials were required/provided; False = open server
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclasses.dataclass
|
|
20
|
+
class McpFinding:
|
|
21
|
+
"""A security finding from an MCP server audit."""
|
|
22
|
+
|
|
23
|
+
severity: str # CRITICAL | HIGH | MEDIUM | LOW
|
|
24
|
+
rule_id: str
|
|
25
|
+
message: str
|
|
26
|
+
detail: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Keyword sets for exfiltration detection (aligned with posture rules in rules.py)
|
|
30
|
+
_INTERNAL_READ_KW = frozenset({
|
|
31
|
+
"db", "database", "crm", "file", "filesystem",
|
|
32
|
+
"s3_read", "storage_read", "read_file",
|
|
33
|
+
})
|
|
34
|
+
_EXTERNAL_WRITE_KW = frozenset({
|
|
35
|
+
"email", "smtp", "webhook", "http_post", "http_external",
|
|
36
|
+
"s3_write", "send", "slack",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Rules ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def _rule_no_auth(ctx: McpContext) -> McpFinding | None:
|
|
43
|
+
"""CRITICAL: HTTP server requires no credentials to enumerate tools. (OWASP LLM06)
|
|
44
|
+
|
|
45
|
+
Not applicable to stdio transport — stdio processes are isolated by the OS.
|
|
46
|
+
"""
|
|
47
|
+
if ctx.server.transport == "stdio":
|
|
48
|
+
return None
|
|
49
|
+
if not ctx.auth_required and ctx.server.tools:
|
|
50
|
+
return McpFinding(
|
|
51
|
+
severity="CRITICAL",
|
|
52
|
+
rule_id="NO_AUTH",
|
|
53
|
+
message=(
|
|
54
|
+
"MCP server accepted initialize and tools/list with no credentials. "
|
|
55
|
+
"Any process with network access can enumerate and invoke all tools."
|
|
56
|
+
),
|
|
57
|
+
detail=f"{len(ctx.server.tools)} tool(s) exposed without authentication.",
|
|
58
|
+
)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _rule_unauth_dangerous(ctx: McpContext) -> McpFinding | None:
|
|
63
|
+
"""CRITICAL: dangerous tools callable without auth on HTTP server. (OWASP LLM06)"""
|
|
64
|
+
if ctx.server.transport == "stdio":
|
|
65
|
+
return None
|
|
66
|
+
if ctx.auth_required:
|
|
67
|
+
return None
|
|
68
|
+
dangerous = [t.name for t in ctx.server.tools if t.is_dangerous]
|
|
69
|
+
if dangerous:
|
|
70
|
+
return McpFinding(
|
|
71
|
+
severity="CRITICAL",
|
|
72
|
+
rule_id="UNAUTH_DANGEROUS_EXEC",
|
|
73
|
+
message=(
|
|
74
|
+
"Dangerous tools are accessible without authentication. "
|
|
75
|
+
"An attacker with local network access can invoke these directly."
|
|
76
|
+
),
|
|
77
|
+
detail=f"Unauthenticated dangerous tools: {', '.join(dangerous)}",
|
|
78
|
+
)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rule_exfiltration_path(ctx: McpContext) -> McpFinding | None:
|
|
83
|
+
"""CRITICAL: internal-read + external-write tools present. (OWASP LLM02, LLM06)"""
|
|
84
|
+
names = {t.name.lower() for t in ctx.server.tools}
|
|
85
|
+
internal = [n for n in names if any(kw in n for kw in _INTERNAL_READ_KW)]
|
|
86
|
+
external = [n for n in names if any(kw in n for kw in _EXTERNAL_WRITE_KW)]
|
|
87
|
+
if internal and external:
|
|
88
|
+
return McpFinding(
|
|
89
|
+
severity="CRITICAL",
|
|
90
|
+
rule_id="EXFILTRATION_PATH",
|
|
91
|
+
message=(
|
|
92
|
+
"Server exposes both internal-read and external-write tools. "
|
|
93
|
+
"Prompt injection can chain these into a data exfiltration path."
|
|
94
|
+
),
|
|
95
|
+
detail=f"Internal-read: {', '.join(internal)} | External-write: {', '.join(external)}",
|
|
96
|
+
)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _rule_code_execution(ctx: McpContext) -> McpFinding | None:
|
|
101
|
+
"""CRITICAL: server exposes code execution tools. (OWASP LLM01, LLM06)"""
|
|
102
|
+
exec_tools = [t.name for t in ctx.server.tools if t.category == "code_execution"]
|
|
103
|
+
if exec_tools:
|
|
104
|
+
return McpFinding(
|
|
105
|
+
severity="CRITICAL",
|
|
106
|
+
rule_id="CODE_EXECUTION_TOOL",
|
|
107
|
+
message=(
|
|
108
|
+
"Server exposes code-execution tools. "
|
|
109
|
+
"Prompt injection into any connected agent grants full host execution."
|
|
110
|
+
),
|
|
111
|
+
detail=f"Execution tools: {', '.join(exec_tools)}",
|
|
112
|
+
)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _rule_unbounded_input(ctx: McpContext) -> McpFinding | None:
|
|
117
|
+
"""HIGH: unconstrained string inputs increase injection payload surface. (OWASP LLM01)"""
|
|
118
|
+
unvalidated: list[str] = []
|
|
119
|
+
for tool in ctx.server.tools:
|
|
120
|
+
schema = tool.input_schema
|
|
121
|
+
props = schema.get("properties", {})
|
|
122
|
+
if not props and schema.get("type") == "object":
|
|
123
|
+
unvalidated.append(f"{tool.name} (no schema)")
|
|
124
|
+
continue
|
|
125
|
+
for prop_name, prop_def in props.items():
|
|
126
|
+
if (
|
|
127
|
+
prop_def.get("type") == "string"
|
|
128
|
+
and "maxLength" not in prop_def
|
|
129
|
+
and "enum" not in prop_def
|
|
130
|
+
and "pattern" not in prop_def
|
|
131
|
+
):
|
|
132
|
+
unvalidated.append(f"{tool.name}.{prop_name}")
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
if unvalidated:
|
|
136
|
+
sample = unvalidated[:5]
|
|
137
|
+
suffix = "…" if len(unvalidated) > 5 else ""
|
|
138
|
+
return McpFinding(
|
|
139
|
+
severity="HIGH",
|
|
140
|
+
rule_id="UNBOUNDED_INPUT",
|
|
141
|
+
message=(
|
|
142
|
+
"Tools accept unconstrained string inputs with no maxLength, enum, or pattern. "
|
|
143
|
+
"Injection payloads can be passed directly through tool arguments."
|
|
144
|
+
),
|
|
145
|
+
detail=f"Unconstrained inputs: {', '.join(sample)}{suffix}",
|
|
146
|
+
)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _rule_tool_sprawl(ctx: McpContext) -> McpFinding | None:
|
|
151
|
+
"""MEDIUM: excessive tool count increases blast radius. (OWASP LLM06)"""
|
|
152
|
+
categories = {t.category for t in ctx.server.tools} - {"other"}
|
|
153
|
+
if len(ctx.server.tools) > 10 or len(categories) >= 5:
|
|
154
|
+
return McpFinding(
|
|
155
|
+
severity="MEDIUM",
|
|
156
|
+
rule_id="TOOL_SPRAWL",
|
|
157
|
+
message=(
|
|
158
|
+
f"Server exposes {len(ctx.server.tools)} tools across {len(categories)} categories. "
|
|
159
|
+
"Every tool is an attack surface — reduce to the minimum required."
|
|
160
|
+
),
|
|
161
|
+
detail=f"Categories: {', '.join(sorted(categories))}",
|
|
162
|
+
)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _rule_vague_descriptions(ctx: McpContext) -> McpFinding | None:
|
|
167
|
+
"""MEDIUM: thin tool descriptions expand prompt injection surface. (OWASP LLM01)"""
|
|
168
|
+
vague = [t.name for t in ctx.server.tools if len(t.description.strip()) < 20]
|
|
169
|
+
if len(vague) >= 2:
|
|
170
|
+
return McpFinding(
|
|
171
|
+
severity="MEDIUM",
|
|
172
|
+
rule_id="VAGUE_TOOL_DESCRIPTIONS",
|
|
173
|
+
message=(
|
|
174
|
+
"Multiple tools have short or missing descriptions. "
|
|
175
|
+
"Vague descriptions make it easier for prompt injection to misdirect tool use."
|
|
176
|
+
),
|
|
177
|
+
detail=f"Thin descriptions on: {', '.join(vague[:5])}{'…' if len(vague) > 5 else ''}",
|
|
178
|
+
)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _rule_missing_rate_limit(ctx: McpContext) -> McpFinding | None:
|
|
183
|
+
"""LOW: MCP protocol has no built-in rate limiting. (OWASP LLM06)"""
|
|
184
|
+
dangerous = [t.name for t in ctx.server.tools if t.is_dangerous]
|
|
185
|
+
if dangerous:
|
|
186
|
+
return McpFinding(
|
|
187
|
+
severity="LOW",
|
|
188
|
+
rule_id="MISSING_RATE_LIMIT",
|
|
189
|
+
message=(
|
|
190
|
+
"Dangerous tools detected. MCP has no built-in rate limiting — "
|
|
191
|
+
"ensure the server layer enforces per-client call limits."
|
|
192
|
+
),
|
|
193
|
+
detail=f"Verify limits on: {', '.join(dangerous)}",
|
|
194
|
+
)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_ALL_RULES = [
|
|
199
|
+
# CRITICAL
|
|
200
|
+
_rule_no_auth,
|
|
201
|
+
_rule_unauth_dangerous,
|
|
202
|
+
_rule_exfiltration_path,
|
|
203
|
+
_rule_code_execution,
|
|
204
|
+
# HIGH
|
|
205
|
+
_rule_unbounded_input,
|
|
206
|
+
# MEDIUM
|
|
207
|
+
_rule_tool_sprawl,
|
|
208
|
+
_rule_vague_descriptions,
|
|
209
|
+
# LOW
|
|
210
|
+
_rule_missing_rate_limit,
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
_SEVERITY_WEIGHT = {"CRITICAL": 40, "HIGH": 20, "MEDIUM": 10, "LOW": 5}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def run_mcp_rules(ctx: McpContext) -> list[McpFinding]:
|
|
217
|
+
"""Run all MCP security rules and return deduplicated findings."""
|
|
218
|
+
findings: list[McpFinding] = []
|
|
219
|
+
seen: set[str] = set()
|
|
220
|
+
for rule_fn in _ALL_RULES:
|
|
221
|
+
finding = rule_fn(ctx)
|
|
222
|
+
if finding and finding.rule_id not in seen:
|
|
223
|
+
findings.append(finding)
|
|
224
|
+
seen.add(finding.rule_id)
|
|
225
|
+
return findings
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def mcp_posture_score(findings: list[McpFinding]) -> int:
|
|
229
|
+
"""0–100 posture score — same deduction weights as the platform Trust Score."""
|
|
230
|
+
deductions = sum(_SEVERITY_WEIGHT.get(f.severity, 0) for f in findings)
|
|
231
|
+
return max(0, 100 - deductions)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Terminal output and JSON report formatting."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from agentsentinel_cli.scanner import AgentInfo
|
|
13
|
+
from agentsentinel_cli.rules import Finding
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
_SEVERITY_COLOR = {
|
|
18
|
+
"CRITICAL": "bold red",
|
|
19
|
+
"HIGH": "bold orange1",
|
|
20
|
+
"MEDIUM": "bold yellow",
|
|
21
|
+
"LOW": "bold cyan",
|
|
22
|
+
}
|
|
23
|
+
_STATUS_COLOR = {
|
|
24
|
+
"TRUSTED": "bold green",
|
|
25
|
+
"WATCH": "bold yellow",
|
|
26
|
+
"ALERT": "bold orange1",
|
|
27
|
+
"CRITICAL": "bold red",
|
|
28
|
+
}
|
|
29
|
+
_SCOPE_STYLE = {
|
|
30
|
+
"read": ("green", "read "),
|
|
31
|
+
"write": ("yellow", "write"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _status_from_score(score: int) -> str:
|
|
36
|
+
if score >= 80:
|
|
37
|
+
return "TRUSTED"
|
|
38
|
+
if score >= 60:
|
|
39
|
+
return "WATCH"
|
|
40
|
+
if score >= 40:
|
|
41
|
+
return "ALERT"
|
|
42
|
+
return "CRITICAL"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _severity_icon(severity: str) -> str:
|
|
46
|
+
return {"CRITICAL": "●", "HIGH": "●", "MEDIUM": "●", "LOW": "●"}.get(severity, "●")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def print_scan_result(
|
|
50
|
+
agents: list[AgentInfo],
|
|
51
|
+
findings_map: dict[Path, list[Finding]],
|
|
52
|
+
scores_map: dict[Path, int],
|
|
53
|
+
target: Path,
|
|
54
|
+
connect_url: str | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
total_findings = sum(len(f) for f in findings_map.values())
|
|
57
|
+
total_critical = sum(1 for fl in findings_map.values() for f in fl if f.severity == "CRITICAL")
|
|
58
|
+
total_high = sum(1 for fl in findings_map.values() for f in fl if f.severity == "HIGH")
|
|
59
|
+
|
|
60
|
+
console.print()
|
|
61
|
+
console.print(Panel.fit(
|
|
62
|
+
f"[bold white]AgentSentinel Security Scan[/bold white]\n"
|
|
63
|
+
f"[dim]Target: {target}[/dim]",
|
|
64
|
+
border_style="bright_blue",
|
|
65
|
+
padding=(0, 2),
|
|
66
|
+
))
|
|
67
|
+
|
|
68
|
+
if not agents:
|
|
69
|
+
console.print("\n[yellow]No agent tool definitions found in target.[/yellow]")
|
|
70
|
+
console.print("[dim]Tip: AgentSentinel detects @tool decorators, BaseTool subclasses, and Tool() calls.[/dim]")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
for agent in agents:
|
|
74
|
+
findings = findings_map.get(agent.file, [])
|
|
75
|
+
score = scores_map.get(agent.file, 100)
|
|
76
|
+
status = _status_from_score(score)
|
|
77
|
+
|
|
78
|
+
console.print(f"\n[bold white]File:[/bold white] [dim]{agent.file}[/dim]")
|
|
79
|
+
|
|
80
|
+
# Hardcoded credentials warning (show before tools table)
|
|
81
|
+
if agent.hardcoded_creds:
|
|
82
|
+
console.print()
|
|
83
|
+
for cred in agent.hardcoded_creds:
|
|
84
|
+
console.print(f" [bold red]⛔ HARDCODED CREDENTIAL[/bold red] [dim]{cred}[/dim]")
|
|
85
|
+
console.print()
|
|
86
|
+
|
|
87
|
+
# Tools table
|
|
88
|
+
tools_table = Table(box=box.SIMPLE, show_header=True, header_style="dim", padding=(0, 1))
|
|
89
|
+
tools_table.add_column("Scope", style="dim", width=6)
|
|
90
|
+
tools_table.add_column("Tool", style="bold white")
|
|
91
|
+
tools_table.add_column("Category", style="dim", width=14)
|
|
92
|
+
tools_table.add_column("", width=12)
|
|
93
|
+
|
|
94
|
+
for tool in sorted(agent.tools, key=lambda t: (t.scope, t.name)):
|
|
95
|
+
scope_color, scope_label = _SCOPE_STYLE[tool.scope]
|
|
96
|
+
danger_tag = Text("⚠ dangerous", style="bold red") if tool.is_dangerous else Text("")
|
|
97
|
+
tools_table.add_row(
|
|
98
|
+
Text(scope_label, style=scope_color),
|
|
99
|
+
tool.name,
|
|
100
|
+
tool.category,
|
|
101
|
+
danger_tag,
|
|
102
|
+
)
|
|
103
|
+
console.print(tools_table)
|
|
104
|
+
|
|
105
|
+
if agent.model:
|
|
106
|
+
console.print(f" [dim]Model:[/dim] {agent.model}")
|
|
107
|
+
if agent.description:
|
|
108
|
+
console.print(f" [dim]Description:[/dim] {agent.description[:80]}")
|
|
109
|
+
|
|
110
|
+
# Findings
|
|
111
|
+
if findings:
|
|
112
|
+
console.print()
|
|
113
|
+
for f in findings:
|
|
114
|
+
color = _SEVERITY_COLOR.get(f.severity, "white")
|
|
115
|
+
console.print(f" [{color}]{_severity_icon(f.severity)} {f.severity:<8}[/{color}] "
|
|
116
|
+
f"[bold white]{f.rule_id}[/bold white]")
|
|
117
|
+
console.print(f" [dim] {f.message}[/dim]")
|
|
118
|
+
if f.detail:
|
|
119
|
+
console.print(f" [dim] {f.detail}[/dim]")
|
|
120
|
+
console.print()
|
|
121
|
+
else:
|
|
122
|
+
console.print(" [green]✓ No posture findings[/green]\n")
|
|
123
|
+
|
|
124
|
+
# Score bar
|
|
125
|
+
status_color = _STATUS_COLOR.get(status, "white")
|
|
126
|
+
bar_filled = int(score / 5)
|
|
127
|
+
bar = "█" * bar_filled + "░" * (20 - bar_filled)
|
|
128
|
+
console.print(
|
|
129
|
+
f" Posture Score [{status_color}]{score:>3}/100[/{status_color}] "
|
|
130
|
+
f"[dim]{bar}[/dim] [{status_color}]{status}[/{status_color}]"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Summary footer
|
|
134
|
+
console.print()
|
|
135
|
+
console.rule(style="bright_blue")
|
|
136
|
+
summary_parts = [
|
|
137
|
+
f"[bold white]{len(agents)}[/bold white] agent{'s' if len(agents) != 1 else ''} scanned",
|
|
138
|
+
f"[bold white]{total_findings}[/bold white] finding{'s' if total_findings != 1 else ''}",
|
|
139
|
+
]
|
|
140
|
+
if total_critical:
|
|
141
|
+
summary_parts.append(f"[bold red]{total_critical} CRITICAL[/bold red]")
|
|
142
|
+
if total_high:
|
|
143
|
+
summary_parts.append(f"[bold orange1]{total_high} HIGH[/bold orange1]")
|
|
144
|
+
console.print(" " + " · ".join(summary_parts))
|
|
145
|
+
|
|
146
|
+
if connect_url:
|
|
147
|
+
console.print(f"\n [dim]Connected to AgentSentinel at {connect_url} — open dashboard for live behavior data.[/dim]")
|
|
148
|
+
else:
|
|
149
|
+
console.print(
|
|
150
|
+
"\n [dim]This is a static scan. Run with [bold]--connect[/bold] to include live behavior "
|
|
151
|
+
"monitoring data from a running AgentSentinel instance.[/dim]"
|
|
152
|
+
)
|
|
153
|
+
console.print()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def as_json(
|
|
157
|
+
agents: list[AgentInfo],
|
|
158
|
+
findings_map: dict[Path, list[Finding]],
|
|
159
|
+
scores_map: dict[Path, int],
|
|
160
|
+
) -> str:
|
|
161
|
+
output = []
|
|
162
|
+
for agent in agents:
|
|
163
|
+
findings = findings_map.get(agent.file, [])
|
|
164
|
+
score = scores_map.get(agent.file, 100)
|
|
165
|
+
output.append({
|
|
166
|
+
"file": str(agent.file),
|
|
167
|
+
"model": agent.model,
|
|
168
|
+
"description": agent.description,
|
|
169
|
+
"hardcoded_credentials": agent.hardcoded_creds,
|
|
170
|
+
"tools": [
|
|
171
|
+
{
|
|
172
|
+
"name": t.name,
|
|
173
|
+
"scope": t.scope,
|
|
174
|
+
"is_dangerous": t.is_dangerous,
|
|
175
|
+
"category": t.category,
|
|
176
|
+
}
|
|
177
|
+
for t in agent.tools
|
|
178
|
+
],
|
|
179
|
+
"findings": [
|
|
180
|
+
{
|
|
181
|
+
"severity": f.severity,
|
|
182
|
+
"rule_id": f.rule_id,
|
|
183
|
+
"message": f.message,
|
|
184
|
+
"detail": f.detail,
|
|
185
|
+
}
|
|
186
|
+
for f in findings
|
|
187
|
+
],
|
|
188
|
+
"posture_score": score,
|
|
189
|
+
"status": _status_from_score(score),
|
|
190
|
+
})
|
|
191
|
+
return json.dumps(output, indent=2)
|