From dc0b20a8460e391fdd646e7028b31edd6d104bfc Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 29 Aug 2026 17:12:24 -0400 Subject: [PATCH 01/16] feat(perspective): add pre-defined perspective identity with reference form Adds an optional 'predefined' field to the perspective model, identifying a well-known perspective published in the CycloneDX perspectives catalog and incorporating the published definition by reference, so tooling can recognize the perspective without matching on free-text names. A perspective either declares a pre-defined identity or defines its own mappings inline; mixing the two is not permitted, keeping the published definition the single source of truth for what a pre-defined perspective contains. Initial enum values: model-card, pqc-readiness. Valid/invalid fixtures added covering the reference form, the inline form, enum rejection, and the forbidden mixed form. Bundled schemas are left to the post-merge bundle workflow. Signed-off-by: Pavel Shukhman --- .../cyclonedx-perspective-2.0.schema.json | 42 +++++++++++++++++-- .../invalid-perspective-predefined-2.0.json | 15 +++++++ ...alid-perspective-predefined-mixed-2.0.json | 23 ++++++++++ .../2.0/valid-perspective-predefined-2.0.json | 32 ++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json create mode 100644 tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json diff --git a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json index 217cf5870..ccae9150a 100644 --- a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json +++ b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json @@ -18,14 +18,37 @@ "title": "Perspective", "description": "A domain-specific view that identifies the types of data relevant to a particular audience and provides optional terminology mappings to facilitate interpretation. Perspectives enable tooling to generate filtered views, translate terminology, and validate document completeness against audience-specific requirements.", "additionalProperties": false, - "required": [ - "name", - "mappings" + "oneOf": [ + { + "$comment": "Reference form: incorporates the published pre-defined perspective by reference; inline mappings shall not be provided.", + "properties": { + "predefined": true, + "mappings": false + }, + "required": [ + "predefined" + ] + }, + { + "$comment": "Inline form: the perspective is fully defined in the document and shall not declare a pre-defined identity.", + "properties": { + "predefined": false, + "name": true, + "mappings": true + }, + "required": [ + "name", + "mappings" + ] + } ], "properties": { "bom-ref": { "$ref": "cyclonedx-common-2.0.schema.json#/$defs/refType" }, + "predefined": { + "$ref": "#/$defs/preDefinedPerspective" + }, "name": { "type": "string", "title": "Perspective Name", @@ -66,6 +89,19 @@ } } }, + "preDefinedPerspective": { + "type": "string", + "title": "Pre-Defined Perspective", + "description": "Identifies a well-known, pre-defined perspective published in the CycloneDX perspectives catalog, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. A perspective declaring a pre-defined identity shall not provide inline mappings; a perspective defining its own mappings shall omit this field.", + "enum": [ + "model-card", + "pqc-readiness" + ], + "meta:enum": { + "model-card": "A transparency view for machine learning models following the industry-standard model card structure, covering model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical and environmental considerations.", + "pqc-readiness": "A view for assessing readiness for the post-quantum cryptography migration, covering cryptographic inventory, quantum resistance of the cryptography in use, and cryptographic agility." + } + }, "perspectiveMapping": { "type": "object", "title": "Perspective Mapping", diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json new file mode 100644 index 000000000..8c069c097 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "threat-model" + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json new file mode 100644 index 000000000..82600905b --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "pqc-readiness", + "name": "PQC Readiness", + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "nativeName": "Cryptographic Inventory", + "relevance": "required" + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json b/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json new file mode 100644 index 000000000..56ae47452 --- /dev/null +++ b/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "serialNumber": "urn:uuid:9d2e4a1b-7c3f-4e8a-b1d6-2f5c8e9a0b3d", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "model-card" + }, + { + "bom-ref": "perspective-2", + "name": "Crypto Inventory", + "description": "An inline perspective fully defined in the document.", + "domains": [ + "cryptographic-security" + ], + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "nativeName": "Cryptographic Inventory", + "relevance": "required", + "weight": 1.0 + } + ] + } + ] +} From f7efd6aa95085924802a4a95af9b06d87b0382f7 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 29 Aug 2026 17:13:22 -0400 Subject: [PATCH 02/16] feat(perspectives): add model card pre-defined perspective catalog entry Second entry in the perspectives catalog, following the delivery shape of the PQC readiness perspective: a complete, minimal, valid 2.0 document containing only the perspective. This is the definition incorporated by reference when a document declares the pre-defined perspective 'model-card'. The mappings express the industry-standard model card structure over the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics in modelProperties (scoped to components of type machine-learning-model, the only type that may carry them), training datasets as components of type data, intended use cases as use case definitions, and ethical and fairness considerations as risk model entries. Assumes the AI/ML model properties proposed in CycloneDX/specification#990. Signed-off-by: Pavel Shukhman --- perspectives/model-card-perspective.json | 168 +++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 perspectives/model-card-perspective.json diff --git a/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json new file mode 100644 index 000000000..d75c7aa87 --- /dev/null +++ b/perspectives/model-card-perspective.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-model-card", + "name": "Model Card", + "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990.", + "domains": [ + "machine-learning", + "artificial-intelligence", + "transparency", + "ethics" + ], + "mappings": [ + { + "expression": "$.components[?(@.type=='machine-learning-model')]['name','version','description']", + "nativeName": "Model Details", + "nativeDescription": "The identifying facts of the model: its name, version, and a description of what it is and does.", + "relevance": "required", + "weight": 1.0, + "rationale": "A model card is meaningless without stating which model, and which revision of it, the card describes." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].parties[?(@.roles[?(@.role=='supplier')])]", + "nativeName": "Developed By", + "nativeDescription": "The organization or individuals responsible for developing and supplying the model.", + "relevance": "required", + "weight": 0.9, + "rationale": "Accountability for a model's behaviour requires knowing who produced it. Expressed through the party model with the supplier role." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].licenses", + "nativeName": "License", + "nativeDescription": "The license under which the model, and by extension its weights, may be used.", + "relevance": "required", + "weight": 0.8, + "rationale": "Model cards conventionally state usage terms; license determines whether a given use is permitted at all." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.tasks", + "nativeName": "Supported Tasks", + "nativeDescription": "The machine learning tasks the model is designed to perform.", + "relevance": "required", + "weight": 0.9, + "rationale": "Tasks anchor the card: they determine the applicable inputs, outputs, and evaluation metrics." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.learningTypes", + "nativeName": "Learning Paradigms", + "nativeDescription": "The learning paradigms applied when training the model, such as supervised or reinforcement learning.", + "relevance": "recommended", + "weight": 0.5, + "rationale": "Helps readers judge what kinds of data and feedback shaped the model's behaviour." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.architecture", + "nativeName": "Model Architecture", + "nativeDescription": "The architecture family and structural characteristics of the model.", + "relevance": "recommended", + "weight": 0.7, + "rationale": "Architecture contextualizes capability and performance claims and supports reproducibility." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties['inputs','outputs']", + "nativeName": "Input and Output Parameters", + "nativeDescription": "The modalities, formats, and constraints of the data the model consumes and produces.", + "relevance": "recommended", + "weight": 0.6, + "rationale": "Input and output specifications define the model's operational envelope and integration contract." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties['parameterCount','quantization']", + "nativeName": "Model Size and Quantization", + "nativeDescription": "The parameter count of the model and any quantization applied to its weights.", + "relevance": "optional", + "weight": 0.3, + "rationale": "Size and quantization inform deployment cost and can affect accuracy relative to the unquantized model." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.training", + "nativeName": "Training Data and Procedure", + "nativeDescription": "How the model was trained: the training formula and the datasets used.", + "relevance": "recommended", + "weight": 0.8, + "rationale": "Training data provenance is central to assessing bias, capability boundaries, and data protection obligations." + }, + { + "expression": "$.components[?(@.type=='data')]", + "nativeName": "Datasets", + "nativeDescription": "Dataset components referenced from the model's training information, carrying dataset composition, governance, and sensitive-data declarations.", + "relevance": "recommended", + "weight": 0.6, + "rationale": "Training references resolve to components of type data; the dataset detail a card reader needs lives on those components." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.evaluation", + "nativeName": "Quantitative Analysis", + "nativeDescription": "Evaluation results: performance metrics, per-slice measurements, confidence intervals, and supporting graphics.", + "relevance": "recommended", + "weight": 0.8, + "rationale": "Metrics, including slice-level results, substantiate capability claims and surface performance disparities between groups." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.useCases", + "nativeName": "Intended Use", + "nativeDescription": "References to the use cases the model is intended for.", + "relevance": "required", + "weight": 0.9, + "rationale": "Intended use separates in-scope application from misuse; it is the card section most consulted by adopters and assessors." + }, + { + "expression": "$.definitions.useCases", + "nativeName": "Use Case Definitions", + "nativeDescription": "The use case definitions that the model's intended-use references resolve to.", + "relevance": "recommended", + "weight": 0.6, + "rationale": "The model links to use cases by reference; the definitions carry the actual descriptions a card reader needs." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].parties[?(@.roles[?(@.role=='end-user')])]", + "nativeName": "Intended Users", + "nativeDescription": "The audiences the model is intended to be used by.", + "relevance": "recommended", + "weight": 0.5, + "rationale": "Stating who the model is for frames the expertise assumed of its operators. Expressed through the party model with the end-user role." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.limitations", + "nativeName": "Technical Limitations", + "nativeDescription": "Known technical limitations of the model, including constraints on accuracy, reasoning, scalability, and appropriate use, and relevant performance tradeoffs.", + "relevance": "required", + "weight": 0.9, + "rationale": "Limitations are the card's primary safeguard against use outside the model's competence." + }, + { + "expression": "$.risks.risks[?(@.domains[?(@.type=='ethical')])]", + "nativeName": "Ethical Considerations", + "nativeDescription": "Risks in the ethical domain associated with the model, including affected parties, benefits, harms, and mitigations.", + "relevance": "required", + "weight": 0.9, + "rationale": "Ethical considerations are expressed as entries in the document's risk model rather than as card-local prose, gaining structured likelihood, impact, and response data." + }, + { + "expression": "$.risks.risks[?(@.inherentRisk.impact.categories[?(@=='fairness' || @=='bias')])]", + "nativeName": "Fairness Assessments", + "nativeDescription": "Risks whose impact is categorized as fairness or bias, describing groups at risk and observed disparities.", + "relevance": "recommended", + "weight": 0.6, + "rationale": "Fairness assessments identify demographic or group-level performance disparities; slice-level evaluation metrics provide their quantitative backing." + }, + { + "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.environmental", + "nativeName": "Environmental Considerations", + "nativeDescription": "Energy consumption and carbon cost of model activities such as training and inference.", + "relevance": "recommended", + "weight": 0.5, + "rationale": "Environmental impact is an established model card section and increasingly a reporting obligation." + } + ] + } + ] +} From 95afc9410dcb48d296dac7a663be887fb4bc80a5 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 29 Aug 2026 17:45:50 -0400 Subject: [PATCH 03/16] feat(perspective): restrict reference form to bom-ref and predefined The reference form previously excluded only inline mappings, leaving name, description, domains, externalReferences, and properties legal alongside a pre-defined identity. That allowed documents to locally shadow published fields (e.g. a different name or domains) with no defined precedence. The reference form now forbids all inline content except bom-ref, so the published definition is unambiguously the single source of truth. Invalid fixture added covering annotation of a pre-defined perspective. Signed-off-by: Pavel Shukhman --- .../cyclonedx-perspective-2.0.schema.json | 11 ++++++++--- ...-perspective-predefined-annotated-2.0.json | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json diff --git a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json index ccae9150a..81ff539a8 100644 --- a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json +++ b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json @@ -20,10 +20,15 @@ "additionalProperties": false, "oneOf": [ { - "$comment": "Reference form: incorporates the published pre-defined perspective by reference; inline mappings shall not be provided.", + "$comment": "Reference form: the perspective is the published pre-defined perspective, incorporated by reference. Only bom-ref may accompany the pre-defined identity; all other inline content is forbidden so the published definition remains the single source of truth.", "properties": { "predefined": true, - "mappings": false + "name": false, + "description": false, + "domains": false, + "mappings": false, + "externalReferences": false, + "properties": false }, "required": [ "predefined" @@ -92,7 +97,7 @@ "preDefinedPerspective": { "type": "string", "title": "Pre-Defined Perspective", - "description": "Identifies a well-known, pre-defined perspective published in the CycloneDX perspectives catalog, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. A perspective declaring a pre-defined identity shall not provide inline mappings; a perspective defining its own mappings shall omit this field.", + "description": "Identifies a well-known, pre-defined perspective published in the CycloneDX perspectives catalog, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. A perspective declaring a pre-defined identity shall not provide any inline content other than bom-ref; a perspective defining its own content shall omit this field.", "enum": [ "model-card", "pqc-readiness" diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json new file mode 100644 index 000000000..7875d0345 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "model-card", + "name": "Threat Model", + "domains": [ + "cryptographic-security" + ] + } + ] +} From ebb5184e91a1dbcdb2009f4c5f19b1f439473058 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 29 Aug 2026 17:46:55 -0400 Subject: [PATCH 04/16] fix(perspectives): align Datasets mapping description with its expression The expression selects all components of type data; JSONPath cannot follow references, so the selection is necessarily broader than the datasets referenced from the model's training information. Soften the description so prose and expression agree. Signed-off-by: Pavel Shukhman --- perspectives/model-card-perspective.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json index d75c7aa87..48df302c1 100644 --- a/perspectives/model-card-perspective.json +++ b/perspectives/model-card-perspective.json @@ -93,7 +93,7 @@ { "expression": "$.components[?(@.type=='data')]", "nativeName": "Datasets", - "nativeDescription": "Dataset components referenced from the model's training information, carrying dataset composition, governance, and sensitive-data declarations.", + "nativeDescription": "Dataset components, typically referenced from the model's training information, carrying dataset composition, governance, and sensitive-data declarations.", "relevance": "recommended", "weight": 0.6, "rationale": "Training references resolve to components of type data; the dataset detail a card reader needs lives on those components." From 3140a3ac5debe2f0f0292e9b26c99473e99ec874 Mon Sep 17 00:00:00 2001 From: taleodor-claude Date: Fri, 4 Sep 2026 07:57:58 -0400 Subject: [PATCH 05/16] feat(perspective): Pre-defined perspectives: cdx: namespace, versioned references, standalone registry * feat(perspective): namespace pre-defined perspective identities under cdx:perspectives: Per review on CycloneDX/specification#1067: prefix the pre-defined perspective enum values with the reserved cdx: namespace and a cdx:perspectives: path, so 'model-card' becomes 'cdx:perspectives:model-card' and 'pqc-readiness' becomes 'cdx:perspectives:pqc-readiness'. This aligns the identities with the reserved CycloneDX property taxonomy and keeps them collision-free with author-chosen names. Updates the enum, its meta:enum keys, the field description, and the valid/invalid test fixtures accordingly. * feat(perspective): version pre-defined perspective references (registry model) Per Steve's review on CycloneDX/specification#1067: adopt a registry model for pre-defined perspectives, referenced by identity plus version. - Add a required sibling 'predefinedVersion' (integer, minimum 1) on the reference form. It selects the published revision of the perspective and is the 'version' of the catalog document that defines it; like other CycloneDX version fields it increments by 1 per revision. Pinning it keeps a reference stable as the catalog perspective evolves. - The reference form now requires both predefined and predefinedVersion (and still forbids all inline content but bom-ref); the inline form forbids both. - The 'predefined' enum stays ids-only, hand-maintained inline (the registry is this id list plus the versioned catalog files) -- no separate data file or generator, unlike the crypto family registry, since the set is small and curated and version is not schema-enumerated per id (mirroring how crypto validates the family but not the parameter set). Fixtures: valid reference form gains the version; the unknown-id, annotated and mixed invalid cases gain it so each isolates its intended violation; new invalid fixture covers a reference missing the required version. * feat(perspective): move pre-defined perspective registry into a standalone defs file Decouples the pre-defined perspective lifecycle from the specification release cycle, the same way the cryptography algorithm registry is handled: perspectives can be added or revised, and new catalog document versions published, by editing the registry alone -- no change to the versioned specification schemas. - New schema/perspectives-defs.schema.json: a single registry file holding the reserved identity enum (definitions.preDefinedPerspectivesEnum) and the identity-to-catalog-document map (definitions.catalog, id -> document path under perspectives/). Kept to one file, no separate data file or generator (the set is small and curated, unlike the ~100-entry crypto family registry that justifies generation). - cyclonedx-perspective-2.0.schema.json: 'predefined' now the external enum (../../perspectives-defs.schema.json#/definitions/ preDefinedPerspectivesEnum) instead of an inline enum; the inline preDefinedPerspective def is removed. - bundler: perspectives-defs.schema.json added to the external ref exceptions so it stays external in the bundle (like cryptography-defs). - schema-v2 validate + functional harnesses: load and register the new external schema so refs resolve. The registry data is expressed with schema keywords (const-valued map entries) so the single file remains a valid JSON Schema under the harness's ajv strict mode. Bundled schemas left to the post-merge workflow. * refactor(perspective): split pre-defined registry into data file + governing schema Follows the crypto registry's file split so the registry data has a real, checkable contract (the previous single file invented an unvalidated const-map convention). - schema/perspectives-defs.json: the registry DATA, hand-edited (no generator). Each entry is a proper object: predefined (the reserved identity, matching the CycloneDX perspective 'predefined' field), file (catalog document path), and description. Declares its governing schema via $schema. - schema/perspectives-defs.schema.json: now a governing meta-schema that defines the data file's shape (perspectives[] of {predefined, file, description}, predefined constrained to preDefinedPerspectivesEnum) and still exposes preDefinedPerspectivesEnum for the specification schema to $ref. Identities are entry values, not object keys. Data edits stay manual, matching how the crypto data file is maintained; the enum in the governing schema is kept in sync by hand (the set is small and curated). Verified the data file validates against the governing schema, and the schema-v2 suite passes. * test(perspective): enforce the pre-defined perspectives registry contract in CI The registry data file is hand-maintained (no generator), so until now nothing asserted that it actually conforms to its governing schema or that the hand-kept enum stays in step with the entries. Add a registry test to the schema-v2 suite (picked up by `npm test` via the `test:*` glob, so the existing JavaScript CI workflow runs it on every pull request): - schema/perspectives-defs.json validates against schema/perspectives-defs.schema.json (strict ajv, draft-07 meta-schema, formats enabled). - preDefinedPerspectivesEnum and the entries' `predefined` values are the same set, with no duplicates on either side; the message names which file to fix. - every entry's `file` points under perspectives/. A missing document only warns, so an identity can be reserved ahead of its catalog document (pqc-readiness, CycloneDX/specification#960). An existing document must be JSON, declare an integer `version` >= 1 (the value a reference's `predefinedVersion` selects), define at least one perspective, and define it inline (no `predefined`/`predefinedVersion`), since catalog entries are the published definitions, not references. The test is not versioned (`-v`) because the registry lives at the schema root and is shared across specification versions. Verified: suite green; each check fails on a tampered input (unknown identity/missing file in data, enum drift, catalog document in reference form, catalog document without version). * test(perspective): register the perspectives registry schema in the Java 2.x harness The Java schema-v2 verification harness resolves external schema URIs through an explicit classpath mapping and disallows anything else, so the perspective schema's $ref to perspectives-defs.schema.json failed to load ("Schema from 'https://cyclonedx.org/schema/perspectives-defs.schema.json' is not allowed to be loaded"), erroring the five perspective fixtures. Map the http/https URIs of perspectives-defs.schema.json to the classpath copy (schema/ is already a test resource root), alongside the existing cryptography-defs and behavior-taxonomy mappings. Verified: `mvn clean test -Pschema-v2-tests` green locally (229 tests, 0 errors). * feat(perspective): generate the pre-defined perspectives registry and enforce immutable published versions The registry data (schema/perspectives-defs.json) is now GENERATED, in the spirit of the crypto registry's generator, and published perspective versions become immutable: - Naming convention replaces any mapping: the catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json`. Identities are lowercase-kebab-case. The identity enum in the governing schema stays hand-maintained (now with meta:enum descriptions, so a reserved identity without a document still has one). - Registry entries are objects {predefined, file, name, description, versions[]}; each version record is {version, sha256, commit, date}: sha256 of the catalog document's canonical JSON (keys sorted, no whitespace, so formatting-only edits do not count as changes), the last commit touching the document, and that commit's UTC date. `name` and `description` are copied from the document at the latest version. lastUpdated is the newest registered date (deterministic). - tools/src/main/js/perspectives-registry/: shared module (convention, canonical hash, structural checks, version-state assessment) used by both the generator and the test, plus generate-perspectives-registry.js. The generator only appends unregistered versions (a new perspective at version 1, or registered latest + 1); it refuses content changed at a registered version, regressed or skipped versions, removed registered documents, documents not defining exactly one inline perspective, and identities violating the convention. Idempotent when nothing changed. - .github/workflows/generate_perspectives_registry.yml: on push to master/main/2.0-dev touching perspectives/**, the governing schema, or the generator, runs the generator and commits the registry directly (with a skip-ci marker), mirroring bundle_2.0_schemas.yml. A failing generator (change without bump merged over a red check) fails the workflow. - The schema-v2 registry test now shares that logic and checks every identity in the enum: reserved, new, unchanged and pending (latest + 1) pass; modified, regressed, skipped and removed fail. It also checks registered entries follow the convention and list contiguous versions from 1, and that every registered identity is in the enum. - Governing schema rewritten for the generated shape (patterns for file, sha256, commit; date-time dates; versions minItems 1). - Perspective schema: `predefined` description now states the naming convention instead of a mapping. Initial registry generated from this branch: model-card version 1 at ebb5184e (the commit is fork-side provenance; the hash is the check key). Verified: JS schema-v2 suite green; Java schema-v2 suite green (229 tests); bundler unchanged (registry ref stays external); no new lint findings on the perspective schema. Test and generator both reject each tampered input: content change at a registered version, skipped/regressed version, removed document, non-contiguous registry versions, non-convention identity, document with two perspectives; a version bump registers cleanly and formatting-only edits are accepted. * feat: create generate_perspectives_registry.yml to generate perspectives defs PR Signed-off-by: Pavel Shukhman * fix: switch generate_perspectives_registry.yml to perspectives code Signed-off-by: Pavel Shukhman --------- Co-authored-by: Claude Code (ReARM Agent) Co-authored-by: Pavel Shukhman Signed-off-by: Pavel Shukhman --- .../generate_perspectives_registry.yml | 77 ++++++ .../cyclonedx-perspective-2.0.schema.json | 33 +-- schema/perspectives-defs.json | 20 ++ schema/perspectives-defs.schema.json | 125 ++++++++++ tools/src/main/js/bundler/bundle-schemas.js | 1 + .../generate-perspectives-registry.js | 127 ++++++++++ .../js/perspectives-registry/package.json | 15 ++ .../perspectives-registry.js | 233 ++++++++++++++++++ .../schema/v2/JsonSchemaVerificationTest.java | 3 + .../schema-v2/json-schema-functional-tests.js | 4 +- .../schema-v2/json-schema-validate-tests.js | 4 +- tools/src/test/js/schema-v2/package.json | 1 + .../schema-v2/perspectives-registry-tests.js | 178 +++++++++++++ .../invalid-perspective-predefined-2.0.json | 3 +- ...-perspective-predefined-annotated-2.0.json | 5 +- ...alid-perspective-predefined-mixed-2.0.json | 5 +- ...-perspective-predefined-noversion-2.0.json | 15 ++ .../2.0/valid-perspective-predefined-2.0.json | 3 +- 18 files changed, 828 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/generate_perspectives_registry.yml create mode 100644 schema/perspectives-defs.json create mode 100644 schema/perspectives-defs.schema.json create mode 100644 tools/src/main/js/perspectives-registry/generate-perspectives-registry.js create mode 100644 tools/src/main/js/perspectives-registry/package.json create mode 100644 tools/src/main/js/perspectives-registry/perspectives-registry.js create mode 100644 tools/src/test/js/schema-v2/perspectives-registry-tests.js create mode 100644 tools/src/test/resources/2.0/invalid-perspective-predefined-noversion-2.0.json diff --git a/.github/workflows/generate_perspectives_registry.yml b/.github/workflows/generate_perspectives_registry.yml new file mode 100644 index 000000000..2721a95d8 --- /dev/null +++ b/.github/workflows/generate_perspectives_registry.yml @@ -0,0 +1,77 @@ +name: Generate Perspectives Registry + +on: + push: + branches: + - 'master' + - 'main' + - '2.0-dev' + paths: + - '.github/workflows/generate_perspectives_registry.yml' # self + - 'tools/src/main/js/perspectives-registry/**' + - 'schema/perspectives-defs.schema.json' + - 'perspectives/**' + workflow_dispatch: # Allows manual trigger + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token +permissions: {} + +jobs: + generate-registry: + name: Generate Registry + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # Required to push the update branch + pull-requests: write # Required to open the pull request + env: + REGISTRY_FILE: schema/perspectives-defs.json + BASE_BRANCH: ${{ github.ref_name }} + UPDATE_BRANCH: update-perspectives-registry/${{ github.ref_name }} + steps: + - name: Checkout repository + # see https://github.com/actions/checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + # see https://github.com/actions/setup-node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.x' + - name: Generate registry + # registers catalog document versions that are not registered yet; + # fails if a catalog document changed without a version bump + run: | + set -eux + node tools/src/main/js/perspectives-registry/generate-perspectives-registry.js + - name: Create Pull Request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + + if git diff --quiet -- "$REGISTRY_FILE" + then + echo "No changes to the perspectives registry" + exit 0 + fi + + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + git checkout -b "$UPDATE_BRANCH" + git add "$REGISTRY_FILE" + git commit -m "chore: update perspectives registry" + + git push -u "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$UPDATE_BRANCH" --force + + gh pr create \ + --title "chore: update perspectives registry" \ + --body "This PR updates \`${REGISTRY_FILE}\` with the catalog document versions registered by \`tools/src/main/js/perspectives-registry/generate-perspectives-registry.js\` for \`${BASE_BRANCH}\`." \ + --base "$BASE_BRANCH" \ + --head "$UPDATE_BRANCH" || echo "Pull request already exists" diff --git a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json index 81ff539a8..126bab773 100644 --- a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json +++ b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json @@ -20,9 +20,10 @@ "additionalProperties": false, "oneOf": [ { - "$comment": "Reference form: the perspective is the published pre-defined perspective, incorporated by reference. Only bom-ref may accompany the pre-defined identity; all other inline content is forbidden so the published definition remains the single source of truth.", + "$comment": "Reference form: the perspective is the published pre-defined perspective, incorporated by reference at a specific version. Both predefined and predefinedVersion are required; only bom-ref may otherwise accompany the pre-defined identity, so the published definition remains the single source of truth.", "properties": { "predefined": true, + "predefinedVersion": true, "name": false, "description": false, "domains": false, @@ -31,13 +32,15 @@ "properties": false }, "required": [ - "predefined" + "predefined", + "predefinedVersion" ] }, { - "$comment": "Inline form: the perspective is fully defined in the document and shall not declare a pre-defined identity.", + "$comment": "Inline form: the perspective is fully defined in the document and shall not declare a pre-defined identity or version.", "properties": { "predefined": false, + "predefinedVersion": false, "name": true, "mappings": true }, @@ -52,7 +55,12 @@ "$ref": "cyclonedx-common-2.0.schema.json#/$defs/refType" }, "predefined": { - "$ref": "#/$defs/preDefinedPerspective" + "title": "Pre-Defined Perspective", + "description": "Identifies a well-known, pre-defined perspective, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. Values use the reserved `cdx:perspectives:` namespace path and are drawn from the CycloneDX pre-defined perspectives registry (`perspectives-defs.schema.json`), which is maintained independently of the specification release cycle; the catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the CycloneDX specification repository. The specific published revision is selected by the sibling `predefinedVersion`. A perspective declaring a pre-defined identity shall provide `predefinedVersion` and shall not provide any inline content other than bom-ref, so the published definition remains the single source of truth; a perspective defining its own content shall omit both fields.", + "$ref": "../../perspectives-defs.schema.json#/definitions/preDefinedPerspectivesEnum" + }, + "predefinedVersion": { + "$ref": "#/$defs/preDefinedPerspectiveVersion" }, "name": { "type": "string", @@ -94,18 +102,11 @@ } } }, - "preDefinedPerspective": { - "type": "string", - "title": "Pre-Defined Perspective", - "description": "Identifies a well-known, pre-defined perspective published in the CycloneDX perspectives catalog, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. A perspective declaring a pre-defined identity shall not provide any inline content other than bom-ref; a perspective defining its own content shall omit this field.", - "enum": [ - "model-card", - "pqc-readiness" - ], - "meta:enum": { - "model-card": "A transparency view for machine learning models following the industry-standard model card structure, covering model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical and environmental considerations.", - "pqc-readiness": "A view for assessing readiness for the post-quantum cryptography migration, covering cryptographic inventory, quantum resistance of the cryptography in use, and cryptographic agility." - } + "preDefinedPerspectiveVersion": { + "type": "integer", + "title": "Pre-Defined Perspective Version", + "description": "The published revision of the pre-defined perspective (identified by `predefined`) that this reference incorporates. This is the `version` of the catalog document that defines the perspective; like other CycloneDX version fields it is an integer incremented by 1 on each published revision. Pinning the version keeps a reference stable as the catalog perspective evolves.", + "minimum": 1 }, "perspectiveMapping": { "type": "object", diff --git a/schema/perspectives-defs.json b/schema/perspectives-defs.json new file mode 100644 index 000000000..d7524e2b5 --- /dev/null +++ b/schema/perspectives-defs.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://cyclonedx.org/schema/perspectives-defs.schema.json", + "lastUpdated": "2026-08-29T21:46:55Z", + "perspectives": [ + { + "predefined": "cdx:perspectives:model-card", + "file": "perspectives/model-card-perspective.json", + "name": "Model Card", + "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990.", + "versions": [ + { + "version": 1, + "sha256": "a589072bc854cb3cb6e81f1729144a999c3cfc8bf85e0131c275e252f3ded320", + "commit": "ebb5184e91a1dbcdb2009f4c5f19b1f439473058", + "date": "2026-08-29T21:46:55Z" + } + ] + } + ] +} diff --git a/schema/perspectives-defs.schema.json b/schema/perspectives-defs.schema.json new file mode 100644 index 000000000..62ef1eeb9 --- /dev/null +++ b/schema/perspectives-defs.schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://cyclonedx.org/schema/perspectives-defs.schema.json", + "$comment": "2026-09-04T00:00:00Z", + "title": "CycloneDX Pre-Defined Perspectives Registry", + "description": "Governs the registry of well-known, pre-defined perspectives (perspectives-defs.json) and declares their reserved identities (preDefinedPerspectivesEnum, referenced by the specification's perspective schema). The identity enum is maintained by hand. The catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the repository, by naming convention; its `version` is the perspective version selected by `predefinedVersion`. The registry data is generated: it records every published version of each perspective with the sha256 of the catalog document's canonical JSON and the commit that introduced it. Once registered, a version's content is immutable; changing a catalog document requires incrementing its `version` by 1. The registry is maintained independently of the CycloneDX specification release cycle.", + "type": "object", + "additionalProperties": false, + "required": [ + "perspectives" + ], + "properties": { + "$schema": { + "type": "string" + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "title": "Last Updated", + "description": "The date and time (timestamp) of the most recently registered version." + }, + "perspectives": { + "type": "array", + "title": "Pre-Defined Perspectives", + "description": "The registered pre-defined perspectives, one entry per identity that has a published catalog document. Identities in the enum without an entry are reserved and not published yet.", + "items": { + "$ref": "#/definitions/registeredPerspective" + } + } + }, + "definitions": { + "preDefinedPerspectivesEnum": { + "type": "string", + "title": "Pre-Defined Perspective Identities", + "description": "The reserved identities of well-known, pre-defined perspectives, using the reserved cdx:perspectives: namespace path. The catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json`. Referenced by the specification's perspective schema.", + "enum": [ + "cdx:perspectives:model-card", + "cdx:perspectives:pqc-readiness" + ], + "meta:enum": { + "cdx:perspectives:model-card": "A transparency view for machine learning models following the industry-standard model card structure, covering model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical and environmental considerations.", + "cdx:perspectives:pqc-readiness": "A view for assessing readiness for the post-quantum cryptography migration, covering cryptographic inventory, quantum resistance of the cryptography in use, and cryptographic agility." + } + }, + "registeredPerspective": { + "type": "object", + "title": "Registered Pre-Defined Perspective", + "additionalProperties": false, + "required": [ + "predefined", + "file", + "versions" + ], + "properties": { + "predefined": { + "$ref": "#/definitions/preDefinedPerspectivesEnum", + "title": "Pre-Defined Perspective Identity", + "description": "The reserved identity of the perspective. Matches the `predefined` field of a perspective in a CycloneDX document." + }, + "file": { + "type": "string", + "title": "Catalog Document", + "description": "Path, relative to the repository root, of the catalog document defining this perspective, as given by the naming convention.", + "pattern": "^perspectives/[a-z0-9]+(-[a-z0-9]+)*-perspective\\.json$" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the perspective, as declared by its catalog document at the latest registered version." + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description of the perspective, as declared by its catalog document at the latest registered version." + }, + "versions": { + "type": "array", + "title": "Registered Versions", + "description": "Every published version of the perspective, in ascending order, contiguous from 1.", + "minItems": 1, + "items": { + "$ref": "#/definitions/registeredVersion" + } + } + } + }, + "registeredVersion": { + "type": "object", + "title": "Registered Version", + "additionalProperties": false, + "required": [ + "version", + "sha256", + "commit", + "date" + ], + "properties": { + "version": { + "type": "integer", + "title": "Version", + "description": "The `version` of the catalog document that published this revision; the value a reference selects via `predefinedVersion`.", + "minimum": 1 + }, + "sha256": { + "type": "string", + "title": "Content Hash", + "description": "Lowercase hex SHA-256 of the catalog document's canonical JSON (object keys sorted, no insignificant whitespace) at this version. A catalog document whose hash differs from the one registered for its `version` has changed without a version bump.", + "pattern": "^[0-9a-f]{64}$" + }, + "commit": { + "type": "string", + "title": "Commit", + "description": "The commit that introduced this version of the catalog document, for provenance.", + "pattern": "^[0-9a-f]{40}$" + }, + "date": { + "type": "string", + "format": "date-time", + "title": "Date", + "description": "The date and time (timestamp) of that commit." + } + } + } + } +} diff --git a/tools/src/main/js/bundler/bundle-schemas.js b/tools/src/main/js/bundler/bundle-schemas.js index 7cc9bd1a1..f36e12bf3 100644 --- a/tools/src/main/js/bundler/bundle-schemas.js +++ b/tools/src/main/js/bundler/bundle-schemas.js @@ -9,6 +9,7 @@ const DEFAULT_REF_EXCEPTION_FILES = [ 'spdx.schema.json', 'behavior-taxonomy.schema.json', 'cryptography-defs.schema.json', + 'perspectives-defs.schema.json', 'jsf-0.82.schema.json' ]; diff --git a/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js b/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js new file mode 100644 index 000000000..a5b5dbdc6 --- /dev/null +++ b/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Generate schema/perspectives-defs.json, the pre-defined perspectives registry. + * call the script via `node -- ` from anywhere inside the repository. + * + * Sources: the identity enum in schema/perspectives-defs.schema.json (hand-maintained) + * and the catalog documents under perspectives/ (hand-maintained, naming convention). + * Registers every catalog document version that is not registered yet, recording the + * sha256 of the document's canonical JSON and the commit that last touched the document. + * Never rewrites a registered version: a document whose content changed at an already + * registered version is an error, and so is any version that is not exactly the + * registered latest + 1 (or 1 for a new perspective). + * + * Exit code is the number of errors (0 = success), capped at 254. + */ + +import {execFile} from 'node:child_process' +import {readFile, writeFile} from 'node:fs/promises' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' +import {promisify} from 'node:util' + +import { + REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, + assess, identitiesOf, readCatalogDocument, +} from './perspectives-registry.js' + +const _thisDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(_thisDir, '..', '..', '..', '..', '..') +const schemaFile = join(repoRoot, REGISTRY_SCHEMA_FILE) +const dataFile = join(repoRoot, REGISTRY_DATA_FILE) +const execFileP = promisify(execFile) + +/** + * @param {string} file path relative to repository root + * @return {Promise<{commit: string, date: string}>} last commit touching the file, date in UTC + */ +async function lastCommitOf(file) { + const {stdout} = await execFileP('git', ['log', '-1', '--format=%H%n%cI', '--', file], {cwd: repoRoot}) + const [commit, date] = stdout.trim().split('\n') + if (!/^[0-9a-f]{40}$/.test(commit ?? '')) { + throw new Error(`no commit found for ${file}; the document shall be committed before it is registered`) + } + return {commit, date: new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z')} +} + +const schema = JSON.parse(await readFile(schemaFile, 'utf-8')) +const identities = identitiesOf(schema) +const previous = await readFile(dataFile, 'utf-8').then(JSON.parse).catch(err => { + if (err.code === 'ENOENT') return {perspectives: []} + throw err +}) +const previousEntries = new Map((previous.perspectives ?? []).map(e => [e.predefined, e])) + +let errCnt = 0 +const entries = [] + +for (const [id] of previousEntries) { + if (!identities.includes(id)) { + ++errCnt + console.error(`!!! ERROR: registered identity ${id} is not in the enum of ${REGISTRY_SCHEMA_FILE}; registered versions are never dropped`) + } +} + +for (const identity of [...identities].sort()) { + const entry = previousEntries.get(identity) + let catalog + try { + catalog = await readCatalogDocument(repoRoot, identity) + } catch (err) { + ++errCnt + console.error(`!!! ERROR: ${identity}: ${err.message}`) + if (entry !== undefined) entries.push(entry) + continue + } + if (catalog.problems.length > 0) { + ++errCnt + console.error(`!!! ERROR: ${identity}: ${catalog.file}\n - ${catalog.problems.join('\n - ')}`) + if (entry !== undefined) entries.push(entry) + continue + } + const {state, detail} = assess(catalog, entry) + console.log(`${identity}: ${state} (${detail})`) + if (!OK_STATES.has(state)) { + ++errCnt + console.error(`!!! ERROR: ${identity}: ${detail}`) + if (entry !== undefined) entries.push(entry) + continue + } + if (state === State.RESERVED) { + continue + } + const versions = [...(entry?.versions ?? [])] + if (state === State.NEW || state === State.PENDING) { + const {commit, date} = await lastCommitOf(catalog.file) + versions.push({version: catalog.version, sha256: catalog.sha256, commit, date}) + console.log(` registering version ${catalog.version} from commit ${commit}`) + } + entries.push({ + predefined: identity, + file: catalog.file, + name: catalog.perspective.name, + description: catalog.perspective.description, + versions, + }) +} + +if (errCnt === 0) { + const lastUpdated = entries.flatMap(e => e.versions.map(v => v.date)).sort().at(-1) ?? previous.lastUpdated + const registry = { + $schema: 'http://cyclonedx.org/schema/perspectives-defs.schema.json', + ...(lastUpdated !== undefined ? {lastUpdated} : {}), + perspectives: entries, + } + const output = JSON.stringify(registry, null, 2) + '\n' + if (output === await readFile(dataFile, 'utf-8').catch(() => undefined)) { + console.log('\nregistry unchanged:', dataFile) + } else { + await writeFile(dataFile, output, 'utf-8') + console.log('\nregistry written:', dataFile) + } +} + +console.log('\n> found', errCnt, 'errors') +process.exitCode = Math.min(errCnt, 254) diff --git a/tools/src/main/js/perspectives-registry/package.json b/tools/src/main/js/perspectives-registry/package.json new file mode 100644 index 000000000..2f930540e --- /dev/null +++ b/tools/src/main/js/perspectives-registry/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "name": "@cyclonedx/perspectives-registry", + "version": "1.0.0", + "description": "Generate and check the CycloneDX pre-defined perspectives registry", + "type": "module", + "main": "perspectives-registry.js", + "engines": { + "node": ">=22.0" + }, + "scripts": { + "generate": "node -- generate-perspectives-registry.js" + }, + "license": "Apache-2.0" +} diff --git a/tools/src/main/js/perspectives-registry/perspectives-registry.js b/tools/src/main/js/perspectives-registry/perspectives-registry.js new file mode 100644 index 000000000..fb0e4e4f0 --- /dev/null +++ b/tools/src/main/js/perspectives-registry/perspectives-registry.js @@ -0,0 +1,233 @@ +"use strict"; + +/** + * Shared logic for the CycloneDX pre-defined perspectives registry. + * + * Used by the generator (tools/src/main/js/perspectives-registry) and by the + * schema-v2 test suite, so that both hash catalog documents and judge + * version states the same way. + * + * Conventions: + * - identities are `cdx:perspectives:` (the enum in + * schema/perspectives-defs.schema.json is the hand-maintained source) + * - the catalog document defining `cdx:perspectives:` is + * `perspectives/-perspective.json` (naming convention, no mapping) + * - a catalog document is a CycloneDX document defining exactly one inline + * perspective; its `version` is the perspective version referenced by + * `predefinedVersion` + * - the registry (schema/perspectives-defs.json) is GENERATED and records, + * per identity, every published version with the sha256 of the catalog + * document's canonical JSON and the commit that introduced it; once + * registered, a version's content is immutable + */ + +import {createHash} from 'node:crypto' +import {readFile, stat} from 'node:fs/promises' +import {join} from 'node:path' + +export const IDENTITY_PREFIX = 'cdx:perspectives:' +export const CATALOG_DIR = 'perspectives' +export const CATALOG_FILE_SUFFIX = '-perspective.json' +export const REGISTRY_SCHEMA_FILE = join('schema', 'perspectives-defs.schema.json') +export const REGISTRY_DATA_FILE = join('schema', 'perspectives-defs.json') +export const ENUM_POINTER = Object.freeze(['definitions', 'preDefinedPerspectivesEnum', 'enum']) + +/** + * Version states of a catalog document relative to the registry. + * @readonly + * @enum {string} + */ +export const State = Object.freeze({ + /** identity in the enum, no catalog document yet */ + RESERVED: 'reserved', + /** first version (1) of a not-yet-registered perspective; the generator will register it */ + NEW: 'new', + /** version equals the registered latest and the content is unchanged */ + UNCHANGED: 'unchanged', + /** version equals registered latest + 1; the generator will register it */ + PENDING: 'pending', + /** version equals the registered latest but the content differs: change without a version bump */ + MODIFIED: 'modified', + /** version is lower than the registered latest */ + REGRESSED: 'regressed', + /** version skips ahead of registered latest + 1 (or a new perspective not starting at 1) */ + SKIPPED: 'skipped', + /** a registered perspective whose catalog document no longer exists */ + REMOVED: 'removed', +}) + +/** states that are acceptable in a pull request / on the base branch */ +export const OK_STATES = Object.freeze(new Set([State.RESERVED, State.NEW, State.UNCHANGED, State.PENDING])) + +/** + * @param {string} identity + * @return {string} catalog document path relative to the repository root, using `/` separators + */ +export function catalogFileOf(identity) { + if (typeof identity !== 'string' || !identity.startsWith(IDENTITY_PREFIX)) { + throw new Error(`not a pre-defined perspective identity: ${identity}`) + } + const name = identity.slice(IDENTITY_PREFIX.length) + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + throw new Error(`identity name is not lowercase-kebab-case: ${identity}`) + } + return `${CATALOG_DIR}/${name}${CATALOG_FILE_SUFFIX}` +} + +/** + * Canonical JSON: object keys sorted, no insignificant whitespace. + * Formatting-only edits of a catalog document therefore do not change its hash. + * @param {*} value + * @return {string} + */ +export function canonicalize(value) { + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + if (value !== null && typeof value === 'object') { + return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** + * @param {*} doc parsed catalog document + * @return {string} lowercase hex sha256 of the canonical JSON + */ +export function sha256Of(doc) { + return createHash('sha256').update(canonicalize(doc), 'utf-8').digest('hex') +} + +/** + * @param {object} schema parsed registry governing schema + * @return {string[]} the identity enum + */ +export function identitiesOf(schema) { + const values = ENUM_POINTER.reduce((node, key) => node?.[key], schema) + if (!Array.isArray(values)) { + throw new Error(`missing enum at /${ENUM_POINTER.join('/')} in registry schema`) + } + return values +} + +/** + * @typedef {object} CatalogDocument + * @property {string} identity + * @property {string} file path relative to repository root + * @property {string} path absolute path + * @property {boolean} exists + * @property {*} [doc] parsed document (when it exists and is JSON) + * @property {number} [version] + * @property {object} [perspective] the single inline perspective + * @property {string} [sha256] + * @property {string[]} problems structural problems of an existing document + */ + +/** + * Read and structurally check the catalog document of an identity. + * @param {string} repoRoot + * @param {string} identity + * @return {Promise} + */ +export async function readCatalogDocument(repoRoot, identity) { + const file = catalogFileOf(identity) + const path = join(repoRoot, ...file.split('/')) + const result = {identity, file, path, exists: false, problems: []} + if (!await stat(path).then(s => s.isFile()).catch(() => false)) { + return result + } + result.exists = true + let doc + try { + doc = JSON.parse(await readFile(path, 'utf-8')) + } catch (err) { + result.problems.push(`not valid JSON: ${err}`) + return result + } + result.doc = doc + result.sha256 = sha256Of(doc) + if (!Number.isInteger(doc?.version) || doc.version < 1) { + result.problems.push('shall declare an integer `version` >= 1 (the value referenced by `predefinedVersion`)') + } else { + result.version = doc.version + } + const perspectives = Array.isArray(doc?.perspectives) ? doc.perspectives : [] + if (perspectives.length !== 1) { + result.problems.push(`shall define exactly one perspective, found ${perspectives.length}`) + } else { + const p = perspectives[0] + if (p?.predefined !== undefined || p?.predefinedVersion !== undefined) { + result.problems.push('shall define its perspective inline, not by pre-defined reference (`predefined` / `predefinedVersion`)') + } + result.perspective = p + } + return result +} + +/** + * @param {object[]|undefined} versions registered version records of the identity + * @return {number} registered latest version, 0 when unregistered + */ +export function latestOf(versions) { + return Array.isArray(versions) && versions.length > 0 ? Math.max(...versions.map(v => v.version)) : 0 +} + +/** + * Judge a catalog document against its registry entry. + * @param {CatalogDocument} catalog + * @param {object|undefined} entry registry entry of the identity + * @return {{state: State, latest: number, detail: string}} + */ +export function assess(catalog, entry) { + const latest = latestOf(entry?.versions) + if (!catalog.exists) { + return latest > 0 + ? {state: State.REMOVED, latest, detail: `registered up to version ${latest} but ${catalog.file} does not exist`} + : {state: State.RESERVED, latest, detail: `${catalog.file} does not exist (yet)`} + } + const {version, sha256} = catalog + if (latest === 0) { + return version === 1 + ? {state: State.NEW, latest, detail: 'version 1, not registered yet'} + : {state: State.SKIPPED, latest, detail: `not registered yet, so it shall start at version 1, found ${version}`} + } + if (version === latest) { + const registered = entry.versions.find(v => v.version === latest) + return registered.sha256 === sha256 + ? {state: State.UNCHANGED, latest, detail: `version ${version}, content matches the registry`} + : {state: State.MODIFIED, latest, detail: `content differs from registered version ${latest} (${registered.commit}); bump \`version\` to ${latest + 1}`} + } + if (version === latest + 1) { + return {state: State.PENDING, latest, detail: `version ${version} bumped from registered ${latest}, not registered yet`} + } + if (version < latest) { + return {state: State.REGRESSED, latest, detail: `version ${version} is below registered latest ${latest}`} + } + return {state: State.SKIPPED, latest, detail: `version ${version} skips ahead of registered latest ${latest}; next is ${latest + 1}`} +} + +/** + * Structural problems of a registry entry that the governing schema cannot express. + * @param {object} entry + * @return {string[]} + */ +export function entryProblems(entry) { + const problems = [] + let expectedFile + try { + expectedFile = catalogFileOf(entry.predefined) + } catch (err) { + problems.push(String(err.message)) + } + if (expectedFile !== undefined && entry.file !== expectedFile) { + problems.push(`\`file\` is ${entry.file}, naming convention expects ${expectedFile}`) + } + const numbers = (entry.versions ?? []).map(v => v.version) + for (let i = 0; i < numbers.length; ++i) { + if (numbers[i] !== i + 1) { + problems.push(`\`versions\` shall be contiguous from 1 in ascending order, found [${numbers.join(', ')}]`) + break + } + } + return problems +} diff --git a/tools/src/test/java/org/cyclonedx/schema/v2/JsonSchemaVerificationTest.java b/tools/src/test/java/org/cyclonedx/schema/v2/JsonSchemaVerificationTest.java index ed04ca612..b4c067976 100644 --- a/tools/src/test/java/org/cyclonedx/schema/v2/JsonSchemaVerificationTest.java +++ b/tools/src/test/java/org/cyclonedx/schema/v2/JsonSchemaVerificationTest.java @@ -54,6 +54,7 @@ class JsonSchemaVerificationTest extends BaseSchemaVerificationTest { private static final String SPDX_NAMESPACE = "cyclonedx.org/schema/spdx.schema.json"; private static final String CRYPTO_DEF_NAMESPACE = "cyclonedx.org/schema/cryptography-defs.schema.json"; private static final String BEHAVIOR_TAXONOMY_NAMESPACE = "cyclonedx.org/schema/behavior-taxonomy.schema.json"; + private static final String PERSPECTIVES_DEFS_NAMESPACE = "cyclonedx.org/schema/perspectives-defs.schema.json"; /** version -> compiled schema. Add new versions here. */ private static final Map SCHEMAS = new LinkedHashMap<>(); @@ -89,6 +90,8 @@ public JsonMetaSchema getMetaSchema( .mapPrefix("http://" + CRYPTO_DEF_NAMESPACE, "classpath:cryptography-defs.schema.json") .mapPrefix("http://" + BEHAVIOR_TAXONOMY_NAMESPACE, "classpath:behavior-taxonomy.schema.json") .mapPrefix("https://" + BEHAVIOR_TAXONOMY_NAMESPACE, "classpath:behavior-taxonomy.schema.json") + .mapPrefix("http://" + PERSPECTIVES_DEFS_NAMESPACE, "classpath:perspectives-defs.schema.json") + .mapPrefix("https://" + PERSPECTIVES_DEFS_NAMESPACE, "classpath:perspectives-defs.schema.json") ).build(); SchemaValidatorsConfig config = SchemaValidatorsConfig.builder() // in 2020-12, "format" is annotation-only unless asserted diff --git a/tools/src/test/js/schema-v2/json-schema-functional-tests.js b/tools/src/test/js/schema-v2/json-schema-functional-tests.js index 63b4298c2..df03a8af7 100644 --- a/tools/src/test/js/schema-v2/json-schema-functional-tests.js +++ b/tools/src/test/js/schema-v2/json-schema-functional-tests.js @@ -58,10 +58,11 @@ console.debug('DEBUG | testdataDir = ', testdataDir); // region validator -const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, bomSchema, bomSchemaModules] = await Promise.all([ +const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, perspectivesDefsSchema, bomSchema, bomSchemaModules] = await Promise.all([ readFile(join(schemaRootDir, 'spdx.schema.json'), 'utf-8').then(JSON.parse), readFile(join(schemaRootDir, 'cryptography-defs.schema.json'), 'utf-8').then(JSON.parse), readFile(join(schemaRootDir, 'behavior-taxonomy.schema.json'), 'utf-8').then(JSON.parse), + readFile(join(schemaRootDir, 'perspectives-defs.schema.json'), 'utf-8').then(JSON.parse), readFile(schemaFile, 'utf-8').then(JSON.parse), glob(join(schemaModelDir, schemaGlob)).then(fs => Promise.all(fs.map( f => readFile(f, 'utf-8').then(s => [basename(f), JSON.parse(s)]) @@ -80,6 +81,7 @@ ajv.addMetaSchema(draft7MetaSchema); ajv.addSchema(spdxSchema, 'https://cyclonedx.org/schema/spdx.schema.json') ajv.addSchema(cryptoDefsSchema, 'https://cyclonedx.org/schema/cryptography-defs.schema.json') ajv.addSchema(behaviorTaxonomySchema, 'https://cyclonedx.org/schema/behavior-taxonomy.schema.json') +ajv.addSchema(perspectivesDefsSchema, 'https://cyclonedx.org/schema/perspectives-defs.schema.json') for (const [f, s] of bomSchemaModules) { ajv.addSchema(s, `https://cyclonedx.org/schema/${testschemaVersion}/model/${f}`) } diff --git a/tools/src/test/js/schema-v2/json-schema-validate-tests.js b/tools/src/test/js/schema-v2/json-schema-validate-tests.js index 9ad5c9d16..64cd892d6 100644 --- a/tools/src/test/js/schema-v2/json-schema-validate-tests.js +++ b/tools/src/test/js/schema-v2/json-schema-validate-tests.js @@ -56,10 +56,11 @@ console.debug('DEBUG | schemaModelDir = ', schemaModelDir); // endregion config -const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, schemas, schemaModules] = await Promise.all([ +const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, perspectivesDefsSchema, schemas, schemaModules] = await Promise.all([ readFile(join(schemaRootDir, 'spdx.schema.json'), 'utf-8').then(JSON.parse), readFile(join(schemaRootDir, 'cryptography-defs.schema.json'), 'utf-8').then(JSON.parse), readFile(join(schemaRootDir, 'behavior-taxonomy.schema.json'), 'utf-8').then(JSON.parse), + readFile(join(schemaRootDir, 'perspectives-defs.schema.json'), 'utf-8').then(JSON.parse), Promise.all(schemaFiles.map( f => readFile(f, 'utf-8').then(s => [f, JSON.parse(s)]) )), @@ -95,6 +96,7 @@ function getAjv(bundled) { ajv.addSchema(spdxSchema, 'https://cyclonedx.org/schema/spdx.schema.json') ajv.addSchema(cryptoDefsSchema, 'https://cyclonedx.org/schema/cryptography-defs.schema.json') ajv.addSchema(behaviorTaxonomySchema, 'https://cyclonedx.org/schema/behavior-taxonomy.schema.json') + ajv.addSchema(perspectivesDefsSchema, 'https://cyclonedx.org/schema/perspectives-defs.schema.json') if (!bundled) { for (const [f, s] of schemaModules) { ajv.addSchema(s, `https://cyclonedx.org/schema/${testschemaVersion}/model/${f}`) diff --git a/tools/src/test/js/schema-v2/package.json b/tools/src/test/js/schema-v2/package.json index 63b0b645f..e78b52306 100644 --- a/tools/src/test/js/schema-v2/package.json +++ b/tools/src/test/js/schema-v2/package.json @@ -16,6 +16,7 @@ }, "scripts": { "test": "run-s \"test:*\"", + "test:perspectives-registry": "node -- perspectives-registry-tests.js", "test:v2.0": "run-s \"test:v2.0:*\"", "test:v2.0:t1-json-schema-validate": "node -- json-schema-validate-tests.js -v 2.0", "test:v2.0:t2-json-schema-semantic": "node -- json-schema-semantic-tests.js -v 2.0", diff --git a/tools/src/test/js/schema-v2/perspectives-registry-tests.js b/tools/src/test/js/schema-v2/perspectives-registry-tests.js new file mode 100644 index 000000000..3f957550e --- /dev/null +++ b/tools/src/test/js/schema-v2/perspectives-registry-tests.js @@ -0,0 +1,178 @@ +"use strict"; + +/** + * validate the pre-defined perspectives registry. + * call the script via `node -- ` + * + * The registry is not tied to a CycloneDX version: it lives at the schema root + * (schema/perspectives-defs.json, generated) and is governed by + * schema/perspectives-defs.schema.json, whose hand-maintained `preDefinedPerspectivesEnum` + * is referenced by the versioned perspective schema. This test asserts: + * - the registry data validates against its governing schema + * - every registered identity is in the enum, without duplicates on either side; + * entries follow the naming convention and list contiguous versions from 1 + * - for every identity in the enum, the catalog document (perspectives/-perspective.json) + * is in an acceptable state relative to the registry: reserved (no document yet), new + * (version 1, not registered), unchanged (registered content), or pending (registered + * latest + 1). A document changed at an already registered version, a regressed or + * skipped version, or a removed registered document fails. + * Shared logic lives in tools/src/main/js/perspectives-registry/perspectives-registry.js. + */ + +import {readFile, stat} from 'node:fs/promises' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +import Ajv2020 from "ajv/dist/2020.js" +import draft7MetaSchema from "ajv/dist/refs/json-schema-draft-07.json" with {type: "json"}; +import addFormats from 'ajv-formats' + +import { + REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, + assess, entryProblems, identitiesOf, readCatalogDocument, +} from '../../../main/js/perspectives-registry/perspectives-registry.js' + + +const _thisDir = dirname(fileURLToPath(import.meta.url)) + +// region config + +const repoRootDir = join(_thisDir, '..', '..', '..', '..', '..') +const registrySchemaFile = join(repoRootDir, REGISTRY_SCHEMA_FILE) +const registryDataFile = join(repoRootDir, REGISTRY_DATA_FILE) + +for (const file of [registrySchemaFile, registryDataFile]) { + if (!await stat(file).then(s => s.isFile()).catch(() => false)) { + throw new Error(`missing file: ${file}`); + } +} +console.debug('DEBUG | registrySchemaFile = ', registrySchemaFile); +console.debug('DEBUG | registryDataFile = ', registryDataFile); + +// endregion config + +const [registrySchema, registryData] = await Promise.all([ + readFile(registrySchemaFile, 'utf-8').then(JSON.parse), + readFile(registryDataFile, 'utf-8').then(JSON.parse), +]) + +let errCnt = 0 + +/** + * @param {string} message + * @param {...*} details + */ +function fail(message, ...details) { + ++errCnt + console.error('!!! ERROR:', message, ...details) +} + +// region schema conformance + +console.log('\n> validate registry data against its governing schema ...') +{ + // same strict setup as the schema validation tests + const ajv = new Ajv2020({ + verbose: true, + addUsedSchema: false, + keywords: ["meta:enum"], + strict: true, + strictSchema: true, + strictNumbers: true, + strictTypes: true, + strictTuples: true, + strictRequired: true, + validateFormats: true, + }); + // the registry schema is draft-07 + ajv.addMetaSchema(draft7MetaSchema); + addFormats(ajv) + let validate + try { + validate = ajv.compile(registrySchema) + } catch (err) { + fail('failed compiling registry schema', '\n in file:', `file://${registrySchemaFile}`, '\n error:', String(err)) + } + if (validate !== undefined) { + if (validate(registryData)) { + console.log('OK.') + } else { + fail('registry data does not conform to its governing schema', + '\n for file:', `file://${registryDataFile}`, + '\n error:', validate.errors) + } + } +} + +// endregion schema conformance + +// region registry entries + +console.log('\n> check registry entries against the enum and the naming convention ...') +const identities = identitiesOf(registrySchema) +const entries = Array.isArray(registryData.perspectives) ? registryData.perspectives : [] +const entryById = new Map() +{ + const dupEnum = identities.filter((v, i) => identities.indexOf(v) !== i) + if (dupEnum.length > 0) { + fail('duplicate values in enum', dupEnum, '\n in file:', `file://${registrySchemaFile}`) + } + let entryErrors = 0 + for (const entry of entries) { + if (entryById.has(entry?.predefined)) { + ++entryErrors + fail('duplicate registry entry for', entry.predefined) + continue + } + entryById.set(entry?.predefined, entry) + if (!identities.includes(entry?.predefined)) { + ++entryErrors + fail('registered identity is not in the enum:', entry?.predefined, '\n add it to', `file://${registrySchemaFile}`) + } + const problems = entryProblems(entry) + if (problems.length > 0) { + ++entryErrors + fail(`registry entry ${entry?.predefined}:`, '\n - ' + problems.join('\n - ')) + } + } + if (dupEnum.length === 0 && entryErrors === 0) { + console.log('OK.', entries.length, 'registered of', identities.length, 'identities') + } +} + +// endregion registry entries + +// region catalog documents + +console.log('\n> check catalog documents against the registry ...') +for (const identity of identities) { + let catalog + try { + catalog = await readCatalogDocument(repoRootDir, identity) + } catch (err) { + fail(`${identity}:`, String(err.message)) + continue + } + console.log('\ntest', identity, '->', catalog.file, '...') + if (catalog.problems.length > 0) { + fail(`catalog document of ${identity}:`, '\n file:', `file://${catalog.path}`, '\n - ' + catalog.problems.join('\n - ')) + continue + } + const {state, detail} = assess(catalog, entryById.get(identity)) + if (!OK_STATES.has(state)) { + fail(`${identity} is ${state}:`, detail, '\n file:', `file://${catalog.path}`) + } else if (state === State.RESERVED) { + console.warn(`WARNING: ${identity} is reserved:`, detail) + } else if (state === State.UNCHANGED) { + console.log('OK.', detail) + } else { + console.log(`OK (${state}).`, detail, '- the registry generator will register it after merge') + } +} + +// endregion catalog documents + +console.log('\n\n> found', errCnt, 'errors') +// Exit statuses should be in the range 0 to 254. +// The status 0 is used to terminate the program successfully. +process.exitCode = Math.min(errCnt, 254) diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json index 8c069c097..b8608c05c 100644 --- a/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json @@ -9,7 +9,8 @@ "perspectives": [ { "bom-ref": "perspective-1", - "predefined": "threat-model" + "predefined": "cdx:perspectives:threat-model", + "predefinedVersion": 1 } ] } diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json index 7875d0345..5d16a613d 100644 --- a/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json @@ -9,11 +9,12 @@ "perspectives": [ { "bom-ref": "perspective-1", - "predefined": "model-card", + "predefined": "cdx:perspectives:model-card", "name": "Threat Model", "domains": [ "cryptographic-security" - ] + ], + "predefinedVersion": 1 } ] } diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json index 82600905b..353f77d26 100644 --- a/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json @@ -9,7 +9,7 @@ "perspectives": [ { "bom-ref": "perspective-1", - "predefined": "pqc-readiness", + "predefined": "cdx:perspectives:pqc-readiness", "name": "PQC Readiness", "mappings": [ { @@ -17,7 +17,8 @@ "nativeName": "Cryptographic Inventory", "relevance": "required" } - ] + ], + "predefinedVersion": 1 } ] } diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-noversion-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-noversion-2.0.json new file mode 100644 index 000000000..3a9a09e7a --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-noversion-2.0.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "cdx:perspectives:model-card" + } + ] +} diff --git a/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json b/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json index 56ae47452..34cc61d66 100644 --- a/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json +++ b/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json @@ -10,7 +10,8 @@ "perspectives": [ { "bom-ref": "perspective-1", - "predefined": "model-card" + "predefined": "cdx:perspectives:model-card", + "predefinedVersion": 1 }, { "bom-ref": "perspective-2", From ec23bed48ba4b53c05326dc327875b32ffa9ce16 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:42:41 -0400 Subject: [PATCH 06/16] chore: sync discover 2x schema file Signed-off-by: Pavel Shukhman --- .github/workflows/discover_2.x_schema.yml | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/discover_2.x_schema.yml diff --git a/.github/workflows/discover_2.x_schema.yml b/.github/workflows/discover_2.x_schema.yml new file mode 100644 index 000000000..c2f147315 --- /dev/null +++ b/.github/workflows/discover_2.x_schema.yml @@ -0,0 +1,42 @@ + +name: Discover CDX-2.x Schema + +on: + workflow_call: + outputs: + versions: + description: 'JSON array of discovered versions' + value: ${{ jobs.discover.outputs.versions }} + +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token +permissions: {} + +jobs: + discover: + timeout-minutes: 5 + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.discover.outputs.versions }} + steps: + - name: Checkout repository + # see https://github.com/actions/checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Discover schema versions + id: discover + run: | + mapfile -d '' -t dirs < <( + find schema \ + -mindepth 1 -maxdepth 1 \ + -type d \ + -name '2.*' \ + -printf '%f\0' \ + | sort -z + ) + if [ ${#dirs[@]} -eq 0 ]; then + echo 'No schema/2.* directories found' >&2 + exit 1 + fi + printf 'versions=' >> "$GITHUB_OUTPUT" + printf '%s\n' "${dirs[@]}" | jq -R . | jq -c -s . >> "$GITHUB_OUTPUT" From f54921538b9ac27d2ee73f328da414e8cfe3305b Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:43:47 -0400 Subject: [PATCH 07/16] chore: sync bundle 2.0 schemas with upstream Signed-off-by: Pavel Shukhman --- .github/workflows/bundle_2.0_schemas.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bundle_2.0_schemas.yml b/.github/workflows/bundle_2.0_schemas.yml index 44c70172a..4db3dab11 100644 --- a/.github/workflows/bundle_2.0_schemas.yml +++ b/.github/workflows/bundle_2.0_schemas.yml @@ -45,7 +45,7 @@ jobs: node-version: '24.x' - name: Install dependencies working-directory: tools/src/main/js/bundler - run: npm install + run: npm ci --no-fund --no-audit - name: Bundle schemas run: | set -eux From c00b7af3cf93212e01ec588bd0dd1977f849df2b Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:44:52 -0400 Subject: [PATCH 08/16] chore: sync generate_algorithm_families.yml with upstream Signed-off-by: Pavel Shukhman --- .github/workflows/generate_algorithm_families.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/generate_algorithm_families.yml b/.github/workflows/generate_algorithm_families.yml index 68ea24486..2e290136c 100644 --- a/.github/workflows/generate_algorithm_families.yml +++ b/.github/workflows/generate_algorithm_families.yml @@ -2,6 +2,7 @@ name: Generate Algorithm Families Enum on: push: + branches: [ 'master' ] paths: - 'schema/cryptography-defs.json' - 'tools/src/main/python/algorithmFamilyGeneration.py' From 945aeb9ab934a7d0fde09728dd2c937c9df85911 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:46:09 -0400 Subject: [PATCH 09/16] chore: sync test_2.x_java.yml Signed-off-by: Pavel Shukhman --- .github/workflows/test_2.x_java.yml | 32 ++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test_2.x_java.yml b/.github/workflows/test_2.x_java.yml index 29d477609..8405d3f03 100644 --- a/.github/workflows/test_2.x_java.yml +++ b/.github/workflows/test_2.x_java.yml @@ -10,16 +10,30 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token +permissions: {} + defaults: run: working-directory: tools -# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token -permissions: {} +env: + JAVA_VERSION: '8' + JAVA_DISTRIBUTION: 'zulu' jobs: + discover-schema: + uses: ./.github/workflows/discover_2.x_schema.yml test_java: + needs: + - discover-schema runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + schema_version: ${{ fromJSON(needs.discover-schema.outputs.versions) }} + name: test ${{ matrix.schema_version }} steps: - name: Checkout # see https://github.com/actions/checkout @@ -30,8 +44,16 @@ jobs: # see https://github.com/actions/setup-java uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: - java-version: '8' - distribution: 'zulu' + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} java-package: jdk - name: test with Maven - run: mvn clean test -Pschema-v2-tests + env: + SCHEMA_VERSION: ${{ matrix.schema_version }} + run: >- + mvn clean test + -D "surefire.includeTags=schema-v${SCHEMA_VERSION}" + -D failIfNoTests=true + - name: Print report + if: ${{ failure() }} + run: cat ./target/surefire-reports/* From 3fb9f3698aaf1e483b21c85afe5cd888a185e8b3 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:46:47 -0400 Subject: [PATCH 10/16] chore: sync test_2.x_js.yml Signed-off-by: Pavel Shukhman --- .github/workflows/test_2.x_js.yml | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test_2.x_js.yml b/.github/workflows/test_2.x_js.yml index 8c867af5e..243d5fe04 100644 --- a/.github/workflows/test_2.x_js.yml +++ b/.github/workflows/test_2.x_js.yml @@ -12,17 +12,30 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token +permissions: {} + defaults: run: working-directory: tools/src/test/js/schema-v2 -# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token -permissions: {} +env: + NODE_VERSION: '24.x' jobs: + discover-schema: + uses: ./.github/workflows/discover_2.x_schema.yml test_js: + needs: + - discover-schema timeout-minutes: 30 runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + schema_version: ${{ fromJSON(needs.discover-schema.outputs.versions) }} + name: test ${{ matrix.schema_version }} steps: - name: Checkout # see https://github.com/actions/checkout @@ -33,10 +46,10 @@ jobs: # see https://github.com/actions/setup-node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '24.x' + node-version: ${{ env.NODE_VERSION }} package-manager-cache: false - name: Install Dependencies - run: npm install + run: npm i --no-fund --no-audit - name: Run test env: VALIDATE_BUNDLED: >- @@ -47,4 +60,5 @@ jobs: && github.base_ref == 'master' ) }} - run: npm test + SCHEMA_VERSION: ${{ matrix.schema_version }} + run: npm run "test:v$SCHEMA_VERSION" From 7e7043ee21ac514bba06908cda797c0e5e761655 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:47:28 -0400 Subject: [PATCH 11/16] chore: sync test_2.x_php.yml Signed-off-by: Pavel Shukhman --- .github/workflows/test_2.x_php.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_2.x_php.yml b/.github/workflows/test_2.x_php.yml index 5a18f11e3..2206ce7e9 100644 --- a/.github/workflows/test_2.x_php.yml +++ b/.github/workflows/test_2.x_php.yml @@ -12,17 +12,30 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token +permissions: {} + defaults: run: working-directory: tools/src/test/php/schema-v2 -# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token -permissions: {} +env: + PHP_VERSION: "8.5" jobs: + discover-schema: + uses: ./.github/workflows/discover_2.x_schema.yml test_php: + needs: + - discover-schema timeout-minutes: 30 runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + schema_version: ${{ fromJSON(needs.discover-schema.outputs.versions) }} + name: test ${{ matrix.schema_version }} steps: - name: Checkout # see https://github.com/actions/checkout @@ -33,9 +46,11 @@ jobs: # see https://github.com/shivammathur/setup-php uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: - php-version: "8.5" + php-version: ${{ env.PHP_VERSION }} tools: composer:v2 - name: Install Dependencies run: composer install - name: Run test - run: composer run test + env: + SCHEMA_VERSION: ${{ matrix.schema_version }} + run: composer run "test:v$SCHEMA_VERSION" From 5828cdd2ad7c0009b0bafef19a50ba914963a083 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:48:04 -0400 Subject: [PATCH 12/16] chore: sync zizmor.yml Signed-off-by: Pavel Shukhman --- .github/workflows/zizmor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 3e4df2d39..59f354ae0 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 # see https://github.com/zizmorcore/zizmor-action - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 with: # advanced-security: false => emit findings as workflow-command annotations (::error file=…) rather than # uploading a SARIF report to GitHub's Security tab. From b9a6c3ed7a455fd8e7fd24685f48c1552690446b Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:51:53 -0400 Subject: [PATCH 13/16] chore: sync lint_2.x_schemas.yml Signed-off-by: Pavel Shukhman --- .github/workflows/lint_2.x_schemas.yml | 32 ++++---------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/.github/workflows/lint_2.x_schemas.yml b/.github/workflows/lint_2.x_schemas.yml index c773ebcf4..e640e4560 100644 --- a/.github/workflows/lint_2.x_schemas.yml +++ b/.github/workflows/lint_2.x_schemas.yml @@ -18,33 +18,7 @@ env: jobs: discover-schema: - timeout-minutes: 5 - runs-on: ubuntu-latest - outputs: - versions: ${{ steps.discover.outputs.versions }} - steps: - - name: Checkout repository - # see https://github.com/actions/checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Discover schema versions - id: discover - run: | - mapfile -d '' -t dirs < <( - find schema \ - -mindepth 1 -maxdepth 1 \ - -type d \ - -name '2.*' \ - -printf '%f\0' \ - | sort -z - ) - if [ ${#dirs[@]} -eq 0 ]; then - echo 'No schema/2.* directories found' >&2 - exit 1 - fi - printf 'versions=' >> "$GITHUB_OUTPUT" - printf '%s\n' "${dirs[@]}" | jq -R . | jq -c -s . >> "$GITHUB_OUTPUT" + uses: ./.github/workflows/discover_2.x_schema.yml discover-linter: timeout-minutes: 5 runs-on: ubuntu-latest @@ -86,6 +60,8 @@ jobs: # exclude: # - schema_version: '2.1' # linter_test: 'no-todos' + # - schema_version: '2.1' + # linter_test: 'no-deprecated' name: lint ${{ matrix.schema_version }} ${{ matrix.linter_test }} env: REPORT_FILE: tools/src/main/js/linter/reports/${{ matrix.schema_version }}_${{ matrix.linter_test }}.json @@ -107,7 +83,7 @@ jobs: package-manager-cache: false - name: Install linter working-directory: tools/src/main/js/linter - run: npm install + run: npm ci --no-fund --no-audit - name: Lint schemas env: SCHEMA_VERSION: ${{ matrix.schema_version }} From 81a9beaf371bfd0b3e2002d122e211b26edb0ddb Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 4 Sep 2026 08:55:14 -0400 Subject: [PATCH 14/16] fix: sync for Update lint_2.x_schemas.yml Signed-off-by: Pavel Shukhman --- .github/workflows/lint_2.x_schemas.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint_2.x_schemas.yml b/.github/workflows/lint_2.x_schemas.yml index e640e4560..b6831de76 100644 --- a/.github/workflows/lint_2.x_schemas.yml +++ b/.github/workflows/lint_2.x_schemas.yml @@ -38,7 +38,7 @@ jobs: package-manager-cache: false - name: Install linter working-directory: tools/src/main/js/linter - run: npm install + run: npm ci --no-fund --no-audit - name: Discover linter tests id: discover run: | @@ -83,7 +83,7 @@ jobs: package-manager-cache: false - name: Install linter working-directory: tools/src/main/js/linter - run: npm ci --no-fund --no-audit + run: npm install - name: Lint schemas env: SCHEMA_VERSION: ${{ matrix.schema_version }} From 5867d5b1ef68d430243b15b5d6c910b7b66224fc Mon Sep 17 00:00:00 2001 From: "Claude Code (ReARM Agent)" Date: Fri, 4 Sep 2026 12:56:00 +0000 Subject: [PATCH 15/16] test(perspective): keep the registry test in CI after the per-version test split Upstream's JavaScript workflow (CycloneDX/specification#1074) now runs `npm run test:v` per matrix entry instead of `npm test`, so the unversioned `test:perspectives-registry` script would no longer run in CI. Move it under the 2.0 group as `test:v2.0:t4-perspectives-registry`; the script itself stays version-agnostic. --- tools/src/test/js/schema-v2/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/src/test/js/schema-v2/package.json b/tools/src/test/js/schema-v2/package.json index e78b52306..bcffdb101 100644 --- a/tools/src/test/js/schema-v2/package.json +++ b/tools/src/test/js/schema-v2/package.json @@ -16,10 +16,10 @@ }, "scripts": { "test": "run-s \"test:*\"", - "test:perspectives-registry": "node -- perspectives-registry-tests.js", "test:v2.0": "run-s \"test:v2.0:*\"", "test:v2.0:t1-json-schema-validate": "node -- json-schema-validate-tests.js -v 2.0", "test:v2.0:t2-json-schema-semantic": "node -- json-schema-semantic-tests.js -v 2.0", - "test:v2.0:t3-json-schema-functional": "node -- json-schema-functional-tests.js -v 2.0" + "test:v2.0:t3-json-schema-functional": "node -- json-schema-functional-tests.js -v 2.0", + "test:v2.0:t4-perspectives-registry": "node -- perspectives-registry-tests.js" } } From 4c7c7c26f38fd6b7507ddf35ebddacefffe229ef Mon Sep 17 00:00:00 2001 From: taleodor-claude Date: Fri, 4 Sep 2026 11:33:53 -0400 Subject: [PATCH 16/16] feat: Scope perspectives to parts of the document; follow references in mappings (via) * feat(perspective): scope perspectives to parts of the document and follow references in mappings Perspectives applied to a whole document, with each mapping carrying the same subject filter; a document with several models got one blended view and tooling could not evaluate completeness per subject. Two additions: - `scopes` on the perspective (reference and inline forms): an array of scope objects {bom-ref, name, description, expressions[], targets[]}, at least one of expressions/targets. Each scope is an independent application of the perspective (one view, one completeness evaluation). Scope resolution is specified as a numbered algorithm in the description: expressions from the document root, targets by bom-ref, one-hop closure through same-document reference entries (`ref` properties, as introduced by the inventory refactor), union of subtrees; containment by JSONPath normalized-path prefix; BOM-Links to other documents not followed. A published pre-defined perspective declares no scopes; the document decides where it applies, so `scopes` is the one field besides bom-ref permitted on the reference form. - `via` on a mapping: ordered traversal steps applied before the expression, so a mapping can select data related to the scope rather than contained in it. A step either resolves reference values found at a root-absolute path restricted to the current set (`refs`), or walks the dependency graph from the referenceable nodes of the current set (`dependencies`: depends-on, provides, dependents, provided-by; optional `transitive`). The expression is then restricted to the subtrees of the nodes the last step produced. Mapping expressions stay root-absolute: selection is the intersection of scope (or traversal result) and expression. Catalog perspectives/model-card-perspective.json rewritten in place at version 1 (the upstream PR is a draft, so the immutability rule is set aside this once): models located by descendant search (`$..[?(@.type=='machine-learning-model')]`) so the entry works whether models sit at the root, in inventories, or in definitions; Datasets and Use Case Definitions reached via `refs` from the model's `modelProperties.training.datasets` / `modelProperties.useCases`; risk mappings stay document-level with a rationale note, since risks reference components in the reverse direction. Fixtures: valid scoped reference (targets and expressions) plus a scoped inline perspective with a chained dependency traversal and a `refs` step; invalid: scope without expressions or targets, traversal step mixing refs and dependencies, unknown dependency direction. Verified: JS schema-v2 2.0 group green except the registry check (the catalog entry is regenerated in the next commit); Java 2.0 suite green (245 tests); bundler unchanged; no new lint findings on the schema. * chore(perspective): re-register model-card version 1 for the in-place rewrite The catalog document changed at version 1 (draft-stage exception to the immutability rule), so its registered hash no longer matched. Dropped the entry and regenerated it with the generator, recording the new content hash and the commit that rewrote the document. * feat(perspective): reusable perspective definitions applied by reference; examples on the inventory layout Written as if CycloneDX/specification#1075 (inventory refactor and definitions registry) were merged: - definitions.perspectives (cyclonedx-definition-2.0.schema.json, appended): reusable inline perspectives, each with a required bom-ref. Typed as perspectiveDefinition, a mixin over the perspective schema that forbids predefined/predefinedVersion/ref/scopes, since scopes belong to each application (same rule as for published pre-defined perspectives). - Third form of a perspective: `ref` (bom-ref or BOM-Link element to a definition), with only bom-ref and scopes permitted alongside, so the definition remains the single source of truth while the document decides where it applies. `predefined` and `ref` are mutually exclusive with each other and with the inline form (oneOf, all directions). - Examples (mapping expression, scope expressions) rewritten on the inventory layout: `$.inventories[*].components[...]`, `$.definitions.components[...]`, `$.inventories[?(@['bom-ref']=='inv-vision')]`, matching the edits #1075 makes to this file so the eventual merge is trivial. The catalog document is untouched (descendant search already covers both layouts) so its registry entry stays valid. Fixtures: the valid scoped fixture gains a definitions.perspectives entry applied at two scopes by reference; invalid: ref plus inline mappings, ref plus predefined, definition declaring scopes, definition without bom-ref. Each checked to fail for that reason. Fixtures keep the root `components` layout because the base branch does not carry #1075 yet. Verified: JS 2.0 group green; Java 2.0 suite green (249 tests); bundler unchanged; no new lint findings on either schema (the definition mixin carries the linter's mixin marker instead of additionalProperties). * feat(perspective): model identifiers, dataset detail, and training procedure in the model card Follow-up to @mrutkows's review of the catalog entry on CycloneDX/specification#1067 (identifiers; datasets as the EU AI Act focus; usage of data relative to training stages). Three changes to perspectives/model-card-perspective.json, still version 1 (draft-stage exception; registry entry regenerated in the next commit): - Model Identifiers (recommended): the model's `identifiers`, that is identity claims grouped by asserting party, each a scheme such as purl or cpe plus a value. Name and version alone are ambiguous across hubs and forks. Schemes are listed uniformly; ranking them is a component model question, not a catalog one. - Datasets split into four mappings, all reached from the scoped model through the same `via.refs` step over `modelProperties.training.datasets`: Dataset Identity and Licensing (required; name, version, identifiers, licenses), Dataset Description and Contents, Dataset Classification and Sensitive Data, Dataset Governance (recommended). Completeness can now report which aspect of a dataset is missing instead of "dataset present". - Training Procedure (recommended): `via.refs` over `modelProperties.training.formula` into `$.formulation[*]`, selecting the formula whose workflows and tasks describe the training stages and the data each consumed. Not mappable today and left out: provenance labels such as crawled or synthetic, and data subsets with rationale; the 2.0 data model has no fields for them. Verified: the catalog document validates against the 2.0 schema; JS 2.0 group green after the registry regeneration in the next commit. * chore(perspective): re-register model-card version 1 after the review follow-up rewrite Same draft-stage exception as before: the catalog document changed at version 1, so the entry was dropped and regenerated with the new content hash and commit. --------- Co-authored-by: Claude Code (ReARM Agent) Signed-off-by: Pavel Shukhman --- perspectives/model-card-perspective.json | 116 +++++++-- .../cyclonedx-definition-2.0.schema.json | 9 + .../cyclonedx-perspective-2.0.schema.json | 241 +++++++++++++++++- schema/perspectives-defs.json | 10 +- ...alid-perspective-definition-noref-2.0.json | 21 ++ ...lid-perspective-definition-scoped-2.0.json | 30 +++ .../invalid-perspective-ref-mixed-2.0.json | 33 +++ ...nvalid-perspective-ref-predefined-2.0.json | 30 +++ .../invalid-perspective-scope-empty-2.0.json | 21 ++ ...invalid-perspective-via-direction-2.0.json | 25 ++ .../invalid-perspective-via-mixed-2.0.json | 26 ++ .../2.0/valid-perspective-scopes-2.0.json | 178 +++++++++++++ 12 files changed, 699 insertions(+), 41 deletions(-) create mode 100644 tools/src/test/resources/2.0/invalid-perspective-definition-noref-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-definition-scoped-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-ref-mixed-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-ref-predefined-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-scope-empty-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-via-direction-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-via-mixed-2.0.json create mode 100644 tools/src/test/resources/2.0/valid-perspective-scopes-2.0.json diff --git a/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json index 48df302c1..703a10420 100644 --- a/perspectives/model-card-perspective.json +++ b/perspectives/model-card-perspective.json @@ -4,13 +4,13 @@ "specVersion": "2.0", "version": 1, "metadata": { - "timestamp": "2026-08-29T12:00:00Z" + "timestamp": "2026-09-04T12:00:00Z" }, "perspectives": [ { "bom-ref": "perspective-model-card", "name": "Model Card", - "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990.", + "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990. Model data is located by descendant search so the perspective applies whether models are declared at the document root, in inventories, or in definitions; related data such as training datasets and use cases is reached through traversal steps from the model's references.", "domains": [ "machine-learning", "artificial-intelligence", @@ -19,7 +19,7 @@ ], "mappings": [ { - "expression": "$.components[?(@.type=='machine-learning-model')]['name','version','description']", + "expression": "$..[?(@.type=='machine-learning-model')]['name','version','description']", "nativeName": "Model Details", "nativeDescription": "The identifying facts of the model: its name, version, and a description of what it is and does.", "relevance": "required", @@ -27,7 +27,15 @@ "rationale": "A model card is meaningless without stating which model, and which revision of it, the card describes." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].parties[?(@.roles[?(@.role=='supplier')])]", + "expression": "$..[?(@.type=='machine-learning-model')].identifiers", + "nativeName": "Model Identifiers", + "nativeDescription": "Identifiers under which the model is published or catalogued, such as a Package-URL for a model hub entry, grouped by the party asserting them.", + "relevance": "recommended", + "weight": 0.7, + "rationale": "Name and version alone are ambiguous across hubs and forks; asserted identifiers let a card be matched to the published artefact." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].parties[?(@.roles[?(@.role=='supplier')])]", "nativeName": "Developed By", "nativeDescription": "The organization or individuals responsible for developing and supplying the model.", "relevance": "required", @@ -35,7 +43,7 @@ "rationale": "Accountability for a model's behaviour requires knowing who produced it. Expressed through the party model with the supplier role." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].licenses", + "expression": "$..[?(@.type=='machine-learning-model')].licenses", "nativeName": "License", "nativeDescription": "The license under which the model, and by extension its weights, may be used.", "relevance": "required", @@ -43,7 +51,7 @@ "rationale": "Model cards conventionally state usage terms; license determines whether a given use is permitted at all." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.tasks", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.tasks", "nativeName": "Supported Tasks", "nativeDescription": "The machine learning tasks the model is designed to perform.", "relevance": "required", @@ -51,7 +59,7 @@ "rationale": "Tasks anchor the card: they determine the applicable inputs, outputs, and evaluation metrics." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.learningTypes", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.learningTypes", "nativeName": "Learning Paradigms", "nativeDescription": "The learning paradigms applied when training the model, such as supervised or reinforcement learning.", "relevance": "recommended", @@ -59,7 +67,7 @@ "rationale": "Helps readers judge what kinds of data and feedback shaped the model's behaviour." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.architecture", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.architecture", "nativeName": "Model Architecture", "nativeDescription": "The architecture family and structural characteristics of the model.", "relevance": "recommended", @@ -67,7 +75,7 @@ "rationale": "Architecture contextualizes capability and performance claims and supports reproducibility." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties['inputs','outputs']", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties['inputs','outputs']", "nativeName": "Input and Output Parameters", "nativeDescription": "The modalities, formats, and constraints of the data the model consumes and produces.", "relevance": "recommended", @@ -75,7 +83,7 @@ "rationale": "Input and output specifications define the model's operational envelope and integration contract." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties['parameterCount','quantization']", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties['parameterCount','quantization']", "nativeName": "Model Size and Quantization", "nativeDescription": "The parameter count of the model and any quantization applied to its weights.", "relevance": "optional", @@ -83,7 +91,7 @@ "rationale": "Size and quantization inform deployment cost and can affect accuracy relative to the unquantized model." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.training", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.training", "nativeName": "Training Data and Procedure", "nativeDescription": "How the model was trained: the training formula and the datasets used.", "relevance": "recommended", @@ -91,15 +99,72 @@ "rationale": "Training data provenance is central to assessing bias, capability boundaries, and data protection obligations." }, { - "expression": "$.components[?(@.type=='data')]", - "nativeName": "Datasets", - "nativeDescription": "Dataset components, typically referenced from the model's training information, carrying dataset composition, governance, and sensitive-data declarations.", + "expression": "$.formulation[*]", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.formula" + } + ], + "nativeName": "Training Procedure", + "nativeDescription": "The formula describing how the model was trained: its workflows and tasks, such as pre-training, fine-tuning, and alignment stages, and the data each consumed.", + "relevance": "recommended", + "weight": 0.7, + "rationale": "Training stages and the datasets each consumed show how the data was used, which a description of the datasets alone cannot convey." + }, + { + "expression": "$..[?(@.type=='data')]['name','version','identifiers','licenses']", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Dataset Identity and Licensing", + "nativeDescription": "Which dataset was used, at which version, under which identifiers and licence terms.", + "relevance": "required", + "weight": 0.8, + "rationale": "A dataset a model card reader cannot identify or check licence terms for cannot be assessed; identity and licensing are the minimum the EU AI Act's data documentation expects." + }, + { + "expression": "$..[?(@.type=='data')].data[*]['type','description','contents']", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Dataset Description and Contents", + "nativeDescription": "What the dataset is: its general theme, a description of its size and role, and its contents or where they are held.", "relevance": "recommended", "weight": 0.6, - "rationale": "Training references resolve to components of type data; the dataset detail a card reader needs lives on those components." + "rationale": "Describing composition and contents lets a reader judge coverage and representativeness of the training data." + }, + { + "expression": "$..[?(@.type=='data')].data[*]['classification','sensitiveData']", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Dataset Classification and Sensitive Data", + "nativeDescription": "The protection level the dataset requires and any sensitive or personal data it contains.", + "relevance": "recommended", + "weight": 0.7, + "rationale": "Sensitive-data declarations drive data protection obligations and are a primary concern of AI regulation." + }, + { + "expression": "$..[?(@.type=='data')].data[*].governance", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Dataset Governance", + "nativeDescription": "The parties accountable for the dataset through its lifecycle: owners, stewards, and custodians.", + "relevance": "recommended", + "weight": 0.5, + "rationale": "Governance names who is accountable for the data, which regulators and downstream users need to trace provenance and responsibility." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.evaluation", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.evaluation", "nativeName": "Quantitative Analysis", "nativeDescription": "Evaluation results: performance metrics, per-slice measurements, confidence intervals, and supporting graphics.", "relevance": "recommended", @@ -107,7 +172,7 @@ "rationale": "Metrics, including slice-level results, substantiate capability claims and surface performance disparities between groups." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.useCases", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.useCases", "nativeName": "Intended Use", "nativeDescription": "References to the use cases the model is intended for.", "relevance": "required", @@ -115,7 +180,12 @@ "rationale": "Intended use separates in-scope application from misuse; it is the card section most consulted by adopters and assessors." }, { - "expression": "$.definitions.useCases", + "expression": "$.definitions.useCases[*]", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.useCases[*]" + } + ], "nativeName": "Use Case Definitions", "nativeDescription": "The use case definitions that the model's intended-use references resolve to.", "relevance": "recommended", @@ -123,7 +193,7 @@ "rationale": "The model links to use cases by reference; the definitions carry the actual descriptions a card reader needs." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].parties[?(@.roles[?(@.role=='end-user')])]", + "expression": "$..[?(@.type=='machine-learning-model')].parties[?(@.roles[?(@.role=='end-user')])]", "nativeName": "Intended Users", "nativeDescription": "The audiences the model is intended to be used by.", "relevance": "recommended", @@ -131,7 +201,7 @@ "rationale": "Stating who the model is for frames the expertise assumed of its operators. Expressed through the party model with the end-user role." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.limitations", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.limitations", "nativeName": "Technical Limitations", "nativeDescription": "Known technical limitations of the model, including constraints on accuracy, reasoning, scalability, and appropriate use, and relevant performance tradeoffs.", "relevance": "required", @@ -144,7 +214,7 @@ "nativeDescription": "Risks in the ethical domain associated with the model, including affected parties, benefits, harms, and mitigations.", "relevance": "required", "weight": 0.9, - "rationale": "Ethical considerations are expressed as entries in the document's risk model rather than as card-local prose, gaining structured likelihood, impact, and response data." + "rationale": "Ethical considerations are expressed as entries in the document's risk model rather than as card-local prose, gaining structured likelihood, impact, and response data. Risks reference the components they affect, not the other way round, so this mapping is evaluated over the whole document rather than through a traversal step; tooling may narrow it to risks whose `affects` include the scoped model." }, { "expression": "$.risks.risks[?(@.inherentRisk.impact.categories[?(@=='fairness' || @=='bias')])]", @@ -152,10 +222,10 @@ "nativeDescription": "Risks whose impact is categorized as fairness or bias, describing groups at risk and observed disparities.", "relevance": "recommended", "weight": 0.6, - "rationale": "Fairness assessments identify demographic or group-level performance disparities; slice-level evaluation metrics provide their quantitative backing." + "rationale": "Fairness assessments identify demographic or group-level performance disparities; slice-level evaluation metrics provide their quantitative backing. Risks reference the components they affect, not the other way round, so this mapping is evaluated over the whole document rather than through a traversal step; tooling may narrow it to risks whose `affects` include the scoped model." }, { - "expression": "$.components[?(@.type=='machine-learning-model')].modelProperties.environmental", + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.environmental", "nativeName": "Environmental Considerations", "nativeDescription": "Energy consumption and carbon cost of model activities such as training and inference.", "relevance": "recommended", diff --git a/schema/2.0/model/cyclonedx-definition-2.0.schema.json b/schema/2.0/model/cyclonedx-definition-2.0.schema.json index ec57179bb..9b6b7c3f1 100644 --- a/schema/2.0/model/cyclonedx-definition-2.0.schema.json +++ b/schema/2.0/model/cyclonedx-definition-2.0.schema.json @@ -25,6 +25,15 @@ }, "businessObjectives": { "$ref": "cyclonedx-business-objective-2.0.schema.json#/$defs/businessObjectives" + }, + "perspectives": { + "type": "array", + "uniqueItems": true, + "title": "Perspectives", + "description": "Reusable perspective declarations. Each entry is a complete inline perspective, identified by its bom-ref, that may be applied from the document's perspectives by reference, optionally scoped to different parts of the document at each application. A definition declares no scopes of its own.", + "items": { + "$ref": "cyclonedx-perspective-2.0.schema.json#/$defs/perspectiveDefinition" + } } } } diff --git a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json index 126bab773..e9ddf3d3f 100644 --- a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json +++ b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json @@ -3,7 +3,7 @@ "$id": "https://cyclonedx.org/schema/2.0/model/cyclonedx-perspective-2.0.schema.json", "type": "null", "title": "CycloneDX Perspective Model", - "$comment" : "OWASP CycloneDX is an Ecma International standard (ECMA-424) developed in collaboration between the OWASP Foundation and Ecma Technical Committee 54 (TC54). The standard is published under a royalty-free patent policy. This JSON schema is the reference implementation and is licensed under the Apache License 2.0.", + "$comment": "OWASP CycloneDX is an Ecma International standard (ECMA-424) developed in collaboration between the OWASP Foundation and Ecma Technical Committee 54 (TC54). The standard is published under a royalty-free patent policy. This JSON schema is the reference implementation and is licensed under the Apache License 2.0.", "$defs": { "perspectives": { "type": "array", @@ -16,20 +16,22 @@ "perspective": { "type": "object", "title": "Perspective", - "description": "A domain-specific view that identifies the types of data relevant to a particular audience and provides optional terminology mappings to facilitate interpretation. Perspectives enable tooling to generate filtered views, translate terminology, and validate document completeness against audience-specific requirements.", + "description": "A domain-specific view that identifies the types of data relevant to a particular audience and provides optional terminology mappings to facilitate interpretation. Perspectives enable tooling to generate filtered views, translate terminology, and validate document completeness against audience-specific requirements. A perspective is either a published pre-defined perspective applied by identity (`predefined`), a perspective definition applied by reference (`ref`), or defined inline. It applies to the whole document unless it declares scopes, each of which applies it to a part of the document.", "additionalProperties": false, "oneOf": [ { - "$comment": "Reference form: the perspective is the published pre-defined perspective, incorporated by reference at a specific version. Both predefined and predefinedVersion are required; only bom-ref may otherwise accompany the pre-defined identity, so the published definition remains the single source of truth.", + "$comment": "Reference form: the perspective is the published pre-defined perspective, incorporated by reference at a specific version. Both predefined and predefinedVersion are required; only bom-ref and scopes may otherwise accompany the pre-defined identity, so the published definition remains the single source of truth while the document decides where it applies.", "properties": { "predefined": true, "predefinedVersion": true, + "scopes": true, "name": false, "description": false, "domains": false, "mappings": false, "externalReferences": false, - "properties": false + "properties": false, + "ref": false }, "required": [ "predefined", @@ -37,12 +39,31 @@ ] }, { - "$comment": "Inline form: the perspective is fully defined in the document and shall not declare a pre-defined identity or version.", + "$comment": "Definition reference form: the perspective is a definition declared under definitions.perspectives (or in another document via BOM-Link), applied by reference. Only bom-ref and scopes may accompany the reference, so the definition remains the single source of truth while the document decides where it applies.", + "properties": { + "ref": true, + "predefined": false, + "predefinedVersion": false, + "scopes": true, + "name": false, + "description": false, + "domains": false, + "mappings": false, + "externalReferences": false, + "properties": false + }, + "required": [ + "ref" + ] + }, + { + "$comment": "Inline form: the perspective is fully defined in the document and shall not declare a pre-defined identity, version, or definition reference.", "properties": { "predefined": false, "predefinedVersion": false, "name": true, - "mappings": true + "mappings": true, + "ref": false }, "required": [ "name", @@ -56,12 +77,26 @@ }, "predefined": { "title": "Pre-Defined Perspective", - "description": "Identifies a well-known, pre-defined perspective, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. Values use the reserved `cdx:perspectives:` namespace path and are drawn from the CycloneDX pre-defined perspectives registry (`perspectives-defs.schema.json`), which is maintained independently of the specification release cycle; the catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the CycloneDX specification repository. The specific published revision is selected by the sibling `predefinedVersion`. A perspective declaring a pre-defined identity shall provide `predefinedVersion` and shall not provide any inline content other than bom-ref, so the published definition remains the single source of truth; a perspective defining its own content shall omit both fields.", + "description": "Identifies a well-known, pre-defined perspective, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. Values use the reserved `cdx:perspectives:` namespace path and are drawn from the CycloneDX pre-defined perspectives registry (`perspectives-defs.schema.json`), which is maintained independently of the specification release cycle; the catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the CycloneDX specification repository. The specific published revision is selected by the sibling `predefinedVersion`. A perspective declaring a pre-defined identity shall provide `predefinedVersion` and shall not provide any inline content other than bom-ref and scopes, so the published definition remains the single source of truth; a perspective defining its own content shall omit both fields.", "$ref": "../../perspectives-defs.schema.json#/definitions/preDefinedPerspectivesEnum" }, "predefinedVersion": { "$ref": "#/$defs/preDefinedPerspectiveVersion" }, + "ref": { + "title": "Perspective Definition Reference", + "description": "Reference using bom-link or bom-ref to a perspective definition declared under `definitions.perspectives`, applying that definition here. A perspective declaring a reference shall not provide any inline content other than bom-ref and scopes, so the definition remains the single source of truth.", + "anyOf": [ + { + "title": "Ref", + "$ref": "cyclonedx-common-2.0.schema.json#/$defs/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "cyclonedx-common-2.0.schema.json#/$defs/bomLinkElementType" + } + ] + }, "name": { "type": "string", "title": "Perspective Name", @@ -85,10 +120,13 @@ "$ref": "#/$defs/perspectiveDomainChoice" } }, + "scopes": { + "$ref": "#/$defs/perspectiveScopes" + }, "mappings": { "type": "array", "title": "Data Type Mappings", - "description": "An array of mappings that identify the types of data relevant to this perspective using JSON path expressions. Each mapping may include domain-specific terminology.", + "description": "An array of mappings that identify the types of data relevant to this perspective using JSON path expressions evaluated from the document root. Each mapping may include domain-specific terminology. When the perspective declares scopes, each mapping is restricted to the part of the document selected by the scope it is evaluated for.", "minItems": 1, "items": { "$ref": "#/$defs/perspectiveMapping" @@ -102,16 +140,120 @@ } } }, + "perspectiveDefinition": { + "title": "Perspective Definition", + "description": "A reusable inline perspective declared under `definitions.perspectives`. It carries a bom-ref so it can be applied by reference, and declares no pre-defined identity, definition reference, or scopes: scopes belong to each application.", + "allOf": [ + { + "$ref": "#/$defs/perspective" + }, + { + "type": "object", + "$comment": "This is a mixin over the referenced perspective schema, which enforces strictness (additionalProperties: false); this branch only forbids the application-specific fields and requires bom-ref.", + "additionalProperties": true, + "properties": { + "bom-ref": true, + "predefined": false, + "predefinedVersion": false, + "ref": false, + "scopes": false + }, + "required": [ + "bom-ref" + ] + } + ] + }, "preDefinedPerspectiveVersion": { "type": "integer", "title": "Pre-Defined Perspective Version", "description": "The published revision of the pre-defined perspective (identified by `predefined`) that this reference incorporates. This is the `version` of the catalog document that defines the perspective; like other CycloneDX version fields it is an integer incremented by 1 on each published revision. Pinning the version keeps a reference stable as the catalog perspective evolves.", "minimum": 1 }, + "perspectiveScopes": { + "type": "array", + "title": "Perspective Scopes", + "description": "The parts of the document this perspective applies to. Each scope is an independent application of the perspective: tooling generates one view and one completeness evaluation per scope. Omitting scopes applies the perspective to the whole document. A scope selects a set of nodes, resolved as follows: (1) evaluate each expression from the document root and select every node it identifies; (2) select the node identified by each target, resolving bom-ref values within this document; (3) for every reference entry within the subtrees of the nodes selected so far, that is, an object whose `ref` property identifies an object elsewhere in this document, also select the referenced node, once, without following references found in that node in turn; (4) the scope is the union of the subtrees of the selected nodes. A node is within a scope when its normalized path, as defined by JSONPath, starts with the normalized path of a selected node. BOM-Links to other documents are recorded but not followed. Scopes are applied at the perspective in the document, whether it applies a pre-defined perspective, applies a definition by reference, or is defined inline; a published pre-defined perspective and a perspective definition declare no scopes of their own.", + "minItems": 1, + "items": { + "$ref": "#/$defs/perspectiveScope" + } + }, + "perspectiveScope": { + "type": "object", + "title": "Perspective Scope", + "description": "One part of the document a perspective applies to, selected by JSON path expressions, by references to objects, or both. Within a scope, expressions and targets are combined by union.", + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "expressions": true + }, + "required": [ + "expressions" + ] + }, + { + "properties": { + "targets": true + }, + "required": [ + "targets" + ] + } + ], + "properties": { + "bom-ref": { + "$ref": "cyclonedx-common-2.0.schema.json#/$defs/refType" + }, + "name": { + "type": "string", + "title": "Scope Name", + "description": "The name of the scope, typically naming the subject it selects.", + "examples": [ + "Vision model", + "TLS stack" + ] + }, + "description": { + "type": "string", + "title": "Scope Description", + "description": "A description of the scope and why the perspective is applied to this part of the document." + }, + "expressions": { + "type": "array", + "title": "Path Expressions", + "description": "[JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expressions, evaluated from the document root, that identify the nodes whose subtrees form this scope.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + }, + "examples": [ + [ + "$.definitions.components[?(@.type=='machine-learning-model' && @.group=='text')]" + ], + [ + "$.inventories[?(@['bom-ref']=='inv-vision')]" + ] + ] + }, + "targets": { + "type": "array", + "title": "Targets", + "description": "References using bom-link or bom-ref to the objects whose subtrees form this scope, such as a component or, where the document declares inventories, an inventory.", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "cyclonedx-common-2.0.schema.json#/$defs/elementLink" + } + } + } + }, "perspectiveMapping": { "type": "object", "title": "Perspective Mapping", - "description": "Maps a JSON path expression to domain-specific terminology, enabling audiences to interpret CycloneDX data using familiar nomenclature. Each mapping identifies a data type within the document structure and may provide alternative names and descriptions used by the target audience.", + "description": "Maps a JSON path expression to domain-specific terminology, enabling audiences to interpret CycloneDX data using familiar nomenclature. Each mapping identifies a data type within the document structure and may provide alternative names and descriptions used by the target audience. Expressions are evaluated from the document root; when the perspective is applied to a scope, the mapping selects only nodes within that scope, or within the nodes produced by its traversal steps.", "additionalProperties": false, "required": [ "expression" @@ -120,14 +262,24 @@ "expression": { "type": "string", "title": "Path Expression", - "description": "A [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expression that identifies the types of data relevant to this perspective.", + "description": "A [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expression, evaluated from the document root, that identifies the types of data relevant to this perspective. When the perspective is applied to a scope, only the identified nodes within the scope are selected; when the mapping declares traversal steps, only those within the nodes the steps produce.", "examples": [ - "$.components[*].pedigree", - "$.components[?(@.type=='machine-learning-model')].modelCard", - "$.components[?(@.type=='cryptographic-asset')]", + "$.inventories[*].components[*].pedigree", + "$.inventories[*].components[?(@.type=='machine-learning-model')].modelProperties", + "$..[?(@.type=='machine-learning-model')].modelProperties", + "$.definitions.components[?(@.type=='cryptographic-asset')]", "$.vulnerabilities" ] }, + "via": { + "type": "array", + "title": "Traversal Steps", + "description": "Steps that follow references from the scoped part of the document to related objects before the expression is evaluated, in order. Use this for data that is related to the scope rather than contained in it, such as the datasets a model was trained on, or the algorithms a library provides.", + "minItems": 1, + "items": { + "$ref": "#/$defs/perspectiveTraversalStep" + } + }, "nativeName": { "type": "string", "title": "Native Name", @@ -161,6 +313,69 @@ } } }, + "perspectiveTraversalStep": { + "type": "object", + "title": "Perspective Traversal Step", + "description": "One step that transforms the set of nodes a mapping is evaluated against by following references, so that a mapping can select data related to the scoped part of the document rather than contained in it. A step either resolves reference values found at a path, or walks the dependency graph. Steps are applied in order, each to the result of the previous one; the first step is applied to the scope, or to the whole document when the perspective declares no scopes. The mapping's expression is then restricted to the subtrees of the nodes the last step produced.", + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "refs": true, + "dependencies": false, + "transitive": false + }, + "required": [ + "refs" + ] + }, + { + "properties": { + "dependencies": true, + "refs": false + }, + "required": [ + "dependencies" + ] + } + ], + "properties": { + "refs": { + "type": "string", + "title": "Reference Path", + "description": "A [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expression, evaluated from the document root and restricted to the current set of nodes, whose results are reference values (bom-ref or bom-link). The step produces the objects within this document those values identify; BOM-Links to other documents are not followed.", + "examples": [ + "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + ] + }, + "dependencies": { + "$ref": "#/$defs/perspectiveDependencyDirection" + }, + "transitive": { + "type": "boolean", + "title": "Transitive", + "description": "When true, the dependency graph is walked repeatedly in the given direction until no further objects are reached, and the step produces every object reached. When false or absent, only directly related objects are produced.", + "default": false + } + } + }, + "perspectiveDependencyDirection": { + "type": "string", + "title": "Dependency Direction", + "description": "The direction in which a traversal step walks the dependency graph, starting from every object within the current set of nodes that carries a bom-ref.", + "enum": [ + "depends-on", + "provides", + "dependents", + "provided-by" + ], + "meta:enum": { + "depends-on": "Produces the objects the current objects depend on, as declared by `dependsOn`.", + "provides": "Produces the objects the current objects provide or implement, as declared by `provides`.", + "dependents": "Produces the objects that depend on the current objects, the reverse of `dependsOn`.", + "provided-by": "Produces the objects that provide or implement the current objects, the reverse of `provides`." + } + }, "perspectiveRelevance": { "type": "string", "title": "Perspective Relevance", diff --git a/schema/perspectives-defs.json b/schema/perspectives-defs.json index d7524e2b5..bcf9a7d41 100644 --- a/schema/perspectives-defs.json +++ b/schema/perspectives-defs.json @@ -1,18 +1,18 @@ { "$schema": "http://cyclonedx.org/schema/perspectives-defs.schema.json", - "lastUpdated": "2026-08-29T21:46:55Z", + "lastUpdated": "2026-09-04T15:26:05Z", "perspectives": [ { "predefined": "cdx:perspectives:model-card", "file": "perspectives/model-card-perspective.json", "name": "Model Card", - "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990.", + "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990. Model data is located by descendant search so the perspective applies whether models are declared at the document root, in inventories, or in definitions; related data such as training datasets and use cases is reached through traversal steps from the model's references.", "versions": [ { "version": 1, - "sha256": "a589072bc854cb3cb6e81f1729144a999c3cfc8bf85e0131c275e252f3ded320", - "commit": "ebb5184e91a1dbcdb2009f4c5f19b1f439473058", - "date": "2026-08-29T21:46:55Z" + "sha256": "1ffe9fe3cf91e491a269c0c97d0c27659a98212add7525ad67e49f972b5c8fe5", + "commit": "c60f783eab4eafa3eee79431a937827cd970857b", + "date": "2026-09-04T15:26:05Z" } ] } diff --git a/tools/src/test/resources/2.0/invalid-perspective-definition-noref-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-definition-noref-2.0.json new file mode 100644 index 000000000..0a7b27a0f --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-definition-noref-2.0.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "definitions": { + "perspectives": [ + { + "name": "Def without bom-ref", + "mappings": [ + { + "expression": "$.vulnerabilities" + } + ] + } + ] + } +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-definition-scoped-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-definition-scoped-2.0.json new file mode 100644 index 000000000..4507bd00f --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-definition-scoped-2.0.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "definitions": { + "perspectives": [ + { + "bom-ref": "def-1", + "name": "Def", + "scopes": [ + { + "name": "s", + "expressions": [ + "$.vulnerabilities" + ] + } + ], + "mappings": [ + { + "expression": "$.vulnerabilities" + } + ] + } + ] + } +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-ref-mixed-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-ref-mixed-2.0.json new file mode 100644 index 000000000..9eaa87fd5 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-ref-mixed-2.0.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "definitions": { + "perspectives": [ + { + "bom-ref": "def-1", + "name": "Def", + "mappings": [ + { + "expression": "$.vulnerabilities" + } + ] + } + ] + }, + "perspectives": [ + { + "bom-ref": "p-1", + "ref": "def-1", + "mappings": [ + { + "expression": "$.vulnerabilities" + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-ref-predefined-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-ref-predefined-2.0.json new file mode 100644 index 000000000..eecf904ea --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-ref-predefined-2.0.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "definitions": { + "perspectives": [ + { + "bom-ref": "def-1", + "name": "Def", + "mappings": [ + { + "expression": "$.vulnerabilities" + } + ] + } + ] + }, + "perspectives": [ + { + "bom-ref": "p-1", + "ref": "def-1", + "predefined": "cdx:perspectives:model-card", + "predefinedVersion": 1 + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-scope-empty-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-scope-empty-2.0.json new file mode 100644 index 000000000..9420a734e --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-scope-empty-2.0.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "card-1", + "predefined": "cdx:perspectives:model-card", + "predefinedVersion": 1, + "scopes": [ + { + "name": "Selects nothing: a scope shall declare expressions or targets" + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-via-direction-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-via-direction-2.0.json new file mode 100644 index 000000000..a60632545 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-via-direction-2.0.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "persp-1", + "name": "Unknown traversal direction", + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "via": [ + { + "dependencies": "uses" + } + ] + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-via-mixed-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-via-mixed-2.0.json new file mode 100644 index 000000000..10b360323 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-via-mixed-2.0.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "persp-1", + "name": "Mixed traversal step", + "mappings": [ + { + "expression": "$.components[?(@.type=='data')]", + "via": [ + { + "refs": "$.components[*].modelProperties.training.datasets[*]", + "dependencies": "depends-on" + } + ] + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/valid-perspective-scopes-2.0.json b/tools/src/test/resources/2.0/valid-perspective-scopes-2.0.json new file mode 100644 index 000000000..62d1c4cf7 --- /dev/null +++ b/tools/src/test/resources/2.0/valid-perspective-scopes-2.0.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "serialNumber": "urn:uuid:3c1f7d2e-8a4b-4c9d-9e1f-5b6a7c8d9e0f", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "definitions": { + "perspectives": [ + { + "bom-ref": "def-crypto-in-use", + "name": "Cryptography in Use", + "description": "A reusable perspective definition, applied below at two scopes.", + "domains": [ + "cryptographic-security" + ], + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "via": [ + { + "dependencies": "depends-on", + "transitive": true + }, + { + "dependencies": "provides" + } + ], + "nativeName": "Algorithms in Use", + "relevance": "required" + } + ] + } + ] + }, + "components": [ + { + "bom-ref": "model-vision", + "type": "machine-learning-model", + "name": "vision-v3", + "version": "3.0.0" + }, + { + "bom-ref": "model-text-a", + "type": "machine-learning-model", + "group": "text", + "name": "text-a", + "version": "1.2.0" + }, + { + "bom-ref": "model-text-b", + "type": "machine-learning-model", + "group": "text", + "name": "text-b", + "version": "2.0.0" + }, + { + "bom-ref": "ds-imagenet", + "type": "data", + "name": "ImageNet" + }, + { + "bom-ref": "lib-openssl", + "type": "library", + "name": "openssl", + "version": "3.3.0" + }, + { + "bom-ref": "alg-aes-256-gcm", + "type": "cryptographic-asset", + "name": "AES-256-GCM" + } + ], + "dependencies": [ + { + "ref": "lib-openssl", + "provides": [ + "alg-aes-256-gcm" + ] + }, + { + "ref": "model-vision", + "dependsOn": [ + "lib-openssl" + ] + } + ], + "perspectives": [ + { + "bom-ref": "card-vision", + "predefined": "cdx:perspectives:model-card", + "predefinedVersion": 1, + "scopes": [ + { + "bom-ref": "scope-vision", + "name": "Vision model", + "targets": [ + "model-vision" + ] + }, + { + "bom-ref": "scope-text", + "name": "Text models", + "description": "Both text models, evaluated as one card.", + "expressions": [ + "$.components[?(@.type=='machine-learning-model' && @.group=='text')]" + ] + } + ] + }, + { + "bom-ref": "crypto-in-use", + "name": "Cryptography in Use", + "description": "An inline perspective scoped to one component, reaching related data through traversal steps.", + "domains": [ + "cryptographic-security" + ], + "scopes": [ + { + "name": "Vision model stack", + "targets": [ + "model-vision" + ], + "expressions": [ + "$.components[?(@['bom-ref']=='lib-openssl')]" + ] + } + ], + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "via": [ + { + "dependencies": "depends-on", + "transitive": true + }, + { + "dependencies": "provides" + } + ], + "nativeName": "Algorithms in Use", + "relevance": "required", + "weight": 1.0 + }, + { + "expression": "$.components[?(@.type=='data')]", + "via": [ + { + "refs": "$.components[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Training Data", + "relevance": "recommended" + } + ] + }, + { + "bom-ref": "crypto-per-model", + "ref": "def-crypto-in-use", + "scopes": [ + { + "name": "Vision model", + "targets": [ + "model-vision" + ] + }, + { + "name": "Text models", + "expressions": [ + "$.components[?(@.type=='machine-learning-model' && @.group=='text')]" + ] + } + ] + } + ] +}