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.
- package/README.md +20 -1
- package/index.js +62 -43
- package/package.json +1 -1
- package/packages/npm-bloom.json +1 -0
- package/packages/pypi-bloom.json +1 -0
- package/packages/rubygems-bloom.json +1 -0
- package/src/daemon-client.js +52 -5
- package/src/tools/scan-prompt.js +31 -2
- package/src/tools/scan-skill.js +49 -47
package/src/daemon-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
package/src/tools/scan-prompt.js
CHANGED
|
@@ -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(/
|
|
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
|
}
|
package/src/tools/scan-skill.js
CHANGED
|
@@ -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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
882
|
-
const
|
|
895
|
+
// Security: Resolve to canonical path FIRST to prevent TOCTOU and symlink attacks
|
|
896
|
+
const inputPath = skill_path;
|
|
897
|
+
let realPath;
|
|
883
898
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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: "
|
|
898
|
-
skill_path:
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
910
|
-
|
|
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
|
-
|
|
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
|
|