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/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json new file mode 100644 index 000000000..703a10420 --- /dev/null +++ b/perspectives/model-card-perspective.json @@ -0,0 +1,238 @@ +{ + "$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": "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. 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", + "transparency", + "ethics" + ], + "mappings": [ + { + "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", + "weight": 1.0, + "rationale": "A model card is meaningless without stating which model, and which revision of it, the card describes." + }, + { + "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", + "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": "$..[?(@.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": "$..[?(@.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": "$..[?(@.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": "$..[?(@.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": "$..[?(@.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": "$..[?(@.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": "$..[?(@.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": "$.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": "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": "$..[?(@.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": "$..[?(@.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[*]", + "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", + "weight": 0.6, + "rationale": "The model links to use cases by reference; the definitions carry the actual descriptions a card reader needs." + }, + { + "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", + "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": "$..[?(@.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. 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')])]", + "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. 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": "$..[?(@.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." + } + ] + } + ] +} 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 217cf5870..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,16 +16,87 @@ "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, - "required": [ - "name", - "mappings" + "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 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, + "ref": false + }, + "required": [ + "predefined", + "predefinedVersion" + ] + }, + { + "$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, + "ref": false + }, + "required": [ + "name", + "mappings" + ] + } ], "properties": { "bom-ref": { "$ref": "cyclonedx-common-2.0.schema.json#/$defs/refType" }, + "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 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", @@ -49,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" @@ -66,10 +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" @@ -78,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", @@ -119,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 new file mode 100644 index 000000000..bcf9a7d41 --- /dev/null +++ b/schema/perspectives-defs.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://cyclonedx.org/schema/perspectives-defs.schema.json", + "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. 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": "1ffe9fe3cf91e491a269c0c97d0c27659a98212add7525ad67e49f972b5c8fe5", + "commit": "c60f783eab4eafa3eee79431a937827cd970857b", + "date": "2026-09-04T15:26:05Z" + } + ] + } + ] +} 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 98e924bcc..b599d46a7 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/AbstractJsonSchemaVerificationTest.java b/tools/src/test/java/org/cyclonedx/schema/v2/AbstractJsonSchemaVerificationTest.java index 5aa51b9a6..1d715e9be 100644 --- a/tools/src/test/java/org/cyclonedx/schema/v2/AbstractJsonSchemaVerificationTest.java +++ b/tools/src/test/java/org/cyclonedx/schema/v2/AbstractJsonSchemaVerificationTest.java @@ -61,6 +61,7 @@ abstract class AbstractJsonSchemaVerificationTest { 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"; private final String version; @@ -107,6 +108,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..bcffdb101 100644 --- a/tools/src/test/js/schema-v2/package.json +++ b/tools/src/test/js/schema-v2/package.json @@ -19,6 +19,7 @@ "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" } } 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-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-predefined-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json new file mode 100644 index 000000000..b8608c05c --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json @@ -0,0 +1,16 @@ +{ + "$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: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 new file mode 100644 index 000000000..5d16a613d --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json @@ -0,0 +1,20 @@ +{ + "$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", + "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 new file mode 100644 index 000000000..353f77d26 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json @@ -0,0 +1,24 @@ +{ + "$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:pqc-readiness", + "name": "PQC Readiness", + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "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/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-predefined-2.0.json b/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json new file mode 100644 index 000000000..34cc61d66 --- /dev/null +++ b/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json @@ -0,0 +1,33 @@ +{ + "$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": "cdx:perspectives:model-card", + "predefinedVersion": 1 + }, + { + "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 + } + ] + } + ] +} 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')]" + ] + } + ] + } + ] +}