@node9/policy-engine 2.8.5 → 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
@@ -1,6 +1,175 @@
1
1
  // src/dlp/index.ts
2
2
  import safeRegex from "safe-regex2";
3
3
 
4
+ // src/scan/checksums.ts
5
+ import { createHash } from "crypto";
6
+ function validateLuhn(digits) {
7
+ if (!/^\d+$/.test(digits)) return false;
8
+ if (digits.length < 12) return false;
9
+ if (!/[1-9]/.test(digits)) return false;
10
+ let sum = 0;
11
+ let double = false;
12
+ for (let i = digits.length - 1; i >= 0; i--) {
13
+ let d = digits.charCodeAt(i) - 48;
14
+ if (double) {
15
+ d *= 2;
16
+ if (d > 9) d -= 9;
17
+ }
18
+ sum += d;
19
+ double = !double;
20
+ }
21
+ return sum % 10 === 0;
22
+ }
23
+ var IBAN_LENGTH = {
24
+ AD: 24,
25
+ AE: 23,
26
+ AL: 28,
27
+ AT: 20,
28
+ AZ: 28,
29
+ BA: 20,
30
+ BE: 16,
31
+ BG: 22,
32
+ BH: 22,
33
+ BI: 27,
34
+ BR: 29,
35
+ BY: 28,
36
+ CH: 21,
37
+ CR: 22,
38
+ CY: 28,
39
+ CZ: 24,
40
+ DE: 22,
41
+ DJ: 27,
42
+ DK: 18,
43
+ DO: 28,
44
+ EE: 20,
45
+ EG: 29,
46
+ ES: 24,
47
+ FI: 18,
48
+ FK: 18,
49
+ FO: 18,
50
+ FR: 27,
51
+ GB: 22,
52
+ GE: 22,
53
+ GI: 23,
54
+ GL: 18,
55
+ GR: 27,
56
+ GT: 28,
57
+ HN: 28,
58
+ HR: 21,
59
+ HU: 28,
60
+ IE: 22,
61
+ IL: 23,
62
+ IQ: 23,
63
+ IS: 26,
64
+ IT: 27,
65
+ JO: 30,
66
+ KW: 30,
67
+ KZ: 20,
68
+ LB: 28,
69
+ LC: 32,
70
+ LI: 21,
71
+ LT: 20,
72
+ LU: 20,
73
+ LV: 21,
74
+ LY: 25,
75
+ MC: 27,
76
+ MD: 24,
77
+ ME: 22,
78
+ MK: 19,
79
+ MN: 20,
80
+ MR: 27,
81
+ MT: 31,
82
+ MU: 30,
83
+ NI: 28,
84
+ NL: 18,
85
+ NO: 15,
86
+ OM: 23,
87
+ PK: 24,
88
+ PL: 28,
89
+ PS: 29,
90
+ PT: 25,
91
+ QA: 29,
92
+ RO: 24,
93
+ RS: 22,
94
+ RU: 33,
95
+ SA: 24,
96
+ SC: 31,
97
+ SD: 18,
98
+ SE: 24,
99
+ SI: 19,
100
+ SK: 24,
101
+ SM: 27,
102
+ SN: 28,
103
+ SO: 23,
104
+ ST: 25,
105
+ SV: 28,
106
+ TL: 23,
107
+ TN: 24,
108
+ TR: 26,
109
+ UA: 29,
110
+ VA: 22,
111
+ VG: 24,
112
+ XK: 20,
113
+ YE: 30
114
+ };
115
+ function validateIban(raw) {
116
+ const s = raw.replace(/[ -]/g, "").toUpperCase();
117
+ if (!/^[A-Z]{2}\d{2}/.test(s)) return false;
118
+ const want = IBAN_LENGTH[s.slice(0, 2)];
119
+ if (want === void 0 || s.length < want) return false;
120
+ const iban = s.slice(0, want);
121
+ if (!/^[A-Z0-9]+$/.test(iban)) return false;
122
+ const rearranged = iban.slice(4) + iban.slice(0, 4);
123
+ let mod = 0;
124
+ for (const ch of rearranged) {
125
+ const code = ch.charCodeAt(0);
126
+ const digits = code >= 65 ? String(code - 55) : ch;
127
+ for (const d of digits) mod = (mod * 10 + (d.charCodeAt(0) - 48)) % 97;
128
+ }
129
+ return mod === 1;
130
+ }
131
+ var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
132
+ var B58_INDEX = Object.fromEntries(
133
+ [...B58].map((c, i) => [c, i])
134
+ );
135
+ function validateBase58Check(s) {
136
+ if (!s) return null;
137
+ let n = 0n;
138
+ for (const c of s) {
139
+ const v = B58_INDEX[c];
140
+ if (v === void 0) return null;
141
+ n = n * 58n + BigInt(v);
142
+ }
143
+ let hex = n.toString(16);
144
+ if (hex.length % 2) hex = "0" + hex;
145
+ let zeros = 0;
146
+ for (const c of s) {
147
+ if (c !== "1") break;
148
+ zeros++;
149
+ }
150
+ const bytes = Buffer.concat([
151
+ Buffer.alloc(zeros),
152
+ n === 0n ? Buffer.alloc(0) : Buffer.from(hex, "hex")
153
+ ]);
154
+ if (bytes.length < 5) return null;
155
+ const body = bytes.subarray(0, bytes.length - 4);
156
+ const check = bytes.subarray(bytes.length - 4);
157
+ const h = createHash("sha256").update(createHash("sha256").update(body).digest()).digest();
158
+ return h.subarray(0, 4).equals(check) ? body : null;
159
+ }
160
+ function validateWif(s) {
161
+ const p = validateBase58Check(s);
162
+ if (!p || p[0] !== 128) return false;
163
+ return p.length === 33 || p.length === 34 && p[33] === 1;
164
+ }
165
+ var XPRV_VERSIONS = /* @__PURE__ */ new Set([76066276, 77428856, 78791436]);
166
+ function validateXprv(s) {
167
+ const p = validateBase58Check(s);
168
+ if (!p || p.length !== 78) return false;
169
+ const version = p.readUInt32BE(0);
170
+ return XPRV_VERSIONS.has(version) && p[45] === 0;
171
+ }
172
+
4
173
  // src/dlp/injection.ts
5
174
  var MAX = 1e5;
6
175
  var UNTRUSTED_TOOLS = /\b(web_?fetch|web_?search|fetch|curl|wget|browser|http_get|read_url|open_url)\b/i;
@@ -245,6 +414,27 @@ var DLP_PATTERNS = [
245
414
  severity: "block",
246
415
  keywords: ["sg."]
247
416
  },
417
+ // ── Cryptocurrency private keys (base58check-validated) ───────────────────
418
+ // Both are anchored with \b on each side: unanchored, `[KL][base58]{51}`
419
+ // matches INSIDE any longer base58 blob (an xprv, a Solana keypair, a
420
+ // Monero address). Lookbehind fails safe-regex2; \b is the house style
421
+ // (see the card regexes). Mainnet only, matching validateWif / validateXprv;
422
+ // testnet (WIF 0xEF, tprv) is deferred. Cost was measured: the WIF regex
423
+ // runs on every string (first keyword-less pattern) at 0.024 ms per 100 KB
424
+ // of prose, so no prefilter is warranted.
425
+ {
426
+ name: "Bitcoin WIF Private Key",
427
+ regex: /\b(?:5[1-9A-HJ-NP-Za-km-z]{50}|[KL][1-9A-HJ-NP-Za-km-z]{51})\b/,
428
+ severity: "block",
429
+ validate: validateWif
430
+ },
431
+ {
432
+ name: "Extended Private Key",
433
+ regex: /\b[xyz]prv[1-9A-HJ-NP-Za-km-z]{107}\b/,
434
+ severity: "block",
435
+ keywords: ["xprv", "yprv", "zprv"],
436
+ validate: validateXprv
437
+ },
248
438
  // ── Private keys (PEM) ────────────────────────────────────────────────────
249
439
  {
250
440
  name: "Private Key (PEM)",
@@ -609,6 +799,33 @@ var DLP_SCAN_LIMITS = {
609
799
  /** Max nesting depth walked; anything deeper is NOT scanned. */
610
800
  maxDepth: MAX_DEPTH
611
801
  };
802
+ function suppressed(pattern, raw) {
803
+ if (pattern.validate) {
804
+ let ok;
805
+ try {
806
+ ok = pattern.validate(raw);
807
+ } catch {
808
+ ok = true;
809
+ }
810
+ return !ok;
811
+ }
812
+ if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) return true;
813
+ if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) return true;
814
+ return false;
815
+ }
816
+ function firstAcceptedMatch(pattern, text) {
817
+ const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : pattern.regex.flags + "g";
818
+ const re = new RegExp(pattern.regex.source, flags);
819
+ let m;
820
+ while ((m = re.exec(text)) !== null) {
821
+ if (m[0].length === 0) {
822
+ re.lastIndex = m.index + 1;
823
+ continue;
824
+ }
825
+ if (!suppressed(pattern, m[0])) return m[0];
826
+ }
827
+ return null;
828
+ }
612
829
  function scanArgs(args, depth = 0, fieldPath = "args") {
613
830
  if (depth > MAX_DEPTH || args === null || args === void 0) return null;
614
831
  if (Array.isArray(args)) {
@@ -633,18 +850,16 @@ function scanArgs(args, depth = 0, fieldPath = "args") {
633
850
  if (pattern.keywords && !pattern.keywords.some((kw) => textLower.includes(kw.toLowerCase()))) {
634
851
  continue;
635
852
  }
636
- if (pattern.regex.test(text)) {
637
- const raw = text.match(pattern.regex)?.[0] ?? "";
638
- if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) continue;
639
- if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) continue;
640
- const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
641
- return {
642
- patternName: pattern.name,
643
- fieldPath,
644
- redactedSample: maskSecret(text, pattern.regex),
645
- severity
646
- };
647
- }
853
+ const raw = firstAcceptedMatch(pattern, text);
854
+ if (raw === null) continue;
855
+ const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
856
+ return {
857
+ patternName: pattern.name,
858
+ fieldPath,
859
+ // Mask the ACCEPTED token, not the first regex hit in the field.
860
+ redactedSample: maskSecret(raw, pattern.regex),
861
+ severity
862
+ };
648
863
  }
649
864
  if (text.length < MAX_JSON_PARSE_BYTES) {
650
865
  const trimmed = text.trim();
@@ -667,17 +882,14 @@ function scanText(text) {
667
882
  if (pattern.keywords && !pattern.keywords.some((kw) => tLower.includes(kw.toLowerCase()))) {
668
883
  continue;
669
884
  }
670
- if (pattern.regex.test(t)) {
671
- const raw = t.match(pattern.regex)?.[0] ?? "";
672
- if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) continue;
673
- if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) continue;
674
- return {
675
- patternName: pattern.name,
676
- fieldPath: "response-text",
677
- redactedSample: maskSecret(t, pattern.regex),
678
- severity: pattern.severity
679
- };
680
- }
885
+ const raw = firstAcceptedMatch(pattern, t);
886
+ if (raw === null) continue;
887
+ return {
888
+ patternName: pattern.name,
889
+ fieldPath: "response-text",
890
+ redactedSample: maskSecret(raw, pattern.regex),
891
+ severity: pattern.severity
892
+ };
681
893
  }
682
894
  return null;
683
895
  }
@@ -691,9 +903,7 @@ function redactText(text) {
691
903
  continue;
692
904
  }
693
905
  result = result.replace(globalRegex, (match) => {
694
- if (DLP_STOPWORDS.some((sw) => match.toLowerCase().includes(sw))) return match;
695
- if (pattern.minEntropy !== void 0 && shannonEntropy(match) < pattern.minEntropy)
696
- return match;
906
+ if (suppressed(pattern, match)) return match;
697
907
  if (!found.includes(pattern.name)) found.push(pattern.name);
698
908
  return `[node9-redacted:${pattern.name}]`;
699
909
  });
@@ -761,11 +971,11 @@ function matchesPattern(text, patterns) {
761
971
  var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
762
972
  function getNestedValue(obj, path) {
763
973
  if (!obj || typeof obj !== "object") return null;
764
- const segments = path.split(".");
765
- for (const seg of segments) {
974
+ const segments2 = path.split(".");
975
+ for (const seg of segments2) {
766
976
  if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
767
977
  }
768
- return segments.reduce((prev, curr) => prev?.[curr], obj);
978
+ return segments2.reduce((prev, curr) => prev?.[curr], obj);
769
979
  }
770
980
  function evaluateSmartConditions(args, rule) {
771
981
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -1736,6 +1946,50 @@ function extractShellDestinations(command) {
1736
1946
  }
1737
1947
  return out;
1738
1948
  }
1949
+ function extractShellDestTokens(command) {
1950
+ const f = parseShared(command);
1951
+ if (f === PARSE_FAIL) return [];
1952
+ const out = [];
1953
+ const seen = /* @__PURE__ */ new Set();
1954
+ try {
1955
+ syntax.Walk(f, (node) => {
1956
+ if (!node) return false;
1957
+ const n = node;
1958
+ if (syntax.NodeType(n) !== "CallExpr") return true;
1959
+ const callArgs = n.Args || [];
1960
+ if (callArgs.length === 0) return true;
1961
+ const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
1962
+ if (!NET_BINARIES.has(name)) return true;
1963
+ const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
1964
+ for (const raw of destTokensForBinary(name, rest)) {
1965
+ if (!raw) continue;
1966
+ let tok = raw.trim();
1967
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(tok);
1968
+ const hasScheme = scheme !== null;
1969
+ if (hasScheme) tok = tok.slice(scheme[0].length);
1970
+ tok = tok.split(/[/?#]/)[0];
1971
+ const at = tok.lastIndexOf("@");
1972
+ if (at >= 0) tok = tok.slice(at + 1);
1973
+ if (tok.startsWith("[")) {
1974
+ const close = tok.indexOf("]");
1975
+ if (close > 0) tok = tok.slice(0, close + 1);
1976
+ } else {
1977
+ tok = tok.split(":")[0];
1978
+ }
1979
+ if (!tok) continue;
1980
+ if (!hasScheme && /^\d+$/.test(tok) && Number(tok) < 16777216) continue;
1981
+ const key = `${name}:${tok}`;
1982
+ if (seen.has(key)) continue;
1983
+ seen.add(key);
1984
+ out.push({ token: tok, binary: name });
1985
+ }
1986
+ return true;
1987
+ });
1988
+ } catch {
1989
+ return out;
1990
+ }
1991
+ return out;
1992
+ }
1739
1993
  var FS_OP_CACHE_MAX = 5e3;
1740
1994
  var fsOpCache = /* @__PURE__ */ new Map();
1741
1995
  function analyzeFsOperation(command) {
@@ -1940,8 +2194,8 @@ function analyzeShellCommand(command) {
1940
2194
  if (allTokens.length === 0) {
1941
2195
  const normalized = command.replace(/\\(.)/g, "$1");
1942
2196
  const sanitized = normalized.replace(/["'<>]/g, " ");
1943
- const segments = sanitized.split(/[|;&]|\$\(|\)|`/);
1944
- segments.forEach((segment) => {
2197
+ const segments2 = sanitized.split(/[|;&]|\$\(|\)|`/);
2198
+ segments2.forEach((segment) => {
1945
2199
  const tokens = segment.trim().split(/\s+/).filter(Boolean);
1946
2200
  if (tokens.length > 0) {
1947
2201
  const action = tokens[0].toLowerCase();
@@ -2108,7 +2362,7 @@ function isSensitivePath(p) {
2108
2362
  return SENSITIVE_PATTERNS.some((re) => re.test(p));
2109
2363
  }
2110
2364
  function splitOnPipe(cmd) {
2111
- const segments = [];
2365
+ const segments2 = [];
2112
2366
  let current = "";
2113
2367
  let inSingle = false;
2114
2368
  let inDouble = false;
@@ -2121,21 +2375,21 @@ function splitOnPipe(cmd) {
2121
2375
  inDouble = !inDouble;
2122
2376
  current += ch;
2123
2377
  } else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
2124
- segments.push(current.trim());
2378
+ segments2.push(current.trim());
2125
2379
  current = "";
2126
2380
  } else {
2127
2381
  current += ch;
2128
2382
  }
2129
2383
  }
2130
- if (current.trim()) segments.push(current.trim());
2131
- return segments.filter(Boolean);
2384
+ if (current.trim()) segments2.push(current.trim());
2385
+ return segments2.filter(Boolean);
2132
2386
  }
2133
2387
  function positionalTokens(segment) {
2134
2388
  return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2135
2389
  }
2136
2390
  function analyzePipeChain(command) {
2137
- const segments = splitOnPipe(command);
2138
- if (segments.length < 2) {
2391
+ const segments2 = splitOnPipe(command);
2392
+ if (segments2.length < 2) {
2139
2393
  return {
2140
2394
  isPipeline: false,
2141
2395
  hasSensitiveSource: false,
@@ -2151,7 +2405,7 @@ function analyzePipeChain(command) {
2151
2405
  let hasSensitiveSource = false;
2152
2406
  let hasExternalSink = false;
2153
2407
  let hasObfuscation = false;
2154
- for (const segment of segments) {
2408
+ for (const segment of segments2) {
2155
2409
  const tokens = segment.split(/\s+/).filter(Boolean);
2156
2410
  if (tokens.length === 0) continue;
2157
2411
  const binary = tokens[0].toLowerCase();
@@ -2191,8 +2445,8 @@ function analyzePipeChain(command) {
2191
2445
 
2192
2446
  // src/policy/flag-tables.ts
2193
2447
  function basename(p) {
2194
- const segments = p.split(/[\\/]/);
2195
- return segments[segments.length - 1] || "";
2448
+ const segments2 = p.split(/[\\/]/);
2449
+ return segments2[segments2.length - 1] || "";
2196
2450
  }
2197
2451
  var FLAGS_WITH_VALUES = {
2198
2452
  curl: /* @__PURE__ */ new Set([
@@ -2362,6 +2616,206 @@ function parseAllSshHostsFromCommand(command) {
2362
2616
  return extractAllSshHosts(tokens.slice(1));
2363
2617
  }
2364
2618
 
2619
+ // src/egress/ssrf.ts
2620
+ var SSRF_MAX_HOST = 253;
2621
+ function parseComponent(s) {
2622
+ if (!s) return null;
2623
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
2624
+ if (s === "0") return 0;
2625
+ if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
2626
+ if (/^[1-9][0-9]*$/.test(s)) return Number(s);
2627
+ return null;
2628
+ }
2629
+ function parseIpv4(input) {
2630
+ let s = input;
2631
+ if (s.endsWith(".")) s = s.slice(0, -1);
2632
+ if (!s) return null;
2633
+ const parts = s.split(".");
2634
+ if (parts.length > 4) return null;
2635
+ const vals = [];
2636
+ for (const p of parts) {
2637
+ const v = parseComponent(p);
2638
+ if (v === null || !Number.isFinite(v) || v < 0) return null;
2639
+ vals.push(v);
2640
+ }
2641
+ const n = vals.length;
2642
+ for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
2643
+ const last = vals[n - 1];
2644
+ const remainingBytes = 4 - (n - 1);
2645
+ const limit = Math.pow(256, remainingBytes);
2646
+ if (last >= limit) return null;
2647
+ let value = last;
2648
+ for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
2649
+ if (value > 4294967295) return null;
2650
+ return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
2651
+ }
2652
+ function expandIpv6(input) {
2653
+ const s = input.toLowerCase();
2654
+ if (!/^[0-9a-f:.]+$/.test(s)) return null;
2655
+ if ((s.match(/::/g) ?? []).length > 1) return null;
2656
+ let head = s;
2657
+ let tailV4 = null;
2658
+ const lastColon = s.lastIndexOf(":");
2659
+ const afterLast = s.slice(lastColon + 1);
2660
+ if (afterLast.includes(".")) {
2661
+ const dotted = parseIpv4(afterLast);
2662
+ if (!dotted) return null;
2663
+ const o = dotted.split(".").map(Number);
2664
+ tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
2665
+ head = s.slice(0, lastColon + 1) + "0";
2666
+ }
2667
+ const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
2668
+ const toGroups = (part) => {
2669
+ if (!part) return [];
2670
+ const out = [];
2671
+ for (const g of part.split(":")) {
2672
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
2673
+ out.push(parseInt(g, 16));
2674
+ }
2675
+ return out;
2676
+ };
2677
+ const left = toGroups(lhs);
2678
+ if (left === null) return null;
2679
+ let right = [];
2680
+ if (rhs !== null) {
2681
+ const r = toGroups(rhs);
2682
+ if (r === null) return null;
2683
+ right = r;
2684
+ }
2685
+ if (tailV4) {
2686
+ if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
2687
+ else left.splice(left.length - 1, 1, ...tailV4);
2688
+ }
2689
+ const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
2690
+ if (rhs === null && groups.length !== 8) return null;
2691
+ if (rhs !== null && left.length + right.length > 8) return null;
2692
+ if (groups.length !== 8) return null;
2693
+ return groups;
2694
+ }
2695
+ function compressIpv6(g) {
2696
+ let bestStart = -1;
2697
+ let bestLen = 0;
2698
+ let i = 0;
2699
+ while (i < 8) {
2700
+ if (g[i] !== 0) {
2701
+ i++;
2702
+ continue;
2703
+ }
2704
+ let j = i;
2705
+ while (j < 8 && g[j] === 0) j++;
2706
+ if (j - i > bestLen) {
2707
+ bestLen = j - i;
2708
+ bestStart = i;
2709
+ }
2710
+ i = j;
2711
+ }
2712
+ const hex = g.map((x) => x.toString(16));
2713
+ if (bestLen < 2) return hex.join(":");
2714
+ return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
2715
+ }
2716
+ function normalizeIpLiteral(host) {
2717
+ try {
2718
+ if (typeof host !== "string") return null;
2719
+ let s = host.trim();
2720
+ if (!s || s.length > SSRF_MAX_HOST) return null;
2721
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
2722
+ const zone = s.indexOf("%");
2723
+ if (zone >= 0) s = s.slice(0, zone);
2724
+ if (!s) return null;
2725
+ if (s.includes(":")) {
2726
+ const g = expandIpv6(s);
2727
+ if (!g) return null;
2728
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
2729
+ if (mapped) {
2730
+ return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
2731
+ }
2732
+ return compressIpv6(g);
2733
+ }
2734
+ return parseIpv4(s);
2735
+ } catch {
2736
+ return null;
2737
+ }
2738
+ }
2739
+ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
2740
+ "169.254.169.254",
2741
+ // AWS / Azure / DigitalOcean / OpenStack IMDS
2742
+ "169.254.170.2",
2743
+ // AWS ECS task role
2744
+ "168.63.129.16",
2745
+ // Azure WireServer
2746
+ "fd00:ec2::254"
2747
+ // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
2748
+ ]);
2749
+ var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
2750
+ var v4Octets = (a) => {
2751
+ const p = a.split(".");
2752
+ return p.length === 4 ? p.map(Number) : null;
2753
+ };
2754
+ function classifySsrf(host) {
2755
+ try {
2756
+ if (typeof host !== "string" || !host) return null;
2757
+ const lower = host.trim().toLowerCase().replace(/\.$/, "");
2758
+ const ip = normalizeIpLiteral(host);
2759
+ if (ip === null) {
2760
+ return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
2761
+ }
2762
+ const hit = (tier, overridable) => ({
2763
+ tier,
2764
+ overridable,
2765
+ kind: "address",
2766
+ normalized: ip
2767
+ });
2768
+ if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
2769
+ const o = v4Octets(ip);
2770
+ if (o) {
2771
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
2772
+ if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
2773
+ if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
2774
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
2775
+ if (o[0] === 127) return hit("private", true);
2776
+ if (o[0] === 10) return hit("private", true);
2777
+ if (o[0] === 192 && o[1] === 168) return hit("private", true);
2778
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
2779
+ return null;
2780
+ }
2781
+ const g = expandIpv6(ip);
2782
+ if (!g) return null;
2783
+ if (g.every((x) => x === 0)) return hit("unspecified", false);
2784
+ if ((g[0] & 65472) === 65152) return hit("link-local", false);
2785
+ if ((g[0] & 65280) === 65280) return hit("multicast", false);
2786
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
2787
+ return null;
2788
+ } catch {
2789
+ return null;
2790
+ }
2791
+ }
2792
+ var TIER_REASON = {
2793
+ metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
2794
+ "link-local": "a link-local address",
2795
+ multicast: "a multicast address",
2796
+ unspecified: "the unspecified address",
2797
+ cgnat: "a carrier-grade NAT address",
2798
+ private: "a loopback or private address"
2799
+ };
2800
+ function ssrfFloor(tokens, opts = {}) {
2801
+ const exempt = new Set(
2802
+ (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
2803
+ );
2804
+ for (const { token, binary } of tokens) {
2805
+ const m = classifySsrf(token);
2806
+ if (!m) continue;
2807
+ if (m.tier === "private" && !opts.ssrfStrict) continue;
2808
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
2809
+ return {
2810
+ ...m,
2811
+ host: token,
2812
+ binary,
2813
+ reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
2814
+ };
2815
+ }
2816
+ return null;
2817
+ }
2818
+
2365
2819
  // src/policy/index.ts
2366
2820
  function resolveCheck(v) {
2367
2821
  return v === "off" || v === "block" ? v : "review";
@@ -2604,6 +3058,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2604
3058
  }
2605
3059
  const builtin = strictestVerdict(candidates);
2606
3060
  if (builtin) return builtin;
3061
+ {
3062
+ const ssrf = ssrfFloor(extractShellDestTokens(shellCommand), {
3063
+ ssrfAllow: config.policy.egress?.ssrfAllow,
3064
+ ssrfStrict: config.policy.egress?.ssrfStrict
3065
+ });
3066
+ if (ssrf) {
3067
+ return {
3068
+ decision: "block",
3069
+ blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
3070
+ reason: ssrf.reason,
3071
+ ruleName: `ssrf:${ssrf.tier}:${ssrf.binary}:${ssrf.host}`,
3072
+ ruleDescription: ssrf.reason,
3073
+ tier: 3
3074
+ };
3075
+ }
3076
+ }
2607
3077
  if (config.policy.egress?.enabled) {
2608
3078
  const dests = extractShellDestinations(shellCommand);
2609
3079
  if (dests.length > 0) {
@@ -3842,34 +4312,249 @@ var FILE_TOOLS = /* @__PURE__ */ new Set([
3842
4312
  var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3843
4313
  var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3844
4314
  var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3845
- 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/;
4315
+ 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/;
4316
+ var PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
4317
+ var PII_IBAN_RE = /\b[A-Z]{2}\d{2}(?:[A-Z0-9]|[ -][A-Z0-9]){11,30}\b/;
4318
+ function hasValidCard(text) {
4319
+ for (const base of [PII_CC16_RE, PII_CC15_RE]) {
4320
+ const re = new RegExp(base.source, "g");
4321
+ let m;
4322
+ while ((m = re.exec(text)) !== null) {
4323
+ if (validateLuhn(m[0].replace(/\D/g, ""))) return true;
4324
+ re.lastIndex = m.index + 1;
4325
+ }
4326
+ }
4327
+ return false;
4328
+ }
4329
+ function hasValidIban(text) {
4330
+ const re = new RegExp(PII_IBAN_RE.source, "g");
4331
+ let m;
4332
+ while ((m = re.exec(text)) !== null) {
4333
+ if (validateIban(m[0])) return true;
4334
+ re.lastIndex = m.index + 1;
4335
+ }
4336
+ return false;
4337
+ }
3846
4338
  function detectPii(text) {
3847
4339
  const found = /* @__PURE__ */ new Set();
3848
4340
  if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
3849
4341
  if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
3850
4342
  if (PII_PHONE_RE.test(text)) found.add("Phone");
3851
- if (PII_CC_RE.test(text)) found.add("Credit Card");
4343
+ if (hasValidCard(text)) found.add("Credit Card");
4344
+ if (hasValidIban(text)) found.add("IBAN");
3852
4345
  return [...found];
3853
4346
  }
3854
- var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
4347
+ var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
3855
4348
  var MAX_PII_SCAN_BYTES = 1e5;
4349
+ function* stringLeaves(v, depth = 0) {
4350
+ if (depth > 6) return;
4351
+ if (typeof v === "string") {
4352
+ if (v.length > 0) yield v;
4353
+ return;
4354
+ }
4355
+ if (typeof v === "number") {
4356
+ if (Number.isFinite(v)) yield String(v);
4357
+ return;
4358
+ }
4359
+ if (!v || typeof v !== "object") return;
4360
+ if (Array.isArray(v)) {
4361
+ for (const x of v) yield* stringLeaves(x, depth + 1);
4362
+ return;
4363
+ }
4364
+ for (const x of Object.values(v)) yield* stringLeaves(x, depth + 1);
4365
+ }
3856
4366
  function detectArgsPii(args) {
3857
4367
  if (args === null || args === void 0) return [];
3858
- let text;
4368
+ const found = /* @__PURE__ */ new Set();
4369
+ let budget = MAX_PII_SCAN_BYTES;
3859
4370
  try {
3860
- text = typeof args === "string" ? args : JSON.stringify(args);
4371
+ for (const leaf of stringLeaves(args)) {
4372
+ if (budget <= 0) break;
4373
+ const t = leaf.length > budget ? leaf.slice(0, budget) : leaf;
4374
+ budget -= t.length;
4375
+ for (const p of detectPii(t)) {
4376
+ if (REALTIME_PII_PATTERNS.includes(p)) found.add(p);
4377
+ }
4378
+ }
3861
4379
  } catch {
3862
4380
  return [];
3863
4381
  }
3864
- if (typeof text !== "string") return [];
3865
- if (text.length > MAX_PII_SCAN_BYTES) text = text.slice(0, MAX_PII_SCAN_BYTES);
3866
- return detectPii(text).filter((p) => REALTIME_PII_PATTERNS.includes(p));
4382
+ return [...found];
4383
+ }
4384
+
4385
+ // src/dlp/canary.ts
4386
+ var CANARY_MIN_LENGTH = 16;
4387
+ var MAX_TEXT = 1e5;
4388
+ var MAX_DEPTH2 = 6;
4389
+ var MAX_JSON_PARSE = 1e4;
4390
+ var URL_DEPTH = 4;
4391
+ var B64_DEPTH = 3;
4392
+ var MIN_SEGMENT = 16;
4393
+ var SEPARATORS = /[./\\?&= \t\n\r:;,\-_@%+#]/g;
4394
+ var stripSeparators = (s) => s.replace(SEPARATORS, "");
4395
+ function percentDecodeOnce(s) {
4396
+ try {
4397
+ return decodeURIComponent(s);
4398
+ } catch {
4399
+ return s.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
4400
+ }
4401
+ }
4402
+ function segments(s, alphabet) {
4403
+ const out = /* @__PURE__ */ new Set();
4404
+ if (alphabet.test(s)) out.add(s);
4405
+ for (const seg of s.split(/[?&\s"'<>]+/)) {
4406
+ if (seg.length >= MIN_SEGMENT && alphabet.test(seg)) out.add(seg);
4407
+ for (const part of seg.split("=")) {
4408
+ if (part.length >= MIN_SEGMENT && alphabet.test(part)) out.add(part);
4409
+ }
4410
+ }
4411
+ return [...out];
4412
+ }
4413
+ var looksText = (s) => s.length > 0 && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(s);
4414
+ var CANARY_DECODERS = {
4415
+ url: (s) => {
4416
+ const out = [];
4417
+ let cur = s;
4418
+ for (let i = 0; i < URL_DEPTH; i++) {
4419
+ const d = percentDecodeOnce(cur);
4420
+ if (d === cur) break;
4421
+ out.push(d);
4422
+ cur = d;
4423
+ }
4424
+ return out;
4425
+ },
4426
+ base64: (s) => {
4427
+ const out = [];
4428
+ let frontier = segments(s, /^[A-Za-z0-9+/\-_=]+$/);
4429
+ for (let depth = 0; depth < B64_DEPTH && frontier.length; depth++) {
4430
+ const next = [];
4431
+ for (const c of frontier) {
4432
+ const d = Buffer.from(c, "base64").toString("utf8");
4433
+ if (!looksText(d) || d.length < CANARY_MIN_LENGTH) continue;
4434
+ out.push(d);
4435
+ next.push(...segments(d, /^[A-Za-z0-9+/\-_=]+$/));
4436
+ }
4437
+ frontier = next;
4438
+ }
4439
+ return out;
4440
+ },
4441
+ hex: (s) => {
4442
+ const out = [];
4443
+ for (const c of segments(s, /^[0-9A-Fa-f]+$/)) {
4444
+ if (c.length % 2 !== 0 || c.length < CANARY_MIN_LENGTH * 2) continue;
4445
+ const d = Buffer.from(c, "hex").toString("utf8");
4446
+ if (looksText(d)) out.push(d);
4447
+ }
4448
+ return out;
4449
+ },
4450
+ separators: (s) => [stripSeparators(s)]
4451
+ };
4452
+ function lowestOffset(cands, needles, stripped) {
4453
+ let best = null;
4454
+ for (const c of cands) {
4455
+ for (const n of needles) {
4456
+ const off = c.indexOf(stripped ? n.stripped : n.raw);
4457
+ if (off >= 0 && (best === null || off < best.off)) best = { off, v: n.v };
4458
+ }
4459
+ }
4460
+ return best?.v ?? null;
4461
+ }
4462
+ var VIEWS = [
4463
+ { view: "url-decoded", decoder: "url", stripped: false },
4464
+ { view: "base64-decoded", decoder: "base64", stripped: false },
4465
+ { view: "hex-decoded", decoder: "hex", stripped: false },
4466
+ { view: "separators-stripped", decoder: "separators", stripped: true }
4467
+ ];
4468
+ function prepare(values) {
4469
+ const out = [];
4470
+ for (const v of values) {
4471
+ if (typeof v.value !== "string" || v.value.length < CANARY_MIN_LENGTH) {
4472
+ console.error(
4473
+ `[node9 engine] canary ${v.id}: value shorter than ${CANARY_MIN_LENGTH}, skipped`
4474
+ );
4475
+ continue;
4476
+ }
4477
+ out.push({ v, raw: v.value, stripped: stripSeparators(v.value) });
4478
+ }
4479
+ return out;
4480
+ }
4481
+ function matchPrepared(text, needles) {
4482
+ if (!text || needles.length === 0) return null;
4483
+ const t = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text;
4484
+ const raw = lowestOffset([t], needles, false);
4485
+ if (raw) return { v: raw, view: "raw" };
4486
+ for (const { view, decoder, stripped } of VIEWS) {
4487
+ let cands;
4488
+ try {
4489
+ cands = CANARY_DECODERS[decoder](t);
4490
+ } catch (e) {
4491
+ console.error(
4492
+ `[node9 engine] canary view ${view} failed, skipped:`,
4493
+ e instanceof Error ? e.message : String(e)
4494
+ );
4495
+ continue;
4496
+ }
4497
+ const hit = lowestOffset(cands, needles, stripped);
4498
+ if (hit) return { v: hit, view };
4499
+ }
4500
+ return null;
4501
+ }
4502
+ function matchCanary(text, values) {
4503
+ if (!text || values.length === 0) return null;
4504
+ const hit = matchPrepared(text, prepare(values));
4505
+ return hit ? { id: hit.v.id, view: hit.view, retired: Boolean(hit.v.retired) } : null;
4506
+ }
4507
+ function matchCanaryArgs(args, values) {
4508
+ if (values.length === 0) return null;
4509
+ const needles = prepare(values);
4510
+ if (needles.length === 0) return null;
4511
+ const walk = (v, depth, fieldPath) => {
4512
+ if (depth > MAX_DEPTH2) return null;
4513
+ if (typeof v === "string") {
4514
+ const hit = matchPrepared(v, needles);
4515
+ if (hit) return { id: hit.v.id, view: hit.view, fieldPath, retired: Boolean(hit.v.retired) };
4516
+ if (v.length < MAX_JSON_PARSE) {
4517
+ const trimmed = v.trim();
4518
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
4519
+ try {
4520
+ return walk(JSON.parse(v), depth + 1, fieldPath);
4521
+ } catch {
4522
+ }
4523
+ }
4524
+ }
4525
+ return null;
4526
+ }
4527
+ if (typeof v === "number" && Number.isFinite(v)) return walk(String(v), depth, fieldPath);
4528
+ if (Array.isArray(v)) {
4529
+ for (let i = 0; i < v.length; i++) {
4530
+ const h = walk(v[i], depth + 1, `${fieldPath}[${i}]`);
4531
+ if (h) return h;
4532
+ }
4533
+ return null;
4534
+ }
4535
+ if (v && typeof v === "object") {
4536
+ for (const [k, child] of Object.entries(v)) {
4537
+ const h = walk(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
4538
+ if (h) return h;
4539
+ }
4540
+ }
4541
+ return null;
4542
+ };
4543
+ try {
4544
+ return walk(args, 0, "");
4545
+ } catch (e) {
4546
+ console.error(
4547
+ "[node9 engine] canary args walk failed:",
4548
+ e instanceof Error ? e.message : String(e)
4549
+ );
4550
+ return null;
4551
+ }
3867
4552
  }
3868
4553
 
3869
4554
  // src/scan/canonical.ts
3870
4555
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3871
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v9";
3872
- var CANONICAL_EXTRACTOR_HASH = "0d6c1ddb9a5af5b7";
4556
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
4557
+ var CANONICAL_EXTRACTOR_HASH = "5c786cc174281e51";
3873
4558
  var DEDUPE_PREVIEW_LEN = 120;
3874
4559
  function extractCanonicalFindings(call, ctx) {
3875
4560
  const out = [];
@@ -3913,6 +4598,28 @@ function extractCanonicalFindings(call, ctx) {
3913
4598
  );
3914
4599
  }
3915
4600
  }
4601
+ if (ctx.canaryValues && ctx.canaryValues.length > 0) {
4602
+ const hit = matchCanaryArgs(call.args, ctx.canaryValues);
4603
+ if (hit) {
4604
+ const v = ctx.canaryValues.find((x) => x.id === hit.id);
4605
+ out.push(
4606
+ makeFinding({
4607
+ type: "canary",
4608
+ ruleName: `canary:${v?.kind ?? "unknown"}`,
4609
+ patternName: "Decoy credential",
4610
+ verdict: "block",
4611
+ severity: "critical",
4612
+ reason: `Decoy credential planted at ${v?.path ?? "a decoy file"} appeared in ${call.toolName} args (${hit.view})`,
4613
+ toolName: call.toolName,
4614
+ ctx,
4615
+ ts,
4616
+ sourceType: "engine"
4617
+ // No `input`: makeFinding stores it verbatim and a finding must never
4618
+ // carry the value (E14). The wire never copies input anyway.
4619
+ })
4620
+ );
4621
+ }
4622
+ }
3916
4623
  for (const value of stringValues(call.args)) {
3917
4624
  const piiHits = detectPii(value);
3918
4625
  for (const pattern of piiHits) {
@@ -4145,6 +4852,9 @@ function toScanFinding(c) {
4145
4852
  "smart-rule": null,
4146
4853
  "ast-fs-op": null,
4147
4854
  dlp: "dlp",
4855
+ // Ships under the dlp rollup with patternName 'Decoy credential' and a
4856
+ // canary:<kind> ruleName until the SaaS wire type gains its own value.
4857
+ canary: "dlp",
4148
4858
  pii: "pii",
4149
4859
  "sensitive-file-read": "sensitive-file-read",
4150
4860
  "privilege-escalation": "privilege-escalation",
@@ -4216,6 +4926,7 @@ export {
4216
4926
  AST_FS_REGEX_RULES,
4217
4927
  BASH_TOOL_NAMES,
4218
4928
  BUILTIN_SHIELDS,
4929
+ CANARY_MIN_LENGTH,
4219
4930
  CANONICAL_EXTRACTOR_HASH,
4220
4931
  CANONICAL_EXTRACTOR_VERSION,
4221
4932
  COST_PER_LOOP_ITER_USD,
@@ -4235,6 +4946,7 @@ export {
4235
4946
  SCAN_SIGNAL_WEIGHTS,
4236
4947
  SENSITIVE_PATH_RE,
4237
4948
  SENSITIVE_PATH_REGEXES,
4949
+ SSRF_MAX_HOST,
4238
4950
  analyzeFsOperation,
4239
4951
  analyzePipeChain,
4240
4952
  analyzeShellCommand,
@@ -4242,6 +4954,7 @@ export {
4242
4954
  classifyAuditEntry,
4243
4955
  classifyRuleSeverity,
4244
4956
  classifyScanSignal,
4957
+ classifySsrf,
4245
4958
  computeAgentDeviceScore,
4246
4959
  computeArgsHash,
4247
4960
  computeBlendedSecurityScore,
@@ -4262,6 +4975,7 @@ export {
4262
4975
  extractNetworkTargets,
4263
4976
  extractPositionalArgs,
4264
4977
  extractSessionLevelFindings,
4978
+ extractShellDestTokens,
4265
4979
  extractShellDestinations,
4266
4980
  getCompiledRegex,
4267
4981
  getNestedValue,
@@ -4272,10 +4986,13 @@ export {
4272
4986
  isProtectedHomePath,
4273
4987
  isShellShapedTool,
4274
4988
  isShieldVerdict,
4989
+ matchCanary,
4990
+ matchCanaryArgs,
4275
4991
  matchSensitivePath,
4276
4992
  matchesPattern,
4277
4993
  narrativeRuleLabel,
4278
4994
  normalizeCommandForPolicy,
4995
+ normalizeIpLiteral,
4279
4996
  parseAllSshHostsFromCommand,
4280
4997
  parseDestHost,
4281
4998
  previewArgs,
@@ -4285,6 +5002,7 @@ export {
4285
5002
  scanInjection,
4286
5003
  scanText,
4287
5004
  sensitivePathMatch,
5005
+ ssrfFloor,
4288
5006
  summarizeBlast,
4289
5007
  summarizeScan,
4290
5008
  toScanFinding,