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
1 change: 1 addition & 0 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<String, RecommendationSource> buildRecommendationsMap(
Map<String, PackageItem> pkgItems) {
Expand All @@ -475,18 +478,20 @@ private Map<String, RecommendationSource> buildRecommendationsMap(
Map<String, List<RecommendationReport>> 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<String, RecommendationSource> result = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -52,7 +54,7 @@ public Exchange aggregate(Exchange oldExchange, Exchange newExchange) {
if (providerResponse == null) {
providerResponse = new ProviderResponse(new HashMap<>(), worstStatus);
}
Map<PackageRef, IndexedRecommendation> recommendations = null;
Map<PackageRef, List<IndexedRecommendation>> recommendations = null;
try {
recommendations = getRecommendations(oldExchange, newExchange);
} catch (Exception e) {
Expand Down Expand Up @@ -106,43 +108,72 @@ private ProviderResponse getProviderResponse(Exchange oldExchange, Exchange newE
}

@SuppressWarnings("unchecked")
private Map<PackageRef, IndexedRecommendation> getRecommendations(
private Map<PackageRef, List<IndexedRecommendation>> getRecommendations(
Exchange oldExchange, Exchange newExchange) {
var body = oldExchange.getIn().getBody();
if (body != null && body instanceof Map) {
return (Map<PackageRef, IndexedRecommendation>) body;
return normalizeRecommendations((Map<PackageRef, ?>) body);
}
body = newExchange.getIn().getBody();
if (body != null && body instanceof Map) {
return (Map<PackageRef, IndexedRecommendation>) body;
return normalizeRecommendations((Map<PackageRef, ?>) body);
}
return null;
}

/**
* Normalizes recommendation maps to a consistent List-valued format. Handles both the legacy
* single-value format (Map&lt;PackageRef, IndexedRecommendation&gt;) from trusted-content/UBI
* providers and the new list format (Map&lt;PackageRef, List&lt;IndexedRecommendation&gt;&gt;)
* from the hardened image provider.
*/
@SuppressWarnings("unchecked")
private Map<PackageRef, List<IndexedRecommendation>> normalizeRecommendations(
Map<PackageRef, ?> raw) {
Map<PackageRef, List<IndexedRecommendation>> normalized = new HashMap<>();
raw.forEach(
(key, value) -> {
if (value instanceof List) {
normalized.put(key, (List<IndexedRecommendation>) value);
} else if (value instanceof IndexedRecommendation) {
normalized.put(key, List.of((IndexedRecommendation) value));
}
});
return normalized;
}

private void setTrustedContent(
Map<PackageRef, IndexedRecommendation> recommendations, ProviderResponse providerResponse) {
Map<PackageRef, List<IndexedRecommendation>> 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<Issue>();
if (pkgItem != null && pkgItem.issues() != null) {
issues.addAll(pkgItem.issues());
pkgItem
.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()));
Expand All @@ -156,9 +187,10 @@ private void setTrustedContent(
new PackageItem(
key.ref(),
recommendation,
allRecommendations,
issues,
pkgItem != null ? pkgItem.warnings() : Collections.emptyList(),
value.sourceName()));
primary.sourceName()));
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -100,7 +101,7 @@ void refresh() {
}

LOG.info("Acquired refresh lock, fetching hardened image data from Hummingbird");
Map<String, IndexedRecommendation> newData = fetchAndParseData(url);
Map<String, List<IndexedRecommendation>> newData = fetchAndParseData(url);
if (newData.isEmpty() && index.size() > 0) {
LOG.warn(
"Hummingbird returned empty data, keeping existing index with "
Expand All @@ -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<IndexedRecommendation> lookup(String baseImageRef) {
return index.get(baseImageRef);
}

Expand All @@ -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<PackageRef, IndexedRecommendation> lookupBySbomId(String sbomId) {
public Map<PackageRef, List<IndexedRecommendation>> lookupBySbomId(String sbomId) {
if (sbomId == null) {
return Collections.emptyMap();
}
Expand All @@ -160,12 +161,12 @@ public Map<PackageRef, IndexedRecommendation> 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);
}

/**
Expand Down Expand Up @@ -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<String, IndexedRecommendation> fetchAndParseData(String url) throws Exception {
Map<String, List<IndexedRecommendation>> fetchAndParseData(String url) throws Exception {
String body = hummingbirdClient.fetchReport(URI.create(url));
return responseHandler.parseAndInvertMapping(body);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String, IndexedRecommendation> parseAndInvertMapping(String json) throws Exception {
public Map<String, List<IndexedRecommendation>> parseAndInvertMapping(String json)
throws Exception {
JsonNode root = objectMapper.readTree(json);
Map<String, IndexedRecommendation> invertedIndex = new HashMap<>();
Map<String, List<IndexedRecommendation>> invertedIndex = new HashMap<>();

JsonNode images = root.path("images");
if (images.isMissingNode() || !images.isObject()) {
Expand Down Expand Up @@ -95,7 +98,7 @@ public Map<String, IndexedRecommendation> 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);
}
}
}
Expand Down
27 changes: 25 additions & 2 deletions src/main/java/io/github/guacsec/trustifyda/model/PackageItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Recommendation> allRecommendations,
List<Issue> issues,
List<String> 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<Issue> issues, List<String> 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<Issue> issues,
List<String> warnings,
String recommendationSource) {
this(packageRef, recommendation, null, issues, warnings, recommendationSource);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,27 +28,27 @@
*/
public class HardenedImageIndex {

private volatile Map<String, IndexedRecommendation> index = new ConcurrentHashMap<>();
private volatile Map<String, List<IndexedRecommendation>> 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<String, IndexedRecommendation> newData) {
public void replaceAll(Map<String, List<IndexedRecommendation>> newData) {
this.index = new ConcurrentHashMap<>(newData);
}

Comment thread
sourcery-ai[bot] marked this conversation as resolved.
/** 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<IndexedRecommendation> get(String baseImageRef) {
return index.getOrDefault(baseImageRef, Collections.emptyList());
}

/** Returns an unmodifiable view of the current index. */
public Map<String, IndexedRecommendation> getAll() {
public Map<String, List<IndexedRecommendation>> 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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading