Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/sbom-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>@sha256:<digest>?repository_url=<registry>/<path>&tag=<tag>`

Expand Down
7 changes: 6 additions & 1 deletion openspec/specs/upload-spdx-api/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,58 @@ void parse_acceptsSpdx23_thenFailsOnDescribes() throws Exception {
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());
}

@Test
void parse_acceptsOciPurlWithUrlEncodedSha256Separator() throws Exception {
String json = """
Expand Down