@node9/policy-engine 2.8.5 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ __export(src_exports, {
33
33
  AST_FS_REGEX_RULES: () => AST_FS_REGEX_RULES,
34
34
  BASH_TOOL_NAMES: () => BASH_TOOL_NAMES,
35
35
  BUILTIN_SHIELDS: () => BUILTIN_SHIELDS,
36
+ CANARY_MIN_LENGTH: () => CANARY_MIN_LENGTH,
36
37
  CANONICAL_EXTRACTOR_HASH: () => CANONICAL_EXTRACTOR_HASH,
37
38
  CANONICAL_EXTRACTOR_VERSION: () => CANONICAL_EXTRACTOR_VERSION,
38
39
  COST_PER_LOOP_ITER_USD: () => COST_PER_LOOP_ITER_USD,
@@ -52,6 +53,7 @@ __export(src_exports, {
52
53
  SCAN_SIGNAL_WEIGHTS: () => SCAN_SIGNAL_WEIGHTS,
53
54
  SENSITIVE_PATH_RE: () => SENSITIVE_PATH_RE,
54
55
  SENSITIVE_PATH_REGEXES: () => SENSITIVE_PATH_REGEXES,
56
+ SSRF_MAX_HOST: () => SSRF_MAX_HOST,
55
57
  analyzeFsOperation: () => analyzeFsOperation,
56
58
  analyzePipeChain: () => analyzePipeChain,
57
59
  analyzeShellCommand: () => analyzeShellCommand,
@@ -59,6 +61,7 @@ __export(src_exports, {
59
61
  classifyAuditEntry: () => classifyAuditEntry,
60
62
  classifyRuleSeverity: () => classifyRuleSeverity,
61
63
  classifyScanSignal: () => classifyScanSignal,
64
+ classifySsrf: () => classifySsrf,
62
65
  computeAgentDeviceScore: () => computeAgentDeviceScore,
63
66
  computeArgsHash: () => computeArgsHash,
64
67
  computeBlendedSecurityScore: () => computeBlendedSecurityScore,
@@ -79,6 +82,7 @@ __export(src_exports, {
79
82
  extractNetworkTargets: () => extractNetworkTargets,
80
83
  extractPositionalArgs: () => extractPositionalArgs,
81
84
  extractSessionLevelFindings: () => extractSessionLevelFindings,
85
+ extractShellDestTokens: () => extractShellDestTokens,
82
86
  extractShellDestinations: () => extractShellDestinations,
83
87
  getCompiledRegex: () => getCompiledRegex,
84
88
  getNestedValue: () => getNestedValue,
@@ -89,10 +93,13 @@ __export(src_exports, {
89
93
  isProtectedHomePath: () => isProtectedHomePath,
90
94
  isShellShapedTool: () => isShellShapedTool,
91
95
  isShieldVerdict: () => isShieldVerdict,
96
+ matchCanary: () => matchCanary,
97
+ matchCanaryArgs: () => matchCanaryArgs,
92
98
  matchSensitivePath: () => matchSensitivePath,
93
99
  matchesPattern: () => matchesPattern,
94
100
  narrativeRuleLabel: () => narrativeRuleLabel,
95
101
  normalizeCommandForPolicy: () => normalizeCommandForPolicy,
102
+ normalizeIpLiteral: () => normalizeIpLiteral,
96
103
  parseAllSshHostsFromCommand: () => parseAllSshHostsFromCommand,
97
104
  parseDestHost: () => parseDestHost,
98
105
  previewArgs: () => previewArgs,
@@ -102,6 +109,7 @@ __export(src_exports, {
102
109
  scanInjection: () => scanInjection,
103
110
  scanText: () => scanText,
104
111
  sensitivePathMatch: () => sensitivePathMatch,
112
+ ssrfFloor: () => ssrfFloor,
105
113
  summarizeBlast: () => summarizeBlast,
106
114
  summarizeScan: () => summarizeScan,
107
115
  toScanFinding: () => toScanFinding,
@@ -116,6 +124,175 @@ module.exports = __toCommonJS(src_exports);
116
124
  // src/dlp/index.ts
117
125
  var import_safe_regex2 = __toESM(require("safe-regex2"));
118
126
 
127
+ // src/scan/checksums.ts
128
+ var import_crypto = require("crypto");
129
+ function validateLuhn(digits) {
130
+ if (!/^\d+$/.test(digits)) return false;
131
+ if (digits.length < 12) return false;
132
+ if (!/[1-9]/.test(digits)) return false;
133
+ let sum = 0;
134
+ let double = false;
135
+ for (let i = digits.length - 1; i >= 0; i--) {
136
+ let d = digits.charCodeAt(i) - 48;
137
+ if (double) {
138
+ d *= 2;
139
+ if (d > 9) d -= 9;
140
+ }
141
+ sum += d;
142
+ double = !double;
143
+ }
144
+ return sum % 10 === 0;
145
+ }
146
+ var IBAN_LENGTH = {
147
+ AD: 24,
148
+ AE: 23,
149
+ AL: 28,
150
+ AT: 20,
151
+ AZ: 28,
152
+ BA: 20,
153
+ BE: 16,
154
+ BG: 22,
155
+ BH: 22,
156
+ BI: 27,
157
+ BR: 29,
158
+ BY: 28,
159
+ CH: 21,
160
+ CR: 22,
161
+ CY: 28,
162
+ CZ: 24,
163
+ DE: 22,
164
+ DJ: 27,
165
+ DK: 18,
166
+ DO: 28,
167
+ EE: 20,
168
+ EG: 29,
169
+ ES: 24,
170
+ FI: 18,
171
+ FK: 18,
172
+ FO: 18,
173
+ FR: 27,
174
+ GB: 22,
175
+ GE: 22,
176
+ GI: 23,
177
+ GL: 18,
178
+ GR: 27,
179
+ GT: 28,
180
+ HN: 28,
181
+ HR: 21,
182
+ HU: 28,
183
+ IE: 22,
184
+ IL: 23,
185
+ IQ: 23,
186
+ IS: 26,
187
+ IT: 27,
188
+ JO: 30,
189
+ KW: 30,
190
+ KZ: 20,
191
+ LB: 28,
192
+ LC: 32,
193
+ LI: 21,
194
+ LT: 20,
195
+ LU: 20,
196
+ LV: 21,
197
+ LY: 25,
198
+ MC: 27,
199
+ MD: 24,
200
+ ME: 22,
201
+ MK: 19,
202
+ MN: 20,
203
+ MR: 27,
204
+ MT: 31,
205
+ MU: 30,
206
+ NI: 28,
207
+ NL: 18,
208
+ NO: 15,
209
+ OM: 23,
210
+ PK: 24,
211
+ PL: 28,
212
+ PS: 29,
213
+ PT: 25,
214
+ QA: 29,
215
+ RO: 24,
216
+ RS: 22,
217
+ RU: 33,
218
+ SA: 24,
219
+ SC: 31,
220
+ SD: 18,
221
+ SE: 24,
222
+ SI: 19,
223
+ SK: 24,
224
+ SM: 27,
225
+ SN: 28,
226
+ SO: 23,
227
+ ST: 25,
228
+ SV: 28,
229
+ TL: 23,
230
+ TN: 24,
231
+ TR: 26,
232
+ UA: 29,
233
+ VA: 22,
234
+ VG: 24,
235
+ XK: 20,
236
+ YE: 30
237
+ };
238
+ function validateIban(raw) {
239
+ const s = raw.replace(/[ -]/g, "").toUpperCase();
240
+ if (!/^[A-Z]{2}\d{2}/.test(s)) return false;
241
+ const want = IBAN_LENGTH[s.slice(0, 2)];
242
+ if (want === void 0 || s.length < want) return false;
243
+ const iban = s.slice(0, want);
244
+ if (!/^[A-Z0-9]+$/.test(iban)) return false;
245
+ const rearranged = iban.slice(4) + iban.slice(0, 4);
246
+ let mod = 0;
247
+ for (const ch of rearranged) {
248
+ const code = ch.charCodeAt(0);
249
+ const digits = code >= 65 ? String(code - 55) : ch;
250
+ for (const d of digits) mod = (mod * 10 + (d.charCodeAt(0) - 48)) % 97;
251
+ }
252
+ return mod === 1;
253
+ }
254
+ var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
255
+ var B58_INDEX = Object.fromEntries(
256
+ [...B58].map((c, i) => [c, i])
257
+ );
258
+ function validateBase58Check(s) {
259
+ if (!s) return null;
260
+ let n = 0n;
261
+ for (const c of s) {
262
+ const v = B58_INDEX[c];
263
+ if (v === void 0) return null;
264
+ n = n * 58n + BigInt(v);
265
+ }
266
+ let hex = n.toString(16);
267
+ if (hex.length % 2) hex = "0" + hex;
268
+ let zeros = 0;
269
+ for (const c of s) {
270
+ if (c !== "1") break;
271
+ zeros++;
272
+ }
273
+ const bytes = Buffer.concat([
274
+ Buffer.alloc(zeros),
275
+ n === 0n ? Buffer.alloc(0) : Buffer.from(hex, "hex")
276
+ ]);
277
+ if (bytes.length < 5) return null;
278
+ const body = bytes.subarray(0, bytes.length - 4);
279
+ const check = bytes.subarray(bytes.length - 4);
280
+ const h = (0, import_crypto.createHash)("sha256").update((0, import_crypto.createHash)("sha256").update(body).digest()).digest();
281
+ return h.subarray(0, 4).equals(check) ? body : null;
282
+ }
283
+ function validateWif(s) {
284
+ const p = validateBase58Check(s);
285
+ if (!p || p[0] !== 128) return false;
286
+ return p.length === 33 || p.length === 34 && p[33] === 1;
287
+ }
288
+ var XPRV_VERSIONS = /* @__PURE__ */ new Set([76066276, 77428856, 78791436]);
289
+ function validateXprv(s) {
290
+ const p = validateBase58Check(s);
291
+ if (!p || p.length !== 78) return false;
292
+ const version = p.readUInt32BE(0);
293
+ return XPRV_VERSIONS.has(version) && p[45] === 0;
294
+ }
295
+
119
296
  // src/dlp/injection.ts
120
297
  var MAX = 1e5;
121
298
  var UNTRUSTED_TOOLS = /\b(web_?fetch|web_?search|fetch|curl|wget|browser|http_get|read_url|open_url)\b/i;
@@ -360,6 +537,27 @@ var DLP_PATTERNS = [
360
537
  severity: "block",
361
538
  keywords: ["sg."]
362
539
  },
540
+ // ── Cryptocurrency private keys (base58check-validated) ───────────────────
541
+ // Both are anchored with \b on each side: unanchored, `[KL][base58]{51}`
542
+ // matches INSIDE any longer base58 blob (an xprv, a Solana keypair, a
543
+ // Monero address). Lookbehind fails safe-regex2; \b is the house style
544
+ // (see the card regexes). Mainnet only, matching validateWif / validateXprv;
545
+ // testnet (WIF 0xEF, tprv) is deferred. Cost was measured: the WIF regex
546
+ // runs on every string (first keyword-less pattern) at 0.024 ms per 100 KB
547
+ // of prose, so no prefilter is warranted.
548
+ {
549
+ name: "Bitcoin WIF Private Key",
550
+ regex: /\b(?:5[1-9A-HJ-NP-Za-km-z]{50}|[KL][1-9A-HJ-NP-Za-km-z]{51})\b/,
551
+ severity: "block",
552
+ validate: validateWif
553
+ },
554
+ {
555
+ name: "Extended Private Key",
556
+ regex: /\b[xyz]prv[1-9A-HJ-NP-Za-km-z]{107}\b/,
557
+ severity: "block",
558
+ keywords: ["xprv", "yprv", "zprv"],
559
+ validate: validateXprv
560
+ },
363
561
  // ── Private keys (PEM) ────────────────────────────────────────────────────
364
562
  {
365
563
  name: "Private Key (PEM)",
@@ -724,6 +922,33 @@ var DLP_SCAN_LIMITS = {
724
922
  /** Max nesting depth walked; anything deeper is NOT scanned. */
725
923
  maxDepth: MAX_DEPTH
726
924
  };
925
+ function suppressed(pattern, raw) {
926
+ if (pattern.validate) {
927
+ let ok;
928
+ try {
929
+ ok = pattern.validate(raw);
930
+ } catch {
931
+ ok = true;
932
+ }
933
+ return !ok;
934
+ }
935
+ if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) return true;
936
+ if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) return true;
937
+ return false;
938
+ }
939
+ function firstAcceptedMatch(pattern, text) {
940
+ const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : pattern.regex.flags + "g";
941
+ const re = new RegExp(pattern.regex.source, flags);
942
+ let m;
943
+ while ((m = re.exec(text)) !== null) {
944
+ if (m[0].length === 0) {
945
+ re.lastIndex = m.index + 1;
946
+ continue;
947
+ }
948
+ if (!suppressed(pattern, m[0])) return m[0];
949
+ }
950
+ return null;
951
+ }
727
952
  function scanArgs(args, depth = 0, fieldPath = "args") {
728
953
  if (depth > MAX_DEPTH || args === null || args === void 0) return null;
729
954
  if (Array.isArray(args)) {
@@ -748,18 +973,16 @@ function scanArgs(args, depth = 0, fieldPath = "args") {
748
973
  if (pattern.keywords && !pattern.keywords.some((kw) => textLower.includes(kw.toLowerCase()))) {
749
974
  continue;
750
975
  }
751
- if (pattern.regex.test(text)) {
752
- const raw = text.match(pattern.regex)?.[0] ?? "";
753
- if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) continue;
754
- if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) continue;
755
- const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
756
- return {
757
- patternName: pattern.name,
758
- fieldPath,
759
- redactedSample: maskSecret(text, pattern.regex),
760
- severity
761
- };
762
- }
976
+ const raw = firstAcceptedMatch(pattern, text);
977
+ if (raw === null) continue;
978
+ const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
979
+ return {
980
+ patternName: pattern.name,
981
+ fieldPath,
982
+ // Mask the ACCEPTED token, not the first regex hit in the field.
983
+ redactedSample: maskSecret(raw, pattern.regex),
984
+ severity
985
+ };
763
986
  }
764
987
  if (text.length < MAX_JSON_PARSE_BYTES) {
765
988
  const trimmed = text.trim();
@@ -782,17 +1005,14 @@ function scanText(text) {
782
1005
  if (pattern.keywords && !pattern.keywords.some((kw) => tLower.includes(kw.toLowerCase()))) {
783
1006
  continue;
784
1007
  }
785
- if (pattern.regex.test(t)) {
786
- const raw = t.match(pattern.regex)?.[0] ?? "";
787
- if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) continue;
788
- if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) continue;
789
- return {
790
- patternName: pattern.name,
791
- fieldPath: "response-text",
792
- redactedSample: maskSecret(t, pattern.regex),
793
- severity: pattern.severity
794
- };
795
- }
1008
+ const raw = firstAcceptedMatch(pattern, t);
1009
+ if (raw === null) continue;
1010
+ return {
1011
+ patternName: pattern.name,
1012
+ fieldPath: "response-text",
1013
+ redactedSample: maskSecret(raw, pattern.regex),
1014
+ severity: pattern.severity
1015
+ };
796
1016
  }
797
1017
  return null;
798
1018
  }
@@ -806,9 +1026,7 @@ function redactText(text) {
806
1026
  continue;
807
1027
  }
808
1028
  result = result.replace(globalRegex, (match) => {
809
- if (DLP_STOPWORDS.some((sw) => match.toLowerCase().includes(sw))) return match;
810
- if (pattern.minEntropy !== void 0 && shannonEntropy(match) < pattern.minEntropy)
811
- return match;
1029
+ if (suppressed(pattern, match)) return match;
812
1030
  if (!found.includes(pattern.name)) found.push(pattern.name);
813
1031
  return `[node9-redacted:${pattern.name}]`;
814
1032
  });
@@ -876,11 +1094,11 @@ function matchesPattern(text, patterns) {
876
1094
  var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
877
1095
  function getNestedValue(obj, path) {
878
1096
  if (!obj || typeof obj !== "object") return null;
879
- const segments = path.split(".");
880
- for (const seg of segments) {
1097
+ const segments2 = path.split(".");
1098
+ for (const seg of segments2) {
881
1099
  if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
882
1100
  }
883
- return segments.reduce((prev, curr) => prev?.[curr], obj);
1101
+ return segments2.reduce((prev, curr) => prev?.[curr], obj);
884
1102
  }
885
1103
  function evaluateSmartConditions(args, rule) {
886
1104
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -1851,6 +2069,50 @@ function extractShellDestinations(command) {
1851
2069
  }
1852
2070
  return out;
1853
2071
  }
2072
+ function extractShellDestTokens(command) {
2073
+ const f = parseShared(command);
2074
+ if (f === PARSE_FAIL) return [];
2075
+ const out = [];
2076
+ const seen = /* @__PURE__ */ new Set();
2077
+ try {
2078
+ syntax.Walk(f, (node) => {
2079
+ if (!node) return false;
2080
+ const n = node;
2081
+ if (syntax.NodeType(n) !== "CallExpr") return true;
2082
+ const callArgs = n.Args || [];
2083
+ if (callArgs.length === 0) return true;
2084
+ const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
2085
+ if (!NET_BINARIES.has(name)) return true;
2086
+ const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
2087
+ for (const raw of destTokensForBinary(name, rest)) {
2088
+ if (!raw) continue;
2089
+ let tok = raw.trim();
2090
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(tok);
2091
+ const hasScheme = scheme !== null;
2092
+ if (hasScheme) tok = tok.slice(scheme[0].length);
2093
+ tok = tok.split(/[/?#]/)[0];
2094
+ const at = tok.lastIndexOf("@");
2095
+ if (at >= 0) tok = tok.slice(at + 1);
2096
+ if (tok.startsWith("[")) {
2097
+ const close = tok.indexOf("]");
2098
+ if (close > 0) tok = tok.slice(0, close + 1);
2099
+ } else {
2100
+ tok = tok.split(":")[0];
2101
+ }
2102
+ if (!tok) continue;
2103
+ if (!hasScheme && /^\d+$/.test(tok) && Number(tok) < 16777216) continue;
2104
+ const key = `${name}:${tok}`;
2105
+ if (seen.has(key)) continue;
2106
+ seen.add(key);
2107
+ out.push({ token: tok, binary: name });
2108
+ }
2109
+ return true;
2110
+ });
2111
+ } catch {
2112
+ return out;
2113
+ }
2114
+ return out;
2115
+ }
1854
2116
  var FS_OP_CACHE_MAX = 5e3;
1855
2117
  var fsOpCache = /* @__PURE__ */ new Map();
1856
2118
  function analyzeFsOperation(command) {
@@ -2055,8 +2317,8 @@ function analyzeShellCommand(command) {
2055
2317
  if (allTokens.length === 0) {
2056
2318
  const normalized = command.replace(/\\(.)/g, "$1");
2057
2319
  const sanitized = normalized.replace(/["'<>]/g, " ");
2058
- const segments = sanitized.split(/[|;&]|\$\(|\)|`/);
2059
- segments.forEach((segment) => {
2320
+ const segments2 = sanitized.split(/[|;&]|\$\(|\)|`/);
2321
+ segments2.forEach((segment) => {
2060
2322
  const tokens = segment.trim().split(/\s+/).filter(Boolean);
2061
2323
  if (tokens.length > 0) {
2062
2324
  const action = tokens[0].toLowerCase();
@@ -2223,7 +2485,7 @@ function isSensitivePath(p) {
2223
2485
  return SENSITIVE_PATTERNS.some((re) => re.test(p));
2224
2486
  }
2225
2487
  function splitOnPipe(cmd) {
2226
- const segments = [];
2488
+ const segments2 = [];
2227
2489
  let current = "";
2228
2490
  let inSingle = false;
2229
2491
  let inDouble = false;
@@ -2236,21 +2498,21 @@ function splitOnPipe(cmd) {
2236
2498
  inDouble = !inDouble;
2237
2499
  current += ch;
2238
2500
  } else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
2239
- segments.push(current.trim());
2501
+ segments2.push(current.trim());
2240
2502
  current = "";
2241
2503
  } else {
2242
2504
  current += ch;
2243
2505
  }
2244
2506
  }
2245
- if (current.trim()) segments.push(current.trim());
2246
- return segments.filter(Boolean);
2507
+ if (current.trim()) segments2.push(current.trim());
2508
+ return segments2.filter(Boolean);
2247
2509
  }
2248
2510
  function positionalTokens(segment) {
2249
2511
  return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2250
2512
  }
2251
2513
  function analyzePipeChain(command) {
2252
- const segments = splitOnPipe(command);
2253
- if (segments.length < 2) {
2514
+ const segments2 = splitOnPipe(command);
2515
+ if (segments2.length < 2) {
2254
2516
  return {
2255
2517
  isPipeline: false,
2256
2518
  hasSensitiveSource: false,
@@ -2266,7 +2528,7 @@ function analyzePipeChain(command) {
2266
2528
  let hasSensitiveSource = false;
2267
2529
  let hasExternalSink = false;
2268
2530
  let hasObfuscation = false;
2269
- for (const segment of segments) {
2531
+ for (const segment of segments2) {
2270
2532
  const tokens = segment.split(/\s+/).filter(Boolean);
2271
2533
  if (tokens.length === 0) continue;
2272
2534
  const binary = tokens[0].toLowerCase();
@@ -2306,8 +2568,8 @@ function analyzePipeChain(command) {
2306
2568
 
2307
2569
  // src/policy/flag-tables.ts
2308
2570
  function basename(p) {
2309
- const segments = p.split(/[\\/]/);
2310
- return segments[segments.length - 1] || "";
2571
+ const segments2 = p.split(/[\\/]/);
2572
+ return segments2[segments2.length - 1] || "";
2311
2573
  }
2312
2574
  var FLAGS_WITH_VALUES = {
2313
2575
  curl: /* @__PURE__ */ new Set([
@@ -2477,6 +2739,206 @@ function parseAllSshHostsFromCommand(command) {
2477
2739
  return extractAllSshHosts(tokens.slice(1));
2478
2740
  }
2479
2741
 
2742
+ // src/egress/ssrf.ts
2743
+ var SSRF_MAX_HOST = 253;
2744
+ function parseComponent(s) {
2745
+ if (!s) return null;
2746
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
2747
+ if (s === "0") return 0;
2748
+ if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
2749
+ if (/^[1-9][0-9]*$/.test(s)) return Number(s);
2750
+ return null;
2751
+ }
2752
+ function parseIpv4(input) {
2753
+ let s = input;
2754
+ if (s.endsWith(".")) s = s.slice(0, -1);
2755
+ if (!s) return null;
2756
+ const parts = s.split(".");
2757
+ if (parts.length > 4) return null;
2758
+ const vals = [];
2759
+ for (const p of parts) {
2760
+ const v = parseComponent(p);
2761
+ if (v === null || !Number.isFinite(v) || v < 0) return null;
2762
+ vals.push(v);
2763
+ }
2764
+ const n = vals.length;
2765
+ for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
2766
+ const last = vals[n - 1];
2767
+ const remainingBytes = 4 - (n - 1);
2768
+ const limit = Math.pow(256, remainingBytes);
2769
+ if (last >= limit) return null;
2770
+ let value = last;
2771
+ for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
2772
+ if (value > 4294967295) return null;
2773
+ return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
2774
+ }
2775
+ function expandIpv6(input) {
2776
+ const s = input.toLowerCase();
2777
+ if (!/^[0-9a-f:.]+$/.test(s)) return null;
2778
+ if ((s.match(/::/g) ?? []).length > 1) return null;
2779
+ let head = s;
2780
+ let tailV4 = null;
2781
+ const lastColon = s.lastIndexOf(":");
2782
+ const afterLast = s.slice(lastColon + 1);
2783
+ if (afterLast.includes(".")) {
2784
+ const dotted = parseIpv4(afterLast);
2785
+ if (!dotted) return null;
2786
+ const o = dotted.split(".").map(Number);
2787
+ tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
2788
+ head = s.slice(0, lastColon + 1) + "0";
2789
+ }
2790
+ const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
2791
+ const toGroups = (part) => {
2792
+ if (!part) return [];
2793
+ const out = [];
2794
+ for (const g of part.split(":")) {
2795
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
2796
+ out.push(parseInt(g, 16));
2797
+ }
2798
+ return out;
2799
+ };
2800
+ const left = toGroups(lhs);
2801
+ if (left === null) return null;
2802
+ let right = [];
2803
+ if (rhs !== null) {
2804
+ const r = toGroups(rhs);
2805
+ if (r === null) return null;
2806
+ right = r;
2807
+ }
2808
+ if (tailV4) {
2809
+ if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
2810
+ else left.splice(left.length - 1, 1, ...tailV4);
2811
+ }
2812
+ const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
2813
+ if (rhs === null && groups.length !== 8) return null;
2814
+ if (rhs !== null && left.length + right.length > 8) return null;
2815
+ if (groups.length !== 8) return null;
2816
+ return groups;
2817
+ }
2818
+ function compressIpv6(g) {
2819
+ let bestStart = -1;
2820
+ let bestLen = 0;
2821
+ let i = 0;
2822
+ while (i < 8) {
2823
+ if (g[i] !== 0) {
2824
+ i++;
2825
+ continue;
2826
+ }
2827
+ let j = i;
2828
+ while (j < 8 && g[j] === 0) j++;
2829
+ if (j - i > bestLen) {
2830
+ bestLen = j - i;
2831
+ bestStart = i;
2832
+ }
2833
+ i = j;
2834
+ }
2835
+ const hex = g.map((x) => x.toString(16));
2836
+ if (bestLen < 2) return hex.join(":");
2837
+ return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
2838
+ }
2839
+ function normalizeIpLiteral(host) {
2840
+ try {
2841
+ if (typeof host !== "string") return null;
2842
+ let s = host.trim();
2843
+ if (!s || s.length > SSRF_MAX_HOST) return null;
2844
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
2845
+ const zone = s.indexOf("%");
2846
+ if (zone >= 0) s = s.slice(0, zone);
2847
+ if (!s) return null;
2848
+ if (s.includes(":")) {
2849
+ const g = expandIpv6(s);
2850
+ if (!g) return null;
2851
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
2852
+ if (mapped) {
2853
+ return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
2854
+ }
2855
+ return compressIpv6(g);
2856
+ }
2857
+ return parseIpv4(s);
2858
+ } catch {
2859
+ return null;
2860
+ }
2861
+ }
2862
+ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
2863
+ "169.254.169.254",
2864
+ // AWS / Azure / DigitalOcean / OpenStack IMDS
2865
+ "169.254.170.2",
2866
+ // AWS ECS task role
2867
+ "168.63.129.16",
2868
+ // Azure WireServer
2869
+ "fd00:ec2::254"
2870
+ // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
2871
+ ]);
2872
+ var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
2873
+ var v4Octets = (a) => {
2874
+ const p = a.split(".");
2875
+ return p.length === 4 ? p.map(Number) : null;
2876
+ };
2877
+ function classifySsrf(host) {
2878
+ try {
2879
+ if (typeof host !== "string" || !host) return null;
2880
+ const lower = host.trim().toLowerCase().replace(/\.$/, "");
2881
+ const ip = normalizeIpLiteral(host);
2882
+ if (ip === null) {
2883
+ return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
2884
+ }
2885
+ const hit = (tier, overridable) => ({
2886
+ tier,
2887
+ overridable,
2888
+ kind: "address",
2889
+ normalized: ip
2890
+ });
2891
+ if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
2892
+ const o = v4Octets(ip);
2893
+ if (o) {
2894
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
2895
+ if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
2896
+ if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
2897
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
2898
+ if (o[0] === 127) return hit("private", true);
2899
+ if (o[0] === 10) return hit("private", true);
2900
+ if (o[0] === 192 && o[1] === 168) return hit("private", true);
2901
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
2902
+ return null;
2903
+ }
2904
+ const g = expandIpv6(ip);
2905
+ if (!g) return null;
2906
+ if (g.every((x) => x === 0)) return hit("unspecified", false);
2907
+ if ((g[0] & 65472) === 65152) return hit("link-local", false);
2908
+ if ((g[0] & 65280) === 65280) return hit("multicast", false);
2909
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
2910
+ return null;
2911
+ } catch {
2912
+ return null;
2913
+ }
2914
+ }
2915
+ var TIER_REASON = {
2916
+ metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
2917
+ "link-local": "a link-local address",
2918
+ multicast: "a multicast address",
2919
+ unspecified: "the unspecified address",
2920
+ cgnat: "a carrier-grade NAT address",
2921
+ private: "a loopback or private address"
2922
+ };
2923
+ function ssrfFloor(tokens, opts = {}) {
2924
+ const exempt = new Set(
2925
+ (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
2926
+ );
2927
+ for (const { token, binary } of tokens) {
2928
+ const m = classifySsrf(token);
2929
+ if (!m) continue;
2930
+ if (m.tier === "private" && !opts.ssrfStrict) continue;
2931
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
2932
+ return {
2933
+ ...m,
2934
+ host: token,
2935
+ binary,
2936
+ reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
2937
+ };
2938
+ }
2939
+ return null;
2940
+ }
2941
+
2480
2942
  // src/policy/index.ts
2481
2943
  function resolveCheck(v) {
2482
2944
  return v === "off" || v === "block" ? v : "review";
@@ -2719,6 +3181,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2719
3181
  }
2720
3182
  const builtin = strictestVerdict(candidates);
2721
3183
  if (builtin) return builtin;
3184
+ {
3185
+ const ssrf = ssrfFloor(extractShellDestTokens(shellCommand), {
3186
+ ssrfAllow: config.policy.egress?.ssrfAllow,
3187
+ ssrfStrict: config.policy.egress?.ssrfStrict
3188
+ });
3189
+ if (ssrf) {
3190
+ return {
3191
+ decision: "block",
3192
+ blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
3193
+ reason: ssrf.reason,
3194
+ ruleName: `ssrf:${ssrf.tier}:${ssrf.binary}:${ssrf.host}`,
3195
+ ruleDescription: ssrf.reason,
3196
+ tier: 3
3197
+ };
3198
+ }
3199
+ }
2722
3200
  if (config.policy.egress?.enabled) {
2723
3201
  const dests = extractShellDestinations(shellCommand);
2724
3202
  if (dests.length > 0) {
@@ -3670,11 +4148,11 @@ function assertBuiltinShieldRegexesAreSafe() {
3670
4148
  assertBuiltinShieldRegexesAreSafe();
3671
4149
 
3672
4150
  // src/loop/index.ts
3673
- var import_crypto = __toESM(require("crypto"));
4151
+ var import_crypto2 = __toESM(require("crypto"));
3674
4152
  var LOOP_MAX_RECORDS = 500;
3675
4153
  function computeArgsHash(args) {
3676
4154
  const str = JSON.stringify(args ?? "");
3677
- return import_crypto.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
4155
+ return import_crypto2.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
3678
4156
  }
3679
4157
  function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
3680
4158
  const hash = computeArgsHash(args);
@@ -3957,34 +4435,249 @@ var FILE_TOOLS = /* @__PURE__ */ new Set([
3957
4435
  var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3958
4436
  var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3959
4437
  var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3960
- 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/;
4438
+ 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/;
4439
+ var PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
4440
+ var PII_IBAN_RE = /\b[A-Z]{2}\d{2}(?:[A-Z0-9]|[ -][A-Z0-9]){11,30}\b/;
4441
+ function hasValidCard(text) {
4442
+ for (const base of [PII_CC16_RE, PII_CC15_RE]) {
4443
+ const re = new RegExp(base.source, "g");
4444
+ let m;
4445
+ while ((m = re.exec(text)) !== null) {
4446
+ if (validateLuhn(m[0].replace(/\D/g, ""))) return true;
4447
+ re.lastIndex = m.index + 1;
4448
+ }
4449
+ }
4450
+ return false;
4451
+ }
4452
+ function hasValidIban(text) {
4453
+ const re = new RegExp(PII_IBAN_RE.source, "g");
4454
+ let m;
4455
+ while ((m = re.exec(text)) !== null) {
4456
+ if (validateIban(m[0])) return true;
4457
+ re.lastIndex = m.index + 1;
4458
+ }
4459
+ return false;
4460
+ }
3961
4461
  function detectPii(text) {
3962
4462
  const found = /* @__PURE__ */ new Set();
3963
4463
  if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
3964
4464
  if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
3965
4465
  if (PII_PHONE_RE.test(text)) found.add("Phone");
3966
- if (PII_CC_RE.test(text)) found.add("Credit Card");
4466
+ if (hasValidCard(text)) found.add("Credit Card");
4467
+ if (hasValidIban(text)) found.add("IBAN");
3967
4468
  return [...found];
3968
4469
  }
3969
- var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
4470
+ var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
3970
4471
  var MAX_PII_SCAN_BYTES = 1e5;
4472
+ function* stringLeaves(v, depth = 0) {
4473
+ if (depth > 6) return;
4474
+ if (typeof v === "string") {
4475
+ if (v.length > 0) yield v;
4476
+ return;
4477
+ }
4478
+ if (typeof v === "number") {
4479
+ if (Number.isFinite(v)) yield String(v);
4480
+ return;
4481
+ }
4482
+ if (!v || typeof v !== "object") return;
4483
+ if (Array.isArray(v)) {
4484
+ for (const x of v) yield* stringLeaves(x, depth + 1);
4485
+ return;
4486
+ }
4487
+ for (const x of Object.values(v)) yield* stringLeaves(x, depth + 1);
4488
+ }
3971
4489
  function detectArgsPii(args) {
3972
4490
  if (args === null || args === void 0) return [];
3973
- let text;
4491
+ const found = /* @__PURE__ */ new Set();
4492
+ let budget = MAX_PII_SCAN_BYTES;
3974
4493
  try {
3975
- text = typeof args === "string" ? args : JSON.stringify(args);
4494
+ for (const leaf of stringLeaves(args)) {
4495
+ if (budget <= 0) break;
4496
+ const t = leaf.length > budget ? leaf.slice(0, budget) : leaf;
4497
+ budget -= t.length;
4498
+ for (const p of detectPii(t)) {
4499
+ if (REALTIME_PII_PATTERNS.includes(p)) found.add(p);
4500
+ }
4501
+ }
3976
4502
  } catch {
3977
4503
  return [];
3978
4504
  }
3979
- if (typeof text !== "string") return [];
3980
- if (text.length > MAX_PII_SCAN_BYTES) text = text.slice(0, MAX_PII_SCAN_BYTES);
3981
- return detectPii(text).filter((p) => REALTIME_PII_PATTERNS.includes(p));
4505
+ return [...found];
4506
+ }
4507
+
4508
+ // src/dlp/canary.ts
4509
+ var CANARY_MIN_LENGTH = 16;
4510
+ var MAX_TEXT = 1e5;
4511
+ var MAX_DEPTH2 = 6;
4512
+ var MAX_JSON_PARSE = 1e4;
4513
+ var URL_DEPTH = 4;
4514
+ var B64_DEPTH = 3;
4515
+ var MIN_SEGMENT = 16;
4516
+ var SEPARATORS = /[./\\?&= \t\n\r:;,\-_@%+#]/g;
4517
+ var stripSeparators = (s) => s.replace(SEPARATORS, "");
4518
+ function percentDecodeOnce(s) {
4519
+ try {
4520
+ return decodeURIComponent(s);
4521
+ } catch {
4522
+ return s.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
4523
+ }
4524
+ }
4525
+ function segments(s, alphabet) {
4526
+ const out = /* @__PURE__ */ new Set();
4527
+ if (alphabet.test(s)) out.add(s);
4528
+ for (const seg of s.split(/[?&\s"'<>]+/)) {
4529
+ if (seg.length >= MIN_SEGMENT && alphabet.test(seg)) out.add(seg);
4530
+ for (const part of seg.split("=")) {
4531
+ if (part.length >= MIN_SEGMENT && alphabet.test(part)) out.add(part);
4532
+ }
4533
+ }
4534
+ return [...out];
4535
+ }
4536
+ var looksText = (s) => s.length > 0 && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(s);
4537
+ var CANARY_DECODERS = {
4538
+ url: (s) => {
4539
+ const out = [];
4540
+ let cur = s;
4541
+ for (let i = 0; i < URL_DEPTH; i++) {
4542
+ const d = percentDecodeOnce(cur);
4543
+ if (d === cur) break;
4544
+ out.push(d);
4545
+ cur = d;
4546
+ }
4547
+ return out;
4548
+ },
4549
+ base64: (s) => {
4550
+ const out = [];
4551
+ let frontier = segments(s, /^[A-Za-z0-9+/\-_=]+$/);
4552
+ for (let depth = 0; depth < B64_DEPTH && frontier.length; depth++) {
4553
+ const next = [];
4554
+ for (const c of frontier) {
4555
+ const d = Buffer.from(c, "base64").toString("utf8");
4556
+ if (!looksText(d) || d.length < CANARY_MIN_LENGTH) continue;
4557
+ out.push(d);
4558
+ next.push(...segments(d, /^[A-Za-z0-9+/\-_=]+$/));
4559
+ }
4560
+ frontier = next;
4561
+ }
4562
+ return out;
4563
+ },
4564
+ hex: (s) => {
4565
+ const out = [];
4566
+ for (const c of segments(s, /^[0-9A-Fa-f]+$/)) {
4567
+ if (c.length % 2 !== 0 || c.length < CANARY_MIN_LENGTH * 2) continue;
4568
+ const d = Buffer.from(c, "hex").toString("utf8");
4569
+ if (looksText(d)) out.push(d);
4570
+ }
4571
+ return out;
4572
+ },
4573
+ separators: (s) => [stripSeparators(s)]
4574
+ };
4575
+ function lowestOffset(cands, needles, stripped) {
4576
+ let best = null;
4577
+ for (const c of cands) {
4578
+ for (const n of needles) {
4579
+ const off = c.indexOf(stripped ? n.stripped : n.raw);
4580
+ if (off >= 0 && (best === null || off < best.off)) best = { off, v: n.v };
4581
+ }
4582
+ }
4583
+ return best?.v ?? null;
4584
+ }
4585
+ var VIEWS = [
4586
+ { view: "url-decoded", decoder: "url", stripped: false },
4587
+ { view: "base64-decoded", decoder: "base64", stripped: false },
4588
+ { view: "hex-decoded", decoder: "hex", stripped: false },
4589
+ { view: "separators-stripped", decoder: "separators", stripped: true }
4590
+ ];
4591
+ function prepare(values) {
4592
+ const out = [];
4593
+ for (const v of values) {
4594
+ if (typeof v.value !== "string" || v.value.length < CANARY_MIN_LENGTH) {
4595
+ console.error(
4596
+ `[node9 engine] canary ${v.id}: value shorter than ${CANARY_MIN_LENGTH}, skipped`
4597
+ );
4598
+ continue;
4599
+ }
4600
+ out.push({ v, raw: v.value, stripped: stripSeparators(v.value) });
4601
+ }
4602
+ return out;
4603
+ }
4604
+ function matchPrepared(text, needles) {
4605
+ if (!text || needles.length === 0) return null;
4606
+ const t = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text;
4607
+ const raw = lowestOffset([t], needles, false);
4608
+ if (raw) return { v: raw, view: "raw" };
4609
+ for (const { view, decoder, stripped } of VIEWS) {
4610
+ let cands;
4611
+ try {
4612
+ cands = CANARY_DECODERS[decoder](t);
4613
+ } catch (e) {
4614
+ console.error(
4615
+ `[node9 engine] canary view ${view} failed, skipped:`,
4616
+ e instanceof Error ? e.message : String(e)
4617
+ );
4618
+ continue;
4619
+ }
4620
+ const hit = lowestOffset(cands, needles, stripped);
4621
+ if (hit) return { v: hit, view };
4622
+ }
4623
+ return null;
4624
+ }
4625
+ function matchCanary(text, values) {
4626
+ if (!text || values.length === 0) return null;
4627
+ const hit = matchPrepared(text, prepare(values));
4628
+ return hit ? { id: hit.v.id, view: hit.view, retired: Boolean(hit.v.retired) } : null;
4629
+ }
4630
+ function matchCanaryArgs(args, values) {
4631
+ if (values.length === 0) return null;
4632
+ const needles = prepare(values);
4633
+ if (needles.length === 0) return null;
4634
+ const walk = (v, depth, fieldPath) => {
4635
+ if (depth > MAX_DEPTH2) return null;
4636
+ if (typeof v === "string") {
4637
+ const hit = matchPrepared(v, needles);
4638
+ if (hit) return { id: hit.v.id, view: hit.view, fieldPath, retired: Boolean(hit.v.retired) };
4639
+ if (v.length < MAX_JSON_PARSE) {
4640
+ const trimmed = v.trim();
4641
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
4642
+ try {
4643
+ return walk(JSON.parse(v), depth + 1, fieldPath);
4644
+ } catch {
4645
+ }
4646
+ }
4647
+ }
4648
+ return null;
4649
+ }
4650
+ if (typeof v === "number" && Number.isFinite(v)) return walk(String(v), depth, fieldPath);
4651
+ if (Array.isArray(v)) {
4652
+ for (let i = 0; i < v.length; i++) {
4653
+ const h = walk(v[i], depth + 1, `${fieldPath}[${i}]`);
4654
+ if (h) return h;
4655
+ }
4656
+ return null;
4657
+ }
4658
+ if (v && typeof v === "object") {
4659
+ for (const [k, child] of Object.entries(v)) {
4660
+ const h = walk(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
4661
+ if (h) return h;
4662
+ }
4663
+ }
4664
+ return null;
4665
+ };
4666
+ try {
4667
+ return walk(args, 0, "");
4668
+ } catch (e) {
4669
+ console.error(
4670
+ "[node9 engine] canary args walk failed:",
4671
+ e instanceof Error ? e.message : String(e)
4672
+ );
4673
+ return null;
4674
+ }
3982
4675
  }
3983
4676
 
3984
4677
  // src/scan/canonical.ts
3985
4678
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3986
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v9";
3987
- var CANONICAL_EXTRACTOR_HASH = "0d6c1ddb9a5af5b7";
4679
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
4680
+ var CANONICAL_EXTRACTOR_HASH = "5c786cc174281e51";
3988
4681
  var DEDUPE_PREVIEW_LEN = 120;
3989
4682
  function extractCanonicalFindings(call, ctx) {
3990
4683
  const out = [];
@@ -4028,6 +4721,28 @@ function extractCanonicalFindings(call, ctx) {
4028
4721
  );
4029
4722
  }
4030
4723
  }
4724
+ if (ctx.canaryValues && ctx.canaryValues.length > 0) {
4725
+ const hit = matchCanaryArgs(call.args, ctx.canaryValues);
4726
+ if (hit) {
4727
+ const v = ctx.canaryValues.find((x) => x.id === hit.id);
4728
+ out.push(
4729
+ makeFinding({
4730
+ type: "canary",
4731
+ ruleName: `canary:${v?.kind ?? "unknown"}`,
4732
+ patternName: "Decoy credential",
4733
+ verdict: "block",
4734
+ severity: "critical",
4735
+ reason: `Decoy credential planted at ${v?.path ?? "a decoy file"} appeared in ${call.toolName} args (${hit.view})`,
4736
+ toolName: call.toolName,
4737
+ ctx,
4738
+ ts,
4739
+ sourceType: "engine"
4740
+ // No `input`: makeFinding stores it verbatim and a finding must never
4741
+ // carry the value (E14). The wire never copies input anyway.
4742
+ })
4743
+ );
4744
+ }
4745
+ }
4031
4746
  for (const value of stringValues(call.args)) {
4032
4747
  const piiHits = detectPii(value);
4033
4748
  for (const pattern of piiHits) {
@@ -4260,6 +4975,9 @@ function toScanFinding(c) {
4260
4975
  "smart-rule": null,
4261
4976
  "ast-fs-op": null,
4262
4977
  dlp: "dlp",
4978
+ // Ships under the dlp rollup with patternName 'Decoy credential' and a
4979
+ // canary:<kind> ruleName until the SaaS wire type gains its own value.
4980
+ canary: "dlp",
4263
4981
  pii: "pii",
4264
4982
  "sensitive-file-read": "sensitive-file-read",
4265
4983
  "privilege-escalation": "privilege-escalation",
@@ -4332,6 +5050,7 @@ var ENGINE_VERSION = "1.4.0";
4332
5050
  AST_FS_REGEX_RULES,
4333
5051
  BASH_TOOL_NAMES,
4334
5052
  BUILTIN_SHIELDS,
5053
+ CANARY_MIN_LENGTH,
4335
5054
  CANONICAL_EXTRACTOR_HASH,
4336
5055
  CANONICAL_EXTRACTOR_VERSION,
4337
5056
  COST_PER_LOOP_ITER_USD,
@@ -4351,6 +5070,7 @@ var ENGINE_VERSION = "1.4.0";
4351
5070
  SCAN_SIGNAL_WEIGHTS,
4352
5071
  SENSITIVE_PATH_RE,
4353
5072
  SENSITIVE_PATH_REGEXES,
5073
+ SSRF_MAX_HOST,
4354
5074
  analyzeFsOperation,
4355
5075
  analyzePipeChain,
4356
5076
  analyzeShellCommand,
@@ -4358,6 +5078,7 @@ var ENGINE_VERSION = "1.4.0";
4358
5078
  classifyAuditEntry,
4359
5079
  classifyRuleSeverity,
4360
5080
  classifyScanSignal,
5081
+ classifySsrf,
4361
5082
  computeAgentDeviceScore,
4362
5083
  computeArgsHash,
4363
5084
  computeBlendedSecurityScore,
@@ -4378,6 +5099,7 @@ var ENGINE_VERSION = "1.4.0";
4378
5099
  extractNetworkTargets,
4379
5100
  extractPositionalArgs,
4380
5101
  extractSessionLevelFindings,
5102
+ extractShellDestTokens,
4381
5103
  extractShellDestinations,
4382
5104
  getCompiledRegex,
4383
5105
  getNestedValue,
@@ -4388,10 +5110,13 @@ var ENGINE_VERSION = "1.4.0";
4388
5110
  isProtectedHomePath,
4389
5111
  isShellShapedTool,
4390
5112
  isShieldVerdict,
5113
+ matchCanary,
5114
+ matchCanaryArgs,
4391
5115
  matchSensitivePath,
4392
5116
  matchesPattern,
4393
5117
  narrativeRuleLabel,
4394
5118
  normalizeCommandForPolicy,
5119
+ normalizeIpLiteral,
4395
5120
  parseAllSshHostsFromCommand,
4396
5121
  parseDestHost,
4397
5122
  previewArgs,
@@ -4401,6 +5126,7 @@ var ENGINE_VERSION = "1.4.0";
4401
5126
  scanInjection,
4402
5127
  scanText,
4403
5128
  sensitivePathMatch,
5129
+ ssrfFloor,
4404
5130
  summarizeBlast,
4405
5131
  summarizeScan,
4406
5132
  toScanFinding,