@node9/proxy 2.8.5 → 2.9.1

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/dist/index.mjs CHANGED
@@ -133,13 +133,21 @@ function filePathFromArgs(args) {
133
133
  return void 0;
134
134
  }
135
135
  function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
136
- const isDlpRow = checkedBy.toLowerCase().includes("dlp") || Boolean(meta?.dlpPattern) || /dlp|taint/i.test(String(meta?.ruleName ?? ""));
136
+ const isDlpRow = checkedBy.toLowerCase().includes("dlp") || checkedBy.toLowerCase().includes("pii") || Boolean(meta?.dlpPattern) || Boolean(meta?.piiPatterns) || Boolean(meta?.canaryId) || /dlp|taint/i.test(String(meta?.ruleName ?? ""));
137
137
  const preview = auditHashArgsEnabled && !isDlpRow ? buildArgsPreview(args) : void 0;
138
138
  const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args), ...preview ? { argsPreview: preview } : {} } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
139
139
  const testRun = isTestCall(toolName, args) || process.env.NODE9_TESTING === "1" ? { testRun: true } : {};
140
140
  const ruleNameField = meta?.ruleName ? { ruleName: meta.ruleName } : {};
141
141
  const agentToolNameField = meta?.agentToolName ? { agentToolName: meta.agentToolName } : {};
142
142
  const dlpFields = meta?.dlpPattern ? { dlpPattern: meta.dlpPattern, dlpSample: meta.dlpSample } : {};
143
+ const canaryFields = meta?.canaryId ? {
144
+ canaryId: meta.canaryId,
145
+ ...meta.canaryHash && { canaryHash: meta.canaryHash },
146
+ ...meta.canaryKind && { canaryKind: meta.canaryKind },
147
+ ...meta.canaryPath && { canaryPath: meta.canaryPath },
148
+ ...meta.canaryView && { canaryView: meta.canaryView },
149
+ ...meta.canaryRetired && { canaryRetired: true }
150
+ } : {};
143
151
  const cloudLinkField = meta?.cloudRequestId ? { cloudRequestId: meta.cloudRequestId } : {};
144
152
  const workingDirField = meta?.workingDir ? { workingDir: meta.workingDir } : {};
145
153
  const shell = process.env.SHELL ? path.basename(process.env.SHELL) : void 0;
@@ -165,6 +173,7 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
165
173
  checkedBy,
166
174
  ...ruleNameField,
167
175
  ...dlpFields,
176
+ ...canaryFields,
168
177
  ...cloudLinkField,
169
178
  ...workingDirField,
170
179
  ...shellTypeField,
@@ -342,7 +351,11 @@ var ConfigFileSchema = z.object({
342
351
  mode: z.enum(["off", "review", "block"]).optional(),
343
352
  allow: z.array(z.string()).optional(),
344
353
  deny: z.array(z.string()).optional(),
345
- allowPrivate: z.boolean().optional()
354
+ allowPrivate: z.boolean().optional(),
355
+ // SSRF floor. `ssrfAllow` exempts OVERRIDABLE tiers only; a tier-1
356
+ // entry is dropped with a warning at load, never silently honoured.
357
+ ssrfAllow: z.array(z.string()).optional(),
358
+ ssrfStrict: z.boolean().optional()
346
359
  }).optional(),
347
360
  loopDetection: z.object({
348
361
  enabled: z.boolean().optional(),
@@ -379,8 +392,8 @@ function sanitizeConfig(raw) {
379
392
  }
380
393
  }
381
394
  const lines = result.error.issues.map((issue) => {
382
- const path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
383
- return ` \u2022 ${path14}: ${issue.message}`;
395
+ const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
396
+ return ` \u2022 ${path15}: ${issue.message}`;
384
397
  });
385
398
  return {
386
399
  sanitized,
@@ -396,11 +409,178 @@ import os2 from "os";
396
409
 
397
410
  // packages/policy-engine/dist/index.mjs
398
411
  import safeRegex from "safe-regex2";
412
+ import { createHash as createHash2 } from "crypto";
399
413
  import mvdanSh from "mvdan-sh";
400
414
  import pm from "picomatch";
401
415
  import safeRegex2 from "safe-regex2";
402
416
  import safeRegex3 from "safe-regex2";
403
417
  import crypto2 from "crypto";
418
+ function validateLuhn(digits) {
419
+ if (!/^\d+$/.test(digits)) return false;
420
+ if (digits.length < 12) return false;
421
+ if (!/[1-9]/.test(digits)) return false;
422
+ let sum = 0;
423
+ let double = false;
424
+ for (let i = digits.length - 1; i >= 0; i--) {
425
+ let d = digits.charCodeAt(i) - 48;
426
+ if (double) {
427
+ d *= 2;
428
+ if (d > 9) d -= 9;
429
+ }
430
+ sum += d;
431
+ double = !double;
432
+ }
433
+ return sum % 10 === 0;
434
+ }
435
+ var IBAN_LENGTH = {
436
+ AD: 24,
437
+ AE: 23,
438
+ AL: 28,
439
+ AT: 20,
440
+ AZ: 28,
441
+ BA: 20,
442
+ BE: 16,
443
+ BG: 22,
444
+ BH: 22,
445
+ BI: 27,
446
+ BR: 29,
447
+ BY: 28,
448
+ CH: 21,
449
+ CR: 22,
450
+ CY: 28,
451
+ CZ: 24,
452
+ DE: 22,
453
+ DJ: 27,
454
+ DK: 18,
455
+ DO: 28,
456
+ EE: 20,
457
+ EG: 29,
458
+ ES: 24,
459
+ FI: 18,
460
+ FK: 18,
461
+ FO: 18,
462
+ FR: 27,
463
+ GB: 22,
464
+ GE: 22,
465
+ GI: 23,
466
+ GL: 18,
467
+ GR: 27,
468
+ GT: 28,
469
+ HN: 28,
470
+ HR: 21,
471
+ HU: 28,
472
+ IE: 22,
473
+ IL: 23,
474
+ IQ: 23,
475
+ IS: 26,
476
+ IT: 27,
477
+ JO: 30,
478
+ KW: 30,
479
+ KZ: 20,
480
+ LB: 28,
481
+ LC: 32,
482
+ LI: 21,
483
+ LT: 20,
484
+ LU: 20,
485
+ LV: 21,
486
+ LY: 25,
487
+ MC: 27,
488
+ MD: 24,
489
+ ME: 22,
490
+ MK: 19,
491
+ MN: 20,
492
+ MR: 27,
493
+ MT: 31,
494
+ MU: 30,
495
+ NI: 28,
496
+ NL: 18,
497
+ NO: 15,
498
+ OM: 23,
499
+ PK: 24,
500
+ PL: 28,
501
+ PS: 29,
502
+ PT: 25,
503
+ QA: 29,
504
+ RO: 24,
505
+ RS: 22,
506
+ RU: 33,
507
+ SA: 24,
508
+ SC: 31,
509
+ SD: 18,
510
+ SE: 24,
511
+ SI: 19,
512
+ SK: 24,
513
+ SM: 27,
514
+ SN: 28,
515
+ SO: 23,
516
+ ST: 25,
517
+ SV: 28,
518
+ TL: 23,
519
+ TN: 24,
520
+ TR: 26,
521
+ UA: 29,
522
+ VA: 22,
523
+ VG: 24,
524
+ XK: 20,
525
+ YE: 30
526
+ };
527
+ function validateIban(raw) {
528
+ const s = raw.replace(/[ -]/g, "").toUpperCase();
529
+ if (!/^[A-Z]{2}\d{2}/.test(s)) return false;
530
+ const want = IBAN_LENGTH[s.slice(0, 2)];
531
+ if (want === void 0 || s.length < want) return false;
532
+ const iban = s.slice(0, want);
533
+ if (!/^[A-Z0-9]+$/.test(iban)) return false;
534
+ const rearranged = iban.slice(4) + iban.slice(0, 4);
535
+ let mod = 0;
536
+ for (const ch of rearranged) {
537
+ const code = ch.charCodeAt(0);
538
+ const digits = code >= 65 ? String(code - 55) : ch;
539
+ for (const d of digits) mod = (mod * 10 + (d.charCodeAt(0) - 48)) % 97;
540
+ }
541
+ return mod === 1;
542
+ }
543
+ var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
544
+ var B58_INDEX = Object.fromEntries(
545
+ [...B58].map((c, i) => [c, i])
546
+ );
547
+ function validateBase58Check(s) {
548
+ if (!s) return null;
549
+ let n = 0n;
550
+ for (const c of s) {
551
+ const v = B58_INDEX[c];
552
+ if (v === void 0) return null;
553
+ n = n * 58n + BigInt(v);
554
+ }
555
+ let hex = n.toString(16);
556
+ if (hex.length % 2) hex = "0" + hex;
557
+ let zeros = 0;
558
+ for (const c of s) {
559
+ if (c !== "1") break;
560
+ zeros++;
561
+ }
562
+ const bytes = Buffer.concat([
563
+ Buffer.alloc(zeros),
564
+ n === 0n ? Buffer.alloc(0) : Buffer.from(hex, "hex")
565
+ ]);
566
+ if (bytes.length < 5) return null;
567
+ const body = bytes.subarray(0, bytes.length - 4);
568
+ const check = bytes.subarray(bytes.length - 4);
569
+ const h = createHash2("sha256").update(createHash2("sha256").update(body).digest()).digest();
570
+ return h.subarray(0, 4).equals(check) ? body : null;
571
+ }
572
+ function validateWif(s) {
573
+ const p = validateBase58Check(s);
574
+ if (!p || p[0] !== 128) return false;
575
+ return p.length === 33 || p.length === 34 && p[33] === 1;
576
+ }
577
+ var XPRV_VERSIONS = /* @__PURE__ */ new Set([76066276, 77428856, 78791436]);
578
+ function validateXprv(s) {
579
+ const p = validateBase58Check(s);
580
+ if (!p || p.length !== 78) return false;
581
+ const version = p.readUInt32BE(0);
582
+ return XPRV_VERSIONS.has(version) && p[45] === 0;
583
+ }
404
584
  var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
405
585
  function isAssignmentContext(text) {
406
586
  return ASSIGNMENT_CONTEXT_RE.test(text);
@@ -593,6 +773,27 @@ var DLP_PATTERNS = [
593
773
  severity: "block",
594
774
  keywords: ["sg."]
595
775
  },
776
+ // ── Cryptocurrency private keys (base58check-validated) ───────────────────
777
+ // Both are anchored with \b on each side: unanchored, `[KL][base58]{51}`
778
+ // matches INSIDE any longer base58 blob (an xprv, a Solana keypair, a
779
+ // Monero address). Lookbehind fails safe-regex2; \b is the house style
780
+ // (see the card regexes). Mainnet only, matching validateWif / validateXprv;
781
+ // testnet (WIF 0xEF, tprv) is deferred. Cost was measured: the WIF regex
782
+ // runs on every string (first keyword-less pattern) at 0.024 ms per 100 KB
783
+ // of prose, so no prefilter is warranted.
784
+ {
785
+ name: "Bitcoin WIF Private Key",
786
+ regex: /\b(?:5[1-9A-HJ-NP-Za-km-z]{50}|[KL][1-9A-HJ-NP-Za-km-z]{51})\b/,
787
+ severity: "block",
788
+ validate: validateWif
789
+ },
790
+ {
791
+ name: "Extended Private Key",
792
+ regex: /\b[xyz]prv[1-9A-HJ-NP-Za-km-z]{107}\b/,
793
+ severity: "block",
794
+ keywords: ["xprv", "yprv", "zprv"],
795
+ validate: validateXprv
796
+ },
596
797
  // ── Private keys (PEM) ────────────────────────────────────────────────────
597
798
  {
598
799
  name: "Private Key (PEM)",
@@ -949,6 +1150,33 @@ function maskSecret(raw, pattern) {
949
1150
  var MAX_DEPTH = 5;
950
1151
  var MAX_STRING_BYTES = 1e5;
951
1152
  var MAX_JSON_PARSE_BYTES = 1e4;
1153
+ function suppressed(pattern, raw) {
1154
+ if (pattern.validate) {
1155
+ let ok;
1156
+ try {
1157
+ ok = pattern.validate(raw);
1158
+ } catch {
1159
+ ok = true;
1160
+ }
1161
+ return !ok;
1162
+ }
1163
+ if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) return true;
1164
+ if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) return true;
1165
+ return false;
1166
+ }
1167
+ function firstAcceptedMatch(pattern, text) {
1168
+ const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : pattern.regex.flags + "g";
1169
+ const re = new RegExp(pattern.regex.source, flags);
1170
+ let m;
1171
+ while ((m = re.exec(text)) !== null) {
1172
+ if (m[0].length === 0) {
1173
+ re.lastIndex = m.index + 1;
1174
+ continue;
1175
+ }
1176
+ if (!suppressed(pattern, m[0])) return m[0];
1177
+ }
1178
+ return null;
1179
+ }
952
1180
  function scanArgs(args, depth = 0, fieldPath = "args") {
953
1181
  if (depth > MAX_DEPTH || args === null || args === void 0) return null;
954
1182
  if (Array.isArray(args)) {
@@ -973,18 +1201,16 @@ function scanArgs(args, depth = 0, fieldPath = "args") {
973
1201
  if (pattern.keywords && !pattern.keywords.some((kw) => textLower.includes(kw.toLowerCase()))) {
974
1202
  continue;
975
1203
  }
976
- if (pattern.regex.test(text)) {
977
- const raw = text.match(pattern.regex)?.[0] ?? "";
978
- if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) continue;
979
- if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) continue;
980
- const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
981
- return {
982
- patternName: pattern.name,
983
- fieldPath,
984
- redactedSample: maskSecret(text, pattern.regex),
985
- severity
986
- };
987
- }
1204
+ const raw = firstAcceptedMatch(pattern, text);
1205
+ if (raw === null) continue;
1206
+ const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
1207
+ return {
1208
+ patternName: pattern.name,
1209
+ fieldPath,
1210
+ // Mask the ACCEPTED token, not the first regex hit in the field.
1211
+ redactedSample: maskSecret(raw, pattern.regex),
1212
+ severity
1213
+ };
988
1214
  }
989
1215
  if (text.length < MAX_JSON_PARSE_BYTES) {
990
1216
  const trimmed = text.trim();
@@ -1048,13 +1274,13 @@ function matchesPattern(text, patterns) {
1048
1274
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1049
1275
  }
1050
1276
  var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1051
- function getNestedValue(obj, path14) {
1277
+ function getNestedValue(obj, path15) {
1052
1278
  if (!obj || typeof obj !== "object") return null;
1053
- const segments = path14.split(".");
1054
- for (const seg of segments) {
1279
+ const segments2 = path15.split(".");
1280
+ for (const seg of segments2) {
1055
1281
  if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
1056
1282
  }
1057
- return segments.reduce((prev, curr) => prev?.[curr], obj);
1283
+ return segments2.reduce((prev, curr) => prev?.[curr], obj);
1058
1284
  }
1059
1285
  function evaluateSmartConditions(args, rule) {
1060
1286
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -2022,6 +2248,50 @@ function extractShellDestinations(command) {
2022
2248
  }
2023
2249
  return out;
2024
2250
  }
2251
+ function extractShellDestTokens(command) {
2252
+ const f = parseShared(command);
2253
+ if (f === PARSE_FAIL) return [];
2254
+ const out = [];
2255
+ const seen = /* @__PURE__ */ new Set();
2256
+ try {
2257
+ syntax.Walk(f, (node) => {
2258
+ if (!node) return false;
2259
+ const n = node;
2260
+ if (syntax.NodeType(n) !== "CallExpr") return true;
2261
+ const callArgs = n.Args || [];
2262
+ if (callArgs.length === 0) return true;
2263
+ const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
2264
+ if (!NET_BINARIES.has(name)) return true;
2265
+ const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
2266
+ for (const raw of destTokensForBinary(name, rest)) {
2267
+ if (!raw) continue;
2268
+ let tok = raw.trim();
2269
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(tok);
2270
+ const hasScheme = scheme !== null;
2271
+ if (hasScheme) tok = tok.slice(scheme[0].length);
2272
+ tok = tok.split(/[/?#]/)[0];
2273
+ const at = tok.lastIndexOf("@");
2274
+ if (at >= 0) tok = tok.slice(at + 1);
2275
+ if (tok.startsWith("[")) {
2276
+ const close = tok.indexOf("]");
2277
+ if (close > 0) tok = tok.slice(0, close + 1);
2278
+ } else {
2279
+ tok = tok.split(":")[0];
2280
+ }
2281
+ if (!tok) continue;
2282
+ if (!hasScheme && /^\d+$/.test(tok) && Number(tok) < 16777216) continue;
2283
+ const key = `${name}:${tok}`;
2284
+ if (seen.has(key)) continue;
2285
+ seen.add(key);
2286
+ out.push({ token: tok, binary: name });
2287
+ }
2288
+ return true;
2289
+ });
2290
+ } catch {
2291
+ return out;
2292
+ }
2293
+ return out;
2294
+ }
2025
2295
  var FS_OP_CACHE_MAX = 5e3;
2026
2296
  var fsOpCache = /* @__PURE__ */ new Map();
2027
2297
  function analyzeFsOperation(command) {
@@ -2226,8 +2496,8 @@ function analyzeShellCommand(command) {
2226
2496
  if (allTokens.length === 0) {
2227
2497
  const normalized = command.replace(/\\(.)/g, "$1");
2228
2498
  const sanitized = normalized.replace(/["'<>]/g, " ");
2229
- const segments = sanitized.split(/[|;&]|\$\(|\)|`/);
2230
- segments.forEach((segment) => {
2499
+ const segments2 = sanitized.split(/[|;&]|\$\(|\)|`/);
2500
+ segments2.forEach((segment) => {
2231
2501
  const tokens = segment.trim().split(/\s+/).filter(Boolean);
2232
2502
  if (tokens.length > 0) {
2233
2503
  const action = tokens[0].toLowerCase();
@@ -2390,7 +2660,7 @@ function isSensitivePath(p) {
2390
2660
  return SENSITIVE_PATTERNS.some((re) => re.test(p));
2391
2661
  }
2392
2662
  function splitOnPipe(cmd) {
2393
- const segments = [];
2663
+ const segments2 = [];
2394
2664
  let current = "";
2395
2665
  let inSingle = false;
2396
2666
  let inDouble = false;
@@ -2403,21 +2673,21 @@ function splitOnPipe(cmd) {
2403
2673
  inDouble = !inDouble;
2404
2674
  current += ch;
2405
2675
  } else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
2406
- segments.push(current.trim());
2676
+ segments2.push(current.trim());
2407
2677
  current = "";
2408
2678
  } else {
2409
2679
  current += ch;
2410
2680
  }
2411
2681
  }
2412
- if (current.trim()) segments.push(current.trim());
2413
- return segments.filter(Boolean);
2682
+ if (current.trim()) segments2.push(current.trim());
2683
+ return segments2.filter(Boolean);
2414
2684
  }
2415
2685
  function positionalTokens(segment) {
2416
2686
  return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2417
2687
  }
2418
2688
  function analyzePipeChain(command) {
2419
- const segments = splitOnPipe(command);
2420
- if (segments.length < 2) {
2689
+ const segments2 = splitOnPipe(command);
2690
+ if (segments2.length < 2) {
2421
2691
  return {
2422
2692
  isPipeline: false,
2423
2693
  hasSensitiveSource: false,
@@ -2433,7 +2703,7 @@ function analyzePipeChain(command) {
2433
2703
  let hasSensitiveSource = false;
2434
2704
  let hasExternalSink = false;
2435
2705
  let hasObfuscation = false;
2436
- for (const segment of segments) {
2706
+ for (const segment of segments2) {
2437
2707
  const tokens = segment.split(/\s+/).filter(Boolean);
2438
2708
  if (tokens.length === 0) continue;
2439
2709
  const binary = tokens[0].toLowerCase();
@@ -2471,8 +2741,8 @@ function analyzePipeChain(command) {
2471
2741
  };
2472
2742
  }
2473
2743
  function basename(p) {
2474
- const segments = p.split(/[\\/]/);
2475
- return segments[segments.length - 1] || "";
2744
+ const segments2 = p.split(/[\\/]/);
2745
+ return segments2[segments2.length - 1] || "";
2476
2746
  }
2477
2747
  var FLAGS_WITH_VALUES = {
2478
2748
  curl: /* @__PURE__ */ new Set([
@@ -2635,6 +2905,204 @@ function extractAllSshHosts(tokens) {
2635
2905
  }
2636
2906
  return [...hosts].filter(Boolean);
2637
2907
  }
2908
+ var SSRF_MAX_HOST = 253;
2909
+ function parseComponent(s) {
2910
+ if (!s) return null;
2911
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
2912
+ if (s === "0") return 0;
2913
+ if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
2914
+ if (/^[1-9][0-9]*$/.test(s)) return Number(s);
2915
+ return null;
2916
+ }
2917
+ function parseIpv4(input) {
2918
+ let s = input;
2919
+ if (s.endsWith(".")) s = s.slice(0, -1);
2920
+ if (!s) return null;
2921
+ const parts = s.split(".");
2922
+ if (parts.length > 4) return null;
2923
+ const vals = [];
2924
+ for (const p of parts) {
2925
+ const v = parseComponent(p);
2926
+ if (v === null || !Number.isFinite(v) || v < 0) return null;
2927
+ vals.push(v);
2928
+ }
2929
+ const n = vals.length;
2930
+ for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
2931
+ const last = vals[n - 1];
2932
+ const remainingBytes = 4 - (n - 1);
2933
+ const limit = Math.pow(256, remainingBytes);
2934
+ if (last >= limit) return null;
2935
+ let value = last;
2936
+ for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
2937
+ if (value > 4294967295) return null;
2938
+ return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
2939
+ }
2940
+ function expandIpv6(input) {
2941
+ const s = input.toLowerCase();
2942
+ if (!/^[0-9a-f:.]+$/.test(s)) return null;
2943
+ if ((s.match(/::/g) ?? []).length > 1) return null;
2944
+ let head = s;
2945
+ let tailV4 = null;
2946
+ const lastColon = s.lastIndexOf(":");
2947
+ const afterLast = s.slice(lastColon + 1);
2948
+ if (afterLast.includes(".")) {
2949
+ const dotted = parseIpv4(afterLast);
2950
+ if (!dotted) return null;
2951
+ const o = dotted.split(".").map(Number);
2952
+ tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
2953
+ head = s.slice(0, lastColon + 1) + "0";
2954
+ }
2955
+ const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
2956
+ const toGroups = (part) => {
2957
+ if (!part) return [];
2958
+ const out = [];
2959
+ for (const g of part.split(":")) {
2960
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
2961
+ out.push(parseInt(g, 16));
2962
+ }
2963
+ return out;
2964
+ };
2965
+ const left = toGroups(lhs);
2966
+ if (left === null) return null;
2967
+ let right = [];
2968
+ if (rhs !== null) {
2969
+ const r = toGroups(rhs);
2970
+ if (r === null) return null;
2971
+ right = r;
2972
+ }
2973
+ if (tailV4) {
2974
+ if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
2975
+ else left.splice(left.length - 1, 1, ...tailV4);
2976
+ }
2977
+ const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
2978
+ if (rhs === null && groups.length !== 8) return null;
2979
+ if (rhs !== null && left.length + right.length > 8) return null;
2980
+ if (groups.length !== 8) return null;
2981
+ return groups;
2982
+ }
2983
+ function compressIpv6(g) {
2984
+ let bestStart = -1;
2985
+ let bestLen = 0;
2986
+ let i = 0;
2987
+ while (i < 8) {
2988
+ if (g[i] !== 0) {
2989
+ i++;
2990
+ continue;
2991
+ }
2992
+ let j = i;
2993
+ while (j < 8 && g[j] === 0) j++;
2994
+ if (j - i > bestLen) {
2995
+ bestLen = j - i;
2996
+ bestStart = i;
2997
+ }
2998
+ i = j;
2999
+ }
3000
+ const hex = g.map((x) => x.toString(16));
3001
+ if (bestLen < 2) return hex.join(":");
3002
+ return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
3003
+ }
3004
+ function normalizeIpLiteral(host) {
3005
+ try {
3006
+ if (typeof host !== "string") return null;
3007
+ let s = host.trim();
3008
+ if (!s || s.length > SSRF_MAX_HOST) return null;
3009
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
3010
+ const zone = s.indexOf("%");
3011
+ if (zone >= 0) s = s.slice(0, zone);
3012
+ if (!s) return null;
3013
+ if (s.includes(":")) {
3014
+ const g = expandIpv6(s);
3015
+ if (!g) return null;
3016
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
3017
+ if (mapped) {
3018
+ return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
3019
+ }
3020
+ return compressIpv6(g);
3021
+ }
3022
+ return parseIpv4(s);
3023
+ } catch {
3024
+ return null;
3025
+ }
3026
+ }
3027
+ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
3028
+ "169.254.169.254",
3029
+ // AWS / Azure / DigitalOcean / OpenStack IMDS
3030
+ "169.254.170.2",
3031
+ // AWS ECS task role
3032
+ "168.63.129.16",
3033
+ // Azure WireServer
3034
+ "fd00:ec2::254"
3035
+ // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
3036
+ ]);
3037
+ var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
3038
+ var v4Octets = (a) => {
3039
+ const p = a.split(".");
3040
+ return p.length === 4 ? p.map(Number) : null;
3041
+ };
3042
+ function classifySsrf(host) {
3043
+ try {
3044
+ if (typeof host !== "string" || !host) return null;
3045
+ const lower = host.trim().toLowerCase().replace(/\.$/, "");
3046
+ const ip = normalizeIpLiteral(host);
3047
+ if (ip === null) {
3048
+ return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
3049
+ }
3050
+ const hit = (tier, overridable) => ({
3051
+ tier,
3052
+ overridable,
3053
+ kind: "address",
3054
+ normalized: ip
3055
+ });
3056
+ if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
3057
+ const o = v4Octets(ip);
3058
+ if (o) {
3059
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
3060
+ if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
3061
+ if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
3062
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
3063
+ if (o[0] === 127) return hit("private", true);
3064
+ if (o[0] === 10) return hit("private", true);
3065
+ if (o[0] === 192 && o[1] === 168) return hit("private", true);
3066
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
3067
+ return null;
3068
+ }
3069
+ const g = expandIpv6(ip);
3070
+ if (!g) return null;
3071
+ if (g.every((x) => x === 0)) return hit("unspecified", false);
3072
+ if ((g[0] & 65472) === 65152) return hit("link-local", false);
3073
+ if ((g[0] & 65280) === 65280) return hit("multicast", false);
3074
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
3075
+ return null;
3076
+ } catch {
3077
+ return null;
3078
+ }
3079
+ }
3080
+ var TIER_REASON = {
3081
+ metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3082
+ "link-local": "a link-local address",
3083
+ multicast: "a multicast address",
3084
+ unspecified: "the unspecified address",
3085
+ cgnat: "a carrier-grade NAT address",
3086
+ private: "a loopback or private address"
3087
+ };
3088
+ function ssrfFloor(tokens, opts = {}) {
3089
+ const exempt = new Set(
3090
+ (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3091
+ );
3092
+ for (const { token, binary } of tokens) {
3093
+ const m = classifySsrf(token);
3094
+ if (!m) continue;
3095
+ if (m.tier === "private" && !opts.ssrfStrict) continue;
3096
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3097
+ return {
3098
+ ...m,
3099
+ host: token,
3100
+ binary,
3101
+ reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
3102
+ };
3103
+ }
3104
+ return null;
3105
+ }
2638
3106
  function resolveCheck(v) {
2639
3107
  return v === "off" || v === "block" ? v : "review";
2640
3108
  }
@@ -2867,6 +3335,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2867
3335
  }
2868
3336
  const builtin = strictestVerdict(candidates);
2869
3337
  if (builtin) return builtin;
3338
+ {
3339
+ const ssrf = ssrfFloor(extractShellDestTokens(shellCommand), {
3340
+ ssrfAllow: config.policy.egress?.ssrfAllow,
3341
+ ssrfStrict: config.policy.egress?.ssrfStrict
3342
+ });
3343
+ if (ssrf) {
3344
+ return {
3345
+ decision: "block",
3346
+ blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
3347
+ reason: ssrf.reason,
3348
+ ruleName: `ssrf:${ssrf.tier}:${ssrf.binary}:${ssrf.host}`,
3349
+ ruleDescription: ssrf.reason,
3350
+ tier: 3
3351
+ };
3352
+ }
3353
+ }
2870
3354
  if (config.policy.egress?.enabled) {
2871
3355
  const dests = extractShellDestinations(shellCommand);
2872
3356
  if (dests.length > 0) {
@@ -3808,28 +4292,236 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
3808
4292
  var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3809
4293
  var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3810
4294
  var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3811
- var PII_CC_RE = /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
4295
+ var PII_CC16_RE = /\b(?:4\d{3}|5[1-5]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
4296
+ var PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
4297
+ var PII_IBAN_RE = /\b[A-Z]{2}\d{2}(?:[A-Z0-9]|[ -][A-Z0-9]){11,30}\b/;
4298
+ function hasValidCard(text) {
4299
+ for (const base of [PII_CC16_RE, PII_CC15_RE]) {
4300
+ const re = new RegExp(base.source, "g");
4301
+ let m;
4302
+ while ((m = re.exec(text)) !== null) {
4303
+ if (validateLuhn(m[0].replace(/\D/g, ""))) return true;
4304
+ re.lastIndex = m.index + 1;
4305
+ }
4306
+ }
4307
+ return false;
4308
+ }
4309
+ function hasValidIban(text) {
4310
+ const re = new RegExp(PII_IBAN_RE.source, "g");
4311
+ let m;
4312
+ while ((m = re.exec(text)) !== null) {
4313
+ if (validateIban(m[0])) return true;
4314
+ re.lastIndex = m.index + 1;
4315
+ }
4316
+ return false;
4317
+ }
3812
4318
  function detectPii(text) {
3813
4319
  const found = /* @__PURE__ */ new Set();
3814
4320
  if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
3815
4321
  if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
3816
4322
  if (PII_PHONE_RE.test(text)) found.add("Phone");
3817
- if (PII_CC_RE.test(text)) found.add("Credit Card");
4323
+ if (hasValidCard(text)) found.add("Credit Card");
4324
+ if (hasValidIban(text)) found.add("IBAN");
3818
4325
  return [...found];
3819
4326
  }
3820
- var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
4327
+ var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
3821
4328
  var MAX_PII_SCAN_BYTES = 1e5;
4329
+ function* stringLeaves(v, depth = 0) {
4330
+ if (depth > 6) return;
4331
+ if (typeof v === "string") {
4332
+ if (v.length > 0) yield v;
4333
+ return;
4334
+ }
4335
+ if (typeof v === "number") {
4336
+ if (Number.isFinite(v)) yield String(v);
4337
+ return;
4338
+ }
4339
+ if (!v || typeof v !== "object") return;
4340
+ if (Array.isArray(v)) {
4341
+ for (const x of v) yield* stringLeaves(x, depth + 1);
4342
+ return;
4343
+ }
4344
+ for (const x of Object.values(v)) yield* stringLeaves(x, depth + 1);
4345
+ }
3822
4346
  function detectArgsPii(args) {
3823
4347
  if (args === null || args === void 0) return [];
3824
- let text;
4348
+ const found = /* @__PURE__ */ new Set();
4349
+ let budget = MAX_PII_SCAN_BYTES;
3825
4350
  try {
3826
- text = typeof args === "string" ? args : JSON.stringify(args);
4351
+ for (const leaf of stringLeaves(args)) {
4352
+ if (budget <= 0) break;
4353
+ const t = leaf.length > budget ? leaf.slice(0, budget) : leaf;
4354
+ budget -= t.length;
4355
+ for (const p of detectPii(t)) {
4356
+ if (REALTIME_PII_PATTERNS.includes(p)) found.add(p);
4357
+ }
4358
+ }
3827
4359
  } catch {
3828
4360
  return [];
3829
4361
  }
3830
- if (typeof text !== "string") return [];
3831
- if (text.length > MAX_PII_SCAN_BYTES) text = text.slice(0, MAX_PII_SCAN_BYTES);
3832
- return detectPii(text).filter((p) => REALTIME_PII_PATTERNS.includes(p));
4362
+ return [...found];
4363
+ }
4364
+ var CANARY_MIN_LENGTH = 16;
4365
+ var MAX_TEXT = 1e5;
4366
+ var MAX_DEPTH2 = 6;
4367
+ var MAX_JSON_PARSE = 1e4;
4368
+ var URL_DEPTH = 4;
4369
+ var B64_DEPTH = 3;
4370
+ var MIN_SEGMENT = 16;
4371
+ var SEPARATORS = /[./\\?&= \t\n\r:;,\-_@%+#]/g;
4372
+ var stripSeparators = (s) => s.replace(SEPARATORS, "");
4373
+ function percentDecodeOnce(s) {
4374
+ try {
4375
+ return decodeURIComponent(s);
4376
+ } catch {
4377
+ return s.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
4378
+ }
4379
+ }
4380
+ function segments(s, alphabet) {
4381
+ const out = /* @__PURE__ */ new Set();
4382
+ if (alphabet.test(s)) out.add(s);
4383
+ for (const seg of s.split(/[?&\s"'<>]+/)) {
4384
+ if (seg.length >= MIN_SEGMENT && alphabet.test(seg)) out.add(seg);
4385
+ for (const part of seg.split("=")) {
4386
+ if (part.length >= MIN_SEGMENT && alphabet.test(part)) out.add(part);
4387
+ }
4388
+ }
4389
+ return [...out];
4390
+ }
4391
+ var looksText = (s) => s.length > 0 && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(s);
4392
+ var CANARY_DECODERS = {
4393
+ url: (s) => {
4394
+ const out = [];
4395
+ let cur = s;
4396
+ for (let i = 0; i < URL_DEPTH; i++) {
4397
+ const d = percentDecodeOnce(cur);
4398
+ if (d === cur) break;
4399
+ out.push(d);
4400
+ cur = d;
4401
+ }
4402
+ return out;
4403
+ },
4404
+ base64: (s) => {
4405
+ const out = [];
4406
+ let frontier = segments(s, /^[A-Za-z0-9+/\-_=]+$/);
4407
+ for (let depth = 0; depth < B64_DEPTH && frontier.length; depth++) {
4408
+ const next = [];
4409
+ for (const c of frontier) {
4410
+ const d = Buffer.from(c, "base64").toString("utf8");
4411
+ if (!looksText(d) || d.length < CANARY_MIN_LENGTH) continue;
4412
+ out.push(d);
4413
+ next.push(...segments(d, /^[A-Za-z0-9+/\-_=]+$/));
4414
+ }
4415
+ frontier = next;
4416
+ }
4417
+ return out;
4418
+ },
4419
+ hex: (s) => {
4420
+ const out = [];
4421
+ for (const c of segments(s, /^[0-9A-Fa-f]+$/)) {
4422
+ if (c.length % 2 !== 0 || c.length < CANARY_MIN_LENGTH * 2) continue;
4423
+ const d = Buffer.from(c, "hex").toString("utf8");
4424
+ if (looksText(d)) out.push(d);
4425
+ }
4426
+ return out;
4427
+ },
4428
+ separators: (s) => [stripSeparators(s)]
4429
+ };
4430
+ function lowestOffset(cands, needles, stripped) {
4431
+ let best = null;
4432
+ for (const c of cands) {
4433
+ for (const n of needles) {
4434
+ const off = c.indexOf(stripped ? n.stripped : n.raw);
4435
+ if (off >= 0 && (best === null || off < best.off)) best = { off, v: n.v };
4436
+ }
4437
+ }
4438
+ return best?.v ?? null;
4439
+ }
4440
+ var VIEWS = [
4441
+ { view: "url-decoded", decoder: "url", stripped: false },
4442
+ { view: "base64-decoded", decoder: "base64", stripped: false },
4443
+ { view: "hex-decoded", decoder: "hex", stripped: false },
4444
+ { view: "separators-stripped", decoder: "separators", stripped: true }
4445
+ ];
4446
+ function prepare(values) {
4447
+ const out = [];
4448
+ for (const v of values) {
4449
+ if (typeof v.value !== "string" || v.value.length < CANARY_MIN_LENGTH) {
4450
+ console.error(
4451
+ `[node9 engine] canary ${v.id}: value shorter than ${CANARY_MIN_LENGTH}, skipped`
4452
+ );
4453
+ continue;
4454
+ }
4455
+ out.push({ v, raw: v.value, stripped: stripSeparators(v.value) });
4456
+ }
4457
+ return out;
4458
+ }
4459
+ function matchPrepared(text, needles) {
4460
+ if (!text || needles.length === 0) return null;
4461
+ const t = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text;
4462
+ const raw = lowestOffset([t], needles, false);
4463
+ if (raw) return { v: raw, view: "raw" };
4464
+ for (const { view, decoder, stripped } of VIEWS) {
4465
+ let cands;
4466
+ try {
4467
+ cands = CANARY_DECODERS[decoder](t);
4468
+ } catch (e) {
4469
+ console.error(
4470
+ `[node9 engine] canary view ${view} failed, skipped:`,
4471
+ e instanceof Error ? e.message : String(e)
4472
+ );
4473
+ continue;
4474
+ }
4475
+ const hit = lowestOffset(cands, needles, stripped);
4476
+ if (hit) return { v: hit, view };
4477
+ }
4478
+ return null;
4479
+ }
4480
+ function matchCanaryArgs(args, values) {
4481
+ if (values.length === 0) return null;
4482
+ const needles = prepare(values);
4483
+ if (needles.length === 0) return null;
4484
+ const walk = (v, depth, fieldPath) => {
4485
+ if (depth > MAX_DEPTH2) return null;
4486
+ if (typeof v === "string") {
4487
+ const hit = matchPrepared(v, needles);
4488
+ if (hit) return { id: hit.v.id, view: hit.view, fieldPath, retired: Boolean(hit.v.retired) };
4489
+ if (v.length < MAX_JSON_PARSE) {
4490
+ const trimmed = v.trim();
4491
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
4492
+ try {
4493
+ return walk(JSON.parse(v), depth + 1, fieldPath);
4494
+ } catch {
4495
+ }
4496
+ }
4497
+ }
4498
+ return null;
4499
+ }
4500
+ if (typeof v === "number" && Number.isFinite(v)) return walk(String(v), depth, fieldPath);
4501
+ if (Array.isArray(v)) {
4502
+ for (let i = 0; i < v.length; i++) {
4503
+ const h = walk(v[i], depth + 1, `${fieldPath}[${i}]`);
4504
+ if (h) return h;
4505
+ }
4506
+ return null;
4507
+ }
4508
+ if (v && typeof v === "object") {
4509
+ for (const [k, child] of Object.entries(v)) {
4510
+ const h = walk(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
4511
+ if (h) return h;
4512
+ }
4513
+ }
4514
+ return null;
4515
+ };
4516
+ try {
4517
+ return walk(args, 0, "");
4518
+ } catch (e) {
4519
+ console.error(
4520
+ "[node9 engine] canary args walk failed:",
4521
+ e instanceof Error ? e.message : String(e)
4522
+ );
4523
+ return null;
4524
+ }
3833
4525
  }
3834
4526
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3835
4527
 
@@ -3959,6 +4651,12 @@ function applyManagedEgress(local, managed, locked, localModeUserSet = true) {
3959
4651
  if (Array.isArray(managed.deny) && managed.deny.length > 0) {
3960
4652
  next.deny = [.../* @__PURE__ */ new Set([...local.deny ?? [], ...managed.deny])];
3961
4653
  }
4654
+ if (typeof managed.ssrfStrict === "boolean") {
4655
+ next.ssrfStrict = managed.ssrfStrict;
4656
+ }
4657
+ if (Array.isArray(managed.ssrfAllow)) {
4658
+ next.ssrfAllow = [...managed.ssrfAllow];
4659
+ }
3962
4660
  if (typeof managed.allowPrivate === "boolean") {
3963
4661
  next.allowPrivate = locked.includes("egressAllowPrivate") ? managed.allowPrivate : (local.allowPrivate ?? true) && managed.allowPrivate;
3964
4662
  }
@@ -4040,9 +4738,9 @@ var B = "[\\s/\\\\]";
4040
4738
  var SEP = "[/\\\\]";
4041
4739
  function pathToRegexFragment(rawPath) {
4042
4740
  const tail = rawPath.trim().replace(/^~[\\/]?/, "").replace(/^\$\{?HOME\}?[\\/]?/, "").replace(/^\/(?:home|Users)\/[^\\/]+[\\/]?/, "").replace(/^[A-Za-z]:[\\/]Users[\\/][^\\/]+[\\/]?/, "").replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
4043
- const segments = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
4044
- if (segments.length === 0) return "";
4045
- return `(^|${B})${segments.join(SEP)}(${B}|$)`;
4741
+ const segments2 = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
4742
+ if (segments2.length === 0) return "";
4743
+ return `(^|${B})${segments2.join(SEP)}(${B}|$)`;
4046
4744
  }
4047
4745
  function pathMatchesFragment(candidate, rawPath) {
4048
4746
  const value = pathToRegexFragment(rawPath);
@@ -4147,6 +4845,20 @@ function isTrustedHost(host) {
4147
4845
  }
4148
4846
 
4149
4847
  // src/config/index.ts
4848
+ function sanitizeSsrfAllow(entries, source) {
4849
+ const kept = [];
4850
+ for (const entry of entries) {
4851
+ const m = classifySsrf(entry);
4852
+ if (m && !m.overridable) {
4853
+ process.emitWarning(
4854
+ `[node9] ${source} ssrfAllow entry "${entry}" is a protected address (${m.tier}) and cannot be exempted; ignoring it.`
4855
+ );
4856
+ continue;
4857
+ }
4858
+ kept.push(entry);
4859
+ }
4860
+ return kept;
4861
+ }
4150
4862
  var DANGEROUS_WORDS = [
4151
4863
  "mkfs",
4152
4864
  // formats/wipes a filesystem partition
@@ -4156,6 +4868,7 @@ var DANGEROUS_WORDS = [
4156
4868
  var DEFAULT_CONFIG = {
4157
4869
  version: "1.0",
4158
4870
  policySource: "local",
4871
+ ssrfStrictSource: "default",
4159
4872
  settings: {
4160
4873
  mode: "standard",
4161
4874
  autoStartDaemon: true,
@@ -4339,7 +5052,17 @@ var DEFAULT_CONFIG = {
4339
5052
  }
4340
5053
  ],
4341
5054
  dlp: { enabled: true, scanIgnoredTools: true, pii: "off" },
4342
- egress: { enabled: false, mode: "review", allow: [], deny: [], allowPrivate: true },
5055
+ egress: {
5056
+ enabled: false,
5057
+ mode: "review",
5058
+ allow: [],
5059
+ deny: [],
5060
+ allowPrivate: true,
5061
+ // The SSRF floor is always on and needs no default; these two only
5062
+ // widen or narrow it. See doc/roadmap/active/ssrf-floor-design.md.
5063
+ ssrfAllow: [],
5064
+ ssrfStrict: false
5065
+ },
4343
5066
  loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
4344
5067
  injectionScan: { enabled: false, minConfidence: "medium", allow: [] },
4345
5068
  skillPinning: { enabled: false, mode: "warn", roots: [] },
@@ -4522,7 +5245,8 @@ function getConfig(cwd) {
4522
5245
  egress: {
4523
5246
  ...DEFAULT_CONFIG.policy.egress,
4524
5247
  allow: [...DEFAULT_CONFIG.policy.egress.allow],
4525
- deny: [...DEFAULT_CONFIG.policy.egress.deny]
5248
+ deny: [...DEFAULT_CONFIG.policy.egress.deny],
5249
+ ssrfAllow: [...DEFAULT_CONFIG.policy.egress.ssrfAllow ?? []]
4526
5250
  },
4527
5251
  loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
4528
5252
  injectionScan: {
@@ -4548,6 +5272,7 @@ function getConfig(cwd) {
4548
5272
  };
4549
5273
  const pr2Creds = getCredentials();
4550
5274
  const keyed = !!pr2Creds?.apiKey && pr2Creds.localOnly !== true;
5275
+ let ssrfStrictSource = "default";
4551
5276
  const applyLayer = (source, isProject = false, isCloud = false) => {
4552
5277
  if (!source) return;
4553
5278
  const s = source.settings || {};
@@ -4630,6 +5355,15 @@ function getConfig(cwd) {
4630
5355
  if (Array.isArray(e.deny)) mergedPolicy.egress.deny.push(...e.deny);
4631
5356
  if (e.allowPrivate !== void 0 && !(isProject && e.allowPrivate === true))
4632
5357
  mergedPolicy.egress.allowPrivate = e.allowPrivate;
5358
+ if (!isProject) {
5359
+ if (Array.isArray(e.ssrfAllow)) {
5360
+ mergedPolicy.egress.ssrfAllow = sanitizeSsrfAllow(e.ssrfAllow, "egress.");
5361
+ }
5362
+ if (e.ssrfStrict !== void 0) {
5363
+ mergedPolicy.egress.ssrfStrict = e.ssrfStrict;
5364
+ ssrfStrictSource = "local";
5365
+ }
5366
+ }
4633
5367
  }
4634
5368
  if (p.loopDetection) {
4635
5369
  const ld = p.loopDetection;
@@ -4736,6 +5470,13 @@ function getConfig(cwd) {
4736
5470
  if (deny) mergedPolicy.egress.deny = deny;
4737
5471
  if (typeof e.allowPrivate === "boolean")
4738
5472
  mergedPolicy.egress.allowPrivate = e.allowPrivate;
5473
+ if (typeof e.ssrfStrict === "boolean") {
5474
+ mergedPolicy.egress.ssrfStrict = e.ssrfStrict;
5475
+ ssrfStrictSource = "workspace";
5476
+ }
5477
+ const ssrfAllow = hosts(e.ssrfAllow);
5478
+ if (ssrfAllow)
5479
+ mergedPolicy.egress.ssrfAllow = sanitizeSsrfAllow(ssrfAllow, "managed egress.");
4739
5480
  } else {
4740
5481
  mergedPolicy.egress = applyManagedEgress(
4741
5482
  mergedPolicy.egress,
@@ -4744,7 +5485,16 @@ function getConfig(cwd) {
4744
5485
  mode: typeof mc.egress.mode === "string" ? mc.egress.mode : void 0,
4745
5486
  allow: hosts(mc.egress.allow),
4746
5487
  deny: hosts(mc.egress.deny),
4747
- allowPrivate: typeof mc.egress.allowPrivate === "boolean" ? mc.egress.allowPrivate : void 0
5488
+ allowPrivate: typeof mc.egress.allowPrivate === "boolean" ? mc.egress.allowPrivate : void 0,
5489
+ ssrfStrict: (() => {
5490
+ if (typeof mc.egress.ssrfStrict !== "boolean") return void 0;
5491
+ ssrfStrictSource = "workspace";
5492
+ return mc.egress.ssrfStrict;
5493
+ })(),
5494
+ ssrfAllow: (() => {
5495
+ const list = hosts(mc.egress.ssrfAllow);
5496
+ return list ? sanitizeSsrfAllow(list, "managed egress.") : void 0;
5497
+ })()
4748
5498
  },
4749
5499
  locked,
4750
5500
  egressModeUserSet
@@ -4839,13 +5589,13 @@ function getConfig(cwd) {
4839
5589
  }
4840
5590
  if (Array.isArray(mc.jailPaths)) {
4841
5591
  for (const jp of mc.jailPaths) {
4842
- const path14 = typeof jp?.path === "string" ? jp.path.trim() : "";
4843
- if (!path14) continue;
5592
+ const path15 = typeof jp?.path === "string" ? jp.path.trim() : "";
5593
+ if (!path15) continue;
4844
5594
  const verdict = jp?.verdict === "review" ? "review" : "block";
4845
- for (const r of pathRules(path14, verdict, "org-managed jail")) {
5595
+ for (const r of pathRules(path15, verdict, "org-managed jail")) {
4846
5596
  mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
4847
5597
  }
4848
- mergedPolicy.managedJailPaths.push({ path: path14, verdict });
5598
+ mergedPolicy.managedJailPaths.push({ path: path15, verdict });
4849
5599
  }
4850
5600
  }
4851
5601
  if (Array.isArray(mc.trustedHosts)) {
@@ -4974,6 +5724,7 @@ function getConfig(cwd) {
4974
5724
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
4975
5725
  mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
4976
5726
  mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
5727
+ const resolvedSsrfStrictSource = keyed && ssrfStrictSource === "local" ? "default" : ssrfStrictSource;
4977
5728
  const result = {
4978
5729
  settings: mergedSettings,
4979
5730
  policy: mergedPolicy,
@@ -4981,7 +5732,10 @@ function getConfig(cwd) {
4981
5732
  // PR-2 — the one truth introspection reads: which of the two working
4982
5733
  // modes this machine is in. 'workspace' = keyed, policy from the cloud;
4983
5734
  // 'local' = the local stack (incl. --local / named-profile keys).
4984
- policySource: keyed ? "workspace" : "local"
5735
+ policySource: keyed ? "workspace" : "local",
5736
+ // A keyed machine drops the local policy layers wholesale, so a 'local'
5737
+ // provenance recorded before the fork cannot survive into the result.
5738
+ ssrfStrictSource: resolvedSsrfStrictSource
4985
5739
  };
4986
5740
  if (!cwd) cachedConfig = result;
4987
5741
  return result;
@@ -5847,14 +6601,92 @@ end run`;
5847
6601
  });
5848
6602
  }
5849
6603
 
6604
+ // src/canary/registry.ts
6605
+ import fs10 from "fs";
6606
+ import os9 from "os";
6607
+ import path12 from "path";
6608
+
6609
+ // src/shields/jail.ts
6610
+ import fs9 from "fs";
6611
+ import os8 from "os";
6612
+ import path11 from "path";
6613
+ var USER_JAIL_SHIELD = "user-jail";
6614
+ function jailStorePath() {
6615
+ return path11.join(os8.homedir(), ".node9", "jail-paths.json");
6616
+ }
6617
+ function readJailPaths() {
6618
+ let text;
6619
+ try {
6620
+ text = fs9.readFileSync(jailStorePath(), "utf8");
6621
+ } catch (err) {
6622
+ if (err.code === "ENOENT") return [];
6623
+ throw err;
6624
+ }
6625
+ let parsed;
6626
+ try {
6627
+ parsed = JSON.parse(text);
6628
+ } catch {
6629
+ throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
6630
+ }
6631
+ if (!Array.isArray(parsed.paths)) return [];
6632
+ return parsed.paths.filter(
6633
+ (p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
6634
+ );
6635
+ }
6636
+ function findJailedPath(candidate) {
6637
+ return findJailedPathIn(candidate, readJailPaths());
6638
+ }
6639
+ function findJailedPathIn(candidate, paths) {
6640
+ if (!candidate) return null;
6641
+ for (const entry of paths) {
6642
+ if (pathMatchesFragment(candidate, entry.path)) return entry;
6643
+ }
6644
+ return null;
6645
+ }
6646
+
6647
+ // src/canary/registry.ts
6648
+ function canaryStorePath() {
6649
+ return path12.join(os9.homedir(), ".node9", "canaries.json");
6650
+ }
6651
+ function isRecord(x) {
6652
+ if (!x || typeof x !== "object") return false;
6653
+ const r = x;
6654
+ return typeof r.id === "string" && typeof r.value === "string" && typeof r.valueHash === "string";
6655
+ }
6656
+ function loadCanaries(opts) {
6657
+ const p = canaryStorePath();
6658
+ let raw;
6659
+ try {
6660
+ raw = fs10.readFileSync(p, "utf-8");
6661
+ } catch (e) {
6662
+ if (e.code === "ENOENT") return [];
6663
+ throw e;
6664
+ }
6665
+ let parsed;
6666
+ try {
6667
+ parsed = JSON.parse(raw);
6668
+ } catch (e) {
6669
+ throw new Error(
6670
+ `[node9] ${p} is not valid JSON; refusing to touch it (${e instanceof Error ? e.message : String(e)})`
6671
+ );
6672
+ }
6673
+ const recs = parsed?.records;
6674
+ if (!Array.isArray(recs)) return [];
6675
+ const out = recs.filter(isRecord);
6676
+ return opts?.includeRetired === false ? out.filter((r) => !r.retiredAt) : out;
6677
+ }
6678
+ function canaryValues() {
6679
+ return loadCanaries().map((r) => ({ id: r.id, value: r.value, retired: Boolean(r.retiredAt) }));
6680
+ }
6681
+
5850
6682
  // src/auth/orchestrator.ts
5851
6683
  init_audit();
5852
6684
 
5853
6685
  // src/auth/cloud.ts
5854
6686
  init_audit();
5855
- import fs9 from "fs";
5856
- import os8 from "os";
5857
- import path11 from "path";
6687
+ import fs11 from "fs";
6688
+ import os10 from "os";
6689
+ import path13 from "path";
5858
6690
  async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
5859
6691
  const controller = new AbortController();
5860
6692
  const timeout = setTimeout(() => controller.abort(), 1e4);
@@ -5862,10 +6694,10 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
5862
6694
  let ciContext;
5863
6695
  if (process.env.CI) {
5864
6696
  try {
5865
- const ciContextPath = path11.join(os8.homedir(), ".node9", "ci-context.json");
5866
- const stats = fs9.statSync(ciContextPath);
6697
+ const ciContextPath = path13.join(os10.homedir(), ".node9", "ci-context.json");
6698
+ const stats = fs11.statSync(ciContextPath);
5867
6699
  if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
5868
- const raw = fs9.readFileSync(ciContextPath, "utf8");
6700
+ const raw = fs11.readFileSync(ciContextPath, "utf8");
5869
6701
  const parsed = JSON.parse(raw);
5870
6702
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
5871
6703
  throw new Error("ci-context.json is not a plain object");
@@ -5898,9 +6730,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
5898
6730
  context: {
5899
6731
  agent: meta?.agent,
5900
6732
  mcpServer: meta?.mcpServer,
5901
- hostname: os8.hostname(),
6733
+ hostname: os10.hostname(),
5902
6734
  cwd: process.cwd(),
5903
- platform: os8.platform()
6735
+ platform: os10.platform()
5904
6736
  },
5905
6737
  ...riskMetadata && { riskMetadata },
5906
6738
  ...ciContext && { ciContext },
@@ -5964,14 +6796,14 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
5964
6796
  });
5965
6797
  clearTimeout(timer);
5966
6798
  if (!res.ok) {
5967
- fs9.appendFileSync(
6799
+ fs11.appendFileSync(
5968
6800
  HOOK_DEBUG_LOG,
5969
6801
  `[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
5970
6802
  `
5971
6803
  );
5972
6804
  }
5973
6805
  } catch (err) {
5974
- fs9.appendFileSync(
6806
+ fs11.appendFileSync(
5975
6807
  HOOK_DEBUG_LOG,
5976
6808
  `[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
5977
6809
  `
@@ -5980,16 +6812,16 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
5980
6812
  }
5981
6813
 
5982
6814
  // src/loop-detector.ts
5983
- import fs10 from "fs";
5984
- import path12 from "path";
5985
- import os9 from "os";
6815
+ import fs12 from "fs";
6816
+ import path14 from "path";
6817
+ import os11 from "os";
5986
6818
  function loopStateFile() {
5987
- return path12.join(os9.homedir(), ".node9", "loop-state.json");
6819
+ return path14.join(os11.homedir(), ".node9", "loop-state.json");
5988
6820
  }
5989
6821
  function readState() {
5990
6822
  try {
5991
- if (!fs10.existsSync(loopStateFile())) return [];
5992
- const raw = fs10.readFileSync(loopStateFile(), "utf-8");
6823
+ if (!fs12.existsSync(loopStateFile())) return [];
6824
+ const raw = fs12.readFileSync(loopStateFile(), "utf-8");
5993
6825
  const parsed = JSON.parse(raw);
5994
6826
  if (!Array.isArray(parsed)) return [];
5995
6827
  return parsed;
@@ -5998,11 +6830,11 @@ function readState() {
5998
6830
  }
5999
6831
  }
6000
6832
  function writeState(records) {
6001
- const dir = path12.dirname(loopStateFile());
6002
- if (!fs10.existsSync(dir)) fs10.mkdirSync(dir, { recursive: true });
6003
- const tmpPath = `${loopStateFile()}.${os9.hostname()}.${process.pid}.tmp`;
6004
- fs10.writeFileSync(tmpPath, JSON.stringify(records));
6005
- fs10.renameSync(tmpPath, loopStateFile());
6833
+ const dir = path14.dirname(loopStateFile());
6834
+ if (!fs12.existsSync(dir)) fs12.mkdirSync(dir, { recursive: true });
6835
+ const tmpPath = `${loopStateFile()}.${os11.hostname()}.${process.pid}.tmp`;
6836
+ fs12.writeFileSync(tmpPath, JSON.stringify(records));
6837
+ fs12.renameSync(tmpPath, loopStateFile());
6006
6838
  }
6007
6839
  function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
6008
6840
  try {
@@ -6014,44 +6846,6 @@ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
6014
6846
  }
6015
6847
  }
6016
6848
 
6017
- // src/shields/jail.ts
6018
- import fs11 from "fs";
6019
- import os10 from "os";
6020
- import path13 from "path";
6021
- var USER_JAIL_SHIELD = "user-jail";
6022
- function jailStorePath() {
6023
- return path13.join(os10.homedir(), ".node9", "jail-paths.json");
6024
- }
6025
- function readJailPaths() {
6026
- let text;
6027
- try {
6028
- text = fs11.readFileSync(jailStorePath(), "utf8");
6029
- } catch (err) {
6030
- if (err.code === "ENOENT") return [];
6031
- throw err;
6032
- }
6033
- let parsed;
6034
- try {
6035
- parsed = JSON.parse(text);
6036
- } catch {
6037
- throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
6038
- }
6039
- if (!Array.isArray(parsed.paths)) return [];
6040
- return parsed.paths.filter(
6041
- (p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
6042
- );
6043
- }
6044
- function findJailedPath(candidate) {
6045
- return findJailedPathIn(candidate, readJailPaths());
6046
- }
6047
- function findJailedPathIn(candidate, paths) {
6048
- if (!candidate) return null;
6049
- for (const entry of paths) {
6050
- if (pathMatchesFragment(candidate, entry.path)) return entry;
6051
- }
6052
- return null;
6053
- }
6054
-
6055
6849
  // src/auth/orchestrator.ts
6056
6850
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
6057
6851
  "write",
@@ -6237,6 +7031,53 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
6237
7031
  taintWarning = `\u26A0\uFE0F node9 flagged this session \u2014 earlier tool output contained ${sessionTaint.record.source}. Approve this ${isWriteTool(toolName) ? "write" : "network"} action before it proceeds.`;
6238
7032
  }
6239
7033
  }
7034
+ const canaryVals = safeCanaryValues();
7035
+ if (canaryVals.length > 0) {
7036
+ const canaryHit = matchCanaryArgs(args, canaryVals);
7037
+ if (canaryHit) {
7038
+ const rec = canaryRecordById(canaryHit.id);
7039
+ const shape = scanArgs(args);
7040
+ const canaryReason = `\u{1F6A8} DECOY CREDENTIAL: the fake ${rec?.kind ?? "credential"} node9 planted at ${rec?.path ?? "a decoy file"} appeared in field "${canaryHit.fieldPath || "args"}". Something read that file; nothing legitimate does.`;
7041
+ if (!isManual)
7042
+ appendLocalAudit(
7043
+ toolName,
7044
+ args,
7045
+ "deny",
7046
+ isObserveMode ? "observe-mode-dlp-canary-would-block" : "dlp-canary-block",
7047
+ {
7048
+ ...meta,
7049
+ canaryId: canaryHit.id,
7050
+ canaryHash: rec?.valueHash,
7051
+ canaryKind: rec?.kind,
7052
+ canaryPath: rec?.path,
7053
+ canaryView: canaryHit.view,
7054
+ canaryRetired: canaryHit.retired,
7055
+ ...shape ? { dlpPattern: shape.patternName, dlpSample: shape.redactedSample } : {}
7056
+ },
7057
+ true
7058
+ );
7059
+ if (isObserveMode) {
7060
+ return {
7061
+ approved: true,
7062
+ checkedBy: "audit",
7063
+ observeWouldBlock: true,
7064
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Decoy Credential)"
7065
+ };
7066
+ }
7067
+ return {
7068
+ approved: false,
7069
+ reason: canaryReason,
7070
+ blockedBy: "local-config",
7071
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Decoy Credential)",
7072
+ // The /dev/tty banner renders ruleDescription under "Triggered by".
7073
+ // Without it the terminal said only "Decoy Credential" and never named
7074
+ // the file, while `node9 canary plant` promises node9 tells you which
7075
+ // file was read. Witnessed by canary-block-message.spec.ts, which calls
7076
+ // the orchestrator directly: this field never reaches the hook stdout.
7077
+ ruleDescription: `The fake credential node9 planted in ${rec?.path ?? "a decoy file"} just left that file. Nothing legitimate reads it.`
7078
+ };
7079
+ }
7080
+ }
6240
7081
  if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
6241
7082
  const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
6242
7083
  const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
@@ -6892,6 +7733,20 @@ async function authorizeAction(toolName, args) {
6892
7733
  const result = await authorizeHeadless(toolName, args);
6893
7734
  return result.approved;
6894
7735
  }
7736
+ function safeCanaryValues() {
7737
+ try {
7738
+ return canaryValues();
7739
+ } catch {
7740
+ return [];
7741
+ }
7742
+ }
7743
+ function canaryRecordById(id) {
7744
+ try {
7745
+ return loadCanaries().find((r) => r.id === id) ?? null;
7746
+ } catch {
7747
+ return null;
7748
+ }
7749
+ }
6895
7750
 
6896
7751
  // src/index.ts
6897
7752
  function protect(toolName, fn) {