diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..b3417622a62 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +/srac/** text eol=lf diff --git a/.github/workflows/internal_tests.yml b/.github/workflows/internal_tests.yml index c86b5d68ed7..e568da8662d 100644 --- a/.github/workflows/internal_tests.yml +++ b/.github/workflows/internal_tests.yml @@ -21,4 +21,4 @@ jobs: internal_tests: uses: eclipse-score/cicd-workflows/.github/workflows/tests.yml@37eaadadfefc263d94a858d25665735659e916fa with: - bazel-target: "test //scripts/tooling:tooling_tests //scripts/known_good:known_good_tests" + bazel-target: "test //scripts/tooling:tooling_tests //scripts/known_good:known_good_tests //srac:srac_projection_tests" diff --git a/srac/BUILD b/srac/BUILD new file mode 100644 index 00000000000..f6fb622e91e --- /dev/null +++ b/srac/BUILD @@ -0,0 +1,66 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@rules_python//python:defs.bzl", "py_binary", "py_library") +load("@score_tooling//python_basics:defs.bzl", "score_py_pytest") + +py_library( + name = "profile", + srcs = [ + "__init__.py", + "tools/__init__.py", + "tools/profile.py", + ], + visibility = ["//visibility:public"], +) + +py_library( + name = "needs_export", + srcs = ["tools/export_needs.py"], + visibility = ["//visibility:public"], + deps = [":profile"], +) + +py_binary( + name = "export_needs", + srcs = ["tools/export_needs_cli.py"], + main = "tools/export_needs_cli.py", + visibility = ["//visibility:public"], + deps = [":needs_export"], +) + +score_py_pytest( + name = "srac_projection_tests", + srcs = ["tests/test_needs_export.py"], + data = [ + "examples/generated/communication.sbom-reference.json", + "examples/generated/communication.srac.json", + "examples/needs/communication.export.json", + "examples/needs/communication.needs.json", + "schema/srac.schema.json", + "schema/srac-report.schema.json", + ], + pytest_config = "//:pyproject.toml", + deps = [ + ":needs_export", + ":profile", + ], +) + +exports_files([ + "examples/generated/communication.sbom-reference.json", + "examples/generated/communication.srac.json", + "examples/needs/communication.export.json", + "examples/needs/communication.needs.json", + "schema/srac-report.schema.json", + "schema/srac.schema.json", +]) diff --git a/srac/NEEDS_EXPORT.md b/srac/NEEDS_EXPORT.md new file mode 100644 index 00000000000..bd78fe82adf --- /dev/null +++ b/srac/NEEDS_EXPORT.md @@ -0,0 +1,78 @@ +# Exporting S-CORE safety metadata from `needs.json` + +This experiment treats the S-CORE Sphinx-needs model as the single source of truth. It does not ask a component team to copy its +safety classification, rationale, requirements, evidence or review state into a second hand-authored file. + +```text +authoritative Sphinx-needs model + needs.json + | + | read-only, deterministic projection + v + portable SRAC safety metadata + | + | SPDX/CycloneDX external reference + v + SPDX or CycloneDX SBOM + | + | discovery and SHA-256 verification + v + receiving tool +``` + +## What is authored and what is derived + +`communication.export.json` contains only transport configuration: the root need ID, explicitly included need IDs, component +PURL/version/repository, publication URI and reproducible creation timestamp. It contains no safety classification or engineering +decision. + +The exporter reads the following values from `needs.json`: + +- `safety` and `safety_relevant` for the projected relevance and classification; +- `status` as source lifecycle provenance, without translating `valid` into an SRAC approval; +- `id`, `type`, `docname` and `external_url` for stable source references; and +- the Sphinx-needs version and exact input digest. + +The generated assertion remains `draft` because the export itself has not received a separate approval. The source need and its +S-CORE review process remain authoritative. Missing or contradictory safety fields fail closed as `undetermined` or an error; +the exporter never infers safety relevance from a title, path or relationship. + +The exporter accepts only the S-CORE source vocabulary `QM`, `ASIL_B` and `ASIL_D`. It does not emit ASIL-A, ASIL-C, SIL or DAL. +An absent classification is exported as `undetermined` / `not-assigned`; an explicitly unsupported value is rejected rather than +silently translated. Functional-safety relevance and security relevance remain separate: security belongs in the impact-analysis +or SBOM/VEX security model, not in `safetyRelevance`. + +## Run the minimal example + +The checked-in `communication.needs.json` is a reduced fixture taken from the public S-CORE `needs.json` shape. It includes a +Feature, Component and Assumption of Use so the mapping gap is visible without checking in the full generated site artifact. + +```bash +bazel run //srac:export_needs -- \ + --needs srac/examples/needs/communication.needs.json \ + --config srac/examples/needs/communication.export.json \ + --output /tmp/communication.srac.json \ + --reference-output /tmp/communication.sbom-reference.json +``` + +The expected outputs are checked in under `examples/generated/`. The reference manifest shows the proposed SPDX 2.3 `OTHER` +external reference and CycloneDX 1.6 `other` external reference. The synthetic carrier SBOMs in that directory demonstrate the +same references in context. `receive_srac` discovers the reference, resolves a previously retrieved local artifact, verifies its +SHA-256 before parsing, validates the assertion and only then correlates it to the referenced component. It does not perform +network retrieval or modify an SBOM. + +## Mapping and feedback for SPDX Functional Safety + +| S-CORE source concept | Export behavior | Standards feedback | +|---|---|---| +| Feature (`feat`) | Preserved as a typed Sphinx-needs evidence reference | A portable model needs an explicit Feature/Dependable Element level or a documented mapping. | +| Component (`comp`) | Used as the projection root and preserved by ID/type/URI | Component identity must remain distinct from an SBOM package and from a source folder. | +| Unit | Not present in this reduced public example | The SPDX mapping needs an unambiguous Unit level below Component. | +| Assumption of Use (`aou_req`) | Preserved as a typed requirement reference | SEooC exchanges need first-class AoU semantics, applicability and satisfaction/verification links. | +| `safety: QM`, `ASIL_B` or `ASIL_D` | Projected exactly to the corresponding S-CORE functional-safety value | Safety integrity is contextual and must not become a global package property. | +| Unsupported ASIL/SIL/DAL or security value | Export rejected | The S-CORE exporter must not imply support for a vocabulary the source model does not use. | +| `status: valid` | Recorded in `sourceOfTruth.rootElementStatus` | Source lifecycle state must not be silently reinterpreted as an external approval. | +| Sphinx-needs relationships | Source IDs remain resolvable; no new relation is inferred | Dependable Element / Feature / Component / Unit and AoU relationships need lossless standardized predicates. | + +This makes the PoC useful as a standards feedback vehicle: it demonstrates transport from an existing safety model while making +the semantic gaps explicit, rather than creating a parallel safety argumentation system. diff --git a/srac/README.md b/srac/README.md new file mode 100644 index 00000000000..6e9b66f95ac --- /dev/null +++ b/srac/README.md @@ -0,0 +1,32 @@ +# SRAC canonical projection and Sphinx-Needs adapter + +This directory contains the first production-oriented slice of the Safety Relevance Assertion Capability (SRAC) work. + +The implementation reads selected records from an authoritative Sphinx-Needs `needs.json` file and produces a deterministic, validated portability artifact. It does not create, infer, review or approve a safety decision. The lifecycle source remains authoritative. + +This draft implementation is intentionally limited to: + +- the canonical SRAC projection and validation contract; +- a read-only Sphinx-Needs adapter; +- deterministic example output and an integrity-bound SBOM reference manifest; and +- focused positive and fail-closed tests. + +The Eclipse S-CORE proof-of-concept pilots, receiver integrations, signatures and attestations are outside this change. Production adoption is subject to the architecture decision proposed in [eclipse-score/score#3318](https://github.com/eclipse-score/score/pull/3318). + +## Run the tests + +```bash +bazel test //srac:srac_projection_tests +``` + +## Generate the example + +```bash +bazel run //srac:export_needs -- \ + --needs srac/examples/needs/communication.needs.json \ + --config srac/examples/needs/communication.export.json \ + --output /tmp/communication.srac.json \ + --reference-output /tmp/communication.sbom-reference.json +``` + +See [NEEDS_EXPORT.md](NEEDS_EXPORT.md) for the mapping boundary and supported S-CORE vocabulary. diff --git a/srac/__init__.py b/srac/__init__.py new file mode 100644 index 00000000000..605ca8cd9f0 --- /dev/null +++ b/srac/__init__.py @@ -0,0 +1,13 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""SRAC sidecar proof of concept.""" diff --git a/srac/examples/generated/communication.sbom-reference.json b/srac/examples/generated/communication.sbom-reference.json new file mode 100644 index 00000000000..98ce342d188 --- /dev/null +++ b/srac/examples/generated/communication.sbom-reference.json @@ -0,0 +1,23 @@ +{ + "artifact": { + "uri": "https://example.org/srac/communication.srac.json", + "sha256": "2108209f8a367eb370de5082b034ac015d6c9df9606c7a065c974d5c08344eea" + }, + "spdx23PackageExternalRef": { + "referenceCategory": "OTHER", + "referenceType": "srac", + "referenceLocator": "https://example.org/srac/communication.srac.json", + "comment": "SRAC sidecar; SHA-256: 2108209f8a367eb370de5082b034ac015d6c9df9606c7a065c974d5c08344eea" + }, + "cycloneDx16ExternalReference": { + "type": "other", + "url": "https://example.org/srac/communication.srac.json", + "comment": "SRAC sidecar", + "hashes": [ + { + "alg": "SHA-256", + "content": "2108209f8a367eb370de5082b034ac015d6c9df9606c7a065c974d5c08344eea" + } + ] + } +} diff --git a/srac/examples/generated/communication.srac.json b/srac/examples/generated/communication.srac.json new file mode 100644 index 00000000000..199e57d9c4e --- /dev/null +++ b/srac/examples/generated/communication.srac.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": "0.2-draft", + "assertionId": "srac-needs-export-comp__com_configuration-0.1", + "subject": { + "name": "score_communication::configuration", + "purl": "pkg:github/eclipse-score/communication@313f1178628189d267c8cd0d75971bdac04c20ee#score/mw/com/configuration", + "version": "313f1178628189d267c8cd0d75971bdac04c20ee", + "repository": "https://github.com/eclipse-score/communication", + "componentPath": "score/mw/com/configuration" + }, + "safetyRelevance": { + "status": "safety-related", + "classification": "ASIL-B" + }, + "rationale": "Read-only projection of S-CORE need 'comp__com_configuration' at needs.json version '0.1'. The source lifecycle status is 'valid'. The export adds no safety decision; the referenced Sphinx-needs model remains authoritative.", + "requirements": [ + { + "id": "aou_req__communication__1", + "type": "sphinx-needs:aou_req", + "uri": "https://eclipse-score.github.io/score/main/modules/communication/docs/requirements/aou_req.html#aou_req__communication__1" + } + ], + "evidence": [ + { + "id": "comp__com_configuration", + "type": "sphinx-needs:comp", + "uri": "https://eclipse-score.github.io/score/main/modules/communication/configuration/docs/architecture/index.html#comp__com_configuration" + }, + { + "id": "feat__com_communication", + "type": "sphinx-needs:feat", + "uri": "https://eclipse-score.github.io/score/main/features/communication/architecture/index.html#feat__com_communication" + } + ], + "sourceOfTruth": { + "system": "sphinx-needs", + "documentUri": "https://eclipse-score.github.io/score/main/needs.json", + "version": "0.1", + "rootElement": "comp__com_configuration", + "rootElementType": "comp", + "rootElementStatus": "valid", + "includedElements": [ + "feat__com_communication", + "aou_req__communication__1" + ], + "sha256": "8741a2702fa43193f3bb31a0e3b1d38105dcaffa565ba5bd6eced4a9d87e7dfa" + }, + "assertion": { + "status": "draft", + "author": { + "name": "S-CORE needs.json export", + "uri": "https://eclipse-score.github.io/score/main/needs.json" + }, + "reviewer": null, + "created": "2026-09-29T00:00:00Z" + } +} diff --git a/srac/examples/needs/communication.export.json b/srac/examples/needs/communication.export.json new file mode 100644 index 00000000000..45f109948a2 --- /dev/null +++ b/srac/examples/needs/communication.export.json @@ -0,0 +1,19 @@ +{ + "sourceVersion": "0.1", + "sourceDocumentUri": "https://eclipse-score.github.io/score/main/needs.json", + "documentationBaseUri": "https://eclipse-score.github.io/score/main", + "rootNeedId": "comp__com_configuration", + "includedNeedIds": [ + "feat__com_communication", + "aou_req__communication__1" + ], + "subject": { + "name": "score_communication::configuration", + "purl": "pkg:github/eclipse-score/communication@313f1178628189d267c8cd0d75971bdac04c20ee#score/mw/com/configuration", + "version": "313f1178628189d267c8cd0d75971bdac04c20ee", + "repository": "https://github.com/eclipse-score/communication", + "componentPath": "score/mw/com/configuration" + }, + "artifactUri": "https://example.org/srac/communication.srac.json", + "created": "2026-09-29T00:00:00Z" +} diff --git a/srac/examples/needs/communication.needs.json b/srac/examples/needs/communication.needs.json new file mode 100644 index 00000000000..caf9714b74e --- /dev/null +++ b/srac/examples/needs/communication.needs.json @@ -0,0 +1,52 @@ +{ + "current_version": "0.1", + "project": "S-CORE", + "project_url": "https://eclipse-score.github.io/score", + "versions": { + "0.1": { + "creator": { + "program": "sphinx_needs", + "version": "8.5.0" + }, + "needs": { + "feat__com_communication": { + "id": "feat__com_communication", + "title": "Communication Feature", + "type": "feat", + "type_name": "Feature", + "docname": "features/communication/architecture/index", + "safety": "ASIL_B", + "security": "YES", + "status": "valid", + "version": 1 + }, + "comp__com_configuration": { + "id": "comp__com_configuration", + "title": "Lola Configuration", + "type": "comp", + "type_name": "Component", + "docname": "modules/communication/configuration/docs/architecture/index", + "belongs_to": [ + "feat__com_communication[version==1]" + ], + "safety": "ASIL_B", + "security": "YES", + "status": "valid", + "version": 1 + }, + "aou_req__communication__1": { + "id": "aou_req__communication__1", + "title": "Monotonic Semi-Dynamic Memory Allocation", + "type": "aou_req", + "type_name": "Assumption of Use Requirement", + "docname": "modules/communication/docs/requirements/aou_req", + "content": "It shall be ensured that enough memory is configured for shared memory instances, in order that LoLa can perform all necessary allocations.", + "safety": "ASIL_B", + "security": "NO", + "status": "valid", + "version": 1 + } + } + } + } +} diff --git a/srac/schema/srac-report.schema.json b/srac/schema/srac-report.schema.json new file mode 100644 index 00000000000..1a1e6ab4834 --- /dev/null +++ b/srac/schema/srac-report.schema.json @@ -0,0 +1,315 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://eclipse.dev/score/schemas/srac/0.2/srac-report.schema.json", + "$comment": "SPDX-License-Identifier: Apache-2.0", + "title": "Draft S-CORE SRAC enrichment report", + "description": "Experimental report joining an SRAC assertion to an SBOM component. Safety fields are copied from the assertion and are not independently assessed by the generator.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "sourceFormat", + "assertionId", + "subject", + "safetyAssessment", + "matchedComponents", + "matchStatus", + "generator", + "integrity" + ], + "properties": { + "schemaVersion": {"const": "0.2-draft"}, + "sourceFormat": {"enum": ["SPDX", "CycloneDX"]}, + "assertionId": {"type": "string", "minLength": 1}, + "subject": {"type": "object"}, + "safetyAssessment": { + "type": "object", + "additionalProperties": false, + "required": ["source", "safetyRelevance", "classification", "assertionStatus", "reviewer"], + "properties": { + "source": {"const": "assertion"}, + "safetyRelevance": { + "description": "Functional-safety relevance only. Security relevance is represented independently by impactLevel and SBOM/VEX security data.", + "enum": ["safety-related", "not-safety-related", "undetermined"] + }, + "classification": { + "description": "Portable classification copied from the assertion. The S-CORE needs.json exporter emits only QM, ASIL-B, ASIL-D and not-assigned. When impactAnalysis.safetyIntegrityLevel is present, the validator requires its equivalent SPDX vocabulary value.", + "enum": ["QM", "ASIL-A", "ASIL-B", "ASIL-C", "ASIL-D", "not-assigned"] + }, + "assertionStatus": {"enum": ["draft", "under-review", "reviewed", "approved", "superseded", "withdrawn"]}, + "reviewer": { + "anyOf": [ + {"type": "null"}, + { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "uri": {"type": "string", "format": "uri"} + } + } + ] + } + } + }, + "impactAnalysis": { + "type": "array", + "items": {"$ref": "#/$defs/impactAnalysis"} + }, + "sourceOfTruth": {"$ref": "#/$defs/sourceOfTruth"}, + "discoveredReference": {"$ref": "#/$defs/discoveredReference"}, + "matchedComponents": { + "type": "array", + "items": { + "type": "object", + "required": ["format", "identifier", "name", "version", "purl"], + "properties": { + "format": {"type": "string", "minLength": 1}, + "identifier": {"type": ["string", "null"]}, + "name": {"type": ["string", "null"]}, + "version": {"type": ["string", "null"]}, + "purl": {"type": "string", "pattern": "^pkg:"} + } + } + }, + "matchStatus": {"enum": ["matched", "unmatched"]}, + "bindingEvidence": { + "type": "object", + "required": ["strategy", "module", "repository", "hash"], + "properties": { + "strategy": {"const": "score-known-good"}, + "module": {"type": "string", "minLength": 1}, + "repository": {"type": "string", "format": "uri"}, + "hash": {"type": "string", "pattern": "^[0-9a-fA-F]{40}$"} + } + }, + "generator": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": {"const": "srac.tools.enrich_sbom"}, + "version": {"type": "string", "minLength": 1} + } + }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "assertionSha256", "sbomSha256"], + "properties": { + "algorithm": {"const": "SHA-256"}, + "assertionSha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "sbomSha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "knownGoodSha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + } + }, + "$defs": { + "discoveredReference": { + "type": "object", + "additionalProperties": false, + "required": ["format", "componentIdentifier", "uri", "sha256"], + "properties": { + "format": {"enum": ["SPDX", "CycloneDX"]}, + "componentIdentifier": {"type": "string", "minLength": 1}, + "uri": {"type": "string", "format": "uri-reference"}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + }, + "sourceOfTruth": { + "type": "object", + "additionalProperties": false, + "required": [ + "system", + "documentUri", + "version", + "rootElement", + "rootElementType", + "rootElementStatus", + "includedElements", + "sha256" + ], + "properties": { + "system": {"const": "sphinx-needs"}, + "documentUri": {"type": "string", "format": "uri"}, + "version": {"type": "string", "minLength": 1}, + "rootElement": {"type": "string", "minLength": 1}, + "rootElementType": {"type": "string", "minLength": 1}, + "rootElementStatus": {"type": "string", "minLength": 1}, + "includedElements": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + }, + "party": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "uri": {"type": "string", "format": "uri"} + } + }, + "elementIdentifiers": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "impactAnalysis": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "trigger", + "impactAnalysisStatus", + "impactedElement", + "addedElement", + "modifiedElement", + "removedElement", + "decisions" + ], + "properties": { + "id": {"type": "string", "minLength": 1}, + "trigger": { + "type": "object", + "additionalProperties": false, + "required": ["type", "uri"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"type": "string", "minLength": 1}, + "uri": {"type": "string", "format": "uri"} + } + }, + "impactAnalysisStatus": { + "enum": ["new", "inProgress", "complete", "stopped", "duplicate", "other"] + }, + "impactLevel": { + "enum": [ + "noCriticalImpact", + "safetyImpact", + "securityImpact", + "safetyAndSecurityImpact", + "qualityImpact", + "availabilityImpact", + "customerSatisfactionImpact", + "other" + ] + }, + "safetyIntegrityLevel": { + "description": "Portable SPDX SafetyIntegrityLevelType spelling of safetyAssessment.classification; both values must be equivalent. SIL and DAL are retained for SPDX alignment but are not emitted by the S-CORE needs.json exporter.", + "enum": [ + "qm", + "asilA", + "asilB", + "asilC", + "asilD", + "sil1", + "sil2", + "sil3", + "sil4", + "dalA", + "dalB", + "dalC", + "dalD", + "dalE", + "other", + "noAssertion" + ] + }, + "impactAnalysisProcess": { + "type": "object", + "additionalProperties": false, + "required": ["uri"], + "properties": {"uri": {"type": "string", "format": "uri"}} + }, + "rationale": {"type": "string", "minLength": 1}, + "impactedElement": {"$ref": "#/$defs/elementIdentifiers"}, + "addedElement": {"$ref": "#/$defs/elementIdentifiers"}, + "modifiedElement": {"$ref": "#/$defs/elementIdentifiers"}, + "removedElement": {"$ref": "#/$defs/elementIdentifiers"}, + "decisions": { + "type": "array", + "items": {"$ref": "#/$defs/decision"} + }, + "requirementVerification": { + "type": "array", + "items": {"$ref": "#/$defs/requirementVerification"} + }, + "bundle": {"$ref": "#/$defs/bundle"} + } + }, + "decision": { + "type": "object", + "additionalProperties": false, + "required": ["decisionType", "decisionStatus", "appliesTo", "originatedBy", "rationale"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "decisionType": { + "enum": [ + "approve", + "approveConditionally", + "reject", + "defer", + "delegate", + "requestChange", + "requestInformation", + "noAction", + "close", + "duplicate", + "other" + ] + }, + "decisionStatus": { + "enum": ["proposed", "requested", "inProgress", "recorded", "superseded", "withdrawn", "enteredInError", "other"] + }, + "appliesTo": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "originatedBy": { + "anyOf": [{"$ref": "#/$defs/party"}, {"type": "null"}] + }, + "rationale": {"type": "string", "minLength": 1} + } + }, + "requirementVerification": { + "type": "object", + "additionalProperties": false, + "required": ["id", "verifies", "evidence"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "verifies": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "evidence": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "rationale": {"type": "string", "minLength": 1} + } + }, + "bundle": { + "type": "object", + "additionalProperties": false, + "required": ["id", "rootElement"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "rootElement": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + } + } + } + } +} diff --git a/srac/schema/srac.schema.json b/srac/schema/srac.schema.json new file mode 100644 index 00000000000..fab51c8c361 --- /dev/null +++ b/srac/schema/srac.schema.json @@ -0,0 +1,277 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://eclipse.dev/score/schemas/srac/0.2/srac.schema.json", + "$comment": "SPDX-License-Identifier: Apache-2.0", + "title": "Draft S-CORE Safety Relevance Assertion Capability profile", + "description": "Experimental profile for the SRAC sidecar proof of concept. This is not an approved S-CORE specification.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "assertionId", "subject", "safetyRelevance", "rationale", "evidence", "assertion"], + "properties": { + "schemaVersion": {"const": "0.2-draft"}, + "assertionId": {"type": "string", "minLength": 1}, + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["name", "purl", "version"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "purl": {"type": "string", "pattern": "^pkg:"}, + "version": {"type": "string", "minLength": 1}, + "repository": {"type": "string", "format": "uri"}, + "componentPath": {"type": "string", "minLength": 1} + } + }, + "safetyRelevance": { + "type": "object", + "additionalProperties": false, + "required": ["status", "classification"], + "properties": { + "status": { + "description": "Functional-safety relevance only. Security relevance is represented independently by impactLevel and SBOM/VEX security data.", + "enum": ["safety-related", "not-safety-related", "undetermined"] + }, + "classification": { + "description": "Portable ISO 26262/SPDX classification vocabulary. The S-CORE needs.json exporter uses only QM, ASIL-B, ASIL-D and not-assigned. When impactAnalysis.safetyIntegrityLevel is present, the validator requires its equivalent SPDX vocabulary value.", + "enum": ["QM", "ASIL-A", "ASIL-B", "ASIL-C", "ASIL-D", "not-assigned"] + } + } + }, + "rationale": {"type": "string", "minLength": 1}, + "requirements": {"type": "array", "items": {"$ref": "#/$defs/reference"}, "uniqueItems": true}, + "evidence": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/reference"} + }, + "impactAnalysis": { + "type": "array", + "items": {"$ref": "#/$defs/impactAnalysis"} + }, + "sourceOfTruth": {"$ref": "#/$defs/sourceOfTruth"}, + "assertion": { + "type": "object", + "additionalProperties": false, + "required": ["status", "author", "created"], + "properties": { + "status": {"enum": ["draft", "under-review", "reviewed", "approved", "superseded", "withdrawn"]}, + "author": {"$ref": "#/$defs/party"}, + "reviewer": {"anyOf": [{"$ref": "#/$defs/party"}, {"type": "null"}]}, + "created": {"type": "string", "format": "date-time"}, + "updated": {"type": "string", "format": "date-time"} + } + } + }, + "$defs": { + "sourceOfTruth": { + "type": "object", + "additionalProperties": false, + "required": [ + "system", + "documentUri", + "version", + "rootElement", + "rootElementType", + "rootElementStatus", + "includedElements", + "sha256" + ], + "properties": { + "system": {"const": "sphinx-needs"}, + "documentUri": {"type": "string", "format": "uri"}, + "version": {"type": "string", "minLength": 1}, + "rootElement": {"type": "string", "minLength": 1}, + "rootElementType": {"type": "string", "minLength": 1}, + "rootElementStatus": {"type": "string", "minLength": 1}, + "includedElements": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + }, + "party": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "uri": {"type": "string", "format": "uri"} + } + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "uri"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"type": "string", "minLength": 1}, + "uri": {"type": "string", "format": "uri"}, + "sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"} + } + }, + "impactAnalysis": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "trigger", + "impactAnalysisStatus", + "impactedElement", + "addedElement", + "modifiedElement", + "removedElement", + "decisions" + ], + "properties": { + "id": {"type": "string", "minLength": 1}, + "trigger": {"$ref": "#/$defs/analysisTrigger"}, + "impactAnalysisStatus": { + "enum": ["new", "inProgress", "complete", "stopped", "duplicate", "other"] + }, + "impactLevel": { + "enum": [ + "noCriticalImpact", + "safetyImpact", + "securityImpact", + "safetyAndSecurityImpact", + "qualityImpact", + "availabilityImpact", + "customerSatisfactionImpact", + "other" + ] + }, + "safetyIntegrityLevel": { + "description": "Portable SPDX SafetyIntegrityLevelType spelling of safetyRelevance.classification; both values must be equivalent. SIL and DAL are retained for SPDX alignment but are not emitted by the S-CORE needs.json exporter.", + "enum": [ + "qm", + "asilA", + "asilB", + "asilC", + "asilD", + "sil1", + "sil2", + "sil3", + "sil4", + "dalA", + "dalB", + "dalC", + "dalD", + "dalE", + "other", + "noAssertion" + ] + }, + "impactAnalysisProcess": {"$ref": "#/$defs/uriReference"}, + "rationale": {"type": "string", "minLength": 1}, + "impactedElement": {"$ref": "#/$defs/elementIdentifiers"}, + "addedElement": {"$ref": "#/$defs/elementIdentifiers"}, + "modifiedElement": {"$ref": "#/$defs/elementIdentifiers"}, + "removedElement": {"$ref": "#/$defs/elementIdentifiers"}, + "decisions": { + "type": "array", + "items": {"$ref": "#/$defs/decision"} + }, + "requirementVerification": { + "type": "array", + "items": {"$ref": "#/$defs/requirementVerification"} + }, + "bundle": {"$ref": "#/$defs/bundle"} + } + }, + "analysisTrigger": { + "type": "object", + "additionalProperties": false, + "required": ["type", "uri"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"type": "string", "minLength": 1}, + "uri": {"type": "string", "format": "uri"} + } + }, + "uriReference": { + "type": "object", + "additionalProperties": false, + "required": ["uri"], + "properties": { + "uri": {"type": "string", "format": "uri"} + } + }, + "elementIdentifiers": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "decision": { + "type": "object", + "additionalProperties": false, + "required": ["decisionType", "decisionStatus", "appliesTo", "originatedBy", "rationale"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "decisionType": { + "enum": [ + "approve", + "approveConditionally", + "reject", + "defer", + "delegate", + "requestChange", + "requestInformation", + "noAction", + "close", + "duplicate", + "other" + ] + }, + "decisionStatus": { + "enum": ["proposed", "requested", "inProgress", "recorded", "superseded", "withdrawn", "enteredInError", "other"] + }, + "appliesTo": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "originatedBy": { + "anyOf": [{"$ref": "#/$defs/party"}, {"type": "null"}] + }, + "rationale": {"type": "string", "minLength": 1} + } + }, + "requirementVerification": { + "type": "object", + "additionalProperties": false, + "required": ["id", "verifies", "evidence"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "verifies": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "evidence": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "rationale": {"type": "string", "minLength": 1} + } + }, + "bundle": { + "type": "object", + "additionalProperties": false, + "required": ["id", "rootElement"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "rootElement": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + } + } + } + } +} diff --git a/srac/tests/test_needs_export.py b/srac/tests/test_needs_export.py new file mode 100644 index 00000000000..ce580bc8b1e --- /dev/null +++ b/srac/tests/test_needs_export.py @@ -0,0 +1,168 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Tests for the read-only Sphinx-needs projection.""" + +from __future__ import annotations + +import hashlib +import json +from copy import deepcopy +from pathlib import Path + +import pytest + +from srac.tools.export_needs import build_assertion, build_reference_manifest +from srac.tools.profile import build_enrichment_report, validate_assertion, validate_report + +SRAC_ROOT = Path(__file__).parents[1] +NEEDS_PATH = SRAC_ROOT / "examples" / "needs" / "communication.needs.json" +CONFIG_PATH = SRAC_ROOT / "examples" / "needs" / "communication.export.json" +GENERATED_PATH = SRAC_ROOT / "examples" / "generated" / "communication.srac.json" +REFERENCE_PATH = SRAC_ROOT / "examples" / "generated" / "communication.sbom-reference.json" + + +def _read(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_checked_in_needs_projection_is_reproducible() -> None: + generated = build_assertion(_read(NEEDS_PATH), _read(CONFIG_PATH), _sha256(NEEDS_PATH)) + + assert generated == _read(GENERATED_PATH) + assert validate_assertion(generated) == [] + assert generated["safetyRelevance"] == { + "status": "safety-related", + "classification": "ASIL-B", + } + assert generated["sourceOfTruth"] == { + "system": "sphinx-needs", + "documentUri": "https://eclipse-score.github.io/score/main/needs.json", + "version": "0.1", + "rootElement": "comp__com_configuration", + "rootElementType": "comp", + "rootElementStatus": "valid", + "includedElements": ["feat__com_communication", "aou_req__communication__1"], + "sha256": _sha256(NEEDS_PATH), + } + assert [item["id"] for item in generated["requirements"]] == ["aou_req__communication__1"] + assert [item["id"] for item in generated["evidence"]] == [ + "comp__com_configuration", + "feat__com_communication", + ] + assert generated["assertion"]["status"] == "draft" + assert generated["assertion"]["reviewer"] is None + + +def test_needs_projection_fails_closed_when_safety_is_missing() -> None: + needs = _read(NEEDS_PATH) + del needs["versions"]["0.1"]["needs"]["comp__com_configuration"]["safety"] + + generated = build_assertion(needs, _read(CONFIG_PATH), "a" * 64) + + assert generated["safetyRelevance"] == { + "status": "undetermined", + "classification": "not-assigned", + } + + +def test_needs_projection_rejects_conflicting_source_safety_fields() -> None: + needs = _read(NEEDS_PATH) + needs["versions"]["0.1"]["needs"]["comp__com_configuration"]["safety_relevant"] = "NO" + + with pytest.raises(ValueError, match="conflicting safety and safety_relevant"): + build_assertion(needs, _read(CONFIG_PATH), "a" * 64) + + +@pytest.mark.parametrize( + "source_classification", + ["ASIL_A", "ASIL_C", "SIL_3", "DAL_A", "SECURITY_RELATED"], +) +def test_needs_projection_rejects_values_outside_score_safety_vocabulary( + source_classification: str, +) -> None: + needs = _read(NEEDS_PATH) + needs["versions"]["0.1"]["needs"]["comp__com_configuration"]["safety"] = source_classification + + with pytest.raises(ValueError, match="unsupported S-CORE safety classification"): + build_assertion(needs, _read(CONFIG_PATH), "a" * 64) + + +@pytest.mark.parametrize( + ("source_classification", "expected"), + [ + ("QM", {"status": "not-safety-related", "classification": "QM"}), + ("ASIL_B", {"status": "safety-related", "classification": "ASIL-B"}), + ("ASIL_D", {"status": "safety-related", "classification": "ASIL-D"}), + ], +) +def test_needs_projection_accepts_only_score_safety_classifications( + source_classification: str, + expected: dict[str, str], +) -> None: + needs = _read(NEEDS_PATH) + needs["versions"]["0.1"]["needs"]["comp__com_configuration"]["safety"] = source_classification + + generated = build_assertion(needs, _read(CONFIG_PATH), "a" * 64) + + assert generated["safetyRelevance"] == expected + + +def test_reference_manifest_is_reproducible_and_integrity_protected() -> None: + expected = _read(REFERENCE_PATH) + + assert build_reference_manifest(_read(CONFIG_PATH), _sha256(GENERATED_PATH)) == expected + assert expected["artifact"]["sha256"] == _sha256(GENERATED_PATH) + assert expected["cycloneDx16ExternalReference"]["hashes"][0]["content"] == _sha256(GENERATED_PATH) + assert expected["spdx23PackageExternalRef"]["referenceCategory"] == "OTHER" + assert expected["spdx23PackageExternalRef"]["comment"] == (f"SRAC sidecar; SHA-256: {_sha256(GENERATED_PATH)}") + assert expected["cycloneDx16ExternalReference"]["comment"] == "SRAC sidecar" + + +def test_enrichment_report_preserves_needs_source_of_truth() -> None: + assertion = _read(GENERATED_PATH) + sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [ + { + "bom-ref": "score-communication", + "name": "score_communication", + "version": assertion["subject"]["version"], + "purl": "pkg:github/eclipse-score/communication@" + assertion["subject"]["version"], + } + ], + } + report = build_enrichment_report( + assertion, + sbom, + integrity={ + "algorithm": "SHA-256", + "assertionSha256": "a" * 64, + "sbomSha256": "b" * 64, + }, + ) + + assert report["matchStatus"] == "matched" + assert report["sourceOfTruth"] == assertion["sourceOfTruth"] + assert validate_report(report) == [] + + +def test_source_of_truth_digest_is_validated() -> None: + assertion = deepcopy(_read(GENERATED_PATH)) + assertion["sourceOfTruth"]["sha256"] = "not-a-digest" + + assert "sourceOfTruth.sha256 must be a lowercase SHA-256 digest" in validate_assertion(assertion) diff --git a/srac/tools/__init__.py b/srac/tools/__init__.py new file mode 100644 index 00000000000..e84e4e29bf6 --- /dev/null +++ b/srac/tools/__init__.py @@ -0,0 +1,13 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""SRAC sidecar validation and SBOM matching tools.""" diff --git a/srac/tools/export_needs.py b/srac/tools/export_needs.py new file mode 100644 index 00000000000..be406e05d32 --- /dev/null +++ b/srac/tools/export_needs.py @@ -0,0 +1,240 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Export a read-only SRAC projection from an authoritative Sphinx-needs file.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from srac.tools.profile import validate_assertion + +SCORE_SAFETY_CLASSIFICATIONS = { + "QM": ("not-safety-related", "QM"), + "ASIL_B": ("safety-related", "ASIL-B"), + "ASIL_D": ("safety-related", "ASIL-D"), +} +SAFETY_RELEVANT_TRUE = {"TRUE", "YES", "Y", "1", "SAFETY_RELATED"} +SAFETY_RELEVANT_FALSE = {"FALSE", "NO", "N", "0", "NOT_SAFETY_RELATED"} + + +def _read_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise ValueError(f"{path} must contain a JSON object") + return value + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _normalize_token(value: object) -> str: + return str(value or "").strip().upper().replace("-", "_").replace(" ", "_") + + +def _safety_relevance(need: Mapping[str, Any]) -> dict[str, str]: + safety = _normalize_token(need.get("safety")) + if safety and safety not in SCORE_SAFETY_CLASSIFICATIONS: + supported = ", ".join(sorted(SCORE_SAFETY_CLASSIFICATIONS)) + raise ValueError( + f"Need {need.get('id')!r} has unsupported S-CORE safety classification {safety!r}; " + f"expected one of {supported}" + ) + mapped = SCORE_SAFETY_CLASSIFICATIONS.get(safety, ("undetermined", "not-assigned")) + explicit = _normalize_token(need.get("safety_relevant")) + if explicit in SAFETY_RELEVANT_TRUE: + if mapped[0] == "not-safety-related": + raise ValueError(f"Need {need.get('id')!r} has conflicting safety and safety_relevant fields") + return {"status": "safety-related", "classification": mapped[1]} + if explicit in SAFETY_RELEVANT_FALSE: + if mapped[0] == "safety-related": + raise ValueError(f"Need {need.get('id')!r} has conflicting safety and safety_relevant fields") + return {"status": "not-safety-related", "classification": mapped[1]} + return {"status": mapped[0], "classification": mapped[1]} + + +def _need_uri(documentation_base_uri: str, need: Mapping[str, Any]) -> str: + external_url = need.get("external_url") + if isinstance(external_url, str) and external_url.strip(): + return external_url + docname = need.get("docname") + need_id = need.get("id") + if not isinstance(docname, str) or not docname or not isinstance(need_id, str) or not need_id: + raise ValueError(f"Need {need_id!r} has neither external_url nor a usable docname") + return f"{documentation_base_uri.rstrip('/')}/{docname}.html#{need_id}" + + +def _reference(documentation_base_uri: str, need: Mapping[str, Any]) -> dict[str, str]: + need_id = need.get("id") + need_type = need.get("type") + if not isinstance(need_id, str) or not need_id or not isinstance(need_type, str) or not need_type: + raise ValueError("Every exported need must have non-empty id and type fields") + return { + "id": need_id, + "type": f"sphinx-needs:{need_type}", + "uri": _need_uri(documentation_base_uri, need), + } + + +def _needs_for_version(needs_document: Mapping[str, Any], version: str) -> Mapping[str, Any]: + versions = needs_document.get("versions") + if not isinstance(versions, Mapping) or not isinstance(versions.get(version), Mapping): + raise ValueError(f"Sphinx-needs version {version!r} is not present") + needs = versions[version].get("needs") + if not isinstance(needs, Mapping): + raise ValueError(f"Sphinx-needs version {version!r} has no needs object") + return needs + + +def build_assertion(needs_document: Mapping[str, Any], config: Mapping[str, Any], source_sha256: str) -> dict[str, Any]: + """Build a deterministic projection without creating a new safety decision.""" + + version = str(config.get("sourceVersion") or needs_document.get("current_version") or "") + root_id = config.get("rootNeedId") + included_ids = config.get("includedNeedIds", []) + subject = config.get("subject") + if not version or not isinstance(root_id, str) or not root_id: + raise ValueError("sourceVersion/current_version and rootNeedId are required") + if not isinstance(included_ids, list) or not all(isinstance(item, str) and item for item in included_ids): + raise ValueError("includedNeedIds must be an array of non-empty strings") + if not isinstance(subject, Mapping): + raise ValueError("subject must be an object") + for key in ("name", "purl", "version"): + if not isinstance(subject.get(key), str) or not subject[key]: + raise ValueError(f"subject.{key} must be a non-empty string") + for key in ("sourceDocumentUri", "documentationBaseUri", "artifactUri", "created"): + if not isinstance(config.get(key), str) or not config[key]: + raise ValueError(f"{key} must be a non-empty string") + + needs = _needs_for_version(needs_document, version) + selected_ids = [root_id, *included_ids] + missing = [need_id for need_id in selected_ids if not isinstance(needs.get(need_id), Mapping)] + if missing: + raise ValueError(f"Needs not found in version {version!r}: {', '.join(missing)}") + selected = [needs[need_id] for need_id in selected_ids] + root = selected[0] + documentation_base_uri = str(config["documentationBaseUri"]) + requirements = [ + _reference(documentation_base_uri, need) for need in selected[1:] if str(need.get("type", "")).endswith("_req") + ] + evidence = [ + _reference(documentation_base_uri, need) + for need in selected + if need is root or not str(need.get("type", "")).endswith("_req") + ] + source_status = str(root.get("status") or "") + assertion = { + "schemaVersion": "0.2-draft", + "assertionId": f"srac-needs-export-{root_id}-{version}", + "subject": dict(subject), + "safetyRelevance": _safety_relevance(root), + "rationale": ( + f"Read-only projection of S-CORE need {root_id!r} at needs.json version {version!r}. " + f"The source lifecycle status is {source_status!r}. The export adds no safety decision; " + "the referenced Sphinx-needs model remains authoritative." + ), + "requirements": requirements, + "evidence": evidence, + "sourceOfTruth": { + "system": "sphinx-needs", + "documentUri": config["sourceDocumentUri"], + "version": version, + "rootElement": root_id, + "rootElementType": str(root.get("type") or ""), + "rootElementStatus": source_status, + "includedElements": included_ids, + "sha256": source_sha256, + }, + "assertion": { + "status": "draft", + "author": { + "name": "S-CORE needs.json export", + "uri": str(config["sourceDocumentUri"]), + }, + "reviewer": None, + "created": config["created"], + }, + } + errors = validate_assertion(assertion) + if errors: + raise ValueError("Generated an invalid SRAC assertion: " + "; ".join(errors)) + return assertion + + +def build_reference_manifest(config: Mapping[str, Any], assertion_sha256: str) -> dict[str, Any]: + """Create illustrative SPDX 2.3 and CycloneDX 1.6 carrier fragments.""" + + artifact_uri = str(config["artifactUri"]) + return { + "artifact": { + "uri": artifact_uri, + "sha256": assertion_sha256, + }, + "spdx23PackageExternalRef": { + "referenceCategory": "OTHER", + "referenceType": "srac", + "referenceLocator": artifact_uri, + "comment": f"SRAC sidecar; SHA-256: {assertion_sha256}", + }, + "cycloneDx16ExternalReference": { + "type": "other", + "url": artifact_uri, + "comment": "SRAC sidecar", + "hashes": [ + { + "alg": "SHA-256", + "content": assertion_sha256, + } + ], + }, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--needs", required=True, type=Path, help="authoritative Sphinx-needs needs.json") + parser.add_argument("--config", required=True, type=Path, help="transport-only export configuration") + parser.add_argument("--output", required=True, type=Path, help="portable SRAC projection to write") + parser.add_argument("--reference-output", type=Path, help="optional SPDX/CycloneDX reference fragments") + return parser.parse_args() + + +def _write_json(path: Path, value: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, indent=2) + stream.write("\n") + + +def main() -> int: + args = parse_args() + config = _read_json(args.config) + assertion = build_assertion(_read_json(args.needs), config, _sha256(args.needs)) + _write_json(args.output, assertion) + if args.reference_output: + _write_json(args.reference_output, build_reference_manifest(config, _sha256(args.output))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/srac/tools/export_needs_cli.py b/srac/tools/export_needs_cli.py new file mode 100644 index 00000000000..5e5aac8607b --- /dev/null +++ b/srac/tools/export_needs_cli.py @@ -0,0 +1,20 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Command-line entry point for the Sphinx-needs SRAC exporter.""" + +from __future__ import annotations + +from srac.tools.export_needs import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/srac/tools/profile.py b/srac/tools/profile.py new file mode 100644 index 00000000000..2b80c534593 --- /dev/null +++ b/srac/tools/profile.py @@ -0,0 +1,655 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Validation and SBOM matching for the experimental SRAC sidecar profile.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Mapping +from copy import deepcopy +from typing import Any +from urllib.parse import urlparse + +SCHEMA_VERSION = "0.2-draft" +TOOL_VERSION = "0.2.0-draft" +FUNCTIONAL_SAFETY_RELEVANCE_VALUES = {"safety-related", "not-safety-related", "undetermined"} +PORTABLE_FUNCTIONAL_SAFETY_CLASSIFICATION_VALUES = { + "QM", + "ASIL-A", + "ASIL-B", + "ASIL-C", + "ASIL-D", + "not-assigned", +} +ASSERTION_STATUS_VALUES = {"draft", "under-review", "reviewed", "approved", "superseded", "withdrawn"} +IMPACT_ANALYSIS_STATUS_VALUES = {"new", "inProgress", "complete", "stopped", "duplicate", "other"} +IMPACT_LEVEL_VALUES = { + "noCriticalImpact", + "safetyImpact", + "securityImpact", + "safetyAndSecurityImpact", + "qualityImpact", + "availabilityImpact", + "customerSatisfactionImpact", + "other", +} +DECISION_TYPE_VALUES = { + "approve", + "approveConditionally", + "reject", + "defer", + "delegate", + "requestChange", + "requestInformation", + "noAction", + "close", + "duplicate", + "other", +} +DECISION_STATUS_VALUES = { + "proposed", + "requested", + "inProgress", + "recorded", + "superseded", + "withdrawn", + "enteredInError", + "other", +} +SPDX_SAFETY_INTEGRITY_LEVEL_VALUES = { + "qm", + "asilA", + "asilB", + "asilC", + "asilD", + "sil1", + "sil2", + "sil3", + "sil4", + "dalA", + "dalB", + "dalC", + "dalD", + "dalE", + "other", + "noAssertion", +} +PORTABLE_CLASSIFICATION_TO_SPDX_SAFETY_INTEGRITY_LEVEL = { + "QM": "qm", + "ASIL-A": "asilA", + "ASIL-B": "asilB", + "ASIL-C": "asilC", + "ASIL-D": "asilD", + "not-assigned": "noAssertion", +} +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") + + +def _is_non_empty_string(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _require_mapping(document: Mapping[str, Any], key: str, errors: list[str]) -> Mapping[str, Any]: + value = document.get(key) + if not isinstance(value, Mapping): + errors.append(f"{key} must be an object") + return {} + return value + + +def _validate_references(value: object, path: str, errors: list[str], *, require_one: bool) -> None: + if not isinstance(value, list): + errors.append(f"{path} must be an array") + return + if require_one and not value: + errors.append(f"{path} must contain at least one item") + for index, item in enumerate(value): + if not isinstance(item, Mapping): + errors.append(f"{path}[{index}] must be an object") + continue + for key in ("id", "type", "uri"): + if not _is_non_empty_string(item.get(key)): + errors.append(f"{path}[{index}].{key} must be a non-empty string") + + +def _validate_identifier_list(value: object, path: str, errors: list[str], *, require_one: bool = False) -> None: + if not isinstance(value, list): + errors.append(f"{path} must be an array") + return + if require_one and not value: + errors.append(f"{path} must contain at least one item") + for index, item in enumerate(value): + if not _is_non_empty_string(item): + errors.append(f"{path}[{index}] must be a non-empty string") + + +def _validate_resolved_identifiers( + value: object, path: str, errors: list[str], resolvable_ids: set[str] | None +) -> None: + if resolvable_ids is None or not isinstance(value, list): + return + for index, identifier in enumerate(value): + if _is_non_empty_string(identifier) and identifier not in resolvable_ids: + errors.append(f"{path}[{index}] references {identifier!r}, which does not resolve to an assertion element") + + +def _validate_source_of_truth(value: object, path: str, errors: list[str]) -> None: + if not isinstance(value, Mapping): + errors.append(f"{path} must be an object") + return + if value.get("system") != "sphinx-needs": + errors.append(f"{path}.system must be 'sphinx-needs'") + for key in ("documentUri", "version", "rootElement", "rootElementType", "rootElementStatus"): + if not _is_non_empty_string(value.get(key)): + errors.append(f"{path}.{key} must be a non-empty string") + _validate_identifier_list(value.get("includedElements"), f"{path}.includedElements", errors) + digest = value.get("sha256") + if not isinstance(digest, str) or SHA256_PATTERN.fullmatch(digest) is None: + errors.append(f"{path}.sha256 must be a lowercase SHA-256 digest") + + +def _validate_impact_analysis( + value: object, + path: str, + errors: list[str], + *, + classification: object = None, + resolvable_ids: set[str] | None = None, +) -> None: + if not isinstance(value, list): + errors.append(f"{path} must be an array") + return + for index, item in enumerate(value): + item_path = f"{path}[{index}]" + if not isinstance(item, Mapping): + errors.append(f"{item_path} must be an object") + continue + if not _is_non_empty_string(item.get("id")): + errors.append(f"{item_path}.id must be a non-empty string") + + trigger = item.get("trigger") + if not isinstance(trigger, Mapping): + errors.append(f"{item_path}.trigger must be an object") + else: + for key in ("type", "uri"): + if not _is_non_empty_string(trigger.get(key)): + errors.append(f"{item_path}.trigger.{key} must be a non-empty string") + + if item.get("impactAnalysisStatus") not in IMPACT_ANALYSIS_STATUS_VALUES: + errors.append(f"{item_path}.impactAnalysisStatus must be one of {sorted(IMPACT_ANALYSIS_STATUS_VALUES)}") + impact_level = item.get("impactLevel") + if impact_level is not None and impact_level not in IMPACT_LEVEL_VALUES: + errors.append(f"{item_path}.impactLevel must be one of {sorted(IMPACT_LEVEL_VALUES)}") + safety_integrity_level = item.get("safetyIntegrityLevel") + if safety_integrity_level is not None and safety_integrity_level not in SPDX_SAFETY_INTEGRITY_LEVEL_VALUES: + errors.append( + f"{item_path}.safetyIntegrityLevel must be one of {sorted(SPDX_SAFETY_INTEGRITY_LEVEL_VALUES)}" + ) + expected_safety_integrity_level = PORTABLE_CLASSIFICATION_TO_SPDX_SAFETY_INTEGRITY_LEVEL.get(classification) + if ( + safety_integrity_level is not None + and expected_safety_integrity_level is not None + and safety_integrity_level != expected_safety_integrity_level + ): + errors.append( + f"{item_path}.safetyIntegrityLevel must be {expected_safety_integrity_level!r} " + f"when safety relevance classification is {classification!r}" + ) + + process = item.get("impactAnalysisProcess") + if process is not None and (not isinstance(process, Mapping) or not _is_non_empty_string(process.get("uri"))): + errors.append(f"{item_path}.impactAnalysisProcess.uri must be a non-empty string") + + for key in ("impactedElement", "addedElement", "modifiedElement", "removedElement"): + _validate_identifier_list(item.get(key), f"{item_path}.{key}", errors) + _validate_resolved_identifiers(item.get(key), f"{item_path}.{key}", errors, resolvable_ids) + + decisions = item.get("decisions") + if not isinstance(decisions, list): + errors.append(f"{item_path}.decisions must be an array") + continue + for decision_index, decision in enumerate(decisions): + decision_path = f"{item_path}.decisions[{decision_index}]" + if not isinstance(decision, Mapping): + errors.append(f"{decision_path} must be an object") + continue + if decision.get("decisionType") not in DECISION_TYPE_VALUES: + errors.append(f"{decision_path}.decisionType must be one of {sorted(DECISION_TYPE_VALUES)}") + if decision.get("decisionStatus") not in DECISION_STATUS_VALUES: + errors.append(f"{decision_path}.decisionStatus must be one of {sorted(DECISION_STATUS_VALUES)}") + _validate_identifier_list(decision.get("appliesTo"), f"{decision_path}.appliesTo", errors, require_one=True) + _validate_resolved_identifiers( + decision.get("appliesTo"), f"{decision_path}.appliesTo", errors, resolvable_ids + ) + originated_by = decision.get("originatedBy") + if originated_by is not None and ( + not isinstance(originated_by, Mapping) or not _is_non_empty_string(originated_by.get("name")) + ): + errors.append(f"{decision_path}.originatedBy.name must be a non-empty string or null") + if not _is_non_empty_string(decision.get("rationale")): + errors.append(f"{decision_path}.rationale must be a non-empty string") + + verification = item.get("requirementVerification") + if verification is not None: + if not isinstance(verification, list): + errors.append(f"{item_path}.requirementVerification must be an array") + else: + for verification_index, verification_item in enumerate(verification): + verification_path = f"{item_path}.requirementVerification[{verification_index}]" + if not isinstance(verification_item, Mapping): + errors.append(f"{verification_path} must be an object") + continue + if not _is_non_empty_string(verification_item.get("id")): + errors.append(f"{verification_path}.id must be a non-empty string") + _validate_identifier_list( + verification_item.get("verifies"), + f"{verification_path}.verifies", + errors, + require_one=True, + ) + _validate_resolved_identifiers( + verification_item.get("verifies"), + f"{verification_path}.verifies", + errors, + resolvable_ids, + ) + _validate_identifier_list( + verification_item.get("evidence"), f"{verification_path}.evidence", errors + ) + _validate_resolved_identifiers( + verification_item.get("evidence"), + f"{verification_path}.evidence", + errors, + resolvable_ids, + ) + + bundle = item.get("bundle") + if bundle is not None: + if not isinstance(bundle, Mapping): + errors.append(f"{item_path}.bundle must be an object") + else: + if not _is_non_empty_string(bundle.get("id")): + errors.append(f"{item_path}.bundle.id must be a non-empty string") + _validate_identifier_list( + bundle.get("rootElement"), f"{item_path}.bundle.rootElement", errors, require_one=True + ) + _validate_resolved_identifiers( + bundle.get("rootElement"), f"{item_path}.bundle.rootElement", errors, resolvable_ids + ) + + +def _assertion_element_ids(document: Mapping[str, Any], errors: list[str]) -> set[str]: + identifiers: list[str] = [] + for key in ("requirements", "evidence"): + value = document.get(key) + if isinstance(value, list): + identifiers.extend( + str(item["id"]) for item in value if isinstance(item, Mapping) and _is_non_empty_string(item.get("id")) + ) + impact_analysis = document.get("impactAnalysis") + if isinstance(impact_analysis, list): + for analysis in impact_analysis: + if not isinstance(analysis, Mapping): + continue + if _is_non_empty_string(analysis.get("id")): + identifiers.append(str(analysis["id"])) + for key in ("decisions", "requirementVerification"): + value = analysis.get(key) + if isinstance(value, list): + identifiers.extend( + str(item["id"]) + for item in value + if isinstance(item, Mapping) and _is_non_empty_string(item.get("id")) + ) + duplicates = sorted({identifier for identifier in identifiers if identifiers.count(identifier) > 1}) + for identifier in duplicates: + errors.append(f"assertion element id {identifier!r} is defined more than once") + return set(identifiers) + + +def validate_assertion(document: Mapping[str, Any]) -> list[str]: + """Validate the core constraints also expressed by ``srac.schema.json``. + + The PoC deliberately has no runtime dependency on a particular JSON Schema + implementation. Consumers should use the published schema with their + standard validator; this function keeps the Bazel pilot self-contained. + """ + + errors: list[str] = [] + if document.get("schemaVersion") != SCHEMA_VERSION: + errors.append(f"schemaVersion must be {SCHEMA_VERSION!r}") + if not _is_non_empty_string(document.get("assertionId")): + errors.append("assertionId must be a non-empty string") + + subject = _require_mapping(document, "subject", errors) + for key in ("name", "purl", "version"): + if not _is_non_empty_string(subject.get(key)): + errors.append(f"subject.{key} must be a non-empty string") + if _is_non_empty_string(subject.get("purl")) and not str(subject["purl"]).startswith("pkg:"): + errors.append("subject.purl must start with 'pkg:'") + + relevance = _require_mapping(document, "safetyRelevance", errors) + if relevance.get("status") not in FUNCTIONAL_SAFETY_RELEVANCE_VALUES: + errors.append(f"safetyRelevance.status must be one of {sorted(FUNCTIONAL_SAFETY_RELEVANCE_VALUES)}") + if relevance.get("classification") not in PORTABLE_FUNCTIONAL_SAFETY_CLASSIFICATION_VALUES: + errors.append( + f"safetyRelevance.classification must be one of {sorted(PORTABLE_FUNCTIONAL_SAFETY_CLASSIFICATION_VALUES)}" + ) + + if not _is_non_empty_string(document.get("rationale")): + errors.append("rationale must be a non-empty string") + if "requirements" in document: + _validate_references(document["requirements"], "requirements", errors, require_one=False) + _validate_references(document.get("evidence"), "evidence", errors, require_one=True) + if "impactAnalysis" in document: + _validate_impact_analysis( + document["impactAnalysis"], + "impactAnalysis", + errors, + classification=relevance.get("classification"), + resolvable_ids=_assertion_element_ids(document, errors), + ) + + source_of_truth = document.get("sourceOfTruth") + if source_of_truth is not None: + _validate_source_of_truth(source_of_truth, "sourceOfTruth", errors) + + assertion = _require_mapping(document, "assertion", errors) + if assertion.get("status") not in ASSERTION_STATUS_VALUES: + errors.append(f"assertion.status must be one of {sorted(ASSERTION_STATUS_VALUES)}") + author = assertion.get("author") + if not isinstance(author, Mapping) or not _is_non_empty_string(author.get("name")): + errors.append("assertion.author.name must be a non-empty string") + if not _is_non_empty_string(assertion.get("created")): + errors.append("assertion.created must be a non-empty date-time string") + return errors + + +def validate_report(document: Mapping[str, Any]) -> list[str]: + """Validate the core constraints expressed by ``srac-report.schema.json``.""" + + errors: list[str] = [] + if document.get("schemaVersion") != SCHEMA_VERSION: + errors.append(f"schemaVersion must be {SCHEMA_VERSION!r}") + if document.get("sourceFormat") not in {"SPDX", "CycloneDX"}: + errors.append("sourceFormat must be 'SPDX' or 'CycloneDX'") + if document.get("matchStatus") not in {"matched", "unmatched"}: + errors.append("matchStatus must be 'matched' or 'unmatched'") + if not isinstance(document.get("matchedComponents"), list): + errors.append("matchedComponents must be an array") + + safety = _require_mapping(document, "safetyAssessment", errors) + if safety.get("source") != "assertion": + errors.append("safetyAssessment.source must be 'assertion'") + if safety.get("safetyRelevance") not in FUNCTIONAL_SAFETY_RELEVANCE_VALUES: + errors.append(f"safetyAssessment.safetyRelevance must be one of {sorted(FUNCTIONAL_SAFETY_RELEVANCE_VALUES)}") + if safety.get("classification") not in PORTABLE_FUNCTIONAL_SAFETY_CLASSIFICATION_VALUES: + errors.append( + f"safetyAssessment.classification must be one of {sorted(PORTABLE_FUNCTIONAL_SAFETY_CLASSIFICATION_VALUES)}" + ) + if safety.get("assertionStatus") not in ASSERTION_STATUS_VALUES: + errors.append(f"safetyAssessment.assertionStatus must be one of {sorted(ASSERTION_STATUS_VALUES)}") + if "impactAnalysis" in document: + _validate_impact_analysis( + document["impactAnalysis"], "impactAnalysis", errors, classification=safety.get("classification") + ) + if "sourceOfTruth" in document: + _validate_source_of_truth(document["sourceOfTruth"], "sourceOfTruth", errors) + + discovered_reference = document.get("discoveredReference") + if discovered_reference is not None: + if not isinstance(discovered_reference, Mapping): + errors.append("discoveredReference must be an object") + else: + if discovered_reference.get("format") not in {"SPDX", "CycloneDX"}: + errors.append("discoveredReference.format must be 'SPDX' or 'CycloneDX'") + for key in ("componentIdentifier", "uri"): + if not _is_non_empty_string(discovered_reference.get(key)): + errors.append(f"discoveredReference.{key} must be a non-empty string") + digest = discovered_reference.get("sha256") + if not isinstance(digest, str) or SHA256_PATTERN.fullmatch(digest) is None: + errors.append("discoveredReference.sha256 must be a lowercase SHA-256 digest") + + generator = _require_mapping(document, "generator", errors) + for key in ("name", "version"): + if not _is_non_empty_string(generator.get(key)): + errors.append(f"generator.{key} must be a non-empty string") + + integrity = _require_mapping(document, "integrity", errors) + if integrity.get("algorithm") != "SHA-256": + errors.append("integrity.algorithm must be 'SHA-256'") + for key in ("assertionSha256", "sbomSha256"): + value = integrity.get(key) + if not isinstance(value, str) or SHA256_PATTERN.fullmatch(value) is None: + errors.append(f"integrity.{key} must be a lowercase SHA-256 digest") + known_good_digest = integrity.get("knownGoodSha256") + if known_good_digest is not None and ( + not isinstance(known_good_digest, str) or SHA256_PATTERN.fullmatch(known_good_digest) is None + ): + errors.append("integrity.knownGoodSha256 must be a lowercase SHA-256 digest") + return errors + + +def _purl_identity(purl: str) -> tuple[str, str | None]: + """Return a PURL without subpath and its version.""" + + without_subpath = purl.split("#", maxsplit=1)[0] + if "@" not in without_subpath: + return without_subpath, None + base, version = without_subpath.rsplit("@", maxsplit=1) + return base, version + + +def _purl_matches(subject_purl: str, candidate_purl: str, subject_version: str) -> bool: + subject_base, purl_version = _purl_identity(subject_purl) + candidate_base, candidate_version = _purl_identity(candidate_purl) + expected_version = purl_version or subject_version + return subject_base == candidate_base and candidate_version == expected_version + + +def _normalize_repository(repository: str) -> str: + normalized = repository.strip().rstrip("/") + if normalized.endswith(".git"): + normalized = normalized[:-4] + return normalized.lower() + + +def _known_good_binding(assertion: Mapping[str, Any], known_good: Mapping[str, Any] | None) -> dict[str, str] | None: + """Resolve an assertion subject to one exact S-CORE known-good module.""" + + if known_good is None: + return None + subject = assertion["subject"] + repository = subject.get("repository") + if not _is_non_empty_string(repository): + return None + expected_repository = _normalize_repository(str(repository)) + expected_hash = str(subject["version"]) + modules = known_good.get("modules") + if not isinstance(modules, Mapping): + return None + + bindings: list[dict[str, str]] = [] + for group in modules.values(): + if not isinstance(group, Mapping): + continue + for module_name, module in group.items(): + if not isinstance(module_name, str) or not isinstance(module, Mapping): + continue + module_repository = module.get("repo") + module_hash = module.get("hash") + if not _is_non_empty_string(module_repository) or not _is_non_empty_string(module_hash): + continue + if ( + _normalize_repository(str(module_repository)) == expected_repository + and str(module_hash) == expected_hash + ): + bindings.append( + { + "module": module_name, + "repository": str(module_repository), + "hash": str(module_hash), + } + ) + return bindings[0] if len(bindings) == 1 else None + + +def _known_good_candidate_matches(candidate: Mapping[str, Any], binding: Mapping[str, str]) -> bool: + """Match an ``unknown`` SBOM module only when known-good supplies its identity.""" + + if candidate.get("name") != binding["module"] or candidate.get("version") != "unknown": + return False + candidate_base, candidate_version = _purl_identity(str(candidate.get("purl", ""))) + repository = urlparse(_normalize_repository(binding["repository"])) + repository_parts = repository.path.strip("/").split("/") + if len(repository_parts) != 2: + return False + expected_base = f"pkg:github/{repository_parts[0]}/{binding['module']}" + return candidate_base.lower() == expected_base.lower() and candidate_version == "unknown" + + +def _spdx_components(sbom: Mapping[str, Any]) -> Iterable[dict[str, Any]]: + for package in sbom.get("packages", []): + if not isinstance(package, Mapping): + continue + for external_ref in package.get("externalRefs", []): + if not isinstance(external_ref, Mapping): + continue + locator = external_ref.get("referenceLocator") + reference_type = str(external_ref.get("referenceType", "")).lower() + if _is_non_empty_string(locator) and (str(locator).startswith("pkg:") or reference_type.endswith("purl")): + yield { + "format": "SPDX-2.3", + "identifier": package.get("SPDXID"), + "name": package.get("name"), + "version": package.get("versionInfo"), + "purl": str(locator), + } + + +def _walk_cyclonedx_components(components: object) -> Iterable[Mapping[str, Any]]: + if not isinstance(components, list): + return + for component in components: + if not isinstance(component, Mapping): + continue + yield component + yield from _walk_cyclonedx_components(component.get("components")) + + +def _cyclonedx_components(sbom: Mapping[str, Any]) -> Iterable[dict[str, Any]]: + metadata = sbom.get("metadata") + roots: list[Mapping[str, Any]] = [] + if isinstance(metadata, Mapping) and isinstance(metadata.get("component"), Mapping): + roots.append(metadata["component"]) + roots.extend(_walk_cyclonedx_components(sbom.get("components"))) + for component in roots: + purl = component.get("purl") + if _is_non_empty_string(purl): + yield { + "format": "CycloneDX-1.6", + "identifier": component.get("bom-ref"), + "name": component.get("name"), + "version": component.get("version"), + "purl": str(purl), + } + + +def _matches_and_binding( + assertion: Mapping[str, Any], + sbom: Mapping[str, Any], + known_good: Mapping[str, Any] | None, +) -> tuple[list[dict[str, Any]], dict[str, str] | None]: + """Find SBOM components representing the assertion subject. + + The component subpath in the assertion PURL is intentionally ignored for + the initial module-level join. ``componentPath`` preserves the intended + KVS scope until the SBOM emits target-level component identities. + """ + + errors = validate_assertion(assertion) + if errors: + raise ValueError("Invalid SRAC assertion: " + "; ".join(errors)) + subject = assertion["subject"] + subject_purl = str(subject["purl"]) + subject_version = str(subject["version"]) + + if str(sbom.get("spdxVersion", "")).startswith("SPDX-2."): + candidates = list(_spdx_components(sbom)) + elif sbom.get("bomFormat") == "CycloneDX": + candidates = list(_cyclonedx_components(sbom)) + else: + raise ValueError("Unsupported SBOM format; expected SPDX 2.x or CycloneDX") + + direct_matches = [ + candidate for candidate in candidates if _purl_matches(subject_purl, str(candidate["purl"]), subject_version) + ] + if direct_matches: + return direct_matches, None + + binding = _known_good_binding(assertion, known_good) + if binding is None: + return [], None + return [candidate for candidate in candidates if _known_good_candidate_matches(candidate, binding)], binding + + +def match_assertion_to_sbom( + assertion: Mapping[str, Any], + sbom: Mapping[str, Any], + known_good: Mapping[str, Any] | None = None, +) -> list[dict[str, Any]]: + """Find SBOM components representing the assertion subject.""" + + matches, _ = _matches_and_binding(assertion, sbom, known_good) + return matches + + +def build_enrichment_report( + assertion: Mapping[str, Any], + sbom: Mapping[str, Any], + known_good: Mapping[str, Any] | None = None, + integrity: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Build a sidecar report without modifying the source SBOM.""" + + matches, binding = _matches_and_binding(assertion, sbom, known_good) + report = { + "schemaVersion": SCHEMA_VERSION, + "sourceFormat": "SPDX" if "spdxVersion" in sbom else "CycloneDX", + "assertionId": assertion["assertionId"], + "subject": deepcopy(assertion["subject"]), + "safetyAssessment": { + "source": "assertion", + "safetyRelevance": assertion["safetyRelevance"]["status"], + "classification": assertion["safetyRelevance"]["classification"], + "assertionStatus": assertion["assertion"]["status"], + "reviewer": deepcopy(assertion["assertion"].get("reviewer")), + }, + "matchedComponents": matches, + "matchStatus": "matched" if matches else "unmatched", + "generator": { + "name": "srac.tools.enrich_sbom", + "version": TOOL_VERSION, + }, + "integrity": dict(integrity or {}), + } + if matches and binding is not None: + report["bindingEvidence"] = { + "strategy": "score-known-good", + **binding, + } + if "impactAnalysis" in assertion: + report["impactAnalysis"] = deepcopy(assertion["impactAnalysis"]) + if "sourceOfTruth" in assertion: + report["sourceOfTruth"] = deepcopy(assertion["sourceOfTruth"]) + return report