@node9/proxy 2.8.4 → 2.9.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/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();
@@ -2244,6 +2514,10 @@ function analyzeShellCommand(command) {
2244
2514
  return { actions, paths, allTokens };
2245
2515
  }
2246
2516
  var DEFAULT_EGRESS_ALLOWLIST = [
2517
+ // node9's own control plane (api, app, dev-api, staging and the apex).
2518
+ // Without it, turning egress on asks the user to approve node9 itself.
2519
+ // A user `deny` entry still wins over this list, see evaluateEgress.
2520
+ "*.node9.ai",
2247
2521
  "*.github.com",
2248
2522
  "*.githubusercontent.com",
2249
2523
  "*.npmjs.org",
@@ -2386,7 +2660,7 @@ function isSensitivePath(p) {
2386
2660
  return SENSITIVE_PATTERNS.some((re) => re.test(p));
2387
2661
  }
2388
2662
  function splitOnPipe(cmd) {
2389
- const segments = [];
2663
+ const segments2 = [];
2390
2664
  let current = "";
2391
2665
  let inSingle = false;
2392
2666
  let inDouble = false;
@@ -2399,21 +2673,21 @@ function splitOnPipe(cmd) {
2399
2673
  inDouble = !inDouble;
2400
2674
  current += ch;
2401
2675
  } else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
2402
- segments.push(current.trim());
2676
+ segments2.push(current.trim());
2403
2677
  current = "";
2404
2678
  } else {
2405
2679
  current += ch;
2406
2680
  }
2407
2681
  }
2408
- if (current.trim()) segments.push(current.trim());
2409
- return segments.filter(Boolean);
2682
+ if (current.trim()) segments2.push(current.trim());
2683
+ return segments2.filter(Boolean);
2410
2684
  }
2411
2685
  function positionalTokens(segment) {
2412
2686
  return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2413
2687
  }
2414
2688
  function analyzePipeChain(command) {
2415
- const segments = splitOnPipe(command);
2416
- if (segments.length < 2) {
2689
+ const segments2 = splitOnPipe(command);
2690
+ if (segments2.length < 2) {
2417
2691
  return {
2418
2692
  isPipeline: false,
2419
2693
  hasSensitiveSource: false,
@@ -2429,7 +2703,7 @@ function analyzePipeChain(command) {
2429
2703
  let hasSensitiveSource = false;
2430
2704
  let hasExternalSink = false;
2431
2705
  let hasObfuscation = false;
2432
- for (const segment of segments) {
2706
+ for (const segment of segments2) {
2433
2707
  const tokens = segment.split(/\s+/).filter(Boolean);
2434
2708
  if (tokens.length === 0) continue;
2435
2709
  const binary = tokens[0].toLowerCase();
@@ -2467,8 +2741,8 @@ function analyzePipeChain(command) {
2467
2741
  };
2468
2742
  }
2469
2743
  function basename(p) {
2470
- const segments = p.split(/[\\/]/);
2471
- return segments[segments.length - 1] || "";
2744
+ const segments2 = p.split(/[\\/]/);
2745
+ return segments2[segments2.length - 1] || "";
2472
2746
  }
2473
2747
  var FLAGS_WITH_VALUES = {
2474
2748
  curl: /* @__PURE__ */ new Set([
@@ -2631,6 +2905,204 @@ function extractAllSshHosts(tokens) {
2631
2905
  }
2632
2906
  return [...hosts].filter(Boolean);
2633
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
+ }
2634
3106
  function resolveCheck(v) {
2635
3107
  return v === "off" || v === "block" ? v : "review";
2636
3108
  }
@@ -2863,6 +3335,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2863
3335
  }
2864
3336
  const builtin = strictestVerdict(candidates);
2865
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
+ }
2866
3354
  if (config.policy.egress?.enabled) {
2867
3355
  const dests = extractShellDestinations(shellCommand);
2868
3356
  if (dests.length > 0) {
@@ -3804,28 +4292,236 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
3804
4292
  var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3805
4293
  var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3806
4294
  var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3807
- 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
+ }
3808
4318
  function detectPii(text) {
3809
4319
  const found = /* @__PURE__ */ new Set();
3810
4320
  if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
3811
4321
  if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
3812
4322
  if (PII_PHONE_RE.test(text)) found.add("Phone");
3813
- 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");
3814
4325
  return [...found];
3815
4326
  }
3816
- var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
4327
+ var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
3817
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
+ }
3818
4346
  function detectArgsPii(args) {
3819
4347
  if (args === null || args === void 0) return [];
3820
- let text;
4348
+ const found = /* @__PURE__ */ new Set();
4349
+ let budget = MAX_PII_SCAN_BYTES;
3821
4350
  try {
3822
- 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
+ }
3823
4359
  } catch {
3824
4360
  return [];
3825
4361
  }
3826
- if (typeof text !== "string") return [];
3827
- if (text.length > MAX_PII_SCAN_BYTES) text = text.slice(0, MAX_PII_SCAN_BYTES);
3828
- 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
+ }
3829
4525
  }
3830
4526
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3831
4527
 
@@ -3955,6 +4651,12 @@ function applyManagedEgress(local, managed, locked, localModeUserSet = true) {
3955
4651
  if (Array.isArray(managed.deny) && managed.deny.length > 0) {
3956
4652
  next.deny = [.../* @__PURE__ */ new Set([...local.deny ?? [], ...managed.deny])];
3957
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
+ }
3958
4660
  if (typeof managed.allowPrivate === "boolean") {
3959
4661
  next.allowPrivate = locked.includes("egressAllowPrivate") ? managed.allowPrivate : (local.allowPrivate ?? true) && managed.allowPrivate;
3960
4662
  }
@@ -4036,9 +4738,9 @@ var B = "[\\s/\\\\]";
4036
4738
  var SEP = "[/\\\\]";
4037
4739
  function pathToRegexFragment(rawPath) {
4038
4740
  const tail = rawPath.trim().replace(/^~[\\/]?/, "").replace(/^\$\{?HOME\}?[\\/]?/, "").replace(/^\/(?:home|Users)\/[^\\/]+[\\/]?/, "").replace(/^[A-Za-z]:[\\/]Users[\\/][^\\/]+[\\/]?/, "").replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
4039
- const segments = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
4040
- if (segments.length === 0) return "";
4041
- 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}|$)`;
4042
4744
  }
4043
4745
  function pathMatchesFragment(candidate, rawPath) {
4044
4746
  const value = pathToRegexFragment(rawPath);
@@ -4143,6 +4845,20 @@ function isTrustedHost(host) {
4143
4845
  }
4144
4846
 
4145
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
+ }
4146
4862
  var DANGEROUS_WORDS = [
4147
4863
  "mkfs",
4148
4864
  // formats/wipes a filesystem partition
@@ -4152,6 +4868,7 @@ var DANGEROUS_WORDS = [
4152
4868
  var DEFAULT_CONFIG = {
4153
4869
  version: "1.0",
4154
4870
  policySource: "local",
4871
+ ssrfStrictSource: "default",
4155
4872
  settings: {
4156
4873
  mode: "standard",
4157
4874
  autoStartDaemon: true,
@@ -4335,7 +5052,17 @@ var DEFAULT_CONFIG = {
4335
5052
  }
4336
5053
  ],
4337
5054
  dlp: { enabled: true, scanIgnoredTools: true, pii: "off" },
4338
- 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
+ },
4339
5066
  loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
4340
5067
  injectionScan: { enabled: false, minConfidence: "medium", allow: [] },
4341
5068
  skillPinning: { enabled: false, mode: "warn", roots: [] },
@@ -4518,7 +5245,8 @@ function getConfig(cwd) {
4518
5245
  egress: {
4519
5246
  ...DEFAULT_CONFIG.policy.egress,
4520
5247
  allow: [...DEFAULT_CONFIG.policy.egress.allow],
4521
- deny: [...DEFAULT_CONFIG.policy.egress.deny]
5248
+ deny: [...DEFAULT_CONFIG.policy.egress.deny],
5249
+ ssrfAllow: [...DEFAULT_CONFIG.policy.egress.ssrfAllow ?? []]
4522
5250
  },
4523
5251
  loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
4524
5252
  injectionScan: {
@@ -4544,6 +5272,7 @@ function getConfig(cwd) {
4544
5272
  };
4545
5273
  const pr2Creds = getCredentials();
4546
5274
  const keyed = !!pr2Creds?.apiKey && pr2Creds.localOnly !== true;
5275
+ let ssrfStrictSource = "default";
4547
5276
  const applyLayer = (source, isProject = false, isCloud = false) => {
4548
5277
  if (!source) return;
4549
5278
  const s = source.settings || {};
@@ -4626,6 +5355,15 @@ function getConfig(cwd) {
4626
5355
  if (Array.isArray(e.deny)) mergedPolicy.egress.deny.push(...e.deny);
4627
5356
  if (e.allowPrivate !== void 0 && !(isProject && e.allowPrivate === true))
4628
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
+ }
4629
5367
  }
4630
5368
  if (p.loopDetection) {
4631
5369
  const ld = p.loopDetection;
@@ -4732,6 +5470,13 @@ function getConfig(cwd) {
4732
5470
  if (deny) mergedPolicy.egress.deny = deny;
4733
5471
  if (typeof e.allowPrivate === "boolean")
4734
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.");
4735
5480
  } else {
4736
5481
  mergedPolicy.egress = applyManagedEgress(
4737
5482
  mergedPolicy.egress,
@@ -4740,7 +5485,16 @@ function getConfig(cwd) {
4740
5485
  mode: typeof mc.egress.mode === "string" ? mc.egress.mode : void 0,
4741
5486
  allow: hosts(mc.egress.allow),
4742
5487
  deny: hosts(mc.egress.deny),
4743
- 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
+ })()
4744
5498
  },
4745
5499
  locked,
4746
5500
  egressModeUserSet
@@ -4835,13 +5589,13 @@ function getConfig(cwd) {
4835
5589
  }
4836
5590
  if (Array.isArray(mc.jailPaths)) {
4837
5591
  for (const jp of mc.jailPaths) {
4838
- const path14 = typeof jp?.path === "string" ? jp.path.trim() : "";
4839
- if (!path14) continue;
5592
+ const path15 = typeof jp?.path === "string" ? jp.path.trim() : "";
5593
+ if (!path15) continue;
4840
5594
  const verdict = jp?.verdict === "review" ? "review" : "block";
4841
- for (const r of pathRules(path14, verdict, "org-managed jail")) {
5595
+ for (const r of pathRules(path15, verdict, "org-managed jail")) {
4842
5596
  mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
4843
5597
  }
4844
- mergedPolicy.managedJailPaths.push({ path: path14, verdict });
5598
+ mergedPolicy.managedJailPaths.push({ path: path15, verdict });
4845
5599
  }
4846
5600
  }
4847
5601
  if (Array.isArray(mc.trustedHosts)) {
@@ -4970,6 +5724,7 @@ function getConfig(cwd) {
4970
5724
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
4971
5725
  mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
4972
5726
  mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
5727
+ const resolvedSsrfStrictSource = keyed && ssrfStrictSource === "local" ? "default" : ssrfStrictSource;
4973
5728
  const result = {
4974
5729
  settings: mergedSettings,
4975
5730
  policy: mergedPolicy,
@@ -4977,7 +5732,10 @@ function getConfig(cwd) {
4977
5732
  // PR-2 — the one truth introspection reads: which of the two working
4978
5733
  // modes this machine is in. 'workspace' = keyed, policy from the cloud;
4979
5734
  // 'local' = the local stack (incl. --local / named-profile keys).
4980
- 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
4981
5739
  };
4982
5740
  if (!cwd) cachedConfig = result;
4983
5741
  return result;
@@ -5843,14 +6601,92 @@ end run`;
5843
6601
  });
5844
6602
  }
5845
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
+
5846
6682
  // src/auth/orchestrator.ts
5847
6683
  init_audit();
5848
6684
 
5849
6685
  // src/auth/cloud.ts
5850
6686
  init_audit();
5851
- import fs9 from "fs";
5852
- import os8 from "os";
5853
- import path11 from "path";
6687
+ import fs11 from "fs";
6688
+ import os10 from "os";
6689
+ import path13 from "path";
5854
6690
  async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
5855
6691
  const controller = new AbortController();
5856
6692
  const timeout = setTimeout(() => controller.abort(), 1e4);
@@ -5858,10 +6694,10 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
5858
6694
  let ciContext;
5859
6695
  if (process.env.CI) {
5860
6696
  try {
5861
- const ciContextPath = path11.join(os8.homedir(), ".node9", "ci-context.json");
5862
- const stats = fs9.statSync(ciContextPath);
6697
+ const ciContextPath = path13.join(os10.homedir(), ".node9", "ci-context.json");
6698
+ const stats = fs11.statSync(ciContextPath);
5863
6699
  if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
5864
- const raw = fs9.readFileSync(ciContextPath, "utf8");
6700
+ const raw = fs11.readFileSync(ciContextPath, "utf8");
5865
6701
  const parsed = JSON.parse(raw);
5866
6702
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
5867
6703
  throw new Error("ci-context.json is not a plain object");
@@ -5894,9 +6730,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
5894
6730
  context: {
5895
6731
  agent: meta?.agent,
5896
6732
  mcpServer: meta?.mcpServer,
5897
- hostname: os8.hostname(),
6733
+ hostname: os10.hostname(),
5898
6734
  cwd: process.cwd(),
5899
- platform: os8.platform()
6735
+ platform: os10.platform()
5900
6736
  },
5901
6737
  ...riskMetadata && { riskMetadata },
5902
6738
  ...ciContext && { ciContext },
@@ -5960,14 +6796,14 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
5960
6796
  });
5961
6797
  clearTimeout(timer);
5962
6798
  if (!res.ok) {
5963
- fs9.appendFileSync(
6799
+ fs11.appendFileSync(
5964
6800
  HOOK_DEBUG_LOG,
5965
6801
  `[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
5966
6802
  `
5967
6803
  );
5968
6804
  }
5969
6805
  } catch (err) {
5970
- fs9.appendFileSync(
6806
+ fs11.appendFileSync(
5971
6807
  HOOK_DEBUG_LOG,
5972
6808
  `[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
5973
6809
  `
@@ -5976,16 +6812,16 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
5976
6812
  }
5977
6813
 
5978
6814
  // src/loop-detector.ts
5979
- import fs10 from "fs";
5980
- import path12 from "path";
5981
- import os9 from "os";
6815
+ import fs12 from "fs";
6816
+ import path14 from "path";
6817
+ import os11 from "os";
5982
6818
  function loopStateFile() {
5983
- return path12.join(os9.homedir(), ".node9", "loop-state.json");
6819
+ return path14.join(os11.homedir(), ".node9", "loop-state.json");
5984
6820
  }
5985
6821
  function readState() {
5986
6822
  try {
5987
- if (!fs10.existsSync(loopStateFile())) return [];
5988
- const raw = fs10.readFileSync(loopStateFile(), "utf-8");
6823
+ if (!fs12.existsSync(loopStateFile())) return [];
6824
+ const raw = fs12.readFileSync(loopStateFile(), "utf-8");
5989
6825
  const parsed = JSON.parse(raw);
5990
6826
  if (!Array.isArray(parsed)) return [];
5991
6827
  return parsed;
@@ -5994,11 +6830,11 @@ function readState() {
5994
6830
  }
5995
6831
  }
5996
6832
  function writeState(records) {
5997
- const dir = path12.dirname(loopStateFile());
5998
- if (!fs10.existsSync(dir)) fs10.mkdirSync(dir, { recursive: true });
5999
- const tmpPath = `${loopStateFile()}.${os9.hostname()}.${process.pid}.tmp`;
6000
- fs10.writeFileSync(tmpPath, JSON.stringify(records));
6001
- 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());
6002
6838
  }
6003
6839
  function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
6004
6840
  try {
@@ -6010,44 +6846,6 @@ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
6010
6846
  }
6011
6847
  }
6012
6848
 
6013
- // src/shields/jail.ts
6014
- import fs11 from "fs";
6015
- import os10 from "os";
6016
- import path13 from "path";
6017
- var USER_JAIL_SHIELD = "user-jail";
6018
- function jailStorePath() {
6019
- return path13.join(os10.homedir(), ".node9", "jail-paths.json");
6020
- }
6021
- function readJailPaths() {
6022
- let text;
6023
- try {
6024
- text = fs11.readFileSync(jailStorePath(), "utf8");
6025
- } catch (err) {
6026
- if (err.code === "ENOENT") return [];
6027
- throw err;
6028
- }
6029
- let parsed;
6030
- try {
6031
- parsed = JSON.parse(text);
6032
- } catch {
6033
- throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
6034
- }
6035
- if (!Array.isArray(parsed.paths)) return [];
6036
- return parsed.paths.filter(
6037
- (p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
6038
- );
6039
- }
6040
- function findJailedPath(candidate) {
6041
- return findJailedPathIn(candidate, readJailPaths());
6042
- }
6043
- function findJailedPathIn(candidate, paths) {
6044
- if (!candidate) return null;
6045
- for (const entry of paths) {
6046
- if (pathMatchesFragment(candidate, entry.path)) return entry;
6047
- }
6048
- return null;
6049
- }
6050
-
6051
6849
  // src/auth/orchestrator.ts
6052
6850
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
6053
6851
  "write",
@@ -6233,6 +7031,53 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
6233
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.`;
6234
7032
  }
6235
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
+ }
6236
7081
  if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
6237
7082
  const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
6238
7083
  const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
@@ -6888,6 +7733,20 @@ async function authorizeAction(toolName, args) {
6888
7733
  const result = await authorizeHeadless(toolName, args);
6889
7734
  return result.approved;
6890
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
+ }
6891
7750
 
6892
7751
  // src/index.ts
6893
7752
  function protect(toolName, fn) {