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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/lib/compliance-controls.js — AIUC-1 controls registry loader + schema validator.
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
let __dirname;
|
|
8
|
+
try {
|
|
9
|
+
__dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
} catch {
|
|
11
|
+
__dirname = process.cwd();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const KNOWN_DOMAINS = new Set(['security', 'safety']);
|
|
15
|
+
const KNOWN_TOOLS = new Set([
|
|
16
|
+
'scan_security', 'scan_agent_prompt', 'scan_project', 'scan_skill',
|
|
17
|
+
'scan_mcp_server', 'scan_agent_action', 'scan_git_diff',
|
|
18
|
+
]);
|
|
19
|
+
const OWASP_TAG_RE = /^LLM\d{2}$/;
|
|
20
|
+
|
|
21
|
+
let _cache = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate the controls registry schema. Returns array of error strings (empty = valid).
|
|
25
|
+
*/
|
|
26
|
+
export function validateRegistry(data) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
|
|
29
|
+
if (!data || typeof data !== 'object') {
|
|
30
|
+
errors.push('Registry must be a non-null object');
|
|
31
|
+
return errors;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!Array.isArray(data.controls)) {
|
|
35
|
+
errors.push('Registry must have a "controls" array');
|
|
36
|
+
return errors;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ids = new Set();
|
|
40
|
+
for (const ctrl of data.controls) {
|
|
41
|
+
// Required fields
|
|
42
|
+
if (!ctrl.id) errors.push(`Control missing "id"`);
|
|
43
|
+
if (!ctrl.title) errors.push(`Control ${ctrl.id || '?'} missing "title"`);
|
|
44
|
+
if (!ctrl.domain) errors.push(`Control ${ctrl.id || '?'} missing "domain"`);
|
|
45
|
+
if (!ctrl.evaluation) errors.push(`Control ${ctrl.id || '?'} missing "evaluation"`);
|
|
46
|
+
|
|
47
|
+
// Duplicate ID check
|
|
48
|
+
if (ctrl.id && ids.has(ctrl.id)) {
|
|
49
|
+
errors.push(`Duplicate control ID: ${ctrl.id}`);
|
|
50
|
+
}
|
|
51
|
+
ids.add(ctrl.id);
|
|
52
|
+
|
|
53
|
+
// Domain validation
|
|
54
|
+
if (ctrl.domain && !KNOWN_DOMAINS.has(ctrl.domain)) {
|
|
55
|
+
errors.push(`Control ${ctrl.id}: unknown domain "${ctrl.domain}"`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Scanner tools validation
|
|
59
|
+
if (Array.isArray(ctrl.scanner_tools)) {
|
|
60
|
+
for (const tool of ctrl.scanner_tools) {
|
|
61
|
+
if (!KNOWN_TOOLS.has(tool)) {
|
|
62
|
+
errors.push(`Control ${ctrl.id}: unknown scanner tool "${tool}"`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// OWASP tags validation
|
|
68
|
+
if (Array.isArray(ctrl.owasp_llm)) {
|
|
69
|
+
for (const tag of ctrl.owasp_llm) {
|
|
70
|
+
if (!OWASP_TAG_RE.test(tag)) {
|
|
71
|
+
errors.push(`Control ${ctrl.id}: invalid OWASP tag "${tag}" (expected LLM\\d{2})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Evaluation field types
|
|
77
|
+
if (ctrl.evaluation) {
|
|
78
|
+
const ev = ctrl.evaluation;
|
|
79
|
+
if (ev.max_aivss_posture !== undefined && typeof ev.max_aivss_posture !== 'number') {
|
|
80
|
+
errors.push(`Control ${ctrl.id}: evaluation.max_aivss_posture must be a number`);
|
|
81
|
+
}
|
|
82
|
+
if (ev.max_critical_findings !== undefined && typeof ev.max_critical_findings !== 'number') {
|
|
83
|
+
errors.push(`Control ${ctrl.id}: evaluation.max_critical_findings must be a number`);
|
|
84
|
+
}
|
|
85
|
+
if (ev.required_tools !== undefined) {
|
|
86
|
+
if (!Array.isArray(ev.required_tools)) {
|
|
87
|
+
errors.push(`Control ${ctrl.id}: evaluation.required_tools must be an array`);
|
|
88
|
+
} else {
|
|
89
|
+
for (const tool of ev.required_tools) {
|
|
90
|
+
if (!KNOWN_TOOLS.has(tool)) {
|
|
91
|
+
errors.push(`Control ${ctrl.id}: evaluation.required_tools references unknown tool "${tool}"`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (ev.fail_on_severities !== undefined && !Array.isArray(ev.fail_on_severities)) {
|
|
97
|
+
errors.push(`Control ${ctrl.id}: evaluation.fail_on_severities must be an array`);
|
|
98
|
+
}
|
|
99
|
+
if (ev.fail_on_actions !== undefined && !Array.isArray(ev.fail_on_actions)) {
|
|
100
|
+
errors.push(`Control ${ctrl.id}: evaluation.fail_on_actions must be an array`);
|
|
101
|
+
}
|
|
102
|
+
if (ev.min_grade !== undefined && typeof ev.min_grade !== 'string') {
|
|
103
|
+
errors.push(`Control ${ctrl.id}: evaluation.min_grade must be a string`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return errors;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Load the AIUC-1 controls registry. Validates on first load.
|
|
113
|
+
* @returns {object} The full registry object
|
|
114
|
+
*/
|
|
115
|
+
export function loadControls() {
|
|
116
|
+
if (_cache) return _cache;
|
|
117
|
+
|
|
118
|
+
const controlsPath = join(__dirname, '..', '..', 'compliance', 'aiuc-1-controls.json');
|
|
119
|
+
const data = JSON.parse(readFileSync(controlsPath, 'utf-8'));
|
|
120
|
+
|
|
121
|
+
const errors = validateRegistry(data);
|
|
122
|
+
if (errors.length > 0) {
|
|
123
|
+
throw new Error(`AIUC-1 controls registry validation failed:\n${errors.join('\n')}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_cache = data;
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Filter controls by domain, control IDs, or OWASP tags.
|
|
132
|
+
* @param {object} [filters]
|
|
133
|
+
* @param {string} [filters.domain] - 'security', 'safety', or 'all'
|
|
134
|
+
* @param {string[]} [filters.controlIds] - Specific control IDs
|
|
135
|
+
* @param {string[]} [filters.owaspFilter] - OWASP LLM tags to match
|
|
136
|
+
* @returns {object[]} Filtered controls
|
|
137
|
+
*/
|
|
138
|
+
export function filterControls({ domain, controlIds, owaspFilter } = {}) {
|
|
139
|
+
const registry = loadControls();
|
|
140
|
+
let controls = registry.controls;
|
|
141
|
+
|
|
142
|
+
if (domain && domain !== 'all') {
|
|
143
|
+
controls = controls.filter(c => c.domain === domain);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (controlIds && controlIds.length > 0) {
|
|
147
|
+
const idSet = new Set(controlIds);
|
|
148
|
+
controls = controls.filter(c => idSet.has(c.id));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (owaspFilter && owaspFilter.length > 0) {
|
|
152
|
+
const owaspSet = new Set(owaspFilter);
|
|
153
|
+
controls = controls.filter(c =>
|
|
154
|
+
Array.isArray(c.owasp_llm) && c.owasp_llm.some(tag => owaspSet.has(tag))
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return controls;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Reset cache (for testing)
|
|
162
|
+
export function _resetCache() {
|
|
163
|
+
_cache = null;
|
|
164
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/lib/compliance-evaluator.js — Deterministic pass/partial/fail evaluation logic.
|
|
2
|
+
|
|
3
|
+
import { scoreBatch } from './aivss.js';
|
|
4
|
+
|
|
5
|
+
const GRADE_ORDER = { A: 4, B: 3, C: 2, D: 1, F: 0 };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if actual grade is worse than threshold.
|
|
9
|
+
* Missing/null grade → treated as F (worst case).
|
|
10
|
+
*/
|
|
11
|
+
function gradeIsWorse(actual, threshold) {
|
|
12
|
+
const actualVal = GRADE_ORDER[actual] ?? 0; // null/missing → F → 0
|
|
13
|
+
const thresholdVal = GRADE_ORDER[threshold] ?? 0;
|
|
14
|
+
return actualVal < thresholdVal;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Evaluate a single control against evidence.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} control - A control from the registry
|
|
21
|
+
* @param {object} evidence
|
|
22
|
+
* @param {object|null} evidence.aivssPosture - Posture from scoreBatch, or null
|
|
23
|
+
* @param {object[]} evidence.findings - Normalized findings from all available tools
|
|
24
|
+
* @param {object} evidence.grades - Map of tool/scope → grade (e.g. { project: 'B' })
|
|
25
|
+
* @param {string[]} evidence.toolsRun - Array of tool names whose output is available
|
|
26
|
+
* @returns {{ control_id: string, status: string, reasons: string[] }}
|
|
27
|
+
*/
|
|
28
|
+
export function evaluateControl(control, evidence) {
|
|
29
|
+
if (!control || !control.id || !control.evaluation) {
|
|
30
|
+
return {
|
|
31
|
+
control_id: control?.id || 'unknown',
|
|
32
|
+
status: 'not_evaluated',
|
|
33
|
+
reasons: ['Malformed control: missing id or evaluation'],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ev = control.evaluation;
|
|
38
|
+
const reasons = [];
|
|
39
|
+
const toolsRun = evidence.toolsRun || [];
|
|
40
|
+
|
|
41
|
+
// 1. Check required_tools
|
|
42
|
+
if (Array.isArray(ev.required_tools)) {
|
|
43
|
+
for (const tool of ev.required_tools) {
|
|
44
|
+
if (!toolsRun.includes(tool)) {
|
|
45
|
+
return {
|
|
46
|
+
control_id: control.id,
|
|
47
|
+
status: 'not_evaluated',
|
|
48
|
+
reasons: [`Missing required tool: ${tool}`],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let status = 'pass';
|
|
55
|
+
|
|
56
|
+
// Scope findings to this control's relevant tools
|
|
57
|
+
const relevantTools = Array.isArray(control.scanner_tools) ? new Set(control.scanner_tools) : null;
|
|
58
|
+
const relevantFindings = (evidence.findings || []).filter(f => {
|
|
59
|
+
if (!relevantTools) return true;
|
|
60
|
+
return relevantTools.has(f.source_tool);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 2. Check fail_on_severities
|
|
64
|
+
if (Array.isArray(ev.fail_on_severities) && ev.fail_on_severities.length > 0) {
|
|
65
|
+
const sevSet = new Set(ev.fail_on_severities);
|
|
66
|
+
const matched = relevantFindings.filter(f => sevSet.has(f.severity));
|
|
67
|
+
if (matched.length > 0) {
|
|
68
|
+
status = 'fail';
|
|
69
|
+
reasons.push(`${matched.length} finding(s) with severity in [${ev.fail_on_severities.join(', ')}]`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 3. Check fail_on_actions
|
|
74
|
+
if (Array.isArray(ev.fail_on_actions) && ev.fail_on_actions.length > 0) {
|
|
75
|
+
const actSet = new Set(ev.fail_on_actions);
|
|
76
|
+
const matched = relevantFindings.filter(f => f.action && actSet.has(f.action));
|
|
77
|
+
if (matched.length > 0) {
|
|
78
|
+
status = 'fail';
|
|
79
|
+
reasons.push(`${matched.length} finding(s) with action in [${ev.fail_on_actions.join(', ')}]`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4. Check max_aivss_posture (scoped to this control's relevant findings)
|
|
84
|
+
if (typeof ev.max_aivss_posture === 'number' && relevantFindings.length > 0) {
|
|
85
|
+
const scopedPosture = scoreBatch(relevantFindings).posture;
|
|
86
|
+
if (scopedPosture.posture_score > ev.max_aivss_posture) {
|
|
87
|
+
status = 'fail';
|
|
88
|
+
reasons.push(`AIVSS posture ${scopedPosture.posture_score} exceeds max ${ev.max_aivss_posture}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 5. Check max_critical_findings (scoped to this control's tools)
|
|
93
|
+
if (typeof ev.max_critical_findings === 'number') {
|
|
94
|
+
const critCount = relevantFindings.filter(f => f.severity === 'CRITICAL').length;
|
|
95
|
+
if (critCount > ev.max_critical_findings) {
|
|
96
|
+
status = 'fail';
|
|
97
|
+
reasons.push(`${critCount} CRITICAL finding(s) exceeds max ${ev.max_critical_findings}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 6. Check min_grade (scoped to control's relevant grade keys)
|
|
102
|
+
if (ev.min_grade) {
|
|
103
|
+
const grades = evidence.grades || {};
|
|
104
|
+
// Only consider grades for tools this control cares about
|
|
105
|
+
const relevantGradeKeys = relevantTools
|
|
106
|
+
? Object.keys(grades).filter(k => relevantTools.has(k) || relevantTools.has(`scan_${k}`))
|
|
107
|
+
: Object.keys(grades);
|
|
108
|
+
const gradeValues = relevantGradeKeys.map(k => grades[k]);
|
|
109
|
+
if (gradeValues.length > 0) {
|
|
110
|
+
const worstGrade = gradeValues.reduce((worst, g) => {
|
|
111
|
+
return gradeIsWorse(g, worst) ? g : worst;
|
|
112
|
+
}, gradeValues[0]);
|
|
113
|
+
if (gradeIsWorse(worstGrade, ev.min_grade)) {
|
|
114
|
+
if (status !== 'fail') status = 'partial';
|
|
115
|
+
reasons.push(`Grade ${worstGrade || 'F'} below minimum ${ev.min_grade}`);
|
|
116
|
+
}
|
|
117
|
+
} else if (status !== 'fail') {
|
|
118
|
+
// No relevant grades available → treat as F
|
|
119
|
+
if (gradeIsWorse(null, ev.min_grade)) {
|
|
120
|
+
status = 'partial';
|
|
121
|
+
reasons.push(`No relevant grade available (treated as F), below minimum ${ev.min_grade}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { control_id: control.id, status, reasons };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Evaluate all controls against evidence.
|
|
131
|
+
*
|
|
132
|
+
* @param {object[]} controls - Array of controls from registry
|
|
133
|
+
* @param {object} evidence - Same shape as evaluateControl
|
|
134
|
+
* @returns {{ controls_evaluated: number, pass: number, partial: number, fail: number, not_evaluated: number, results: object[] }}
|
|
135
|
+
*/
|
|
136
|
+
export function evaluateAll(controls, evidence) {
|
|
137
|
+
const results = controls.map(c => evaluateControl(c, evidence));
|
|
138
|
+
|
|
139
|
+
const summary = { pass: 0, partial: 0, fail: 0, not_evaluated: 0 };
|
|
140
|
+
for (const r of results) {
|
|
141
|
+
summary[r.status] = (summary[r.status] || 0) + 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
controls_evaluated: controls.length,
|
|
146
|
+
...summary,
|
|
147
|
+
results,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// src/lib/normalize-finding.js — Normalize findings from 6 different tool shapes into one internal format.
|
|
2
|
+
|
|
3
|
+
const SEVERITY_MAP = {
|
|
4
|
+
'error': { severity: 'HIGH', severity_rank: 3 },
|
|
5
|
+
'ERROR': { severity: 'HIGH', severity_rank: 3 },
|
|
6
|
+
'warning': { severity: 'MEDIUM', severity_rank: 2 },
|
|
7
|
+
'WARNING': { severity: 'MEDIUM', severity_rank: 2 },
|
|
8
|
+
'info': { severity: 'INFO', severity_rank: 0 },
|
|
9
|
+
'INFO': { severity: 'INFO', severity_rank: 0 },
|
|
10
|
+
'CRITICAL': { severity: 'CRITICAL', severity_rank: 4 },
|
|
11
|
+
'critical': { severity: 'CRITICAL', severity_rank: 4 },
|
|
12
|
+
'LOW': { severity: 'LOW', severity_rank: 1 },
|
|
13
|
+
'low': { severity: 'LOW', severity_rank: 1 },
|
|
14
|
+
'HIGH': { severity: 'HIGH', severity_rank: 3 },
|
|
15
|
+
'high': { severity: 'HIGH', severity_rank: 3 },
|
|
16
|
+
'MEDIUM': { severity: 'MEDIUM', severity_rank: 2 },
|
|
17
|
+
'medium': { severity: 'MEDIUM', severity_rank: 2 },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DEFAULT_SEVERITY = { severity: 'MEDIUM', severity_rank: 2 };
|
|
21
|
+
|
|
22
|
+
// Map ruleId segments to categories for tools that don't emit category directly.
|
|
23
|
+
// Keyed by the second dotted segment of ruleId (e.g. "injection" from "python.injection.sql-injection").
|
|
24
|
+
const RULE_CATEGORY_MAP = {
|
|
25
|
+
'injection': 'injection',
|
|
26
|
+
'crypto': 'crypto',
|
|
27
|
+
'auth': 'auth',
|
|
28
|
+
'xss': 'xss',
|
|
29
|
+
'ssrf': 'ssrf',
|
|
30
|
+
'path': 'path-traversal',
|
|
31
|
+
'deserialization': 'deserialization',
|
|
32
|
+
'info': 'info-exposure',
|
|
33
|
+
'permissions': 'permissions',
|
|
34
|
+
'logging': 'info-exposure',
|
|
35
|
+
'secrets': 'info-exposure',
|
|
36
|
+
'prompt': 'prompt-injection',
|
|
37
|
+
'exfiltration': 'exfiltration',
|
|
38
|
+
'supply': 'supply-chain',
|
|
39
|
+
'command': 'injection',
|
|
40
|
+
'sql': 'injection',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract a unified rule_id from any finding shape.
|
|
45
|
+
*/
|
|
46
|
+
function extractRuleId(finding) {
|
|
47
|
+
return finding.ruleId || finding.rule_id || finding.id || finding.rule || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Infer category from ruleId when no explicit category is set.
|
|
52
|
+
* Looks at dotted segments of the ruleId for known category keywords.
|
|
53
|
+
*/
|
|
54
|
+
function inferCategory(ruleId) {
|
|
55
|
+
if (!ruleId) return null;
|
|
56
|
+
const segments = ruleId.toLowerCase().split('.');
|
|
57
|
+
for (const seg of segments) {
|
|
58
|
+
if (RULE_CATEGORY_MAP[seg]) return RULE_CATEGORY_MAP[seg];
|
|
59
|
+
}
|
|
60
|
+
// Check if any segment contains a known keyword
|
|
61
|
+
for (const seg of segments) {
|
|
62
|
+
for (const [key, cat] of Object.entries(RULE_CATEGORY_MAP)) {
|
|
63
|
+
if (seg.includes(key)) return cat;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Normalize confidence to uppercase HIGH/MEDIUM/LOW.
|
|
71
|
+
*/
|
|
72
|
+
function normalizeConfidence(confidence) {
|
|
73
|
+
if (!confidence) return 'MEDIUM';
|
|
74
|
+
const upper = String(confidence).toUpperCase();
|
|
75
|
+
if (upper === 'HIGH' || upper === 'MEDIUM' || upper === 'LOW') return upper;
|
|
76
|
+
return 'MEDIUM';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Normalize action to uppercase BLOCK/WARN/ALLOW or null.
|
|
81
|
+
*/
|
|
82
|
+
function normalizeAction(action) {
|
|
83
|
+
if (!action) return null;
|
|
84
|
+
const upper = String(action).toUpperCase();
|
|
85
|
+
if (upper === 'BLOCK' || upper === 'WARN' || upper === 'ALLOW') return upper;
|
|
86
|
+
if (upper === 'LOG') return 'WARN';
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalize a single finding into the internal format.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} finding - Raw finding from any scanner tool
|
|
94
|
+
* @param {string} sourceTool - Tool that produced this finding (fallback if not on finding)
|
|
95
|
+
* @param {object} [options] - Options
|
|
96
|
+
* @param {boolean} [options.includeRaw] - Include original finding as `raw` field
|
|
97
|
+
* @returns {object} Normalized finding
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeFinding(finding, sourceTool, options = {}) {
|
|
100
|
+
if (!finding || typeof finding !== 'object') {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const originalSeverity = finding.severity || null;
|
|
105
|
+
const mapped = SEVERITY_MAP[originalSeverity] || DEFAULT_SEVERITY;
|
|
106
|
+
|
|
107
|
+
const normalized = {
|
|
108
|
+
rule_id: extractRuleId(finding) || 'unknown',
|
|
109
|
+
original_severity: originalSeverity,
|
|
110
|
+
severity: mapped.severity,
|
|
111
|
+
severity_rank: mapped.severity_rank,
|
|
112
|
+
confidence: normalizeConfidence(finding.confidence),
|
|
113
|
+
message: finding.message || '',
|
|
114
|
+
category: finding.category || inferCategory(extractRuleId(finding)),
|
|
115
|
+
cwe: finding.cwe || (finding.metadata && finding.metadata.cwe) || null,
|
|
116
|
+
owasp: finding.owasp || (finding.metadata && finding.metadata.owasp) || null,
|
|
117
|
+
file: finding.file || null,
|
|
118
|
+
line: typeof finding.line === 'number' ? finding.line : null,
|
|
119
|
+
action: normalizeAction(finding.action),
|
|
120
|
+
risk_score: typeof finding.risk_score === 'number'
|
|
121
|
+
? finding.risk_score
|
|
122
|
+
: (typeof finding.risk_score === 'string' ? parseFloat(finding.risk_score) || null : null),
|
|
123
|
+
source_tool: finding.source_tool || sourceTool || 'unknown',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (options.includeRaw) {
|
|
127
|
+
normalized.raw = finding;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return normalized;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalize an array of findings.
|
|
135
|
+
*
|
|
136
|
+
* @param {object[]} findings - Array of raw findings
|
|
137
|
+
* @param {string} sourceTool - Default source tool (per-finding source_tool wins)
|
|
138
|
+
* @param {object} [options] - Options passed to normalizeFinding
|
|
139
|
+
* @returns {object[]} Array of normalized findings
|
|
140
|
+
*/
|
|
141
|
+
export function normalizeFindings(findings, sourceTool, options = {}) {
|
|
142
|
+
if (!Array.isArray(findings)) return [];
|
|
143
|
+
return findings
|
|
144
|
+
.map(f => normalizeFinding(f, sourceTool, options))
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
}
|
|
@@ -32,6 +32,17 @@ const BLOOM_FILTERS = {
|
|
|
32
32
|
rubygems: null
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
// Flutter/Dart SDK packages are legitimate dependencies even though they do
|
|
36
|
+
// not appear in the pub.dev package dump used for the text-based lookup.
|
|
37
|
+
const DART_SDK_PACKAGES = new Set([
|
|
38
|
+
'flutter',
|
|
39
|
+
'flutter_test',
|
|
40
|
+
'flutter_driver',
|
|
41
|
+
'flutter_localizations',
|
|
42
|
+
'flutter_web_plugins',
|
|
43
|
+
'integration_test',
|
|
44
|
+
]);
|
|
45
|
+
|
|
35
46
|
// Load package lists on startup
|
|
36
47
|
export function loadPackageLists() {
|
|
37
48
|
const packagesDir = join(__dirname, '..', '..', 'packages');
|
|
@@ -67,6 +78,10 @@ export function loadPackageLists() {
|
|
|
67
78
|
|
|
68
79
|
// Check if a package is hallucinated
|
|
69
80
|
export function isHallucinated(packageName, ecosystem) {
|
|
81
|
+
if (ecosystem === 'dart' && DART_SDK_PACKAGES.has(packageName)) {
|
|
82
|
+
return { hallucinated: false, sdkPackage: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
const legitPackages = LEGITIMATE_PACKAGES[ecosystem];
|
|
71
86
|
|
|
72
87
|
// First check Set-based lookup (exact match)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/tools/compliance-controls.js — get_compliance_controls MCP tool (thin wrapper)
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { loadControls, filterControls } from '../lib/compliance-controls.js';
|
|
5
|
+
|
|
6
|
+
export const complianceControlsSchema = {
|
|
7
|
+
domain: z.enum(['security', 'safety', 'all']).optional().describe("Filter by domain"),
|
|
8
|
+
control_ids: z.array(z.string()).optional().describe("Specific control IDs to retrieve"),
|
|
9
|
+
owasp_filter: z.array(z.string()).optional().describe("Filter by OWASP LLM tags (e.g. LLM01)"),
|
|
10
|
+
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level"),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function getComplianceControls({ domain, control_ids, owasp_filter, verbosity }) {
|
|
14
|
+
const level = verbosity || 'compact';
|
|
15
|
+
|
|
16
|
+
const controls = filterControls({
|
|
17
|
+
domain: domain || 'all',
|
|
18
|
+
controlIds: control_ids,
|
|
19
|
+
owaspFilter: owasp_filter,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const registry = loadControls();
|
|
23
|
+
|
|
24
|
+
let output;
|
|
25
|
+
switch (level) {
|
|
26
|
+
case 'minimal':
|
|
27
|
+
output = {
|
|
28
|
+
framework: registry.framework,
|
|
29
|
+
controls_count: controls.length,
|
|
30
|
+
controls: controls.map(c => ({ id: c.id, title: c.title, domain: c.domain })),
|
|
31
|
+
};
|
|
32
|
+
break;
|
|
33
|
+
|
|
34
|
+
case 'full':
|
|
35
|
+
output = {
|
|
36
|
+
framework: registry.framework,
|
|
37
|
+
schema_version: registry.schema_version,
|
|
38
|
+
source: registry.source,
|
|
39
|
+
source_snapshot: registry.source_snapshot,
|
|
40
|
+
controls_count: controls.length,
|
|
41
|
+
controls,
|
|
42
|
+
};
|
|
43
|
+
break;
|
|
44
|
+
|
|
45
|
+
case 'compact':
|
|
46
|
+
default:
|
|
47
|
+
output = {
|
|
48
|
+
framework: registry.framework,
|
|
49
|
+
controls_count: controls.length,
|
|
50
|
+
controls: controls.map(c => ({
|
|
51
|
+
id: c.id,
|
|
52
|
+
title: c.title,
|
|
53
|
+
domain: c.domain,
|
|
54
|
+
owasp_llm: c.owasp_llm,
|
|
55
|
+
scanner_tools: c.scanner_tools,
|
|
56
|
+
evaluation: c.evaluation,
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
content: [{
|
|
63
|
+
type: 'text',
|
|
64
|
+
text: JSON.stringify(output, null, 2),
|
|
65
|
+
}],
|
|
66
|
+
};
|
|
67
|
+
}
|