thumbgate 1.23.2 → 1.25.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +15 -4
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/package.json +16 -4
- package/public/ai-malpractice-prevention.html +62 -5
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/gates-engine.js +98 -13
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/security-scanner.js +80 -10
- package/scripts/tool-registry.js +35 -1
- package/scripts/vector-store.js +1 -0
- package/src/api/server.js +161 -2
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* mcp-oauth.js — OAuth 2.1 (PKCE) authorization-server machinery for the remote
|
|
6
|
+
* ThumbGate MCP connector, required by the Claude Connectors Directory
|
|
7
|
+
* ("Use OAuth 2.0 for authenticated services").
|
|
8
|
+
*
|
|
9
|
+
* Pure, dependency-free, IO-free functions + an injectable store, so the full
|
|
10
|
+
* flow is unit-testable without a network. The HTTP wiring (in src/api/server.js)
|
|
11
|
+
* is a thin shell over these.
|
|
12
|
+
*
|
|
13
|
+
* Implements:
|
|
14
|
+
* - RFC 9728 protected-resource metadata
|
|
15
|
+
* - RFC 8414 authorization-server metadata
|
|
16
|
+
* - RFC 7591 dynamic client registration (open registration)
|
|
17
|
+
* - Authorization-code grant with mandatory PKCE S256 (RFC 7636)
|
|
18
|
+
* - Bearer access tokens bound to a ThumbGate API key (so existing gating is unchanged)
|
|
19
|
+
*
|
|
20
|
+
* SECURITY NOTES:
|
|
21
|
+
* - PKCE S256 is mandatory; the plain method is rejected.
|
|
22
|
+
* - Auth codes are single-use and short-lived (60s); access tokens TTL-bound.
|
|
23
|
+
* - redirect_uri is matched exactly against the registered set.
|
|
24
|
+
* - The token never exposes the bound ThumbGate key; it maps server-side only.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
const AUTH_CODE_TTL_MS = 60 * 1000; // 1 minute
|
|
30
|
+
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
31
|
+
const DEFAULT_SCOPE = 'mcp:read mcp:write';
|
|
32
|
+
|
|
33
|
+
// Upper bounds on the in-memory store. The registration and authorization
|
|
34
|
+
// endpoints are reachable pre-auth, so without a cap a malicious caller could
|
|
35
|
+
// grow these Maps unboundedly and exhaust server memory. When a Map is full we
|
|
36
|
+
// evict the oldest entry (FIFO) rather than deny service to legitimate clients.
|
|
37
|
+
const MAX_CLIENTS = 10000;
|
|
38
|
+
const MAX_CODES = 10000;
|
|
39
|
+
const MAX_TOKENS = 50000;
|
|
40
|
+
|
|
41
|
+
function now() {
|
|
42
|
+
return Date.now();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function randomToken(bytes = 32) {
|
|
46
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function base64UrlSha256(input) {
|
|
50
|
+
return crypto.createHash('sha256').update(String(input)).digest('base64url');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a fresh in-memory store.
|
|
55
|
+
*
|
|
56
|
+
* DURABILITY (known limitation): this uses plain Maps, so a process restart or a
|
|
57
|
+
* multi-instance / load-balanced deployment will drop issued tokens and
|
|
58
|
+
* registered clients (clients see 401s, in-flight authorizations break). For
|
|
59
|
+
* single-instance use this is fine; production multi-tenancy needs a durable,
|
|
60
|
+
* shared backing (Redis/DB) — tracked as the per-user-data-scoping follow-up.
|
|
61
|
+
* Entry counts are bounded (see MAX_* and capInsert) to prevent memory
|
|
62
|
+
* exhaustion from anonymous calls to the registration/authorization endpoints.
|
|
63
|
+
*/
|
|
64
|
+
function createStore() {
|
|
65
|
+
return {
|
|
66
|
+
clients: new Map(),
|
|
67
|
+
codes: new Map(),
|
|
68
|
+
tokens: new Map(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Insert into a Map, evicting the oldest entry (FIFO) once `max` is reached. */
|
|
73
|
+
function capInsert(map, key, value, max) {
|
|
74
|
+
if (map.size >= max && !map.has(key)) {
|
|
75
|
+
const oldest = map.keys().next().value;
|
|
76
|
+
if (oldest !== undefined) map.delete(oldest);
|
|
77
|
+
}
|
|
78
|
+
map.set(key, value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Metadata (RFC 9728 / RFC 8414)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function trimSlash(u) {
|
|
86
|
+
// Non-regex trailing-slash strip (avoids a SonarCloud S5852 false-positive on a
|
|
87
|
+
// provably-linear pattern).
|
|
88
|
+
let s = String(u || '');
|
|
89
|
+
while (s.endsWith('/')) s = s.slice(0, -1);
|
|
90
|
+
return s;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildProtectedResourceMetadata(baseUrl) {
|
|
94
|
+
const base = trimSlash(baseUrl);
|
|
95
|
+
return {
|
|
96
|
+
resource: `${base}/mcp`,
|
|
97
|
+
authorization_servers: [base],
|
|
98
|
+
bearer_methods_supported: ['header'],
|
|
99
|
+
scopes_supported: ['mcp:read', 'mcp:write'],
|
|
100
|
+
resource_documentation: `${base}/docs/connectors`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildAuthServerMetadata(baseUrl) {
|
|
105
|
+
const base = trimSlash(baseUrl);
|
|
106
|
+
return {
|
|
107
|
+
issuer: base,
|
|
108
|
+
authorization_endpoint: `${base}/oauth/authorize`,
|
|
109
|
+
token_endpoint: `${base}/oauth/token`,
|
|
110
|
+
registration_endpoint: `${base}/oauth/register`,
|
|
111
|
+
scopes_supported: ['mcp:read', 'mcp:write'],
|
|
112
|
+
response_types_supported: ['code'],
|
|
113
|
+
grant_types_supported: ['authorization_code'],
|
|
114
|
+
code_challenge_methods_supported: ['S256'],
|
|
115
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Dynamic client registration (RFC 7591)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
// The MCP authorization spec is explicit: "All redirect URIs MUST be either
|
|
124
|
+
// `localhost` or use HTTPS." We therefore accept only HTTPS and loopback
|
|
125
|
+
// (http://localhost | http://127.0.0.1) and reject every other scheme —
|
|
126
|
+
// including native-app custom schemes (myapp://, intent://, etc.), which the MCP
|
|
127
|
+
// profile does not sanction and the real client (Claude) does not use.
|
|
128
|
+
function isAllowedRedirectUri(uri) {
|
|
129
|
+
const u = String(uri || '');
|
|
130
|
+
if (/^https:\/\//i.test(u)) return true;
|
|
131
|
+
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(u)) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function registerClient(store, body = {}) {
|
|
136
|
+
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter(Boolean) : [];
|
|
137
|
+
if (redirectUris.length === 0) {
|
|
138
|
+
return { error: 'invalid_redirect_uri', error_description: 'redirect_uris is required' };
|
|
139
|
+
}
|
|
140
|
+
for (const uri of redirectUris) {
|
|
141
|
+
if (!isAllowedRedirectUri(uri)) {
|
|
142
|
+
return { error: 'invalid_redirect_uri', error_description: `unsupported redirect_uri: ${uri}` };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const clientId = `tg_${randomToken(16)}`;
|
|
146
|
+
const record = {
|
|
147
|
+
client_id: clientId,
|
|
148
|
+
redirect_uris: redirectUris,
|
|
149
|
+
token_endpoint_auth_method: 'none',
|
|
150
|
+
grant_types: ['authorization_code'],
|
|
151
|
+
response_types: ['code'],
|
|
152
|
+
client_name: typeof body.client_name === 'string' ? body.client_name.slice(0, 200) : 'mcp-client',
|
|
153
|
+
created_at: now(),
|
|
154
|
+
};
|
|
155
|
+
capInsert(store.clients, clientId, record, MAX_CLIENTS);
|
|
156
|
+
return record;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getClient(store, clientId) {
|
|
160
|
+
return store.clients.get(clientId) || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Authorization code (PKCE S256)
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Issue an auth code. `boundKey` is the ThumbGate API key the resulting access
|
|
169
|
+
* token will act as (resolved by the authorize step once the user consents).
|
|
170
|
+
*/
|
|
171
|
+
function createAuthorizationCode(store, {
|
|
172
|
+
clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, boundKey, state, resource,
|
|
173
|
+
} = {}) {
|
|
174
|
+
const client = getClient(store, clientId);
|
|
175
|
+
if (!client) return { error: 'invalid_client' };
|
|
176
|
+
if (!client.redirect_uris.includes(redirectUri)) return { error: 'invalid_request', error_description: 'redirect_uri mismatch' };
|
|
177
|
+
if (codeChallengeMethod !== 'S256') return { error: 'invalid_request', error_description: 'code_challenge_method must be S256' };
|
|
178
|
+
if (!codeChallenge || String(codeChallenge).length < 16) return { error: 'invalid_request', error_description: 'code_challenge required' };
|
|
179
|
+
|
|
180
|
+
const code = randomToken(24);
|
|
181
|
+
capInsert(store.codes, code, {
|
|
182
|
+
clientId,
|
|
183
|
+
redirectUri,
|
|
184
|
+
codeChallenge,
|
|
185
|
+
scope: scope || DEFAULT_SCOPE,
|
|
186
|
+
boundKey: boundKey || '',
|
|
187
|
+
resource: resource || '', // RFC 8707 resource indicator (the MCP server URL)
|
|
188
|
+
expiresAt: now() + AUTH_CODE_TTL_MS,
|
|
189
|
+
used: false,
|
|
190
|
+
}, MAX_CODES);
|
|
191
|
+
return { code, state };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function verifyPkce(codeChallenge, codeVerifier) {
|
|
195
|
+
if (!codeChallenge || !codeVerifier) return false;
|
|
196
|
+
// RFC 7636: verifier 43–128 chars.
|
|
197
|
+
if (String(codeVerifier).length < 43 || String(codeVerifier).length > 128) return false;
|
|
198
|
+
try {
|
|
199
|
+
return crypto.timingSafeEqual(
|
|
200
|
+
Buffer.from(base64UrlSha256(codeVerifier)),
|
|
201
|
+
Buffer.from(String(codeChallenge)),
|
|
202
|
+
);
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Token exchange + validation
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
function exchangeCode(store, { code, codeVerifier, clientId, redirectUri, resource } = {}) {
|
|
213
|
+
const entry = store.codes.get(code);
|
|
214
|
+
if (!entry) return { error: 'invalid_grant', error_description: 'unknown code' };
|
|
215
|
+
// Single-use + expiry: consume regardless of outcome.
|
|
216
|
+
store.codes.delete(code);
|
|
217
|
+
if (entry.used) return { error: 'invalid_grant', error_description: 'code already used' };
|
|
218
|
+
if (now() > entry.expiresAt) return { error: 'invalid_grant', error_description: 'code expired' };
|
|
219
|
+
if (entry.clientId !== clientId) return { error: 'invalid_grant', error_description: 'client mismatch' };
|
|
220
|
+
if (entry.redirectUri !== redirectUri) return { error: 'invalid_grant', error_description: 'redirect_uri mismatch' };
|
|
221
|
+
if (!verifyPkce(entry.codeChallenge, codeVerifier)) return { error: 'invalid_grant', error_description: 'PKCE verification failed' };
|
|
222
|
+
// RFC 8707: the resource at token time must match the one bound at authorize time.
|
|
223
|
+
if (entry.resource && resource && entry.resource !== resource) {
|
|
224
|
+
return { error: 'invalid_target', error_description: 'resource indicator mismatch' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const accessToken = `tgat_${randomToken(32)}`;
|
|
228
|
+
capInsert(store.tokens, accessToken, {
|
|
229
|
+
boundKey: entry.boundKey,
|
|
230
|
+
scope: entry.scope,
|
|
231
|
+
clientId,
|
|
232
|
+
aud: entry.resource || resource || '',
|
|
233
|
+
expiresAt: now() + ACCESS_TOKEN_TTL_MS,
|
|
234
|
+
}, MAX_TOKENS);
|
|
235
|
+
return {
|
|
236
|
+
access_token: accessToken,
|
|
237
|
+
token_type: 'Bearer',
|
|
238
|
+
expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
|
|
239
|
+
scope: entry.scope,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Resolve an access token to its session, or null if invalid/expired. */
|
|
244
|
+
function resolveAccessToken(store, token) {
|
|
245
|
+
if (!token) return null;
|
|
246
|
+
const entry = store.tokens.get(token);
|
|
247
|
+
if (!entry) return null;
|
|
248
|
+
if (now() > entry.expiresAt) {
|
|
249
|
+
store.tokens.delete(token);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return { boundKey: entry.boundKey, scope: entry.scope, clientId: entry.clientId, aud: entry.aud };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* RFC 8707 audience validation: a token is valid for `expectedResource` only if it
|
|
257
|
+
* was issued for it (or carries no audience, for back-compat). MCP servers MUST
|
|
258
|
+
* reject tokens minted for a different resource.
|
|
259
|
+
*/
|
|
260
|
+
function tokenAudienceValid(session, expectedResource) {
|
|
261
|
+
if (!session) return false;
|
|
262
|
+
if (!session.aud) return true; // no audience recorded — accept (back-compat)
|
|
263
|
+
return session.aud === expectedResource;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Best-effort GC of expired codes/tokens (call opportunistically). */
|
|
267
|
+
function pruneExpired(store) {
|
|
268
|
+
const t = now();
|
|
269
|
+
for (const [k, v] of store.codes) if (t > v.expiresAt) store.codes.delete(k);
|
|
270
|
+
for (const [k, v] of store.tokens) if (t > v.expiresAt) store.tokens.delete(k);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
createStore,
|
|
275
|
+
buildProtectedResourceMetadata,
|
|
276
|
+
buildAuthServerMetadata,
|
|
277
|
+
registerClient,
|
|
278
|
+
getClient,
|
|
279
|
+
createAuthorizationCode,
|
|
280
|
+
verifyPkce,
|
|
281
|
+
exchangeCode,
|
|
282
|
+
resolveAccessToken,
|
|
283
|
+
tokenAudienceValid,
|
|
284
|
+
pruneExpired,
|
|
285
|
+
isAllowedRedirectUri,
|
|
286
|
+
base64UrlSha256,
|
|
287
|
+
AUTH_CODE_TTL_MS,
|
|
288
|
+
ACCESS_TOKEN_TTL_MS,
|
|
289
|
+
DEFAULT_SCOPE,
|
|
290
|
+
MAX_CLIENTS,
|
|
291
|
+
MAX_CODES,
|
|
292
|
+
MAX_TOKENS,
|
|
293
|
+
};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { loadOptionalModule } = require("./private-core-boundary");
|
|
5
|
+
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Security Scanner — OWASP-aware static analysis for PreToolUse checks.
|
|
6
9
|
*
|
|
@@ -16,6 +19,10 @@
|
|
|
16
19
|
const fs = require('fs');
|
|
17
20
|
const path = require('path');
|
|
18
21
|
const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
|
|
22
|
+
const { scanInstallCommand, detectSlopsquat } = loadOptionalModule('./slopsquat-guard', () => ({
|
|
23
|
+
scanInstallCommand: () => ({ detected: false, findings: [] }),
|
|
24
|
+
detectSlopsquat: () => null,
|
|
25
|
+
}));
|
|
19
26
|
|
|
20
27
|
// ---------------------------------------------------------------------------
|
|
21
28
|
// Vulnerability pattern definitions (OWASP Top 10 + supply chain)
|
|
@@ -277,6 +284,18 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
277
284
|
path: 'package.json',
|
|
278
285
|
});
|
|
279
286
|
}
|
|
287
|
+
|
|
288
|
+
// Tier 3: Slopsquat Guard — deterministic typosquat detection
|
|
289
|
+
const slopsquatFinding = detectSlopsquat(pkg, 'npm');
|
|
290
|
+
if (slopsquatFinding) {
|
|
291
|
+
findings.push({
|
|
292
|
+
id: slopsquatFinding.id,
|
|
293
|
+
category: 'supply-chain',
|
|
294
|
+
severity: slopsquatFinding.severity,
|
|
295
|
+
label: slopsquatFinding.label,
|
|
296
|
+
path: 'package.json',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
280
299
|
}
|
|
281
300
|
}
|
|
282
301
|
}
|
|
@@ -313,27 +332,68 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
313
332
|
* @param {Object} input - Hook input { tool_name, tool_input }
|
|
314
333
|
* @returns {Object|null} Gate result or null if clean
|
|
315
334
|
*/
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Evaluate slopsquat guard for a Bash command.
|
|
338
|
+
* @param {string} toolName
|
|
339
|
+
* @param {Object} toolInput
|
|
340
|
+
* @returns {Object|null}
|
|
341
|
+
*/
|
|
342
|
+
function evaluateSlopsquatScan(toolName, toolInput) {
|
|
343
|
+
if (toolName !== "Bash") return null;
|
|
344
|
+
const command = toolInput.command || "";
|
|
345
|
+
if (!command) return null;
|
|
346
|
+
|
|
347
|
+
const { resolveMode, scanInstallCommand } = loadOptionalModule("./slopsquat-guard", () => ({
|
|
348
|
+
resolveMode: () => "block",
|
|
349
|
+
scanInstallCommand: () => ({ detected: false, findings: [] }),
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
const mode = resolveMode();
|
|
353
|
+
if (mode === "off") return null;
|
|
354
|
+
|
|
355
|
+
const result = scanInstallCommand(command);
|
|
356
|
+
if (!result.detected) return null;
|
|
357
|
+
|
|
358
|
+
const hasCritical = result.findings.some(f => f.severity === "critical");
|
|
359
|
+
const decision = (mode === "block" && hasCritical) ? "deny" : "warn";
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
decision,
|
|
363
|
+
gate: "slopsquat-guard",
|
|
364
|
+
message: "✗ THUMBGATE: " + result.findings[0].label,
|
|
365
|
+
severity: hasCritical ? "critical" : "high",
|
|
366
|
+
reasoning: result.findings.map(f => f.label),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
316
370
|
function evaluateSecurityScan(input = {}) {
|
|
317
371
|
const toolName = input.tool_name || input.toolName || '';
|
|
318
372
|
const toolInput = input.tool_input || {};
|
|
319
373
|
|
|
320
|
-
// Only scan write-type operations
|
|
374
|
+
// Only scan write-type operations and Bash commands
|
|
321
375
|
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
322
|
-
|
|
376
|
+
const IS_BASH = toolName === 'Bash';
|
|
377
|
+
|
|
378
|
+
if (!WRITE_TOOLS.has(toolName) && !IS_BASH) {
|
|
323
379
|
return null;
|
|
324
380
|
}
|
|
325
381
|
|
|
326
382
|
const filePath = toolInput.file_path || toolInput.path || '';
|
|
327
383
|
const content = toolInput.content || toolInput.new_string || '';
|
|
384
|
+
const command = toolInput.command || '';
|
|
328
385
|
|
|
329
|
-
if (!content) return null;
|
|
386
|
+
if (!content && !command) return null;
|
|
330
387
|
|
|
331
|
-
// Tier 1: Code vulnerability scan
|
|
332
|
-
|
|
388
|
+
// Tier 1: Code vulnerability scan (for Edits)
|
|
389
|
+
let codeResult = { detected: false, findings: [] };
|
|
390
|
+
if (content) {
|
|
391
|
+
codeResult = scanCode(content, filePath);
|
|
392
|
+
}
|
|
333
393
|
|
|
334
394
|
// Tier 2: Supply chain scan for package.json changes
|
|
335
395
|
let supplyChainResult = { detected: false, findings: [] };
|
|
336
|
-
if (filePath && path.basename(filePath) === 'package.json') {
|
|
396
|
+
if (filePath && path.basename(filePath) === 'package.json' && content) {
|
|
337
397
|
let oldContent = '';
|
|
338
398
|
try {
|
|
339
399
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
@@ -344,7 +404,14 @@ function evaluateSecurityScan(input = {}) {
|
|
|
344
404
|
supplyChainResult = scanDependencyChange(oldContent, content);
|
|
345
405
|
}
|
|
346
406
|
|
|
347
|
-
|
|
407
|
+
// Tier 3: Slopsquat Guard for Bash commands
|
|
408
|
+
let slopsquatResult = { detected: false, findings: [] };
|
|
409
|
+
if (IS_BASH && command) {
|
|
410
|
+
const slopsquatGate = evaluateSlopsquatScan(toolName, toolInput);
|
|
411
|
+
if (slopsquatGate) return slopsquatGate;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const allFindings = [...codeResult.findings, ...supplyChainResult.findings, ...slopsquatResult.findings];
|
|
348
415
|
if (allFindings.length === 0) return null;
|
|
349
416
|
|
|
350
417
|
// Determine overall severity
|
|
@@ -359,16 +426,18 @@ function evaluateSecurityScan(input = {}) {
|
|
|
359
426
|
`[${f.severity.toUpperCase()}] ${f.label}${f.line ? ` (line ${f.line})` : ''}`
|
|
360
427
|
).join('; ');
|
|
361
428
|
|
|
362
|
-
const message = `Security scan detected ${allFindings.length} issue(s) in ${filePath || 'code'}: ${summary}`;
|
|
429
|
+
const message = `Security scan detected ${allFindings.length} issue(s) in ${filePath || (IS_BASH ? 'command' : 'code')}: ${summary}`;
|
|
363
430
|
|
|
364
431
|
const reasoning = [
|
|
365
|
-
|
|
432
|
+
IS_BASH
|
|
433
|
+
? `Scanned Bash command for slopsquat/typosquat risk: "${command.slice(0, 100)}..."`
|
|
434
|
+
: `Scanned ${content.length} bytes of content being written to ${filePath || 'unknown file'}`,
|
|
366
435
|
...allFindings.map(f => `${f.category}/${f.id}: ${f.label}${f.match ? ` — matched: ${f.match.slice(0, 60)}` : ''}`),
|
|
367
436
|
];
|
|
368
437
|
|
|
369
438
|
recordAuditEvent({
|
|
370
439
|
toolName,
|
|
371
|
-
toolInput: { file_path: filePath, content_length: content.length },
|
|
440
|
+
toolInput: { file_path: filePath, content_length: content.length, command: IS_BASH ? command : undefined },
|
|
372
441
|
decision,
|
|
373
442
|
gateId,
|
|
374
443
|
message,
|
|
@@ -439,6 +508,7 @@ function scanGitDiff(diffContent) {
|
|
|
439
508
|
// ---------------------------------------------------------------------------
|
|
440
509
|
|
|
441
510
|
module.exports = {
|
|
511
|
+
evaluateSlopsquatScan,
|
|
442
512
|
VULN_PATTERNS,
|
|
443
513
|
SUPPLY_CHAIN_PATTERNS,
|
|
444
514
|
scanCode,
|
package/scripts/tool-registry.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
// Human-readable display title from a snake_case tool name.
|
|
5
|
+
// e.g. "capture_feedback" -> "Capture Feedback". The Claude Connectors Directory
|
|
6
|
+
// requires every tool to carry BOTH a title and a readOnlyHint/destructiveHint.
|
|
7
|
+
function humanizeTitle(name) {
|
|
8
|
+
return String(name || '')
|
|
9
|
+
.split(/[_-]+/)
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
12
|
+
.join(' ');
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
function readOnlyTool(tool) {
|
|
5
16
|
return {
|
|
6
17
|
...tool,
|
|
18
|
+
title: tool.title || humanizeTitle(tool.name),
|
|
7
19
|
annotations: {
|
|
20
|
+
title: tool.title || humanizeTitle(tool.name),
|
|
8
21
|
readOnlyHint: true,
|
|
9
22
|
},
|
|
10
23
|
};
|
|
@@ -13,7 +26,9 @@ function readOnlyTool(tool) {
|
|
|
13
26
|
function destructiveTool(tool) {
|
|
14
27
|
return {
|
|
15
28
|
...tool,
|
|
29
|
+
title: tool.title || humanizeTitle(tool.name),
|
|
16
30
|
annotations: {
|
|
31
|
+
title: tool.title || humanizeTitle(tool.name),
|
|
17
32
|
destructiveHint: true,
|
|
18
33
|
},
|
|
19
34
|
};
|
|
@@ -1396,6 +1411,25 @@ const TOOLS = [
|
|
|
1396
1411
|
}),
|
|
1397
1412
|
];
|
|
1398
1413
|
|
|
1414
|
+
// Normalize at export: guarantee EVERY tool carries a human-readable title and a
|
|
1415
|
+
// readOnlyHint/destructiveHint annotation (both required by the Connectors
|
|
1416
|
+
// Directory; the #1 rejection cause is missing annotations). Tools defined as
|
|
1417
|
+
// plain objects (not via readOnlyTool/destructiveTool) are backfilled here:
|
|
1418
|
+
// title from the name, and a conservative destructiveHint when no hint is set
|
|
1419
|
+
// (so an un-hinted tool is gated rather than silently treated as read-only).
|
|
1420
|
+
const NORMALIZED_TOOLS = TOOLS.map((tool) => {
|
|
1421
|
+
const title = tool.title || humanizeTitle(tool.name);
|
|
1422
|
+
const existing = tool.annotations || {};
|
|
1423
|
+
const hasHint = existing.readOnlyHint === true || existing.destructiveHint === true;
|
|
1424
|
+
const annotations = {
|
|
1425
|
+
title,
|
|
1426
|
+
...existing,
|
|
1427
|
+
...(hasHint ? {} : { destructiveHint: true }),
|
|
1428
|
+
};
|
|
1429
|
+
return { ...tool, title, annotations };
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1399
1432
|
module.exports = {
|
|
1400
|
-
TOOLS,
|
|
1433
|
+
TOOLS: NORMALIZED_TOOLS,
|
|
1434
|
+
humanizeTitle,
|
|
1401
1435
|
};
|