agent-security-scanner-mcp 4.1.0 → 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.
- package/README.md +394 -1
- package/compliance/gdpr-technical-controls.json +112 -0
- package/compliance/soc2-technical-controls.json +148 -0
- package/index.js +148 -1
- package/openclaw.plugin.json +21 -1
- package/package.json +1 -1
- package/src/lib/compliance-controls.js +100 -21
- package/src/lib/compliance-evaluator.js +150 -9
- package/src/lib/compliance-evidence.js +321 -0
- package/src/lib/cyclonedx.js +113 -0
- package/src/lib/lockfile-parsers.js +671 -0
- package/src/lib/osv-client.js +254 -0
- package/src/lib/purl.js +90 -0
- package/src/lib/sbom-component.js +88 -0
- package/src/tools/compliance-controls.js +22 -12
- package/src/tools/evaluate-compliance.js +161 -0
- package/src/tools/sbom-diff.js +199 -0
- package/src/tools/sbom-generate.js +116 -0
- package/src/tools/sbom-hallucinations.js +117 -0
- package/src/tools/sbom-report.js +271 -0
- package/src/tools/sbom-vulnerabilities.js +121 -0
|
@@ -4,6 +4,131 @@ import { scoreBatch } from './aivss.js';
|
|
|
4
4
|
|
|
5
5
|
const GRADE_ORDER = { A: 4, B: 3, C: 2, D: 1, F: 0 };
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a dot-path like "supply_chain.vulnerabilities.by_severity.critical" against an object.
|
|
9
|
+
* Returns undefined for any missing intermediate key (never throws).
|
|
10
|
+
*/
|
|
11
|
+
export function resolvePath(obj, path) {
|
|
12
|
+
if (obj == null || typeof path !== 'string') return undefined;
|
|
13
|
+
const segments = path.split('.');
|
|
14
|
+
let current = obj;
|
|
15
|
+
for (const seg of segments) {
|
|
16
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
17
|
+
current = current[seg];
|
|
18
|
+
}
|
|
19
|
+
return current;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run a single evidence_check against the evidence bundle.
|
|
24
|
+
* Returns { passed: boolean, status: string|null, reason: string } where:
|
|
25
|
+
* - passed=true means check succeeded (no status change needed)
|
|
26
|
+
* - passed=false means on_fail status should be applied, with reason
|
|
27
|
+
*/
|
|
28
|
+
function runEvidenceCheck(check, evidenceBundle) {
|
|
29
|
+
const value = resolvePath(evidenceBundle, check.path);
|
|
30
|
+
|
|
31
|
+
// Missing evidence handling — three tiers:
|
|
32
|
+
//
|
|
33
|
+
// 1. Any node along the path is explicitly `null` → source failure → not_evaluated.
|
|
34
|
+
// The evidence collector sets sections to null when a data source fails (e.g., OSV down).
|
|
35
|
+
//
|
|
36
|
+
// 2. A top-level section key is missing entirely (e.g., bundle has no "supply_chain") →
|
|
37
|
+
// evidence was never collected → not_evaluated. Defaults must not mask this.
|
|
38
|
+
//
|
|
39
|
+
// 3. A deeper leaf is missing but its parent exists (e.g., scan.by_category_severity.crypto
|
|
40
|
+
// is absent because no crypto findings) → safe to apply check.default.
|
|
41
|
+
//
|
|
42
|
+
if (value === undefined || value === null) {
|
|
43
|
+
const segments = check.path.split('.');
|
|
44
|
+
|
|
45
|
+
// Walk the path to find where it breaks
|
|
46
|
+
let current = evidenceBundle;
|
|
47
|
+
let depth = 0;
|
|
48
|
+
for (depth = 0; depth < segments.length; depth++) {
|
|
49
|
+
if (current === null) {
|
|
50
|
+
// Explicit null — source failure
|
|
51
|
+
const failedAt = segments.slice(0, depth).join('.') || segments[0];
|
|
52
|
+
return {
|
|
53
|
+
passed: false,
|
|
54
|
+
status: 'not_evaluated',
|
|
55
|
+
reason: `Evidence source unavailable: ${failedAt} is null (full path: ${check.path})`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (current === undefined || typeof current !== 'object') break;
|
|
59
|
+
const next = current[segments[depth]];
|
|
60
|
+
if (next === null) {
|
|
61
|
+
// Explicit null at this level
|
|
62
|
+
const failedAt = segments.slice(0, depth + 1).join('.');
|
|
63
|
+
return {
|
|
64
|
+
passed: false,
|
|
65
|
+
status: 'not_evaluated',
|
|
66
|
+
reason: `Evidence source unavailable: ${failedAt} is null (full path: ${check.path})`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (next === undefined) break; // missing key — check depth below
|
|
70
|
+
current = next;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// If the break happened at depth 0 or 1, the top-level section is missing.
|
|
74
|
+
// This means evidence was never collected — not_evaluated, no defaults.
|
|
75
|
+
if (depth <= 1) {
|
|
76
|
+
return {
|
|
77
|
+
passed: false,
|
|
78
|
+
status: 'not_evaluated',
|
|
79
|
+
reason: `Evidence source unavailable: ${segments[0]} is missing (full path: ${check.path})`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Break happened deeper — parent section exists, leaf key absent.
|
|
84
|
+
// Safe to apply default (e.g., scan ran but no "crypto" category).
|
|
85
|
+
if (check.default !== undefined) {
|
|
86
|
+
return evaluateOp(check.operator, check.default, check.value, check);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
passed: false,
|
|
91
|
+
status: 'not_evaluated',
|
|
92
|
+
reason: `Missing evidence at path: ${check.path}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return evaluateOp(check.operator, value, check.value, check);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function evaluateOp(operator, actual, expected, check) {
|
|
100
|
+
let passed;
|
|
101
|
+
switch (operator) {
|
|
102
|
+
case 'exists':
|
|
103
|
+
passed = actual !== undefined && actual !== null;
|
|
104
|
+
break;
|
|
105
|
+
case 'eq':
|
|
106
|
+
passed = actual === expected;
|
|
107
|
+
break;
|
|
108
|
+
case 'lte':
|
|
109
|
+
passed = typeof actual === 'number' && actual <= expected;
|
|
110
|
+
break;
|
|
111
|
+
case 'gte':
|
|
112
|
+
passed = typeof actual === 'number' && actual >= expected;
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
return { passed: false, status: 'not_evaluated', reason: `Unknown operator: ${operator}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (passed) {
|
|
119
|
+
return { passed: true, status: null, reason: '' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const status = check.on_fail || 'fail';
|
|
123
|
+
// Use distinct reason for not_evaluated vs failure to avoid confusing audit consumers.
|
|
124
|
+
// "reason" is the failure message; "not_evaluated_reason" explains why evidence was insufficient.
|
|
125
|
+
const reason = status === 'not_evaluated'
|
|
126
|
+
? (check.not_evaluated_reason || `Evidence insufficient: ${check.path} ${operator} ${expected} (actual: ${actual})`)
|
|
127
|
+
: (check.reason || `Check failed: ${check.path} ${operator} ${expected} (actual: ${actual})`);
|
|
128
|
+
|
|
129
|
+
return { passed: false, status, reason };
|
|
130
|
+
}
|
|
131
|
+
|
|
7
132
|
/**
|
|
8
133
|
* Check if actual grade is worse than threshold.
|
|
9
134
|
* Missing/null grade → treated as F (worst case).
|
|
@@ -18,14 +143,11 @@ function gradeIsWorse(actual, threshold) {
|
|
|
18
143
|
* Evaluate a single control against evidence.
|
|
19
144
|
*
|
|
20
145
|
* @param {object} control - A control from the registry
|
|
21
|
-
* @param {object} evidence
|
|
22
|
-
* @param {object
|
|
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
|
|
146
|
+
* @param {object} evidence - Legacy evidence shape (aivssPosture, findings, grades, toolsRun)
|
|
147
|
+
* @param {object} [evidenceBundle] - Full evidence bundle for evidence_checks evaluation
|
|
26
148
|
* @returns {{ control_id: string, status: string, reasons: string[] }}
|
|
27
149
|
*/
|
|
28
|
-
export function evaluateControl(control, evidence) {
|
|
150
|
+
export function evaluateControl(control, evidence, evidenceBundle) {
|
|
29
151
|
if (!control || !control.id || !control.evaluation) {
|
|
30
152
|
return {
|
|
31
153
|
control_id: control?.id || 'unknown',
|
|
@@ -123,6 +245,24 @@ export function evaluateControl(control, evidence) {
|
|
|
123
245
|
}
|
|
124
246
|
}
|
|
125
247
|
|
|
248
|
+
// 7. Run evidence_checks (generic path-based checks, used by SOC2/GDPR controls)
|
|
249
|
+
if (Array.isArray(ev.evidence_checks) && evidenceBundle) {
|
|
250
|
+
for (const check of ev.evidence_checks) {
|
|
251
|
+
const result = runEvidenceCheck(check, evidenceBundle);
|
|
252
|
+
if (!result.passed) {
|
|
253
|
+
if (result.status === 'not_evaluated') {
|
|
254
|
+
// If we haven't already failed/passed via legacy checks, mark not_evaluated
|
|
255
|
+
if (status === 'pass') status = 'not_evaluated';
|
|
256
|
+
} else if (result.status === 'fail') {
|
|
257
|
+
status = 'fail';
|
|
258
|
+
} else if (result.status === 'partial' && status !== 'fail') {
|
|
259
|
+
status = 'partial';
|
|
260
|
+
}
|
|
261
|
+
if (result.reason) reasons.push(result.reason);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
126
266
|
return { control_id: control.id, status, reasons };
|
|
127
267
|
}
|
|
128
268
|
|
|
@@ -130,11 +270,12 @@ export function evaluateControl(control, evidence) {
|
|
|
130
270
|
* Evaluate all controls against evidence.
|
|
131
271
|
*
|
|
132
272
|
* @param {object[]} controls - Array of controls from registry
|
|
133
|
-
* @param {object} evidence -
|
|
273
|
+
* @param {object} evidence - Legacy evidence shape
|
|
274
|
+
* @param {object} [evidenceBundle] - Full evidence bundle for evidence_checks
|
|
134
275
|
* @returns {{ controls_evaluated: number, pass: number, partial: number, fail: number, not_evaluated: number, results: object[] }}
|
|
135
276
|
*/
|
|
136
|
-
export function evaluateAll(controls, evidence) {
|
|
137
|
-
const results = controls.map(c => evaluateControl(c, evidence));
|
|
277
|
+
export function evaluateAll(controls, evidence, evidenceBundle) {
|
|
278
|
+
const results = controls.map(c => evaluateControl(c, evidence, evidenceBundle));
|
|
138
279
|
|
|
139
280
|
const summary = { pass: 0, partial: 0, fail: 0, not_evaluated: 0 };
|
|
140
281
|
for (const r of results) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// CycloneDX v1.5 JSON serializer.
|
|
2
|
+
// Consumes the normalized ComponentList model from sbom-component.js.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
|
|
6
|
+
const SPEC_VERSION = '1.5';
|
|
7
|
+
const TOOL_NAME = 'agent-security-scanner-mcp';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Serialize a ComponentList into a CycloneDX v1.5 BOM.
|
|
11
|
+
* @param {import('./sbom-component.js').ComponentList} componentList
|
|
12
|
+
* @param {object[]} [vulnerabilities=[]]
|
|
13
|
+
* @param {{ toolVersion?: string }} [options={}]
|
|
14
|
+
* @returns {object} CycloneDX JSON object
|
|
15
|
+
*/
|
|
16
|
+
export function serialize(componentList, vulnerabilities = [], options = {}) {
|
|
17
|
+
const { toolVersion = '0.0.0' } = options;
|
|
18
|
+
const { components, edges, metadata } = componentList;
|
|
19
|
+
|
|
20
|
+
const bom = {
|
|
21
|
+
bomFormat: 'CycloneDX',
|
|
22
|
+
specVersion: SPEC_VERSION,
|
|
23
|
+
serialNumber: `urn:uuid:${randomUUID()}`,
|
|
24
|
+
version: 1,
|
|
25
|
+
metadata: {
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
tools: [{
|
|
28
|
+
name: TOOL_NAME,
|
|
29
|
+
version: toolVersion,
|
|
30
|
+
}],
|
|
31
|
+
component: {
|
|
32
|
+
type: 'application',
|
|
33
|
+
name: metadata.name || 'unknown',
|
|
34
|
+
version: metadata.version || '0.0.0',
|
|
35
|
+
'bom-ref': `pkg:npm/${metadata.name}@${metadata.version}`,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
components: components.map(c => serializeComponent(c)),
|
|
39
|
+
dependencies: serializeDependencies(edges, components),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (vulnerabilities.length > 0) {
|
|
43
|
+
bom.vulnerabilities = vulnerabilities;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return bom;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serializeComponent(component) {
|
|
50
|
+
const result = {
|
|
51
|
+
type: 'library',
|
|
52
|
+
name: component.name,
|
|
53
|
+
version: component.version,
|
|
54
|
+
purl: component.purl,
|
|
55
|
+
'bom-ref': component.purl,
|
|
56
|
+
scope: component.scope === 'optional' ? 'optional' : 'required',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (component.namespace) {
|
|
60
|
+
result.group = component.namespace;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const properties = [];
|
|
64
|
+
if (component.isDev) {
|
|
65
|
+
properties.push({ name: 'cdx:development', value: 'true' });
|
|
66
|
+
}
|
|
67
|
+
if (component.ecosystem) {
|
|
68
|
+
properties.push({ name: 'cdx:ecosystem', value: component.ecosystem });
|
|
69
|
+
}
|
|
70
|
+
if (properties.length > 0) {
|
|
71
|
+
result.properties = properties;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function serializeDependencies(edges, components) {
|
|
78
|
+
if (!edges || edges.length === 0) {
|
|
79
|
+
// Return flat list — each component depends on nothing
|
|
80
|
+
return components.map(c => ({ ref: c.purl, dependsOn: [] }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Group edges by source
|
|
84
|
+
const depMap = new Map();
|
|
85
|
+
for (const edge of edges) {
|
|
86
|
+
if (!depMap.has(edge.from)) depMap.set(edge.from, []);
|
|
87
|
+
depMap.get(edge.from).push(edge.to);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Include all components as refs, merge edges
|
|
91
|
+
const allRefs = new Set(components.map(c => c.purl));
|
|
92
|
+
for (const [from, tos] of depMap) {
|
|
93
|
+
allRefs.add(from);
|
|
94
|
+
for (const to of tos) allRefs.add(to);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return [...allRefs].map(ref => ({
|
|
98
|
+
ref,
|
|
99
|
+
dependsOn: depMap.get(ref) || [],
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Add vulnerability entries to an existing BOM.
|
|
105
|
+
* @param {object} bom - CycloneDX BOM object
|
|
106
|
+
* @param {object[]} vulnerabilities - Array of CycloneDX vulnerability objects
|
|
107
|
+
* @returns {object} The mutated BOM
|
|
108
|
+
*/
|
|
109
|
+
export function addVulnerabilities(bom, vulnerabilities) {
|
|
110
|
+
if (!bom.vulnerabilities) bom.vulnerabilities = [];
|
|
111
|
+
bom.vulnerabilities.push(...vulnerabilities);
|
|
112
|
+
return bom;
|
|
113
|
+
}
|