@euconform/core 1.3.0 → 1.5.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.
Files changed (69) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/assets/datasets/crows-pairs-de.json +613 -0
  4. package/assets/datasets/crows-pairs-en.json +9055 -0
  5. package/dist/datasets/crows-pairs.d.ts +7 -0
  6. package/dist/datasets/crows-pairs.d.ts.map +1 -0
  7. package/dist/datasets/crows-pairs.js +41 -0
  8. package/dist/evidence/output.d.ts.map +1 -1
  9. package/dist/evidence/output.js +1 -0
  10. package/dist/evidence/types.d.ts +8 -2
  11. package/dist/evidence/types.d.ts.map +1 -1
  12. package/dist/evidence/validate.d.ts.map +1 -1
  13. package/dist/evidence/validate.js +59 -39
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/inference/index.d.ts +1 -1
  17. package/dist/inference/index.d.ts.map +1 -1
  18. package/dist/inference/index.js +1 -1
  19. package/dist/inference/ollama-client.d.ts +9 -3
  20. package/dist/inference/ollama-client.d.ts.map +1 -1
  21. package/dist/inference/ollama-client.js +23 -11
  22. package/dist/sbom/component-mapper.d.ts +26 -0
  23. package/dist/sbom/component-mapper.d.ts.map +1 -0
  24. package/dist/sbom/component-mapper.js +83 -0
  25. package/dist/sbom/cyclonedx-import.d.ts +34 -0
  26. package/dist/sbom/cyclonedx-import.d.ts.map +1 -0
  27. package/dist/sbom/cyclonedx-import.js +131 -0
  28. package/dist/sbom/index.d.ts +6 -0
  29. package/dist/sbom/index.d.ts.map +1 -0
  30. package/dist/sbom/index.js +4 -0
  31. package/dist/sbom/known-packages.d.ts +13 -0
  32. package/dist/sbom/known-packages.d.ts.map +1 -0
  33. package/dist/sbom/known-packages.js +129 -0
  34. package/dist/sbom/purl.d.ts +14 -0
  35. package/dist/sbom/purl.d.ts.map +1 -0
  36. package/dist/sbom/purl.js +56 -0
  37. package/dist/sbom/types.d.ts +39 -0
  38. package/dist/sbom/types.d.ts.map +1 -0
  39. package/dist/sbom/types.js +5 -0
  40. package/dist/validation/index.d.ts +2 -0
  41. package/dist/validation/index.d.ts.map +1 -0
  42. package/dist/validation/index.js +1 -0
  43. package/dist/validation/schema-validator.d.ts +18 -0
  44. package/dist/validation/schema-validator.d.ts.map +1 -0
  45. package/dist/validation/schema-validator.js +99 -0
  46. package/package.json +8 -2
  47. package/src/datasets/crows-pairs.ts +64 -0
  48. package/src/evidence/output.ts +1 -0
  49. package/src/evidence/types.ts +9 -2
  50. package/src/evidence/validate.ts +107 -70
  51. package/src/index.ts +1 -0
  52. package/src/inference/index.ts +6 -1
  53. package/src/inference/ollama-client.ts +30 -14
  54. package/src/sbom/component-mapper.ts +107 -0
  55. package/src/sbom/cyclonedx-import.ts +198 -0
  56. package/src/sbom/index.ts +12 -0
  57. package/src/sbom/known-packages.ts +142 -0
  58. package/src/sbom/purl.ts +66 -0
  59. package/src/sbom/types.ts +34 -0
  60. package/src/validation/index.ts +7 -0
  61. package/src/validation/schema-validator.ts +133 -0
  62. package/tests/evidence/{ecef-schemas.test.ts → spec-schemas.test.ts} +3 -3
  63. package/tests/fixtures/cyclonedx-minimal.json +13 -0
  64. package/tests/fixtures/cyclonedx-mixed.json +17 -0
  65. package/tests/fixtures/cyclonedx-ml-bom.json +25 -0
  66. package/tests/sbom/component-mapper.test.ts +258 -0
  67. package/tests/sbom/cyclonedx-import.test.ts +329 -0
  68. package/tests/sbom/purl.test.ts +102 -0
  69. package/tests/validation/schema-validator.test.ts +219 -0
@@ -232,11 +232,17 @@ export interface BomComponent {
232
232
  kind: BomComponentKind;
233
233
  name: string;
234
234
  version?: string;
235
- source: "package.json" | "lock-file" | "code" | "config" | "docs";
235
+ source: "package.json" | "lock-file" | "code" | "config" | "docs" | "sbom-import";
236
+ }
237
+
238
+ export interface AibomMetadata {
239
+ importSource: "cyclonedx" | "spdx" | "manual";
240
+ importTool?: string;
241
+ originalTimestamp?: string;
236
242
  }
237
243
 
238
244
  export interface AiBillOfMaterials {
239
- schemaVersion: "euconform.aibom.v1";
245
+ schemaVersion: "euconform.aibom.v1" | "euconform.aibom.v1.1";
240
246
  generatedAt: string;
241
247
  project: {
242
248
  name: string;
@@ -251,6 +257,7 @@ export interface AiBillOfMaterials {
251
257
  humanReviewFlow: boolean;
252
258
  incidentHandling: boolean;
253
259
  };
260
+ metadata?: AibomMetadata;
254
261
  }
255
262
 
256
263
  // ---------------------------------------------------------------------------
@@ -7,10 +7,16 @@ function assertObject(data: unknown, label: string): Record<string, unknown> {
7
7
  return data as Record<string, unknown>;
8
8
  }
9
9
 
10
- function requireSchemaVersion(obj: Record<string, unknown>, expected: string, label: string): void {
11
- if (obj.schemaVersion !== expected) {
10
+ function requireSchemaVersion(
11
+ obj: Record<string, unknown>,
12
+ expected: string | string[],
13
+ label: string
14
+ ): void {
15
+ const allowed = Array.isArray(expected) ? expected : [expected];
16
+ if (!allowed.includes(obj.schemaVersion as string)) {
17
+ const quoted = allowed.map((v) => `"${v}"`).join(" or ");
12
18
  throw new Error(
13
- `Invalid ${label} schema version: expected "${expected}", got "${String(obj.schemaVersion)}"`
19
+ `Invalid ${label} schema version: expected ${quoted}, got "${String(obj.schemaVersion)}"`
14
20
  );
15
21
  }
16
22
  }
@@ -102,7 +108,7 @@ export function validateScanReport(data: unknown): ScanReport {
102
108
 
103
109
  export function validateAiBillOfMaterials(data: unknown): AiBillOfMaterials {
104
110
  const obj = assertObject(data, "AIBOM");
105
- requireSchemaVersion(obj, "euconform.aibom.v1", "AIBOM");
111
+ requireSchemaVersion(obj, ["euconform.aibom.v1", "euconform.aibom.v1.1"], "AIBOM");
106
112
  requireField(obj, "generatedAt", "string", "AIBOM");
107
113
 
108
114
  const project = requireField(obj, "project", "object", "AIBOM") as Record<string, unknown>;
@@ -152,8 +158,89 @@ export function validateCiReport(data: unknown): CiReport {
152
158
  const VALID_BUNDLE_ROLES = new Set(["report", "aibom", "ci", "summary"]);
153
159
  const SHA256_HEX = /^[a-f0-9]{64}$/;
154
160
 
155
- export function validateScanBundle(data: unknown): ScanBundle {
156
- const obj = assertObject(data, "bundle");
161
+ interface BundleArtifactFields {
162
+ role: string;
163
+ fileName: string;
164
+ sha256: string;
165
+ required: boolean;
166
+ declaredSchemaVersion: string | undefined;
167
+ mimeType: string | undefined;
168
+ }
169
+
170
+ function readBundleArtifactFields(
171
+ raw: Record<string, unknown>,
172
+ index: number
173
+ ): BundleArtifactFields {
174
+ const scope = "bundle artifact";
175
+ const role = requireField(raw, "role", "string", scope, `artifacts[${index}].role`) as string;
176
+ const fileName = requireField(
177
+ raw,
178
+ "fileName",
179
+ "string",
180
+ scope,
181
+ `artifacts[${index}].fileName`
182
+ ) as string;
183
+ const sha256 = requireField(
184
+ raw,
185
+ "sha256",
186
+ "string",
187
+ scope,
188
+ `artifacts[${index}].sha256`
189
+ ) as string;
190
+ const required = requireField(
191
+ raw,
192
+ "required",
193
+ "boolean",
194
+ scope,
195
+ `artifacts[${index}].required`
196
+ ) as boolean;
197
+
198
+ const declaredSchemaVersion = raw.schemaVersion;
199
+ if (declaredSchemaVersion !== undefined && typeof declaredSchemaVersion !== "string") {
200
+ throw new Error(`Invalid bundle artifact: '${fileName}' has non-string schemaVersion`);
201
+ }
202
+ if (raw.mimeType !== undefined && typeof raw.mimeType !== "string") {
203
+ throw new Error(`Invalid bundle artifact: '${fileName}' has non-string mimeType`);
204
+ }
205
+
206
+ return {
207
+ role,
208
+ fileName,
209
+ sha256,
210
+ required,
211
+ declaredSchemaVersion: declaredSchemaVersion as string | undefined,
212
+ mimeType: raw.mimeType as string | undefined,
213
+ };
214
+ }
215
+
216
+ function assertBundleArtifactIntegrity(
217
+ fields: BundleArtifactFields,
218
+ seenRoles: Set<string>,
219
+ seenFiles: Set<string>,
220
+ bundleMajor: string | null
221
+ ): void {
222
+ const { role, fileName, sha256, declaredSchemaVersion } = fields;
223
+
224
+ if (!VALID_BUNDLE_ROLES.has(role)) {
225
+ throw new Error(`Invalid bundle artifact: unsupported role '${role}'`);
226
+ }
227
+ if (!SHA256_HEX.test(sha256)) {
228
+ throw new Error(`Invalid bundle artifact: '${fileName}' has invalid sha256`);
229
+ }
230
+ if (seenRoles.has(role)) {
231
+ throw new Error(`Invalid bundle artifact: duplicate role '${role}'`);
232
+ }
233
+ if (seenFiles.has(fileName)) {
234
+ throw new Error(`Invalid bundle artifact: duplicate fileName '${fileName}'`);
235
+ }
236
+ if (declaredSchemaVersion && bundleMajor && majorVersion(declaredSchemaVersion) !== bundleMajor) {
237
+ throw new Error(
238
+ `Invalid bundle artifact: '${fileName}' uses schemaVersion '${declaredSchemaVersion}' with a different major than the bundle`
239
+ );
240
+ }
241
+ }
242
+
243
+ function validateBundleHeader(obj: Record<string, unknown>): void {
157
244
  requireSchemaVersion(obj, "euconform.bundle.v1", "bundle");
158
245
  requireField(obj, "generatedAt", "string", "bundle");
159
246
 
@@ -164,6 +251,11 @@ export function validateScanBundle(data: unknown): ScanBundle {
164
251
  const target = requireField(obj, "target", "object", "bundle") as Record<string, unknown>;
165
252
  requireField(target, "name", "string", "bundle", "target.name");
166
253
  requireField(target, "rootPath", "string", "bundle", "target.rootPath");
254
+ }
255
+
256
+ export function validateScanBundle(data: unknown): ScanBundle {
257
+ const obj = assertObject(data, "bundle");
258
+ validateBundleHeader(obj);
167
259
 
168
260
  const artifacts = requireField(obj, "artifacts", "array", "bundle") as unknown[];
169
261
  if (artifacts.length === 0) {
@@ -176,71 +268,15 @@ export function validateScanBundle(data: unknown): ScanBundle {
176
268
  let hasRequiredReport = false;
177
269
 
178
270
  for (let index = 0; index < artifacts.length; index++) {
179
- const artifact = assertObject(artifacts[index], "bundle artifact");
180
- const role = requireField(
181
- artifact,
182
- "role",
183
- "string",
184
- "bundle artifact",
185
- `artifacts[${index}].role`
186
- ) as string;
187
- const fileName = requireField(
188
- artifact,
189
- "fileName",
190
- "string",
191
- "bundle artifact",
192
- `artifacts[${index}].fileName`
193
- ) as string;
194
- const sha256 = requireField(
195
- artifact,
196
- "sha256",
197
- "string",
198
- "bundle artifact",
199
- `artifacts[${index}].sha256`
200
- ) as string;
201
- const required = requireField(
202
- artifact,
203
- "required",
204
- "boolean",
205
- "bundle artifact",
206
- `artifacts[${index}].required`
207
- ) as boolean;
208
-
209
- if (!VALID_BUNDLE_ROLES.has(role)) {
210
- throw new Error(`Invalid bundle artifact: unsupported role '${role}'`);
211
- }
212
- if (!SHA256_HEX.test(sha256)) {
213
- throw new Error(`Invalid bundle artifact: '${fileName}' has invalid sha256`);
214
- }
215
- if (seenRoles.has(role)) {
216
- throw new Error(`Invalid bundle artifact: duplicate role '${role}'`);
217
- }
218
- if (seenFiles.has(fileName)) {
219
- throw new Error(`Invalid bundle artifact: duplicate fileName '${fileName}'`);
220
- }
221
- seenRoles.add(role);
222
- seenFiles.add(fileName);
271
+ const raw = assertObject(artifacts[index], "bundle artifact");
272
+ const fields = readBundleArtifactFields(raw, index);
273
+ assertBundleArtifactIntegrity(fields, seenRoles, seenFiles, bundleMajor);
223
274
 
224
- const declaredSchemaVersion = artifact.schemaVersion;
225
- if (declaredSchemaVersion !== undefined && typeof declaredSchemaVersion !== "string") {
226
- throw new Error(`Invalid bundle artifact: '${fileName}' has non-string schemaVersion`);
227
- }
228
- if (artifact.mimeType !== undefined && typeof artifact.mimeType !== "string") {
229
- throw new Error(`Invalid bundle artifact: '${fileName}' has non-string mimeType`);
230
- }
231
-
232
- if (
233
- declaredSchemaVersion &&
234
- bundleMajor &&
235
- majorVersion(declaredSchemaVersion) !== bundleMajor
236
- ) {
237
- throw new Error(
238
- `Invalid bundle artifact: '${fileName}' uses schemaVersion '${declaredSchemaVersion}' with a different major than the bundle`
239
- );
240
- }
275
+ seenRoles.add(fields.role);
276
+ seenFiles.add(fields.fileName);
241
277
 
242
- if (role === "report") {
243
- hasRequiredReport = required;
278
+ if (fields.role === "report") {
279
+ hasRequiredReport = fields.required;
244
280
  }
245
281
  }
246
282
 
@@ -259,12 +295,13 @@ export function validateEcefJsonDocument(
259
295
  case "euconform.report.v1":
260
296
  return validateScanReport(data);
261
297
  case "euconform.aibom.v1":
298
+ case "euconform.aibom.v1.1":
262
299
  return validateAiBillOfMaterials(data);
263
300
  case "euconform.ci.v1":
264
301
  return validateCiReport(data);
265
302
  case "euconform.bundle.v1":
266
303
  return validateScanBundle(data);
267
304
  default:
268
- throw new Error(`Unsupported ECEF schemaVersion '${schemaVersion}'`);
305
+ throw new Error(`Unsupported EuConform Evidence Format schemaVersion '${schemaVersion}'`);
269
306
  }
270
307
  }
package/src/index.ts CHANGED
@@ -147,6 +147,7 @@ export {
147
147
  type UserPreferences,
148
148
  type EnhancedModelSelectionState,
149
149
  type CapabilityDetectionStatus,
150
+ type CapabilityCache,
150
151
  } from "./inference";
151
152
 
152
153
  // Inference Factory
@@ -132,7 +132,12 @@ export async function createAutoInferenceClient(preferredModel?: string): Promis
132
132
  }
133
133
 
134
134
  // Re-export types and utilities
135
- export { detectOllama, listOllamaModels, OllamaClient } from "./ollama-client";
135
+ export {
136
+ detectOllama,
137
+ listOllamaModels,
138
+ OllamaClient,
139
+ type CapabilityCache,
140
+ } from "./ollama-client";
136
141
  export { BrowserInference, checkWebGPU, BROWSER_MODELS } from "./browser-inference";
137
142
  export {
138
143
  InferenceFactory,
@@ -54,6 +54,12 @@ export interface OllamaLogProbsResponse extends OllamaGenerateResponse {
54
54
  }>;
55
55
  }
56
56
 
57
+ /** Abstraction for capability caching — localStorage in browser, file-based in CLI */
58
+ export interface CapabilityCache {
59
+ get(key: string): string | null;
60
+ set(key: string, value: string): void;
61
+ }
62
+
57
63
  const OLLAMA_BASE_URL = "http://localhost:11434";
58
64
 
59
65
  /**
@@ -217,11 +223,13 @@ export class OllamaClient {
217
223
  private baseUrl: string;
218
224
  public readonly model: string;
219
225
  private capabilityCache: Map<string, ModelCapabilityCache>;
226
+ private externalCache: CapabilityCache | null;
220
227
 
221
- constructor(model: string, baseUrl = OLLAMA_BASE_URL) {
228
+ constructor(model: string, baseUrl = OLLAMA_BASE_URL, cache?: CapabilityCache) {
222
229
  this.baseUrl = baseUrl;
223
230
  this.model = model;
224
231
  this.capabilityCache = new Map();
232
+ this.externalCache = cache ?? null;
225
233
  }
226
234
 
227
235
  private isCloudModel(): boolean {
@@ -344,33 +352,36 @@ export class OllamaClient {
344
352
  }
345
353
 
346
354
  /**
347
- * Get cached capability result from localStorage
355
+ * Get cached capability result from localStorage or external cache
348
356
  */
349
357
  private getCachedCapability(): ModelCapabilityCache | null {
350
358
  try {
351
359
  const cacheKey = `ollama_supports_logprobs_${this.model}`;
352
- const cached = localStorage.getItem(cacheKey);
360
+ let cached: string | null = null;
361
+
362
+ if (this.externalCache) {
363
+ cached = this.externalCache.get(cacheKey);
364
+ } else if (typeof localStorage !== "undefined") {
365
+ cached = localStorage.getItem(cacheKey);
366
+ }
353
367
 
354
368
  if (cached) {
355
369
  const capability: ModelCapabilityCache = JSON.parse(cached);
356
-
357
- // Check if cache is still valid (24 hours)
358
370
  const cacheAge = Date.now() - new Date(capability.testedAt).getTime();
359
- const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
360
-
361
- if (cacheAge < maxAge) {
371
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
372
+ if (cacheAge < CACHE_TTL) {
362
373
  return capability;
363
374
  }
364
375
  }
365
- } catch (error) {
366
- console.warn("Failed to read capability cache:", error);
367
- }
368
376
 
369
- return null;
377
+ return null;
378
+ } catch {
379
+ return null;
380
+ }
370
381
  }
371
382
 
372
383
  /**
373
- * Cache capability result in localStorage
384
+ * Cache capability result in localStorage or external cache
374
385
  */
375
386
  private setCachedCapability(supports: boolean): void {
376
387
  try {
@@ -381,7 +392,12 @@ export class OllamaClient {
381
392
  testedAt: new Date().toISOString(),
382
393
  };
383
394
 
384
- localStorage.setItem(cacheKey, JSON.stringify(capability));
395
+ if (this.externalCache) {
396
+ this.externalCache.set(cacheKey, JSON.stringify(capability));
397
+ } else if (typeof localStorage !== "undefined") {
398
+ localStorage.setItem(cacheKey, JSON.stringify(capability));
399
+ }
400
+
385
401
  this.capabilityCache.set(this.model, capability);
386
402
  } catch (error) {
387
403
  console.warn("Failed to cache capability result:", error);
@@ -0,0 +1,107 @@
1
+ import type { BomComponentKind } from "../evidence/types";
2
+ import { lookupKnownPackage } from "./known-packages";
3
+ import { parsePurl } from "./purl";
4
+ import type { CycloneDxComponent } from "./types";
5
+
6
+ export interface ComponentMapping {
7
+ kind: BomComponentKind;
8
+ source: "sbom-import";
9
+ confidence: "high" | "medium";
10
+ purlVersion?: string;
11
+ }
12
+
13
+ const CYCLONEDX_TYPE_MAP: Record<
14
+ string,
15
+ { kind: BomComponentKind; confidence: "high" | "medium" }
16
+ > = {
17
+ "machine-learning-model": { kind: "model", confidence: "high" },
18
+ data: { kind: "dataset", confidence: "medium" },
19
+ platform: { kind: "inference-provider", confidence: "medium" },
20
+ service: { kind: "inference-provider", confidence: "medium" },
21
+ };
22
+
23
+ /** Tiers 7-8: Try to find a kind via purl namespace/name and name-only lookups. */
24
+ function lookupViaPurl(
25
+ parsed: ReturnType<typeof parsePurl>
26
+ ): { kind: BomComponentKind; confidence: "high" | "medium" } | null {
27
+ if (!parsed) return null;
28
+
29
+ // Tier 7: namespace/name (e.g. @langchain/core)
30
+ if (parsed.namespace) {
31
+ const scopedName = `${parsed.namespace}/${parsed.name}`;
32
+ const scopedKind = lookupKnownPackage(scopedName);
33
+ if (scopedKind) return { kind: scopedKind, confidence: "high" };
34
+ }
35
+
36
+ // Tier 8: purl name only
37
+ const purlKind = lookupKnownPackage(parsed.name);
38
+ if (purlKind) return { kind: purlKind, confidence: "medium" };
39
+
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Map a CycloneDX component to an EuConform aibom component kind.
45
+ *
46
+ * 8-tier detection (descending priority):
47
+ * 1. ML-BOM extensions (modelCard, data)
48
+ * 2. CycloneDX component type mapping
49
+ * 3. Exact package name in registry
50
+ * 4. Explicit scoped name in registry (@langchain/core)
51
+ * 5. Scope mapping via KNOWN_AI_SCOPES (@langchain -> tool)
52
+ * 6. Scope-stripped name in registry (@huggingface/transformers -> transformers)
53
+ * 7. Purl namespace/name against registry
54
+ * 8. Purl name-only against registry
55
+ *
56
+ * Steps 3-6 are handled by lookupKnownPackage().
57
+ * Returns null if the component is not AI-relevant or has empty name.
58
+ */
59
+ export function mapComponent(component: CycloneDxComponent): ComponentMapping | null {
60
+ // Name validation
61
+ if (!component.name || component.name.trim() === "") {
62
+ return null;
63
+ }
64
+
65
+ // Extract purl info (used for fallback and version)
66
+ const parsed = component.purl ? parsePurl(component.purl) : null;
67
+ const purlVersion = parsed?.version;
68
+
69
+ // Tier 1: ML-BOM extensions
70
+ if (component.modelCard != null) {
71
+ return { kind: "model", source: "sbom-import", confidence: "high", purlVersion };
72
+ }
73
+
74
+ if (Array.isArray(component.data) && component.data.some((d) => d.type === "dataset")) {
75
+ return { kind: "dataset", source: "sbom-import", confidence: "high", purlVersion };
76
+ }
77
+
78
+ // Tier 2: CycloneDX component type
79
+ const typeMapping = CYCLONEDX_TYPE_MAP[component.type];
80
+ if (typeMapping) {
81
+ return {
82
+ kind: typeMapping.kind,
83
+ source: "sbom-import",
84
+ confidence: typeMapping.confidence,
85
+ purlVersion,
86
+ };
87
+ }
88
+
89
+ // Tiers 3-6: Known-package registry (handles exact, scoped, scope-fallback, scope-stripped)
90
+ const knownKind = lookupKnownPackage(component.name);
91
+ if (knownKind) {
92
+ return { kind: knownKind, source: "sbom-import", confidence: "high", purlVersion };
93
+ }
94
+
95
+ // Tiers 7-8: Purl fallback
96
+ const purlMatch = lookupViaPurl(parsed);
97
+ if (purlMatch) {
98
+ return {
99
+ kind: purlMatch.kind,
100
+ source: "sbom-import",
101
+ confidence: purlMatch.confidence,
102
+ purlVersion,
103
+ };
104
+ }
105
+
106
+ return null;
107
+ }
@@ -0,0 +1,198 @@
1
+ import { basename, extname } from "node:path";
2
+ import type { AiBillOfMaterials, AibomMetadata, BomComponent } from "../evidence/types";
3
+ import type { ValidationResult } from "../validation/schema-validator";
4
+ import { validate } from "../validation/schema-validator";
5
+ import { mapComponent } from "./component-mapper";
6
+ import type { CycloneDxBom } from "./types";
7
+ import { SUPPORTED_SPEC_VERSIONS } from "./types";
8
+
9
+ export interface ImportWarning {
10
+ component: string;
11
+ message: string;
12
+ }
13
+
14
+ export interface ImportSourceInfo {
15
+ bomFormat: "CycloneDX";
16
+ specVersion: string;
17
+ projectNameSource: "metadata.component.name" | "sourcePath" | "fallback";
18
+ importTool?: string;
19
+ originalTimestamp?: string;
20
+ }
21
+
22
+ export interface ImportSummary {
23
+ totalComponents: number;
24
+ filteredByScope: number;
25
+ aiRelevant: number;
26
+ skipped: number;
27
+ duplicatesRemoved: number;
28
+ byKind: Record<string, number>;
29
+ warnings: ImportWarning[];
30
+ source: ImportSourceInfo;
31
+ }
32
+
33
+ export interface ImportOptions {
34
+ scope?: "all" | "production";
35
+ sourcePath?: string;
36
+ }
37
+
38
+ export interface CycloneDxImportResult {
39
+ aibom: AiBillOfMaterials;
40
+ summary: ImportSummary;
41
+ validation: ValidationResult;
42
+ }
43
+
44
+ export function importCycloneDx(sbom: unknown, options?: ImportOptions): CycloneDxImportResult {
45
+ const bom = parseBom(sbom);
46
+ const scope = options?.scope ?? "all";
47
+
48
+ const allComponents = bom.components ?? [];
49
+ const warnings: ImportWarning[] = [];
50
+ const totalComponents = allComponents.length;
51
+
52
+ const inScope =
53
+ scope === "production"
54
+ ? allComponents.filter((c) => c.scope !== "optional" && c.scope !== "excluded")
55
+ : allComponents;
56
+ const filteredByScope = totalComponents - inScope.length;
57
+
58
+ const mapped: BomComponent[] = [];
59
+ for (const comp of inScope) {
60
+ const mapping = mapComponent(comp);
61
+ if (!mapping) {
62
+ if (!comp.name || comp.name.trim() === "") {
63
+ warnings.push({ component: "(empty)", message: "Skipped: missing or empty name" });
64
+ }
65
+ continue;
66
+ }
67
+
68
+ const version = comp.version || mapping.purlVersion;
69
+ if (!version) {
70
+ warnings.push({ component: comp.name, message: "Missing version field" });
71
+ }
72
+
73
+ mapped.push({
74
+ id: version ? `${mapping.kind}:${comp.name}:${version}` : `${mapping.kind}:${comp.name}`,
75
+ kind: mapping.kind,
76
+ name: comp.name,
77
+ ...(version ? { version } : {}),
78
+ source: "sbom-import",
79
+ });
80
+ }
81
+
82
+ const seen = new Set<string>();
83
+ let duplicatesRemoved = 0;
84
+ const deduped: BomComponent[] = [];
85
+ for (const comp of mapped) {
86
+ if (seen.has(comp.id)) {
87
+ duplicatesRemoved++;
88
+ continue;
89
+ }
90
+ seen.add(comp.id);
91
+ deduped.push(comp);
92
+ }
93
+
94
+ const byKind: Record<string, number> = {};
95
+ for (const comp of deduped) {
96
+ byKind[comp.kind] = (byKind[comp.kind] ?? 0) + 1;
97
+ }
98
+
99
+ const { projectName, projectNameSource } = resolveProjectName(bom, options?.sourcePath);
100
+ const importTool = extractToolString(bom);
101
+ const originalTimestamp = bom.metadata?.timestamp;
102
+
103
+ const aibom: AiBillOfMaterials = {
104
+ schemaVersion: "euconform.aibom.v1.1",
105
+ generatedAt: new Date().toISOString(),
106
+ project: { name: projectName, rootPath: "." },
107
+ components: deduped,
108
+ complianceCapabilities: {
109
+ biasEvaluation: false,
110
+ jsonExport: false,
111
+ pdfExport: false,
112
+ loggingInfrastructure: false,
113
+ humanReviewFlow: false,
114
+ incidentHandling: false,
115
+ },
116
+ metadata: buildMetadata(importTool, originalTimestamp),
117
+ };
118
+
119
+ const validation = validate(aibom);
120
+
121
+ return {
122
+ aibom,
123
+ summary: {
124
+ totalComponents,
125
+ filteredByScope,
126
+ aiRelevant: deduped.length,
127
+ skipped: inScope.length - mapped.length,
128
+ duplicatesRemoved,
129
+ byKind,
130
+ warnings,
131
+ source: buildSourceInfo(bom.specVersion, projectNameSource, importTool, originalTimestamp),
132
+ },
133
+ validation,
134
+ };
135
+ }
136
+
137
+ function resolveProjectName(
138
+ bom: CycloneDxBom,
139
+ sourcePath?: string
140
+ ): { projectName: string; projectNameSource: ImportSourceInfo["projectNameSource"] } {
141
+ const metaName = bom.metadata?.component?.name;
142
+ if (metaName) {
143
+ return { projectName: metaName, projectNameSource: "metadata.component.name" };
144
+ }
145
+
146
+ if (sourcePath) {
147
+ const name = basename(sourcePath, extname(sourcePath));
148
+ return { projectName: name, projectNameSource: "sourcePath" };
149
+ }
150
+
151
+ return { projectName: "sbom-import", projectNameSource: "fallback" };
152
+ }
153
+
154
+ function extractToolString(bom: CycloneDxBom): string | undefined {
155
+ const tool = bom.metadata?.tools?.[0];
156
+ if (!tool?.name) return undefined;
157
+ return tool.version ? `${tool.name} ${tool.version}` : tool.name;
158
+ }
159
+
160
+ function buildMetadata(importTool?: string, originalTimestamp?: string): AibomMetadata {
161
+ const meta: AibomMetadata = { importSource: "cyclonedx" };
162
+ if (importTool) meta.importTool = importTool;
163
+ if (originalTimestamp) meta.originalTimestamp = originalTimestamp;
164
+ return meta;
165
+ }
166
+
167
+ function buildSourceInfo(
168
+ specVersion: string,
169
+ projectNameSource: ImportSourceInfo["projectNameSource"],
170
+ importTool?: string,
171
+ originalTimestamp?: string
172
+ ): ImportSourceInfo {
173
+ const info: ImportSourceInfo = { bomFormat: "CycloneDX", specVersion, projectNameSource };
174
+ if (importTool) info.importTool = importTool;
175
+ if (originalTimestamp) info.originalTimestamp = originalTimestamp;
176
+ return info;
177
+ }
178
+
179
+ function parseBom(sbom: unknown): CycloneDxBom {
180
+ if (typeof sbom !== "object" || sbom === null) {
181
+ throw new Error("Input must be a JSON object");
182
+ }
183
+
184
+ const record = sbom as Record<string, unknown>;
185
+
186
+ if (record.bomFormat !== "CycloneDX") {
187
+ throw new Error(`Invalid bomFormat: expected "CycloneDX", got "${String(record.bomFormat)}"`);
188
+ }
189
+
190
+ const specVersion = String(record.specVersion ?? "");
191
+ if (!SUPPORTED_SPEC_VERSIONS.includes(specVersion as (typeof SUPPORTED_SPEC_VERSIONS)[number])) {
192
+ throw new Error(
193
+ `Unsupported specVersion "${specVersion}". Supported: ${SUPPORTED_SPEC_VERSIONS.join(", ")}`
194
+ );
195
+ }
196
+
197
+ return sbom as CycloneDxBom;
198
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ importCycloneDx,
3
+ type CycloneDxImportResult,
4
+ type ImportSummary,
5
+ type ImportWarning,
6
+ type ImportOptions,
7
+ type ImportSourceInfo,
8
+ } from "./cyclonedx-import";
9
+ export { mapComponent, type ComponentMapping } from "./component-mapper";
10
+ export { lookupKnownPackage, KNOWN_AI_PACKAGES, KNOWN_AI_SCOPES } from "./known-packages";
11
+ export { parsePurl, type ParsedPurl } from "./purl";
12
+ export type { CycloneDxBom, CycloneDxComponent } from "./types";