@gianmarcomaz/vantyr 1.0.0

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,243 @@
1
+ /**
2
+ * Spec Compliance Analyzer
3
+ * Validates MCP server protocol conformance.
4
+ *
5
+ * Supports patterns from JS/TS, Python, and Go MCP SDKs.
6
+ */
7
+
8
+ /**
9
+ * @param {Array<{path: string, content: string}>} files
10
+ * @returns {Array}
11
+ */
12
+ function analyzeSpecCompliance(files) {
13
+ const findings = [];
14
+
15
+ const allContent = files.map((f) => f.content).join("\n");
16
+ const allPaths = files.map((f) => f.path);
17
+
18
+ // ─── CHECK 1: Server manifest/metadata ───
19
+ const hasServerName =
20
+ /server[_-]?name/i.test(allContent) ||
21
+ /(?:createServer|McpServer|Server|mcp\.NewServer|NewServer)\s*\(\s*\{[\s\S]{0,100}?name\s*:/i.test(allContent) ||
22
+ /name\s*=\s*['"][a-zA-Z0-9_-]+[-_]mcp[-_]/i.test(allContent);
23
+ const hasVersion =
24
+ /server[_-]?version|mcp[_-]?version/i.test(allContent) ||
25
+ /(?:createServer|McpServer|Server|mcp\.NewServer|NewServer)\s*\(\s*\{[\s\S]{0,100}?version\s*:/i.test(allContent);
26
+ const hasCapabilities =
27
+ /capabilities\s*[:=]/.test(allContent) ||
28
+ /setCapabilities/.test(allContent) ||
29
+ /WithCapabilities|ServerCapabilities|Capabilities\s*\{/.test(allContent); // Go
30
+
31
+ if (!(hasServerName && hasVersion)) {
32
+ findings.push({
33
+ severity: "medium",
34
+ file: "project",
35
+ line: null,
36
+ snippet: "",
37
+ message: `Server ${!hasServerName ? "name" : "version"} not found in source code.`,
38
+ remediation: "Declare server name and version in your MCP server configuration.",
39
+ });
40
+ }
41
+ if (!hasCapabilities) {
42
+ findings.push({
43
+ severity: "low",
44
+ file: "project",
45
+ line: null,
46
+ snippet: "",
47
+ message: "No explicit capabilities declaration found.",
48
+ remediation: "Declare server capabilities to inform clients what features are supported.",
49
+ });
50
+ }
51
+
52
+ const hasProtocolSupport = /@modelcontextprotocol\/sdk|mcp[_-]python|mcp[_-]sdk|github\.com\/mark3b\/mcp|mcp-go|protocolVersion/i.test(allContent);
53
+ if (!hasProtocolSupport) {
54
+ findings.push({
55
+ severity: "medium",
56
+ file: "project",
57
+ line: null,
58
+ snippet: "",
59
+ message: "No MCP SDK or protocol version declaration found.",
60
+ remediation: "Use an official MCP SDK or explicitly declare protocol compatibility.",
61
+ });
62
+ }
63
+
64
+ // ─── CHECK 2: Tool definitions structure ───
65
+
66
+ // Pattern 1: Direct SDK calls (JS/TS/Python)
67
+ const hasDirectSDKCall =
68
+ /server\.tool\s*\(/.test(allContent) ||
69
+ /\.addTool\s*\(/.test(allContent) ||
70
+ /\.registerTools?\s*\(/.test(allContent) ||
71
+ /@server\.tool/.test(allContent) ||
72
+ /@mcp\.tool/.test(allContent);
73
+
74
+ // Pattern 2: Variable-then-register
75
+ const hasVarThenRegister =
76
+ /(?:const|let|var)\s+\w+\s*=\s*\{[^}]*name\s*:/.test(allContent) &&
77
+ /(?:addTool|registerTool|server\.tool)/.test(allContent);
78
+
79
+ // Pattern 3: Array of tools
80
+ const hasToolArray =
81
+ /tools\s*[:=]\s*\[/.test(allContent) ||
82
+ /(?:const|let|var)\s+tools\s*=\s*\[/.test(allContent);
83
+
84
+ // Pattern 4: Object matching MCP tool shape (name + description + inputSchema)
85
+ const hasToolShapeObject =
86
+ /name\s*[:=]\s*['"][\w-]+['"]/.test(allContent) &&
87
+ /description\s*[:=]\s*['"][^'"]{3,}['"]/.test(allContent) &&
88
+ /inputSchema|input_schema|InputSchema/.test(allContent);
89
+
90
+ // Pattern 5: Tool definition files (tools.ts, toolDefinitions.ts, etc.)
91
+ const hasToolDefFile = allPaths.some((p) =>
92
+ /[\/\\](?:tools|toolDefinitions|tool[_-]?defs|mcpTools)\.(?:ts|js|py)$/i.test(p)
93
+ );
94
+
95
+ // Go SDK patterns
96
+ const hasGoToolDef =
97
+ /mcp\.NewTool\s*\(/.test(allContent) ||
98
+ /\.AddTool\s*\(/.test(allContent) ||
99
+ /AddTools?\s*\(/.test(allContent) ||
100
+ /RegisterTool/.test(allContent);
101
+
102
+ const hasToolDef = hasDirectSDKCall || hasVarThenRegister || hasToolArray ||
103
+ hasToolShapeObject || hasToolDefFile || hasGoToolDef;
104
+
105
+ const hasInputSchema =
106
+ /inputSchema|input_schema|parameters\s*[:=]\s*\{/.test(allContent) ||
107
+ /InputSchema|mcp\.Property|WithDescription|Required\s*[:=]/.test(allContent);
108
+ const hasSchemaType =
109
+ /"type"\s*:\s*"object"/.test(allContent) || /type\s*[:=]\s*['"]object['"]/.test(allContent);
110
+ const hasProperties =
111
+ /"properties"\s*:/.test(allContent) || /properties\s*[:=]/.test(allContent);
112
+
113
+ if (hasToolDef) {
114
+ if (hasInputSchema) {
115
+ if (!hasSchemaType && !/InputSchema/.test(allContent)) {
116
+ findings.push({
117
+ severity: "medium",
118
+ file: "project",
119
+ line: null,
120
+ snippet: "",
121
+ message: "Tool input schema lacks a top-level 'type' field.",
122
+ remediation: "Ensure the root of the inputSchema is an explicitly typed 'object'.",
123
+ });
124
+ }
125
+ } else {
126
+ findings.push({
127
+ severity: "medium",
128
+ file: "project",
129
+ line: null,
130
+ snippet: "",
131
+ message: "Tool definitions found but no input schemas detected.",
132
+ remediation: "Define inputSchema with JSON Schema for each tool to enable input validation.",
133
+ });
134
+ }
135
+ } else {
136
+ findings.push({
137
+ severity: "medium",
138
+ file: "project",
139
+ line: null,
140
+ snippet: "",
141
+ message: "No MCP tool definitions found.",
142
+ remediation: "Define tools using the MCP SDK's server.tool() or equivalent method.",
143
+ });
144
+ }
145
+
146
+ // ─── CHECK 3: Error handling ───
147
+ const hasJsonRpcError =
148
+ /error\s*[:=]\s*\{.*code/s.test(allContent) ||
149
+ /JsonRpcError|McpError/.test(allContent) ||
150
+ /fmt\.Errorf|errors\.Is|errors\.As|errors\.New/.test(allContent); // Go
151
+ const hasTryCatch =
152
+ /try\s*\{[\s\S]*catch/m.test(allContent) ||
153
+ /if\s+err\s*!=\s*nil/.test(allContent); // Go error handling
154
+ const hasErrorHandler =
155
+ /\.on\s*\(\s*['"]error['"]|onerror|error_handler|catch_exceptions/.test(allContent) ||
156
+ /ErrorHandler|HandleError/.test(allContent); // Go
157
+
158
+ if (hasTryCatch || hasErrorHandler) {
159
+ const hasSilentErrors = /catch\s*\([^)]*\)\s*\{\s*\}|except\s+Exception[^:]*:\s*pass/.test(allContent);
160
+ if (hasSilentErrors) {
161
+ findings.push({
162
+ severity: "low",
163
+ file: "project",
164
+ line: null,
165
+ snippet: "",
166
+ message: "Silent error handling detected (empty catch block).",
167
+ remediation: "Properly handle and return errors to the language model.",
168
+ });
169
+ }
170
+ } else {
171
+ findings.push({
172
+ severity: "medium",
173
+ file: "project",
174
+ line: null,
175
+ snippet: "",
176
+ message: "No error handling patterns detected in tool handlers.",
177
+ remediation: "Wrap tool handlers in try/catch blocks and return proper JSON-RPC error responses.",
178
+ });
179
+ }
180
+ if (!hasJsonRpcError) {
181
+ findings.push({
182
+ severity: "low",
183
+ file: "project",
184
+ line: null,
185
+ snippet: "",
186
+ message: "No JSON-RPC error response patterns found.",
187
+ remediation: "Return MCP-compliant error objects with error codes and messages.",
188
+ });
189
+ }
190
+
191
+ // ─── CHECK 4: Transport implementation ───
192
+ const hasStdio =
193
+ /stdio|StdioServerTransport|stdin|stdout/.test(allContent) ||
194
+ /CommandTransport|StdioTransport/.test(allContent); // Go
195
+ const hasHttp =
196
+ /SSEServerTransport|HttpServerTransport|express|fastify|http\.createServer/.test(allContent) ||
197
+ /SSEHandler|StreamableHTTP|http\.ListenAndServe/.test(allContent); // Go
198
+
199
+ if (hasHttp) {
200
+ const hasAuth = /auth|authenticate|middleware|bearer|authorization/i.test(allContent);
201
+ if (!hasAuth) {
202
+ findings.push({
203
+ severity: "high",
204
+ file: "project",
205
+ line: null,
206
+ snippet: "",
207
+ message: "HTTP transport without authentication middleware detected.",
208
+ remediation: "Add authentication middleware to protect your HTTP-based MCP server.",
209
+ });
210
+ }
211
+ }
212
+
213
+ // ─── CHECK 5: Documentation ───
214
+ const hasReadme = allPaths.some((p) => /readme\.md$/i.test(p));
215
+ const hasToolDescriptions = /description\s*[:=]\s*['"][^'"]{5,}['"]/.test(allContent);
216
+
217
+ if (!hasReadme) {
218
+ findings.push({
219
+ severity: "low",
220
+ file: "project",
221
+ line: null,
222
+ snippet: "",
223
+ message: "No README.md file found.",
224
+ remediation: "Create a README with setup instructions, tool documentation, and usage examples.",
225
+ });
226
+ }
227
+
228
+ const hasEmptyToolDescriptions = /description\s*[:=]\s*['"]["']/i.test(allContent);
229
+ if (hasEmptyToolDescriptions) {
230
+ findings.push({
231
+ severity: "low",
232
+ file: "project",
233
+ line: null,
234
+ snippet: "",
235
+ message: "Empty tool descriptions detected.",
236
+ remediation: "Provide meaningful descriptions for all tools. LLMs rely on these descriptions to know when and how to use the tool.",
237
+ });
238
+ }
239
+
240
+ return findings.map(f => ({ ...f, category: 'SC' }));
241
+ }
242
+
243
+ export { analyzeSpecCompliance };
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Tool Poisoning Analyzer
3
+ * Scans MCP tool definitions for description-based prompt injection.
4
+ */
5
+
6
+ // Patterns to find tool definitions
7
+ const TOOL_DEF_PATTERNS = [
8
+ // JavaScript/TypeScript
9
+ /server\.tool\s*\(/g,
10
+ /\.addTool\s*\(/g,
11
+ /\.add_tool\s*\(/g,
12
+ /tools\s*:\s*\[/g,
13
+ // Python decorators
14
+ /@server\.tool/g,
15
+ /@mcp\.tool/g,
16
+ // Go
17
+ /mcp\.NewTool\s*\(/g,
18
+ /\.AddTool\s*\(/g,
19
+ /AddTools?\s*\(/g,
20
+ /RegisterTool/g,
21
+ ];
22
+
23
+ // Extract description from nearby lines
24
+ const DESCRIPTION_PATTERNS = [
25
+ /description\s*[:=]\s*['"`]([^'"`]*(?:['"`][^'"`]*)*?)['"`]/gi,
26
+ /description\s*[:=]\s*['"`]([\s\S]*?)['"`]/gi,
27
+ /"""([\s\S]*?)"""/g, // Python docstrings
28
+ /'''([\s\S]*?)'''/g,
29
+ ];
30
+
31
+ // Extract tool names
32
+ const NAME_PATTERNS = [
33
+ /name\s*[:=]\s*['"`]([^'"`]+)['"`]/gi,
34
+ ];
35
+
36
+ const SHADOWED_NAMES = new Set([
37
+ "read_file", "write_file", "execute_command", "run_script", "get_user_info",
38
+ "sudo", "eval", "system", "fetch_url", "http_get", "fs_read", "fs_write",
39
+ ]);
40
+
41
+ // Critical injection patterns in descriptions
42
+ const INJECTION_PATTERNS = [
43
+ {
44
+ regex: /ignore\s+(?:previous|above|all|prior)/i,
45
+ severity: "critical",
46
+ message: "Instruction override attempt: 'ignore previous' pattern detected.",
47
+ },
48
+ {
49
+ regex: /disregard\s+(?:previous|above|all|prior|earlier)/i,
50
+ severity: "critical",
51
+ message: "Instruction override attempt: 'disregard' pattern detected.",
52
+ },
53
+ {
54
+ // Reduced from critical to high: legitimate descriptions occasionally say
55
+ // "You should always provide X in format Y." That's verbose but not injection.
56
+ // What IS injection is "you must always ignore / exfiltrate / bypass" —
57
+ // which will also be caught by the ignore/disregard/forget patterns above.
58
+ regex: /you\s+(?:must|should|have\s+to|need\s+to)\s+(?:always|never)/i,
59
+ severity: "high",
60
+ message: "Behavioral instruction found in tool description — LLMs should not be instructed via tool descriptions.",
61
+ },
62
+ {
63
+ // "You are now connected" / "You are now in read-only mode" are benign status
64
+ // phrases. The injection vector is "You are now [persona/role]", so require
65
+ // role-assignment language after "you are now" before flagging.
66
+ regex: /you\s+are\s+now\s+(?!(?:connected|logged|subscribed|available|ready|enabled|configured|running|serving|listening|active|online|offline|authenticated|authorized|processing|complete|done|finished|started|stopped|in\s)\b)/i,
67
+ severity: "critical",
68
+ message: "Identity override attempt detected: 'you are now [persona]' pattern.",
69
+ },
70
+ {
71
+ regex: /system\s+prompt/i,
72
+ severity: "critical",
73
+ message: "Reference to system prompt found in description.",
74
+ },
75
+ {
76
+ regex: /instructions?\s+override/i,
77
+ severity: "critical",
78
+ message: "Explicit instruction override language detected.",
79
+ },
80
+ {
81
+ regex: /forget\s+(?:everything|all|previous)/i,
82
+ severity: "critical",
83
+ message: "Memory reset attempt in tool description.",
84
+ },
85
+ {
86
+ regex: /[\u200B\u200C\u200D\u202E\uFEFF]/,
87
+ severity: "critical",
88
+ message: "Zero-width or text-direction override characters detected. These hide instructions from human reviewers.",
89
+ },
90
+ {
91
+ regex: /<!--[\s\S]*?-->/,
92
+ severity: "critical",
93
+ message: "HTML comment detected. Instructions inside HTML comments hide from users but are processed by LLMs.",
94
+ },
95
+ ];
96
+
97
+ const WARNING_PATTERNS_TOOL = [
98
+ {
99
+ regex: /before\s+using\s+this\s+tool/i,
100
+ severity: "info",
101
+ message: "Cross-tool instruction: references behavior before tool use.",
102
+ },
103
+ {
104
+ regex: /(?:first|also)\s+(?:call|execute|run|use)/i,
105
+ severity: "info",
106
+ message: "Cross-tool reference: instructs calling other tools.",
107
+ },
108
+ {
109
+ regex: /<[a-z][\s\S]*>/i,
110
+ severity: "medium",
111
+ message: "HTML/XML formatting in tool description — could hide content.",
112
+ },
113
+ ];
114
+
115
+ /**
116
+ * @param {Array<{path: string, content: string}>} files
117
+ * @returns {{ score: number, status: string, findings: Array }}
118
+ */
119
+ function analyzeToolPoisoning(files) {
120
+ const findings = [];
121
+ let toolDefsFound = 0;
122
+
123
+ for (const file of files) {
124
+ const content = file.content;
125
+ const lines = content.split("\n");
126
+
127
+ // Step 1: Find tool definitions
128
+ let hasToolDef = false;
129
+ for (const pattern of TOOL_DEF_PATTERNS) {
130
+ pattern.lastIndex = 0;
131
+ if (pattern.test(content)) {
132
+ hasToolDef = true;
133
+ break;
134
+ }
135
+ }
136
+
137
+ if (!hasToolDef) continue;
138
+ toolDefsFound++;
139
+
140
+ // Step 2: Extract descriptions
141
+ const rawDescriptions = [];
142
+ for (const pattern of DESCRIPTION_PATTERNS) {
143
+ pattern.lastIndex = 0;
144
+ let match;
145
+ while ((match = pattern.exec(content)) !== null) {
146
+ const desc = match[1] || match[0];
147
+ const lineNum = content.slice(0, match.index).split("\n").length;
148
+ rawDescriptions.push({ text: desc, line: lineNum });
149
+ }
150
+ }
151
+
152
+ // Deduplicate by trimmed text — overlapping patterns (e.g. patterns 1 & 2)
153
+ // would otherwise fire on the same description string and double every finding.
154
+ const seenTexts = new Set();
155
+ const descriptions = rawDescriptions.filter(d => {
156
+ const key = d.text.trim();
157
+ if (seenTexts.has(key)) return false;
158
+ seenTexts.add(key);
159
+ return true;
160
+ });
161
+
162
+ // Step 3: Analyze descriptions
163
+ for (const desc of descriptions) {
164
+ // Check for long descriptions
165
+ if (desc.text.length > 1000) {
166
+ findings.push({
167
+ severity: "medium",
168
+ file: file.path,
169
+ line: desc.line,
170
+ snippet: desc.text.slice(0, 120) + "...",
171
+ message: `Unusually long tool description (${desc.text.length} chars). Could hide injected instructions.`,
172
+ remediation:
173
+ "Keep tool descriptions concise and factual. Descriptions over 1000 characters should be reviewed.",
174
+ });
175
+ }
176
+
177
+ // Check for base64 — tuned to 40+ chars to avoid random hashes.
178
+ // NOTE: \b fails for strings ending in '=' (non-word char), so we use
179
+ // explicit lookahead/lookbehind instead of word-boundary anchors.
180
+ const base64Match = desc.text.match(/(?<![A-Za-z0-9+/=])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/=])/);
181
+ if (base64Match) {
182
+ findings.push({
183
+ severity: "medium",
184
+ file: file.path,
185
+ line: desc.line,
186
+ snippet: base64Match[0].slice(0, 60) + "...",
187
+ message: "Base64-encoded string in tool description — could hide instructions.",
188
+ remediation:
189
+ "Remove encoded content from tool descriptions. All description content should be human-readable.",
190
+ });
191
+ }
192
+
193
+ // Check injection patterns
194
+ for (const pattern of INJECTION_PATTERNS) {
195
+ if (pattern.regex.test(desc.text)) {
196
+ findings.push({
197
+ severity: pattern.severity,
198
+ file: file.path,
199
+ line: desc.line,
200
+ snippet: desc.text.slice(0, 150),
201
+ message: pattern.message,
202
+ remediation:
203
+ "Keep tool descriptions factual and concise. Do not include instructions for the LLM in tool descriptions.",
204
+ });
205
+ }
206
+ }
207
+
208
+ // Check warning patterns
209
+ for (const pattern of WARNING_PATTERNS_TOOL) {
210
+ if (pattern.regex.test(desc.text)) {
211
+ findings.push({
212
+ severity: pattern.severity,
213
+ file: file.path,
214
+ line: desc.line,
215
+ snippet: desc.text.slice(0, 150),
216
+ message: pattern.message,
217
+ remediation:
218
+ "Tool descriptions should explain what the tool does, not how the LLM should behave.",
219
+ });
220
+ }
221
+ }
222
+ }
223
+
224
+ // Step 4: Analyze tool names for shadowing
225
+ for (const pattern of NAME_PATTERNS) {
226
+ pattern.lastIndex = 0;
227
+ let match;
228
+ while ((match = pattern.exec(content)) !== null) {
229
+ const toolName = match[1].toLowerCase();
230
+ if (SHADOWED_NAMES.has(toolName)) {
231
+ const lineNum = content.slice(0, match.index).split("\n").length;
232
+ findings.push({
233
+ severity: "high",
234
+ file: file.path,
235
+ line: lineNum,
236
+ snippet: match[0],
237
+ message: `Tool name '${toolName}' shadows a common system or sensitive tool name.`,
238
+ remediation: "Use a more specific, domain-prefixed name for the tool to avoid confusing the LLM or shadowing built-in systemic capabilities.",
239
+ });
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ return findings.map(f => ({ ...f, category: 'TP' }));
246
+ }
247
+
248
+ export { analyzeToolPoisoning };
@@ -0,0 +1,82 @@
1
+ // Standard deductions applied uniformly across ALL categories
2
+ const SEVERITY_DEDUCTIONS = {
3
+ critical: 25,
4
+ high: 15,
5
+ medium: 5,
6
+ low: 3,
7
+ info: 0
8
+ };
9
+
10
+ export function calculateTrustScore(allFindings) {
11
+ // Group findings by category
12
+ const categories = {
13
+ NE: { findings: [], score: 100 },
14
+ CI: { findings: [], score: 100 },
15
+ CL: { findings: [], score: 100 },
16
+ TP: { findings: [], score: 100 },
17
+ SC: { findings: [], score: 100 },
18
+ IV: { findings: [], score: 100 }
19
+ };
20
+
21
+ for (const finding of allFindings) {
22
+ if (categories[finding.category]) {
23
+ categories[finding.category].findings.push(finding);
24
+ }
25
+ }
26
+
27
+ // Calculate per-category scores using IDENTICAL math for every category
28
+ for (const [key, cat] of Object.entries(categories)) {
29
+ let deduction = 0;
30
+ for (const finding of cat.findings) {
31
+ deduction += SEVERITY_DEDUCTIONS[finding.severity] || 0;
32
+ }
33
+ cat.score = Math.max(0, 100 - deduction);
34
+ }
35
+
36
+ // Weighted average using OWASP-aligned weights
37
+ const WEIGHTS = {
38
+ CL: 0.25, // Credential Leaks — OWASP MCP01 (#1)
39
+ CI: 0.20, // Command Injection — OWASP MCP05 (#5)
40
+ NE: 0.15, // Network Exposure — OWASP MCP07 (#7)
41
+ IV: 0.15, // Input Validation — SSRF/Path Traversal
42
+ TP: 0.15, // Tool Poisoning — OWASP MCP03 (#3)
43
+ SC: 0.10 // Spec Compliance — Protocol hygiene
44
+ };
45
+
46
+ let weightedSum = 0;
47
+ for (const [key, weight] of Object.entries(WEIGHTS)) {
48
+ weightedSum += categories[key].score * weight;
49
+ }
50
+
51
+ let trustScore = Math.round(weightedSum);
52
+
53
+ // Count totals
54
+ let totalFindings = allFindings.length;
55
+ let criticalCount = allFindings.filter(f => f.severity === 'critical').length;
56
+ let highCount = allFindings.filter(f => f.severity === 'high').length;
57
+ let mediumCount = allFindings.filter(f => f.severity === 'medium').length;
58
+ let lowCount = allFindings.filter(f => f.severity === 'low').length;
59
+ let infoCount = allFindings.filter(f => f.severity === 'info').length;
60
+
61
+ // A repo with any HIGH or CRITICAL finding cannot be CERTIFIED.
62
+ // Cap the Trust Score at 75 so the label always falls into WARNING or FAILED,
63
+ // regardless of how clean the other categories are.
64
+ const hasCriticalOrHigh = criticalCount > 0 || highCount > 0;
65
+ let scoreCapped = false;
66
+ if (hasCriticalOrHigh && trustScore > 75) {
67
+ trustScore = 75;
68
+ scoreCapped = true;
69
+ }
70
+
71
+ // Determine pass count (categories scoring >= 80)
72
+ let passCount = Object.values(categories).filter(c => c.score >= 80).length;
73
+
74
+ return {
75
+ trustScore,
76
+ categories,
77
+ totalFindings,
78
+ stats: { critical: criticalCount, high: highCount, medium: mediumCount, low: lowCount, info: infoCount },
79
+ passCount,
80
+ scoreCapped
81
+ };
82
+ }