agent-security-scanner-mcp 3.18.0 → 3.20.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/analyzer.py +23 -2
- package/compliance/aiuc-1-controls.json +330 -0
- package/cross_file_analyzer.py +478 -5
- package/index.js +21 -1
- package/package.json +5 -2
- package/python_taint_fallback.py +688 -0
- package/rules/__init__.py +42 -3
- package/rules/prompt-injection.security.yaml +4 -4
- package/src/cli/report.js +71 -0
- package/src/fix-patterns.js +9 -9
- package/src/history.js +1 -1
- package/src/lib/aivss.js +284 -0
- package/src/lib/compliance-controls.js +164 -0
- package/src/lib/compliance-evaluator.js +149 -0
- package/src/lib/normalize-finding.js +146 -0
- package/src/tools/check-package.js +15 -0
- package/src/tools/compliance-controls.js +67 -0
- package/src/tools/scan-prompt.js +44 -31
- package/src/tools/scan-skill.js +42 -22
- package/src/tools/score-aivss.js +98 -0
package/src/tools/scan-prompt.js
CHANGED
|
@@ -58,11 +58,41 @@ const CONFIDENCE_MULTIPLIERS = {
|
|
|
58
58
|
// Maximum prompt size to prevent DoS via large inputs (100KB)
|
|
59
59
|
const MAX_PROMPT_SIZE = 100 * 1024;
|
|
60
60
|
|
|
61
|
+
// Maximum text length fed to any single regex to prevent ReDoS.
|
|
62
|
+
// Prompt-injection patterns look for short markers/phrases, so scanning
|
|
63
|
+
// overlapping 2 KB windows covers all realistic payloads while keeping
|
|
64
|
+
// worst-case regex time bounded.
|
|
65
|
+
const REGEX_SCAN_WINDOW = 2048;
|
|
66
|
+
const REGEX_SCAN_OVERLAP = 256;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Match a regex against text safely — splits long text into overlapping
|
|
70
|
+
* windows so no single regex call processes more than REGEX_SCAN_WINDOW chars.
|
|
71
|
+
*/
|
|
72
|
+
function safeMatch(text, regex) {
|
|
73
|
+
if (text.length <= REGEX_SCAN_WINDOW) {
|
|
74
|
+
return text.match(regex);
|
|
75
|
+
}
|
|
76
|
+
for (let offset = 0; offset < text.length; offset += REGEX_SCAN_WINDOW - REGEX_SCAN_OVERLAP) {
|
|
77
|
+
const chunk = text.slice(offset, offset + REGEX_SCAN_WINDOW);
|
|
78
|
+
const m = chunk.match(regex);
|
|
79
|
+
if (m) return m;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
// Rule caches — loaded once per process, not on every call
|
|
62
85
|
let _agentAttackRulesCache = null;
|
|
63
86
|
let _promptInjectionRulesCache = null;
|
|
64
87
|
let _openClawRulesCache = null;
|
|
65
88
|
|
|
89
|
+
function normalizeYamlRegexPattern(pattern) {
|
|
90
|
+
return pattern
|
|
91
|
+
.replace(/^["']|["']$/g, '')
|
|
92
|
+
.replace(/\(\?i\)/g, '')
|
|
93
|
+
.replace(/\\\\/g, '\\');
|
|
94
|
+
}
|
|
95
|
+
|
|
66
96
|
// Load agent attack rules from YAML
|
|
67
97
|
function loadAgentAttackRules() {
|
|
68
98
|
if (_agentAttackRulesCache !== null) return _agentAttackRulesCache;
|
|
@@ -108,11 +138,7 @@ function loadAgentAttackRules() {
|
|
|
108
138
|
inMetadata = true;
|
|
109
139
|
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
110
140
|
let pattern = line.replace(/^\s+- /, '').trim();
|
|
111
|
-
pattern = pattern
|
|
112
|
-
// Strip Python-style inline flags - JS doesn't support them
|
|
113
|
-
pattern = pattern.replace(/^\(\?i\)/, '');
|
|
114
|
-
// Unescape double backslashes from YAML (\\s -> \s)
|
|
115
|
-
pattern = pattern.replace(/\\\\/g, '\\');
|
|
141
|
+
pattern = normalizeYamlRegexPattern(pattern);
|
|
116
142
|
if (pattern) rule.patterns.push(pattern);
|
|
117
143
|
} else if (inMetadata && line.match(/^\s+\w+:/)) {
|
|
118
144
|
const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
|
|
@@ -182,11 +208,7 @@ function loadPromptInjectionRules() {
|
|
|
182
208
|
inMetadata = true;
|
|
183
209
|
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
184
210
|
let pattern = line.replace(/^\s+- /, '').trim();
|
|
185
|
-
pattern = pattern
|
|
186
|
-
// Strip Python-style inline flags - JS doesn't support them
|
|
187
|
-
pattern = pattern.replace(/^\(\?i\)/, '');
|
|
188
|
-
// Unescape double backslashes from YAML (\\s -> \s)
|
|
189
|
-
pattern = pattern.replace(/\\\\/g, '\\');
|
|
211
|
+
pattern = normalizeYamlRegexPattern(pattern);
|
|
190
212
|
if (pattern) rule.patterns.push(pattern);
|
|
191
213
|
} else if (inMetadata && line.match(/^\s+\w+:/)) {
|
|
192
214
|
const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
|
|
@@ -253,8 +275,7 @@ function loadOpenClawRules() {
|
|
|
253
275
|
inPatterns = true;
|
|
254
276
|
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
255
277
|
let pattern = line.replace(/^\s+- /, '').trim();
|
|
256
|
-
pattern = pattern
|
|
257
|
-
pattern = pattern.replace(/\\\\/g, '\\');
|
|
278
|
+
pattern = normalizeYamlRegexPattern(pattern);
|
|
258
279
|
if (pattern) rule.patterns.push(pattern);
|
|
259
280
|
} else if (line.match(/^\s+\w+:/) && !line.match(/^\s+- /)) {
|
|
260
281
|
inPatterns = false;
|
|
@@ -579,22 +600,12 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
579
600
|
}
|
|
580
601
|
}
|
|
581
602
|
|
|
582
|
-
// Scan expanded text against all rules
|
|
583
|
-
// Security: Add timeout protection for regex matching
|
|
584
|
-
const REGEX_TIMEOUT_MS = 1000;
|
|
585
|
-
|
|
603
|
+
// Scan expanded text against all rules using windowed matching to prevent ReDoS
|
|
586
604
|
for (const rule of allRules) {
|
|
587
605
|
for (const pattern of rule.patterns) {
|
|
588
606
|
try {
|
|
589
|
-
const regex = new RegExp(pattern, 'i');
|
|
590
|
-
const
|
|
591
|
-
const match = expandedText.match(regex);
|
|
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
|
-
}
|
|
607
|
+
const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
|
|
608
|
+
const match = safeMatch(expandedText, regex);
|
|
598
609
|
|
|
599
610
|
if (match) {
|
|
600
611
|
findings.push({
|
|
@@ -617,7 +628,9 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
617
628
|
}
|
|
618
629
|
|
|
619
630
|
// 2.8: Runtime base64 decode-and-rescan
|
|
620
|
-
|
|
631
|
+
// Cap base64 match length to avoid matching entire large inputs as one blob.
|
|
632
|
+
// Real base64 payloads are at most a few KB; 4096 chars ≈ 3KB decoded.
|
|
633
|
+
const base64Regex = /[A-Za-z0-9+/]{40,4096}={0,2}/g;
|
|
621
634
|
const b64Matches = expandedText.match(base64Regex);
|
|
622
635
|
if (b64Matches) {
|
|
623
636
|
for (const b64str of b64Matches) {
|
|
@@ -631,8 +644,8 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
631
644
|
if (!rule.id.startsWith('generic.prompt')) continue;
|
|
632
645
|
for (const pattern of rule.patterns) {
|
|
633
646
|
try {
|
|
634
|
-
const regex = new RegExp(pattern, 'i');
|
|
635
|
-
const match = decoded
|
|
647
|
+
const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
|
|
648
|
+
const match = safeMatch(decoded, regex);
|
|
636
649
|
if (match) {
|
|
637
650
|
findings.push({
|
|
638
651
|
rule_id: rule.id + '.base64-decoded',
|
|
@@ -674,8 +687,8 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
674
687
|
for (const rule of allRules) {
|
|
675
688
|
for (const pattern of rule.patterns) {
|
|
676
689
|
try {
|
|
677
|
-
const regex = new RegExp(pattern, 'i');
|
|
678
|
-
const match = innerDecoded
|
|
690
|
+
const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
|
|
691
|
+
const match = safeMatch(innerDecoded, regex);
|
|
679
692
|
if (match) {
|
|
680
693
|
findings.push({
|
|
681
694
|
rule_id: rule.id + '.nested-base64-decoded',
|
|
@@ -718,7 +731,7 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
|
|
|
718
731
|
for (const rule of allRules) {
|
|
719
732
|
for (const pattern of rule.patterns) {
|
|
720
733
|
try {
|
|
721
|
-
const regex = new RegExp(pattern, 'i');
|
|
734
|
+
const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
|
|
722
735
|
if (regex.test(prevMsg)) {
|
|
723
736
|
prevTotalScore += parseInt(rule.metadata?.risk_score || '50') / 100;
|
|
724
737
|
msgHasMatch = true;
|
package/src/tools/scan-skill.js
CHANGED
|
@@ -126,6 +126,12 @@ function normPath(p) { return IS_WIN ? p.toLowerCase() : p; }
|
|
|
126
126
|
function pathStartsWith(child, parent) {
|
|
127
127
|
return normPath(child) === normPath(parent) || normPath(child).startsWith(normPath(parent) + sep);
|
|
128
128
|
}
|
|
129
|
+
function normalizeRulePattern(pattern) {
|
|
130
|
+
return pattern
|
|
131
|
+
.replace(/^["']|["']$/g, '')
|
|
132
|
+
.replace(/\(\?i\)/g, '')
|
|
133
|
+
.replace(/\\\\/g, '\\');
|
|
134
|
+
}
|
|
129
135
|
const MAX_CLAWHAVOC_SCAN_LEN = 2 * 1024 * 1024; // 2 MB cap for regex matching
|
|
130
136
|
|
|
131
137
|
// ---------------------------------------------------------------------------
|
|
@@ -176,9 +182,7 @@ function loadClawHavocRules() {
|
|
|
176
182
|
inMetadata = true;
|
|
177
183
|
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
178
184
|
let pattern = line.replace(/^\s+- /, '').trim();
|
|
179
|
-
pattern = pattern
|
|
180
|
-
pattern = pattern.replace(/^\(\?i\)/, '');
|
|
181
|
-
pattern = pattern.replace(/\\\\/g, '\\');
|
|
185
|
+
pattern = normalizeRulePattern(pattern);
|
|
182
186
|
if (pattern) rule.patterns.push(pattern);
|
|
183
187
|
} else if (inMetadata && line.match(/^\s+\w+:/)) {
|
|
184
188
|
const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
|
|
@@ -892,15 +896,44 @@ function generateRecommendation(grade) {
|
|
|
892
896
|
// ---------------------------------------------------------------------------
|
|
893
897
|
|
|
894
898
|
export async function scanSkill({ skill_path, verbosity, baseline }) {
|
|
895
|
-
|
|
899
|
+
const canonCwd = realpathSync(process.cwd());
|
|
900
|
+
const configuredSkillRoots = [
|
|
901
|
+
resolve(homedir(), '.openclaw', 'skills'),
|
|
902
|
+
resolve(homedir(), '.openclaw', 'workspace', 'skills'),
|
|
903
|
+
];
|
|
904
|
+
const allowedSkillRoots = configuredSkillRoots.map(root => {
|
|
905
|
+
try {
|
|
906
|
+
return existsSync(root) ? realpathSync(root) : null;
|
|
907
|
+
} catch {
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
}).filter(Boolean);
|
|
911
|
+
|
|
912
|
+
// Reject obvious escapes before touching the filesystem so absolute traversal
|
|
913
|
+
// attempts fail closed even when the target path does not exist.
|
|
896
914
|
const inputPath = skill_path;
|
|
897
|
-
|
|
915
|
+
const requestedPath = resolve(inputPath);
|
|
916
|
+
const isRequestedAllowed = pathStartsWith(requestedPath, canonCwd)
|
|
917
|
+
|| configuredSkillRoots.some(root => pathStartsWith(requestedPath, root))
|
|
918
|
+
|| allowedSkillRoots.some(root => pathStartsWith(requestedPath, root));
|
|
898
919
|
|
|
920
|
+
if (!isRequestedAllowed) {
|
|
921
|
+
return {
|
|
922
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
923
|
+
error: "skill_path must be within the current working directory or ~/.openclaw/skills/ (or ~/.openclaw/workspace/skills/)",
|
|
924
|
+
skill_path: requestedPath,
|
|
925
|
+
attempted_path: inputPath
|
|
926
|
+
}) }]
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Resolve to canonical path after the initial boundary check to prevent
|
|
931
|
+
// symlink escapes while still returning a deterministic security error for
|
|
932
|
+
// out-of-scope absolute paths.
|
|
933
|
+
let realPath;
|
|
899
934
|
try {
|
|
900
|
-
|
|
901
|
-
realPath = realpathSync(resolve(inputPath));
|
|
935
|
+
realPath = realpathSync(requestedPath);
|
|
902
936
|
} catch (err) {
|
|
903
|
-
// Check for different error types
|
|
904
937
|
let errorMessage;
|
|
905
938
|
if (err.code === 'ENOENT') {
|
|
906
939
|
errorMessage = "Path not found";
|
|
@@ -921,20 +954,7 @@ export async function scanSkill({ skill_path, verbosity, baseline }) {
|
|
|
921
954
|
};
|
|
922
955
|
}
|
|
923
956
|
|
|
924
|
-
// Verify containment on canonical path ONLY
|
|
925
|
-
// This prevents symlink escapes by checking the REAL resolved location
|
|
926
|
-
const canonCwd = realpathSync(process.cwd());
|
|
927
|
-
const allowedSkillRoots = [
|
|
928
|
-
resolve(homedir(), '.openclaw', 'skills'),
|
|
929
|
-
resolve(homedir(), '.openclaw', 'workspace', 'skills'),
|
|
930
|
-
].map(root => {
|
|
931
|
-
try {
|
|
932
|
-
return existsSync(root) ? realpathSync(root) : null;
|
|
933
|
-
} catch {
|
|
934
|
-
return null;
|
|
935
|
-
}
|
|
936
|
-
}).filter(Boolean);
|
|
937
|
-
|
|
957
|
+
// Verify containment on canonical path ONLY.
|
|
938
958
|
const isAllowed = pathStartsWith(realPath, canonCwd)
|
|
939
959
|
|| allowedSkillRoots.some(root => pathStartsWith(realPath, root));
|
|
940
960
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/tools/score-aivss.js — score_aivss MCP tool (thin wrapper around src/lib/aivss.js)
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { normalizeFindings } from '../lib/normalize-finding.js';
|
|
5
|
+
import { scoreBatch, AIVSS_MODEL } from '../lib/aivss.js';
|
|
6
|
+
|
|
7
|
+
export const scoreAivssSchema = {
|
|
8
|
+
findings: z.array(z.object({
|
|
9
|
+
ruleId: z.string().optional(),
|
|
10
|
+
rule_id: z.string().optional(),
|
|
11
|
+
id: z.string().optional(),
|
|
12
|
+
rule: z.string().optional(),
|
|
13
|
+
severity: z.string(),
|
|
14
|
+
message: z.string(),
|
|
15
|
+
confidence: z.string().optional(),
|
|
16
|
+
category: z.string().optional(),
|
|
17
|
+
cwe: z.string().optional(),
|
|
18
|
+
owasp: z.string().optional(),
|
|
19
|
+
risk_score: z.union([z.number(), z.string()]).optional(),
|
|
20
|
+
action: z.string().optional(),
|
|
21
|
+
file: z.string().optional(),
|
|
22
|
+
line: z.number().optional(),
|
|
23
|
+
source_tool: z.string().optional(),
|
|
24
|
+
})).describe('Findings from any scanner tool or raw JSON'),
|
|
25
|
+
source_tool: z.string().optional().describe('Tool that produced findings, for normalization hints'),
|
|
26
|
+
overrides: z.object({
|
|
27
|
+
AV: z.string().optional(),
|
|
28
|
+
AC: z.string().optional(),
|
|
29
|
+
PR: z.string().optional(),
|
|
30
|
+
UI: z.string().optional(),
|
|
31
|
+
S: z.string().optional(),
|
|
32
|
+
MR: z.string().optional(),
|
|
33
|
+
DS: z.string().optional(),
|
|
34
|
+
EI: z.string().optional(),
|
|
35
|
+
DC: z.string().optional(),
|
|
36
|
+
AD: z.string().optional(),
|
|
37
|
+
C: z.string().optional(),
|
|
38
|
+
I: z.string().optional(),
|
|
39
|
+
A: z.string().optional(),
|
|
40
|
+
SI: z.string().optional(),
|
|
41
|
+
}).optional().describe('Manual AIVSS metric overrides'),
|
|
42
|
+
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level"),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function scoreAivssTool({ findings, source_tool, overrides, verbosity }) {
|
|
46
|
+
const level = verbosity || 'compact';
|
|
47
|
+
|
|
48
|
+
const normalized = normalizeFindings(findings, source_tool || 'unknown', {
|
|
49
|
+
includeRaw: level === 'full',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = scoreBatch(normalized, overrides || {});
|
|
53
|
+
|
|
54
|
+
let output;
|
|
55
|
+
switch (level) {
|
|
56
|
+
case 'minimal':
|
|
57
|
+
output = {
|
|
58
|
+
findings_count: result.findings.length,
|
|
59
|
+
posture_score: result.posture.posture_score,
|
|
60
|
+
posture_rating: result.posture.posture_rating,
|
|
61
|
+
aggregate_method: result.posture.aggregate_method,
|
|
62
|
+
};
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'full':
|
|
66
|
+
output = {
|
|
67
|
+
findings: result.findings,
|
|
68
|
+
posture: result.posture,
|
|
69
|
+
};
|
|
70
|
+
break;
|
|
71
|
+
|
|
72
|
+
case 'compact':
|
|
73
|
+
default:
|
|
74
|
+
output = {
|
|
75
|
+
findings: result.findings.map(f => ({
|
|
76
|
+
rule_id: f.rule_id,
|
|
77
|
+
aivss_score: f.aivss_score,
|
|
78
|
+
rating: f.rating,
|
|
79
|
+
vector_string: f.vector_string,
|
|
80
|
+
mapping_confidence: f.metrics.mapping_confidence,
|
|
81
|
+
})),
|
|
82
|
+
posture: {
|
|
83
|
+
posture_score: result.posture.posture_score,
|
|
84
|
+
posture_rating: result.posture.posture_rating,
|
|
85
|
+
max_score: result.posture.max_score,
|
|
86
|
+
score_distribution: result.posture.score_distribution,
|
|
87
|
+
aggregate_method: result.posture.aggregate_method,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
content: [{
|
|
94
|
+
type: 'text',
|
|
95
|
+
text: JSON.stringify(output, null, 2),
|
|
96
|
+
}],
|
|
97
|
+
};
|
|
98
|
+
}
|