agent-security-scanner-mcp 3.16.0 → 3.17.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.
@@ -40,11 +40,24 @@ class DaemonClient {
40
40
  async ensureRunning() {
41
41
  if (this._dead) throw new Error('Daemon permanently unavailable');
42
42
  if (this._proc && !this._proc.killed && this._proc.exitCode === null) return;
43
+
44
+ // Security: Fix race condition - create promise synchronously before any await
43
45
  if (this._starting) return this._starting;
44
46
 
45
- this._starting = this._spawn();
47
+ // Create promise SYNCHRONOUSLY to prevent race window
48
+ const spawnPromise = (async () => {
49
+ try {
50
+ await this._spawn();
51
+ } catch (err) {
52
+ this._starting = null; // Clear on error
53
+ throw err;
54
+ }
55
+ })();
56
+
57
+ this._starting = spawnPromise;
58
+
46
59
  try {
47
- await this._starting;
60
+ await spawnPromise;
48
61
  } finally {
49
62
  this._starting = null;
50
63
  }
@@ -218,12 +231,46 @@ class DaemonClient {
218
231
 
219
232
  async shutdown() {
220
233
  if (!this._proc || this._proc.killed || this._proc.exitCode !== null) return;
234
+
235
+ // Security: Fix process orphaning with timeout and SIGKILL fallback
236
+ // Try graceful shutdown first (5 second timeout)
221
237
  try {
222
- await this._send({ action: 'shutdown' });
223
- } catch {
224
- // ignore process may already be gone
238
+ await Promise.race([
239
+ this._send({ action: 'shutdown' }),
240
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Shutdown timeout')), 5000))
241
+ ]);
242
+ } catch (err) {
243
+ console.warn(`Graceful daemon shutdown failed: ${err.message}. Force-killing.`);
225
244
  }
245
+
246
+ // Force kill if still running
247
+ if (this._proc && !this._proc.killed && this._proc.exitCode === null) {
248
+ try {
249
+ this._proc.kill('SIGKILL');
250
+ } catch (err) {
251
+ console.error(`Failed to kill daemon process:`, err.message);
252
+ }
253
+ }
254
+
226
255
  this._cleanup();
256
+
257
+ // Wait for process to actually exit (with timeout)
258
+ if (this._proc) {
259
+ await new Promise(resolve => {
260
+ const checkInterval = setInterval(() => {
261
+ if (!this._proc || this._proc.exitCode !== null) {
262
+ clearInterval(checkInterval);
263
+ resolve();
264
+ }
265
+ }, 100);
266
+
267
+ // Timeout after 2 seconds
268
+ setTimeout(() => {
269
+ clearInterval(checkInterval);
270
+ resolve();
271
+ }, 2000);
272
+ });
273
+ }
227
274
  }
228
275
  }
229
276
 
@@ -484,12 +484,29 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
484
484
  const allRules = [...agentRules, ...promptRules, ...openclawRules];
485
485
 
486
486
  // 2.7: Extract content from code blocks (``` and ~~~) and append to scan text
487
+ // Security: Add size limits and iteration caps to prevent ReDoS
488
+ const EXPANDED_TEXT_MAX = 500 * 1024; // 500KB absolute max
489
+ const MAX_CODE_BLOCK_ITERATIONS = 100;
490
+ const MAX_SINGLE_BLOCK_SIZE = 10000; // 10KB per code block
491
+
487
492
  let expandedText = prompt_text;
488
493
  const codeBlockRegex = /(`{3,})([\s\S]*?)\1|(~{3,})([\s\S]*?)\3/g;
489
494
  let codeBlockMatch;
495
+ let iterations = 0;
496
+
490
497
  while ((codeBlockMatch = codeBlockRegex.exec(prompt_text)) !== null) {
498
+ if (++iterations > MAX_CODE_BLOCK_ITERATIONS) {
499
+ console.warn('Code block extraction iteration limit reached');
500
+ break;
501
+ }
502
+ if (expandedText.length > EXPANDED_TEXT_MAX) {
503
+ console.warn('Expanded text size limit reached');
504
+ break;
505
+ }
506
+
491
507
  // Group 2 = content inside backtick fences, Group 4 = content inside tilde fences
492
508
  const inner = (codeBlockMatch[2] || codeBlockMatch[4] || '')
509
+ .substring(0, MAX_SINGLE_BLOCK_SIZE) // Cap individual block size
493
510
  .replace(/^\w*\n?/, ''); // strip optional language tag
494
511
  expandedText += '\n' + inner;
495
512
  }
@@ -531,9 +548,10 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
531
548
  }
532
549
 
533
550
  // 2.7d: Strip Zalgo diacritics — NFKD decompose first, then strip combining marks
551
+ // Security: Use Unicode property escapes to catch ALL combining marks (fixes bypass)
534
552
  const nfkd = expandedText.normalize('NFKD');
535
- const zalgoStripped = nfkd.replace(/[\u0300-\u036f\u0488\u0489\u1dc0-\u1dff\u20d0-\u20ff\ufe20-\ufe2f]/g, '');
536
- if (zalgoStripped !== expandedText) {
553
+ const zalgoStripped = nfkd.replace(/\p{Mn}/gu, ''); // All Mark-Nonspacing combining characters
554
+ if (zalgoStripped !== expandedText && expandedText.length < EXPANDED_TEXT_MAX) {
537
555
  expandedText += '\n' + zalgoStripped;
538
556
  }
539
557
 
@@ -562,12 +580,22 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
562
580
  }
563
581
 
564
582
  // Scan expanded text against all rules
583
+ // Security: Add timeout protection for regex matching
584
+ const REGEX_TIMEOUT_MS = 1000;
585
+
565
586
  for (const rule of allRules) {
566
587
  for (const pattern of rule.patterns) {
567
588
  try {
568
589
  const regex = new RegExp(pattern, 'i');
590
+ const startTime = Date.now();
569
591
  const match = expandedText.match(regex);
570
592
 
593
+ // Check for regex timeout (ReDoS protection)
594
+ if (Date.now() - startTime > REGEX_TIMEOUT_MS) {
595
+ console.warn(`Regex timeout for rule ${rule.id}, skipping`);
596
+ break;
597
+ }
598
+
571
599
  if (match) {
572
600
  findings.push({
573
601
  rule_id: rule.id,
@@ -583,6 +611,7 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
583
611
  }
584
612
  } catch (e) {
585
613
  // Skip invalid regex
614
+ console.warn(`Regex error for rule ${rule.id}:`, e.message);
586
615
  }
587
616
  }
588
617
  }
@@ -3,7 +3,7 @@
3
3
  // supply chain verification, and rug pull detection.
4
4
 
5
5
  import { z } from "zod";
6
- import { existsSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, writeFileSync, mkdirSync, unlinkSync, renameSync, chmodSync } from "fs";
6
+ import { existsSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, writeFileSync, mkdirSync, unlinkSync, renameSync, chmodSync, mkdtempSync, rmSync } from "fs";
7
7
  import { resolve, basename, dirname, extname, join, sep } from "path";
8
8
  import { createHash } from "crypto";
9
9
  import { tmpdir, homedir } from "os";
@@ -329,11 +329,20 @@ async function runCodeBlockScan(blocks, signal) {
329
329
  const ext = LANG_EXT_MAP[lang];
330
330
  if (!ext) continue;
331
331
 
332
- const tmpName = `skill-scan-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
333
- const tmpPath = join(tmpdir(), tmpName);
332
+ // Security: Create unique temp directory to prevent race conditions and symlink attacks
333
+ let tmpDir;
334
+ try {
335
+ tmpDir = mkdtempSync(join(tmpdir(), 'skill-scan-'));
336
+ } catch (err) {
337
+ console.error(`Failed to create temp directory: ${err.message}`);
338
+ continue;
339
+ }
340
+
341
+ const tmpPath = join(tmpDir, `code.${ext}`);
334
342
 
335
343
  try {
336
- writeFileSync(tmpPath, code, 'utf-8');
344
+ // Write with restrictive permissions (0600) to prevent other users from reading
345
+ writeFileSync(tmpPath, code, { encoding: 'utf-8', mode: 0o600 });
337
346
  const issues = await runAnalyzerAsync(tmpPath, 'auto', signal);
338
347
  if (Array.isArray(issues)) {
339
348
  for (const issue of issues) {
@@ -350,7 +359,12 @@ async function runCodeBlockScan(blocks, signal) {
350
359
  }
351
360
  }
352
361
  } finally {
353
- try { unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
362
+ // Clean up entire directory atomically
363
+ try {
364
+ rmSync(tmpDir, { recursive: true, force: true });
365
+ } catch (err) {
366
+ console.error(`Failed to clean up temp dir ${tmpDir}:`, err.message);
367
+ }
354
368
  }
355
369
  } catch (error) {
356
370
  console.error(`Layer 2 (code block scan) failed for ${lang}:`, error.message);
@@ -878,64 +892,52 @@ function generateRecommendation(grade) {
878
892
  // ---------------------------------------------------------------------------
879
893
 
880
894
  export async function scanSkill({ skill_path, verbosity, baseline }) {
881
- // Path resolution
882
- const resolvedPath = resolve(skill_path);
895
+ // Security: Resolve to canonical path FIRST to prevent TOCTOU and symlink attacks
896
+ const inputPath = skill_path;
897
+ let realPath;
883
898
 
884
- // Path containment — check on resolved path FIRST (before existence)
885
- // so that invalid external paths get rejected with the right error message.
886
- // Use raw cwd here (resolvedPath is also non-canonical at this point).
887
- const rawCwd = process.cwd();
888
- const allowedSkillRoots = [
889
- resolve(homedir(), '.openclaw', 'skills'),
890
- resolve(homedir(), '.openclaw', 'workspace', 'skills'),
891
- ];
892
- const isAllowed = pathStartsWith(resolvedPath, rawCwd)
893
- || allowedSkillRoots.some(root => pathStartsWith(resolvedPath, root));
894
- if (!isAllowed) {
899
+ try {
900
+ // Resolve to canonical path immediately (defeats symlink attacks)
901
+ realPath = realpathSync(resolve(inputPath));
902
+ } catch (err) {
895
903
  return {
896
904
  content: [{ type: "text", text: JSON.stringify({
897
- error: "skill_path must be within the current working directory or ~/.openclaw/skills/ (or ~/.openclaw/workspace/skills/)",
898
- skill_path: resolvedPath
905
+ error: "Invalid path, symlink loop, or permission denied",
906
+ skill_path: inputPath,
907
+ details: err.message
899
908
  }) }]
900
909
  };
901
910
  }
902
911
 
903
- if (!existsSync(resolvedPath)) {
904
- return {
905
- content: [{ type: "text", text: JSON.stringify({ error: "Skill path not found", skill_path: resolvedPath }) }]
906
- };
907
- }
912
+ // Verify containment on canonical path ONLY
913
+ // This prevents symlink escapes by checking the REAL resolved location
914
+ const canonCwd = realpathSync(process.cwd());
915
+ const allowedSkillRoots = [
916
+ resolve(homedir(), '.openclaw', 'skills'),
917
+ resolve(homedir(), '.openclaw', 'workspace', 'skills'),
918
+ ].map(root => {
919
+ try {
920
+ return existsSync(root) ? realpathSync(root) : null;
921
+ } catch {
922
+ return null;
923
+ }
924
+ }).filter(Boolean);
908
925
 
909
- // Reject symlinks at the top level to prevent symlink-based path escapes
910
- const topStat = lstatSync(resolvedPath);
911
- if (topStat.isSymbolicLink()) {
912
- return {
913
- content: [{ type: "text", text: JSON.stringify({
914
- error: "Symbolic links are not allowed as skill_path — resolve the real path first",
915
- skill_path: resolvedPath
916
- }) }]
917
- };
918
- }
926
+ const isAllowed = pathStartsWith(realPath, canonCwd)
927
+ || allowedSkillRoots.some(root => pathStartsWith(realPath, root));
919
928
 
920
- // Resolve to real path and re-verify containment (defeats symlink escapes)
921
- // Use canonical cwd here since realPath is also canonical.
922
- const realPath = realpathSync(resolvedPath);
923
- let canonCwd;
924
- try { canonCwd = realpathSync(rawCwd); } catch { canonCwd = rawCwd; }
925
- const canonRoots = allowedSkillRoots.map(root => {
926
- try { return realpathSync(root); } catch { return root; }
927
- });
928
- const realAllowed = pathStartsWith(realPath, canonCwd)
929
- || canonRoots.some(root => pathStartsWith(realPath, root));
930
- if (!realAllowed) {
929
+ if (!isAllowed) {
931
930
  return {
932
931
  content: [{ type: "text", text: JSON.stringify({
933
932
  error: "skill_path must be within the current working directory or ~/.openclaw/skills/ (or ~/.openclaw/workspace/skills/)",
934
- skill_path: realPath
933
+ skill_path: realPath,
934
+ attempted_path: inputPath
935
935
  }) }]
936
936
  };
937
937
  }
938
938
 
939
+ // Path is now safe - realPath is canonical and within allowed boundaries
940
+
939
941
  const stat = statSync(realPath);
940
942
  let skillDir, skillFile;
941
943