@solongate/proxy 0.15.4 → 0.16.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/hooks/guard.mjs +124 -57
- package/package.json +1 -1
package/hooks/guard.mjs
CHANGED
|
@@ -296,92 +296,159 @@ process.stdin.on('end', async () => {
|
|
|
296
296
|
'policy.json', '.mcp.json',
|
|
297
297
|
];
|
|
298
298
|
|
|
299
|
-
//
|
|
299
|
+
// Block helper — logs to cloud + exits with code 2
|
|
300
|
+
async function blockSelfProtection(reason) {
|
|
301
|
+
if (API_KEY && API_KEY.startsWith('sg_live_')) {
|
|
302
|
+
try {
|
|
303
|
+
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
|
|
306
|
+
body: JSON.stringify({
|
|
307
|
+
tool: data.tool_name || '', arguments: args,
|
|
308
|
+
decision: 'DENY', reason,
|
|
309
|
+
source: 'claude-code-guard',
|
|
310
|
+
}),
|
|
311
|
+
signal: AbortSignal.timeout(3000),
|
|
312
|
+
});
|
|
313
|
+
} catch {}
|
|
314
|
+
}
|
|
315
|
+
process.stderr.write(reason);
|
|
316
|
+
process.exit(2);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Normalization layers ──
|
|
320
|
+
|
|
321
|
+
// 1. Decode ANSI-C quoting: $'\x72' → r, $'\162' → r, $'\n' → newline
|
|
322
|
+
function decodeAnsiC(s) {
|
|
323
|
+
return s.replace(/\$'([^']*)'/g, (_, content) => {
|
|
324
|
+
return content
|
|
325
|
+
.replace(/\\x([0-9a-fA-F]{2})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
326
|
+
.replace(/\\([0-7]{1,3})/g, (__, oct) => String.fromCharCode(parseInt(oct, 8)))
|
|
327
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
328
|
+
.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r')
|
|
329
|
+
.replace(/\\(.)/g, '$1');
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 2. Strip all shell quoting: empty quotes, single, double, backslash escapes
|
|
300
334
|
function stripShellQuotes(s) {
|
|
301
|
-
|
|
335
|
+
let r = s;
|
|
336
|
+
r = r.replace(/""/g, ''); // empty double quotes
|
|
337
|
+
r = r.replace(/''/g, ''); // empty single quotes
|
|
338
|
+
r = r.replace(/\\(.)/g, '$1'); // backslash escapes
|
|
339
|
+
r = r.replace(/'/g, ''); // remaining single quotes
|
|
340
|
+
r = r.replace(/"/g, ''); // remaining double quotes
|
|
341
|
+
return r;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 3. Full normalization pipeline
|
|
345
|
+
function normalizeShell(s) {
|
|
346
|
+
return stripShellQuotes(decodeAnsiC(s));
|
|
302
347
|
}
|
|
303
348
|
|
|
304
|
-
//
|
|
305
|
-
|
|
349
|
+
// 4. Extract inner commands from eval, bash -c, sh -c, pipe to bash/sh
|
|
350
|
+
function extractInnerCommands(s) {
|
|
351
|
+
const inner = [];
|
|
352
|
+
// eval "cmd" or eval 'cmd' or eval cmd
|
|
353
|
+
for (const m of s.matchAll(/\beval\s+["']([^"']+)["']/gi)) inner.push(m[1]);
|
|
354
|
+
for (const m of s.matchAll(/\beval\s+([^;"'|&]+)/gi)) inner.push(m[1]);
|
|
355
|
+
// bash -c "cmd" or sh -c "cmd"
|
|
356
|
+
for (const m of s.matchAll(/\b(?:bash|sh)\s+-c\s+["']([^"']+)["']/gi)) inner.push(m[1]);
|
|
357
|
+
// echo "cmd" | bash/sh or printf "cmd" | bash/sh
|
|
358
|
+
for (const m of s.matchAll(/(?:echo|printf)\s+["']([^"']+)["']\s*\|\s*(?:bash|sh)\b/gi)) inner.push(m[1]);
|
|
359
|
+
// find ... -name "pattern" ... -exec ...
|
|
360
|
+
for (const m of s.matchAll(/-name\s+["']?([^\s"']+)["']?/gi)) inner.push(m[1]);
|
|
361
|
+
return inner;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 5. Check variable assignments for protected path fragments
|
|
365
|
+
// e.g. X=".solon" && rm -rf ${X}gate → detects ".solon" as prefix of ".solongate"
|
|
366
|
+
function checkVarAssignments(s) {
|
|
367
|
+
const assignments = [...s.matchAll(/(\w+)=["']?([^"'\s&|;]+)["']?/g)];
|
|
368
|
+
for (const [, , value] of assignments) {
|
|
369
|
+
const v = value.toLowerCase();
|
|
370
|
+
if (v.length < 3) continue; // avoid false positives
|
|
371
|
+
for (const p of protectedPaths) {
|
|
372
|
+
if (p.startsWith(v) || p.includes(v)) return p;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 6. Check if a glob/wildcard could match any protected path
|
|
306
379
|
function globMatchesProtected(s) {
|
|
307
380
|
if (!s.includes('*') && !s.includes('?')) return null;
|
|
308
|
-
// Extract all path segments and the full string to test
|
|
309
381
|
const segments = s.split('/').filter(Boolean);
|
|
310
382
|
const candidates = [s, ...segments];
|
|
311
383
|
for (const candidate of candidates) {
|
|
312
384
|
if (!candidate.includes('*') && !candidate.includes('?')) continue;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
385
|
+
// Prefix match: ".antig*" → prefix ".antig"
|
|
386
|
+
const starIdx = candidate.indexOf('*');
|
|
387
|
+
const qIdx = candidate.indexOf('?');
|
|
388
|
+
const firstWild = starIdx === -1 ? qIdx : qIdx === -1 ? starIdx : Math.min(starIdx, qIdx);
|
|
389
|
+
const prefix = candidate.slice(0, firstWild).toLowerCase();
|
|
390
|
+
if (prefix.length > 0) {
|
|
391
|
+
for (const p of protectedPaths) {
|
|
392
|
+
if (p.startsWith(prefix)) return p;
|
|
393
|
+
}
|
|
319
394
|
}
|
|
395
|
+
// Regex match for patterns like ".cl?ude"
|
|
396
|
+
try {
|
|
397
|
+
const escaped = candidate.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
|
|
398
|
+
const re = new RegExp('^' + escaped + '$', 'i');
|
|
399
|
+
for (const p of protectedPaths) {
|
|
400
|
+
if (re.test(p)) return p;
|
|
401
|
+
}
|
|
402
|
+
} catch {}
|
|
320
403
|
}
|
|
321
404
|
return null;
|
|
322
405
|
}
|
|
323
406
|
|
|
324
|
-
//
|
|
407
|
+
// ── Build all candidate strings from tool input ──
|
|
325
408
|
const rawStrings = scanStrings(args).map(s => s.replace(/\\/g, '/').toLowerCase());
|
|
326
|
-
const allStrings =
|
|
409
|
+
const allStrings = new Set();
|
|
410
|
+
|
|
327
411
|
for (const s of rawStrings) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
412
|
+
// Raw string
|
|
413
|
+
allStrings.add(s);
|
|
414
|
+
// Normalized (ANSI-C decoded, quotes stripped)
|
|
415
|
+
const norm = normalizeShell(s);
|
|
416
|
+
allStrings.add(norm);
|
|
417
|
+
// Extract inner commands (eval, bash -c, pipe to bash, find -name)
|
|
418
|
+
for (const inner of extractInnerCommands(s)) {
|
|
419
|
+
allStrings.add(inner.toLowerCase());
|
|
420
|
+
allStrings.add(normalizeShell(inner.toLowerCase()));
|
|
421
|
+
}
|
|
422
|
+
// Split by spaces + shell operators for token-level checks
|
|
423
|
+
for (const tok of s.split(/[\s;&|]+/)) {
|
|
424
|
+
if (tok) {
|
|
425
|
+
allStrings.add(tok);
|
|
426
|
+
allStrings.add(normalizeShell(tok));
|
|
338
427
|
}
|
|
339
428
|
}
|
|
429
|
+
// Also split normalized version
|
|
430
|
+
for (const tok of norm.split(/[\s;&|]+/)) {
|
|
431
|
+
if (tok) allStrings.add(tok);
|
|
432
|
+
}
|
|
340
433
|
}
|
|
341
434
|
|
|
435
|
+
// ── Check all candidates ──
|
|
342
436
|
for (const s of allStrings) {
|
|
343
437
|
// Direct match
|
|
344
438
|
for (const p of protectedPaths) {
|
|
345
439
|
if (s.includes(p)) {
|
|
346
|
-
|
|
347
|
-
if (API_KEY && API_KEY.startsWith('sg_live_')) {
|
|
348
|
-
try {
|
|
349
|
-
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
350
|
-
method: 'POST',
|
|
351
|
-
headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
|
|
352
|
-
body: JSON.stringify({
|
|
353
|
-
tool: data.tool_name || '', arguments: args,
|
|
354
|
-
decision: 'DENY', reason: msg,
|
|
355
|
-
source: 'claude-code-guard',
|
|
356
|
-
}),
|
|
357
|
-
signal: AbortSignal.timeout(3000),
|
|
358
|
-
});
|
|
359
|
-
} catch {}
|
|
360
|
-
}
|
|
361
|
-
process.stderr.write(msg);
|
|
362
|
-
process.exit(2);
|
|
440
|
+
await blockSelfProtection('SOLONGATE: Access to protected path "' + p + '" is blocked');
|
|
363
441
|
}
|
|
364
442
|
}
|
|
365
443
|
// Wildcard/glob match
|
|
366
444
|
const globHit = globMatchesProtected(s);
|
|
367
445
|
if (globHit) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
body: JSON.stringify({
|
|
375
|
-
tool: data.tool_name || '', arguments: args,
|
|
376
|
-
decision: 'DENY', reason: msg,
|
|
377
|
-
source: 'claude-code-guard',
|
|
378
|
-
}),
|
|
379
|
-
signal: AbortSignal.timeout(3000),
|
|
380
|
-
});
|
|
381
|
-
} catch {}
|
|
382
|
-
}
|
|
383
|
-
process.stderr.write(msg);
|
|
384
|
-
process.exit(2);
|
|
446
|
+
await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches protected "' + globHit + '" — blocked');
|
|
447
|
+
}
|
|
448
|
+
// Variable assignment targeting protected paths
|
|
449
|
+
const varHit = checkVarAssignments(s);
|
|
450
|
+
if (varHit) {
|
|
451
|
+
await blockSelfProtection('SOLONGATE: Variable assignment targets protected "' + varHit + '" — blocked');
|
|
385
452
|
}
|
|
386
453
|
}
|
|
387
454
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solongate/proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "MCP security proxy — protect any MCP server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|