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.
@@ -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)