From 2c2f37168e4982748ead4cae2fb343c7703a700d Mon Sep 17 00:00:00 2001 From: rhartuv Date: Sun, 5 Jul 2026 13:52:45 +0300 Subject: [PATCH] fix: add PACKAGE-MANAGER support --- docs/sbom-requirements.md | 2 +- openspec/specs/upload-spdx-api/spec.md | 7 ++- .../exploitiq/service/SpdxParsingService.java | 10 +++- .../service/SpdxParsingServiceTest.java | 53 +++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/docs/sbom-requirements.md b/docs/sbom-requirements.md index 131bb704..b85da338 100644 --- a/docs/sbom-requirements.md +++ b/docs/sbom-requirements.md @@ -34,7 +34,7 @@ An SPDX product SBOM MUST: - Set `spdxVersion` to `SPDX-2.3`. - Include a `DESCRIBES` relationship from the document to a **product** package (the product name is taken from that package). - List **component** packages linked to the product with `PACKAGE_OF` relationships. -- Give each component an OCI package URL (`purl`) in `externalRefs` with `referenceCategory` `PACKAGE_MANAGER` and `referenceType` `purl`. Supported components use the form: +- Give each component an OCI package URL (`purl`) in `externalRefs` with `referenceCategory` `PACKAGE_MANAGER` or `PACKAGE-MANAGER` and `referenceType` `purl`. Supported components use the form: `pkg:oci/@sha256:?repository_url=/&tag=` diff --git a/openspec/specs/upload-spdx-api/spec.md b/openspec/specs/upload-spdx-api/spec.md index f167373b..9d44f684 100644 --- a/openspec/specs/upload-spdx-api/spec.md +++ b/openspec/specs/upload-spdx-api/spec.md @@ -88,7 +88,12 @@ For ProductEndpoint structured SBOM metadata validation errors (returned as `sbo - **AND** `expectedLabels` values are resolved from the backend property named by `configuredProperty` ### Requirement: Unsupported component handling -When parsing an SPDX file, the system SHALL classify each component (package with PACKAGE_OF relationship to the product) as supported or unsupported. A component is supported only if it has a purl (package URL) that starts with the OCI prefix `pkg:oci/`. Components with no purl or with a purl that does not start with `pkg:oci/` SHALL be considered unsupported. The SPDX parser SHALL add unsupported components to an `unsupportedComponents` list in the parse result. When the parse result contains zero supported components (all components are unsupported or there are no PACKAGE_OF components), the SPDX parser SHALL throw a parse error (validation exception) and the upload endpoint SHALL return HTTP 400 (Bad Request) with the file field mapped to the exact error message: "At least one supported component is required. Supported components are packages with a PACKAGE_OF relationship to the product that have a purl (package URL) starting with pkg:oci. No such components were found." The upload flow SHALL add each unsupported component to the product's `submissionFailures` with an informative error message that states what is expected (purl with prefix `pkg:oci/`) and what was provided (the actual purl value, or "missing" if no purl). Async component processing SHALL run only for supported (OCI) components; unsupported components SHALL NOT be sent through the pipeline. The product's `submittedCount` SHALL be the total of all components in the SPDX (supported + unsupported). +When parsing an SPDX file, the system SHALL classify each component (package with PACKAGE_OF relationship to the product) as supported or unsupported. A component is supported only if it has a purl (package URL) that starts with the OCI prefix `pkg:oci/`. When locating component purls in `externalRefs`, the parser SHALL treat `referenceCategory` values `PACKAGE_MANAGER` and `PACKAGE-MANAGER` equivalently (SPDX 2.3 schema allows both forms). Components with no purl or with a purl that does not start with `pkg:oci/` SHALL be considered unsupported. The SPDX parser SHALL add unsupported components to an `unsupportedComponents` list in the parse result. When the parse result contains zero supported components (all components are unsupported or there are no PACKAGE_OF components), the SPDX parser SHALL throw a parse error (validation exception) and the upload endpoint SHALL return HTTP 400 (Bad Request) with the file field mapped to the exact error message: "At least one supported component is required. Supported components are packages with a PACKAGE_OF relationship to the product that have a purl (package URL) starting with pkg:oci. No such components were found." The upload flow SHALL add each unsupported component to the product's `submissionFailures` with an informative error message that states what is expected (purl with prefix `pkg:oci/`) and what was provided (the actual purl value, or "missing" if no purl). Async component processing SHALL run only for supported (OCI) components; unsupported components SHALL NOT be sent through the pipeline. The product's `submittedCount` SHALL be the total of all components in the SPDX (supported + unsupported). + +#### Scenario: Parser accepts PACKAGE-MANAGER referenceCategory for component purls +- **WHEN** the SPDX parser processes a document that contains a component package with an OCI purl in `externalRefs` where `referenceCategory` is `PACKAGE-MANAGER` (hyphen form) +- **THEN** the parser SHALL extract the purl and add the component to the `components` list +- **AND** the component SHALL NOT be added to `unsupportedComponents` #### Scenario: Parser returns unsupported components list for non-OCI purls - **WHEN** the SPDX parser processes a document that contains a component package with a purl that does not start with `pkg:oci/` (e.g. `pkg:maven/org.foo/bar@1.0`) or a component with no purl diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/SpdxParsingService.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/SpdxParsingService.java index 56d9ba58..cf3985c2 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/SpdxParsingService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/SpdxParsingService.java @@ -62,8 +62,9 @@ public class SpdxParsingService { private static final String SPDX_FIELD_REFERENCE_TYPE = "referenceType"; private static final String SPDX_FIELD_REFERENCE_LOCATOR = "referenceLocator"; - // External ref category/type values + // External ref category/type values (SPDX 2.3 schema allows both underscore and hyphen forms) private static final String SPDX_REF_CATEGORY_PACKAGE_MANAGER = "PACKAGE_MANAGER"; + private static final String SPDX_REF_CATEGORY_PACKAGE_MANAGER_HYPHEN = "PACKAGE-MANAGER"; private static final String SPDX_REF_CATEGORY_SECURITY = "SECURITY"; private static final String SPDX_REF_TYPE_PURL = "purl"; private static final String SPDX_REF_TYPE_CPE22 = "cpe22Type"; @@ -169,7 +170,7 @@ public ParsedSpdx parse(JsonNode spdxJson) { if (componentPackage.has(SPDX_FIELD_EXTERNAL_REFS) && componentPackage.get(SPDX_FIELD_EXTERNAL_REFS).isArray()) { ArrayNode externalRefs = (ArrayNode) componentPackage.get(SPDX_FIELD_EXTERNAL_REFS); for (JsonNode ref : externalRefs) { - if (ref.has(SPDX_FIELD_REFERENCE_CATEGORY) && SPDX_REF_CATEGORY_PACKAGE_MANAGER.equals(ref.get(SPDX_FIELD_REFERENCE_CATEGORY).asText()) + if (ref.has(SPDX_FIELD_REFERENCE_CATEGORY) && isPackageManagerCategory(ref.get(SPDX_FIELD_REFERENCE_CATEGORY).asText()) && ref.has(SPDX_FIELD_REFERENCE_TYPE) && SPDX_REF_TYPE_PURL.equals(ref.get(SPDX_FIELD_REFERENCE_TYPE).asText()) && ref.has(SPDX_FIELD_REFERENCE_LOCATOR)) { purl = ref.get(SPDX_FIELD_REFERENCE_LOCATOR).asText(); @@ -223,6 +224,11 @@ private void validateSpdxDocument(JsonNode spdxJson) { } } + private static boolean isPackageManagerCategory(String category) { + return SPDX_REF_CATEGORY_PACKAGE_MANAGER.equals(category) + || SPDX_REF_CATEGORY_PACKAGE_MANAGER_HYPHEN.equals(category); + } + /** * Extracts CPE from package externalRefs where referenceCategory is SECURITY and referenceType is cpe22Type * @param packageNode The package JSON node diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/SpdxParsingServiceTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/SpdxParsingServiceTest.java index 9a84eb1c..658b19d8 100644 --- a/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/SpdxParsingServiceTest.java +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/SpdxParsingServiceTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Files; import java.nio.file.Path; @@ -59,4 +60,56 @@ void parse_acceptsSpdx23_thenFailsOnDescribes() throws Exception { SbomValidationException ex = assertThrows(SbomValidationException.class, () -> service.parse(root)); assertEquals("No DESCRIBES relationship found in SPDX document", ex.getMessage()); } + + @Test + void parse_acceptsPackageManagerHyphenReferenceCategory() throws Exception { + String json = """ + { + "spdxVersion": "SPDX-2.3", + "packages": [ + { + "SPDXID": "SPDXRef-Product", + "name": "test-product", + "versionInfo": "1.0" + }, + { + "SPDXID": "SPDXRef-Component", + "name": "test-component", + "versionInfo": "1.0", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:oci/test-component@sha256:abc123?repository_url=registry.example.com/test-component&tag=1.0" + } + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Product" + }, + { + "spdxElementId": "SPDXRef-Component", + "relationshipType": "PACKAGE_OF", + "relatedSpdxElement": "SPDXRef-Product" + } + ] + } + """; + JsonNode root = objectMapper.readTree(json); + SpdxParsingService.ParsedSpdx parsed = service.parse(root); + + assertEquals(1, parsed.components().size()); + assertTrue(parsed.unsupportedComponents().isEmpty()); + SpdxParsingService.ComponentInfo component = parsed.components().get(0); + assertEquals("SPDXRef-Component", component.spdxId()); + assertEquals("test-component", component.name()); + assertEquals( + "pkg:oci/test-component@sha256:abc123?repository_url=registry.example.com/test-component&tag=1.0", + component.purl()); + assertEquals("registry.example.com/test-component@sha256:abc123", component.image()); + } }