@node9/proxy 2.12.0 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1132,13 +1132,32 @@ var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
1132
1132
  })
1133
1133
  );
1134
1134
  var SENSITIVE_PATH_PATTERNS = [
1135
- /[/\\]\.ssh[/\\]/i,
1136
- /[/\\]\.aws[/\\]/i,
1135
+ /[/\\]\.ssh([/\\]|$)/i,
1136
+ /[/\\]\.aws([/\\]|$)/i,
1137
1137
  /[/\\]\.config[/\\]gcloud[/\\]/i,
1138
1138
  /[/\\]\.azure[/\\]/i,
1139
1139
  /[/\\]\.kube[/\\]config$/i,
1140
- /[/\\]\.env($|\.)/i,
1141
- // .env, .env.local, .env.production — not .envoy
1140
+ // ⚠️ ONE SEMANTIC, FOUR COPIES. This is the AST tier's `.env` rule verbatim
1141
+ // (shell/index.ts SENSITIVE_PATH_RULES), whose reasoning is documented there:
1142
+ // structural suffix chain rather than a hand-written list, `example|sample|
1143
+ // template` exempt because a fixture stays a fixture whatever follows, and
1144
+ // `.test` anchored because `test` names an ENVIRONMENT -- `.env.test` is the
1145
+ // committed template, `.env.test.local` is gitignored and holds real values.
1146
+ //
1147
+ // It was previously `[/\\]\.env($|\.)` with NO exemptions, so `Read .env.example`
1148
+ // blocked while `cat .env.example` allowed: the same file, opposite verdicts,
1149
+ // decided only by which tool asked. See src/__tests__/jail-both-doors.test.ts,
1150
+ // which is the contract that now holds these copies in step, and stage 5 of
1151
+ // doc/credential-jail-architecture.md, which replaces them with one generated
1152
+ // source.
1153
+ // ⚠️ The `.local` branch comes FIRST and takes no exemption. A fixture stays a
1154
+ // fixture whatever follows it -- `.env.example.md` is documentation -- but
1155
+ // `.env.example.local` is gitignored by the `.env*.local` convention and holds
1156
+ // real values, exactly the reasoning that anchors `(?!\.test$)` rather than
1157
+ // using `\b`. Without this branch the fixture exemption also bought a two-step
1158
+ // bypass: `cp .env .env.sample`, then read the copy.
1159
+ /[/\\]\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
1160
+ // .env + any suffix chain; fixtures exempt unless .local
1142
1161
  /[/\\]\.git-credentials$/i,
1143
1162
  /[/\\]\.npmrc$/i,
1144
1163
  /[/\\]\.docker[/\\]config\.json$/i,
@@ -1640,6 +1659,10 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1640
1659
  "od",
1641
1660
  "xxd",
1642
1661
  "hexdump",
1662
+ // Emits the file's bytes, re-encoded, so it is a read by the set's own test
1663
+ // ("does it emit file contents"). Absent until 2026-09-10, which is why
1664
+ // `base64 ~/.ssh/id_rsa` printed a private key with no verdict.
1665
+ "base64",
1643
1666
  "strings",
1644
1667
  "sort",
1645
1668
  "uniq",
@@ -1648,7 +1671,11 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1648
1671
  "dd"
1649
1672
  ]);
1650
1673
  var FS_OP_PRESCREEN_RE = new RegExp(
1651
- `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
1674
+ // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
1675
+ // reader right after `"` / `'`, and without these two characters the
1676
+ // prescreen rejected every string-wrapped read before the parser ran.
1677
+ // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
1678
+ `(?:^|[\\s|;&("'\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
1652
1679
  );
1653
1680
  var HOME_CACHE_ALLOWLIST = [
1654
1681
  ".cache",
@@ -1669,12 +1696,12 @@ var SENSITIVE_PATH_RULES = [
1669
1696
  {
1670
1697
  rule: "shield:project-jail:block-read-ssh",
1671
1698
  reason: "Reading SSH private keys is blocked by project-jail shield",
1672
- match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
1699
+ match: (p) => /([\\/]\.ssh[\\/]|^\.ssh[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.ssh$)/i.test(p)
1673
1700
  },
1674
1701
  {
1675
1702
  rule: "shield:project-jail:block-read-aws",
1676
1703
  reason: "Reading AWS credentials is blocked by project-jail shield",
1677
- match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
1704
+ match: (p) => /([\\/]\.aws[\\/]|^\.aws[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.aws$)/i.test(p)
1678
1705
  },
1679
1706
  {
1680
1707
  // Mirrors the JSON shield's `.env` pattern (project-jail.json's
@@ -1718,7 +1745,9 @@ var SENSITIVE_PATH_RULES = [
1718
1745
  // symmetry — silently exempts every `.env.test.*` file.
1719
1746
  //
1720
1747
  // shields.test.ts:983-995 is the canonical contract; keep both in step.
1721
- match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$/i.test(p)
1748
+ match: (p) => /(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i.test(
1749
+ p
1750
+ )
1722
1751
  },
1723
1752
  {
1724
1753
  // verdict: 'review' (not 'block') is a deliberate design choice
@@ -1929,15 +1958,14 @@ function listOps() {
1929
1958
  return _listOps;
1930
1959
  }
1931
1960
  var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
1961
+ var FIND_EXEC_FLAGS = /* @__PURE__ */ new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1932
1962
  var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1933
1963
  function unwrapCommandHead(words) {
1934
1964
  let i = 0;
1935
1965
  while (i < words.length) {
1936
1966
  const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1937
1967
  if (head === "find") {
1938
- const x = words.findIndex(
1939
- (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1940
- );
1968
+ const x = words.findIndex((w, k) => k > i && w !== null && FIND_EXEC_FLAGS.has(w));
1941
1969
  if (x < 0) break;
1942
1970
  i = x + 1;
1943
1971
  continue;
@@ -1959,7 +1987,11 @@ function unwrapCommandHead(words) {
1959
1987
  if (t.startsWith("-")) {
1960
1988
  i++;
1961
1989
  const nxt = words[i];
1962
- if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
1990
+ if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()) && // A reader is a command, never a flag's operand: `env - cat X`,
1991
+ // `stdbuf -o0 cat X`, `ionice -c3 cat X`. Without this the head was
1992
+ // swallowed and the jail needed a looser fallback whose cost was a
1993
+ // false positive on `sudo echo cat X`.
1994
+ !FS_READ_TOOLS.has(nxt.split("/").pop()?.toLowerCase() ?? ""))
1963
1995
  i++;
1964
1996
  continue;
1965
1997
  }
@@ -2080,36 +2112,29 @@ function isProtectedHomePath(rawPath) {
2080
2112
  }
2081
2113
  function extractLiteralArgs(callExpr) {
2082
2114
  const args = callExpr.Args || [];
2083
- if (args.length === 0) return { name: "", flags: [], paths: [] };
2084
- const litFromWord = (w) => {
2085
- const parts = w?.Parts || [];
2086
- let s = "";
2087
- for (const p of parts) {
2088
- const t = syntax.NodeType(p);
2089
- if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
2090
- else if (t === "SglQuoted") s += p.Value ?? "";
2091
- else if (t === "DblQuoted") {
2092
- const inner = p.Parts || [];
2093
- if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
2094
- s += inner.map((ip) => ip.Value ?? "").join("");
2095
- } else {
2096
- return null;
2097
- }
2098
- }
2099
- return s;
2100
- };
2101
- const name = (litFromWord(args[0]) || "").toLowerCase();
2115
+ if (args.length === 0) return { name: "", flags: [], paths: [], words: [] };
2116
+ const words = args.map((a) => resolveWordLiteral(a));
2117
+ const name = (words[0] ?? "").toLowerCase();
2102
2118
  const flags = [];
2103
2119
  const paths = [];
2104
- for (let i = 1; i < args.length; i++) {
2105
- const v = litFromWord(args[i]);
2120
+ for (let i = 1; i < words.length; i++) {
2121
+ const v = words[i];
2106
2122
  if (v === null) continue;
2107
2123
  if (v.startsWith("-")) flags.push(v);
2108
2124
  else paths.push(v);
2109
2125
  }
2110
- return { name, flags, paths };
2126
+ return { name, flags, paths, words };
2111
2127
  }
2112
- var NET_BINARIES = /* @__PURE__ */ new Set(["curl", "wget", "scp", "ssh", "nc", "ncat", "netcat"]);
2128
+ var NET_BINARIES = /* @__PURE__ */ new Set([
2129
+ "curl",
2130
+ "wget",
2131
+ "scp",
2132
+ "ssh",
2133
+ "nc",
2134
+ "ncat",
2135
+ "netcat",
2136
+ "rsync"
2137
+ ]);
2113
2138
  var VALUE_FLAGS = {
2114
2139
  curl: /* @__PURE__ */ new Set([
2115
2140
  "-d",
@@ -2394,6 +2419,9 @@ function deriveRedirOp(sample) {
2394
2419
  }
2395
2420
  }
2396
2421
  var REDIR_TRUNCATE_OPS = /* @__PURE__ */ new Set([deriveRedirOp(">_f")]);
2422
+ var REDIR_FILE_IN_OPS = new Set(
2423
+ [deriveRedirOp("cat < f"), deriveRedirOp("cat <> f")].filter((op) => op >= 0)
2424
+ );
2397
2425
  var REDIR_HEREDOC_OPS = /* @__PURE__ */ new Set([
2398
2426
  deriveRedirOp("cat <<X\nX"),
2399
2427
  deriveRedirOp("cat <<-X\nX")
@@ -2458,16 +2486,21 @@ function isRmCreatedInCommandCleanup(command) {
2458
2486
  }
2459
2487
  return sawRm && ok;
2460
2488
  }
2461
- function analyzeFsOperationImpl(command) {
2489
+ function analyzeFsOperationImpl(command, depth = 0) {
2462
2490
  const f = parseShared(command);
2463
2491
  if (f === PARSE_FAIL) return null;
2464
2492
  let result = null;
2465
2493
  try {
2466
2494
  syntax.Walk(f, (node) => {
2467
- if (!node || result) return false;
2495
+ if (!node || result?.verdict === "block") return false;
2468
2496
  const n = node;
2469
- if (syntax.NodeType(n) !== "CallExpr") return true;
2470
- const { name, flags, paths } = extractLiteralArgs(n);
2497
+ const nodeType = syntax.NodeType(n);
2498
+ if (nodeType === "Stmt") {
2499
+ result = stricter(result, jailedRedirectRead(n));
2500
+ return result?.verdict !== "block";
2501
+ }
2502
+ if (nodeType !== "CallExpr") return true;
2503
+ const { name, flags, paths, words } = extractLiteralArgs(n);
2471
2504
  if (!name) return true;
2472
2505
  if (name === "rm") {
2473
2506
  const flagStr = flags.join("").toLowerCase();
@@ -2496,19 +2529,22 @@ function analyzeFsOperationImpl(command) {
2496
2529
  }
2497
2530
  }
2498
2531
  }
2499
- if (FS_READ_TOOLS.has(name)) {
2500
- for (const p of paths) {
2501
- for (const sp of SENSITIVE_PATH_RULES) {
2502
- if (sp.match(p)) {
2503
- result = {
2504
- ruleName: sp.rule,
2505
- verdict: sp.verdict ?? "block",
2506
- reason: sp.reason,
2507
- path: p
2508
- };
2509
- return false;
2510
- }
2532
+ if (depth < 1) {
2533
+ const payload = literalShellPayload(words, name);
2534
+ if (payload !== null) {
2535
+ const inner = analyzeFsOperationImpl(payload, depth + 1);
2536
+ if (inner) {
2537
+ result = inner;
2538
+ return false;
2511
2539
  }
2540
+ return true;
2541
+ }
2542
+ }
2543
+ const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
2544
+ if (readPaths) {
2545
+ for (const p of readPaths) {
2546
+ result = stricter(result, matchSensitivePath2(p));
2547
+ if (result?.verdict === "block") return false;
2512
2548
  }
2513
2549
  }
2514
2550
  return true;
@@ -2518,6 +2554,57 @@ function analyzeFsOperationImpl(command) {
2518
2554
  return null;
2519
2555
  }
2520
2556
  }
2557
+ function stricter(a, b) {
2558
+ if (!a) return b;
2559
+ if (!b) return a;
2560
+ return b.verdict === "block" && a.verdict !== "block" ? b : a;
2561
+ }
2562
+ function matchSensitivePath2(p) {
2563
+ for (const sp of SENSITIVE_PATH_RULES) {
2564
+ if (sp.match(p))
2565
+ return { ruleName: sp.rule, verdict: sp.verdict ?? "block", reason: sp.reason, path: p };
2566
+ }
2567
+ return null;
2568
+ }
2569
+ var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(w.split("/").pop()?.toLowerCase() ?? "");
2570
+ var positionalAfter = (words, from, to = words.length) => words.slice(from, to).filter((w) => w !== null && !w.startsWith("-"));
2571
+ function wrappedReadPaths(words, name) {
2572
+ if (name === "find") {
2573
+ const k = words.findIndex((w) => w !== null && FIND_EXEC_FLAGS.has(w));
2574
+ if (k < 1 || !isReaderWord(words[k + 1] ?? null)) return null;
2575
+ const firstPredicate = words.findIndex((w, i) => i > 0 && w !== null && w.startsWith("-"));
2576
+ return positionalAfter(words, 1, firstPredicate > 0 ? firstPredicate : k);
2577
+ }
2578
+ if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
2579
+ const h = unwrapCommandHead(words);
2580
+ return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
2581
+ }
2582
+ function literalShellPayload(words, name) {
2583
+ const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
2584
+ const head = (words[h] ?? "").split("/").pop()?.toLowerCase() ?? "";
2585
+ if (head === "eval") {
2586
+ const rest = words.slice(h + 1);
2587
+ if (rest.length === 0 || rest.some((w) => w === null)) return null;
2588
+ return rest.join(" ");
2589
+ }
2590
+ if (SHELL_INTERPRETERS.has(head)) {
2591
+ const c = words.findIndex((w, i) => i > h && w !== null && isInlineCodeFlag(head, w));
2592
+ if (c < 0) return null;
2593
+ return words[c + 1] ?? null;
2594
+ }
2595
+ return null;
2596
+ }
2597
+ function jailedRedirectRead(stmt) {
2598
+ const redirs = stmt.Redirs || [];
2599
+ for (const r of redirs) {
2600
+ if (!r || !REDIR_FILE_IN_OPS.has(r.Op)) continue;
2601
+ const p = resolveWordLiteral(r.Word);
2602
+ if (p === null || p === "") continue;
2603
+ const hit = matchSensitivePath2(p);
2604
+ if (hit) return hit;
2605
+ }
2606
+ return null;
2607
+ }
2521
2608
  function analyzeShellCommand(command) {
2522
2609
  const actions = [];
2523
2610
  const paths = [];
@@ -2650,21 +2737,7 @@ function evaluateEgress(dests, policy) {
2650
2737
  }
2651
2738
  return review;
2652
2739
  }
2653
- var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
2654
- "cat",
2655
- "head",
2656
- "tail",
2657
- "grep",
2658
- "awk",
2659
- "sed",
2660
- "cut",
2661
- "sort",
2662
- "tee",
2663
- "less",
2664
- "more",
2665
- "strings",
2666
- "xxd"
2667
- ]);
2740
+ var SOURCE_COMMANDS = /* @__PURE__ */ new Set([...FS_READ_TOOLS, "tee"]);
2668
2741
  var SINK_COMMANDS = /* @__PURE__ */ new Set([
2669
2742
  "curl",
2670
2743
  "wget",
@@ -2695,16 +2768,25 @@ var OBFUSCATORS = /* @__PURE__ */ new Set([
2695
2768
  "node"
2696
2769
  ]);
2697
2770
  var SENSITIVE_PATTERNS = [
2698
- /(?:^|\/)\.env(?:\.|$)/i,
2699
- // .env, .env.local, .env.production
2771
+ // Kept in step with the AST tier and dlp/ -- see jail-both-doors.test.ts.
2772
+ /(?:^|\/)\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
2773
+ // .env chain; fixtures exempt unless .local
2700
2774
  /id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
2701
2775
  // SSH private keys
2702
2776
  /\.pem$|\.key$|\.p12$|\.pfx$/i,
2703
2777
  // certificate files
2704
- /(?:^|\/)\.ssh\//i,
2705
- // ~/.ssh/ directory
2706
- /(?:^|\/)\.aws\/credentials/i,
2707
- // AWS credentials
2778
+ // The `$` half mirrors shell/index.ts's SENSITIVE_PATH_RULES: a file INSIDE
2779
+ // the directory counts wherever it appears, while the directory ITSELF counts
2780
+ // only when the path is ROOTED (`~/.ssh`, `/home/u/.ssh`) -- an unrooted
2781
+ // `config/.ssh` is more likely a search pattern than a read. These are
2782
+ // extracted TOKENS (see `args.some(isSensitivePath)` below), the same input
2783
+ // contract as the shell tier, so the same boundary is the right one.
2784
+ // Without it `grep -r x ~/.ssh | curl -d @-` scored one tier BELOW the
2785
+ // identical pipeline naming a file inside that directory.
2786
+ /(?:^|\/)\.ssh\/|^(?:[~/]|[A-Za-z]:).*\/\.ssh$/i,
2787
+ // ~/.ssh/ and ~/.ssh
2788
+ /(?:^|\/)\.aws\/credentials|^(?:[~/]|[A-Za-z]:).*\/\.aws$/i,
2789
+ // AWS creds + dir
2708
2790
  /(?:^|\/)\.netrc$/i,
2709
2791
  // netrc (stores HTTP credentials)
2710
2792
  /(?:^|\/)(passwd|shadow|sudoers)$/i,
@@ -2738,8 +2820,8 @@ function splitOnPipe(cmd) {
2738
2820
  if (current.trim()) segments2.push(current.trim());
2739
2821
  return segments2.filter(Boolean);
2740
2822
  }
2741
- function positionalTokens(segment) {
2742
- return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2823
+ function positionalTokens(tokens) {
2824
+ return tokens.slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2743
2825
  }
2744
2826
  function analyzePipeChain(command) {
2745
2827
  const segments2 = splitOnPipe(command);
@@ -2762,8 +2844,10 @@ function analyzePipeChain(command) {
2762
2844
  for (const segment of segments2) {
2763
2845
  const tokens = segment.split(/\s+/).filter(Boolean);
2764
2846
  if (tokens.length === 0) continue;
2765
- const binary = tokens[0].toLowerCase();
2766
- const args = positionalTokens(segment);
2847
+ const h = unwrapCommandHead(tokens);
2848
+ const head = h < tokens.length ? h : 0;
2849
+ const binary = tokens[head].toLowerCase();
2850
+ const args = positionalTokens(tokens.slice(head));
2767
2851
  if (SOURCE_COMMANDS.has(binary)) {
2768
2852
  sourceFiles.push(...args);
2769
2853
  if (args.some(isSensitivePath)) hasSensitiveSource = true;
@@ -4162,7 +4246,7 @@ var project_jail_default = {
4162
4246
  {
4163
4247
  field: "command",
4164
4248
  op: "matches",
4165
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
4249
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
4166
4250
  flags: "i"
4167
4251
  }
4168
4252
  ],
@@ -4176,7 +4260,7 @@ var project_jail_default = {
4176
4260
  {
4177
4261
  field: "command",
4178
4262
  op: "matches",
4179
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
4263
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
4180
4264
  flags: "i"
4181
4265
  }
4182
4266
  ],
@@ -4190,7 +4274,7 @@ var project_jail_default = {
4190
4274
  {
4191
4275
  field: "command",
4192
4276
  op: "matches",
4193
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
4277
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
4194
4278
  flags: "i"
4195
4279
  }
4196
4280
  ],
@@ -4204,7 +4288,7 @@ var project_jail_default = {
4204
4288
  {
4205
4289
  field: "command",
4206
4290
  op: "matches",
4207
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
4291
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
4208
4292
  flags: "i"
4209
4293
  }
4210
4294
  ],
@@ -4218,7 +4302,7 @@ var project_jail_default = {
4218
4302
  {
4219
4303
  field: "file_path",
4220
4304
  op: "matches",
4221
- value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
4305
+ value: "([\\/\\\\]\\.ssh([\\/\\\\]|$)|^\\.ssh[\\/\\\\])",
4222
4306
  flags: "i"
4223
4307
  }
4224
4308
  ],
@@ -4232,7 +4316,7 @@ var project_jail_default = {
4232
4316
  {
4233
4317
  field: "file_path",
4234
4318
  op: "matches",
4235
- value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
4319
+ value: "([\\/\\\\]\\.aws([\\/\\\\]|$)|^\\.aws[\\/\\\\])",
4236
4320
  flags: "i"
4237
4321
  }
4238
4322
  ],
@@ -4246,7 +4330,7 @@ var project_jail_default = {
4246
4330
  {
4247
4331
  field: "file_path",
4248
4332
  op: "matches",
4249
- value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
4333
+ value: "(^|[\\/\\\\])\\.env(?![\\w-])(?:[\\w.-]*\\.local$|(?!\\.(example|sample|template)\\b)(?!\\.test$)[\\w.-]*$)",
4250
4334
  flags: "i"
4251
4335
  }
4252
4336
  ],
@@ -7023,12 +7107,13 @@ function extractFilePaths(toolName, args) {
7023
7107
  }
7024
7108
  return paths.filter(Boolean);
7025
7109
  }
7110
+ var NETWORK_COMMAND_RE = new RegExp(`(?<![.\\w-])(${[...NET_BINARIES].join("|")})\\b`);
7026
7111
  function isNetworkTool(toolName, args) {
7027
7112
  const t = toolName.toLowerCase();
7028
7113
  if (t === "bash" || t === "shell" || t === "run_shell_command" || t === "terminal.execute") {
7029
7114
  const a = args;
7030
7115
  const cmd = typeof a?.command === "string" ? a.command : typeof a?.cmd === "string" ? a.cmd : "";
7031
- return /\b(curl|wget|scp|rsync|nc|ncat|netcat|ssh)\b/.test(cmd);
7116
+ return NETWORK_COMMAND_RE.test(cmd);
7032
7117
  }
7033
7118
  return false;
7034
7119
  }
package/dist/scan-ink.mjs CHANGED
@@ -709,13 +709,32 @@ var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
709
709
  })
710
710
  );
711
711
  var SENSITIVE_PATH_PATTERNS = [
712
- /[/\\]\.ssh[/\\]/i,
713
- /[/\\]\.aws[/\\]/i,
712
+ /[/\\]\.ssh([/\\]|$)/i,
713
+ /[/\\]\.aws([/\\]|$)/i,
714
714
  /[/\\]\.config[/\\]gcloud[/\\]/i,
715
715
  /[/\\]\.azure[/\\]/i,
716
716
  /[/\\]\.kube[/\\]config$/i,
717
- /[/\\]\.env($|\.)/i,
718
- // .env, .env.local, .env.production — not .envoy
717
+ // ⚠️ ONE SEMANTIC, FOUR COPIES. This is the AST tier's `.env` rule verbatim
718
+ // (shell/index.ts SENSITIVE_PATH_RULES), whose reasoning is documented there:
719
+ // structural suffix chain rather than a hand-written list, `example|sample|
720
+ // template` exempt because a fixture stays a fixture whatever follows, and
721
+ // `.test` anchored because `test` names an ENVIRONMENT -- `.env.test` is the
722
+ // committed template, `.env.test.local` is gitignored and holds real values.
723
+ //
724
+ // It was previously `[/\\]\.env($|\.)` with NO exemptions, so `Read .env.example`
725
+ // blocked while `cat .env.example` allowed: the same file, opposite verdicts,
726
+ // decided only by which tool asked. See src/__tests__/jail-both-doors.test.ts,
727
+ // which is the contract that now holds these copies in step, and stage 5 of
728
+ // doc/credential-jail-architecture.md, which replaces them with one generated
729
+ // source.
730
+ // ⚠️ The `.local` branch comes FIRST and takes no exemption. A fixture stays a
731
+ // fixture whatever follows it -- `.env.example.md` is documentation -- but
732
+ // `.env.example.local` is gitignored by the `.env*.local` convention and holds
733
+ // real values, exactly the reasoning that anchors `(?!\.test$)` rather than
734
+ // using `\b`. Without this branch the fixture exemption also bought a two-step
735
+ // bypass: `cp .env .env.sample`, then read the copy.
736
+ /[/\\]\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
737
+ // .env + any suffix chain; fixtures exempt unless .local
719
738
  /[/\\]\.git-credentials$/i,
720
739
  /[/\\]\.npmrc$/i,
721
740
  /[/\\]\.docker[/\\]config\.json$/i,
@@ -782,6 +801,10 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
782
801
  "od",
783
802
  "xxd",
784
803
  "hexdump",
804
+ // Emits the file's bytes, re-encoded, so it is a read by the set's own test
805
+ // ("does it emit file contents"). Absent until 2026-09-10, which is why
806
+ // `base64 ~/.ssh/id_rsa` printed a private key with no verdict.
807
+ "base64",
785
808
  "strings",
786
809
  "sort",
787
810
  "uniq",
@@ -790,7 +813,11 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
790
813
  "dd"
791
814
  ]);
792
815
  var FS_OP_PRESCREEN_RE = new RegExp(
793
- `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
816
+ // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
817
+ // reader right after `"` / `'`, and without these two characters the
818
+ // prescreen rejected every string-wrapped read before the parser ran.
819
+ // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
820
+ `(?:^|[\\s|;&("'\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
794
821
  );
795
822
  function deriveRedirOp(sample) {
796
823
  try {
@@ -807,10 +834,14 @@ function deriveRedirOp(sample) {
807
834
  }
808
835
  }
809
836
  var REDIR_TRUNCATE_OPS = /* @__PURE__ */ new Set([deriveRedirOp(">_f")]);
837
+ var REDIR_FILE_IN_OPS = new Set(
838
+ [deriveRedirOp("cat < f"), deriveRedirOp("cat <> f")].filter((op) => op >= 0)
839
+ );
810
840
  var REDIR_HEREDOC_OPS = /* @__PURE__ */ new Set([
811
841
  deriveRedirOp("cat <<X\nX"),
812
842
  deriveRedirOp("cat <<-X\nX")
813
843
  ]);
844
+ var SOURCE_COMMANDS = /* @__PURE__ */ new Set([...FS_READ_TOOLS, "tee"]);
814
845
  var aws_default = {
815
846
  name: "aws",
816
847
  description: "Protects AWS infrastructure from destructive AI operations",
@@ -1362,7 +1393,7 @@ var project_jail_default = {
1362
1393
  {
1363
1394
  field: "command",
1364
1395
  op: "matches",
1365
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
1396
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
1366
1397
  flags: "i"
1367
1398
  }
1368
1399
  ],
@@ -1376,7 +1407,7 @@ var project_jail_default = {
1376
1407
  {
1377
1408
  field: "command",
1378
1409
  op: "matches",
1379
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
1410
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
1380
1411
  flags: "i"
1381
1412
  }
1382
1413
  ],
@@ -1390,7 +1421,7 @@ var project_jail_default = {
1390
1421
  {
1391
1422
  field: "command",
1392
1423
  op: "matches",
1393
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
1424
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
1394
1425
  flags: "i"
1395
1426
  }
1396
1427
  ],
@@ -1404,7 +1435,7 @@ var project_jail_default = {
1404
1435
  {
1405
1436
  field: "command",
1406
1437
  op: "matches",
1407
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
1438
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
1408
1439
  flags: "i"
1409
1440
  }
1410
1441
  ],
@@ -1418,7 +1449,7 @@ var project_jail_default = {
1418
1449
  {
1419
1450
  field: "file_path",
1420
1451
  op: "matches",
1421
- value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
1452
+ value: "([\\/\\\\]\\.ssh([\\/\\\\]|$)|^\\.ssh[\\/\\\\])",
1422
1453
  flags: "i"
1423
1454
  }
1424
1455
  ],
@@ -1432,7 +1463,7 @@ var project_jail_default = {
1432
1463
  {
1433
1464
  field: "file_path",
1434
1465
  op: "matches",
1435
- value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
1466
+ value: "([\\/\\\\]\\.aws([\\/\\\\]|$)|^\\.aws[\\/\\\\])",
1436
1467
  flags: "i"
1437
1468
  }
1438
1469
  ],
@@ -1446,7 +1477,7 @@ var project_jail_default = {
1446
1477
  {
1447
1478
  field: "file_path",
1448
1479
  op: "matches",
1449
- value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
1480
+ value: "(^|[\\/\\\\])\\.env(?![\\w-])(?:[\\w.-]*\\.local$|(?!\\.(example|sample|template)\\b)(?!\\.test$)[\\w.-]*$)",
1450
1481
  flags: "i"
1451
1482
  }
1452
1483
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "2.12.0",
3
+ "version": "2.13.1",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",