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/README.md b/README.md index 764c320..47e1eb0 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.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 + ### 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/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 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/docs/spec/README.md b/docs/spec/README.md index 8871952..f48c6e1 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 @@ -10,6 +10,7 @@ The **EuConform Evidence Format** is the open specification behind the scanner a |----------|---------| | `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 @@ -23,18 +24,34 @@ 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 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 +- `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 - [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) @@ -55,7 +72,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/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 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/cli/README.md b/packages/cli/README.md index c6a9309..367462d 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.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. @@ -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.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 +- 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 diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts new file mode 100644 index 0000000..97f4043 --- /dev/null +++ b/packages/cli/src/commands/import.ts @@ -0,0 +1,126 @@ +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 and excluded-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, sourcePath: inputPath }); + } 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); + 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: ${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])) { + 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/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 0000000..95ddc32 --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,117 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import { basename, 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 = basename(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", + 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 = await Promise.all(files.map(validateFile)); + + if (args.json) { + printJsonResults(results); + } else { + printHumanResults(results); + } + + 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..81eda93 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,7 +2,9 @@ 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"; const main = defineCommand({ @@ -12,8 +14,10 @@ const main = defineCommand({ }, subCommands: { scan: scanCommand, + validate: validateCommand, verify: verifyCommand, bias: biasCommand, + import: importCommand, }, }); 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/package.json b/packages/core/package.json index 7ee6e94..549e926 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,9 @@ "./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", + "./sbom": "./src/sbom/index.ts" }, "scripts": { "build": "tsc", @@ -40,6 +42,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/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, }; diff --git a/packages/core/src/evidence/types.ts b/packages/core/src/evidence/types.ts index 280c9af..5e54ca9 100644 --- a/packages/core/src/evidence/types.ts +++ b/packages/core/src/evidence/types.ts @@ -232,11 +232,17 @@ 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 AibomMetadata { + importSource: "cyclonedx" | "spdx" | "manual"; + importTool?: string; + originalTimestamp?: string; } export interface AiBillOfMaterials { - schemaVersion: "euconform.aibom.v1"; + schemaVersion: "euconform.aibom.v1" | "euconform.aibom.v1.1"; generatedAt: string; project: { name: string; @@ -251,6 +257,7 @@ export interface AiBillOfMaterials { humanReviewFlow: boolean; incidentHandling: boolean; }; + metadata?: AibomMetadata; } // --------------------------------------------------------------------------- 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/sbom/component-mapper.ts b/packages/core/src/sbom/component-mapper.ts new file mode 100644 index 0000000..4a99f40 --- /dev/null +++ b/packages/core/src/sbom/component-mapper.ts @@ -0,0 +1,107 @@ +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< + 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" }, +}; + +/** 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. + * + * 8-tier detection (descending priority): + * 1. ML-BOM extensions (modelCard, data) + * 2. CycloneDX component type mapping + * 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 + * + * 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", purlVersion }; + } + + if (Array.isArray(component.data) && component.data.some((d) => d.type === "dataset")) { + 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, + purlVersion, + }; + } + + // 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", 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/cyclonedx-import.ts b/packages/core/src/sbom/cyclonedx-import.ts new file mode 100644 index 0000000..51f3d2e --- /dev/null +++ b/packages/core/src/sbom/cyclonedx-import.ts @@ -0,0 +1,198 @@ +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[]; + source: ImportSourceInfo; +} + +export interface ImportOptions { + scope?: "all" | "production"; + sourcePath?: string; +} + +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 allComponents = bom.components ?? []; + const warnings: ImportWarning[] = []; + const totalComponents = allComponents.length; + + const inScope = + scope === "production" + ? allComponents.filter((c) => c.scope !== "optional" && c.scope !== "excluded") + : allComponents; + const filteredByScope = totalComponents - inScope.length; + + const mapped: BomComponent[] = []; + for (const comp of inScope) { + const mapping = mapComponent(comp); + if (!mapping) { + if (!comp.name || comp.name.trim() === "") { + warnings.push({ component: "(empty)", message: "Skipped: missing or empty name" }); + } + continue; + } + + const version = comp.version || mapping.purlVersion; + if (!version) { + warnings.push({ component: comp.name, message: "Missing version field" }); + } + + mapped.push({ + id: version ? `${mapping.kind}:${comp.name}:${version}` : `${mapping.kind}:${comp.name}`, + kind: mapping.kind, + name: comp.name, + ...(version ? { version } : {}), + source: "sbom-import", + }); + } + + const seen = new Set(); + let duplicatesRemoved = 0; + const deduped: BomComponent[] = []; + for (const comp of mapped) { + if (seen.has(comp.id)) { + duplicatesRemoved++; + continue; + } + seen.add(comp.id); + deduped.push(comp); + } + + const byKind: Record = {}; + for (const comp of deduped) { + byKind[comp.kind] = (byKind[comp.kind] ?? 0) + 1; + } + + const { projectName, projectNameSource } = resolveProjectName(bom, options?.sourcePath); + const importTool = extractToolString(bom); + const originalTimestamp = bom.metadata?.timestamp; + + const aibom: AiBillOfMaterials = { + schemaVersion: "euconform.aibom.v1.1", + generatedAt: new Date().toISOString(), + project: { name: projectName, rootPath: "." }, + components: deduped, + complianceCapabilities: { + biasEvaluation: false, + jsonExport: false, + pdfExport: false, + loggingInfrastructure: false, + humanReviewFlow: false, + incidentHandling: false, + }, + metadata: buildMetadata(importTool, originalTimestamp), + }; + + const validation = validate(aibom); + + return { + aibom, + summary: { + totalComponents, + filteredByScope, + aiRelevant: deduped.length, + skipped: inScope.length - mapped.length, + duplicatesRemoved, + byKind, + warnings, + source: buildSourceInfo(bom.specVersion, projectNameSource, importTool, originalTimestamp), + }, + 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 extractToolString(bom: CycloneDxBom): string | undefined { + const tool = bom.metadata?.tools?.[0]; + if (!tool?.name) return undefined; + return tool.version ? `${tool.name} ${tool.version}` : tool.name; +} + +function buildMetadata(importTool?: string, originalTimestamp?: string): AibomMetadata { + const meta: AibomMetadata = { importSource: "cyclonedx" }; + if (importTool) meta.importTool = importTool; + if (originalTimestamp) meta.originalTimestamp = originalTimestamp; + return meta; +} + +function buildSourceInfo( + specVersion: string, + projectNameSource: ImportSourceInfo["projectNameSource"], + importTool?: string, + originalTimestamp?: string +): ImportSourceInfo { + const info: ImportSourceInfo = { bomFormat: "CycloneDX", specVersion, projectNameSource }; + if (importTool) info.importTool = importTool; + if (originalTimestamp) info.originalTimestamp = originalTimestamp; + return info; +} + +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..1ddc326 --- /dev/null +++ b/packages/core/src/sbom/index.ts @@ -0,0 +1,12 @@ +export { + importCycloneDx, + type CycloneDxImportResult, + type ImportSummary, + type ImportWarning, + type ImportOptions, + type ImportSourceInfo, +} from "./cyclonedx-import"; +export { mapComponent, type ComponentMapping } from "./component-mapper"; +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/src/sbom/known-packages.ts b/packages/core/src/sbom/known-packages.ts new file mode 100644 index 0000000..e136472 --- /dev/null +++ b/packages/core/src/sbom/known-packages.ts @@ -0,0 +1,142 @@ +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", + 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", + + // 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 { + 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/src/sbom/purl.ts b/packages/core/src/sbom/purl.ts new file mode 100644 index 0000000..ae48257 --- /dev/null +++ b/packages/core/src/sbom/purl.ts @@ -0,0 +1,66 @@ +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); + + try { + remainder = decodeURIComponent(remainder); + } catch { + return null; + } + + // 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/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/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..0696ebb --- /dev/null +++ b/packages/core/src/validation/schema-validator.ts @@ -0,0 +1,133 @@ +import { existsSync, 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" | "aibom.v1.1" | "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. +// 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 _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"); + 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.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" }, +}; + +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/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/component-mapper.test.ts b/packages/core/tests/sbom/component-mapper.test.ts new file mode 100644 index 0000000..89b1ad0 --- /dev/null +++ b/packages/core/tests/sbom/component-mapper.test.ts @@ -0,0 +1,258 @@ +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"); + }); + + 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", () => { + 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"); + }); + }); + + 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(); + }); + }); +}); 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..2c3b2ae --- /dev/null +++ b/packages/core/tests/sbom/cyclonedx-import.test.ts @@ -0,0 +1,329 @@ +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.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"); + 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"); + }); + + 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", () => { + it("generates id as kind:name", () => { + const result = importCycloneDx(minimalBom); + 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"); + }); + }); +}); diff --git a/packages/core/tests/sbom/purl.test.ts b/packages/core/tests/sbom/purl.test.ts new file mode 100644 index 0000000..79a3905 --- /dev/null +++ b/packages/core/tests/sbom/purl.test.ts @@ -0,0 +1,102 @@ +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(); + }); + + 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 new file mode 100644 index 0000000..d67aabe --- /dev/null +++ b/packages/core/tests/validation/schema-validator.test.ts @@ -0,0 +1,219 @@ +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); + }); + + it("accepts aibom v1.1 with metadata field", () => { + const result = validate({ + schemaVersion: "euconform.aibom.v1.1", + 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); + }); + + 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", () => { + 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"); + } + }); + }); +}); 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: