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.
@@ -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|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
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 - Same shape as evaluateControl
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
+ }