@euconform/core 1.4.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +12 -0
- package/dist/evidence/output.d.ts.map +1 -1
- package/dist/evidence/output.js +1 -0
- package/dist/evidence/types.d.ts +8 -2
- package/dist/evidence/types.d.ts.map +1 -1
- package/dist/evidence/validate.d.ts.map +1 -1
- package/dist/evidence/validate.js +6 -3
- package/dist/sbom/component-mapper.d.ts +26 -0
- package/dist/sbom/component-mapper.d.ts.map +1 -0
- package/dist/sbom/component-mapper.js +83 -0
- package/dist/sbom/cyclonedx-import.d.ts +34 -0
- package/dist/sbom/cyclonedx-import.d.ts.map +1 -0
- package/dist/sbom/cyclonedx-import.js +131 -0
- package/dist/sbom/index.d.ts +6 -0
- package/dist/sbom/index.d.ts.map +1 -0
- package/dist/sbom/index.js +4 -0
- package/dist/sbom/known-packages.d.ts +13 -0
- package/dist/sbom/known-packages.d.ts.map +1 -0
- package/dist/sbom/known-packages.js +129 -0
- package/dist/sbom/purl.d.ts +14 -0
- package/dist/sbom/purl.d.ts.map +1 -0
- package/dist/sbom/purl.js +56 -0
- package/dist/sbom/types.d.ts +39 -0
- package/dist/sbom/types.d.ts.map +1 -0
- package/dist/sbom/types.js +5 -0
- package/dist/validation/index.d.ts +2 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +1 -0
- package/dist/validation/schema-validator.d.ts +18 -0
- package/dist/validation/schema-validator.d.ts.map +1 -0
- package/dist/validation/schema-validator.js +99 -0
- package/package.json +6 -2
- package/src/evidence/output.ts +1 -0
- package/src/evidence/types.ts +9 -2
- package/src/evidence/validate.ts +11 -4
- package/src/sbom/component-mapper.ts +107 -0
- package/src/sbom/cyclonedx-import.ts +198 -0
- package/src/sbom/index.ts +12 -0
- package/src/sbom/known-packages.ts +142 -0
- package/src/sbom/purl.ts +66 -0
- package/src/sbom/types.ts +34 -0
- package/src/validation/index.ts +7 -0
- package/src/validation/schema-validator.ts +133 -0
- package/tests/fixtures/cyclonedx-minimal.json +13 -0
- package/tests/fixtures/cyclonedx-mixed.json +17 -0
- package/tests/fixtures/cyclonedx-ml-bom.json +25 -0
- package/tests/sbom/component-mapper.test.ts +258 -0
- package/tests/sbom/cyclonedx-import.test.ts +329 -0
- package/tests/sbom/purl.test.ts +102 -0
- package/tests/validation/schema-validator.test.ts +219 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a Package URL (purl) string into its components.
|
|
3
|
+
* Follows the purl spec: pkg:type/namespace/name@version?qualifiers#subpath
|
|
4
|
+
* Returns null for invalid or non-pkg URLs.
|
|
5
|
+
*/
|
|
6
|
+
export function parsePurl(purl) {
|
|
7
|
+
if (!purl || !purl.startsWith("pkg:"))
|
|
8
|
+
return null;
|
|
9
|
+
// Strip qualifiers and subpath
|
|
10
|
+
let rest = purl.slice(4); // remove "pkg:"
|
|
11
|
+
const hashIdx = rest.indexOf("#");
|
|
12
|
+
if (hashIdx !== -1)
|
|
13
|
+
rest = rest.slice(0, hashIdx);
|
|
14
|
+
const queryIdx = rest.indexOf("?");
|
|
15
|
+
if (queryIdx !== -1)
|
|
16
|
+
rest = rest.slice(0, queryIdx);
|
|
17
|
+
// Split type from the rest: type/...
|
|
18
|
+
const slashIdx = rest.indexOf("/");
|
|
19
|
+
if (slashIdx <= 0)
|
|
20
|
+
return null;
|
|
21
|
+
const type = rest.slice(0, slashIdx);
|
|
22
|
+
let remainder = rest.slice(slashIdx + 1);
|
|
23
|
+
try {
|
|
24
|
+
remainder = decodeURIComponent(remainder);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
// Extract version
|
|
30
|
+
let version;
|
|
31
|
+
const atIdx = remainder.lastIndexOf("@");
|
|
32
|
+
if (atIdx !== -1) {
|
|
33
|
+
version = remainder.slice(atIdx + 1);
|
|
34
|
+
remainder = remainder.slice(0, atIdx);
|
|
35
|
+
}
|
|
36
|
+
// Split namespace/name (last segment is name, rest is namespace)
|
|
37
|
+
const lastSlash = remainder.lastIndexOf("/");
|
|
38
|
+
let namespace;
|
|
39
|
+
let name;
|
|
40
|
+
if (lastSlash !== -1) {
|
|
41
|
+
namespace = remainder.slice(0, lastSlash);
|
|
42
|
+
name = remainder.slice(lastSlash + 1);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
name = remainder;
|
|
46
|
+
}
|
|
47
|
+
if (!name)
|
|
48
|
+
return null;
|
|
49
|
+
return {
|
|
50
|
+
scheme: "pkg",
|
|
51
|
+
type,
|
|
52
|
+
namespace: namespace || undefined,
|
|
53
|
+
name,
|
|
54
|
+
version: version || undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal CycloneDX types covering fields relevant to AI component detection.
|
|
3
|
+
* Supports CycloneDX JSON format versions 1.4, 1.5, 1.6.
|
|
4
|
+
*/
|
|
5
|
+
export interface CycloneDxBom {
|
|
6
|
+
bomFormat: "CycloneDX";
|
|
7
|
+
specVersion: string;
|
|
8
|
+
version?: number;
|
|
9
|
+
metadata?: CycloneDxMetadata;
|
|
10
|
+
components?: CycloneDxComponent[];
|
|
11
|
+
}
|
|
12
|
+
export interface CycloneDxMetadata {
|
|
13
|
+
timestamp?: string;
|
|
14
|
+
tools?: Array<{
|
|
15
|
+
name?: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
}>;
|
|
18
|
+
component?: CycloneDxComponent;
|
|
19
|
+
}
|
|
20
|
+
export interface CycloneDxComponent {
|
|
21
|
+
type: string;
|
|
22
|
+
name: string;
|
|
23
|
+
version?: string;
|
|
24
|
+
purl?: string;
|
|
25
|
+
group?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
scope?: "required" | "optional" | "excluded";
|
|
28
|
+
properties?: Array<{
|
|
29
|
+
name: string;
|
|
30
|
+
value: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** CycloneDX ML-BOM extensions (1.5+) */
|
|
33
|
+
modelCard?: unknown;
|
|
34
|
+
data?: Array<{
|
|
35
|
+
type?: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
export declare const SUPPORTED_SPEC_VERSIONS: readonly ["1.4", "1.5", "1.6"];
|
|
39
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/sbom/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,WAAW,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,UAAU,CAAC,EAAE,kBAAkB,EAAE,CAAC;CACnC;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,SAAS,CAAC,EAAE,kBAAkB,CAAC;CAChC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,UAAU,GAAG,UAAU,GAAG,UAAU,CAAC;IAC7C,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,yCAAyC;IACzC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACjC;AAED,eAAO,MAAM,uBAAuB,gCAAiC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/validation/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EACR,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,eAAe,GACrB,MAAM,oBAAoB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { validate, } from "./schema-validator";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ValidationError {
|
|
2
|
+
path: string;
|
|
3
|
+
message: string;
|
|
4
|
+
keyword: string;
|
|
5
|
+
expected?: unknown;
|
|
6
|
+
received?: unknown;
|
|
7
|
+
}
|
|
8
|
+
export type SchemaType = "report.v1" | "aibom.v1" | "aibom.v1.1" | "ci.v1" | "bundle.v1";
|
|
9
|
+
export interface ValidationResult {
|
|
10
|
+
valid: boolean;
|
|
11
|
+
schemaType: SchemaType;
|
|
12
|
+
errors: ValidationError[];
|
|
13
|
+
}
|
|
14
|
+
export interface ValidateOptions {
|
|
15
|
+
strict?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function validate(data: unknown, _options?: ValidateOptions): ValidationResult;
|
|
18
|
+
//# sourceMappingURL=schema-validator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-validator.d.ts","sourceRoot":"","sources":["../../src/validation/schema-validator.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,UAAU,GAAG,YAAY,GAAG,OAAO,GAAG,WAAW,CAAC;AAEzF,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,UAAU,CAAC;IACvB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAmCD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,eAAe,GAAG,gBAAgB,CAyEpF"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import addFormats from "ajv-formats";
|
|
5
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
6
|
+
// Resolve schema directory relative to this file at runtime.
|
|
7
|
+
// When bundled into packages/cli/dist, schemas are copied to dist/schemas/.
|
|
8
|
+
// When running from source at packages/core/src/validation/, schemas are at ../../../../docs/spec/schemas.
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const _bundledSchemasDir = resolve(__dirname, "schemas");
|
|
12
|
+
const schemasDir = existsSync(_bundledSchemasDir)
|
|
13
|
+
? _bundledSchemasDir
|
|
14
|
+
: resolve(__dirname, "../../../../docs/spec/schemas");
|
|
15
|
+
function loadSchema(filename) {
|
|
16
|
+
const content = readFileSync(resolve(schemasDir, filename), "utf-8");
|
|
17
|
+
return JSON.parse(content);
|
|
18
|
+
}
|
|
19
|
+
const SCHEMA_MAP = {
|
|
20
|
+
"euconform.report.v1": { schemaFile: "report-v1.schema.json", type: "report.v1" },
|
|
21
|
+
"euconform.aibom.v1": { schemaFile: "aibom-v1.schema.json", type: "aibom.v1" },
|
|
22
|
+
"euconform.aibom.v1.1": { schemaFile: "aibom-v1.1.schema.json", type: "aibom.v1.1" },
|
|
23
|
+
"euconform.ci.v1": { schemaFile: "ci-v1.schema.json", type: "ci.v1" },
|
|
24
|
+
"euconform.bundle.v1": { schemaFile: "bundle-v1.schema.json", type: "bundle.v1" },
|
|
25
|
+
};
|
|
26
|
+
let ajvInstance = null;
|
|
27
|
+
function getAjv() {
|
|
28
|
+
if (!ajvInstance) {
|
|
29
|
+
ajvInstance = new Ajv2020({ allErrors: true, strict: false });
|
|
30
|
+
addFormats(ajvInstance);
|
|
31
|
+
}
|
|
32
|
+
return ajvInstance;
|
|
33
|
+
}
|
|
34
|
+
export function validate(data, _options) {
|
|
35
|
+
if (typeof data !== "object" || data === null) {
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
schemaType: "report.v1",
|
|
39
|
+
errors: [
|
|
40
|
+
{
|
|
41
|
+
path: "",
|
|
42
|
+
message: "Input must be a JSON object",
|
|
43
|
+
keyword: "type",
|
|
44
|
+
expected: "object",
|
|
45
|
+
received: typeof data,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const record = data;
|
|
51
|
+
const schemaVersion = record.schemaVersion;
|
|
52
|
+
if (typeof schemaVersion !== "string") {
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
schemaType: "report.v1",
|
|
56
|
+
errors: [
|
|
57
|
+
{
|
|
58
|
+
path: "",
|
|
59
|
+
message: `Missing or invalid schemaVersion field. Expected one of: ${Object.keys(SCHEMA_MAP).join(", ")}`,
|
|
60
|
+
keyword: "required",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const entry = SCHEMA_MAP[schemaVersion];
|
|
66
|
+
if (!entry) {
|
|
67
|
+
return {
|
|
68
|
+
valid: false,
|
|
69
|
+
schemaType: "report.v1",
|
|
70
|
+
errors: [
|
|
71
|
+
{
|
|
72
|
+
path: "/schemaVersion",
|
|
73
|
+
message: `Unknown schemaVersion "${schemaVersion}". Expected one of: ${Object.keys(SCHEMA_MAP).join(", ")}`,
|
|
74
|
+
keyword: "const",
|
|
75
|
+
expected: Object.keys(SCHEMA_MAP),
|
|
76
|
+
received: schemaVersion,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const ajv = getAjv();
|
|
82
|
+
let validateFn = ajv.getSchema(schemaVersion);
|
|
83
|
+
if (!validateFn) {
|
|
84
|
+
const schema = loadSchema(entry.schemaFile);
|
|
85
|
+
validateFn = ajv.compile({ ...schema, $id: schemaVersion });
|
|
86
|
+
}
|
|
87
|
+
const valid = validateFn(data);
|
|
88
|
+
if (valid) {
|
|
89
|
+
return { valid: true, schemaType: entry.type, errors: [] };
|
|
90
|
+
}
|
|
91
|
+
const errors = (validateFn.errors ?? []).map((err) => ({
|
|
92
|
+
path: err.instancePath ?? "",
|
|
93
|
+
message: err.message ?? "Validation error",
|
|
94
|
+
keyword: err.keyword ?? "unknown",
|
|
95
|
+
expected: err.params,
|
|
96
|
+
received: undefined,
|
|
97
|
+
}));
|
|
98
|
+
return { valid: false, schemaType: entry.type, errors };
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@euconform/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"license": "MIT OR EUPL-1.2",
|
|
5
5
|
"author": "Benedikt Hiepler",
|
|
6
6
|
"description": "Core engine for EU AI Act compliance, risk classification, and fairness metrics.",
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"./types": "./src/types.ts",
|
|
31
31
|
"./scanner": "./src/scanner/index.ts",
|
|
32
32
|
"./evidence": "./src/evidence/index.ts",
|
|
33
|
-
"./datasets": "./src/datasets/crows-pairs.ts"
|
|
33
|
+
"./datasets": "./src/datasets/crows-pairs.ts",
|
|
34
|
+
"./validation": "./src/validation/index.ts",
|
|
35
|
+
"./sbom": "./src/sbom/index.ts"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"@types/node": "^22.15.30",
|
|
@@ -41,6 +43,8 @@
|
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
43
45
|
"@xenova/transformers": "^2.17.2",
|
|
46
|
+
"ajv": "^8.18.0",
|
|
47
|
+
"ajv-formats": "^3.0.1",
|
|
44
48
|
"ignore": "^7.0.3"
|
|
45
49
|
},
|
|
46
50
|
"scripts": {
|
package/src/evidence/output.ts
CHANGED
package/src/evidence/types.ts
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/evidence/validate.ts
CHANGED
|
@@ -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(
|
|
11
|
-
|
|
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
|
|
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>;
|
|
@@ -289,6 +295,7 @@ export function validateEcefJsonDocument(
|
|
|
289
295
|
case "euconform.report.v1":
|
|
290
296
|
return validateScanReport(data);
|
|
291
297
|
case "euconform.aibom.v1":
|
|
298
|
+
case "euconform.aibom.v1.1":
|
|
292
299
|
return validateAiBillOfMaterials(data);
|
|
293
300
|
case "euconform.ci.v1":
|
|
294
301
|
return validateCiReport(data);
|
|
@@ -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";
|