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.
@@ -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
+ }