-
Notifications
You must be signed in to change notification settings - Fork 13
fix: SBOM analysis request should be blocked when user limit exceeded #302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ga-release
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,7 @@ public class SbomReportService { | |
| private ComponentProcessingService componentProcessingService; | ||
| private CredentialProcessingService credentialProcessingService; | ||
| private ObjectMapper objectMapper; | ||
| private RequestQueueService queueService; | ||
|
|
||
| @Inject | ||
| public void setCycloneDxParsingService(CycloneDxParsingService cycloneDxParsingService) { | ||
|
|
@@ -94,6 +95,11 @@ public void setObjectMapper(ObjectMapper objectMapper) { | |
| this.objectMapper = objectMapper; | ||
| } | ||
|
|
||
| @Inject | ||
| public void setRequestQueueService(RequestQueueService queueService) { | ||
| this.queueService = queueService; | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * Generates a product ID from SBOM name and version. | ||
|
|
@@ -221,35 +227,55 @@ public String submitSpdx(InputStream fileInputStream, String cveId, String crede | |
| throw new ValidationException(errors); | ||
| } | ||
| LOGGER.info("Processing SPDX file upload for CVE: " + cveId); | ||
|
|
||
| SpdxParsingService.ProductInfo productInfo = parsed.productInfo(); | ||
| Map<String, String> metadata = new HashMap<>(); | ||
| // Add CPE to metadata if present | ||
| if (productInfo.cpe() != null && !productInfo.cpe().trim().isEmpty()) { | ||
| metadata.put("cpe", productInfo.cpe()); | ||
| } | ||
|
|
||
| if (Objects.nonNull(productInfo.spdxId())) { | ||
| metadata.put(RepositoryConstants.SPDX_ID_METADATA_KEY, productInfo.spdxId()); | ||
| } | ||
|
|
||
| int totalComponentCount = parsed.components().size() + parsed.unsupportedComponents().size(); | ||
| Product product = this.createProduct(cveId, productInfo.name(), productInfo.version(), totalComponentCount, metadata); | ||
| final SpdxParsingService.ProductInfo productInfo = parsed.productInfo(); | ||
|
|
||
| for (SpdxParsingService.UnsupportedComponentInfo unsupported : parsed.unsupportedComponents()) { | ||
| String errorMessage = | ||
| "Expects a container image purl with format pkg:oci/name@sha256:hash or pkg:oci/name@sha256%3Ahash?repository_url=...&tag=..."; | ||
| String imageForDisplay = unsupported.purl() != null ? unsupported.purl() : ""; | ||
| productRepository.addSubmissionFailure(product.id(), new FailedComponent( | ||
| unsupported.name(), unsupported.version(), imageForDisplay, errorMessage)); | ||
| } | ||
| // Generate productId before capacity check (needed for product slot optimization) | ||
| final String productId = generateProductId(productInfo.name(), productInfo.version()); | ||
|
|
||
| // Get current user | ||
| final String user = userService.getUserName(); | ||
|
|
||
| // Make variables final for lambda capture | ||
| final SpdxParsingService.ParsedSpdx finalParsed = parsed; | ||
| final String finalCveId = cveId; | ||
| final String finalCredentialId = credentialId; | ||
|
|
||
| // Start component processing (chunks run in parallel on executor) | ||
| processSpdxComponents(product.id(), parsed, cveId, credentialId); | ||
| // Check user capacity before creating product (matches RPM/CycloneDX pattern) | ||
| return queueService.runIfHasCapacity(user, productId, () -> { | ||
| try { | ||
| Map<String, String> metadata = new HashMap<>(); | ||
| // Add CPE to metadata if present | ||
| if (productInfo.cpe() != null && !productInfo.cpe().trim().isEmpty()) { | ||
| metadata.put("cpe", productInfo.cpe()); | ||
| } | ||
|
|
||
| LOGGER.infof("Created product %s, started component processing", product.id()); | ||
|
|
||
| return product.id(); | ||
| if (Objects.nonNull(productInfo.spdxId())) { | ||
| metadata.put(RepositoryConstants.SPDX_ID_METADATA_KEY, productInfo.spdxId()); | ||
| } | ||
|
|
||
| int totalComponentCount = finalParsed.components().size() + finalParsed.unsupportedComponents().size(); | ||
| Product product = this.createProduct(productId, finalCveId, productInfo.name(), productInfo.version(), totalComponentCount, metadata); | ||
|
|
||
| for (SpdxParsingService.UnsupportedComponentInfo unsupported : finalParsed.unsupportedComponents()) { | ||
| String errorMessage = | ||
| "Expects a container image purl with format pkg:oci/name@sha256:hash or pkg:oci/name@sha256%3Ahash?repository_url=...&tag=..."; | ||
| String imageForDisplay = unsupported.purl() != null ? unsupported.purl() : ""; | ||
| productRepository.addSubmissionFailure(product.id(), new FailedComponent( | ||
| unsupported.name(), unsupported.version(), imageForDisplay, errorMessage)); | ||
| } | ||
|
|
||
| // Start component processing (chunks run in parallel on executor) | ||
| processSpdxComponents(product.id(), finalParsed, finalCveId, finalCredentialId); | ||
|
|
||
| LOGGER.infof("Created product %s, started component processing", product.id()); | ||
|
|
||
| return product.id(); | ||
| } catch (Exception e) { | ||
| LOGGER.errorf(e, "Failed to create product or start component processing for CVE %s", finalCveId); | ||
| throw e; | ||
| } | ||
| }); | ||
|
Comment on lines
+245
to
+278
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Consider adding context logging for non-queue failures inside the lambda. If A lightweight option: return queueService.runIfHasCapacity(user, productId, () -> {
try {
// ... existing lambda body ...
} catch (Exception e) {
LOGGER.errorf(e, "Failed SPDX upload for CVE %s, product %s/%s",
finalCveId, productInfo.name(), productInfo.version());
throw e;
}
});Non-blocking — the current code works correctly, this just improves debuggability. |
||
| } | ||
|
|
||
| private void processSpdxComponents(String productId, SpdxParsingService.ParsedSpdx parsed, String vulnerabilityId, String credentialId) { | ||
|
|
@@ -273,8 +299,7 @@ private void processSpdxComponents(String productId, SpdxParsingService.ParsedSp | |
| } | ||
| } | ||
|
|
||
| private Product createProduct(String cveId, String sbomName, String sbomVersion, int componentCount, Map<String, String> metadata) { | ||
| String productId = generateProductId(sbomName, sbomVersion); | ||
| private Product createProduct(String productId, String cveId, String sbomName, String sbomVersion, int componentCount, Map<String, String> metadata) { | ||
| Product product = newProductDocument(cveId, productId, sbomName, sbomVersion, componentCount, metadata); | ||
| productRepository.save(product, userService.getUserName()); | ||
| return product; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| package com.redhat.ecosystemappeng.exploitiq.service; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.redhat.ecosystemappeng.exploitiq.repository.ProductRepositoryService; | ||
| import io.quarkus.test.InjectMock; | ||
| import io.quarkus.test.component.QuarkusComponentTest; | ||
| import jakarta.inject.Inject; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.io.ByteArrayInputStream; | ||
| import java.io.InputStream; | ||
| import java.nio.charset.StandardCharsets; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertThrows; | ||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.mockito.ArgumentMatchers.any; | ||
| import static org.mockito.ArgumentMatchers.anyString; | ||
| import static org.mockito.ArgumentMatchers.eq; | ||
| import static org.mockito.Mockito.doAnswer; | ||
| import static org.mockito.Mockito.doThrow; | ||
| import static org.mockito.Mockito.never; | ||
| import static org.mockito.Mockito.verify; | ||
| import static org.mockito.Mockito.when; | ||
|
|
||
| /** | ||
| * Tests that SPDX upload checks queue capacity before creating product, | ||
| * matching the pattern used by RPM and CycloneDX flows. | ||
| */ | ||
| @QuarkusComponentTest | ||
| class SbomReportServiceQueueAdmissionTest { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important: Missing happy path test. Both tests verify rejection (queue throws before the lambda runs). There's no test verifying the lambda actually executes when capacity is available. The RPM equivalent ( A happy path test would catch:
Suggested approach: mock |
||
|
|
||
| @Inject | ||
| SbomReportService sbomReportService; | ||
|
|
||
| @InjectMock | ||
| RequestQueueService queueService; | ||
| @InjectMock | ||
| UserService userService; | ||
| @InjectMock | ||
| ProductRepositoryService productRepository; | ||
| @InjectMock | ||
| SpdxParsingService spdxParsingService; | ||
| @InjectMock | ||
| ComponentProcessingService componentProcessingService; | ||
| @InjectMock | ||
| CycloneDxParsingService cycloneDxParsingService; | ||
| @InjectMock | ||
| ReportService reportService; | ||
| @InjectMock | ||
| CredentialProcessingService credentialProcessingService; | ||
| @InjectMock | ||
| ObjectMapper objectMapper; | ||
|
|
||
| /** | ||
| * Test happy path: when capacity is available, the lambda executes, | ||
| * product is created, and component processing starts. | ||
| */ | ||
| @Test | ||
| void submitSpdx_HappyPath_CreatesProductAndProcessesComponents() throws Exception { | ||
| String spdxJson = """ | ||
| { | ||
| "spdxVersion": "SPDX-2.3", | ||
| "name": "happy-product", | ||
| "versionInfo": "3.0.0", | ||
| "documentNamespace": "https://example.com/happy", | ||
| "packages": [] | ||
| } | ||
| """; | ||
|
|
||
| InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8)); | ||
|
|
||
| SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo( | ||
| "happy-product", "3.0.0", null, null | ||
| ); | ||
| SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx( | ||
| productInfo, | ||
| java.util.List.of(), | ||
| java.util.List.of() | ||
| ); | ||
| when(spdxParsingService.parse(any())).thenReturn(parsedSpdx); | ||
| when(userService.getUserName()).thenReturn("charlie"); | ||
|
|
||
| // Mock queueService to execute the lambda when capacity is available | ||
| doAnswer(invocation -> { | ||
| @SuppressWarnings("unchecked") | ||
| java.util.function.Supplier<String> action = invocation.getArgument(2); | ||
| return action.get(); // Execute the lambda | ||
| }).when(queueService).runIfHasCapacity(eq("charlie"), anyString(), any()); | ||
|
|
||
| // Execute | ||
| String result = sbomReportService.submitSpdx(spdxStream, "CVE-2024-9999", null); | ||
|
|
||
| // Verify: Product created | ||
| verify(productRepository).save(any(), eq("charlie")); | ||
|
|
||
| // Verify: Component processing started | ||
| verify(componentProcessingService).processComponents(any(), anyString(), any(), eq("CVE-2024-9999"), eq(null)); | ||
|
|
||
| // Verify: Returns product ID (non-null) | ||
| assertEquals(true, result != null && !result.isEmpty()); | ||
| } | ||
|
|
||
| /** | ||
| * Test that SPDX upload throws UserQueueExceededException when user limit is exceeded, | ||
| * and does NOT create a product document. This matches the behavior of RPM analysis. | ||
| */ | ||
| @Test | ||
| void submitSpdx_UserQueueExceeded_DoesNotCreateProduct() throws Exception { | ||
| // Prepare minimal valid SPDX JSON | ||
| String spdxJson = """ | ||
| { | ||
| "spdxVersion": "SPDX-2.3", | ||
| "name": "test-product", | ||
| "documentNamespace": "https://example.com/test", | ||
| "packages": [ | ||
| { | ||
| "name": "test-component", | ||
| "versionInfo": "1.0.0", | ||
| "externalRefs": [ | ||
| { | ||
| "referenceCategory": "PACKAGE-MANAGER", | ||
| "referenceType": "purl", | ||
| "referenceLocator": "pkg:oci/test@sha256:abcd1234" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| """; | ||
|
|
||
| InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8)); | ||
|
|
||
| // Mock parsing to return valid parsed data | ||
| SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo( | ||
| "test-product", "1.0.0", null, null | ||
| ); | ||
| SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx( | ||
| productInfo, | ||
| java.util.List.of(), // components | ||
| java.util.List.of() // unsupported components | ||
| ); | ||
| when(spdxParsingService.parse(any())).thenReturn(parsedSpdx); | ||
| when(userService.getUserName()).thenReturn("alice"); | ||
|
|
||
| // Mock queueService to throw UserQueueExceededException (user limit exceeded) | ||
| doThrow(new UserQueueExceededException(5)) | ||
| .when(queueService).runIfHasCapacity(eq("alice"), anyString(), any()); | ||
|
|
||
| // Assert that exception is thrown | ||
| assertThrows(UserQueueExceededException.class, | ||
| () -> sbomReportService.submitSpdx(spdxStream, "CVE-2024-1234", null)); | ||
|
Comment on lines
+150
to
+151
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important: Arguments to
Consider using doThrow(new UserQueueExceededException(5))
.when(queueService).runIfHasCapacity(eq("alice"), anyString(), any());Same applies to the second test with |
||
|
|
||
| // Verify: No product created (save never called on productRepository) | ||
| verify(productRepository, never()).save(any(), any()); | ||
|
|
||
| // Verify: No component processing started | ||
| verify(componentProcessingService, never()).processComponents(any(), any(), any(), any(), any()); | ||
| } | ||
|
|
||
| /** | ||
| * Test that SPDX upload throws RequestQueueExceededException when global queue is full, | ||
| * and does NOT create a product document. | ||
| */ | ||
| @Test | ||
| void submitSpdx_GlobalQueueExceeded_DoesNotCreateProduct() throws Exception { | ||
| String spdxJson = """ | ||
| { | ||
| "spdxVersion": "SPDX-2.3", | ||
| "name": "test-product", | ||
| "versionInfo": "2.0.0", | ||
| "documentNamespace": "https://example.com/test2", | ||
| "packages": [] | ||
| } | ||
| """; | ||
|
|
||
| InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8)); | ||
|
|
||
| SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo( | ||
| "test-product", "2.0.0", null, null | ||
| ); | ||
| SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx( | ||
| productInfo, | ||
| java.util.List.of(), | ||
| java.util.List.of() | ||
| ); | ||
| when(spdxParsingService.parse(any())).thenReturn(parsedSpdx); | ||
| when(userService.getUserName()).thenReturn("bob"); | ||
|
|
||
| // Mock queueService to throw RequestQueueExceededException (global queue full) | ||
| doThrow(new RequestQueueExceededException(500)) | ||
| .when(queueService).runIfHasCapacity(eq("bob"), anyString(), any()); | ||
|
|
||
| // Assert that exception is thrown | ||
| assertThrows(RequestQueueExceededException.class, | ||
| () -> sbomReportService.submitSpdx(spdxStream, "CVE-2024-5678", null)); | ||
|
|
||
| // Verify: No product created | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Add The first test verifies both verify(productRepository, never()).save(any(), any());
verify(componentProcessingService, never()).processComponents(any(), any(), any(), any(), any()); |
||
| verify(productRepository, never()).save(any(), any()); | ||
|
|
||
| // Verify: No component processing started | ||
| verify(componentProcessingService, never()).processComponents(any(), any(), any(), any(), any()); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@TamarW0 This is used for the slot reservation in the queue, but the product uses another generated productId, you need it to be the same.