@node9/policy-engine 2.22.1 → 2.23.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.d.mts CHANGED
@@ -1351,7 +1351,7 @@ declare const LONG_OUTPUT_THRESHOLD_BYTES: number;
1351
1351
  * and fails CI when the hash drifts without a version bump — forgetting
1352
1352
  * is loud, not silent.
1353
1353
  */
1354
- declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v17";
1354
+ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v18";
1355
1355
  /**
1356
1356
  * SHA-256 prefix of the detector-source files
1357
1357
  * (canonical.ts + pii.ts + destructive-regex.ts).
@@ -1362,7 +1362,7 @@ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v17";
1362
1362
  * files changed, this hash must change too, and you must consciously
1363
1363
  * decide whether to bump CANONICAL_EXTRACTOR_VERSION."
1364
1364
  */
1365
- declare const CANONICAL_EXTRACTOR_HASH = "ca0a79b38a804347";
1365
+ declare const CANONICAL_EXTRACTOR_HASH = "5b2e76537f368d99";
1366
1366
  declare function extractCanonicalFindings(call: ToolCallEntry, ctx: ExtractContext): CanonicalFinding[];
1367
1367
  declare function extractSessionLevelFindings(calls: ReadonlyArray<SessionToolCall>, ctx: SessionExtractContext): CanonicalFinding[];
1368
1368
  /**
package/dist/index.d.ts CHANGED
@@ -1351,7 +1351,7 @@ declare const LONG_OUTPUT_THRESHOLD_BYTES: number;
1351
1351
  * and fails CI when the hash drifts without a version bump — forgetting
1352
1352
  * is loud, not silent.
1353
1353
  */
1354
- declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v17";
1354
+ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v18";
1355
1355
  /**
1356
1356
  * SHA-256 prefix of the detector-source files
1357
1357
  * (canonical.ts + pii.ts + destructive-regex.ts).
@@ -1362,7 +1362,7 @@ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v17";
1362
1362
  * files changed, this hash must change too, and you must consciously
1363
1363
  * decide whether to bump CANONICAL_EXTRACTOR_VERSION."
1364
1364
  */
1365
- declare const CANONICAL_EXTRACTOR_HASH = "ca0a79b38a804347";
1365
+ declare const CANONICAL_EXTRACTOR_HASH = "5b2e76537f368d99";
1366
1366
  declare function extractCanonicalFindings(call: ToolCallEntry, ctx: ExtractContext): CanonicalFinding[];
1367
1367
  declare function extractSessionLevelFindings(calls: ReadonlyArray<SessionToolCall>, ctx: SessionExtractContext): CanonicalFinding[];
1368
1368
  /**
package/dist/index.js CHANGED
@@ -1373,6 +1373,7 @@ function normalizeCommandForPolicyImpl(command) {
1373
1373
  const source = command.slice(s, e);
1374
1374
  if (resolved === source) continue;
1375
1375
  if (resolved === "" || /\s/.test(resolved)) continue;
1376
+ if (/^[;&|()<>]+$/.test(resolved)) continue;
1376
1377
  rewrites.push([s, e, resolved]);
1377
1378
  const quoteOnly = source.replace(/['"]/g, "");
1378
1379
  if (quoteOnly !== source) quoteOnlyRewrites.push([s, e, quoteOnly]);
@@ -2392,6 +2393,60 @@ var NET_BINARIES = /* @__PURE__ */ new Set([
2392
2393
  "rsync"
2393
2394
  ]);
2394
2395
  var VALUE_FLAGS = {
2396
+ // rsync 3.2.7, its own --help: every flag whose operand could be mistaken for
2397
+ // a host. `-e ssh` is the one that matters most (`ssh` is not the destination).
2398
+ rsync: /* @__PURE__ */ new Set([
2399
+ "-e",
2400
+ "--rsh",
2401
+ "-f",
2402
+ "--filter",
2403
+ "-T",
2404
+ "--temp-dir",
2405
+ "-B",
2406
+ "--block-size",
2407
+ "-M",
2408
+ "--remote-option",
2409
+ "--exclude",
2410
+ "--exclude-from",
2411
+ "--include",
2412
+ "--include-from",
2413
+ "--files-from",
2414
+ "--compare-dest",
2415
+ "--copy-dest",
2416
+ "--link-dest",
2417
+ "--partial-dir",
2418
+ "--log-file",
2419
+ "--password-file",
2420
+ "--bwlimit",
2421
+ "--timeout",
2422
+ "--contimeout",
2423
+ "--port",
2424
+ "--sockopts",
2425
+ "--address",
2426
+ "--chmod",
2427
+ "--chown",
2428
+ "--max-size",
2429
+ "--min-size",
2430
+ "--modify-window",
2431
+ "--out-format",
2432
+ "--log-file-format",
2433
+ "--backup-dir",
2434
+ "--suffix",
2435
+ "--iconv",
2436
+ "--max-delete",
2437
+ "--checksum-choice",
2438
+ "--info",
2439
+ "--debug",
2440
+ "--stderr",
2441
+ "--outbuf",
2442
+ "--skip-compress",
2443
+ "--usermap",
2444
+ "--groupmap",
2445
+ "--mkpath",
2446
+ "--write-batch",
2447
+ "--read-batch",
2448
+ "--only-write-batch"
2449
+ ]),
2395
2450
  curl: /* @__PURE__ */ new Set([
2396
2451
  "-d",
2397
2452
  "--data",
@@ -2475,23 +2530,163 @@ var VALUE_FLAGS = {
2475
2530
  ]),
2476
2531
  nc: /* @__PURE__ */ new Set(["-p", "-s", "-w", "-X", "-x", "-e", "-g", "-G", "-i", "-O", "-T", "-q", "-m"])
2477
2532
  };
2533
+ var HOME_VARIABLES = /* @__PURE__ */ new Set(["HOME", "USERPROFILE"]);
2534
+ var assignmentTable = null;
2535
+ var currentStmtOffset = Number.MAX_SAFE_INTEGER;
2536
+ var PAYLOAD_BUDGET = 256;
2537
+ var payloadBudget = 0;
2538
+ var seenPayloads = null;
2539
+ function payloadKey(payload) {
2540
+ if (!assignmentTable || assignmentTable.size === 0) return payload;
2541
+ const bindings = [];
2542
+ for (const [name, rec] of assignmentTable) {
2543
+ if (rec.value !== null) bindings.push(`${name}=${rec.value}`);
2544
+ }
2545
+ return `${payload}\0${bindings.sort().join("")}`;
2546
+ }
2547
+ function claimPayload(payload) {
2548
+ if (!seenPayloads) return "ok";
2549
+ const key = payloadKey(payload);
2550
+ if (seenPayloads.has(key)) return "seen";
2551
+ if (payloadBudget <= 0) return "exhausted";
2552
+ payloadBudget--;
2553
+ seenPayloads.add(key);
2554
+ return "ok";
2555
+ }
2556
+ var UNANALYSABLE_NESTING = {
2557
+ ruleName: "review-unanalysable-nesting",
2558
+ verdict: "review",
2559
+ reason: "This command nests more wrapped shell payloads than the policy engine will unwrap, so some of what it runs was not read.",
2560
+ path: ""
2561
+ };
2562
+ var ASSIGNMENT_HEADS = /* @__PURE__ */ new Set(["export", "declare", "local", "readonly", "typeset"]);
2563
+ function recordTopLevelAssignments(f) {
2564
+ const stmts = Array.isArray(f?.Stmts) ? f.Stmts : [];
2565
+ for (const stmt of stmts) recordTopLevelStmt(stmt);
2566
+ }
2567
+ function probeBinOp(src) {
2568
+ try {
2569
+ const cmd = syntax.NewParser().Parse(src, "probe")?.Stmts?.[0]?.Cmd;
2570
+ if (cmd && syntax.NodeType(cmd) === "BinaryCmd") return cmd.Op;
2571
+ } catch {
2572
+ }
2573
+ return null;
2574
+ }
2575
+ var AND_OP = probeBinOp("a && b");
2576
+ var OR_OP = probeBinOp("a || b");
2577
+ var AND_OR_OPS = new Set([AND_OP, OR_OP].filter((o) => o !== null));
2578
+ function recordTopLevelStmt(stmt) {
2579
+ if (!stmt || !stmt.Cmd) return false;
2580
+ const t = syntax.NodeType(stmt.Cmd);
2581
+ if (t === "BinaryCmd") {
2582
+ if (!AND_OR_OPS.has(stmt.Cmd.Op)) return false;
2583
+ if (!recordTopLevelStmt(stmt.Cmd.X)) return false;
2584
+ if (stmt.Cmd.Op === AND_OP) return recordTopLevelStmt(stmt.Cmd.Y);
2585
+ return true;
2586
+ }
2587
+ if (t !== "CallExpr" && t !== "DeclClause") return false;
2588
+ let at = 0;
2589
+ try {
2590
+ at = stmt.Pos().Offset();
2591
+ } catch {
2592
+ at = 0;
2593
+ }
2594
+ recordAssignments(stmt.Cmd, at);
2595
+ if (stmt.Negated) return false;
2596
+ if (t === "DeclClause") return ASSIGNMENT_HEADS.has(stmt.Cmd.Variant?.Value ?? "");
2597
+ return (stmt.Cmd.Args || []).length === 0 && (stmt.Cmd.Assigns || []).length > 0;
2598
+ }
2599
+ function recordAssignments(n, at) {
2600
+ if (!assignmentTable) return;
2601
+ const t = syntax.NodeType(n);
2602
+ let assigns = [];
2603
+ if (t === "CallExpr") {
2604
+ if ((n.Args || []).length > 0) return;
2605
+ assigns = n.Assigns || [];
2606
+ } else if (t === "DeclClause") {
2607
+ if (!ASSIGNMENT_HEADS.has(n.Variant?.Value ?? "")) return;
2608
+ assigns = (n.Args || []).filter((a) => syntax.NodeType(a) === "Assign");
2609
+ } else return;
2610
+ for (const a of assigns) {
2611
+ const name = a?.Name?.Value;
2612
+ if (!name || !a.Value || a.Append) continue;
2613
+ assignmentTable.set(name, { value: resolveWordLiteral(a.Value), at });
2614
+ }
2615
+ }
2616
+ function resolveTrivialSubst(part) {
2617
+ if (syntax.NodeType(part) !== "CmdSubst") return void 0;
2618
+ const stmts = part.Stmts || [];
2619
+ if (stmts.length !== 1) return void 0;
2620
+ const st = stmts[0];
2621
+ if ((st.Redirs || []).length > 0 || st.Negated || st.Background) return void 0;
2622
+ const cmd = st.Cmd;
2623
+ if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return void 0;
2624
+ if ((cmd.Assigns || []).length > 0) return void 0;
2625
+ const words = (cmd.Args || []).map((a) => resolveWordLiteral(a));
2626
+ if (words.length === 0 || words.some((w) => w === null)) return void 0;
2627
+ const head = baseWord(words[0]);
2628
+ const rest = words.slice(1);
2629
+ if (head === "echo") {
2630
+ let i = 0;
2631
+ while (i < rest.length && /^-[neE]+$/.test(rest[i])) i++;
2632
+ return rest.slice(i).join(" ");
2633
+ }
2634
+ if (head === "printf") {
2635
+ if (rest.length !== 2) return void 0;
2636
+ if (!/^%s(\\n)?$/.test(rest[0])) return void 0;
2637
+ return rest[1];
2638
+ }
2639
+ return void 0;
2640
+ }
2641
+ function recordedExpansion(name) {
2642
+ if (!assignmentTable || !name) return void 0;
2643
+ const rec = assignmentTable.get(name);
2644
+ if (rec === void 0 || rec.at >= currentStmtOffset) return void 0;
2645
+ return rec.value;
2646
+ }
2647
+ function isPlainParam(p) {
2648
+ if (syntax.NodeType(p) !== "ParamExp") return false;
2649
+ return !(p.Excl || p.Length || p.Width || p.Index || p.Slice || p.Repl || p.Exp);
2650
+ }
2651
+ function expandPlainParam(p) {
2652
+ if (!assignmentTable) return void 0;
2653
+ if (!isPlainParam(p)) return void 0;
2654
+ const recorded = recordedExpansion(p.Param?.Value);
2655
+ if (recorded !== void 0) return recorded;
2656
+ return HOME_VARIABLES.has(p.Param?.Value) ? "~" : void 0;
2657
+ }
2478
2658
  function resolveWordLiteral(w) {
2479
2659
  const parts = w?.Parts || [];
2480
2660
  let s = "";
2481
2661
  for (const p of parts) {
2482
- const t = syntax.NodeType(p);
2483
- if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
2484
- else if (t === "SglQuoted") s += p.Value ?? "";
2485
- else if (t === "DblQuoted") {
2486
- const inner = p.Parts || [];
2487
- if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
2488
- s += inner.map((ip) => ip.Value ?? "").join("");
2489
- } else {
2490
- return null;
2491
- }
2662
+ const piece = resolvePart(p, false);
2663
+ if (piece === void 0 || piece === null) return null;
2664
+ s += piece;
2492
2665
  }
2493
2666
  return s;
2494
2667
  }
2668
+ function resolvePart(p, inQuotes) {
2669
+ const t = syntax.NodeType(p);
2670
+ if (t === "Lit") {
2671
+ const raw = p.Value ?? "";
2672
+ if (!inQuotes) return raw.replace(/\\(.)/g, "$1");
2673
+ return assignmentTable ? raw.replace(/\\([$`"\\])/g, "$1") : raw;
2674
+ }
2675
+ if (t === "SglQuoted") return p.Value ?? "";
2676
+ if (t === "ParamExp") return expandPlainParam(p);
2677
+ if (t === "CmdSubst") return assignmentTable ? resolveTrivialSubst(p) : void 0;
2678
+ if (t === "DblQuoted" && !inQuotes) {
2679
+ const inner = p.Parts || [];
2680
+ let out = "";
2681
+ for (const ip of inner) {
2682
+ const piece = resolvePart(ip, true);
2683
+ if (piece === void 0 || piece === null) return piece;
2684
+ out += piece;
2685
+ }
2686
+ return out;
2687
+ }
2688
+ return void 0;
2689
+ }
2495
2690
  function parseDestHost(token) {
2496
2691
  if (!token) return null;
2497
2692
  let t = token.trim();
@@ -2546,6 +2741,7 @@ function destTokensForBinary(binary, args) {
2546
2741
  case "ssh":
2547
2742
  return positionals.slice(0, 1);
2548
2743
  case "scp":
2744
+ case "rsync":
2549
2745
  return positionals.filter((p) => p.includes(":") || p.includes("@"));
2550
2746
  case "nc":
2551
2747
  case "ncat":
@@ -2746,17 +2942,35 @@ function analyzeFsOperationImpl(command, depth = 0) {
2746
2942
  const f = parseShared(command);
2747
2943
  if (f === PARSE_FAIL) return null;
2748
2944
  let result = null;
2945
+ const outerTable = assignmentTable;
2946
+ const outerOffset = currentStmtOffset;
2947
+ const outerSeen = seenPayloads;
2948
+ const outerBudget = payloadBudget;
2949
+ if (depth === 0) {
2950
+ seenPayloads = /* @__PURE__ */ new Set();
2951
+ payloadBudget = PAYLOAD_BUDGET;
2952
+ }
2953
+ assignmentTable = new Map(
2954
+ [...outerTable ?? []].map(([k, r]) => [k, { value: r.value, at: -1 }])
2955
+ );
2956
+ currentStmtOffset = Number.MAX_SAFE_INTEGER;
2749
2957
  try {
2958
+ recordTopLevelAssignments(f);
2750
2959
  syntax.Walk(f, (node) => {
2751
2960
  if (!node || result?.verdict === "block") return false;
2752
2961
  const n = node;
2753
2962
  const nodeType = syntax.NodeType(n);
2754
2963
  if (nodeType === "Stmt") {
2964
+ try {
2965
+ currentStmtOffset = n.Pos().Offset();
2966
+ } catch {
2967
+ currentStmtOffset = Number.MAX_SAFE_INTEGER;
2968
+ }
2755
2969
  result = stricter(result, jailedRedirectRead(n));
2756
2970
  return result?.verdict !== "block";
2757
2971
  }
2758
2972
  if (nodeType !== "CallExpr") return true;
2759
- const { name, flags, paths, words, args } = extractLiteralArgs(n);
2973
+ const { name, flags, paths, words } = extractLiteralArgs(n);
2760
2974
  if (!name) return true;
2761
2975
  if (name === "rm") {
2762
2976
  const flagStr = flags.join("").toLowerCase();
@@ -2785,9 +2999,30 @@ function analyzeFsOperationImpl(command, depth = 0) {
2785
2999
  }
2786
3000
  }
2787
3001
  }
2788
- if (depth < 1) {
3002
+ if (depth < 24 && name === "find") {
3003
+ for (const action of findActions(words, 0)) {
3004
+ const h = unwrapCommandHead(action);
3005
+ const inner = literalShellPayload(action.slice(h), baseWord(action[h]));
3006
+ if (inner === null) continue;
3007
+ const claim = claimPayload(inner);
3008
+ if (claim === "exhausted") {
3009
+ result = stricter(result, UNANALYSABLE_NESTING);
3010
+ continue;
3011
+ }
3012
+ if (claim === "seen") continue;
3013
+ const v = analyzeFsOperationImpl(inner, depth + 1);
3014
+ result = stricter(result, v);
3015
+ if (result?.verdict === "block") return false;
3016
+ }
3017
+ }
3018
+ if (depth < 24) {
2789
3019
  const payload = literalShellPayload(words, name);
2790
- if (payload !== null) {
3020
+ const claim = payload === null ? "seen" : claimPayload(payload);
3021
+ if (claim === "exhausted") {
3022
+ result = stricter(result, UNANALYSABLE_NESTING);
3023
+ return true;
3024
+ }
3025
+ if (payload !== null && claim === "ok") {
2791
3026
  const inner = analyzeFsOperationImpl(payload, depth + 1);
2792
3027
  if (inner) {
2793
3028
  result = inner;
@@ -2796,7 +3031,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
2796
3031
  return true;
2797
3032
  }
2798
3033
  }
2799
- const readPaths = FS_READ_TOOLS.has(name) ? [...readTargets(name, args, flags, words, 1), ...flagOperandFiles(name, words, 1)] : wrappedReadPaths(words, name);
3034
+ const readPaths = FS_READ_TOOLS.has(name) ? readerPaths(words, 0) : wrappedReadPaths(words, name);
2800
3035
  if (readPaths) {
2801
3036
  for (const p of readPaths) {
2802
3037
  result = stricter(result, matchSensitivePath2(p));
@@ -2811,6 +3046,11 @@ function analyzeFsOperationImpl(command, depth = 0) {
2811
3046
  return result;
2812
3047
  } catch {
2813
3048
  return null;
3049
+ } finally {
3050
+ assignmentTable = outerTable;
3051
+ currentStmtOffset = outerOffset;
3052
+ seenPayloads = outerSeen;
3053
+ if (depth === 0) payloadBudget = outerBudget;
2814
3054
  }
2815
3055
  }
2816
3056
  function stricter(a, b) {
@@ -2868,6 +3108,18 @@ function resolveCopyShape(words, h) {
2868
3108
  return null;
2869
3109
  }
2870
3110
  var FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
3111
+ function findAction(words, k) {
3112
+ const end = words.findIndex((w, i) => i > k && (w === ";" || w === "+"));
3113
+ return words.slice(k + 1, end < 0 ? words.length : end);
3114
+ }
3115
+ function findActions(words, h) {
3116
+ const out = [];
3117
+ for (let i = h + 1; i < words.length; i++) {
3118
+ const w = words[i];
3119
+ if (w !== null && FIND_EXEC_FLAGS.has(w)) out.push(findAction(words, i));
3120
+ }
3121
+ return out;
3122
+ }
2871
3123
  function findStartPoints(words, h) {
2872
3124
  const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
2873
3125
  if (k < 0) return { k, starts: [] };
@@ -2883,8 +3135,13 @@ function copySourcePaths(words) {
2883
3135
  if (fi >= 0) {
2884
3136
  const { k, starts } = findStartPoints(words, fi);
2885
3137
  if (k < 0) return [];
2886
- const action = unwrapCommandHead(words.slice(k + 1));
2887
- return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
3138
+ const out = [];
3139
+ for (const action of findActions(words, fi)) {
3140
+ const h2 = unwrapCommandHead(action);
3141
+ if (!resolveCopyShape(action, h2)) continue;
3142
+ out.push(...starts, ...copySourcePaths(action));
3143
+ }
3144
+ return out;
2888
3145
  }
2889
3146
  if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
2890
3147
  const r = resolveCopyShape(words, h);
@@ -3088,6 +3345,15 @@ function flagEffect(token, shape, known) {
3088
3345
  }
3089
3346
  return NONE;
3090
3347
  }
3348
+ function readerPaths(words, h) {
3349
+ const head = baseWord(words[h]);
3350
+ const from = h + 1;
3351
+ const flags = words.slice(from).filter((w) => w !== null && w.startsWith("-"));
3352
+ return [
3353
+ ...readTargets(head, positionedArgs(words, from), flags, words, from),
3354
+ ...flagOperandFiles(head, words, from)
3355
+ ];
3356
+ }
3091
3357
  function readTargets(verb, args, flags, words = [], from = 1) {
3092
3358
  const shape = PATTERN_VERBS[verb];
3093
3359
  if (!shape) return args.map((a) => a.value);
@@ -3169,18 +3435,21 @@ function flagOperandFiles(verb, words, from) {
3169
3435
  function wrappedReadPaths(words, name) {
3170
3436
  if (name === "find") {
3171
3437
  const { k, starts } = findStartPoints(words, 0);
3172
- return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
3438
+ if (k < 0) return null;
3439
+ const paths = [];
3440
+ let reads = false;
3441
+ for (const action of findActions(words, 0)) {
3442
+ const h2 = unwrapCommandHead(action);
3443
+ if (!isReaderWord(action[h2] ?? null)) continue;
3444
+ reads = true;
3445
+ paths.push(...readerPaths(action, h2));
3446
+ }
3447
+ return reads ? [...starts, ...paths] : paths;
3173
3448
  }
3174
3449
  if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
3175
3450
  const h = unwrapCommandHead(words);
3176
3451
  if (h <= 0 || !isReaderWord(words[h] ?? null)) return null;
3177
- const head = baseWord(words[h]);
3178
- const rest = words.slice(h + 1);
3179
- const restFlags = rest.filter((w) => w !== null && w.startsWith("-"));
3180
- return [
3181
- ...readTargets(head, positionedArgs(words, h + 1), restFlags, words, h + 1),
3182
- ...flagOperandFiles(head, words, h + 1)
3183
- ];
3452
+ return readerPaths(words, h);
3184
3453
  }
3185
3454
  function literalShellPayload(words, name) {
3186
3455
  const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
@@ -5789,8 +6058,8 @@ function matchCanaryArgs(args, values) {
5789
6058
 
5790
6059
  // src/scan/canonical.ts
5791
6060
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
5792
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v17";
5793
- var CANONICAL_EXTRACTOR_HASH = "ca0a79b38a804347";
6061
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v18";
6062
+ var CANONICAL_EXTRACTOR_HASH = "5b2e76537f368d99";
5794
6063
  var DEDUPE_PREVIEW_LEN = 120;
5795
6064
  function extractCanonicalFindings(call, ctx) {
5796
6065
  const out = [];
package/dist/index.mjs CHANGED
@@ -1231,6 +1231,7 @@ function normalizeCommandForPolicyImpl(command) {
1231
1231
  const source = command.slice(s, e);
1232
1232
  if (resolved === source) continue;
1233
1233
  if (resolved === "" || /\s/.test(resolved)) continue;
1234
+ if (/^[;&|()<>]+$/.test(resolved)) continue;
1234
1235
  rewrites.push([s, e, resolved]);
1235
1236
  const quoteOnly = source.replace(/['"]/g, "");
1236
1237
  if (quoteOnly !== source) quoteOnlyRewrites.push([s, e, quoteOnly]);
@@ -2250,6 +2251,60 @@ var NET_BINARIES = /* @__PURE__ */ new Set([
2250
2251
  "rsync"
2251
2252
  ]);
2252
2253
  var VALUE_FLAGS = {
2254
+ // rsync 3.2.7, its own --help: every flag whose operand could be mistaken for
2255
+ // a host. `-e ssh` is the one that matters most (`ssh` is not the destination).
2256
+ rsync: /* @__PURE__ */ new Set([
2257
+ "-e",
2258
+ "--rsh",
2259
+ "-f",
2260
+ "--filter",
2261
+ "-T",
2262
+ "--temp-dir",
2263
+ "-B",
2264
+ "--block-size",
2265
+ "-M",
2266
+ "--remote-option",
2267
+ "--exclude",
2268
+ "--exclude-from",
2269
+ "--include",
2270
+ "--include-from",
2271
+ "--files-from",
2272
+ "--compare-dest",
2273
+ "--copy-dest",
2274
+ "--link-dest",
2275
+ "--partial-dir",
2276
+ "--log-file",
2277
+ "--password-file",
2278
+ "--bwlimit",
2279
+ "--timeout",
2280
+ "--contimeout",
2281
+ "--port",
2282
+ "--sockopts",
2283
+ "--address",
2284
+ "--chmod",
2285
+ "--chown",
2286
+ "--max-size",
2287
+ "--min-size",
2288
+ "--modify-window",
2289
+ "--out-format",
2290
+ "--log-file-format",
2291
+ "--backup-dir",
2292
+ "--suffix",
2293
+ "--iconv",
2294
+ "--max-delete",
2295
+ "--checksum-choice",
2296
+ "--info",
2297
+ "--debug",
2298
+ "--stderr",
2299
+ "--outbuf",
2300
+ "--skip-compress",
2301
+ "--usermap",
2302
+ "--groupmap",
2303
+ "--mkpath",
2304
+ "--write-batch",
2305
+ "--read-batch",
2306
+ "--only-write-batch"
2307
+ ]),
2253
2308
  curl: /* @__PURE__ */ new Set([
2254
2309
  "-d",
2255
2310
  "--data",
@@ -2333,23 +2388,163 @@ var VALUE_FLAGS = {
2333
2388
  ]),
2334
2389
  nc: /* @__PURE__ */ new Set(["-p", "-s", "-w", "-X", "-x", "-e", "-g", "-G", "-i", "-O", "-T", "-q", "-m"])
2335
2390
  };
2391
+ var HOME_VARIABLES = /* @__PURE__ */ new Set(["HOME", "USERPROFILE"]);
2392
+ var assignmentTable = null;
2393
+ var currentStmtOffset = Number.MAX_SAFE_INTEGER;
2394
+ var PAYLOAD_BUDGET = 256;
2395
+ var payloadBudget = 0;
2396
+ var seenPayloads = null;
2397
+ function payloadKey(payload) {
2398
+ if (!assignmentTable || assignmentTable.size === 0) return payload;
2399
+ const bindings = [];
2400
+ for (const [name, rec] of assignmentTable) {
2401
+ if (rec.value !== null) bindings.push(`${name}=${rec.value}`);
2402
+ }
2403
+ return `${payload}\0${bindings.sort().join("")}`;
2404
+ }
2405
+ function claimPayload(payload) {
2406
+ if (!seenPayloads) return "ok";
2407
+ const key = payloadKey(payload);
2408
+ if (seenPayloads.has(key)) return "seen";
2409
+ if (payloadBudget <= 0) return "exhausted";
2410
+ payloadBudget--;
2411
+ seenPayloads.add(key);
2412
+ return "ok";
2413
+ }
2414
+ var UNANALYSABLE_NESTING = {
2415
+ ruleName: "review-unanalysable-nesting",
2416
+ verdict: "review",
2417
+ reason: "This command nests more wrapped shell payloads than the policy engine will unwrap, so some of what it runs was not read.",
2418
+ path: ""
2419
+ };
2420
+ var ASSIGNMENT_HEADS = /* @__PURE__ */ new Set(["export", "declare", "local", "readonly", "typeset"]);
2421
+ function recordTopLevelAssignments(f) {
2422
+ const stmts = Array.isArray(f?.Stmts) ? f.Stmts : [];
2423
+ for (const stmt of stmts) recordTopLevelStmt(stmt);
2424
+ }
2425
+ function probeBinOp(src) {
2426
+ try {
2427
+ const cmd = syntax.NewParser().Parse(src, "probe")?.Stmts?.[0]?.Cmd;
2428
+ if (cmd && syntax.NodeType(cmd) === "BinaryCmd") return cmd.Op;
2429
+ } catch {
2430
+ }
2431
+ return null;
2432
+ }
2433
+ var AND_OP = probeBinOp("a && b");
2434
+ var OR_OP = probeBinOp("a || b");
2435
+ var AND_OR_OPS = new Set([AND_OP, OR_OP].filter((o) => o !== null));
2436
+ function recordTopLevelStmt(stmt) {
2437
+ if (!stmt || !stmt.Cmd) return false;
2438
+ const t = syntax.NodeType(stmt.Cmd);
2439
+ if (t === "BinaryCmd") {
2440
+ if (!AND_OR_OPS.has(stmt.Cmd.Op)) return false;
2441
+ if (!recordTopLevelStmt(stmt.Cmd.X)) return false;
2442
+ if (stmt.Cmd.Op === AND_OP) return recordTopLevelStmt(stmt.Cmd.Y);
2443
+ return true;
2444
+ }
2445
+ if (t !== "CallExpr" && t !== "DeclClause") return false;
2446
+ let at = 0;
2447
+ try {
2448
+ at = stmt.Pos().Offset();
2449
+ } catch {
2450
+ at = 0;
2451
+ }
2452
+ recordAssignments(stmt.Cmd, at);
2453
+ if (stmt.Negated) return false;
2454
+ if (t === "DeclClause") return ASSIGNMENT_HEADS.has(stmt.Cmd.Variant?.Value ?? "");
2455
+ return (stmt.Cmd.Args || []).length === 0 && (stmt.Cmd.Assigns || []).length > 0;
2456
+ }
2457
+ function recordAssignments(n, at) {
2458
+ if (!assignmentTable) return;
2459
+ const t = syntax.NodeType(n);
2460
+ let assigns = [];
2461
+ if (t === "CallExpr") {
2462
+ if ((n.Args || []).length > 0) return;
2463
+ assigns = n.Assigns || [];
2464
+ } else if (t === "DeclClause") {
2465
+ if (!ASSIGNMENT_HEADS.has(n.Variant?.Value ?? "")) return;
2466
+ assigns = (n.Args || []).filter((a) => syntax.NodeType(a) === "Assign");
2467
+ } else return;
2468
+ for (const a of assigns) {
2469
+ const name = a?.Name?.Value;
2470
+ if (!name || !a.Value || a.Append) continue;
2471
+ assignmentTable.set(name, { value: resolveWordLiteral(a.Value), at });
2472
+ }
2473
+ }
2474
+ function resolveTrivialSubst(part) {
2475
+ if (syntax.NodeType(part) !== "CmdSubst") return void 0;
2476
+ const stmts = part.Stmts || [];
2477
+ if (stmts.length !== 1) return void 0;
2478
+ const st = stmts[0];
2479
+ if ((st.Redirs || []).length > 0 || st.Negated || st.Background) return void 0;
2480
+ const cmd = st.Cmd;
2481
+ if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return void 0;
2482
+ if ((cmd.Assigns || []).length > 0) return void 0;
2483
+ const words = (cmd.Args || []).map((a) => resolveWordLiteral(a));
2484
+ if (words.length === 0 || words.some((w) => w === null)) return void 0;
2485
+ const head = baseWord(words[0]);
2486
+ const rest = words.slice(1);
2487
+ if (head === "echo") {
2488
+ let i = 0;
2489
+ while (i < rest.length && /^-[neE]+$/.test(rest[i])) i++;
2490
+ return rest.slice(i).join(" ");
2491
+ }
2492
+ if (head === "printf") {
2493
+ if (rest.length !== 2) return void 0;
2494
+ if (!/^%s(\\n)?$/.test(rest[0])) return void 0;
2495
+ return rest[1];
2496
+ }
2497
+ return void 0;
2498
+ }
2499
+ function recordedExpansion(name) {
2500
+ if (!assignmentTable || !name) return void 0;
2501
+ const rec = assignmentTable.get(name);
2502
+ if (rec === void 0 || rec.at >= currentStmtOffset) return void 0;
2503
+ return rec.value;
2504
+ }
2505
+ function isPlainParam(p) {
2506
+ if (syntax.NodeType(p) !== "ParamExp") return false;
2507
+ return !(p.Excl || p.Length || p.Width || p.Index || p.Slice || p.Repl || p.Exp);
2508
+ }
2509
+ function expandPlainParam(p) {
2510
+ if (!assignmentTable) return void 0;
2511
+ if (!isPlainParam(p)) return void 0;
2512
+ const recorded = recordedExpansion(p.Param?.Value);
2513
+ if (recorded !== void 0) return recorded;
2514
+ return HOME_VARIABLES.has(p.Param?.Value) ? "~" : void 0;
2515
+ }
2336
2516
  function resolveWordLiteral(w) {
2337
2517
  const parts = w?.Parts || [];
2338
2518
  let s = "";
2339
2519
  for (const p of parts) {
2340
- const t = syntax.NodeType(p);
2341
- if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
2342
- else if (t === "SglQuoted") s += p.Value ?? "";
2343
- else if (t === "DblQuoted") {
2344
- const inner = p.Parts || [];
2345
- if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
2346
- s += inner.map((ip) => ip.Value ?? "").join("");
2347
- } else {
2348
- return null;
2349
- }
2520
+ const piece = resolvePart(p, false);
2521
+ if (piece === void 0 || piece === null) return null;
2522
+ s += piece;
2350
2523
  }
2351
2524
  return s;
2352
2525
  }
2526
+ function resolvePart(p, inQuotes) {
2527
+ const t = syntax.NodeType(p);
2528
+ if (t === "Lit") {
2529
+ const raw = p.Value ?? "";
2530
+ if (!inQuotes) return raw.replace(/\\(.)/g, "$1");
2531
+ return assignmentTable ? raw.replace(/\\([$`"\\])/g, "$1") : raw;
2532
+ }
2533
+ if (t === "SglQuoted") return p.Value ?? "";
2534
+ if (t === "ParamExp") return expandPlainParam(p);
2535
+ if (t === "CmdSubst") return assignmentTable ? resolveTrivialSubst(p) : void 0;
2536
+ if (t === "DblQuoted" && !inQuotes) {
2537
+ const inner = p.Parts || [];
2538
+ let out = "";
2539
+ for (const ip of inner) {
2540
+ const piece = resolvePart(ip, true);
2541
+ if (piece === void 0 || piece === null) return piece;
2542
+ out += piece;
2543
+ }
2544
+ return out;
2545
+ }
2546
+ return void 0;
2547
+ }
2353
2548
  function parseDestHost(token) {
2354
2549
  if (!token) return null;
2355
2550
  let t = token.trim();
@@ -2404,6 +2599,7 @@ function destTokensForBinary(binary, args) {
2404
2599
  case "ssh":
2405
2600
  return positionals.slice(0, 1);
2406
2601
  case "scp":
2602
+ case "rsync":
2407
2603
  return positionals.filter((p) => p.includes(":") || p.includes("@"));
2408
2604
  case "nc":
2409
2605
  case "ncat":
@@ -2604,17 +2800,35 @@ function analyzeFsOperationImpl(command, depth = 0) {
2604
2800
  const f = parseShared(command);
2605
2801
  if (f === PARSE_FAIL) return null;
2606
2802
  let result = null;
2803
+ const outerTable = assignmentTable;
2804
+ const outerOffset = currentStmtOffset;
2805
+ const outerSeen = seenPayloads;
2806
+ const outerBudget = payloadBudget;
2807
+ if (depth === 0) {
2808
+ seenPayloads = /* @__PURE__ */ new Set();
2809
+ payloadBudget = PAYLOAD_BUDGET;
2810
+ }
2811
+ assignmentTable = new Map(
2812
+ [...outerTable ?? []].map(([k, r]) => [k, { value: r.value, at: -1 }])
2813
+ );
2814
+ currentStmtOffset = Number.MAX_SAFE_INTEGER;
2607
2815
  try {
2816
+ recordTopLevelAssignments(f);
2608
2817
  syntax.Walk(f, (node) => {
2609
2818
  if (!node || result?.verdict === "block") return false;
2610
2819
  const n = node;
2611
2820
  const nodeType = syntax.NodeType(n);
2612
2821
  if (nodeType === "Stmt") {
2822
+ try {
2823
+ currentStmtOffset = n.Pos().Offset();
2824
+ } catch {
2825
+ currentStmtOffset = Number.MAX_SAFE_INTEGER;
2826
+ }
2613
2827
  result = stricter(result, jailedRedirectRead(n));
2614
2828
  return result?.verdict !== "block";
2615
2829
  }
2616
2830
  if (nodeType !== "CallExpr") return true;
2617
- const { name, flags, paths, words, args } = extractLiteralArgs(n);
2831
+ const { name, flags, paths, words } = extractLiteralArgs(n);
2618
2832
  if (!name) return true;
2619
2833
  if (name === "rm") {
2620
2834
  const flagStr = flags.join("").toLowerCase();
@@ -2643,9 +2857,30 @@ function analyzeFsOperationImpl(command, depth = 0) {
2643
2857
  }
2644
2858
  }
2645
2859
  }
2646
- if (depth < 1) {
2860
+ if (depth < 24 && name === "find") {
2861
+ for (const action of findActions(words, 0)) {
2862
+ const h = unwrapCommandHead(action);
2863
+ const inner = literalShellPayload(action.slice(h), baseWord(action[h]));
2864
+ if (inner === null) continue;
2865
+ const claim = claimPayload(inner);
2866
+ if (claim === "exhausted") {
2867
+ result = stricter(result, UNANALYSABLE_NESTING);
2868
+ continue;
2869
+ }
2870
+ if (claim === "seen") continue;
2871
+ const v = analyzeFsOperationImpl(inner, depth + 1);
2872
+ result = stricter(result, v);
2873
+ if (result?.verdict === "block") return false;
2874
+ }
2875
+ }
2876
+ if (depth < 24) {
2647
2877
  const payload = literalShellPayload(words, name);
2648
- if (payload !== null) {
2878
+ const claim = payload === null ? "seen" : claimPayload(payload);
2879
+ if (claim === "exhausted") {
2880
+ result = stricter(result, UNANALYSABLE_NESTING);
2881
+ return true;
2882
+ }
2883
+ if (payload !== null && claim === "ok") {
2649
2884
  const inner = analyzeFsOperationImpl(payload, depth + 1);
2650
2885
  if (inner) {
2651
2886
  result = inner;
@@ -2654,7 +2889,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
2654
2889
  return true;
2655
2890
  }
2656
2891
  }
2657
- const readPaths = FS_READ_TOOLS.has(name) ? [...readTargets(name, args, flags, words, 1), ...flagOperandFiles(name, words, 1)] : wrappedReadPaths(words, name);
2892
+ const readPaths = FS_READ_TOOLS.has(name) ? readerPaths(words, 0) : wrappedReadPaths(words, name);
2658
2893
  if (readPaths) {
2659
2894
  for (const p of readPaths) {
2660
2895
  result = stricter(result, matchSensitivePath2(p));
@@ -2669,6 +2904,11 @@ function analyzeFsOperationImpl(command, depth = 0) {
2669
2904
  return result;
2670
2905
  } catch {
2671
2906
  return null;
2907
+ } finally {
2908
+ assignmentTable = outerTable;
2909
+ currentStmtOffset = outerOffset;
2910
+ seenPayloads = outerSeen;
2911
+ if (depth === 0) payloadBudget = outerBudget;
2672
2912
  }
2673
2913
  }
2674
2914
  function stricter(a, b) {
@@ -2726,6 +2966,18 @@ function resolveCopyShape(words, h) {
2726
2966
  return null;
2727
2967
  }
2728
2968
  var FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
2969
+ function findAction(words, k) {
2970
+ const end = words.findIndex((w, i) => i > k && (w === ";" || w === "+"));
2971
+ return words.slice(k + 1, end < 0 ? words.length : end);
2972
+ }
2973
+ function findActions(words, h) {
2974
+ const out = [];
2975
+ for (let i = h + 1; i < words.length; i++) {
2976
+ const w = words[i];
2977
+ if (w !== null && FIND_EXEC_FLAGS.has(w)) out.push(findAction(words, i));
2978
+ }
2979
+ return out;
2980
+ }
2729
2981
  function findStartPoints(words, h) {
2730
2982
  const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
2731
2983
  if (k < 0) return { k, starts: [] };
@@ -2741,8 +2993,13 @@ function copySourcePaths(words) {
2741
2993
  if (fi >= 0) {
2742
2994
  const { k, starts } = findStartPoints(words, fi);
2743
2995
  if (k < 0) return [];
2744
- const action = unwrapCommandHead(words.slice(k + 1));
2745
- return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
2996
+ const out = [];
2997
+ for (const action of findActions(words, fi)) {
2998
+ const h2 = unwrapCommandHead(action);
2999
+ if (!resolveCopyShape(action, h2)) continue;
3000
+ out.push(...starts, ...copySourcePaths(action));
3001
+ }
3002
+ return out;
2746
3003
  }
2747
3004
  if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
2748
3005
  const r = resolveCopyShape(words, h);
@@ -2946,6 +3203,15 @@ function flagEffect(token, shape, known) {
2946
3203
  }
2947
3204
  return NONE;
2948
3205
  }
3206
+ function readerPaths(words, h) {
3207
+ const head = baseWord(words[h]);
3208
+ const from = h + 1;
3209
+ const flags = words.slice(from).filter((w) => w !== null && w.startsWith("-"));
3210
+ return [
3211
+ ...readTargets(head, positionedArgs(words, from), flags, words, from),
3212
+ ...flagOperandFiles(head, words, from)
3213
+ ];
3214
+ }
2949
3215
  function readTargets(verb, args, flags, words = [], from = 1) {
2950
3216
  const shape = PATTERN_VERBS[verb];
2951
3217
  if (!shape) return args.map((a) => a.value);
@@ -3027,18 +3293,21 @@ function flagOperandFiles(verb, words, from) {
3027
3293
  function wrappedReadPaths(words, name) {
3028
3294
  if (name === "find") {
3029
3295
  const { k, starts } = findStartPoints(words, 0);
3030
- return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
3296
+ if (k < 0) return null;
3297
+ const paths = [];
3298
+ let reads = false;
3299
+ for (const action of findActions(words, 0)) {
3300
+ const h2 = unwrapCommandHead(action);
3301
+ if (!isReaderWord(action[h2] ?? null)) continue;
3302
+ reads = true;
3303
+ paths.push(...readerPaths(action, h2));
3304
+ }
3305
+ return reads ? [...starts, ...paths] : paths;
3031
3306
  }
3032
3307
  if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
3033
3308
  const h = unwrapCommandHead(words);
3034
3309
  if (h <= 0 || !isReaderWord(words[h] ?? null)) return null;
3035
- const head = baseWord(words[h]);
3036
- const rest = words.slice(h + 1);
3037
- const restFlags = rest.filter((w) => w !== null && w.startsWith("-"));
3038
- return [
3039
- ...readTargets(head, positionedArgs(words, h + 1), restFlags, words, h + 1),
3040
- ...flagOperandFiles(head, words, h + 1)
3041
- ];
3310
+ return readerPaths(words, h);
3042
3311
  }
3043
3312
  function literalShellPayload(words, name) {
3044
3313
  const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
@@ -5647,8 +5916,8 @@ function matchCanaryArgs(args, values) {
5647
5916
 
5648
5917
  // src/scan/canonical.ts
5649
5918
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
5650
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v17";
5651
- var CANONICAL_EXTRACTOR_HASH = "ca0a79b38a804347";
5919
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v18";
5920
+ var CANONICAL_EXTRACTOR_HASH = "5b2e76537f368d99";
5652
5921
  var DEDUPE_PREVIEW_LEN = 120;
5653
5922
  function extractCanonicalFindings(call, ctx) {
5654
5923
  const out = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "2.22.1",
3
+ "version": "2.23.0",
4
4
  "description": "Shared policy evaluation engine for node9 — DLP, smart rules, AST shell parsing, shields, loop detection. Pure functions, no I/O. Used by both node9-proxy and the node9 SaaS firewall.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://node9.ai",