@solongate/proxy 0.47.1 → 0.47.3
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/README.md +53 -68
- package/dist/index.js +4 -1
- package/dist/login.js +4 -1
- package/hooks/guard.bundled.mjs +7536 -7334
- package/hooks/guard.mjs +141 -21
- package/package.json +1 -1
package/hooks/guard.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { createHash } from 'node:crypto';
|
|
|
31
31
|
// the installed hook self-updates when the cloud version is higher (see
|
|
32
32
|
// maybeSelfUpdate). This is what makes guard fixes propagate without a manual
|
|
33
33
|
// reinstall — the same trust model as the OPA WASM this hook already runs.
|
|
34
|
-
const HOOK_VERSION =
|
|
34
|
+
const HOOK_VERSION = 14;
|
|
35
35
|
|
|
36
36
|
// Safe file read with size limit (1MB max) to prevent DoS via large files
|
|
37
37
|
const MAX_FILE_READ = 1024 * 1024; // 1MB
|
|
@@ -526,6 +526,7 @@ const TAMPER_PROTECTED_GLOBS = [
|
|
|
526
526
|
'**' + TAMPER_SG + '/.pi-config-cache.json',
|
|
527
527
|
'**' + TAMPER_SG + '/cloud-guard.json',
|
|
528
528
|
'**' + TAMPER_SG + '/.opa-wasm-*.json',
|
|
529
|
+
'**' + TAMPER_SG + '/.ratelimit-*.json',
|
|
529
530
|
// Persistent host data (DB + audit JSONL) at ~/.solongate/data
|
|
530
531
|
'**' + TAMPER_SG + '/data/**',
|
|
531
532
|
// Customer install layout (zip extracted as solongate/)
|
|
@@ -626,6 +627,107 @@ function tamperCheck(toolName, args) {
|
|
|
626
627
|
return null;
|
|
627
628
|
}
|
|
628
629
|
|
|
630
|
+
// ── Extra security layers (rate limit, egress allowlist, DLP block) ──
|
|
631
|
+
// These are configured per-project in the dashboard and delivered to the guard
|
|
632
|
+
// via /policies/active (security). All fail OPEN: any error here returns null
|
|
633
|
+
// (allow) so a config glitch never bricks the agent. Tamper protection and
|
|
634
|
+
// policy are unaffected and still run.
|
|
635
|
+
|
|
636
|
+
// DLP patterns mirror the server's set (apps/api/src/lib/security-layers.ts).
|
|
637
|
+
// Mirrors apps/api/src/lib/security-layers.ts DLP_PATTERNS. Provider-specific
|
|
638
|
+
// rules plus generic Bearer / secret-assignment catch-alls for the long tail.
|
|
639
|
+
const DLP_PATTERNS = [
|
|
640
|
+
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
|
|
641
|
+
{ name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
642
|
+
{ name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
643
|
+
{ name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/ },
|
|
644
|
+
{ name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
|
|
645
|
+
{ name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
|
|
646
|
+
{ name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
|
|
647
|
+
{ name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
648
|
+
{ name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/ },
|
|
649
|
+
{ name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
|
|
650
|
+
{ name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
|
|
651
|
+
{ name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/ },
|
|
652
|
+
{ name: 'npm token', re: /npm_[A-Za-z0-9]{36}/ },
|
|
653
|
+
{ name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
654
|
+
{ name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
|
|
655
|
+
{ name: 'secret assignment', re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/i },
|
|
656
|
+
];
|
|
657
|
+
|
|
658
|
+
// Scan args against the enabled built-in patterns + any user custom patterns.
|
|
659
|
+
// `cfg` = { patterns: string[], custom: {name,re}[] }.
|
|
660
|
+
function dlpScan(args, cfg) {
|
|
661
|
+
if (!cfg) return null;
|
|
662
|
+
let text = '';
|
|
663
|
+
try { text = JSON.stringify(args || {}); } catch { return null; }
|
|
664
|
+
const allow = new Set(Array.isArray(cfg.patterns) ? cfg.patterns : []);
|
|
665
|
+
for (const p of DLP_PATTERNS) {
|
|
666
|
+
if (allow.has(p.name) && p.re.test(text)) return p.name;
|
|
667
|
+
}
|
|
668
|
+
for (const c of Array.isArray(cfg.custom) ? cfg.custom : []) {
|
|
669
|
+
try { if (new RegExp(c.re).test(text)) return c.name || 'custom pattern'; } catch { /* skip invalid */ }
|
|
670
|
+
}
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Multi-window sliding rate limit, persisted under ~/.solongate (tamper-protected
|
|
675
|
+
// from the agent, writable by the guard). One timestamps file per agent, pruned
|
|
676
|
+
// to the last 24h and capped for performance; counts this agent's calls within
|
|
677
|
+
// each enabled window (minute/hour/day). Returns the exceeded window or null.
|
|
678
|
+
const RL_WINDOWS = [
|
|
679
|
+
{ key: 'perDay', ms: 86400000, label: 'day' },
|
|
680
|
+
{ key: 'perHour', ms: 3600000, label: 'hour' },
|
|
681
|
+
{ key: 'perMinute', ms: 60000, label: 'minute' },
|
|
682
|
+
];
|
|
683
|
+
function rateLimitCheck(agentKey, limits) {
|
|
684
|
+
try {
|
|
685
|
+
const file = join(resolve(homedir(), '.solongate'), '.ratelimit-' + agentKey + '.json');
|
|
686
|
+
const now = Date.now();
|
|
687
|
+
let stamps = [];
|
|
688
|
+
if (existsSync(file)) {
|
|
689
|
+
try { stamps = JSON.parse(readFileSync(file, 'utf-8')); } catch { stamps = []; }
|
|
690
|
+
}
|
|
691
|
+
if (!Array.isArray(stamps)) stamps = [];
|
|
692
|
+
// Prune to the longest window (24h) and cap size to bound work/IO.
|
|
693
|
+
stamps = stamps.filter((t) => typeof t === 'number' && now - t < 86400000);
|
|
694
|
+
if (stamps.length > 50000) stamps = stamps.slice(-50000);
|
|
695
|
+
// Check each enabled window against the current count (before adding now).
|
|
696
|
+
for (const w of RL_WINDOWS) {
|
|
697
|
+
const limit = limits[w.key];
|
|
698
|
+
if (limit > 0) {
|
|
699
|
+
const count = stamps.reduce((n, t) => (now - t < w.ms ? n + 1 : n), 0);
|
|
700
|
+
if (count >= limit) return { window: w.label, limit };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
stamps.push(now);
|
|
704
|
+
try { writeFileSync(file, JSON.stringify(stamps)); } catch {}
|
|
705
|
+
return null;
|
|
706
|
+
} catch {
|
|
707
|
+
return null; // fail open
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Runs all enabled enforcement layers; returns a deny reason or null (allow).
|
|
712
|
+
function securityLayerCheck(toolName, args, cfg, agentKey) {
|
|
713
|
+
if (!cfg) return null;
|
|
714
|
+
try {
|
|
715
|
+
if (cfg.dlpBlock) {
|
|
716
|
+
const hit = dlpScan(args, cfg.dlpBlock);
|
|
717
|
+
if (hit) return 'Security layer (DLP): blocked - arguments contain a ' + hit +
|
|
718
|
+
'. Blocked by SolonGate - check your dashboard for details.';
|
|
719
|
+
}
|
|
720
|
+
if (cfg.rateLimit) {
|
|
721
|
+
const hit = rateLimitCheck(agentKey, cfg.rateLimit);
|
|
722
|
+
if (hit) {
|
|
723
|
+
return 'Security layer (rate limit): exceeded ' + hit.limit + ' calls/' + hit.window +
|
|
724
|
+
' for this agent. Blocked by SolonGate - check your dashboard to review or adjust the limit.';
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} catch { /* fail open */ }
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
|
|
629
731
|
// ── Policy Evaluation ──
|
|
630
732
|
|
|
631
733
|
// Permission filter: a rule with rule.permission set only applies to tool
|
|
@@ -1055,6 +1157,13 @@ process.stdin.on('end', async () => {
|
|
|
1055
1157
|
// fallback for when the API is unreachable.
|
|
1056
1158
|
const hookCwd = data.cwd || process.cwd();
|
|
1057
1159
|
let policy;
|
|
1160
|
+
// Self-protection (tamper guard) defaults ON. The cloud per-project setting
|
|
1161
|
+
// can turn it off; delivered via /policies/active and cached alongside the
|
|
1162
|
+
// policy. Any failure to read it leaves protection ON (fail safe).
|
|
1163
|
+
let selfProtectEnabled = true;
|
|
1164
|
+
// Extra security layers (rate limit, egress, DLP block) delivered by the
|
|
1165
|
+
// cloud. Null = none configured. Fail open if unread.
|
|
1166
|
+
let securityCfg = null;
|
|
1058
1167
|
// Cache keyed by agent_id so different agents in different terminals
|
|
1059
1168
|
// don't share a stale cached policy.
|
|
1060
1169
|
const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
@@ -1066,21 +1175,26 @@ process.stdin.on('end', async () => {
|
|
|
1066
1175
|
try {
|
|
1067
1176
|
if (existsSync(policyCacheFile)) {
|
|
1068
1177
|
const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
|
|
1069
|
-
if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS
|
|
1070
|
-
dashboardPolicy = cached.policy;
|
|
1178
|
+
if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
|
|
1179
|
+
if (cached.policy) dashboardPolicy = cached.policy;
|
|
1180
|
+
if (typeof cached.selfProtect === 'boolean') selfProtectEnabled = cached.selfProtect;
|
|
1181
|
+
if (cached.security !== undefined) securityCfg = cached.security;
|
|
1071
1182
|
}
|
|
1072
1183
|
}
|
|
1073
1184
|
} catch {}
|
|
1074
1185
|
// Refresh from API if cache expired
|
|
1075
1186
|
if (!dashboardPolicy) {
|
|
1076
1187
|
try {
|
|
1077
|
-
|
|
1188
|
+
// Report our installed version (hv) so the dashboard can show whether
|
|
1189
|
+
// this guard is on the latest build.
|
|
1190
|
+
const res = await fetch(API_URL + '/api/v1/policies/active?agent_id=' + encodeURIComponent(AGENT_ID || '') + '&hv=' + HOOK_VERSION, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8000) });
|
|
1078
1191
|
if (res.ok) {
|
|
1079
1192
|
const body = await res.json();
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1193
|
+
// Capture the self-protection flag even when no cloud policy is set.
|
|
1194
|
+
if (typeof body?.self_protection_enabled === 'boolean') selfProtectEnabled = body.self_protection_enabled;
|
|
1195
|
+
if (body?.security !== undefined) securityCfg = body.security;
|
|
1196
|
+
if (body && body.policy) dashboardPolicy = body.policy;
|
|
1197
|
+
try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg })); } catch {}
|
|
1084
1198
|
}
|
|
1085
1199
|
} catch {}
|
|
1086
1200
|
}
|
|
@@ -1121,8 +1235,12 @@ process.stdin.on('end', async () => {
|
|
|
1121
1235
|
|
|
1122
1236
|
if (process.env.SOLONGATE_DEBUG) {
|
|
1123
1237
|
}
|
|
1124
|
-
//
|
|
1125
|
-
|
|
1238
|
+
// Tamper / self-protection — runs before policy eval. ON by default; the
|
|
1239
|
+
// per-project cloud setting can disable it (fail safe: stays on if unread).
|
|
1240
|
+
let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
|
|
1241
|
+
// Extra security layers run after tamper, before policy. Block reason wins
|
|
1242
|
+
// immediately (BLACK). Fail-open by design.
|
|
1243
|
+
if (!reason) reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
|
|
1126
1244
|
if (process.env.SOLONGATE_DEBUG) {
|
|
1127
1245
|
}
|
|
1128
1246
|
// OPA WASM is the SOLE policy engine. With no policy configured for this
|
|
@@ -1143,17 +1261,19 @@ process.stdin.on('end', async () => {
|
|
|
1143
1261
|
} else if (policy && policy.rules) {
|
|
1144
1262
|
const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
|
|
1145
1263
|
if (opaResult === undefined) {
|
|
1146
|
-
// OPA produced no decision (no WASM bundle, runtime missing, fetch
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
|
|
1264
|
+
// OPA produced no decision (no WASM bundle yet, runtime missing, fetch
|
|
1265
|
+
// error). This happens on COLD START — the first call(s) in a session
|
|
1266
|
+
// before the policy + WASM are cached. Don't leave an enforcement gap:
|
|
1267
|
+
// run the deterministic, WASM-free JS evaluator so DENY rules (e.g.
|
|
1268
|
+
// secret-file protection) and whitelist defaults apply IMMEDIATELY, from
|
|
1269
|
+
// the very first call. evaluate() implements both modes:
|
|
1270
|
+
// - returns a deny reason → block (DENY match, or whitelist no-match)
|
|
1271
|
+
// - returns null → allow (denylist default / whitelist match)
|
|
1272
|
+
// This closes the "worked, but late" window where a denylist policy used
|
|
1273
|
+
// to fail OPEN until WASM warmed up.
|
|
1274
|
+
const legacy = evaluate(policy, args, toolName);
|
|
1275
|
+
if (typeof legacy === 'string') {
|
|
1276
|
+
reason = legacy;
|
|
1157
1277
|
opaRoute = 'black';
|
|
1158
1278
|
} else {
|
|
1159
1279
|
opaRoute = 'white';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solongate/proxy",
|
|
3
|
-
"version": "0.47.
|
|
3
|
+
"version": "0.47.3",
|
|
4
4
|
"description": "AI tool security proxy — protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|