agent-security-scanner-mcp 4.1.1 → 4.2.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,321 @@
1
+ // src/lib/compliance-evidence.js — Collect normalized evidence bundle from scan + SBOM tooling.
2
+ // Uses JS imports only — no CLI shelling, no HTML parsing.
3
+
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { scanProject } from '../tools/scan-project.js';
7
+ import { normalizeFindings } from './normalize-finding.js';
8
+ import { scoreBatch } from './aivss.js';
9
+ import { discoverDependencies } from './lockfile-parsers.js';
10
+ import { serialize } from './cyclonedx.js';
11
+ import { componentFromBomComponent } from './sbom-component.js';
12
+ import { queryBatch } from './osv-client.js';
13
+ import { isHallucinated } from '../tools/check-package.js';
14
+ import { ecosystemFromPurlType } from './purl.js';
15
+
16
+ const HALLUCINATION_ECOSYSTEMS = new Set(['npm', 'pypi', 'rubygems', 'dart', 'perl', 'raku', 'crates']);
17
+ const CATEGORY_DEFAULTS = [
18
+ 'exfiltration', 'info-exposure', 'crypto', 'permissions', 'auth',
19
+ 'prompt-injection', 'supply-chain', 'injection', 'xss', 'ssrf',
20
+ 'deserialization', 'secrets', 'path-traversal', 'logging',
21
+ ];
22
+ const SEVERITY_LEVELS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
23
+
24
+ /**
25
+ * Build a compliance evidence bundle from a project directory.
26
+ *
27
+ * Collects scan results, SBOM data, vulnerability info, hallucination checks, and drift analysis.
28
+ * Each data source is collected independently — partial failures produce null sections, not aborts.
29
+ *
30
+ * @param {object} opts
31
+ * @param {string} opts.directory - Project root to scan
32
+ * @param {string} [opts.sbomPath] - Pre-existing SBOM file (skips discovery)
33
+ * @param {string} [opts.baselinePath] - SBOM baseline for drift comparison
34
+ * @param {string} [opts.toolVersion] - Scanner version for metadata
35
+ * @param {'minimal'|'compact'|'full'} [opts.verbosity='compact'] - Detail level
36
+ * @returns {Promise<object>} Evidence bundle
37
+ */
38
+ export async function collectEvidence({ directory, sbomPath, baselinePath, toolVersion, verbosity = 'compact' }) {
39
+ const toolsRun = [];
40
+ const errors = [];
41
+
42
+ // --- 1. Scan project ---
43
+ let scanData = null;
44
+ try {
45
+ const result = await scanProject({ directory_path: directory, verbosity: 'full' });
46
+ const raw = JSON.parse(result.content[0].text);
47
+ scanData = raw;
48
+ toolsRun.push('scan_project', 'scan_security');
49
+ } catch (err) {
50
+ errors.push({ source: 'scan_project', message: err.message });
51
+ }
52
+
53
+ // --- 2. Normalize findings + AIVSS ---
54
+ // scan_project wraps scan_security — duplicate findings under both source_tools
55
+ // so AIUC-1 controls scoped to either tool see them correctly.
56
+ let normalized = [];
57
+ let aivssResult = null;
58
+ if (scanData) {
59
+ const base = normalizeFindings(scanData.issues || [], 'scan_security');
60
+ const duped = base.map(f => ({ ...f, source_tool: 'scan_project' }));
61
+ normalized = [...base, ...duped];
62
+ aivssResult = scoreBatch(base); // score deduplicated set
63
+ }
64
+
65
+ // --- 3. SBOM generation ---
66
+ let components = null;
67
+ let componentList = null;
68
+ let bom = null;
69
+ try {
70
+ if (sbomPath) {
71
+ // Explicit sbom_path: error if it doesn't exist (don't silently fall through)
72
+ if (!existsSync(sbomPath)) {
73
+ throw new Error(`SBOM file not found: ${sbomPath}`);
74
+ }
75
+ bom = JSON.parse(readFileSync(sbomPath, 'utf-8'));
76
+ components = (bom.components || []).map(componentFromBomComponent);
77
+ } else if (existsSync(directory)) {
78
+ componentList = discoverDependencies(directory);
79
+ components = componentList.components;
80
+ bom = serialize(componentList);
81
+ }
82
+ if (components) toolsRun.push('sbom_generate');
83
+ } catch (err) {
84
+ errors.push({ source: 'sbom_generate', message: err.message });
85
+ }
86
+
87
+ // --- 4. Vulnerability scan ---
88
+ let vulnData = null;
89
+ if (components && components.length > 0) {
90
+ try {
91
+ const cacheDir = directory ? join(directory, '.scanner', 'cache', 'vuln') : null;
92
+ const results = await queryBatch(components, { cacheDir });
93
+
94
+ const allVulns = [];
95
+ const affectedPackages = new Set();
96
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
97
+
98
+ for (const [key, vulns] of results) {
99
+ for (const vuln of vulns) {
100
+ const level = vuln.ratings?.[0]?.severity || 'unknown';
101
+ allVulns.push(vuln);
102
+ affectedPackages.add(key);
103
+ bySeverity[level] = (bySeverity[level] || 0) + 1;
104
+ }
105
+ }
106
+
107
+ vulnData = {
108
+ total: allVulns.length,
109
+ affected_packages: affectedPackages.size,
110
+ by_severity: bySeverity,
111
+ vulnerabilities: allVulns,
112
+ };
113
+ toolsRun.push('sbom_scan_vulnerabilities');
114
+ } catch (err) {
115
+ errors.push({ source: 'sbom_scan_vulnerabilities', message: err.message });
116
+ }
117
+ }
118
+
119
+ // --- 5. Hallucination check ---
120
+ let hallucinationData = null;
121
+ if (components && components.length > 0) {
122
+ try {
123
+ const hallucinated = [];
124
+ const unsupported = [];
125
+ let legitimateCount = 0;
126
+
127
+ for (const comp of components) {
128
+ if (!HALLUCINATION_ECOSYSTEMS.has(comp.ecosystem)) {
129
+ unsupported.push({ name: comp.name, ecosystem: comp.ecosystem });
130
+ continue;
131
+ }
132
+ const result = isHallucinated(comp.name, comp.ecosystem);
133
+ if (result && result.hallucinated) {
134
+ hallucinated.push({ name: comp.name, ecosystem: comp.ecosystem, confidence: result.confidence || 'medium' });
135
+ } else {
136
+ legitimateCount++;
137
+ }
138
+ }
139
+
140
+ hallucinationData = {
141
+ hallucinated_count: hallucinated.length,
142
+ unsupported_count: unsupported.length,
143
+ legitimate_count: legitimateCount,
144
+ hallucinated_packages: hallucinated,
145
+ unsupported_packages: unsupported,
146
+ };
147
+ toolsRun.push('sbom_check_hallucinations');
148
+ } catch (err) {
149
+ errors.push({ source: 'sbom_check_hallucinations', message: err.message });
150
+ }
151
+ }
152
+
153
+ // --- 6. SBOM drift ---
154
+ let driftData = null;
155
+ if (bom) {
156
+ try {
157
+ const resolvedBaseline = baselinePath || join(directory, '.scanner', 'sbom-baseline.json');
158
+ const baselineExists = existsSync(resolvedBaseline);
159
+
160
+ if (baselineExists) {
161
+ const baselineBom = JSON.parse(readFileSync(resolvedBaseline, 'utf-8'));
162
+ const currentMap = buildComponentMap(bom.components || []);
163
+ const baselineMap = buildComponentMap(baselineBom.components || []);
164
+
165
+ let added = 0, removed = 0, versionChanged = 0;
166
+ for (const [key, comp] of currentMap) {
167
+ const baseComp = baselineMap.get(key);
168
+ if (!baseComp) added++;
169
+ else if (baseComp.version !== comp.version) versionChanged++;
170
+ }
171
+ for (const key of baselineMap.keys()) {
172
+ if (!currentMap.has(key)) removed++;
173
+ }
174
+
175
+ driftData = {
176
+ baseline_exists: true,
177
+ added,
178
+ removed,
179
+ version_changed: versionChanged,
180
+ summary: `+${added} added, -${removed} removed, ~${versionChanged} changed`,
181
+ };
182
+ } else {
183
+ driftData = { baseline_exists: false, added: 0, removed: 0, version_changed: 0, summary: '' };
184
+ }
185
+ toolsRun.push('sbom_diff');
186
+ } catch (err) {
187
+ errors.push({ source: 'sbom_diff', message: err.message });
188
+ }
189
+ }
190
+
191
+ // --- Build the evidence bundle ---
192
+ const grade = scanData?.grade || null;
193
+ const bySeverity = buildSeverityMap(normalized);
194
+ const byCategory = buildCategoryMap(normalized);
195
+ const byCategorySeverity = buildCategorySeverityMap(normalized);
196
+
197
+ const bundle = {
198
+ metadata: {
199
+ generated_at: new Date().toISOString(),
200
+ directory,
201
+ tool_version: toolVersion || 'unknown',
202
+ },
203
+ tools_run: [...new Set(toolsRun)],
204
+ errors: errors.length > 0 ? errors : undefined,
205
+ scan: scanData ? {
206
+ grade,
207
+ findings: normalized,
208
+ by_severity: bySeverity,
209
+ by_category: byCategory,
210
+ by_category_severity: byCategorySeverity,
211
+ } : null,
212
+ aivss: aivssResult ? {
213
+ posture: aivssResult.posture,
214
+ findings: aivssResult.findings,
215
+ } : null,
216
+ sbom: components ? {
217
+ component_count: components.length,
218
+ ecosystems: [...new Set(components.map(c => c.ecosystem))],
219
+ direct_count: components.filter(c => c.isDirect).length,
220
+ dev_count: components.filter(c => c.isDev).length,
221
+ bom: verbosity === 'full' ? bom : undefined,
222
+ } : null,
223
+ supply_chain: {
224
+ vulnerabilities: vulnData ? {
225
+ total: vulnData.total,
226
+ affected_packages: vulnData.affected_packages,
227
+ by_severity: vulnData.by_severity,
228
+ vulnerabilities: verbosity === 'full' ? vulnData.vulnerabilities : undefined,
229
+ } : null,
230
+ hallucinations: hallucinationData ? {
231
+ hallucinated_count: hallucinationData.hallucinated_count,
232
+ unsupported_count: hallucinationData.unsupported_count,
233
+ legitimate_count: hallucinationData.legitimate_count,
234
+ hallucinated_packages: hallucinationData.hallucinated_packages,
235
+ unsupported_packages: hallucinationData.unsupported_packages,
236
+ } : null,
237
+ drift: driftData,
238
+ },
239
+ };
240
+
241
+ return bundle;
242
+ }
243
+
244
+ /**
245
+ * Build the legacy-compatible evidence object for the compliance evaluator.
246
+ * This converts the evidence bundle into the shape expected by evaluateControl/evaluateAll.
247
+ */
248
+ export function buildEvaluatorEvidence(bundle) {
249
+ const grade = bundle.scan?.grade || null;
250
+ return {
251
+ aivssPosture: bundle.aivss?.posture || null,
252
+ findings: bundle.scan?.findings || [],
253
+ grades: { scan_project: grade, scan_security: grade, project: grade },
254
+ toolsRun: bundle.tools_run,
255
+ };
256
+ }
257
+
258
+ // --- Internal helpers ---
259
+
260
+ function buildSeverityMap(findings) {
261
+ const map = {};
262
+ for (const level of SEVERITY_LEVELS) map[level] = 0;
263
+ for (const f of findings) {
264
+ if (f.source_tool === 'scan_project') continue; // avoid double-counting
265
+ const sev = f.severity || 'INFO';
266
+ map[sev] = (map[sev] || 0) + 1;
267
+ }
268
+ return map;
269
+ }
270
+
271
+ function buildCategoryMap(findings) {
272
+ const map = {};
273
+ for (const cat of CATEGORY_DEFAULTS) map[cat] = 0;
274
+ for (const f of findings) {
275
+ if (f.source_tool === 'scan_project') continue;
276
+ const cat = f.category || 'unknown';
277
+ map[cat] = (map[cat] || 0) + 1;
278
+ }
279
+ return map;
280
+ }
281
+
282
+ function buildCategorySeverityMap(findings) {
283
+ const map = {};
284
+ for (const cat of CATEGORY_DEFAULTS) {
285
+ map[cat] = {};
286
+ for (const sev of SEVERITY_LEVELS) map[cat][sev] = 0;
287
+ }
288
+ for (const f of findings) {
289
+ if (f.source_tool === 'scan_project') continue;
290
+ const cat = f.category || 'unknown';
291
+ const sev = f.severity || 'INFO';
292
+ if (!map[cat]) {
293
+ map[cat] = {};
294
+ for (const s of SEVERITY_LEVELS) map[cat][s] = 0;
295
+ }
296
+ map[cat][sev] = (map[cat][sev] || 0) + 1;
297
+ }
298
+ return map;
299
+ }
300
+
301
+ function buildComponentMap(components) {
302
+ const map = new Map();
303
+ for (const comp of components) {
304
+ const eco = extractEcosystem(comp);
305
+ const key = `${eco}:${comp.name}`;
306
+ map.set(key, comp);
307
+ }
308
+ return map;
309
+ }
310
+
311
+ function extractEcosystem(component) {
312
+ if (component.properties) {
313
+ const eco = component.properties.find(p => p.name === 'cdx:ecosystem');
314
+ if (eco) return eco.value;
315
+ }
316
+ if (component.purl) {
317
+ const match = component.purl.match(/^pkg:([^/]+)/);
318
+ if (match) return ecosystemFromPurlType(match[1]);
319
+ }
320
+ return component.ecosystem || 'unknown';
321
+ }
@@ -1,31 +1,35 @@
1
1
  // src/tools/compliance-controls.js — get_compliance_controls MCP tool (thin wrapper)
2
2
 
3
3
  import { z } from 'zod';
4
- import { loadControls, filterControls } from '../lib/compliance-controls.js';
4
+ import { loadControls, filterControls, listFrameworks } from '../lib/compliance-controls.js';
5
5
 
6
6
  export const complianceControlsSchema = {
7
- domain: z.enum(['security', 'safety', 'all']).optional().describe("Filter by domain"),
7
+ framework: z.string().optional().describe("Framework to query (default: aiuc-1). Use 'soc2-technical', 'gdpr-technical', etc."),
8
+ domain: z.string().optional().describe("Filter by domain (e.g. 'security', 'safety', 'all'). Accepted values depend on the framework."),
8
9
  control_ids: z.array(z.string()).optional().describe("Specific control IDs to retrieve"),
9
10
  owasp_filter: z.array(z.string()).optional().describe("Filter by OWASP LLM tags (e.g. LLM01)"),
10
11
  verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level"),
11
12
  };
12
13
 
13
- export async function getComplianceControls({ domain, control_ids, owasp_filter, verbosity }) {
14
+ export async function getComplianceControls({ framework, domain, control_ids, owasp_filter, verbosity }) {
15
+ const fw = framework || 'aiuc-1';
14
16
  const level = verbosity || 'compact';
15
17
 
16
18
  const controls = filterControls({
19
+ framework: fw,
17
20
  domain: domain || 'all',
18
21
  controlIds: control_ids,
19
22
  owaspFilter: owasp_filter,
20
23
  });
21
24
 
22
- const registry = loadControls();
25
+ const registry = loadControls(fw);
23
26
 
24
27
  let output;
25
28
  switch (level) {
26
29
  case 'minimal':
27
30
  output = {
28
31
  framework: registry.framework,
32
+ domains: registry.domains,
29
33
  controls_count: controls.length,
30
34
  controls: controls.map(c => ({ id: c.id, title: c.title, domain: c.domain })),
31
35
  };
@@ -37,6 +41,8 @@ export async function getComplianceControls({ domain, control_ids, owasp_filter,
37
41
  schema_version: registry.schema_version,
38
42
  source: registry.source,
39
43
  source_snapshot: registry.source_snapshot,
44
+ domains: registry.domains,
45
+ available_frameworks: listFrameworks(),
40
46
  controls_count: controls.length,
41
47
  controls,
42
48
  };
@@ -47,14 +53,18 @@ export async function getComplianceControls({ domain, control_ids, owasp_filter,
47
53
  output = {
48
54
  framework: registry.framework,
49
55
  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
- })),
56
+ controls: controls.map(c => {
57
+ const out = {
58
+ id: c.id,
59
+ title: c.title,
60
+ domain: c.domain,
61
+ scanner_tools: c.scanner_tools,
62
+ evaluation: c.evaluation,
63
+ };
64
+ if (c.owasp_llm) out.owasp_llm = c.owasp_llm;
65
+ if (c.references) out.references = c.references;
66
+ return out;
67
+ }),
58
68
  };
59
69
  }
60
70
 
@@ -0,0 +1,161 @@
1
+ // src/tools/evaluate-compliance.js — evaluate_compliance MCP tool.
2
+ // Collects evidence from scan + SBOM, evaluates controls per framework, persists optionally.
3
+
4
+ import { z } from 'zod';
5
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { collectEvidence, buildEvaluatorEvidence } from '../lib/compliance-evidence.js';
8
+ import { loadControls } from '../lib/compliance-controls.js';
9
+ import { evaluateAll } from '../lib/compliance-evaluator.js';
10
+ function formatTimestamp(date) {
11
+ const pad = (n) => String(n).padStart(2, '0');
12
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
13
+ }
14
+
15
+ export const evaluateComplianceSchema = {
16
+ directory_path: z.string().describe('Path to project root directory to evaluate'),
17
+ frameworks: z.array(z.string()).optional()
18
+ .describe('Compliance frameworks to evaluate (default: ["aiuc-1"]). Options: aiuc-1, soc2-technical, gdpr-technical'),
19
+ sbom_path: z.string().optional().describe('Path to existing SBOM file (skips SBOM generation)'),
20
+ baseline_path: z.string().optional().describe('Path to SBOM baseline file for drift comparison'),
21
+ save_evidence: z.boolean().optional().describe('Save evidence bundle to .scanner/evidence/ (default: false)'),
22
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe('Response detail level (default: compact)'),
23
+ };
24
+
25
+ export async function evaluateCompliance({ directory_path, frameworks, sbom_path, baseline_path, save_evidence, verbosity }) {
26
+ if (!existsSync(directory_path)) {
27
+ return error(`Directory not found: ${directory_path}`);
28
+ }
29
+
30
+ const fws = (frameworks && frameworks.length > 0) ? frameworks : ['aiuc-1'];
31
+ const level = verbosity || 'compact';
32
+
33
+ // Collect evidence once, evaluate against all requested frameworks
34
+ let bundle;
35
+ try {
36
+ bundle = await collectEvidence({
37
+ directory: directory_path,
38
+ sbomPath: sbom_path,
39
+ baselinePath: baseline_path,
40
+ toolVersion: process.env.npm_package_version || 'unknown',
41
+ verbosity: level,
42
+ });
43
+ } catch (err) {
44
+ return error(`Evidence collection failed: ${err.message}`);
45
+ }
46
+
47
+ // Add requested frameworks to metadata
48
+ bundle.metadata.frameworks = fws;
49
+
50
+ // Build legacy evaluator evidence (for AIUC-1 backward compat)
51
+ const legacyEvidence = buildEvaluatorEvidence(bundle);
52
+
53
+ // Evaluate each framework
54
+ const frameworkResults = [];
55
+ for (const fw of fws) {
56
+ try {
57
+ const registry = loadControls(fw);
58
+ const result = evaluateAll(registry.controls, legacyEvidence, bundle);
59
+ frameworkResults.push({
60
+ framework: registry.framework,
61
+ controls_evaluated: result.controls_evaluated,
62
+ summary: {
63
+ pass: result.pass,
64
+ partial: result.partial,
65
+ fail: result.fail,
66
+ not_evaluated: result.not_evaluated,
67
+ },
68
+ results: result.results,
69
+ });
70
+ } catch (err) {
71
+ frameworkResults.push({
72
+ framework: fw,
73
+ error: err.message,
74
+ });
75
+ }
76
+ }
77
+
78
+ // Persist evidence if requested
79
+ let savedPath = null;
80
+ if (save_evidence) {
81
+ try {
82
+ const evidenceDir = join(directory_path, '.scanner', 'evidence');
83
+ if (!existsSync(evidenceDir)) {
84
+ mkdirSync(evidenceDir, { recursive: true, mode: 0o700 });
85
+ }
86
+ const filename = `${formatTimestamp(new Date())}.json`;
87
+ savedPath = join(evidenceDir, filename);
88
+ const persistBundle = {
89
+ ...bundle,
90
+ compliance: frameworkResults,
91
+ };
92
+ writeFileSync(savedPath, JSON.stringify(persistBundle, null, 2), { encoding: 'utf-8', mode: 0o600 });
93
+ } catch (err) {
94
+ // Non-fatal — report but don't fail
95
+ if (!bundle.errors) bundle.errors = [];
96
+ bundle.errors.push({ source: 'evidence_persist', message: err.message });
97
+ }
98
+ }
99
+
100
+ // Build response based on verbosity
101
+ let response;
102
+ switch (level) {
103
+ case 'minimal':
104
+ response = {
105
+ directory: directory_path,
106
+ tools_run: bundle.tools_run,
107
+ compliance: frameworkResults.map(r => ({
108
+ framework: r.framework,
109
+ summary: r.summary || null,
110
+ error: r.error || undefined,
111
+ })),
112
+ ...(savedPath && { evidence_saved: savedPath }),
113
+ };
114
+ break;
115
+
116
+ case 'full':
117
+ response = {
118
+ evidence: bundle,
119
+ compliance: frameworkResults,
120
+ ...(savedPath && { evidence_saved: savedPath }),
121
+ };
122
+ break;
123
+
124
+ case 'compact':
125
+ default:
126
+ response = {
127
+ directory: directory_path,
128
+ tools_run: bundle.tools_run,
129
+ errors: bundle.errors || undefined,
130
+ scan_summary: bundle.scan ? {
131
+ grade: bundle.scan.grade,
132
+ by_severity: bundle.scan.by_severity,
133
+ } : null,
134
+ sbom_summary: bundle.sbom ? {
135
+ component_count: bundle.sbom.component_count,
136
+ ecosystems: bundle.sbom.ecosystems,
137
+ } : null,
138
+ supply_chain: bundle.supply_chain.vulnerabilities || bundle.supply_chain.hallucinations ? {
139
+ vulnerabilities: bundle.supply_chain.vulnerabilities ? {
140
+ total: bundle.supply_chain.vulnerabilities.total,
141
+ by_severity: bundle.supply_chain.vulnerabilities.by_severity,
142
+ } : null,
143
+ hallucinations: bundle.supply_chain.hallucinations ? {
144
+ hallucinated_count: bundle.supply_chain.hallucinations.hallucinated_count,
145
+ } : null,
146
+ drift: bundle.supply_chain.drift,
147
+ } : null,
148
+ compliance: frameworkResults,
149
+ ...(savedPath && { evidence_saved: savedPath }),
150
+ };
151
+ break;
152
+ }
153
+
154
+ return {
155
+ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
156
+ };
157
+ }
158
+
159
+ function error(msg) {
160
+ return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
161
+ }