From 563baad103e0e826a2d05985b21c209216a6f658 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 17 Apr 2026 21:36:24 +0200 Subject: [PATCH 01/20] add sbom-import source type for CycloneDX import support --- docs/spec/schemas/aibom-v1.schema.json | 2 +- packages/core/src/evidence/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/spec/schemas/aibom-v1.schema.json b/docs/spec/schemas/aibom-v1.schema.json index 507ba67..08e3a57 100644 --- a/docs/spec/schemas/aibom-v1.schema.json +++ b/docs/spec/schemas/aibom-v1.schema.json @@ -47,7 +47,7 @@ "name": { "type": "string" }, "version": { "type": "string" }, "source": { - "enum": ["package.json", "lock-file", "code", "config", "docs"] + "enum": ["package.json", "lock-file", "code", "config", "docs", "sbom-import"] } } } diff --git a/packages/core/src/evidence/types.ts b/packages/core/src/evidence/types.ts index 280c9af..50f3ec8 100644 --- a/packages/core/src/evidence/types.ts +++ b/packages/core/src/evidence/types.ts @@ -232,7 +232,7 @@ export interface BomComponent { kind: BomComponentKind; name: string; version?: string; - source: "package.json" | "lock-file" | "code" | "config" | "docs"; + source: "package.json" | "lock-file" | "code" | "config" | "docs" | "sbom-import"; } export interface AiBillOfMaterials { From 00fb87a141cddd45e9b3b3e6ee8a67b9e847c99b Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 17 Apr 2026 21:36:50 +0200 Subject: [PATCH 02/20] add AJV-based schema validator with auto-detection --- packages/core/package.json | 5 +- packages/core/src/index.ts | 8 + packages/core/src/validation/index.ts | 7 + .../core/src/validation/schema-validator.ts | 127 +++++++++++++ .../tests/validation/schema-validator.test.ts | 175 ++++++++++++++++++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/validation/index.ts create mode 100644 packages/core/src/validation/schema-validator.ts create mode 100644 packages/core/tests/validation/schema-validator.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 7ee6e94..ab21a15 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,8 @@ "./types": "./src/types.ts", "./scanner": "./src/scanner/index.ts", "./evidence": "./src/evidence/index.ts", - "./datasets": "./src/datasets/crows-pairs.ts" + "./datasets": "./src/datasets/crows-pairs.ts", + "./validation": "./src/validation/index.ts" }, "scripts": { "build": "tsc", @@ -40,6 +41,8 @@ }, "dependencies": { "@xenova/transformers": "^2.17.2", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "ignore": "^7.0.3" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7297b6c..16b8fbd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -174,3 +174,11 @@ export { calculateLatencyBias, validateCrowsPairsDataset, } from "./metrics"; + +// Validation +export { + validate, + type ValidationResult, + type ValidationError, + type SchemaType, +} from "./validation"; diff --git a/packages/core/src/validation/index.ts b/packages/core/src/validation/index.ts new file mode 100644 index 0000000..6a9748b --- /dev/null +++ b/packages/core/src/validation/index.ts @@ -0,0 +1,7 @@ +export { + validate, + type ValidationResult, + type ValidationError, + type SchemaType, + type ValidateOptions, +} from "./schema-validator"; diff --git a/packages/core/src/validation/schema-validator.ts b/packages/core/src/validation/schema-validator.ts new file mode 100644 index 0000000..573c2b3 --- /dev/null +++ b/packages/core/src/validation/schema-validator.ts @@ -0,0 +1,127 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import addFormats from "ajv-formats"; +import Ajv2020 from "ajv/dist/2020"; + +export interface ValidationError { + path: string; + message: string; + keyword: string; + expected?: unknown; + received?: unknown; +} + +export type SchemaType = "report.v1" | "aibom.v1" | "ci.v1" | "bundle.v1"; + +export interface ValidationResult { + valid: boolean; + schemaType: SchemaType; + errors: ValidationError[]; +} + +export interface ValidateOptions { + strict?: boolean; +} + +// Resolve schema directory relative to this file at runtime +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const schemasDir = resolve(__dirname, "../../../../docs/spec/schemas"); + +function loadSchema(filename: string): object { + const content = readFileSync(resolve(schemasDir, filename), "utf-8"); + return JSON.parse(content) as object; +} + +const SCHEMA_MAP: Record = { + "euconform.report.v1": { schemaFile: "report-v1.schema.json", type: "report.v1" }, + "euconform.aibom.v1": { schemaFile: "aibom-v1.schema.json", type: "aibom.v1" }, + "euconform.ci.v1": { schemaFile: "ci-v1.schema.json", type: "ci.v1" }, + "euconform.bundle.v1": { schemaFile: "bundle-v1.schema.json", type: "bundle.v1" }, +}; + +let ajvInstance: Ajv2020 | null = null; + +function getAjv(): Ajv2020 { + if (!ajvInstance) { + ajvInstance = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajvInstance); + } + return ajvInstance; +} + +export function validate(data: unknown, _options?: ValidateOptions): ValidationResult { + if (typeof data !== "object" || data === null) { + return { + valid: false, + schemaType: "report.v1", + errors: [ + { + path: "", + message: "Input must be a JSON object", + keyword: "type", + expected: "object", + received: typeof data, + }, + ], + }; + } + + const record = data as Record; + const schemaVersion = record.schemaVersion; + + if (typeof schemaVersion !== "string") { + return { + valid: false, + schemaType: "report.v1", + errors: [ + { + path: "", + message: `Missing or invalid schemaVersion field. Expected one of: ${Object.keys(SCHEMA_MAP).join(", ")}`, + keyword: "required", + }, + ], + }; + } + + const entry = SCHEMA_MAP[schemaVersion]; + if (!entry) { + return { + valid: false, + schemaType: "report.v1", + errors: [ + { + path: "/schemaVersion", + message: `Unknown schemaVersion "${schemaVersion}". Expected one of: ${Object.keys(SCHEMA_MAP).join(", ")}`, + keyword: "const", + expected: Object.keys(SCHEMA_MAP), + received: schemaVersion, + }, + ], + }; + } + + const ajv = getAjv(); + let validateFn = ajv.getSchema(schemaVersion); + if (!validateFn) { + const schema = loadSchema(entry.schemaFile); + validateFn = ajv.compile({ ...schema, $id: schemaVersion }); + } + + const valid = validateFn(data) as boolean; + + if (valid) { + return { valid: true, schemaType: entry.type, errors: [] }; + } + + const errors: ValidationError[] = (validateFn.errors ?? []).map((err) => ({ + path: err.instancePath ?? "", + message: err.message ?? "Validation error", + keyword: err.keyword ?? "unknown", + expected: err.params, + received: undefined, + })); + + return { valid: false, schemaType: entry.type, errors }; +} diff --git a/packages/core/tests/validation/schema-validator.test.ts b/packages/core/tests/validation/schema-validator.test.ts new file mode 100644 index 0000000..411f7ef --- /dev/null +++ b/packages/core/tests/validation/schema-validator.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; +import { validate } from "../../src/validation/schema-validator"; + +describe("schema-validator", () => { + describe("auto-detection", () => { + it("detects report.v1 schema type", () => { + const result = validate({ + schemaVersion: "euconform.report.v1", + generatedAt: "2026-01-01T00:00:00Z", + tool: { name: "euconform", version: "1.0.0" }, + target: { + name: "test", + rootPath: "/test", + repoType: "unknown", + detectedStack: [], + }, + aiFootprint: { + usesAI: false, + inferenceModes: [], + providerHints: [], + ragHints: [], + }, + complianceSignals: { + disclosure: { status: "unknown", confidence: "low", evidence: [] }, + biasTesting: { status: "unknown", confidence: "low", evidence: [] }, + reportingExports: { status: "unknown", confidence: "low", evidence: [] }, + loggingMonitoring: { status: "unknown", confidence: "low", evidence: [] }, + humanOversight: { status: "unknown", confidence: "low", evidence: [] }, + dataGovernance: { status: "unknown", confidence: "low", evidence: [] }, + incidentReporting: { status: "unknown", confidence: "low", evidence: [] }, + }, + assessmentHints: { + possibleModes: [], + riskIndicators: [], + gpaiIndicators: [], + openQuestions: [], + }, + gaps: [], + recommendationSummary: [], + }); + expect(result.schemaType).toBe("report.v1"); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("detects aibom.v1 schema type", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1", + generatedAt: "2026-01-01T00:00:00Z", + project: { name: "test", rootPath: "/test" }, + components: [], + complianceCapabilities: { + biasEvaluation: false, + jsonExport: true, + pdfExport: false, + loggingInfrastructure: false, + humanReviewFlow: false, + incidentHandling: false, + }, + }); + expect(result.schemaType).toBe("aibom.v1"); + expect(result.valid).toBe(true); + }); + + it("returns error for missing schemaVersion", () => { + const result = validate({ foo: "bar" }); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain("schemaVersion"); + }); + + it("returns error for unknown schemaVersion", () => { + const result = validate({ schemaVersion: "euconform.unknown.v9" }); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain("unknown"); + }); + }); + + describe("validation errors", () => { + it("reports missing required fields", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1", + }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + const paths = result.errors.map((e) => e.path); + expect(paths).toContain(""); + }); + + it("reports invalid enum values", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1", + generatedAt: "2026-01-01T00:00:00Z", + project: { name: "test", rootPath: "/test" }, + components: [ + { + id: "bad:component", + kind: "invalid-kind", + name: "Bad", + source: "package.json", + }, + ], + complianceCapabilities: { + biasEvaluation: false, + jsonExport: false, + pdfExport: false, + loggingInfrastructure: false, + humanReviewFlow: false, + incidentHandling: false, + }, + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.keyword === "enum")).toBe(true); + }); + + it("reports additional properties", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1", + generatedAt: "2026-01-01T00:00:00Z", + project: { name: "test", rootPath: "/test" }, + components: [], + complianceCapabilities: { + biasEvaluation: false, + jsonExport: false, + pdfExport: false, + loggingInfrastructure: false, + humanReviewFlow: false, + incidentHandling: false, + }, + extraField: "should fail", + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.keyword === "additionalProperties")).toBe(true); + }); + + it("accepts sbom-import as valid source", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1", + generatedAt: "2026-01-01T00:00:00Z", + project: { name: "test", rootPath: "/test" }, + components: [ + { + id: "ai-framework:torch", + kind: "ai-framework", + name: "torch", + version: "2.0.0", + source: "sbom-import", + }, + ], + complianceCapabilities: { + biasEvaluation: false, + jsonExport: false, + pdfExport: false, + loggingInfrastructure: false, + humanReviewFlow: false, + incidentHandling: false, + }, + }); + expect(result.valid).toBe(true); + }); + }); + + describe("ValidationResult structure", () => { + it("returns structured errors with path, message, keyword", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1", + }); + expect(result.valid).toBe(false); + for (const error of result.errors) { + expect(error).toHaveProperty("path"); + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("keyword"); + } + }); + }); +}); From 4aa4fa785520c50691a194d3a5bf7e3059450279 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 17 Apr 2026 21:37:04 +0200 Subject: [PATCH 03/20] add euconform validate CLI command --- packages/cli/src/commands/validate.ts | 114 ++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/tsup.config.ts | 14 +++ .../core/src/validation/schema-validator.ts | 11 +- 4 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/validate.ts diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 0000000..c0e8e2d --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,114 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { validate } from "@euconform/core/validation"; +import { defineCommand } from "citty"; +import consola from "consola"; + +export default defineCommand({ + meta: { + name: "validate", + description: "Validate EuConform JSON files against published schemas", + }, + args: { + path: { + type: "positional", + required: true, + description: "Path to a EuConform JSON file or directory containing EuConform JSON files", + }, + json: { + type: "boolean", + default: false, + description: "Output results as JSON", + }, + }, + async run({ args }) { + const inputPath = resolve(args.path as string); + const files = await resolveInputFiles(inputPath); + + if (files.length === 0) { + consola.error("No EuConform JSON files found at the given path."); + process.exit(2); + } + + const results: Array<{ + file: string; + result: ReturnType; + }> = []; + + for (const file of files) { + let data: unknown; + try { + const content = await readFile(file, "utf-8"); + data = JSON.parse(content); + } catch { + results.push({ + file, + result: { + valid: false, + schemaType: "report.v1", + errors: [ + { + path: "", + message: `Failed to read or parse file: ${file}`, + keyword: "parse", + }, + ], + }, + }); + continue; + } + results.push({ file, result: validate(data) }); + } + + if (args.json) { + process.stdout.write( + `${JSON.stringify( + results.map((r) => ({ + file: r.file, + valid: r.result.valid, + schemaType: r.result.schemaType, + errors: r.result.errors, + })), + null, + 2 + )}\n` + ); + } else { + for (const { file, result } of results) { + const shortName = file.split("/").pop() ?? file; + if (result.valid) { + consola.success(`${shortName} \u2014 valid (euconform.${result.schemaType})`); + } else { + consola.error( + `${shortName} \u2014 ${result.errors.length} error(s) (euconform.${result.schemaType})` + ); + for (const err of result.errors) { + consola.log(` ${err.path || "/"} \u2014 ${err.message}`); + } + } + } + } + + const hasErrors = results.some((r) => !r.result.valid); + process.exit(hasErrors ? 1 : 0); + }, +}); + +async function resolveInputFiles(inputPath: string): Promise { + const info = await stat(inputPath).catch(() => null); + if (!info) return []; + + if (info.isFile()) { + return inputPath.endsWith(".json") ? [inputPath] : []; + } + + if (info.isDirectory()) { + const entries = await readdir(inputPath); + return entries + .filter((name) => name.startsWith("euconform.") && name.endsWith(".json")) + .map((name) => join(inputPath, name)) + .sort(); + } + + return []; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1d70b2c..9f95780 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ import { defineCommand, runMain } from "citty"; import biasCommand from "./commands/bias"; import scanCommand from "./commands/scan"; +import validateCommand from "./commands/validate"; import verifyCommand from "./commands/verify"; const main = defineCommand({ @@ -12,6 +13,7 @@ const main = defineCommand({ }, subCommands: { scan: scanCommand, + validate: validateCommand, verify: verifyCommand, bias: biasCommand, }, diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index e8426c4..7926fe4 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,5 +1,10 @@ +import { copyFile, mkdir, readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; import { defineConfig } from "tsup"; +const schemasSource = resolve(__dirname, "../../docs/spec/schemas"); +const schemasDest = resolve(__dirname, "dist/schemas"); + export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], @@ -8,4 +13,13 @@ export default defineConfig({ clean: true, noExternal: ["@euconform/core"], external: ["sharp", "@xenova/transformers", "onnxruntime-node"], + async onSuccess() { + await mkdir(schemasDest, { recursive: true }); + const files = await readdir(schemasSource); + for (const file of files) { + if (file.endsWith(".json")) { + await copyFile(join(schemasSource, file), join(schemasDest, file)); + } + } + }, }); diff --git a/packages/core/src/validation/schema-validator.ts b/packages/core/src/validation/schema-validator.ts index 573c2b3..0deb696 100644 --- a/packages/core/src/validation/schema-validator.ts +++ b/packages/core/src/validation/schema-validator.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import addFormats from "ajv-formats"; @@ -24,10 +24,15 @@ export interface ValidateOptions { strict?: boolean; } -// Resolve schema directory relative to this file at runtime +// Resolve schema directory relative to this file at runtime. +// When bundled into packages/cli/dist, schemas are copied to dist/schemas/. +// When running from source at packages/core/src/validation/, schemas are at ../../../../docs/spec/schemas. const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const schemasDir = resolve(__dirname, "../../../../docs/spec/schemas"); +const _bundledSchemasDir = resolve(__dirname, "schemas"); +const schemasDir = existsSync(_bundledSchemasDir) + ? _bundledSchemasDir + : resolve(__dirname, "../../../../docs/spec/schemas"); function loadSchema(filename: string): object { const content = readFileSync(resolve(schemasDir, filename), "utf-8"); From 2b2bbe45a2cccc4abb0e97e223de69099b2b83b1 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 17 Apr 2026 21:37:22 +0200 Subject: [PATCH 04/20] add CycloneDX types, AI package registry, and component mapper --- packages/core/src/sbom/component-mapper.ts | 54 ++++++ packages/core/src/sbom/known-packages.ts | 97 +++++++++++ packages/core/src/sbom/types.ts | 34 ++++ .../core/tests/sbom/component-mapper.test.ts | 164 ++++++++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 packages/core/src/sbom/component-mapper.ts create mode 100644 packages/core/src/sbom/known-packages.ts create mode 100644 packages/core/src/sbom/types.ts create mode 100644 packages/core/tests/sbom/component-mapper.test.ts diff --git a/packages/core/src/sbom/component-mapper.ts b/packages/core/src/sbom/component-mapper.ts new file mode 100644 index 0000000..81d4148 --- /dev/null +++ b/packages/core/src/sbom/component-mapper.ts @@ -0,0 +1,54 @@ +import type { BomComponentKind } from "../evidence/types"; +import { lookupKnownPackage } from "./known-packages"; +import type { CycloneDxComponent } from "./types"; + +export interface ComponentMapping { + kind: BomComponentKind; + source: "sbom-import"; + confidence: "high" | "medium"; +} + +const CYCLONEDX_TYPE_MAP: Record< + string, + { kind: BomComponentKind; confidence: "high" | "medium" } +> = { + "machine-learning-model": { kind: "model", confidence: "high" }, + data: { kind: "dataset", confidence: "medium" }, + platform: { kind: "inference-provider", confidence: "medium" }, + service: { kind: "inference-provider", confidence: "medium" }, +}; + +/** + * Map a CycloneDX component to an EuConform aibom component kind. + * + * Three-tier detection (descending priority): + * 1. CycloneDX ML-BOM extensions (modelCard, data) + * 2. CycloneDX component type mapping + * 3. Known AI package registry lookup + * + * Returns null if the component is not AI-relevant. + */ +export function mapComponent(component: CycloneDxComponent): ComponentMapping | null { + // Tier 1: ML-BOM extensions + if (component.modelCard != null) { + return { kind: "model", source: "sbom-import", confidence: "high" }; + } + + if (Array.isArray(component.data) && component.data.some((d) => d.type === "dataset")) { + return { kind: "dataset", source: "sbom-import", confidence: "high" }; + } + + // Tier 2: CycloneDX component type + const typeMapping = CYCLONEDX_TYPE_MAP[component.type]; + if (typeMapping) { + return { kind: typeMapping.kind, source: "sbom-import", confidence: typeMapping.confidence }; + } + + // Tier 3: Known-package registry + const knownKind = lookupKnownPackage(component.name); + if (knownKind) { + return { kind: knownKind, source: "sbom-import", confidence: "high" }; + } + + return null; +} diff --git a/packages/core/src/sbom/known-packages.ts b/packages/core/src/sbom/known-packages.ts new file mode 100644 index 0000000..a9fd683 --- /dev/null +++ b/packages/core/src/sbom/known-packages.ts @@ -0,0 +1,97 @@ +import type { BomComponentKind } from "../evidence/types"; + +export const KNOWN_AI_PACKAGES: Record = { + // Models / model runtimes + "llama.cpp": "model", + "llama-cpp-python": "model", + "whisper.cpp": "model", + ggml: "model", + ctransformers: "model", + + // AI Frameworks + transformers: "ai-framework", + torch: "ai-framework", + pytorch: "ai-framework", + tensorflow: "ai-framework", + "tensorflow-gpu": "ai-framework", + jax: "ai-framework", + keras: "ai-framework", + "pytorch-lightning": "ai-framework", + "scikit-learn": "ai-framework", + sklearn: "ai-framework", + xgboost: "ai-framework", + lightgbm: "ai-framework", + spacy: "ai-framework", + "huggingface-hub": "ai-framework", + diffusers: "ai-framework", + accelerate: "ai-framework", + peft: "ai-framework", + trl: "ai-framework", + onnxruntime: "ai-framework", + "onnxruntime-gpu": "ai-framework", + mlflow: "ai-framework", + + // Embeddings + "sentence-transformers": "embedding", + fastembed: "embedding", + "openai-embeddings": "embedding", + "cohere-embed": "embedding", + + // Inference Providers + openai: "inference-provider", + anthropic: "inference-provider", + ollama: "inference-provider", + vllm: "inference-provider", + "together-ai": "inference-provider", + replicate: "inference-provider", + groq: "inference-provider", + mistralai: "inference-provider", + cohere: "inference-provider", + "google-generativeai": "inference-provider", + "@google/generative-ai": "inference-provider", + ai21: "inference-provider", + + // Vector Stores + pinecone: "vector-store", + "pinecone-client": "vector-store", + weaviate: "vector-store", + "weaviate-client": "vector-store", + qdrant: "vector-store", + "qdrant-client": "vector-store", + milvus: "vector-store", + pymilvus: "vector-store", + chromadb: "vector-store", + "chromadb-client": "vector-store", + pgvector: "vector-store", + faiss: "vector-store", + "faiss-cpu": "vector-store", + "faiss-gpu": "vector-store", + + // Datasets + datasets: "dataset", + + // Tools / Orchestration + langchain: "tool", + "langchain-core": "tool", + "langchain-community": "tool", + llamaindex: "tool", + "llama-index": "tool", + "semantic-kernel": "tool", + autogen: "tool", + crewai: "tool", + dspy: "tool", + "haystack-ai": "tool", + guidance: "tool", + guardrails: "tool", + "guardrails-ai": "tool", + lmql: "tool", + outlines: "tool", +}; + +/** + * Look up a package name in the known AI packages registry. + * Returns the BomComponentKind if found, null otherwise. + */ +export function lookupKnownPackage(name: string): BomComponentKind | null { + return KNOWN_AI_PACKAGES[name.toLowerCase()] ?? null; +} diff --git a/packages/core/src/sbom/types.ts b/packages/core/src/sbom/types.ts new file mode 100644 index 0000000..47d9437 --- /dev/null +++ b/packages/core/src/sbom/types.ts @@ -0,0 +1,34 @@ +/** + * Minimal CycloneDX types covering fields relevant to AI component detection. + * Supports CycloneDX JSON format versions 1.4, 1.5, 1.6. + */ + +export interface CycloneDxBom { + bomFormat: "CycloneDX"; + specVersion: string; + version?: number; + metadata?: CycloneDxMetadata; + components?: CycloneDxComponent[]; +} + +export interface CycloneDxMetadata { + timestamp?: string; + tools?: Array<{ name?: string; version?: string }>; + component?: CycloneDxComponent; +} + +export interface CycloneDxComponent { + type: string; + name: string; + version?: string; + purl?: string; + group?: string; + description?: string; + scope?: "required" | "optional" | "excluded"; + properties?: Array<{ name: string; value: string }>; + /** CycloneDX ML-BOM extensions (1.5+) */ + modelCard?: unknown; + data?: Array<{ type?: string }>; +} + +export const SUPPORTED_SPEC_VERSIONS = ["1.4", "1.5", "1.6"] as const; diff --git a/packages/core/tests/sbom/component-mapper.test.ts b/packages/core/tests/sbom/component-mapper.test.ts new file mode 100644 index 0000000..474d028 --- /dev/null +++ b/packages/core/tests/sbom/component-mapper.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { mapComponent } from "../../src/sbom/component-mapper"; +import { lookupKnownPackage } from "../../src/sbom/known-packages"; +import type { CycloneDxComponent } from "../../src/sbom/types"; + +describe("known-packages", () => { + it("recognizes torch as ai-framework", () => { + expect(lookupKnownPackage("torch")).toBe("ai-framework"); + }); + + it("recognizes openai as inference-provider", () => { + expect(lookupKnownPackage("openai")).toBe("inference-provider"); + }); + + it("recognizes chromadb as vector-store", () => { + expect(lookupKnownPackage("chromadb")).toBe("vector-store"); + }); + + it("recognizes langchain as tool", () => { + expect(lookupKnownPackage("langchain")).toBe("tool"); + }); + + it("returns null for unknown packages", () => { + expect(lookupKnownPackage("express")).toBeNull(); + expect(lookupKnownPackage("lodash")).toBeNull(); + expect(lookupKnownPackage("react")).toBeNull(); + }); + + it("is case-insensitive", () => { + expect(lookupKnownPackage("Transformers")).toBe("ai-framework"); + expect(lookupKnownPackage("OPENAI")).toBe("inference-provider"); + }); +}); + +describe("component-mapper", () => { + describe("tier 1: ML-BOM extensions", () => { + it("maps component with modelCard to model", () => { + const comp: CycloneDxComponent = { + type: "library", + name: "my-custom-model", + modelCard: { modelParameters: {} }, + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("model"); + expect(result!.confidence).toBe("high"); + }); + + it("maps component with data array containing ml type to dataset", () => { + const comp: CycloneDxComponent = { + type: "library", + name: "training-data", + data: [{ type: "dataset" }], + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("dataset"); + expect(result!.confidence).toBe("high"); + }); + }); + + describe("tier 2: CycloneDX component type", () => { + it("maps machine-learning-model to model", () => { + const comp: CycloneDxComponent = { + type: "machine-learning-model", + name: "llama-3", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("model"); + expect(result!.confidence).toBe("high"); + }); + + it("maps data type to dataset", () => { + const comp: CycloneDxComponent = { + type: "data", + name: "training-corpus", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("dataset"); + expect(result!.confidence).toBe("medium"); + }); + + it("maps platform type to inference-provider", () => { + const comp: CycloneDxComponent = { + type: "platform", + name: "inference-platform", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("inference-provider"); + expect(result!.confidence).toBe("medium"); + }); + + it("maps service type to inference-provider", () => { + const comp: CycloneDxComponent = { + type: "service", + name: "model-api", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("inference-provider"); + expect(result!.confidence).toBe("medium"); + }); + }); + + describe("tier 3: known-package registry", () => { + it("maps known framework-type package to ai-framework", () => { + const comp: CycloneDxComponent = { + type: "framework", + name: "transformers", + version: "4.38.0", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("ai-framework"); + expect(result!.confidence).toBe("high"); + }); + + it("maps known library-type package", () => { + const comp: CycloneDxComponent = { + type: "library", + name: "openai", + version: "1.12.0", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("inference-provider"); + expect(result!.confidence).toBe("high"); + }); + }); + + describe("non-AI components", () => { + it("returns null for unknown library", () => { + const comp: CycloneDxComponent = { + type: "library", + name: "express", + version: "4.18.0", + }; + expect(mapComponent(comp)).toBeNull(); + }); + + it("returns null for unknown framework", () => { + const comp: CycloneDxComponent = { + type: "framework", + name: "react", + version: "18.2.0", + }; + expect(mapComponent(comp)).toBeNull(); + }); + }); + + describe("source field", () => { + it("always sets source to sbom-import", () => { + const comp: CycloneDxComponent = { + type: "machine-learning-model", + name: "test-model", + }; + const result = mapComponent(comp); + expect(result!.source).toBe("sbom-import"); + }); + }); +}); From 2592b4b0d7801f1e162af16bca094ab18bebd5b2 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 17 Apr 2026 21:37:37 +0200 Subject: [PATCH 05/20] add CycloneDX SBOM import with AI component filtering --- packages/core/package.json | 3 +- packages/core/src/index.ts | 7 + packages/core/src/sbom/cyclonedx-import.ts | 124 +++++++++++++++++ packages/core/src/sbom/index.ts | 10 ++ .../tests/fixtures/cyclonedx-minimal.json | 13 ++ .../core/tests/fixtures/cyclonedx-mixed.json | 17 +++ .../core/tests/fixtures/cyclonedx-ml-bom.json | 25 ++++ .../core/tests/sbom/cyclonedx-import.test.ts | 125 ++++++++++++++++++ 8 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/sbom/cyclonedx-import.ts create mode 100644 packages/core/src/sbom/index.ts create mode 100644 packages/core/tests/fixtures/cyclonedx-minimal.json create mode 100644 packages/core/tests/fixtures/cyclonedx-mixed.json create mode 100644 packages/core/tests/fixtures/cyclonedx-ml-bom.json create mode 100644 packages/core/tests/sbom/cyclonedx-import.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index ab21a15..549e926 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,7 +24,8 @@ "./scanner": "./src/scanner/index.ts", "./evidence": "./src/evidence/index.ts", "./datasets": "./src/datasets/crows-pairs.ts", - "./validation": "./src/validation/index.ts" + "./validation": "./src/validation/index.ts", + "./sbom": "./src/sbom/index.ts" }, "scripts": { "build": "tsc", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 16b8fbd..a4725d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -182,3 +182,10 @@ export { type ValidationError, type SchemaType, } from "./validation"; + +// SBOM +export { + importCycloneDx, + type CycloneDxImportResult, + type ImportSummary, +} from "./sbom"; diff --git a/packages/core/src/sbom/cyclonedx-import.ts b/packages/core/src/sbom/cyclonedx-import.ts new file mode 100644 index 0000000..eddaa62 --- /dev/null +++ b/packages/core/src/sbom/cyclonedx-import.ts @@ -0,0 +1,124 @@ +import type { AiBillOfMaterials, BomComponent } from "../evidence/types"; +import type { ValidationResult } from "../validation/schema-validator"; +import { validate } from "../validation/schema-validator"; +import { mapComponent } from "./component-mapper"; +import type { CycloneDxBom } from "./types"; +import { SUPPORTED_SPEC_VERSIONS } from "./types"; + +export interface ImportSummary { + totalComponents: number; + aiRelevant: number; + skipped: number; + byKind: Record; + warnings: ImportWarning[]; +} + +export interface ImportWarning { + component: string; + message: string; +} + +export interface ImportOptions { + scope?: "all" | "production"; +} + +export interface CycloneDxImportResult { + aibom: AiBillOfMaterials; + summary: ImportSummary; + validation: ValidationResult; +} + +export function importCycloneDx(sbom: unknown, options?: ImportOptions): CycloneDxImportResult { + const bom = parseBom(sbom); + const scope = options?.scope ?? "all"; + + const components = bom.components ?? []; + const warnings: ImportWarning[] = []; + const mapped: BomComponent[] = []; + const byKind: Record = {}; + + for (const comp of components) { + if (scope === "production" && comp.scope === "optional") { + continue; + } + + const mapping = mapComponent(comp); + if (!mapping) continue; + + if (!comp.version) { + warnings.push({ + component: comp.name, + message: "Missing version field", + }); + } + + const bomComponent: BomComponent = { + id: `${mapping.kind}:${comp.name}`, + kind: mapping.kind, + name: comp.name, + ...(comp.version ? { version: comp.version } : {}), + source: "sbom-import", + }; + + mapped.push(bomComponent); + byKind[mapping.kind] = (byKind[mapping.kind] ?? 0) + 1; + } + + const totalAfterScope = + scope === "production" + ? components.filter((c) => c.scope !== "optional").length + : components.length; + + const aibom: AiBillOfMaterials = { + schemaVersion: "euconform.aibom.v1", + generatedAt: new Date().toISOString(), + project: { + name: "sbom-import", + rootPath: ".", + }, + components: mapped, + complianceCapabilities: { + biasEvaluation: false, + jsonExport: true, + pdfExport: false, + loggingInfrastructure: false, + humanReviewFlow: false, + incidentHandling: false, + }, + }; + + const validation = validate(aibom); + + return { + aibom, + summary: { + totalComponents: totalAfterScope, + aiRelevant: mapped.length, + skipped: totalAfterScope - mapped.length, + byKind, + warnings, + }, + validation, + }; +} + +function parseBom(sbom: unknown): CycloneDxBom { + if (typeof sbom !== "object" || sbom === null) { + throw new Error("Input must be a JSON object"); + } + + const record = sbom as Record; + + if (record.bomFormat !== "CycloneDX") { + throw new Error(`Invalid bomFormat: expected "CycloneDX", got "${String(record.bomFormat)}"`); + } + + const specVersion = String(record.specVersion ?? ""); + if (!SUPPORTED_SPEC_VERSIONS.includes(specVersion as (typeof SUPPORTED_SPEC_VERSIONS)[number])) { + throw new Error( + `Unsupported specVersion "${specVersion}". Supported: ${SUPPORTED_SPEC_VERSIONS.join(", ")}` + ); + } + + return sbom as CycloneDxBom; +} diff --git a/packages/core/src/sbom/index.ts b/packages/core/src/sbom/index.ts new file mode 100644 index 0000000..651fa3b --- /dev/null +++ b/packages/core/src/sbom/index.ts @@ -0,0 +1,10 @@ +export { + importCycloneDx, + type CycloneDxImportResult, + type ImportSummary, + type ImportWarning, + type ImportOptions, +} from "./cyclonedx-import"; +export { mapComponent, type ComponentMapping } from "./component-mapper"; +export { lookupKnownPackage, KNOWN_AI_PACKAGES } from "./known-packages"; +export type { CycloneDxBom, CycloneDxComponent } from "./types"; diff --git a/packages/core/tests/fixtures/cyclonedx-minimal.json b/packages/core/tests/fixtures/cyclonedx-minimal.json new file mode 100644 index 0000000..a16404a --- /dev/null +++ b/packages/core/tests/fixtures/cyclonedx-minimal.json @@ -0,0 +1,13 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "components": [ + { + "type": "machine-learning-model", + "name": "llama-3.2-1b", + "version": "1.0.0", + "purl": "pkg:generic/llama-3.2-1b@1.0.0" + } + ] +} diff --git a/packages/core/tests/fixtures/cyclonedx-mixed.json b/packages/core/tests/fixtures/cyclonedx-mixed.json new file mode 100644 index 0000000..8f706e6 --- /dev/null +++ b/packages/core/tests/fixtures/cyclonedx-mixed.json @@ -0,0 +1,17 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "components": [ + { "type": "library", "name": "express", "version": "4.18.2" }, + { "type": "library", "name": "lodash", "version": "4.17.21" }, + { "type": "library", "name": "openai", "version": "1.12.0" }, + { "type": "framework", "name": "transformers", "version": "4.38.0" }, + { "type": "library", "name": "chromadb", "version": "0.4.22" }, + { "type": "library", "name": "langchain", "version": "0.1.0" }, + { "type": "machine-learning-model", "name": "whisper-large-v3", "version": "1.0.0" }, + { "type": "library", "name": "typescript", "version": "5.3.0" }, + { "type": "library", "name": "react", "version": "18.2.0" }, + { "type": "library", "name": "axios", "version": "1.6.0" } + ] +} diff --git a/packages/core/tests/fixtures/cyclonedx-ml-bom.json b/packages/core/tests/fixtures/cyclonedx-ml-bom.json new file mode 100644 index 0000000..972ef1a --- /dev/null +++ b/packages/core/tests/fixtures/cyclonedx-ml-bom.json @@ -0,0 +1,25 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "components": [ + { + "type": "library", + "name": "custom-bert-model", + "version": "2.0.0", + "modelCard": { + "modelParameters": { + "approach": { "type": "supervised" }, + "task": "text-classification" + } + } + }, + { + "type": "library", + "name": "sentiment-dataset", + "version": "1.0.0", + "data": [{ "type": "dataset", "name": "imdb-reviews" }] + }, + { "type": "library", "name": "numpy", "version": "1.24.0" } + ] +} diff --git a/packages/core/tests/sbom/cyclonedx-import.test.ts b/packages/core/tests/sbom/cyclonedx-import.test.ts new file mode 100644 index 0000000..4a17502 --- /dev/null +++ b/packages/core/tests/sbom/cyclonedx-import.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { importCycloneDx } from "../../src/sbom/cyclonedx-import"; +import minimalBom from "../fixtures/cyclonedx-minimal.json"; +import mixedBom from "../fixtures/cyclonedx-mixed.json"; +import mlBom from "../fixtures/cyclonedx-ml-bom.json"; + +describe("cyclonedx-import", () => { + describe("basic import", () => { + it("imports a minimal CycloneDX BOM", () => { + const result = importCycloneDx(minimalBom); + expect(result.aibom.schemaVersion).toBe("euconform.aibom.v1"); + expect(result.aibom.components).toHaveLength(1); + expect(result.aibom.components[0].kind).toBe("model"); + expect(result.aibom.components[0].name).toBe("llama-3.2-1b"); + expect(result.aibom.components[0].source).toBe("sbom-import"); + }); + + it("produces a valid aibom", () => { + const result = importCycloneDx(minimalBom); + expect(result.validation.valid).toBe(true); + }); + }); + + describe("filtering", () => { + it("filters AI-relevant components from mixed BOM", () => { + const result = importCycloneDx(mixedBom); + expect(result.summary.totalComponents).toBe(10); + expect(result.summary.aiRelevant).toBe(5); + expect(result.summary.skipped).toBe(5); + }); + + it("correctly maps component kinds in mixed BOM", () => { + const result = importCycloneDx(mixedBom); + const kinds = result.aibom.components.map((c) => c.kind).sort(); + expect(kinds).toEqual([ + "ai-framework", + "inference-provider", + "model", + "tool", + "vector-store", + ]); + }); + + it("provides byKind summary", () => { + const result = importCycloneDx(mixedBom); + expect(result.summary.byKind.model).toBe(1); + expect(result.summary.byKind["ai-framework"]).toBe(1); + expect(result.summary.byKind["inference-provider"]).toBe(1); + expect(result.summary.byKind.tool).toBe(1); + expect(result.summary.byKind["vector-store"]).toBe(1); + }); + }); + + describe("ML-BOM extensions", () => { + it("detects modelCard extension", () => { + const result = importCycloneDx(mlBom); + const model = result.aibom.components.find((c) => c.name === "custom-bert-model"); + expect(model).toBeDefined(); + expect(model?.kind).toBe("model"); + }); + + it("detects data extension as dataset", () => { + const result = importCycloneDx(mlBom); + const dataset = result.aibom.components.find((c) => c.name === "sentiment-dataset"); + expect(dataset).toBeDefined(); + expect(dataset?.kind).toBe("dataset"); + }); + + it("skips non-AI components even in ML BOM", () => { + const result = importCycloneDx(mlBom); + expect(result.summary.totalComponents).toBe(3); + expect(result.summary.aiRelevant).toBe(2); + expect(result.summary.skipped).toBe(1); + }); + }); + + describe("input validation", () => { + it("rejects non-CycloneDX input", () => { + expect(() => importCycloneDx({ foo: "bar" })).toThrow("bomFormat"); + }); + + it("rejects unsupported specVersion", () => { + expect(() => + importCycloneDx({ + bomFormat: "CycloneDX", + specVersion: "1.0", + components: [], + }) + ).toThrow("specVersion"); + }); + + it("handles BOM with no components", () => { + const result = importCycloneDx({ + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [], + }); + expect(result.aibom.components).toEqual([]); + expect(result.summary.totalComponents).toBe(0); + }); + }); + + describe("scope filtering", () => { + it("excludes optional-scope components when scope is production", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [ + { type: "machine-learning-model", name: "prod-model", scope: "required" }, + { type: "machine-learning-model", name: "dev-model", scope: "optional" }, + ], + }; + const result = importCycloneDx(bom, { scope: "production" }); + expect(result.aibom.components).toHaveLength(1); + expect(result.aibom.components[0].name).toBe("prod-model"); + }); + }); + + describe("component ID generation", () => { + it("generates id as kind:name", () => { + const result = importCycloneDx(minimalBom); + expect(result.aibom.components[0].id).toBe("model:llama-3.2-1b"); + }); + }); +}); From bac03404daf4a526a7d2a44b9777ddf6ba8b9da6 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 17 Apr 2026 21:38:00 +0200 Subject: [PATCH 06/20] add euconform import CLI command for CycloneDX SBOM --- packages/cli/src/commands/import.ts | 117 ++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 2 files changed, 119 insertions(+) create mode 100644 packages/cli/src/commands/import.ts diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts new file mode 100644 index 0000000..5c16be5 --- /dev/null +++ b/packages/cli/src/commands/import.ts @@ -0,0 +1,117 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { importCycloneDx } from "@euconform/core/sbom"; +import { defineCommand } from "citty"; +import consola from "consola"; + +export default defineCommand({ + meta: { + name: "import", + description: + "Import a CycloneDX SBOM and extract AI-relevant components into an EuConform AI BOM", + }, + args: { + path: { + type: "positional", + required: true, + description: "Path to a CycloneDX JSON SBOM file", + }, + scope: { + type: "string", + default: "all", + description: + 'Component scope filter: "all" includes everything, "production" excludes optional-scope components', + }, + json: { + type: "boolean", + default: false, + description: "Output results as JSON to stdout", + }, + output: { + type: "string", + alias: "o", + default: ".euconform", + description: "Output directory for the generated aibom file", + }, + }, + async run({ args }) { + const inputPath = resolve(args.path as string); + const outputDir = resolve(args.output as string); + const scope = (args.scope as string) === "production" ? "production" : "all"; + + let data: unknown; + try { + const content = await readFile(inputPath, "utf-8"); + data = JSON.parse(content); + } catch { + consola.error(`Failed to read or parse file: ${inputPath}`); + process.exit(2); + } + + let result: ReturnType; + try { + result = importCycloneDx(data, { scope }); + } catch (error) { + consola.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + if (args.json) { + process.stdout.write( + `${JSON.stringify( + { + aibom: result.aibom, + summary: result.summary, + validation: { + valid: result.validation.valid, + errors: result.validation.errors, + }, + }, + null, + 2 + )}\n` + ); + return; + } + + // Human-readable output + const fileName = basename(inputPath); + consola.log(""); + consola.log("CycloneDX Import"); + consola.log("\u2500".repeat(40)); + consola.log(`Source: ${fileName} (CycloneDX)`); + consola.log( + `Components: ${result.summary.totalComponents} total \u2192 ${result.summary.aiRelevant} AI-relevant, ${result.summary.skipped} skipped` + ); + consola.log(""); + + for (const [kind, count] of Object.entries(result.summary.byKind).sort((a, b) => b[1] - a[1])) { + const names = result.aibom.components + .filter((c) => c.kind === kind) + .map((c) => c.name) + .join(", "); + consola.log(` ${kind.padEnd(20)} ${String(count).padStart(2)} (${names})`); + } + + if (result.summary.warnings.length > 0) { + consola.log(""); + for (const w of result.summary.warnings) { + consola.warn(`${w.component}: ${w.message}`); + } + } + + // Write output + await mkdir(outputDir, { recursive: true }); + const aibomPath = join(outputDir, "euconform.aibom.json"); + await writeFile(aibomPath, JSON.stringify(result.aibom, null, 2), "utf-8"); + consola.log(""); + consola.success(`${aibomPath} ${result.validation.valid ? "(valid)" : "(validation errors!)"}`); + + if (!result.validation.valid) { + for (const err of result.validation.errors) { + consola.error(` ${err.path || "/"} \u2014 ${err.message}`); + } + process.exit(1); + } + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9f95780..81eda93 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { defineCommand, runMain } from "citty"; import biasCommand from "./commands/bias"; +import importCommand from "./commands/import"; import scanCommand from "./commands/scan"; import validateCommand from "./commands/validate"; import verifyCommand from "./commands/verify"; @@ -16,6 +17,7 @@ const main = defineCommand({ validate: validateCommand, verify: verifyCommand, bias: biasCommand, + import: importCommand, }, }); From 581327d4ff9caae14fb1b20ffeb435d6f4c95b9b Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 17 Apr 2026 21:38:13 +0200 Subject: [PATCH 07/20] fix biome lint warnings in validate command and tests --- packages/cli/src/commands/validate.ts | 111 +++++++++--------- .../core/tests/sbom/component-mapper.test.ts | 34 +++--- 2 files changed, 74 insertions(+), 71 deletions(-) diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index c0e8e2d..b3dee37 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,9 +1,63 @@ import { readFile, readdir, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; import { validate } from "@euconform/core/validation"; +import type { ValidationResult } from "@euconform/core/validation"; import { defineCommand } from "citty"; import consola from "consola"; +interface FileResult { + file: string; + result: ValidationResult; +} + +async function validateFile(file: string): Promise { + try { + const content = await readFile(file, "utf-8"); + const data: unknown = JSON.parse(content); + return { file, result: validate(data) }; + } catch { + return { + file, + result: { + valid: false, + schemaType: "report.v1", + errors: [{ path: "", message: `Failed to read or parse file: ${file}`, keyword: "parse" }], + }, + }; + } +} + +function printJsonResults(results: FileResult[]): void { + process.stdout.write( + `${JSON.stringify( + results.map((r) => ({ + file: r.file, + valid: r.result.valid, + schemaType: r.result.schemaType, + errors: r.result.errors, + })), + null, + 2 + )}\n` + ); +} + +function printHumanResults(results: FileResult[]): void { + for (const { file, result } of results) { + const shortName = file.split("/").pop() ?? file; + if (result.valid) { + consola.success(`${shortName} \u2014 valid (euconform.${result.schemaType})`); + } else { + consola.error( + `${shortName} \u2014 ${result.errors.length} error(s) (euconform.${result.schemaType})` + ); + for (const err of result.errors) { + consola.log(` ${err.path || "/"} \u2014 ${err.message}`); + } + } + } +} + export default defineCommand({ meta: { name: "validate", @@ -30,63 +84,12 @@ export default defineCommand({ process.exit(2); } - const results: Array<{ - file: string; - result: ReturnType; - }> = []; - - for (const file of files) { - let data: unknown; - try { - const content = await readFile(file, "utf-8"); - data = JSON.parse(content); - } catch { - results.push({ - file, - result: { - valid: false, - schemaType: "report.v1", - errors: [ - { - path: "", - message: `Failed to read or parse file: ${file}`, - keyword: "parse", - }, - ], - }, - }); - continue; - } - results.push({ file, result: validate(data) }); - } + const results = await Promise.all(files.map(validateFile)); if (args.json) { - process.stdout.write( - `${JSON.stringify( - results.map((r) => ({ - file: r.file, - valid: r.result.valid, - schemaType: r.result.schemaType, - errors: r.result.errors, - })), - null, - 2 - )}\n` - ); + printJsonResults(results); } else { - for (const { file, result } of results) { - const shortName = file.split("/").pop() ?? file; - if (result.valid) { - consola.success(`${shortName} \u2014 valid (euconform.${result.schemaType})`); - } else { - consola.error( - `${shortName} \u2014 ${result.errors.length} error(s) (euconform.${result.schemaType})` - ); - for (const err of result.errors) { - consola.log(` ${err.path || "/"} \u2014 ${err.message}`); - } - } - } + printHumanResults(results); } const hasErrors = results.some((r) => !r.result.valid); diff --git a/packages/core/tests/sbom/component-mapper.test.ts b/packages/core/tests/sbom/component-mapper.test.ts index 474d028..fbd01e0 100644 --- a/packages/core/tests/sbom/component-mapper.test.ts +++ b/packages/core/tests/sbom/component-mapper.test.ts @@ -42,8 +42,8 @@ describe("component-mapper", () => { }; const result = mapComponent(comp); expect(result).not.toBeNull(); - expect(result!.kind).toBe("model"); - expect(result!.confidence).toBe("high"); + expect(result?.kind).toBe("model"); + expect(result?.confidence).toBe("high"); }); it("maps component with data array containing ml type to dataset", () => { @@ -54,8 +54,8 @@ describe("component-mapper", () => { }; const result = mapComponent(comp); expect(result).not.toBeNull(); - expect(result!.kind).toBe("dataset"); - expect(result!.confidence).toBe("high"); + expect(result?.kind).toBe("dataset"); + expect(result?.confidence).toBe("high"); }); }); @@ -67,8 +67,8 @@ describe("component-mapper", () => { }; const result = mapComponent(comp); expect(result).not.toBeNull(); - expect(result!.kind).toBe("model"); - expect(result!.confidence).toBe("high"); + expect(result?.kind).toBe("model"); + expect(result?.confidence).toBe("high"); }); it("maps data type to dataset", () => { @@ -78,8 +78,8 @@ describe("component-mapper", () => { }; const result = mapComponent(comp); expect(result).not.toBeNull(); - expect(result!.kind).toBe("dataset"); - expect(result!.confidence).toBe("medium"); + expect(result?.kind).toBe("dataset"); + expect(result?.confidence).toBe("medium"); }); it("maps platform type to inference-provider", () => { @@ -89,8 +89,8 @@ describe("component-mapper", () => { }; const result = mapComponent(comp); expect(result).not.toBeNull(); - expect(result!.kind).toBe("inference-provider"); - expect(result!.confidence).toBe("medium"); + expect(result?.kind).toBe("inference-provider"); + expect(result?.confidence).toBe("medium"); }); it("maps service type to inference-provider", () => { @@ -100,8 +100,8 @@ describe("component-mapper", () => { }; const result = mapComponent(comp); expect(result).not.toBeNull(); - expect(result!.kind).toBe("inference-provider"); - expect(result!.confidence).toBe("medium"); + expect(result?.kind).toBe("inference-provider"); + expect(result?.confidence).toBe("medium"); }); }); @@ -114,8 +114,8 @@ describe("component-mapper", () => { }; const result = mapComponent(comp); expect(result).not.toBeNull(); - expect(result!.kind).toBe("ai-framework"); - expect(result!.confidence).toBe("high"); + expect(result?.kind).toBe("ai-framework"); + expect(result?.confidence).toBe("high"); }); it("maps known library-type package", () => { @@ -126,8 +126,8 @@ describe("component-mapper", () => { }; const result = mapComponent(comp); expect(result).not.toBeNull(); - expect(result!.kind).toBe("inference-provider"); - expect(result!.confidence).toBe("high"); + expect(result?.kind).toBe("inference-provider"); + expect(result?.confidence).toBe("high"); }); }); @@ -158,7 +158,7 @@ describe("component-mapper", () => { name: "test-model", }; const result = mapComponent(comp); - expect(result!.source).toBe("sbom-import"); + expect(result?.source).toBe("sbom-import"); }); }); }); From c70b24d1ac4abbd318174a90c3efb336dad51e2a Mon Sep 17 00:00:00 2001 From: Hiepler Date: Sun, 19 Apr 2026 12:03:57 +0200 Subject: [PATCH 08/20] fix add sbom-import to SOURCE_PRIORITY to fix type exhaustiveness --- packages/core/src/evidence/output.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/evidence/output.ts b/packages/core/src/evidence/output.ts index d432aff..c359d81 100644 --- a/packages/core/src/evidence/output.ts +++ b/packages/core/src/evidence/output.ts @@ -81,6 +81,7 @@ const SOURCE_PRIORITY: Record = { config: 4, code: 3, "lock-file": 2, + "sbom-import": 2, docs: 1, }; From eec10bd616e3496e94705dcffb212afa2330eff2 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Sun, 19 Apr 2026 12:04:21 +0200 Subject: [PATCH 09/20] handle scope excluded in production filter and set all complianceCapabilities to --- packages/core/src/sbom/cyclonedx-import.ts | 6 +-- .../core/tests/sbom/cyclonedx-import.test.ts | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/core/src/sbom/cyclonedx-import.ts b/packages/core/src/sbom/cyclonedx-import.ts index eddaa62..fd0fd08 100644 --- a/packages/core/src/sbom/cyclonedx-import.ts +++ b/packages/core/src/sbom/cyclonedx-import.ts @@ -38,7 +38,7 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone const byKind: Record = {}; for (const comp of components) { - if (scope === "production" && comp.scope === "optional") { + if (scope === "production" && (comp.scope === "optional" || comp.scope === "excluded")) { continue; } @@ -66,7 +66,7 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone const totalAfterScope = scope === "production" - ? components.filter((c) => c.scope !== "optional").length + ? components.filter((c) => c.scope !== "optional" && c.scope !== "excluded").length : components.length; const aibom: AiBillOfMaterials = { @@ -79,7 +79,7 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone components: mapped, complianceCapabilities: { biasEvaluation: false, - jsonExport: true, + jsonExport: false, pdfExport: false, loggingInfrastructure: false, humanReviewFlow: false, diff --git a/packages/core/tests/sbom/cyclonedx-import.test.ts b/packages/core/tests/sbom/cyclonedx-import.test.ts index 4a17502..87ecd7e 100644 --- a/packages/core/tests/sbom/cyclonedx-import.test.ts +++ b/packages/core/tests/sbom/cyclonedx-import.test.ts @@ -114,6 +114,43 @@ describe("cyclonedx-import", () => { expect(result.aibom.components).toHaveLength(1); expect(result.aibom.components[0].name).toBe("prod-model"); }); + + it("excludes excluded-scope components when scope is production", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [ + { type: "machine-learning-model", name: "prod-model", scope: "required" }, + { type: "machine-learning-model", name: "excluded-model", scope: "excluded" }, + ], + }; + const result = importCycloneDx(bom, { scope: "production" }); + expect(result.aibom.components).toHaveLength(1); + expect(result.aibom.components[0].name).toBe("prod-model"); + }); + + it("includes components without scope in production mode", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [{ type: "machine-learning-model", name: "no-scope-model" }], + }; + const result = importCycloneDx(bom, { scope: "production" }); + expect(result.aibom.components).toHaveLength(1); + }); + }); + + describe("complianceCapabilities", () => { + it("sets all capabilities to false for imports", () => { + const result = importCycloneDx(minimalBom); + const caps = result.aibom.complianceCapabilities; + expect(caps.biasEvaluation).toBe(false); + expect(caps.jsonExport).toBe(false); + expect(caps.pdfExport).toBe(false); + expect(caps.loggingInfrastructure).toBe(false); + expect(caps.humanReviewFlow).toBe(false); + expect(caps.incidentHandling).toBe(false); + }); }); describe("component ID generation", () => { From 7c278f4a7d977b6079e603a6529948c2270bba41 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Sun, 19 Apr 2026 12:04:39 +0200 Subject: [PATCH 10/20] add purl parser for CycloneDX package URL extraction --- packages/core/src/sbom/purl.ts | 63 ++++++++++++++++++ packages/core/tests/sbom/purl.test.ts | 94 +++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 packages/core/src/sbom/purl.ts create mode 100644 packages/core/tests/sbom/purl.test.ts diff --git a/packages/core/src/sbom/purl.ts b/packages/core/src/sbom/purl.ts new file mode 100644 index 0000000..97383fe --- /dev/null +++ b/packages/core/src/sbom/purl.ts @@ -0,0 +1,63 @@ +export interface ParsedPurl { + scheme: "pkg"; + type: string; + namespace?: string; + name: string; + version?: string; +} + +/** + * Parse a Package URL (purl) string into its components. + * Follows the purl spec: pkg:type/namespace/name@version?qualifiers#subpath + * Returns null for invalid or non-pkg URLs. + */ +export function parsePurl(purl: string): ParsedPurl | null { + if (!purl || !purl.startsWith("pkg:")) return null; + + // Strip qualifiers and subpath + let rest = purl.slice(4); // remove "pkg:" + const hashIdx = rest.indexOf("#"); + if (hashIdx !== -1) rest = rest.slice(0, hashIdx); + const queryIdx = rest.indexOf("?"); + if (queryIdx !== -1) rest = rest.slice(0, queryIdx); + + // Split type from the rest: type/... + const slashIdx = rest.indexOf("/"); + if (slashIdx <= 0) return null; + + const type = rest.slice(0, slashIdx); + let remainder = rest.slice(slashIdx + 1); + + // Decode percent-encoding + remainder = decodeURIComponent(remainder); + + // Extract version + let version: string | undefined; + const atIdx = remainder.lastIndexOf("@"); + if (atIdx !== -1) { + version = remainder.slice(atIdx + 1); + remainder = remainder.slice(0, atIdx); + } + + // Split namespace/name (last segment is name, rest is namespace) + const lastSlash = remainder.lastIndexOf("/"); + let namespace: string | undefined; + let name: string; + + if (lastSlash !== -1) { + namespace = remainder.slice(0, lastSlash); + name = remainder.slice(lastSlash + 1); + } else { + name = remainder; + } + + if (!name) return null; + + return { + scheme: "pkg", + type, + namespace: namespace || undefined, + name, + version: version || undefined, + }; +} diff --git a/packages/core/tests/sbom/purl.test.ts b/packages/core/tests/sbom/purl.test.ts new file mode 100644 index 0000000..662174d --- /dev/null +++ b/packages/core/tests/sbom/purl.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { parsePurl } from "../../src/sbom/purl"; + +describe("parsePurl", () => { + it("parses a simple pypi purl", () => { + const result = parsePurl("pkg:pypi/transformers@4.38.0"); + expect(result).toEqual({ + scheme: "pkg", + type: "pypi", + namespace: undefined, + name: "transformers", + version: "4.38.0", + }); + }); + + it("parses npm purl with namespace", () => { + const result = parsePurl("pkg:npm/%40langchain/core@0.1.22"); + expect(result).toEqual({ + scheme: "pkg", + type: "npm", + namespace: "@langchain", + name: "core", + version: "0.1.22", + }); + }); + + it("parses npm purl with unencoded @ namespace", () => { + const result = parsePurl("pkg:npm/@huggingface/transformers@2.0.0"); + expect(result).toEqual({ + scheme: "pkg", + type: "npm", + namespace: "@huggingface", + name: "transformers", + version: "2.0.0", + }); + }); + + it("parses generic purl without version", () => { + const result = parsePurl("pkg:generic/llama-3.2-1b"); + expect(result).toEqual({ + scheme: "pkg", + type: "generic", + namespace: undefined, + name: "llama-3.2-1b", + version: undefined, + }); + }); + + it("parses purl with qualifiers (ignores them)", () => { + const result = parsePurl("pkg:npm/openai@1.12.0?vcs_url=github.com/openai"); + expect(result).toEqual({ + scheme: "pkg", + type: "npm", + namespace: undefined, + name: "openai", + version: "1.12.0", + }); + }); + + it("parses purl with subpath (ignores it)", () => { + const result = parsePurl("pkg:pypi/torch@2.2.0#cuda"); + expect(result).toEqual({ + scheme: "pkg", + type: "pypi", + namespace: undefined, + name: "torch", + version: "2.2.0", + }); + }); + + it("parses maven purl with deep namespace", () => { + const result = parsePurl("pkg:maven/org.tensorflow/tensorflow-core-api@0.5.0"); + expect(result).toEqual({ + scheme: "pkg", + type: "maven", + namespace: "org.tensorflow", + name: "tensorflow-core-api", + version: "0.5.0", + }); + }); + + it("returns null for empty string", () => { + expect(parsePurl("")).toBeNull(); + }); + + it("returns null for non-pkg scheme", () => { + expect(parsePurl("http://example.com")).toBeNull(); + }); + + it("returns null for malformed purl", () => { + expect(parsePurl("pkg:")).toBeNull(); + expect(parsePurl("pkg:/")).toBeNull(); + }); +}); From 86303ab468418e70d31a9cda7ae88ea50a0597df Mon Sep 17 00:00:00 2001 From: Hiepler Date: Sun, 19 Apr 2026 12:04:53 +0200 Subject: [PATCH 11/20] add scoped package support and 8-tier component mapper with purl fallback --- packages/core/src/sbom/component-mapper.ts | 71 ++++++++++++-- packages/core/src/sbom/known-packages.ts | 49 +++++++++- .../core/tests/sbom/component-mapper.test.ts | 94 +++++++++++++++++++ 3 files changed, 203 insertions(+), 11 deletions(-) diff --git a/packages/core/src/sbom/component-mapper.ts b/packages/core/src/sbom/component-mapper.ts index 81d4148..4a99f40 100644 --- a/packages/core/src/sbom/component-mapper.ts +++ b/packages/core/src/sbom/component-mapper.ts @@ -1,11 +1,13 @@ import type { BomComponentKind } from "../evidence/types"; import { lookupKnownPackage } from "./known-packages"; +import { parsePurl } from "./purl"; import type { CycloneDxComponent } from "./types"; export interface ComponentMapping { kind: BomComponentKind; source: "sbom-import"; confidence: "high" | "medium"; + purlVersion?: string; } const CYCLONEDX_TYPE_MAP: Record< @@ -18,36 +20,87 @@ const CYCLONEDX_TYPE_MAP: Record< service: { kind: "inference-provider", confidence: "medium" }, }; +/** Tiers 7-8: Try to find a kind via purl namespace/name and name-only lookups. */ +function lookupViaPurl( + parsed: ReturnType +): { kind: BomComponentKind; confidence: "high" | "medium" } | null { + if (!parsed) return null; + + // Tier 7: namespace/name (e.g. @langchain/core) + if (parsed.namespace) { + const scopedName = `${parsed.namespace}/${parsed.name}`; + const scopedKind = lookupKnownPackage(scopedName); + if (scopedKind) return { kind: scopedKind, confidence: "high" }; + } + + // Tier 8: purl name only + const purlKind = lookupKnownPackage(parsed.name); + if (purlKind) return { kind: purlKind, confidence: "medium" }; + + return null; +} + /** * Map a CycloneDX component to an EuConform aibom component kind. * - * Three-tier detection (descending priority): - * 1. CycloneDX ML-BOM extensions (modelCard, data) + * 8-tier detection (descending priority): + * 1. ML-BOM extensions (modelCard, data) * 2. CycloneDX component type mapping - * 3. Known AI package registry lookup + * 3. Exact package name in registry + * 4. Explicit scoped name in registry (@langchain/core) + * 5. Scope mapping via KNOWN_AI_SCOPES (@langchain -> tool) + * 6. Scope-stripped name in registry (@huggingface/transformers -> transformers) + * 7. Purl namespace/name against registry + * 8. Purl name-only against registry * - * Returns null if the component is not AI-relevant. + * Steps 3-6 are handled by lookupKnownPackage(). + * Returns null if the component is not AI-relevant or has empty name. */ export function mapComponent(component: CycloneDxComponent): ComponentMapping | null { + // Name validation + if (!component.name || component.name.trim() === "") { + return null; + } + + // Extract purl info (used for fallback and version) + const parsed = component.purl ? parsePurl(component.purl) : null; + const purlVersion = parsed?.version; + // Tier 1: ML-BOM extensions if (component.modelCard != null) { - return { kind: "model", source: "sbom-import", confidence: "high" }; + return { kind: "model", source: "sbom-import", confidence: "high", purlVersion }; } if (Array.isArray(component.data) && component.data.some((d) => d.type === "dataset")) { - return { kind: "dataset", source: "sbom-import", confidence: "high" }; + return { kind: "dataset", source: "sbom-import", confidence: "high", purlVersion }; } // Tier 2: CycloneDX component type const typeMapping = CYCLONEDX_TYPE_MAP[component.type]; if (typeMapping) { - return { kind: typeMapping.kind, source: "sbom-import", confidence: typeMapping.confidence }; + return { + kind: typeMapping.kind, + source: "sbom-import", + confidence: typeMapping.confidence, + purlVersion, + }; } - // Tier 3: Known-package registry + // Tiers 3-6: Known-package registry (handles exact, scoped, scope-fallback, scope-stripped) const knownKind = lookupKnownPackage(component.name); if (knownKind) { - return { kind: knownKind, source: "sbom-import", confidence: "high" }; + return { kind: knownKind, source: "sbom-import", confidence: "high", purlVersion }; + } + + // Tiers 7-8: Purl fallback + const purlMatch = lookupViaPurl(parsed); + if (purlMatch) { + return { + kind: purlMatch.kind, + source: "sbom-import", + confidence: purlMatch.confidence, + purlVersion, + }; } return null; diff --git a/packages/core/src/sbom/known-packages.ts b/packages/core/src/sbom/known-packages.ts index a9fd683..e136472 100644 --- a/packages/core/src/sbom/known-packages.ts +++ b/packages/core/src/sbom/known-packages.ts @@ -48,7 +48,6 @@ export const KNOWN_AI_PACKAGES: Record = { mistralai: "inference-provider", cohere: "inference-provider", "google-generativeai": "inference-provider", - "@google/generative-ai": "inference-provider", ai21: "inference-provider", // Vector Stores @@ -86,12 +85,58 @@ export const KNOWN_AI_PACKAGES: Record = { "guardrails-ai": "tool", lmql: "tool", outlines: "tool", + + // Scoped packages (npm) + "@langchain/core": "tool", + "@langchain/openai": "tool", + "@langchain/community": "tool", + "@langchain/anthropic": "tool", + "@huggingface/transformers": "ai-framework", + "@huggingface/inference": "inference-provider", + "@google/generative-ai": "inference-provider", + "@anthropic-ai/sdk": "inference-provider", + "@pinecone-database/pinecone": "vector-store", + "@qdrant/js-client-rest": "vector-store", +}; + +export const KNOWN_AI_SCOPES: Record = { + "@langchain": "tool", + "@huggingface": "ai-framework", + "@tensorflow": "ai-framework", + "@anthropic-ai": "inference-provider", + "@pinecone-database": "vector-store", + "@qdrant": "vector-store", }; /** * Look up a package name in the known AI packages registry. + * For scoped packages, tries: + * 1. Exact match (e.g. @langchain/core) + * 2. Scope match via KNOWN_AI_SCOPES (e.g. @langchain -> tool) + * 3. Strip scope, check bare name (e.g. @huggingface/transformers -> transformers) * Returns the BomComponentKind if found, null otherwise. */ export function lookupKnownPackage(name: string): BomComponentKind | null { - return KNOWN_AI_PACKAGES[name.toLowerCase()] ?? null; + const lower = name.toLowerCase(); + + // 1. Exact match + const exact = KNOWN_AI_PACKAGES[lower]; + if (exact) return exact; + + // Scoped package handling + if (lower.startsWith("@") && lower.includes("/")) { + const scopeEnd = lower.indexOf("/"); + const scope = lower.slice(0, scopeEnd); + const bareName = lower.slice(scopeEnd + 1); + + // 2. Known AI scope + const scopeKind = KNOWN_AI_SCOPES[scope]; + if (scopeKind) return scopeKind; + + // 3. Strip scope, check bare name + const bareKind = KNOWN_AI_PACKAGES[bareName]; + if (bareKind) return bareKind; + } + + return null; } diff --git a/packages/core/tests/sbom/component-mapper.test.ts b/packages/core/tests/sbom/component-mapper.test.ts index fbd01e0..89b1ad0 100644 --- a/packages/core/tests/sbom/component-mapper.test.ts +++ b/packages/core/tests/sbom/component-mapper.test.ts @@ -30,6 +30,31 @@ describe("known-packages", () => { expect(lookupKnownPackage("Transformers")).toBe("ai-framework"); expect(lookupKnownPackage("OPENAI")).toBe("inference-provider"); }); + + it("recognizes @langchain/core as tool", () => { + expect(lookupKnownPackage("@langchain/core")).toBe("tool"); + }); + + it("recognizes @huggingface/transformers as ai-framework", () => { + expect(lookupKnownPackage("@huggingface/transformers")).toBe("ai-framework"); + }); + + it("recognizes @google/generative-ai as inference-provider", () => { + expect(lookupKnownPackage("@google/generative-ai")).toBe("inference-provider"); + }); + + it("falls back to scope lookup for unknown @langchain/* packages", () => { + expect(lookupKnownPackage("@langchain/unknown-new-package")).toBe("tool"); + }); + + it("falls back to stripping scope and checking name", () => { + expect(lookupKnownPackage("@someorg/transformers")).toBe("ai-framework"); + }); + + it("returns null for unknown scoped packages", () => { + expect(lookupKnownPackage("@types/node")).toBeNull(); + expect(lookupKnownPackage("@testing-library/react")).toBeNull(); + }); }); describe("component-mapper", () => { @@ -161,4 +186,73 @@ describe("component-mapper", () => { expect(result?.source).toBe("sbom-import"); }); }); + + describe("name validation", () => { + it("returns null for component with empty name", () => { + const comp: CycloneDxComponent = { + type: "machine-learning-model", + name: "", + }; + expect(mapComponent(comp)).toBeNull(); + }); + }); + + describe("purl fallback", () => { + it("detects AI package via purl when name is unrecognized", () => { + const comp: CycloneDxComponent = { + type: "library", + name: "custom-wrapper", + purl: "pkg:pypi/transformers@4.38.0", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result?.kind).toBe("ai-framework"); + }); + + it("detects scoped package via purl namespace/name", () => { + const comp: CycloneDxComponent = { + type: "library", + name: "some-alias", + purl: "pkg:npm/%40langchain/core@0.1.22", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result?.kind).toBe("tool"); + }); + + it("does not match purl when name already matched", () => { + const comp: CycloneDxComponent = { + type: "library", + name: "openai", + purl: "pkg:pypi/openai@1.0.0", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result?.kind).toBe("inference-provider"); + expect(result?.confidence).toBe("high"); + }); + }); + + describe("version extraction from purl", () => { + it("extracts version from purl via purlVersion", () => { + const comp: CycloneDxComponent = { + type: "machine-learning-model", + name: "my-model", + purl: "pkg:generic/my-model@3.0.0", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result?.purlVersion).toBe("3.0.0"); + }); + + it("returns undefined purlVersion when no purl", () => { + const comp: CycloneDxComponent = { + type: "machine-learning-model", + name: "my-model", + }; + const result = mapComponent(comp); + expect(result).not.toBeNull(); + expect(result?.purlVersion).toBeUndefined(); + }); + }); }); From 02a6ed2cd375e901c0edb4075571594cac8782e4 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Sun, 19 Apr 2026 12:05:25 +0200 Subject: [PATCH 12/20] add optional metadata field to aibom schema for import provenance --- docs/spec/schemas/aibom-v1.schema.json | 16 ++++++++++++++++ packages/core/src/evidence/types.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/docs/spec/schemas/aibom-v1.schema.json b/docs/spec/schemas/aibom-v1.schema.json index 08e3a57..9971faf 100644 --- a/docs/spec/schemas/aibom-v1.schema.json +++ b/docs/spec/schemas/aibom-v1.schema.json @@ -71,6 +71,22 @@ "humanReviewFlow": { "type": "boolean" }, "incidentHandling": { "type": "boolean" } } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "importSource": { + "enum": ["cyclonedx", "spdx", "manual"] + }, + "importTool": { + "type": "string" + }, + "originalTimestamp": { + "type": "string", + "format": "date-time" + } + } } } } diff --git a/packages/core/src/evidence/types.ts b/packages/core/src/evidence/types.ts index 50f3ec8..60fa632 100644 --- a/packages/core/src/evidence/types.ts +++ b/packages/core/src/evidence/types.ts @@ -235,6 +235,12 @@ export interface BomComponent { source: "package.json" | "lock-file" | "code" | "config" | "docs" | "sbom-import"; } +export interface AibomMetadata { + importSource: "cyclonedx" | "spdx" | "manual"; + importTool?: string; + originalTimestamp?: string; +} + export interface AiBillOfMaterials { schemaVersion: "euconform.aibom.v1"; generatedAt: string; @@ -251,6 +257,7 @@ export interface AiBillOfMaterials { humanReviewFlow: boolean; incidentHandling: boolean; }; + metadata?: AibomMetadata; } // --------------------------------------------------------------------------- From 3b374ce3baeb06d40c4f8a12a406d8ba6601217b Mon Sep 17 00:00:00 2001 From: Hiepler Date: Sun, 19 Apr 2026 12:05:45 +0200 Subject: [PATCH 13/20] update import CLI with provenance display, sourcePath, and extended summary --- packages/cli/src/commands/import.ts | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts index 5c16be5..97f4043 100644 --- a/packages/cli/src/commands/import.ts +++ b/packages/cli/src/commands/import.ts @@ -20,7 +20,7 @@ export default defineCommand({ type: "string", default: "all", description: - 'Component scope filter: "all" includes everything, "production" excludes optional-scope components', + 'Component scope filter: "all" includes everything, "production" excludes optional and excluded-scope components', }, json: { type: "boolean", @@ -50,7 +50,7 @@ export default defineCommand({ let result: ReturnType; try { - result = importCycloneDx(data, { scope }); + result = importCycloneDx(data, { scope, sourcePath: inputPath }); } catch (error) { consola.error(error instanceof Error ? error.message : String(error)); process.exit(1); @@ -62,10 +62,7 @@ export default defineCommand({ { aibom: result.aibom, summary: result.summary, - validation: { - valid: result.validation.valid, - errors: result.validation.errors, - }, + validation: { valid: result.validation.valid, errors: result.validation.errors }, }, null, 2 @@ -76,13 +73,25 @@ export default defineCommand({ // Human-readable output const fileName = basename(inputPath); + const src = result.summary.source; + const sourceLabel = src.importTool + ? `${fileName} (CycloneDX ${src.specVersion}, ${src.importTool})` + : `${fileName} (CycloneDX ${src.specVersion})`; + consola.log(""); consola.log("CycloneDX Import"); consola.log("\u2500".repeat(40)); - consola.log(`Source: ${fileName} (CycloneDX)`); - consola.log( - `Components: ${result.summary.totalComponents} total \u2192 ${result.summary.aiRelevant} AI-relevant, ${result.summary.skipped} skipped` - ); + consola.log(`Source: ${sourceLabel}`); + consola.log(`Project: ${result.aibom.project.name}`); + + let componentsLine = `Components: ${result.summary.totalComponents} total \u2192 ${result.summary.aiRelevant} AI-relevant, ${result.summary.skipped} skipped`; + if (result.summary.filteredByScope > 0) { + componentsLine += `, ${result.summary.filteredByScope} scope-filtered`; + } + if (result.summary.duplicatesRemoved > 0) { + componentsLine += `, ${result.summary.duplicatesRemoved} duplicates removed`; + } + consola.log(componentsLine); consola.log(""); for (const [kind, count] of Object.entries(result.summary.byKind).sort((a, b) => b[1] - a[1])) { From ffba2a58cc5ac9c527cf4f86d256afe9a58c1838 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Sun, 19 Apr 2026 12:06:18 +0200 Subject: [PATCH 14/20] fix formatting and update sbom exports --- .../public/schemas/spec/aibom-v1.schema.json | 2 +- packages/core/src/sbom/cyclonedx-import.ts | 156 +++++++++++++--- packages/core/src/sbom/index.ts | 4 +- .../core/tests/sbom/cyclonedx-import.test.ts | 169 +++++++++++++++++- 4 files changed, 300 insertions(+), 31 deletions(-) diff --git a/apps/docs/public/schemas/spec/aibom-v1.schema.json b/apps/docs/public/schemas/spec/aibom-v1.schema.json index 507ba67..08e3a57 100644 --- a/apps/docs/public/schemas/spec/aibom-v1.schema.json +++ b/apps/docs/public/schemas/spec/aibom-v1.schema.json @@ -47,7 +47,7 @@ "name": { "type": "string" }, "version": { "type": "string" }, "source": { - "enum": ["package.json", "lock-file", "code", "config", "docs"] + "enum": ["package.json", "lock-file", "code", "config", "docs", "sbom-import"] } } } diff --git a/packages/core/src/sbom/cyclonedx-import.ts b/packages/core/src/sbom/cyclonedx-import.ts index fd0fd08..587c255 100644 --- a/packages/core/src/sbom/cyclonedx-import.ts +++ b/packages/core/src/sbom/cyclonedx-import.ts @@ -1,25 +1,38 @@ -import type { AiBillOfMaterials, BomComponent } from "../evidence/types"; +import { basename, extname } from "node:path"; +import type { AiBillOfMaterials, AibomMetadata, BomComponent } from "../evidence/types"; import type { ValidationResult } from "../validation/schema-validator"; import { validate } from "../validation/schema-validator"; import { mapComponent } from "./component-mapper"; import type { CycloneDxBom } from "./types"; import { SUPPORTED_SPEC_VERSIONS } from "./types"; +export interface ImportWarning { + component: string; + message: string; +} + +export interface ImportSourceInfo { + bomFormat: "CycloneDX"; + specVersion: string; + projectNameSource: "metadata.component.name" | "sourcePath" | "fallback"; + importTool?: string; + originalTimestamp?: string; +} + export interface ImportSummary { totalComponents: number; + filteredByScope: number; aiRelevant: number; skipped: number; + duplicatesRemoved: number; byKind: Record; warnings: ImportWarning[]; -} - -export interface ImportWarning { - component: string; - message: string; + source: ImportSourceInfo; } export interface ImportOptions { scope?: "all" | "production"; + sourcePath?: string; } export interface CycloneDxImportResult { @@ -32,51 +45,80 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone const bom = parseBom(sbom); const scope = options?.scope ?? "all"; - const components = bom.components ?? []; + const allComponents = bom.components ?? []; const warnings: ImportWarning[] = []; - const mapped: BomComponent[] = []; - const byKind: Record = {}; - for (const comp of components) { - if (scope === "production" && (comp.scope === "optional" || comp.scope === "excluded")) { + // Step 1: Count raw total + const totalComponents = allComponents.length; + + // Step 2: Scope filtering + const inScope = + scope === "production" + ? allComponents.filter((c) => c.scope !== "optional" && c.scope !== "excluded") + : allComponents; + const filteredByScope = totalComponents - inScope.length; + + // Step 3: Map components + const mapped: BomComponent[] = []; + for (const comp of inScope) { + if (!comp.name || comp.name.trim() === "") { + warnings.push({ component: "(empty)", message: "Skipped: missing or empty name" }); continue; } const mapping = mapComponent(comp); if (!mapping) continue; - if (!comp.version) { - warnings.push({ - component: comp.name, - message: "Missing version field", - }); + const version = comp.version || mapping.purlVersion; + if (!version) { + warnings.push({ component: comp.name, message: "Missing version field" }); } const bomComponent: BomComponent = { - id: `${mapping.kind}:${comp.name}`, + id: version ? `${mapping.kind}:${comp.name}:${version}` : `${mapping.kind}:${comp.name}`, kind: mapping.kind, name: comp.name, - ...(comp.version ? { version: comp.version } : {}), + ...(version ? { version } : {}), source: "sbom-import", }; mapped.push(bomComponent); - byKind[mapping.kind] = (byKind[mapping.kind] ?? 0) + 1; } - const totalAfterScope = - scope === "production" - ? components.filter((c) => c.scope !== "optional" && c.scope !== "excluded").length - : components.length; + // Step 4: Deduplicate (by kind+name+version) + const seen = new Set(); + let duplicatesRemoved = 0; + const deduped: BomComponent[] = []; + for (const comp of mapped) { + const key = `${comp.kind}:${comp.name}:${comp.version ?? ""}`; + if (seen.has(key)) { + duplicatesRemoved++; + continue; + } + seen.add(key); + deduped.push(comp); + } + // Step 5: Compute byKind + const byKind: Record = {}; + for (const comp of deduped) { + byKind[comp.kind] = (byKind[comp.kind] ?? 0) + 1; + } + + // Step 6: Extract provenance + const { projectName, projectNameSource } = resolveProjectName(bom, options?.sourcePath); + const metadata = extractMetadata(bom); + const sourceInfo = extractSourceInfo(bom, projectNameSource); + + // Step 7: Build aibom const aibom: AiBillOfMaterials = { schemaVersion: "euconform.aibom.v1", generatedAt: new Date().toISOString(), project: { - name: "sbom-import", + name: projectName, rootPath: ".", }, - components: mapped, + components: deduped, complianceCapabilities: { biasEvaluation: false, jsonExport: false, @@ -85,6 +127,7 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone humanReviewFlow: false, incidentHandling: false, }, + ...(metadata ? { metadata } : {}), }; const validation = validate(aibom); @@ -92,16 +135,73 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone return { aibom, summary: { - totalComponents: totalAfterScope, - aiRelevant: mapped.length, - skipped: totalAfterScope - mapped.length, + totalComponents, + filteredByScope, + aiRelevant: deduped.length, + skipped: inScope.length - mapped.length, + duplicatesRemoved, byKind, warnings, + source: sourceInfo, }, validation, }; } +function resolveProjectName( + bom: CycloneDxBom, + sourcePath?: string +): { projectName: string; projectNameSource: ImportSourceInfo["projectNameSource"] } { + const metaName = bom.metadata?.component?.name; + if (metaName) { + return { projectName: metaName, projectNameSource: "metadata.component.name" }; + } + + if (sourcePath) { + const name = basename(sourcePath, extname(sourcePath)); + return { projectName: name, projectNameSource: "sourcePath" }; + } + + return { projectName: "sbom-import", projectNameSource: "fallback" }; +} + +function extractMetadata(bom: CycloneDxBom): AibomMetadata | undefined { + const meta: AibomMetadata = { importSource: "cyclonedx" }; + + const tool = bom.metadata?.tools?.[0]; + if (tool?.name) { + meta.importTool = tool.version ? `${tool.name} ${tool.version}` : tool.name; + } + + if (bom.metadata?.timestamp) { + meta.originalTimestamp = bom.metadata.timestamp; + } + + return meta; +} + +function extractSourceInfo( + bom: CycloneDxBom, + projectNameSource: ImportSourceInfo["projectNameSource"] +): ImportSourceInfo { + const info: ImportSourceInfo = { + bomFormat: "CycloneDX", + specVersion: bom.specVersion, + projectNameSource, + }; + + const tool = bom.metadata?.tools?.[0]; + if (tool?.name) { + info.importTool = tool.version ? `${tool.name} ${tool.version}` : tool.name; + } + + if (bom.metadata?.timestamp) { + info.originalTimestamp = bom.metadata.timestamp; + } + + return info; +} + function parseBom(sbom: unknown): CycloneDxBom { if (typeof sbom !== "object" || sbom === null) { throw new Error("Input must be a JSON object"); diff --git a/packages/core/src/sbom/index.ts b/packages/core/src/sbom/index.ts index 651fa3b..1ddc326 100644 --- a/packages/core/src/sbom/index.ts +++ b/packages/core/src/sbom/index.ts @@ -4,7 +4,9 @@ export { type ImportSummary, type ImportWarning, type ImportOptions, + type ImportSourceInfo, } from "./cyclonedx-import"; export { mapComponent, type ComponentMapping } from "./component-mapper"; -export { lookupKnownPackage, KNOWN_AI_PACKAGES } from "./known-packages"; +export { lookupKnownPackage, KNOWN_AI_PACKAGES, KNOWN_AI_SCOPES } from "./known-packages"; +export { parsePurl, type ParsedPurl } from "./purl"; export type { CycloneDxBom, CycloneDxComponent } from "./types"; diff --git a/packages/core/tests/sbom/cyclonedx-import.test.ts b/packages/core/tests/sbom/cyclonedx-import.test.ts index 87ecd7e..db40b26 100644 --- a/packages/core/tests/sbom/cyclonedx-import.test.ts +++ b/packages/core/tests/sbom/cyclonedx-import.test.ts @@ -156,7 +156,174 @@ describe("cyclonedx-import", () => { describe("component ID generation", () => { it("generates id as kind:name", () => { const result = importCycloneDx(minimalBom); - expect(result.aibom.components[0].id).toBe("model:llama-3.2-1b"); + expect(result.aibom.components[0].id).toBe("model:llama-3.2-1b:1.0.0"); + }); + }); + + describe("deduplication", () => { + it("removes exact duplicates (same kind+name+version)", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [ + { type: "library", name: "openai", version: "1.12.0" }, + { type: "library", name: "openai", version: "1.12.0" }, + { type: "library", name: "openai", version: "1.12.0" }, + ], + }; + const result = importCycloneDx(bom); + expect(result.aibom.components).toHaveLength(1); + expect(result.summary.duplicatesRemoved).toBe(2); + }); + + it("keeps different versions of the same package", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [ + { type: "library", name: "torch", version: "2.0.0" }, + { type: "library", name: "torch", version: "2.2.0" }, + ], + }; + const result = importCycloneDx(bom); + expect(result.aibom.components).toHaveLength(2); + expect(result.summary.duplicatesRemoved).toBe(0); + }); + }); + + describe("ID format", () => { + it("uses kind:name:version when version present", () => { + const result = importCycloneDx(minimalBom); + expect(result.aibom.components[0].id).toBe("model:llama-3.2-1b:1.0.0"); + }); + + it("uses kind:name when version absent", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [{ type: "machine-learning-model", name: "some-model" }], + }; + const result = importCycloneDx(bom); + expect(result.aibom.components[0].id).toBe("model:some-model"); + }); + }); + + describe("provenance", () => { + it("sets metadata from BOM metadata", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + metadata: { + timestamp: "2026-04-17T10:00:00Z", + tools: [{ name: "cdxgen", version: "10.0.0" }], + component: { type: "application", name: "my-ai-app" }, + }, + components: [{ type: "machine-learning-model", name: "test-model", version: "1.0" }], + }; + const result = importCycloneDx(bom); + expect(result.aibom.metadata).toEqual({ + importSource: "cyclonedx", + importTool: "cdxgen 10.0.0", + originalTimestamp: "2026-04-17T10:00:00Z", + }); + }); + + it("omits importTool when no tools in metadata", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [{ type: "machine-learning-model", name: "test-model", version: "1.0" }], + }; + const result = importCycloneDx(bom); + expect(result.aibom.metadata).toEqual({ + importSource: "cyclonedx", + }); + }); + + it("includes source info in summary", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + metadata: { + timestamp: "2026-04-17T10:00:00Z", + tools: [{ name: "trivy", version: "0.50.0" }], + }, + components: [], + }; + const result = importCycloneDx(bom); + expect(result.summary.source.bomFormat).toBe("CycloneDX"); + expect(result.summary.source.specVersion).toBe("1.5"); + expect(result.summary.source.importTool).toBe("trivy 0.50.0"); + expect(result.summary.source.originalTimestamp).toBe("2026-04-17T10:00:00Z"); + }); + }); + + describe("project name", () => { + it("uses metadata.component.name when available", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + metadata: { + component: { type: "application", name: "my-ai-app" }, + }, + components: [], + }; + const result = importCycloneDx(bom); + expect(result.aibom.project.name).toBe("my-ai-app"); + expect(result.summary.source.projectNameSource).toBe("metadata.component.name"); + }); + + it("falls back to sourcePath basename", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [], + }; + const result = importCycloneDx(bom, { sourcePath: "/path/to/cyclonedx-bom.json" }); + expect(result.aibom.project.name).toBe("cyclonedx-bom"); + expect(result.summary.source.projectNameSource).toBe("sourcePath"); + }); + + it("falls back to sbom-import when no metadata or sourcePath", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [], + }; + const result = importCycloneDx(bom); + expect(result.aibom.project.name).toBe("sbom-import"); + expect(result.summary.source.projectNameSource).toBe("fallback"); + }); + }); + + describe("filteredByScope in summary", () => { + it("counts scope-filtered components", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [ + { type: "machine-learning-model", name: "prod", scope: "required" }, + { type: "machine-learning-model", name: "opt", scope: "optional" }, + { type: "machine-learning-model", name: "excl", scope: "excluded" }, + { type: "library", name: "express", scope: "required" }, + ], + }; + const result = importCycloneDx(bom, { scope: "production" }); + expect(result.summary.totalComponents).toBe(4); + expect(result.summary.filteredByScope).toBe(2); + expect(result.summary.aiRelevant).toBe(1); + }); + }); + + describe("version fallback from purl", () => { + it("uses purl version when component version missing", () => { + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + components: [{ type: "library", name: "openai", purl: "pkg:pypi/openai@1.30.0" }], + }; + const result = importCycloneDx(bom); + expect(result.aibom.components[0].version).toBe("1.30.0"); }); }); }); From 0c293a97c7af72657e34e9a6acb89dd0a33c7a53 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Sun, 19 Apr 2026 12:06:34 +0200 Subject: [PATCH 15/20] address code review findings - sync schema, extract tool helper, add metadata test --- .../public/schemas/spec/aibom-v1.schema.json | 16 +++++++++ packages/core/src/sbom/cyclonedx-import.ts | 34 +++++++------------ .../tests/validation/schema-validator.test.ts | 23 +++++++++++++ 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/apps/docs/public/schemas/spec/aibom-v1.schema.json b/apps/docs/public/schemas/spec/aibom-v1.schema.json index 08e3a57..9971faf 100644 --- a/apps/docs/public/schemas/spec/aibom-v1.schema.json +++ b/apps/docs/public/schemas/spec/aibom-v1.schema.json @@ -71,6 +71,22 @@ "humanReviewFlow": { "type": "boolean" }, "incidentHandling": { "type": "boolean" } } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "importSource": { + "enum": ["cyclonedx", "spdx", "manual"] + }, + "importTool": { + "type": "string" + }, + "originalTimestamp": { + "type": "string", + "format": "date-time" + } + } } } } diff --git a/packages/core/src/sbom/cyclonedx-import.ts b/packages/core/src/sbom/cyclonedx-import.ts index 587c255..9c370c3 100644 --- a/packages/core/src/sbom/cyclonedx-import.ts +++ b/packages/core/src/sbom/cyclonedx-import.ts @@ -127,7 +127,7 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone humanReviewFlow: false, incidentHandling: false, }, - ...(metadata ? { metadata } : {}), + metadata, }; const validation = validate(aibom); @@ -165,18 +165,17 @@ function resolveProjectName( return { projectName: "sbom-import", projectNameSource: "fallback" }; } -function extractMetadata(bom: CycloneDxBom): AibomMetadata | undefined { - const meta: AibomMetadata = { importSource: "cyclonedx" }; - +function extractToolString(bom: CycloneDxBom): string | undefined { const tool = bom.metadata?.tools?.[0]; - if (tool?.name) { - meta.importTool = tool.version ? `${tool.name} ${tool.version}` : tool.name; - } - - if (bom.metadata?.timestamp) { - meta.originalTimestamp = bom.metadata.timestamp; - } + if (!tool?.name) return undefined; + return tool.version ? `${tool.name} ${tool.version}` : tool.name; +} +function extractMetadata(bom: CycloneDxBom): AibomMetadata { + const meta: AibomMetadata = { importSource: "cyclonedx" }; + const importTool = extractToolString(bom); + if (importTool) meta.importTool = importTool; + if (bom.metadata?.timestamp) meta.originalTimestamp = bom.metadata.timestamp; return meta; } @@ -189,16 +188,9 @@ function extractSourceInfo( specVersion: bom.specVersion, projectNameSource, }; - - const tool = bom.metadata?.tools?.[0]; - if (tool?.name) { - info.importTool = tool.version ? `${tool.name} ${tool.version}` : tool.name; - } - - if (bom.metadata?.timestamp) { - info.originalTimestamp = bom.metadata.timestamp; - } - + const importTool = extractToolString(bom); + if (importTool) info.importTool = importTool; + if (bom.metadata?.timestamp) info.originalTimestamp = bom.metadata.timestamp; return info; } diff --git a/packages/core/tests/validation/schema-validator.test.ts b/packages/core/tests/validation/schema-validator.test.ts index 411f7ef..27b50b4 100644 --- a/packages/core/tests/validation/schema-validator.test.ts +++ b/packages/core/tests/validation/schema-validator.test.ts @@ -157,6 +157,29 @@ describe("schema-validator", () => { }); expect(result.valid).toBe(true); }); + + it("accepts aibom with metadata field", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1", + generatedAt: "2026-01-01T00:00:00Z", + project: { name: "test", rootPath: "/test" }, + components: [], + complianceCapabilities: { + biasEvaluation: false, + jsonExport: false, + pdfExport: false, + loggingInfrastructure: false, + humanReviewFlow: false, + incidentHandling: false, + }, + metadata: { + importSource: "cyclonedx", + importTool: "cdxgen 10.0.0", + originalTimestamp: "2026-04-19T10:00:00Z", + }, + }); + expect(result.valid).toBe(true); + }); }); describe("ValidationResult structure", () => { From 96ce33a19c087fa1c578a74d5f33d3b6ac71378a Mon Sep 17 00:00:00 2001 From: Hiepler Date: Mon, 20 Apr 2026 09:57:17 +0200 Subject: [PATCH 16/20] simplify import orchestrator and fix code review findings --- packages/cli/src/commands/validate.ts | 4 +- packages/core/src/sbom/cyclonedx-import.ts | 64 ++++++++-------------- 2 files changed, 25 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index b3dee37..95ddc32 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,5 +1,5 @@ import { readFile, readdir, stat } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { validate } from "@euconform/core/validation"; import type { ValidationResult } from "@euconform/core/validation"; import { defineCommand } from "citty"; @@ -44,7 +44,7 @@ function printJsonResults(results: FileResult[]): void { function printHumanResults(results: FileResult[]): void { for (const { file, result } of results) { - const shortName = file.split("/").pop() ?? file; + const shortName = basename(file); if (result.valid) { consola.success(`${shortName} \u2014 valid (euconform.${result.schemaType})`); } else { diff --git a/packages/core/src/sbom/cyclonedx-import.ts b/packages/core/src/sbom/cyclonedx-import.ts index 9c370c3..9004cdd 100644 --- a/packages/core/src/sbom/cyclonedx-import.ts +++ b/packages/core/src/sbom/cyclonedx-import.ts @@ -47,77 +47,63 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone const allComponents = bom.components ?? []; const warnings: ImportWarning[] = []; - - // Step 1: Count raw total const totalComponents = allComponents.length; - // Step 2: Scope filtering const inScope = scope === "production" ? allComponents.filter((c) => c.scope !== "optional" && c.scope !== "excluded") : allComponents; const filteredByScope = totalComponents - inScope.length; - // Step 3: Map components const mapped: BomComponent[] = []; for (const comp of inScope) { - if (!comp.name || comp.name.trim() === "") { - warnings.push({ component: "(empty)", message: "Skipped: missing or empty name" }); + const mapping = mapComponent(comp); + if (!mapping) { + if (!comp.name || comp.name.trim() === "") { + warnings.push({ component: "(empty)", message: "Skipped: missing or empty name" }); + } continue; } - const mapping = mapComponent(comp); - if (!mapping) continue; - const version = comp.version || mapping.purlVersion; if (!version) { warnings.push({ component: comp.name, message: "Missing version field" }); } - const bomComponent: BomComponent = { + mapped.push({ id: version ? `${mapping.kind}:${comp.name}:${version}` : `${mapping.kind}:${comp.name}`, kind: mapping.kind, name: comp.name, ...(version ? { version } : {}), source: "sbom-import", - }; - - mapped.push(bomComponent); + }); } - // Step 4: Deduplicate (by kind+name+version) const seen = new Set(); let duplicatesRemoved = 0; const deduped: BomComponent[] = []; for (const comp of mapped) { - const key = `${comp.kind}:${comp.name}:${comp.version ?? ""}`; - if (seen.has(key)) { + if (seen.has(comp.id)) { duplicatesRemoved++; continue; } - seen.add(key); + seen.add(comp.id); deduped.push(comp); } - // Step 5: Compute byKind const byKind: Record = {}; for (const comp of deduped) { byKind[comp.kind] = (byKind[comp.kind] ?? 0) + 1; } - // Step 6: Extract provenance const { projectName, projectNameSource } = resolveProjectName(bom, options?.sourcePath); - const metadata = extractMetadata(bom); - const sourceInfo = extractSourceInfo(bom, projectNameSource); + const importTool = extractToolString(bom); + const originalTimestamp = bom.metadata?.timestamp; - // Step 7: Build aibom const aibom: AiBillOfMaterials = { schemaVersion: "euconform.aibom.v1", generatedAt: new Date().toISOString(), - project: { - name: projectName, - rootPath: ".", - }, + project: { name: projectName, rootPath: "." }, components: deduped, complianceCapabilities: { biasEvaluation: false, @@ -127,7 +113,7 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone humanReviewFlow: false, incidentHandling: false, }, - metadata, + metadata: buildMetadata(importTool, originalTimestamp), }; const validation = validate(aibom); @@ -142,7 +128,7 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone duplicatesRemoved, byKind, warnings, - source: sourceInfo, + source: buildSourceInfo(bom.specVersion, projectNameSource, importTool, originalTimestamp), }, validation, }; @@ -171,26 +157,22 @@ function extractToolString(bom: CycloneDxBom): string | undefined { return tool.version ? `${tool.name} ${tool.version}` : tool.name; } -function extractMetadata(bom: CycloneDxBom): AibomMetadata { +function buildMetadata(importTool?: string, originalTimestamp?: string): AibomMetadata { const meta: AibomMetadata = { importSource: "cyclonedx" }; - const importTool = extractToolString(bom); if (importTool) meta.importTool = importTool; - if (bom.metadata?.timestamp) meta.originalTimestamp = bom.metadata.timestamp; + if (originalTimestamp) meta.originalTimestamp = originalTimestamp; return meta; } -function extractSourceInfo( - bom: CycloneDxBom, - projectNameSource: ImportSourceInfo["projectNameSource"] +function buildSourceInfo( + specVersion: string, + projectNameSource: ImportSourceInfo["projectNameSource"], + importTool?: string, + originalTimestamp?: string ): ImportSourceInfo { - const info: ImportSourceInfo = { - bomFormat: "CycloneDX", - specVersion: bom.specVersion, - projectNameSource, - }; - const importTool = extractToolString(bom); + const info: ImportSourceInfo = { bomFormat: "CycloneDX", specVersion, projectNameSource }; if (importTool) info.importTool = importTool; - if (bom.metadata?.timestamp) info.originalTimestamp = bom.metadata.timestamp; + if (originalTimestamp) info.originalTimestamp = originalTimestamp; return info; } From 2578d8b95ebe7dd72acbbdeb3d1f4270b6348033 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Mon, 20 Apr 2026 17:44:24 +0200 Subject: [PATCH 17/20] adapt readme, add CLI documentation for evidence workflows --- README.md | 123 +++++++++++++++++++++++++++++++++++------ docs/spec/README.md | 25 +++++++-- packages/cli/README.md | 110 +++++++++++++++++++++++++++++++++++- 3 files changed, 234 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 764c320..6d3140a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@

EuConform

- ๐Ÿ‡ช๐Ÿ‡บ Open-Source EU AI Act Compliance Tool + ๐Ÿ‡ช๐Ÿ‡บ Open-Source Evidence Toolkit For AI Compliance

- Classify risk levels โ€ข Detect algorithmic bias โ€ข Generate compliance reports
- 100% offline โ€ข GDPR-by-design โ€ข WCAG 2.2 AA accessible + Open evidence format โ€ข Local bias evaluation โ€ข Schema validation โ€ข CycloneDX interoperability
+ Offline-first โ€ข Privacy-preserving โ€ข Reusable artifacts โ€ข WCAG 2.2 AA accessible

@@ -34,6 +34,10 @@

+

+ EuConform defines an open evidence format for AI compliance and provides the tools to produce, validate, and empirically evaluate it โ€” offline and vendor-independent. +

+ --- > [!IMPORTANT] @@ -59,17 +63,29 @@ | Feature | Description | |---------|-------------| -| ๐ŸŽฏ **Risk Classification** | Interactive quiz implementing EU AI Act Article 5 (prohibited), Article 6 + Annex III (high-risk) | -| ๐Ÿ“Š **Bias Detection** | CrowS-Pairs methodology with log-probability analysis for scientific bias measurement | -| ๐Ÿ“„ **PDF Reports** | Generate Annex IV-compliant technical documentation entirely in-browser | +| ๐Ÿงพ **Open Evidence Format** | Produce portable `report`, `aibom`, `ci`, and `bundle` artifacts as inspectable JSON documents | +| ๐Ÿงช **Local Bias Evaluation** | CrowS-Pairs-based model evaluation with log-probability and latency fallback โ€” reproducible, offline, no vendor dependency | +| โœ… **Schema Validation** | Validate EuConform JSON documents against the published schemas with `euconform validate` | +| ๐Ÿ“ฆ **Bundle Verification** | Verify manifest, directory, or ZIP bundle integrity before handing artifacts to CI, reviewers, or auditors | | ๐Ÿšฆ **Compliance CI Gate** | Turn `euconform scan` into GitHub-native annotations, CI summaries, and machine-readable artifacts | -| ๐ŸŒ **100% Offline** | All processing happens client-side using transformers.js (WebGPU) | -| ๐Ÿ”’ **Privacy-First** | Zero tracking, no cookies, no external fonts โ€“ your data never leaves your browser | -| ๐Ÿ“ค **Custom Test Suites** | Upload your own CSV/JSON test cases for domain-specific bias evaluation | +| ๐ŸŽฏ **Risk Classification** | Interactive quiz implementing EU AI Act Article 5 (prohibited), Article 6 + Annex III (high-risk) | +| ๐Ÿ”„ **CycloneDX Interoperability** | Import external CycloneDX SBOMs into the EuConform AI BOM layer as an interoperability bridge | +| ๐ŸŒ **Offline-First** | Core evidence workflows stay local and inspectable instead of depending on vendor dashboards | +| ๐Ÿ”’ **Privacy-Preserving** | Zero tracking, no cookies, no external fonts โ€“ your data stays under your control | | ๐ŸŒ™ **Dark Mode** | Beautiful glassmorphism design with full dark mode support | | โ™ฟ **Accessible** | WCAG 2.2 AA compliant with full keyboard navigation | | ๐ŸŒ **Multilingual** | English and German interface | +## ๐Ÿงฐ CLI At A Glance + +| Command | Primary output | Use case | +|---------|----------------|----------| +| `scan` | Native EuConform artifacts | Generate structured evidence from a real repository | +| `bias` | Bias report JSON and/or Markdown | Run reproducible local model evaluation with Ollama โ€” EuConform's distinctive empirical layer | +| `validate` | Valid/invalid status per JSON file | Check EuConform JSON files against published schemas | +| `verify` | Bundle integrity status | Check a manifest, extracted bundle, or ZIP archive | +| `import` | `euconform.aibom.json` | Map an external CycloneDX SBOM into the EuConform AI BOM layer | + ## ๐Ÿš€ Quick Start > **Want to try it without installation?** Click the [**๐ŸŒ Deploy**](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FHiepler%2FEuConform&project-name=euconform&repository-name=euconform) link above to start your own instance on Vercel. @@ -97,15 +113,20 @@ pnpm dev # Open http://localhost:3001 ``` -### CLI Scanner +### Build The CLI -The local scanner turns EuConform into a reproducible evidence tool for real repositories: +The repo-local examples below use the built CLI directly: ```bash # Build the CLI pnpm --filter @euconform/cli build +``` + +### Workflow 1: Scan A Repository + +Generate native EuConform artifacts from a real codebase: -# Scan the current project +```bash node packages/cli/dist/index.js scan . --scope production ``` @@ -115,6 +136,11 @@ This writes: - `.euconform/euconform.summary.md` - `.euconform/euconform.bundle.json` +Typical use: +- evidence collection for local OSS or internal AI projects +- CI gating and reviewer handoff +- portable artifact generation without a vendor platform + For CI usage, add GitHub-native annotations and fail thresholds: ```bash @@ -127,12 +153,65 @@ For portable artifact exchange, create a bundle archive: node packages/cli/dist/index.js scan . --scope production --zip true ``` +### Workflow 2: Validate And Verify Existing Artifacts + +Validate individual EuConform JSON documents against the published schemas: + +```bash +node packages/cli/dist/index.js validate .euconform +``` + +Typical output: +- one line per file such as `euconform.aibom.json โ€” valid (euconform.aibom.v1)` +- exit code `0` for fully valid input, `1` for schema errors, `2` when no EuConform JSON files are found + Verify a bundle manifest, extracted bundle directory, or ZIP archive: ```bash node packages/cli/dist/index.js verify .euconform/euconform.bundle.json ``` +Typical use: +- reviewer-side schema checking before manual analysis +- CI sanity checks for artifact sets already produced elsewhere +- portability checks before sharing bundles with downstream tools + +### Workflow 3: Evaluate Model Bias Locally + +Run a reproducible CrowS-Pairs bias evaluation against a local Ollama model: + +```bash +node packages/cli/dist/index.js bias llama3.2 --lang de --output all +``` + +This is EuConform's distinctive empirical evidence layer. It produces model-behavior data that no other open-source compliance tool currently offers โ€” completely offline, reproducible, and independent of any vendor API. + +Typical use: +- empirical model-behavior evidence for Art. 10 bias/fairness documentation +- reproducible local evaluation before and after model updates +- adding a behavioral evidence layer on top of structural evidence from `scan` + +### Workflow 4: Import An External CycloneDX SBOM + +Map an external CycloneDX JSON file into the EuConform AI BOM layer: + +```bash +node packages/cli/dist/index.js import ./third-party.cdx.json \ + --scope production \ + --output /tmp/euconform-import + +node packages/cli/dist/index.js validate /tmp/euconform-import/euconform.aibom.json +``` + +This writes: +- `/tmp/euconform-import/euconform.aibom.json` + +Important notes: +- `import` accepts CycloneDX JSON and maps only the AI-relevant subset into `euconform.aibom.v1` +- `--scope production` excludes `optional` and `excluded` components +- the importer is intentionally conservative and does **not** infer compliance capabilities from an SBOM +- project naming may come from BOM metadata or the source filename, depending on the input + ### Try The Format In 10 Minutes If you want to evaluate the current adoption path as an OSS builder, use one of the @@ -193,10 +272,14 @@ Supports Llama, Mistral, and Qwen variants with automatic log-probability detect ### CLI Scanner & CI -`euconform scan` is designed to complement the web wizard: -- The **scanner** gathers technical evidence from a real codebase. -- The **web app** remains the place for role and risk classification with human context. -- The **bias evaluation tooling** adds empirical model-behavior evidence on top. +EuConform's CLI is designed as reusable evidence infrastructure: +- `euconform scan` produces native EuConform artifacts from a repository. +- `euconform bias` provides EuConform's distinctive empirical model-behavior evidence layer. +- `euconform validate` checks individual EuConform JSON files against the published schemas. +- `euconform verify` checks artifact-set integrity for manifests, directories, and ZIP bundles. +- `euconform import` bridges external CycloneDX JSON into the EuConform AI BOM layer. + +The **web app** remains the place for role and risk classification with human context. #### GitHub Actions Example @@ -215,13 +298,19 @@ In GitHub Actions, EuConform emits: ### EuConform Evidence Format -The scanner artifacts are defined as the **EuConform Evidence Format**, an open specification for offline AI Act evidence exchange. +EuConform implements the **EuConform Evidence Format**, an open specification for portable, machine-readable AI compliance evidence. - `euconform.report.v1` captures compliance evidence, gaps, and open questions - `euconform.aibom.v1` is the AI Bill of Materials (AI BOM) inventory layer - `euconform.ci.v1` captures CI thresholds, status, and top findings - `euconform.bundle.v1` binds artifact sets into a portable, integrity-aware manifest +Current workflow boundaries: +- `scan` **produces** native EuConform artifacts from source repositories +- `import` can **ingest** external CycloneDX JSON into the current AIBOM layer for interoperability workflows +- `validate` makes the schema and compatibility story inspectable at the JSON-document level +- `verify` checks portable bundle integrity before exchange or automation + Stage 1 documentation, schemas, and example artifacts live in [docs/spec/README.md](docs/spec/README.md). Reference source projects for OSS builders live in [examples/README.md](examples/README.md). diff --git a/docs/spec/README.md b/docs/spec/README.md index 8871952..dbb3486 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -1,6 +1,6 @@ # EuConform Evidence Format -The **EuConform Evidence Format** is the open specification behind the scanner artifacts produced by EuConform. +EuConform implements the **EuConform Evidence Format**, an open specification for portable, machine-readable AI compliance evidence. ## Document types @@ -23,12 +23,27 @@ The **EuConform Evidence Format** is the open specification behind the scanner a - the format is currently documented as an **open specification** - `AI BOM` is a **sub-specification** inside the format, not the umbrella name +## Interoperability + +EuConform supports native artifact generation, empirical model evaluation, and external ingestion: + +- `scan` generates native EuConform artifacts from a repository +- `bias` produces reproducible model-behavior evidence via local CrowS-Pairs evaluation โ€” EuConform's distinctive empirical layer +- `validate` checks EuConform JSON documents against the published schemas +- `verify` checks bundle integrity for manifests, extracted directories, and ZIP archives +- `import` maps external CycloneDX JSON into the current `euconform.aibom.v1` layer as an interoperability bridge + +Important boundaries: +- `bias` is independent of `scan` and can be used standalone for model evaluation +- `import` does **not** replace a full native EuConform scan of a repository +- `validate` and `verify` complement each other: schema checks for individual documents, integrity checks for artifact sets + ## Versioning and compatibility - `schemaVersion` is the compatibility boundary for every document - Schemas enforce `additionalProperties: false` โ€” all fields must be explicitly defined -- Patch releases must not change document shape -- Adding new optional fields requires a new schema revision (e.g. `report-v1.1.schema.json`) because strict schemas reject unknown properties +- Published schema revisions should avoid changing document shape in place +- When new fields affect document shape or compatibility expectations, prefer a new schema revision (e.g. `report-v1.1.schema.json`) because strict schemas reject unknown properties - Major schema changes must use a new `schemaVersion` (e.g. `euconform.report.v2`) ## Schemas @@ -55,7 +70,9 @@ The **EuConform Evidence Format** is the open specification behind the scanner a ## Verification flow - Build the CLI with `pnpm --filter @euconform/cli build` -- Generate artifacts with `node packages/cli/dist/index.js scan .` +- Generate native artifacts with `node packages/cli/dist/index.js scan .` +- Validate EuConform JSON documents with `node packages/cli/dist/index.js validate ` +- Optionally import a CycloneDX JSON file with `node packages/cli/dist/index.js import ` - Optionally create a transport archive with `node packages/cli/dist/index.js scan . --zip true` - Verify a manifest, bundle directory, or ZIP archive with `node packages/cli/dist/index.js verify ` - Hash and metadata mismatches are warnings by default and become errors in strict mode diff --git a/packages/cli/README.md b/packages/cli/README.md index c6a9309..4cad0ed 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2,14 +2,17 @@ `@euconform/cli` is the command-line interface for **EuConform**, an offline-first evidence engine for European AI systems. -It scans real project directories, produces machine-readable AI Act evidence, and verifies evidence bundles before they are handed to CI, reviewers, auditors, or downstream tools. +It scans real project directories, runs reproducible local bias evaluation against Ollama models, validates and verifies machine-readable evidence, and can import external CycloneDX SBOMs into the EuConform AI BOM layer. The CLI is also the first public producer and consumer of the **EuConform Evidence Format**. ## What it does - `scan` inspects a codebase and generates structured evidence artifacts +- `bias` runs a reproducible local CrowS-Pairs evaluation against an Ollama model โ€” EuConform's distinctive empirical layer +- `validate` checks EuConform JSON files against the published schemas - `verify` validates an EuConform Evidence Format bundle manifest, directory, or ZIP archive +- `import` maps a CycloneDX JSON SBOM into `euconform.aibom.v1` as an interoperability bridge - outputs stay local and can be reviewed in the EuConform web viewer This package is designed for teams that want **technical evidence for human review**, not automated legal verdicts. @@ -29,6 +32,16 @@ npm install -g @euconform/cli euconform scan . ``` +## Command Overview + +| Command | Input | Output | Primary use case | +|---------|-------|--------|------------------| +| `scan ` | Repository directory | EuConform artifact set in `.euconform/` | Native evidence generation from source code | +| `bias ` | Ollama model name | Bias report JSON and/or Markdown | Reproducible local model evaluation โ€” EuConform's distinctive empirical layer | +| `validate ` | EuConform JSON file or directory | Valid/invalid status per file | Schema checks in CI, review, or local QA | +| `verify ` | Bundle manifest, extracted bundle dir, or ZIP | Integrity status | Artifact exchange and transport verification | +| `import ` | CycloneDX JSON SBOM | `euconform.aibom.json` | Interoperability bridge from external SBOM ecosystems | + ## Commands ### `euconform scan ` @@ -55,6 +68,31 @@ Optional CI mode: euconform scan . --ci github --fail-on high ``` +### `euconform validate ` + +Validates EuConform JSON files against the published schemas. + +Accepted inputs: +- a single `.json` file +- a directory containing files named like `euconform.*.json` + +Example: + +```bash +euconform validate .euconform +``` + +Machine-readable output: + +```bash +euconform validate .euconform --json +``` + +Exit codes: +- `0` all matched files are valid +- `1` one or more files failed schema validation +- `2` no matching EuConform JSON files were found + ### `euconform verify ` Verifies an EuConform Evidence Format bundle in one of three forms: @@ -81,9 +119,72 @@ Machine-readable output: euconform verify .euconform/euconform.bundle.json --json ``` +### `euconform bias ` + +Runs a reproducible CrowS-Pairs bias evaluation against a local Ollama model. + +This is EuConform's distinctive empirical layer โ€” no other open-source compliance tool currently offers reproducible, offline model-behavior evaluation for AI Act documentation. + +Example: + +```bash +euconform bias llama3.2 --lang de --output all +``` + +What it does: +- evaluates model bias locally via CrowS-Pairs methodology with log-probability or latency fallback +- produces structured bias reports as JSON and/or Markdown +- runs completely offline against any Ollama-compatible model +- supports German and English evaluation datasets + +Typical use: +- Art. 10 bias/fairness documentation with empirical evidence +- reproducible evaluation before and after model updates +- behavioral evidence layer on top of structural evidence from `scan` + +### `euconform import ` + +Imports a CycloneDX JSON SBOM and writes an EuConform AI BOM. + +Example: + +```bash +euconform import third-party.cdx.json --scope production +``` + +What it does: +- accepts CycloneDX JSON as input +- maps AI-relevant components into `euconform.aibom.v1` +- writes `euconform.aibom.json` to `.euconform/` by default +- excludes `optional` and `excluded` components when `--scope production` is used +- can derive the project name from BOM metadata or the source filename +- keeps `complianceCapabilities` conservative instead of inferring them from the SBOM + +Machine-readable output: + +```bash +euconform import third-party.cdx.json --scope production --json +``` + +## Interoperability Workflow + +When you already have a third-party SBOM and want to bring it into the EuConform workflow: + +```bash +euconform import third-party.cdx.json --scope production +euconform validate .euconform/euconform.aibom.json +# If you also have a EuConform bundle manifest, directory, or ZIP: +euconform verify path/to/euconform.bundle.json +``` + +Notes: +- `import` is an interoperability bridge into the current AIBOM layer +- `validate` checks EuConform document shape +- `verify` is only needed when you are working with a bundle manifest, directory, or ZIP archive + ## What is the EuConform Evidence Format? -The **EuConform Evidence Format** is an open specification for structured, offline AI Act evidence exchange. +EuConform implements the **EuConform Evidence Format**, an open specification for portable, machine-readable AI compliance evidence. Today the CLI works with these document types: @@ -105,9 +206,12 @@ Most AI compliance workflows still depend on PDFs, screenshots, checklists, or v `@euconform/cli` takes a different approach: - scan implementation evidence instead of asking only questionnaires +- evaluate model bias locally and reproducibly instead of relying on third-party APIs +- validate inspectable JSON instead of relying on opaque one-off exports - produce versionable artifacts instead of one-off documents - verify bundle integrity before evidence is shared -- keep outputs portable and inspectable outside one product UI +- bridge external SBOM ecosystems into an open AI evidence layer +- keep review portable and inspectable outside one product UI or vendor workflow ## Scope From 17d154ca950f2776db90994673db87fec27694ce Mon Sep 17 00:00:00 2001 From: Hiepler Date: Tue, 21 Apr 2026 11:32:00 +0200 Subject: [PATCH 18/20] introduce AI BOM v1.1 schema, add PURL decoding error handling, and clean up core exports --- README.md | 2 +- .../schemas/spec/aibom-v1.1.schema.json | 92 +++++++++++++++++++ .../public/schemas/spec/aibom-v1.schema.json | 16 ---- docs/spec/README.md | 4 +- docs/spec/schemas/aibom-v1.1.schema.json | 92 +++++++++++++++++++ docs/spec/schemas/aibom-v1.schema.json | 16 ---- packages/cli/README.md | 2 +- packages/core/src/evidence/types.ts | 2 +- packages/core/src/evidence/validate.ts | 15 ++- packages/core/src/index.ts | 15 --- packages/core/src/sbom/cyclonedx-import.ts | 2 +- packages/core/src/sbom/purl.ts | 7 +- .../core/src/validation/schema-validator.ts | 3 +- .../core/tests/sbom/cyclonedx-import.test.ts | 2 +- packages/core/tests/sbom/purl.test.ts | 8 ++ .../tests/validation/schema-validator.test.ts | 25 ++++- 16 files changed, 241 insertions(+), 62 deletions(-) create mode 100644 apps/docs/public/schemas/spec/aibom-v1.1.schema.json create mode 100644 docs/spec/schemas/aibom-v1.1.schema.json diff --git a/README.md b/README.md index 6d3140a..47e1eb0 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ This writes: - `/tmp/euconform-import/euconform.aibom.json` Important notes: -- `import` accepts CycloneDX JSON and maps only the AI-relevant subset into `euconform.aibom.v1` +- `import` accepts CycloneDX JSON and maps only the AI-relevant subset into `euconform.aibom.v1.1` - `--scope production` excludes `optional` and `excluded` components - the importer is intentionally conservative and does **not** infer compliance capabilities from an SBOM - project naming may come from BOM metadata or the source filename, depending on the input diff --git a/apps/docs/public/schemas/spec/aibom-v1.1.schema.json b/apps/docs/public/schemas/spec/aibom-v1.1.schema.json new file mode 100644 index 0000000..cd04204 --- /dev/null +++ b/apps/docs/public/schemas/spec/aibom-v1.1.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://euconform.eu/schemas/spec/aibom-v1.1.schema.json", + "title": "EuConform Evidence Format AI BOM v1.1", + "description": "Stage 1 inventory document for AI-relevant components and compliance capabilities. Adds optional import provenance metadata.", + "type": "object", + "required": ["schemaVersion", "generatedAt", "project", "components", "complianceCapabilities"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "const": "euconform.aibom.v1.1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "project": { + "type": "object", + "required": ["name", "rootPath"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "rootPath": { "type": "string" } + } + }, + "components": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "kind", "name", "source"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "kind": { + "enum": [ + "framework", + "runtime", + "inference-provider", + "ai-framework", + "model", + "vector-store", + "embedding", + "dataset", + "tool" + ] + }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "source": { + "enum": ["package.json", "lock-file", "code", "config", "docs", "sbom-import"] + } + } + } + }, + "complianceCapabilities": { + "type": "object", + "required": [ + "biasEvaluation", + "jsonExport", + "pdfExport", + "loggingInfrastructure", + "humanReviewFlow", + "incidentHandling" + ], + "additionalProperties": false, + "properties": { + "biasEvaluation": { "type": "boolean" }, + "jsonExport": { "type": "boolean" }, + "pdfExport": { "type": "boolean" }, + "loggingInfrastructure": { "type": "boolean" }, + "humanReviewFlow": { "type": "boolean" }, + "incidentHandling": { "type": "boolean" } + } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "importSource": { + "enum": ["cyclonedx", "spdx", "manual"] + }, + "importTool": { + "type": "string" + }, + "originalTimestamp": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/apps/docs/public/schemas/spec/aibom-v1.schema.json b/apps/docs/public/schemas/spec/aibom-v1.schema.json index 9971faf..08e3a57 100644 --- a/apps/docs/public/schemas/spec/aibom-v1.schema.json +++ b/apps/docs/public/schemas/spec/aibom-v1.schema.json @@ -71,22 +71,6 @@ "humanReviewFlow": { "type": "boolean" }, "incidentHandling": { "type": "boolean" } } - }, - "metadata": { - "type": "object", - "additionalProperties": false, - "properties": { - "importSource": { - "enum": ["cyclonedx", "spdx", "manual"] - }, - "importTool": { - "type": "string" - }, - "originalTimestamp": { - "type": "string", - "format": "date-time" - } - } } } } diff --git a/docs/spec/README.md b/docs/spec/README.md index dbb3486..f48c6e1 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -10,6 +10,7 @@ EuConform implements the **EuConform Evidence Format**, an open specification fo |----------|---------| | `euconform.report.v1` | Compliance-oriented evidence, open questions, gaps, and recommendations | | `euconform.aibom.v1` | AI Bill of Materials (AI BOM) inventory for runtimes, providers, models, and supporting components | +| `euconform.aibom.v1.1` | AI BOM v1 plus optional import provenance metadata | | `euconform.ci.v1` | CI gate status, fail threshold, gap counts, and top findings | ### Stage 2 โ€” Available @@ -31,7 +32,7 @@ EuConform supports native artifact generation, empirical model evaluation, and e - `bias` produces reproducible model-behavior evidence via local CrowS-Pairs evaluation โ€” EuConform's distinctive empirical layer - `validate` checks EuConform JSON documents against the published schemas - `verify` checks bundle integrity for manifests, extracted directories, and ZIP archives -- `import` maps external CycloneDX JSON into the current `euconform.aibom.v1` layer as an interoperability bridge +- `import` maps external CycloneDX JSON into the AIBOM layer (emits `euconform.aibom.v1.1`) as an interoperability bridge Important boundaries: - `bias` is independent of `scan` and can be used standalone for model evaluation @@ -50,6 +51,7 @@ Important boundaries: - [Report schema](./schemas/report-v1.schema.json) - [AI BOM schema](./schemas/aibom-v1.schema.json) +- [AI BOM v1.1 schema](./schemas/aibom-v1.1.schema.json) - [CI schema](./schemas/ci-v1.schema.json) - [Bundle schema](./schemas/bundle-v1.schema.json) diff --git a/docs/spec/schemas/aibom-v1.1.schema.json b/docs/spec/schemas/aibom-v1.1.schema.json new file mode 100644 index 0000000..cd04204 --- /dev/null +++ b/docs/spec/schemas/aibom-v1.1.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://euconform.eu/schemas/spec/aibom-v1.1.schema.json", + "title": "EuConform Evidence Format AI BOM v1.1", + "description": "Stage 1 inventory document for AI-relevant components and compliance capabilities. Adds optional import provenance metadata.", + "type": "object", + "required": ["schemaVersion", "generatedAt", "project", "components", "complianceCapabilities"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "const": "euconform.aibom.v1.1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "project": { + "type": "object", + "required": ["name", "rootPath"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "rootPath": { "type": "string" } + } + }, + "components": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "kind", "name", "source"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "kind": { + "enum": [ + "framework", + "runtime", + "inference-provider", + "ai-framework", + "model", + "vector-store", + "embedding", + "dataset", + "tool" + ] + }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "source": { + "enum": ["package.json", "lock-file", "code", "config", "docs", "sbom-import"] + } + } + } + }, + "complianceCapabilities": { + "type": "object", + "required": [ + "biasEvaluation", + "jsonExport", + "pdfExport", + "loggingInfrastructure", + "humanReviewFlow", + "incidentHandling" + ], + "additionalProperties": false, + "properties": { + "biasEvaluation": { "type": "boolean" }, + "jsonExport": { "type": "boolean" }, + "pdfExport": { "type": "boolean" }, + "loggingInfrastructure": { "type": "boolean" }, + "humanReviewFlow": { "type": "boolean" }, + "incidentHandling": { "type": "boolean" } + } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "importSource": { + "enum": ["cyclonedx", "spdx", "manual"] + }, + "importTool": { + "type": "string" + }, + "originalTimestamp": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/docs/spec/schemas/aibom-v1.schema.json b/docs/spec/schemas/aibom-v1.schema.json index 9971faf..08e3a57 100644 --- a/docs/spec/schemas/aibom-v1.schema.json +++ b/docs/spec/schemas/aibom-v1.schema.json @@ -71,22 +71,6 @@ "humanReviewFlow": { "type": "boolean" }, "incidentHandling": { "type": "boolean" } } - }, - "metadata": { - "type": "object", - "additionalProperties": false, - "properties": { - "importSource": { - "enum": ["cyclonedx", "spdx", "manual"] - }, - "importTool": { - "type": "string" - }, - "originalTimestamp": { - "type": "string", - "format": "date-time" - } - } } } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 4cad0ed..1216103 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12,7 +12,7 @@ The CLI is also the first public producer and consumer of the **EuConform Eviden - `bias` runs a reproducible local CrowS-Pairs evaluation against an Ollama model โ€” EuConform's distinctive empirical layer - `validate` checks EuConform JSON files against the published schemas - `verify` validates an EuConform Evidence Format bundle manifest, directory, or ZIP archive -- `import` maps a CycloneDX JSON SBOM into `euconform.aibom.v1` as an interoperability bridge +- `import` maps a CycloneDX JSON SBOM into `euconform.aibom.v1.1` as an interoperability bridge - outputs stay local and can be reviewed in the EuConform web viewer This package is designed for teams that want **technical evidence for human review**, not automated legal verdicts. diff --git a/packages/core/src/evidence/types.ts b/packages/core/src/evidence/types.ts index 60fa632..5e54ca9 100644 --- a/packages/core/src/evidence/types.ts +++ b/packages/core/src/evidence/types.ts @@ -242,7 +242,7 @@ export interface AibomMetadata { } export interface AiBillOfMaterials { - schemaVersion: "euconform.aibom.v1"; + schemaVersion: "euconform.aibom.v1" | "euconform.aibom.v1.1"; generatedAt: string; project: { name: string; diff --git a/packages/core/src/evidence/validate.ts b/packages/core/src/evidence/validate.ts index 0c79338..63768d1 100644 --- a/packages/core/src/evidence/validate.ts +++ b/packages/core/src/evidence/validate.ts @@ -7,10 +7,16 @@ function assertObject(data: unknown, label: string): Record { return data as Record; } -function requireSchemaVersion(obj: Record, expected: string, label: string): void { - if (obj.schemaVersion !== expected) { +function requireSchemaVersion( + obj: Record, + expected: string | string[], + label: string +): void { + const allowed = Array.isArray(expected) ? expected : [expected]; + if (!allowed.includes(obj.schemaVersion as string)) { + const quoted = allowed.map((v) => `"${v}"`).join(" or "); throw new Error( - `Invalid ${label} schema version: expected "${expected}", got "${String(obj.schemaVersion)}"` + `Invalid ${label} schema version: expected ${quoted}, got "${String(obj.schemaVersion)}"` ); } } @@ -102,7 +108,7 @@ export function validateScanReport(data: unknown): ScanReport { export function validateAiBillOfMaterials(data: unknown): AiBillOfMaterials { const obj = assertObject(data, "AIBOM"); - requireSchemaVersion(obj, "euconform.aibom.v1", "AIBOM"); + requireSchemaVersion(obj, ["euconform.aibom.v1", "euconform.aibom.v1.1"], "AIBOM"); requireField(obj, "generatedAt", "string", "AIBOM"); const project = requireField(obj, "project", "object", "AIBOM") as Record; @@ -289,6 +295,7 @@ export function validateEcefJsonDocument( case "euconform.report.v1": return validateScanReport(data); case "euconform.aibom.v1": + case "euconform.aibom.v1.1": return validateAiBillOfMaterials(data); case "euconform.ci.v1": return validateCiReport(data); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a4725d3..7297b6c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -174,18 +174,3 @@ export { calculateLatencyBias, validateCrowsPairsDataset, } from "./metrics"; - -// Validation -export { - validate, - type ValidationResult, - type ValidationError, - type SchemaType, -} from "./validation"; - -// SBOM -export { - importCycloneDx, - type CycloneDxImportResult, - type ImportSummary, -} from "./sbom"; diff --git a/packages/core/src/sbom/cyclonedx-import.ts b/packages/core/src/sbom/cyclonedx-import.ts index 9004cdd..51f3d2e 100644 --- a/packages/core/src/sbom/cyclonedx-import.ts +++ b/packages/core/src/sbom/cyclonedx-import.ts @@ -101,7 +101,7 @@ export function importCycloneDx(sbom: unknown, options?: ImportOptions): Cyclone const originalTimestamp = bom.metadata?.timestamp; const aibom: AiBillOfMaterials = { - schemaVersion: "euconform.aibom.v1", + schemaVersion: "euconform.aibom.v1.1", generatedAt: new Date().toISOString(), project: { name: projectName, rootPath: "." }, components: deduped, diff --git a/packages/core/src/sbom/purl.ts b/packages/core/src/sbom/purl.ts index 97383fe..ae48257 100644 --- a/packages/core/src/sbom/purl.ts +++ b/packages/core/src/sbom/purl.ts @@ -28,8 +28,11 @@ export function parsePurl(purl: string): ParsedPurl | null { const type = rest.slice(0, slashIdx); let remainder = rest.slice(slashIdx + 1); - // Decode percent-encoding - remainder = decodeURIComponent(remainder); + try { + remainder = decodeURIComponent(remainder); + } catch { + return null; + } // Extract version let version: string | undefined; diff --git a/packages/core/src/validation/schema-validator.ts b/packages/core/src/validation/schema-validator.ts index 0deb696..0696ebb 100644 --- a/packages/core/src/validation/schema-validator.ts +++ b/packages/core/src/validation/schema-validator.ts @@ -12,7 +12,7 @@ export interface ValidationError { received?: unknown; } -export type SchemaType = "report.v1" | "aibom.v1" | "ci.v1" | "bundle.v1"; +export type SchemaType = "report.v1" | "aibom.v1" | "aibom.v1.1" | "ci.v1" | "bundle.v1"; export interface ValidationResult { valid: boolean; @@ -42,6 +42,7 @@ function loadSchema(filename: string): object { const SCHEMA_MAP: Record = { "euconform.report.v1": { schemaFile: "report-v1.schema.json", type: "report.v1" }, "euconform.aibom.v1": { schemaFile: "aibom-v1.schema.json", type: "aibom.v1" }, + "euconform.aibom.v1.1": { schemaFile: "aibom-v1.1.schema.json", type: "aibom.v1.1" }, "euconform.ci.v1": { schemaFile: "ci-v1.schema.json", type: "ci.v1" }, "euconform.bundle.v1": { schemaFile: "bundle-v1.schema.json", type: "bundle.v1" }, }; diff --git a/packages/core/tests/sbom/cyclonedx-import.test.ts b/packages/core/tests/sbom/cyclonedx-import.test.ts index db40b26..2c3b2ae 100644 --- a/packages/core/tests/sbom/cyclonedx-import.test.ts +++ b/packages/core/tests/sbom/cyclonedx-import.test.ts @@ -8,7 +8,7 @@ describe("cyclonedx-import", () => { describe("basic import", () => { it("imports a minimal CycloneDX BOM", () => { const result = importCycloneDx(minimalBom); - expect(result.aibom.schemaVersion).toBe("euconform.aibom.v1"); + expect(result.aibom.schemaVersion).toBe("euconform.aibom.v1.1"); expect(result.aibom.components).toHaveLength(1); expect(result.aibom.components[0].kind).toBe("model"); expect(result.aibom.components[0].name).toBe("llama-3.2-1b"); diff --git a/packages/core/tests/sbom/purl.test.ts b/packages/core/tests/sbom/purl.test.ts index 662174d..79a3905 100644 --- a/packages/core/tests/sbom/purl.test.ts +++ b/packages/core/tests/sbom/purl.test.ts @@ -91,4 +91,12 @@ describe("parsePurl", () => { expect(parsePurl("pkg:")).toBeNull(); expect(parsePurl("pkg:/")).toBeNull(); }); + + it("returns null for malformed percent-encoding", () => { + expect(parsePurl("pkg:npm/%ZZ/bad-encoding@1.0.0")).toBeNull(); + }); + + it("returns null for truncated percent-encoding", () => { + expect(parsePurl("pkg:npm/foo%2")).toBeNull(); + }); }); diff --git a/packages/core/tests/validation/schema-validator.test.ts b/packages/core/tests/validation/schema-validator.test.ts index 27b50b4..d67aabe 100644 --- a/packages/core/tests/validation/schema-validator.test.ts +++ b/packages/core/tests/validation/schema-validator.test.ts @@ -158,9 +158,9 @@ describe("schema-validator", () => { expect(result.valid).toBe(true); }); - it("accepts aibom with metadata field", () => { + it("accepts aibom v1.1 with metadata field", () => { const result = validate({ - schemaVersion: "euconform.aibom.v1", + schemaVersion: "euconform.aibom.v1.1", generatedAt: "2026-01-01T00:00:00Z", project: { name: "test", rootPath: "/test" }, components: [], @@ -180,6 +180,27 @@ describe("schema-validator", () => { }); expect(result.valid).toBe(true); }); + + it("rejects aibom v1 with metadata field", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1", + generatedAt: "2026-01-01T00:00:00Z", + project: { name: "test", rootPath: "/test" }, + components: [], + complianceCapabilities: { + biasEvaluation: false, + jsonExport: false, + pdfExport: false, + loggingInfrastructure: false, + humanReviewFlow: false, + incidentHandling: false, + }, + metadata: { + importSource: "cyclonedx", + }, + }); + expect(result.valid).toBe(false); + }); }); describe("ValidationResult structure", () => { From 0f6c07dd58a386d51abf72e758311709dddb3a77 Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 24 Apr 2026 11:29:58 +0200 Subject: [PATCH 19/20] add project documentation, planning artifacts, and schema governance files --- .changeset/schema-validator-cyclonedx-import.md | 12 ++++++++++++ packages/cli/README.md | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .changeset/schema-validator-cyclonedx-import.md diff --git a/.changeset/schema-validator-cyclonedx-import.md b/.changeset/schema-validator-cyclonedx-import.md new file mode 100644 index 0000000..59bc1c3 --- /dev/null +++ b/.changeset/schema-validator-cyclonedx-import.md @@ -0,0 +1,12 @@ +--- +"@euconform/core": minor +"@euconform/cli": minor +--- + +Add schema validation, CycloneDX SBOM import, and AI BOM v1.1 schema revision + +- Introduce `euconform.aibom.v1.1` schema with optional import provenance metadata +- Add `validate` command for checking EuConform JSON documents against published schemas +- Add `import` command for mapping CycloneDX JSON SBOMs into the EuConform AI BOM layer +- Add PURL parser with graceful handling of malformed percent-encoding +- Remove `validate` and `importCycloneDx` from core barrel export (available via `@euconform/core/validation` and `@euconform/core/sbom` subpath exports) diff --git a/packages/cli/README.md b/packages/cli/README.md index 1216103..367462d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -154,7 +154,7 @@ euconform import third-party.cdx.json --scope production What it does: - accepts CycloneDX JSON as input -- maps AI-relevant components into `euconform.aibom.v1` +- maps AI-relevant components into `euconform.aibom.v1.1` - writes `euconform.aibom.json` to `.euconform/` by default - excludes `optional` and `excluded` components when `--scope production` is used - can derive the project name from BOM metadata or the source filename From aacf9094c6729d3d5aeb33d977fe6c50322ceeae Mon Sep 17 00:00:00 2001 From: Hiepler Date: Fri, 24 Apr 2026 11:37:12 +0200 Subject: [PATCH 20/20] update pnpm lock file --- pnpm-lock.yaml | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ed7fe5..fcaaac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,12 @@ importers: '@xenova/transformers': specifier: ^2.17.2 version: 2.17.2 + ajv: + specifier: ^8.18.0 + version: 8.18.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.18.0) ignore: specifier: ^7.0.3 version: 7.0.5 @@ -1924,6 +1930,17 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2325,6 +2342,9 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -2332,6 +2352,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -2589,6 +2612,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3111,6 +3137,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -5086,6 +5116,17 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-escapes@7.2.0: @@ -5483,6 +5524,8 @@ snapshots: extendable-error@0.1.7: {} + fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} fast-glob@3.3.3: @@ -5493,6 +5536,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -5734,6 +5779,8 @@ snapshots: jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} jsonfile@4.0.0: @@ -6217,6 +6264,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} restore-cursor@5.1.0: