@privacyscrubber/mcp-server 2.4.4 → 2.4.5

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/README.md CHANGED
@@ -482,7 +482,7 @@ Returns a visual dashboard showing your current tier, session request count, act
482
482
  * **Response Example (Free Tier):**
483
483
  ```
484
484
  ╔══════════════════════════════════════════════════╗
485
- ║ PrivacyScrubber MCP Server v2.4.4 ║
485
+ ║ PrivacyScrubber MCP Server v2.4.5 ║
486
486
  ╠══════════════════════════════════════════════════╣
487
487
  ║ 🔓 Tier: FREE ║
488
488
  ║ 📊 Session requests: 5 ║
package/index.js CHANGED
@@ -2178,6 +2178,35 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2178
2178
  throw new Error(`Resource not found: ${uri}`);
2179
2179
  });
2180
2180
 
2181
+ function resolveWorkspaceSafePath(inputPath, paramName = "file_path") {
2182
+ const workspaceRoot = fs.existsSync(process.cwd()) ? fs.realpathSync(process.cwd()) : path.resolve(process.cwd());
2183
+ const resolvedPath = path.resolve(workspaceRoot, inputPath);
2184
+ const relToRoot = path.relative(workspaceRoot, resolvedPath);
2185
+ if (relToRoot.startsWith("..") || path.isAbsolute(relToRoot)) {
2186
+ return {
2187
+ ok: false,
2188
+ error: `Error: Security policy violation — target '${paramName}' resolves outside the active workspace directory (path traversal blocked).`
2189
+ };
2190
+ }
2191
+ let checkCursor = resolvedPath;
2192
+ while (!fs.existsSync(checkCursor)) {
2193
+ const parentCursor = path.dirname(checkCursor);
2194
+ if (parentCursor === checkCursor) break;
2195
+ checkCursor = parentCursor;
2196
+ }
2197
+ if (fs.existsSync(checkCursor)) {
2198
+ const realCheck = fs.realpathSync(checkCursor);
2199
+ const relReal = path.relative(workspaceRoot, realCheck);
2200
+ if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
2201
+ return {
2202
+ ok: false,
2203
+ error: `Error: Security policy violation — target path symlink escapes the active workspace directory.`
2204
+ };
2205
+ }
2206
+ }
2207
+ return { ok: true, resolvedPath, workspaceRoot };
2208
+ }
2209
+
2181
2210
  // Handle tool execution calls
2182
2211
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2183
2212
  const { name, arguments: args } = request.params;
@@ -2457,7 +2486,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2457
2486
  }
2458
2487
 
2459
2488
  if (name === "sanitize_file" || name === "scrub_file") {
2460
- const rawPath = args.file_path || args.filePath;
2489
+ const rawPath = args?.file_path || args?.filePath;
2461
2490
  if (!rawPath) {
2462
2491
  return {
2463
2492
  isError: true,
@@ -2474,8 +2503,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2474
2503
  const targetSession = getSessionMap(session_id);
2475
2504
  const targetIgnoreList = getSessionIgnoreList(session_id);
2476
2505
  const filePath = rawPath;
2477
-
2478
- const resolvedPath = path.resolve(filePath);
2506
+ const safeTarget = resolveWorkspaceSafePath(filePath, "file_path");
2507
+ if (!safeTarget.ok) {
2508
+ return {
2509
+ isError: true,
2510
+ content: [{ type: "text", text: safeTarget.error }]
2511
+ };
2512
+ }
2513
+ const resolvedPath = safeTarget.resolvedPath;
2479
2514
  if (!fs.existsSync(resolvedPath)) {
2480
2515
  return {
2481
2516
  isError: true,
@@ -2882,23 +2917,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2882
2917
  }
2883
2918
 
2884
2919
  if (name === "redact_file") {
2885
- const { file_path, dry_run, no_backup } = args;
2886
- if (!file_path || !fs.existsSync(file_path)) {
2920
+ const { file_path, dry_run, no_backup } = args || {};
2921
+ if (!file_path) {
2922
+ return { isError: true, content: [{ type: "text", text: "Error: Missing required parameter 'file_path'." }] };
2923
+ }
2924
+ const safeTarget = resolveWorkspaceSafePath(file_path, "file_path");
2925
+ if (!safeTarget.ok) {
2926
+ return { isError: true, content: [{ type: "text", text: safeTarget.error }] };
2927
+ }
2928
+ const resolvedRedactPath = safeTarget.resolvedPath;
2929
+ if (!fs.existsSync(resolvedRedactPath)) {
2887
2930
  return { isError: true, content: [{ type: "text", text: `Error: File not found at path: ${file_path}` }] };
2888
2931
  }
2889
2932
 
2890
2933
  try {
2891
- const stat = await fs.promises.stat(file_path);
2934
+ const stat = await fs.promises.stat(resolvedRedactPath);
2892
2935
  if (stat.size > 50 * 1024 * 1024) {
2893
2936
  return { isError: true, content: [{ type: "text", text: `Error: File is too large (>50MB).` }] };
2894
2937
  }
2895
2938
 
2896
- const buffer = fs.readFileSync(file_path);
2939
+ const buffer = fs.readFileSync(resolvedRedactPath);
2897
2940
  if (buffer.indexOf(0) !== -1) {
2898
2941
  return { isError: true, content: [{ type: "text", text: `Error: Cannot redact binary file.` }] };
2899
2942
  }
2900
2943
  let content = buffer.toString('utf8');
2901
- const ext = path.extname(file_path).toLowerCase();
2944
+ const ext = path.extname(resolvedRedactPath).toLowerCase();
2902
2945
  if (ext === ".eml") {
2903
2946
  const decodeQpUtf8 = (str) =>
2904
2947
  str.replace(/=\r?\n/g, "").replace(/(?:=[0-9A-Fa-f]{2})+/g, (seq) => {
@@ -2992,15 +3035,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2992
3035
  }
2993
3036
 
2994
3037
  if (!no_backup) {
2995
- fs.writeFileSync(`${file_path}.bak`, content, 'utf8');
3038
+ fs.writeFileSync(`${resolvedRedactPath}.bak`, content, 'utf8');
2996
3039
  }
2997
3040
 
2998
- fs.writeFileSync(file_path, result.scrubbedText, 'utf8');
3041
+ fs.writeFileSync(resolvedRedactPath, result.scrubbedText, 'utf8');
2999
3042
 
3000
3043
  return {
3001
3044
  content: [{
3002
3045
  type: "text",
3003
- text: `✅ File successfully redacted.\nReplaced ${Object.keys(localSessionMap).length} tokens (${summary}).\n${!no_backup ? `Backup saved to ${file_path}.bak` : "No backup created (no_backup=true)."}`
3046
+ text: `✅ File successfully redacted.\nReplaced ${Object.keys(localSessionMap).length} tokens (${summary}).\n${!no_backup ? `Backup saved to ${resolvedRedactPath}.bak` : "No backup created (no_backup=true)."}`
3004
3047
  }]
3005
3048
  };
3006
3049
 
@@ -3168,8 +3211,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3168
3211
  }
3169
3212
 
3170
3213
  if (name === "audit_directory_for_pii") {
3171
- const { directory_path, extensions, ignore_node_modules } = args;
3172
- if (!directory_path || !fs.existsSync(directory_path)) {
3214
+ const { directory_path, extensions, ignore_node_modules } = args || {};
3215
+ if (!directory_path) {
3216
+ return { isError: true, content: [{ type: "text", text: "Error: Missing required parameter 'directory_path'." }] };
3217
+ }
3218
+ const safeDir = resolveWorkspaceSafePath(directory_path, "directory_path");
3219
+ if (!safeDir.ok) {
3220
+ return { isError: true, content: [{ type: "text", text: safeDir.error }] };
3221
+ }
3222
+ if (!fs.existsSync(safeDir.resolvedPath)) {
3173
3223
  return { isError: true, content: [{ type: "text", text: `Error: Directory not found at path: ${directory_path}` }] };
3174
3224
  }
3175
3225
 
@@ -3302,7 +3352,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3302
3352
  // Transparent in-memory unmasking of any token placeholders before local execution
3303
3353
  const rawExecutableCommand = reverseTokens(command, targetSession);
3304
3354
 
3305
- const execCwd = cwd ? path.resolve(cwd) : process.cwd();
3355
+ let execCwd = process.cwd();
3356
+ if (cwd) {
3357
+ const safeCwd = resolveWorkspaceSafePath(cwd, "cwd");
3358
+ if (!safeCwd.ok) {
3359
+ return { isError: true, content: [{ type: "text", text: safeCwd.error }] };
3360
+ }
3361
+ execCwd = safeCwd.resolvedPath;
3362
+ }
3306
3363
  let stdout = '';
3307
3364
  let stderr = '';
3308
3365
  let exitCode = 0;
@@ -3358,7 +3415,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3358
3415
  content: [{ type: "text", text: "Error: Missing required parameter 'file_path'." }]
3359
3416
  };
3360
3417
  }
3361
- const resolvedPath = path.resolve(process.cwd(), file_path);
3418
+ const safeTarget = resolveWorkspaceSafePath(file_path, "file_path");
3419
+ if (!safeTarget.ok) {
3420
+ return {
3421
+ isError: true,
3422
+ content: [{ type: "text", text: safeTarget.error }]
3423
+ };
3424
+ }
3425
+ const resolvedPath = safeTarget.resolvedPath;
3362
3426
  if (!fs.existsSync(resolvedPath)) {
3363
3427
  return {
3364
3428
  isError: true,
@@ -3464,7 +3528,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3464
3528
  const { staged = false, cwd, session_id = "default" } = args || {};
3465
3529
  const targetSession = getSessionMap(session_id);
3466
3530
  const targetIgnoreList = getSessionIgnoreList(session_id);
3467
- const repoCwd = cwd ? path.resolve(cwd) : process.cwd();
3531
+ let repoCwd = process.cwd();
3532
+ if (cwd) {
3533
+ const safeCwd = resolveWorkspaceSafePath(cwd, "cwd");
3534
+ if (!safeCwd.ok) {
3535
+ return { isError: true, content: [{ type: "text", text: safeCwd.error }] };
3536
+ }
3537
+ repoCwd = safeCwd.resolvedPath;
3538
+ }
3468
3539
  const license = checkLicenseStatus();
3469
3540
  const limitStatus = checkFreeTierLimit(license.isPro);
3470
3541
  if (limitStatus.blocked) {
@@ -3528,7 +3599,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3528
3599
  };
3529
3600
  }
3530
3601
 
3531
- const resolvedPath = path.resolve(process.cwd(), file_path);
3602
+ const workspaceRoot = fs.existsSync(process.cwd()) ? fs.realpathSync(process.cwd()) : path.resolve(process.cwd());
3603
+ const resolvedPath = path.resolve(workspaceRoot, file_path);
3604
+ const relToRoot = path.relative(workspaceRoot, resolvedPath);
3605
+ if (relToRoot.startsWith("..") || path.isAbsolute(relToRoot)) {
3606
+ return {
3607
+ isError: true,
3608
+ content: [{ type: "text", text: "Error: Security policy violation — target 'file_path' resolves outside the active workspace directory (path traversal blocked)." }]
3609
+ };
3610
+ }
3611
+ let checkCursor = resolvedPath;
3612
+ while (!fs.existsSync(checkCursor)) {
3613
+ const parentCursor = path.dirname(checkCursor);
3614
+ if (parentCursor === checkCursor) break;
3615
+ checkCursor = parentCursor;
3616
+ }
3617
+ if (fs.existsSync(checkCursor)) {
3618
+ const realCheck = fs.realpathSync(checkCursor);
3619
+ const relReal = path.relative(workspaceRoot, realCheck);
3620
+ if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
3621
+ return {
3622
+ isError: true,
3623
+ content: [{ type: "text", text: "Error: Security policy violation — target path symlink escapes the active workspace directory." }]
3624
+ };
3625
+ }
3626
+ }
3627
+
3532
3628
  const targetSession = getSessionMap(session_id);
3533
3629
  const restored = PrivacyScrubberCore.unscrubText(content, targetSession);
3534
3630
  const authenticContent = restored.restoredText;
@@ -3561,7 +3657,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3561
3657
 
3562
3658
  if (name === "create_agent_rules") {
3563
3659
  const { agent_types = ["all"], workspace_dir } = args || {};
3564
- const targetDir = workspace_dir ? path.resolve(workspace_dir) : process.cwd();
3660
+ let targetDir = process.cwd();
3661
+ if (workspace_dir) {
3662
+ const safeWorkspace = resolveWorkspaceSafePath(workspace_dir, "workspace_dir");
3663
+ if (!safeWorkspace.ok) {
3664
+ return { isError: true, content: [{ type: "text", text: safeWorkspace.error }] };
3665
+ }
3666
+ targetDir = safeWorkspace.resolvedPath;
3667
+ }
3565
3668
  const targets = agent_types.includes("all") ? ["cursor", "windsurf", "claude_code", "copilot", "cline"] : agent_types;
3566
3669
 
3567
3670
  const ruleText = `# PrivacyScrubber Zero-Trust Agentic Guard (CISO Security Rules)
@@ -4051,6 +4154,18 @@ function getSessionMetricsSummary() {
4051
4154
  return parts.join(', ');
4052
4155
  }
4053
4156
 
4157
+ function isCatastrophicRegex(pat) {
4158
+ if (!pat || typeof pat !== 'string' || pat.length > 500) return true;
4159
+ const nestedQuantifierRegex = /\([^)]*(\+|\*|\{\d+,?\d*\})\)[+*]|\([^)]*(\+|\*|\{\d+,?\d*\})\)\{\d+,?\d*\}|\([^)]*\|[^)]*\)(?:[+*]|\{\d+,?\d*\})/;
4160
+ if (nestedQuantifierRegex.test(pat)) return true;
4161
+ try {
4162
+ new RegExp(pat, 'gi');
4163
+ return false;
4164
+ } catch (_) {
4165
+ return true;
4166
+ }
4167
+ }
4168
+
4054
4169
  function loadCustomRules() {
4055
4170
  const license = checkLicenseStatus();
4056
4171
  const configPath = resolveConfigPath();
@@ -4066,7 +4181,7 @@ function loadCustomRules() {
4066
4181
  label: (r.label || r.category || "CUSTOM").toUpperCase(),
4067
4182
  regex: r.pattern || r.regex || "",
4068
4183
  category: (r.label || r.category || "CUSTOM").toUpperCase()
4069
- })).filter(r => r.pattern);
4184
+ })).filter(r => r.pattern && !isCatastrophicRegex(r.pattern));
4070
4185
  } else {
4071
4186
  mcpLog(`${colors.yellowBold}⚠️ [PrivacyScrubber] Custom rules detected in privacyscrubber.json, but are ignored in the Free Tier.${colors.reset}\n${colors.cyan}👉 Set PRIVACYSCRUBBER_KEY to your PRO license key.${colors.reset}\n`);
4072
4187
  }
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "privacyscrubber/pii-masking-mcp",
4
- "version": "2.4.4",
4
+ "version": "2.4.5",
5
5
  "description": "CISO-Approved Zero-Trust PII & Secrets Redaction MCP Server for Cursor, Windsurf, and Claude Desktop. Prevent API leaks and comply with HIPAA/SOC 2 locally.",
6
6
  "author": {
7
7
  "name": "PrivacyScrubber",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@privacyscrubber/mcp-server",
3
- "version": "2.4.4",
3
+ "version": "2.4.5",
4
4
  "mcpName": "io.github.moxno/privacyscrubber-mcp",
5
5
  "description": "CISO-Approved Zero-Trust PII & Secrets Redaction MCP Server for Cursor, Windsurf, and Claude Desktop. Prevent API leaks and comply with HIPAA/SOC 2 locally.",
6
6
  "main": "index.js",
@@ -27,7 +27,7 @@ const cmdIndex = args.indexOf("--");
27
27
 
28
28
  if (args.includes("--version") || args.includes("-v")) {
29
29
  const pkgPath = path.resolve(__dirname, './package.json');
30
- let version = '2.4.4';
30
+ let version = '2.4.5';
31
31
  try { version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version; } catch (_) {}
32
32
  console.log(`ps-guard / pii-masking-run v${version} (PrivacyScrubber MCP)`);
33
33
  process.exit(0);