diff --git a/CONVENTIONS.md b/CONVENTIONS.md index c2323060..bcbbe8ab 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -249,6 +249,7 @@ Background scheduled providers (e.g., `HardenedImageProvider`) do not use Camel - **REST Assured pattern**: `given().header(...).body(...).when().post(...).then().assertThat().statusCode(...)` - **Cache testing**: Two-request pattern to verify cache hits; `server.resetRequests()` between tests - **Test data**: JSON fixtures in `src/test/resources/{format}/` (e.g., `pypi-registry/`, `depsdev/`, `trustify/`, `reports/`) +- **Test comments**: Use `//` (regular line comments) for test method descriptions, not `///` (Java 23 markdown doc comments) or `/** */` Javadoc. The project targets Java 21. - **Assertions**: JUnit static imports + Hamcrest matchers - **Unit testing CDI beans**: For non-Quarkus unit tests, instantiate beans directly and set `@Inject`/`@ConfigProperty` fields manually (package-private visibility). Mock CDI `Instance` with Mockito. - **Unit testing Camel routes**: For route builder tests that don't need full Camel context, test the `process()` methods directly by constructing mock `Exchange` and `Message` objects. diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/providers/ProviderResponseHandler.java b/src/main/java/io/github/guacsec/trustifyda/integration/providers/ProviderResponseHandler.java index eb2e2a36..5fd8ee79 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/providers/ProviderResponseHandler.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/providers/ProviderResponseHandler.java @@ -54,6 +54,7 @@ import io.github.guacsec.trustifyda.model.DependencyTree; import io.github.guacsec.trustifyda.model.PackageItem; import io.github.guacsec.trustifyda.model.ProviderResponse; +import io.github.guacsec.trustifyda.model.trustify.Recommendation; import io.github.guacsec.trustifyda.monitoring.MonitoringProcessor; import io.quarkus.runtime.annotations.RegisterForReflection; @@ -300,6 +301,7 @@ public ProviderResponse filterCves(Exchange exchange) { return new PackageItem( entry.getKey(), item.recommendation(), + item.allRecommendations(), filteredIssues, item.warnings() == null ? Collections.emptyList() : item.warnings(), item.recommendationSource()); @@ -466,6 +468,7 @@ private SourceSummary buildSummary( /** * Builds the provider-level recommendations map from all PackageItems that have recommendations. * Groups recommendations by their source name (e.g. "trusted-content", "hardened-images"). + * Supports multiple recommendations per dependency via the allRecommendations field. */ private Map buildRecommendationsMap( Map pkgItems) { @@ -475,18 +478,20 @@ private Map buildRecommendationsMap( Map> bySource = new HashMap<>(); pkgItems.forEach( (packageRef, item) -> { - if (item.recommendation() == null || item.recommendation().packageName() == null) { - return; - } String sourceName = item.recommendationSource(); if (sourceName == null) { return; } - var recReport = - new RecommendationReport() - .ref(new PackageRef(packageRef)) - .recommendation(item.recommendation().packageName()); - bySource.computeIfAbsent(sourceName, k -> new ArrayList<>()).add(recReport); + for (Recommendation rec : item.allRecommendations()) { + if (rec == null || rec.packageName() == null) { + continue; + } + var recReport = + new RecommendationReport() + .ref(new PackageRef(packageRef)) + .recommendation(rec.packageName()); + bySource.computeIfAbsent(sourceName, k -> new ArrayList<>()).add(recReport); + } }); Map result = new HashMap<>(); diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/RecommendationAggregation.java b/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/RecommendationAggregation.java index edde9e79..b2afb0a3 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/RecommendationAggregation.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/RecommendationAggregation.java @@ -20,7 +20,9 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.apache.camel.AggregationStrategy; import org.apache.camel.Exchange; @@ -52,7 +54,7 @@ public Exchange aggregate(Exchange oldExchange, Exchange newExchange) { if (providerResponse == null) { providerResponse = new ProviderResponse(new HashMap<>(), worstStatus); } - Map recommendations = null; + Map> recommendations = null; try { recommendations = getRecommendations(oldExchange, newExchange); } catch (Exception e) { @@ -106,25 +108,53 @@ private ProviderResponse getProviderResponse(Exchange oldExchange, Exchange newE } @SuppressWarnings("unchecked") - private Map getRecommendations( + private Map> getRecommendations( Exchange oldExchange, Exchange newExchange) { var body = oldExchange.getIn().getBody(); if (body != null && body instanceof Map) { - return (Map) body; + return normalizeRecommendations((Map) body); } body = newExchange.getIn().getBody(); if (body != null && body instanceof Map) { - return (Map) body; + return normalizeRecommendations((Map) body); } return null; } + /** + * Normalizes recommendation maps to a consistent List-valued format. Handles both the legacy + * single-value format (Map<PackageRef, IndexedRecommendation>) from trusted-content/UBI + * providers and the new list format (Map<PackageRef, List<IndexedRecommendation>>) + * from the hardened image provider. + */ + @SuppressWarnings("unchecked") + private Map> normalizeRecommendations( + Map raw) { + Map> normalized = new HashMap<>(); + raw.forEach( + (key, value) -> { + if (value instanceof List) { + normalized.put(key, (List) value); + } else if (value instanceof IndexedRecommendation) { + normalized.put(key, List.of((IndexedRecommendation) value)); + } + }); + return normalized; + } + private void setTrustedContent( - Map recommendations, ProviderResponse providerResponse) { + Map> recommendations, + ProviderResponse providerResponse) { recommendations.forEach( - (key, value) -> { + (key, values) -> { + if (values == null || values.isEmpty()) { + return; + } + IndexedRecommendation primary = values.get(0); var pkgItem = providerResponse.pkgItems().get(key.ref()); - var recommendation = toRecommendation(value); + var recommendation = toRecommendation(primary); + var allRecommendations = + values.stream().map(this::toRecommendation).collect(Collectors.toList()); var issues = new ArrayList(); if (pkgItem != null && pkgItem.issues() != null) { issues.addAll(pkgItem.issues()); @@ -132,17 +162,18 @@ private void setTrustedContent( .issues() .forEach( issue -> { - if (value.vulnerabilities() == null || value.vulnerabilities().isEmpty()) { + if (primary.vulnerabilities() == null + || primary.vulnerabilities().isEmpty()) { return; } - var vuln = value.vulnerabilities().get(issue.getId()); + var vuln = primary.vulnerabilities().get(issue.getId()); if (vuln == null) { return; } var remediation = new RemediationTrustedContent() - .ref(value.packageName()) + .ref(primary.packageName()) .status(Vulnerability.Status.toString(vuln.getStatus())) .justification( Vulnerability.Justification.toString(vuln.getJustification())); @@ -156,9 +187,10 @@ private void setTrustedContent( new PackageItem( key.ref(), recommendation, + allRecommendations, issues, pkgItem != null ? pkgItem.warnings() : Collections.emptyList(), - value.sourceName())); + primary.sourceName())); }); } diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageProvider.java b/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageProvider.java index 72d81d9e..19bab273 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageProvider.java @@ -19,6 +19,7 @@ import java.net.URI; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -100,7 +101,7 @@ void refresh() { } LOG.info("Acquired refresh lock, fetching hardened image data from Hummingbird"); - Map newData = fetchAndParseData(url); + Map> newData = fetchAndParseData(url); if (newData.isEmpty() && index.size() > 0) { LOG.warn( "Hummingbird returned empty data, keeping existing index with " @@ -122,12 +123,12 @@ void refresh() { } /** - * Looks up a hardened image recommendation for the given base image reference. + * Looks up hardened image recommendations for the given base image reference. * * @param baseImageRef the base image reference to look up - * @return the recommendation, or {@code null} if none is available + * @return the list of recommendations, empty if none available */ - public IndexedRecommendation lookup(String baseImageRef) { + public List lookup(String baseImageRef) { return index.get(baseImageRef); } @@ -137,9 +138,9 @@ public IndexedRecommendation lookup(String baseImageRef) { * map if the PURL is not an OCI type, has no repository_url qualifier, or no match is found. * * @param sbomId the SBOM identifier (an OCI PURL string) - * @return a map of package ref to recommendation, or empty if no match + * @return a map of package ref to recommendations, or empty if no match */ - public Map lookupBySbomId(String sbomId) { + public Map> lookupBySbomId(String sbomId) { if (sbomId == null) { return Collections.emptyMap(); } @@ -160,12 +161,12 @@ public Map lookupBySbomId(String sbomId) { return Collections.emptyMap(); } - var recommendation = lookup(baseImageRef); - if (recommendation == null) { + var recommendations = lookup(baseImageRef); + if (recommendations.isEmpty()) { return Collections.emptyMap(); } - return Map.of(pkgRef, recommendation); + return Map.of(pkgRef, recommendations); } /** @@ -198,7 +199,7 @@ public HardenedImageIndex getIndex() { * Fetches the Hummingbird report and parses it into an inverted index mapping base image * references to hardened image recommendations. */ - Map fetchAndParseData(String url) throws Exception { + Map> fetchAndParseData(String url) throws Exception { String body = hummingbirdClient.fetchReport(URI.create(url)); return responseHandler.parseAndInvertMapping(body); } diff --git a/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageResponseHandler.java b/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageResponseHandler.java index e352f8d5..4030a036 100644 --- a/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageResponseHandler.java +++ b/src/main/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageResponseHandler.java @@ -17,8 +17,10 @@ package io.github.guacsec.trustifyda.integration.providers.trustify.hardened; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.TreeMap; @@ -51,11 +53,12 @@ public HardenedImageResponseHandler(ObjectMapper objectMapper) { /** * Parses the Hummingbird JSON response and inverts the mapping. The response contains hardened * images with {@code compare_to} arrays listing base images. This method inverts the - * relationship: each base image reference maps to its hardened image recommendation. + * relationship: each base image reference maps to all its hardened image recommendations. */ - public Map parseAndInvertMapping(String json) throws Exception { + public Map> parseAndInvertMapping(String json) + throws Exception { JsonNode root = objectMapper.readTree(json); - Map invertedIndex = new HashMap<>(); + Map> invertedIndex = new HashMap<>(); JsonNode images = root.path("images"); if (images.isMissingNode() || !images.isObject()) { @@ -95,7 +98,7 @@ public Map parseAndInvertMapping(String json) thr for (JsonNode baseRef : compareTo) { String baseImageRef = baseRef.asText(null); if (baseImageRef != null && !baseImageRef.isBlank()) { - invertedIndex.put(baseImageRef, recommendation); + invertedIndex.computeIfAbsent(baseImageRef, k -> new ArrayList<>()).add(recommendation); } } } diff --git a/src/main/java/io/github/guacsec/trustifyda/model/PackageItem.java b/src/main/java/io/github/guacsec/trustifyda/model/PackageItem.java index 119bb0d6..f0bf96c0 100644 --- a/src/main/java/io/github/guacsec/trustifyda/model/PackageItem.java +++ b/src/main/java/io/github/guacsec/trustifyda/model/PackageItem.java @@ -17,21 +17,44 @@ package io.github.guacsec.trustifyda.model; +import java.util.Collections; import java.util.List; import io.github.guacsec.trustifyda.api.v5.Issue; import io.github.guacsec.trustifyda.model.trustify.Recommendation; -/** Aggregated data for a single package: its recommendation, vulnerability issues, and warnings. */ +/** + * Aggregated data for a single package: its recommendations, vulnerability issues, and warnings. + */ public record PackageItem( String packageRef, Recommendation recommendation, + List allRecommendations, List issues, List warnings, String recommendationSource) { + /** Compact constructor that defaults null allRecommendations to a singleton list. */ + public PackageItem { + if (allRecommendations == null || allRecommendations.isEmpty()) { + allRecommendations = + recommendation != null ? List.of(recommendation) : Collections.emptyList(); + } + } + + /** Backward-compatible constructor without allRecommendations and recommendationSource. */ public PackageItem( String packageRef, Recommendation recommendation, List issues, List warnings) { - this(packageRef, recommendation, issues, warnings, null); + this(packageRef, recommendation, null, issues, warnings, null); + } + + /** Backward-compatible constructor without allRecommendations. */ + public PackageItem( + String packageRef, + Recommendation recommendation, + List issues, + List warnings, + String recommendationSource) { + this(packageRef, recommendation, null, issues, warnings, recommendationSource); } } diff --git a/src/main/java/io/github/guacsec/trustifyda/model/trustify/HardenedImageIndex.java b/src/main/java/io/github/guacsec/trustifyda/model/trustify/HardenedImageIndex.java index d56c7f2f..cfef98c2 100644 --- a/src/main/java/io/github/guacsec/trustifyda/model/trustify/HardenedImageIndex.java +++ b/src/main/java/io/github/guacsec/trustifyda/model/trustify/HardenedImageIndex.java @@ -18,6 +18,7 @@ package io.github.guacsec.trustifyda.model.trustify; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -27,27 +28,27 @@ */ public class HardenedImageIndex { - private volatile Map index = new ConcurrentHashMap<>(); + private volatile Map> index = new ConcurrentHashMap<>(); /** * Atomically replaces the entire index with the given data. Concurrent readers will see either * the old or new map — never a partially updated state. */ - public void replaceAll(Map newData) { + public void replaceAll(Map> newData) { this.index = new ConcurrentHashMap<>(newData); } - /** Looks up a hardened image recommendation for the given base image reference. */ - public IndexedRecommendation get(String baseImageRef) { - return index.get(baseImageRef); + /** Looks up all hardened image recommendations for the given base image reference. */ + public List get(String baseImageRef) { + return index.getOrDefault(baseImageRef, Collections.emptyList()); } /** Returns an unmodifiable view of the current index. */ - public Map getAll() { + public Map> getAll() { return Collections.unmodifiableMap(index); } - /** Returns the number of entries in the index. */ + /** Returns the number of base image entries in the index. */ public int size() { return index.size(); } diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageIntegrationTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageIntegrationTest.java index ed5a3bb0..adde3179 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageIntegrationTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageIntegrationTest.java @@ -121,7 +121,7 @@ private void populateHardenedIndex() { .vulnerabilities(Collections.emptyMap()) .sourceName(HARDENED_SOURCE) .build(); - hardenedImageProvider.getIndex().replaceAll(Map.of(NGINX_BASE_REF, recommendation)); + hardenedImageProvider.getIndex().replaceAll(Map.of(NGINX_BASE_REF, List.of(recommendation))); } /** Stubs the Trustify vulnerability and recommendation endpoints in WireMock. */ diff --git a/src/test/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageProviderTest.java b/src/test/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageProviderTest.java index c6a8d895..a9b11637 100644 --- a/src/test/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageProviderTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/integration/providers/trustify/hardened/HardenedImageProviderTest.java @@ -18,8 +18,8 @@ package io.github.guacsec.trustifyda.integration.providers.trustify.hardened; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -35,6 +35,7 @@ import java.time.Duration; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -94,8 +95,8 @@ void testRefreshAcquiresLockAndPopulatesIndex() throws Exception { .build(); doReturn( Map.of( - "docker.io/library/nginx:1.25", nginxRec, - "docker.io/library/nginx:1.25-alpine", nginxRec)) + "docker.io/library/nginx:1.25", List.of(nginxRec), + "docker.io/library/nginx:1.25-alpine", List.of(nginxRec))) .when(spyProvider) .fetchAndParseData(anyString()); @@ -105,12 +106,13 @@ void testRefreshAcquiresLockAndPopulatesIndex() throws Exception { // Then the lock was acquired and the index is populated verify(lock).tryAcquire(eq(LOCK_KEY), any()); assertEquals(2, spyProvider.getIndex().size()); - assertNotNull(spyProvider.lookup("docker.io/library/nginx:1.25")); - assertNotNull(spyProvider.lookup("docker.io/library/nginx:1.25-alpine")); + assertFalse(spyProvider.lookup("docker.io/library/nginx:1.25").isEmpty()); + assertFalse(spyProvider.lookup("docker.io/library/nginx:1.25-alpine").isEmpty()); assertEquals( normalized(HARDENED_NGINX_PURL), - spyProvider.lookup("docker.io/library/nginx:1.25").packageName().ref()); - assertEquals("hardened", spyProvider.lookup("docker.io/library/nginx:1.25").sourceName()); + spyProvider.lookup("docker.io/library/nginx:1.25").get(0).packageName().ref()); + assertEquals( + "hardened", spyProvider.lookup("docker.io/library/nginx:1.25").get(0).sourceName()); } // Verifies that a second instance skips refresh when the lock is already held. @@ -140,23 +142,25 @@ void testParseAndInvertMapping() throws Exception { + " {\"compare_to\": [\"docker.io/library/node:20\"]}}}"; // When parsing and inverting the mapping - Map result = responseHandler.parseAndInvertMapping(json); + Map> result = responseHandler.parseAndInvertMapping(json); // Then each base image maps to its hardened recommendation with an OCI PURL assertEquals(3, result.size()); - var pythonRec = result.get("docker.io/library/python:3.12"); - assertNotNull(pythonRec); - assertEquals("oci", pythonRec.packageName().purl().getType()); - assertEquals("hardened-python", pythonRec.packageName().purl().getName()); + var pythonRecs = result.get("docker.io/library/python:3.12"); + assertNotNull(pythonRecs); + assertEquals(1, pythonRecs.size()); + assertEquals("oci", pythonRecs.get(0).packageName().purl().getType()); + assertEquals("hardened-python", pythonRecs.get(0).packageName().purl().getName()); assertEquals( - pythonRec.packageName().ref(), - result.get("docker.io/library/python:3.12-slim").packageName().ref()); - - var nodeRec = result.get("docker.io/library/node:20"); - assertNotNull(nodeRec); - assertEquals("oci", nodeRec.packageName().purl().getType()); - assertEquals("hardened-node", nodeRec.packageName().purl().getName()); - assertEquals("hardened", nodeRec.sourceName()); + pythonRecs.get(0).packageName().ref(), + result.get("docker.io/library/python:3.12-slim").get(0).packageName().ref()); + + var nodeRecs = result.get("docker.io/library/node:20"); + assertNotNull(nodeRecs); + assertEquals(1, nodeRecs.size()); + assertEquals("oci", nodeRecs.get(0).packageName().purl().getType()); + assertEquals("hardened-node", nodeRecs.get(0).packageName().purl().getName()); + assertEquals("hardened", nodeRecs.get(0).sourceName()); } // Verifies that the provider returns empty results when the Hummingbird URL is not configured. @@ -168,9 +172,9 @@ void testDisabledWhenUrlNotConfigured() { // When the refresh runs provider.refresh(); - // Then no lock is acquired and lookup returns null + // Then no lock is acquired and lookup returns empty verify(lock, never()).tryAcquire(anyString(), any()); - assertNull(provider.lookup("docker.io/library/nginx:1.25")); + assertTrue(provider.lookup("docker.io/library/nginx:1.25").isEmpty()); } // Verifies that the lock is released after a successful data load. @@ -189,7 +193,7 @@ void testLockReleasedAfterSuccessfulLoad() throws Exception { .vulnerabilities(Collections.emptyMap()) .sourceName("hardened") .build(); - doReturn(Map.of("base:1.0", rec)).when(spyProvider).fetchAndParseData(anyString()); + doReturn(Map.of("base:1.0", List.of(rec))).when(spyProvider).fetchAndParseData(anyString()); // When refresh completes successfully spyProvider.refresh(); @@ -197,7 +201,7 @@ void testLockReleasedAfterSuccessfulLoad() throws Exception { // Then the lock is released and the index is populated verify(lock).release(LOCK_KEY); assertEquals(1, spyProvider.getIndex().size()); - assertNotNull(spyProvider.lookup("base:1.0")); + assertFalse(spyProvider.lookup("base:1.0").isEmpty()); } // Verifies that parsing handles the standard Hummingbird object format with container ref keys. @@ -210,14 +214,15 @@ void testParseObjectFormat() throws Exception { + " {\"compare_to\": [\"docker.io/library/alpine:3.19\"]}}}"; // When parsing - Map result = responseHandler.parseAndInvertMapping(json); + Map> result = responseHandler.parseAndInvertMapping(json); // Then the mapping is correctly inverted with an OCI PURL assertEquals(1, result.size()); - var rec = result.get("docker.io/library/alpine:3.19"); - assertNotNull(rec); - assertEquals("oci", rec.packageName().purl().getType()); - assertEquals("hardened-alpine", rec.packageName().purl().getName()); + var recs = result.get("docker.io/library/alpine:3.19"); + assertNotNull(recs); + assertEquals(1, recs.size()); + assertEquals("oci", recs.get(0).packageName().purl().getType()); + assertEquals("hardened-alpine", recs.get(0).packageName().purl().getName()); } // Verifies that parsing returns empty index for responses with no images. @@ -227,7 +232,7 @@ void testParseEmptyResponse() throws Exception { String json = "{\"images\": {}}"; // When parsing - Map result = responseHandler.parseAndInvertMapping(json); + Map> result = responseHandler.parseAndInvertMapping(json); // Then the result is empty assertTrue(result.isEmpty()); @@ -245,12 +250,12 @@ void testParseSkipsInvalidEntries() throws Exception { + "\"registry.example.com/hardened-python:3.12\": {}}}"; // When parsing - Map result = responseHandler.parseAndInvertMapping(json); + Map> result = responseHandler.parseAndInvertMapping(json); // Then only the valid entry with compare_to is indexed assertEquals(1, result.size()); assertNotNull(result.get("base:3.0")); - assertNull(result.get("base:1.0")); + assertTrue(result.getOrDefault("base:1.0", Collections.emptyList()).isEmpty()); } // Verifies that a populated index is preserved when Hummingbird returns empty data. @@ -269,19 +274,19 @@ void testEmptyResponsePreservesExistingIndex() throws Exception { .vulnerabilities(Collections.emptyMap()) .sourceName("hardened") .build(); - doReturn(Map.of("base:1.0", rec)).when(spyProvider).fetchAndParseData(anyString()); + doReturn(Map.of("base:1.0", List.of(rec))).when(spyProvider).fetchAndParseData(anyString()); spyProvider.refresh(); assertEquals(1, spyProvider.getIndex().size()); // Second refresh: Hummingbird returns empty data - doReturn(Collections.emptyMap()) + doReturn(Collections.>emptyMap()) .when(spyProvider) .fetchAndParseData(anyString()); spyProvider.refresh(); // Then the existing index is preserved assertEquals(1, spyProvider.getIndex().size()); - assertNotNull(spyProvider.lookup("base:1.0")); + assertFalse(spyProvider.lookup("base:1.0").isEmpty()); } // Verifies that a populated index is preserved when fetchAndParseData throws an exception. @@ -300,7 +305,7 @@ void testFetchFailurePreservesExistingIndex() throws Exception { .vulnerabilities(Collections.emptyMap()) .sourceName("hardened") .build(); - doReturn(Map.of("base:1.0", rec)).when(spyProvider).fetchAndParseData(anyString()); + doReturn(Map.of("base:1.0", List.of(rec))).when(spyProvider).fetchAndParseData(anyString()); spyProvider.refresh(); assertEquals(1, spyProvider.getIndex().size()); @@ -312,7 +317,7 @@ void testFetchFailurePreservesExistingIndex() throws Exception { // Then the existing index is preserved and the lock is released assertEquals(1, spyProvider.getIndex().size()); - assertNotNull(spyProvider.lookup("base:1.0")); + assertFalse(spyProvider.lookup("base:1.0").isEmpty()); verify(lock, atLeastOnce()).release(LOCK_KEY); } @@ -331,7 +336,7 @@ void testLockReleaseFailureDoesNotPropagate() throws Exception { .vulnerabilities(Collections.emptyMap()) .sourceName("hardened") .build(); - doReturn(Map.of("base:1.0", rec)).when(spyProvider).fetchAndParseData(anyString()); + doReturn(Map.of("base:1.0", List.of(rec))).when(spyProvider).fetchAndParseData(anyString()); // Lock release throws (simulating Redis connection failure) doThrow(new RuntimeException("Redis connection lost")).when(lock).release(LOCK_KEY); @@ -341,7 +346,7 @@ void testLockReleaseFailureDoesNotPropagate() throws Exception { // Then the index is still populated (refresh completed before the finally block) assertEquals(1, spyProvider.getIndex().size()); - assertNotNull(spyProvider.lookup("base:1.0")); + assertFalse(spyProvider.lookup("base:1.0").isEmpty()); } // Verifies that lookupBySbomId returns the recommendation for a matching OCI PURL. @@ -354,7 +359,7 @@ void testLookupBySbomIdMatchingOciPurl() { .vulnerabilities(Collections.emptyMap()) .sourceName("hardened") .build(); - provider.getIndex().replaceAll(Map.of("docker.io/library/nginx:1.25", rec)); + provider.getIndex().replaceAll(Map.of("docker.io/library/nginx:1.25", List.of(rec))); // When looking up by OCI PURL with matching repository_url and tag String ociPurl = @@ -364,8 +369,9 @@ void testLookupBySbomIdMatchingOciPurl() { // Then the recommendation is returned assertEquals(1, result.size()); var entry = result.entrySet().iterator().next(); - assertEquals(normalized(HARDENED_NGINX_PURL), entry.getValue().packageName().ref()); - assertEquals("hardened", entry.getValue().sourceName()); + assertEquals(1, entry.getValue().size()); + assertEquals(normalized(HARDENED_NGINX_PURL), entry.getValue().get(0).packageName().ref()); + assertEquals("hardened", entry.getValue().get(0).sourceName()); } // Verifies that lookupBySbomId returns empty for a non-OCI PURL. @@ -403,4 +409,65 @@ void testLookupBySbomIdNoMatch() { // Then the result is empty assertTrue(result.isEmpty()); } + + // Verifies that multiple hardened images sharing the same compare_to base are all preserved. + @Test + void testParseAndInvertMappingPreservesMultipleRecommendationsPerBase() throws Exception { + // Given a Hummingbird response where two hardened images both replace the same base image + String json = + "{\"images\": {" + + "\"registry.example.com/hardened-nginx-v1:1.25\":" + + " {\"compare_to\": [\"docker.io/library/nginx:1.25\"]}," + + "\"registry.example.com/hardened-nginx-v2:1.25\":" + + " {\"compare_to\": [\"docker.io/library/nginx:1.25\"," + + " \"docker.io/library/nginx:1.25-alpine\"]}}}"; + + // When parsing and inverting the mapping + Map> result = responseHandler.parseAndInvertMapping(json); + + // Then the shared base image has both hardened recommendations + var nginxRecs = result.get("docker.io/library/nginx:1.25"); + assertNotNull(nginxRecs); + assertEquals( + 2, nginxRecs.size(), "Both hardened images should be preserved for the shared base image"); + var recNames = nginxRecs.stream().map(r -> r.packageName().purl().getName()).sorted().toList(); + assertEquals(List.of("hardened-nginx-v1", "hardened-nginx-v2"), recNames); + + // And the non-shared base image has only one recommendation + var alpineRecs = result.get("docker.io/library/nginx:1.25-alpine"); + assertNotNull(alpineRecs); + assertEquals(1, alpineRecs.size()); + assertEquals("hardened-nginx-v2", alpineRecs.get(0).packageName().purl().getName()); + } + + // Verifies that lookupBySbomId returns all recommendations when multiple hardened images match. + @Test + void testLookupBySbomIdReturnsMultipleRecommendations() { + // Given an index with two hardened images for the same base + IndexedRecommendation recV1 = + IndexedRecommendation.builder() + .packageName(new PackageRef(HARDENED_NGINX_PURL)) + .vulnerabilities(Collections.emptyMap()) + .sourceName("hardened") + .build(); + String hardenedV2Purl = + "pkg:oci/hardened-nginx-v2@sha256:def789?repository_url=registry.example.com/hardened-nginx-v2&tag=1.25"; + IndexedRecommendation recV2 = + IndexedRecommendation.builder() + .packageName(new PackageRef(hardenedV2Purl)) + .vulnerabilities(Collections.emptyMap()) + .sourceName("hardened") + .build(); + provider.getIndex().replaceAll(Map.of("docker.io/library/nginx:1.25", List.of(recV1, recV2))); + + // When looking up by OCI PURL + String ociPurl = + "pkg:oci/nginx@sha256:def456?repository_url=docker.io%2Flibrary%2Fnginx&tag=1.25"; + var result = provider.lookupBySbomId(ociPurl); + + // Then both recommendations are returned + assertEquals(1, result.size()); + var recommendations = result.entrySet().iterator().next().getValue(); + assertEquals(2, recommendations.size(), "Both hardened alternatives should be returned"); + } }