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.
@@ -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
- if (!WRITE_TOOLS.has(toolName)) {
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
- const codeResult = scanCode(content, filePath);
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
- const allFindings = [...codeResult.findings, ...supplyChainResult.findings];
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
- `Scanned ${content.length} bytes of content being written to ${filePath || 'unknown file'}`,
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,
@@ -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
  };
@@ -312,6 +312,7 @@ function setGeminiEmbedderForTests(loader) {
312
312
  module.exports = {
313
313
  upsertFeedback,
314
314
  searchSimilar,
315
+ embed,
315
316
  TABLE_NAME,
316
317
  getEmbeddingConfig,
317
318
  getLastEmbeddingProfile,