From 3991875b276c7c1ae0344e48bb78c92a3623a4bb Mon Sep 17 00:00:00 2001
From: Adva Oren
Date: Mon, 6 Jul 2026 17:48:13 +0300
Subject: [PATCH] fix: deduplicate hardened image recommendations in
DockerfileAnnotator (TC-4934)
Add .distinct() to getHardenedRecommendations() stream to prevent duplicate
entries when multiple providers return the same hardened image alternative.
Co-Authored-By: Claude Opus 4.6
---
.../intellij/image/DockerfileAnnotator.java | 67 ++--
.../image/HardenedImageIntentionAction.java | 39 +-
...DockerfileAnnotatorRecommendationTest.java | 59 +--
.../HardenedImageIntentionActionTest.java | 346 ++++++++++++++++++
4 files changed, 449 insertions(+), 62 deletions(-)
create mode 100644 src/test/java/org/jboss/tools/intellij/image/HardenedImageIntentionActionTest.java
diff --git a/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java b/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java
index 133bdfe..4cbb0c1 100644
--- a/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java
+++ b/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java
@@ -118,7 +118,7 @@ private static boolean hasProviderRecommendations(ProviderReport provider) {
}
static String generateMessage(String image, AnalysisReport report, String recommendation,
- String hardenedRecommendation) {
+ List hardenedRecommendations) {
var messageBuilder = new StringBuilder(image);
Optional.ofNullable(report.getProviders())
@@ -160,17 +160,17 @@ static String generateMessage(String image, AnalysisReport report, String recomm
.append("Replace your image with RedHat UBI: ")
.append(recommendation);
}
- if (hardenedRecommendation != null) {
+ if (hardenedRecommendations != null && !hardenedRecommendations.isEmpty()) {
messageBuilder.append(System.lineSeparator())
- .append("A Red Hat Hardened Image is available: ")
- .append(hardenedRecommendation);
+ .append("Red Hat Hardened Image available: ")
+ .append(String.join(", ", hardenedRecommendations));
}
return messageBuilder.toString();
}
static String generateTooltip(String image, AnalysisReport report, String recommendation,
- String hardenedRecommendation) {
+ List hardenedRecommendations) {
var tooltipBuilder = new StringBuilder("").append("").append(image).append("
");
Optional.ofNullable(report.getProviders())
@@ -217,10 +217,10 @@ static String generateTooltip(String image, AnalysisReport report, String recomm
.append(recommendation)
.append("
");
}
- if (hardenedRecommendation != null) {
+ if (hardenedRecommendations != null && !hardenedRecommendations.isEmpty()) {
tooltipBuilder.append("")
- .append("A Red Hat Hardened Image is available: ")
- .append(hardenedRecommendation)
+ .append("
Red Hat Hardened Image available: ")
+ .append(String.join(", ", hardenedRecommendations))
.append("
");
}
@@ -262,9 +262,13 @@ static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
return findMatchingRecommendation(deps, imageRef, DependencyReport::getRef, DependencyReport::getRecommendation);
}
- /** Returns the hardened image recommendation (from provider-level recommendations), or null if none. */
- static String getHardenedRecommendation(AnalysisReport report, ImageRef imageRef) {
- var deps = Optional.ofNullable(report.getProviders())
+ /**
+ * Returns hardened image references from provider-level recommendations matching the given image.
+ * Each entry is a displayable image reference suitable for Dockerfile FROM line replacement.
+ * Supports the 1→N case where multiple hardened alternatives exist for a single image.
+ */
+ static List getHardenedRecommendations(AnalysisReport report, ImageRef imageRef) {
+ return Optional.ofNullable(report.getProviders())
.stream()
.flatMap(provider -> provider.values().stream())
.filter(Objects::nonNull)
@@ -274,10 +278,22 @@ static String getHardenedRecommendation(AnalysisReport report, ImageRef imageRef
.filter(entry -> entry.getValue() != null)
.map(entry -> entry.getValue().getDependencies())
.filter(Objects::nonNull)
- .flatMap(Collection::stream);
- return findMatchingRecommendation(deps, imageRef,
- io.github.guacsec.trustifyda.api.v5.RecommendationReport::getRef,
- io.github.guacsec.trustifyda.api.v5.RecommendationReport::getRecommendation);
+ .flatMap(Collection::stream)
+ .filter(r -> r.getRef() != null)
+ .filter(r -> {
+ try {
+ return imageRef.getPackageURL().equals(r.getRef().purl());
+ } catch (MalformedPackageURLException e) {
+ LOG.warn("Skipping recommendation with malformed PURL", e);
+ return false;
+ }
+ })
+ .map(io.github.guacsec.trustifyda.api.v5.RecommendationReport::getRecommendation)
+ .filter(Objects::nonNull)
+ .map(DockerfileAnnotator::toImageName)
+ .filter(Objects::nonNull)
+ .distinct()
+ .collect(Collectors.toList());
}
private static String findMatchingRecommendation(Stream items, ImageRef imageRef,
@@ -337,11 +353,12 @@ private static String fullyDecode(String value) {
@NotNull
private static HighlightSeverity getHighlightSeverity(AnalysisReport report, String recommendation,
- String hardenedRecommendation, boolean hasIssue,
+ List hardenedRecommendations, boolean hasIssue,
@NotNull PsiElement context) {
// Recommendation-only (no vulnerabilities): use INFORMATION severity (blue)
if (!hasIssue) {
- boolean hasAnyRecommendation = recommendation != null || hardenedRecommendation != null;
+ boolean hasAnyRecommendation = recommendation != null
+ || (hardenedRecommendations != null && !hardenedRecommendations.isEmpty());
if (hasAnyRecommendation) {
return HighlightSeverity.INFORMATION;
}
@@ -446,16 +463,17 @@ public void apply(@NotNull PsiFile file, Map annotationResult
boolean recommendationsEnabled = ApiSettingsState.getInstance().recommendationsEnabled;
var recommendation = recommendationsEnabled
? getRecommendation(report, value.getImageRef()) : null;
- var hardenedRecommendation = recommendationsEnabled
- ? getHardenedRecommendation(report, value.getImageRef()) : null;
+ var hardenedRecommendations = recommendationsEnabled
+ ? getHardenedRecommendations(report, value.getImageRef()) : List.of();
var message = generateMessage(key.getImageName(), report,
- recommendation, hardenedRecommendation);
+ recommendation, hardenedRecommendations);
var tooltip = generateTooltip(key.getImageName(), report,
- recommendation, hardenedRecommendation);
+ recommendation, hardenedRecommendations);
elements.forEach(e -> {
- var severity = getHighlightSeverity(report, recommendation, hardenedRecommendation, hasIssue, e);
+ var severity = getHighlightSeverity(report, recommendation,
+ hardenedRecommendations, hasIssue, e);
if (e != null) {
var builder = holder
.newAnnotation(severity, message)
@@ -469,8 +487,9 @@ public void apply(@NotNull PsiFile file, Map annotationResult
}
builder = builder.withFix(new ImageReportIntentionAction());
builder = builder.withFix(new UBIIntentionAction());
- if (hardenedRecommendation != null) {
- builder = builder.withFix(new HardenedImageIntentionAction());
+ for (String hardenedImage : hardenedRecommendations) {
+ builder = builder.withFix(
+ new HardenedImageIntentionAction(hardenedImage));
}
builder.create();
}
diff --git a/src/main/java/org/jboss/tools/intellij/image/HardenedImageIntentionAction.java b/src/main/java/org/jboss/tools/intellij/image/HardenedImageIntentionAction.java
index b7b672d..ba8b06e 100644
--- a/src/main/java/org/jboss/tools/intellij/image/HardenedImageIntentionAction.java
+++ b/src/main/java/org/jboss/tools/intellij/image/HardenedImageIntentionAction.java
@@ -14,24 +14,34 @@
import com.intellij.codeInsight.intention.IntentionAction;
import com.intellij.codeInspection.util.IntentionFamilyName;
import com.intellij.codeInspection.util.IntentionName;
-import org.jboss.tools.intellij.image.build.filetype.DockerfileFileType;
-import com.intellij.ide.BrowserUtil;
+import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.TextRange;
+import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiFile;
+import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.util.IncorrectOperationException;
+import org.jboss.tools.intellij.image.build.filetype.DockerfileFileType;
+import org.jboss.tools.intellij.image.build.psi.DockerfileFromInstruction;
+import org.jboss.tools.intellij.image.build.psi.DockerfileImageName;
import org.jetbrains.annotations.NotNull;
-import java.net.URI;
-
-/** Intention action that directs users to the Red Hat Hardened Container Images catalog. */
+/**
+ * Intention action that replaces a Dockerfile FROM line's image reference
+ * with a recommended Red Hat Hardened Image.
+ */
public class HardenedImageIntentionAction implements IntentionAction {
- public static final String HARDENED_IMAGE_LINK = "https://catalog.redhat.com/software/containers/search?gs&q=hardened";
+ private final String imageReference;
+
+ public HardenedImageIntentionAction(String imageReference) {
+ this.imageReference = imageReference;
+ }
@Override
public @IntentionName @NotNull String getText() {
- return "Switch to a Red Hat Hardened Image for enhanced security";
+ return "Replace with Red Hat Hardened Image: " + imageReference;
}
@Override
@@ -46,11 +56,22 @@ public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile psiF
@Override
public void invoke(@NotNull Project project, Editor editor, PsiFile psiFile) throws IncorrectOperationException {
- BrowserUtil.browse(URI.create(HARDENED_IMAGE_LINK));
+ int offset = editor.getCaretModel().getOffset();
+ PsiElement element = psiFile.findElementAt(offset);
+ DockerfileFromInstruction fromInstruction =
+ PsiTreeUtil.getParentOfType(element, DockerfileFromInstruction.class, false);
+ if (fromInstruction != null) {
+ DockerfileImageName imageName = fromInstruction.getImageName();
+ if (imageName != null) {
+ Document document = editor.getDocument();
+ TextRange range = imageName.getTextRange();
+ document.replaceString(range.getStartOffset(), range.getEndOffset(), imageReference);
+ }
+ }
}
@Override
public boolean startInWriteAction() {
- return false;
+ return true;
}
}
diff --git a/src/test/java/org/jboss/tools/intellij/image/DockerfileAnnotatorRecommendationTest.java b/src/test/java/org/jboss/tools/intellij/image/DockerfileAnnotatorRecommendationTest.java
index fd158fd..1046891 100644
--- a/src/test/java/org/jboss/tools/intellij/image/DockerfileAnnotatorRecommendationTest.java
+++ b/src/test/java/org/jboss/tools/intellij/image/DockerfileAnnotatorRecommendationTest.java
@@ -24,6 +24,7 @@
import io.github.guacsec.trustifyda.image.ImageRef;
import org.junit.Test;
+import java.util.List;
import java.util.TreeMap;
import static org.junit.Assert.assertEquals;
@@ -47,7 +48,7 @@ public class DockerfileAnnotatorRecommendationTest {
@Test
public void testGenerateMessageWithUbiRecommendation() {
AnalysisReport report = new AnalysisReport();
- String message = DockerfileAnnotator.generateMessage("nginx:latest", report, "ubi9/ubi", null);
+ String message = DockerfileAnnotator.generateMessage("nginx:latest", report, "ubi9/ubi", List.of());
assertTrue("Should contain UBI recommendation",
message.contains("Replace your image with RedHat UBI: ubi9/ubi"));
@@ -58,10 +59,10 @@ public void testGenerateMessageWithUbiRecommendation() {
@Test
public void testGenerateMessageWithHardenedRecommendation() {
AnalysisReport report = new AnalysisReport();
- String message = DockerfileAnnotator.generateMessage("nginx:latest", report, null, "hardened-nginx");
+ String message = DockerfileAnnotator.generateMessage("nginx:latest", report, null, List.of("hardened-nginx"));
assertTrue("Should contain hardened recommendation",
- message.contains("A Red Hat Hardened Image is available: hardened-nginx"));
+ message.contains("Red Hat Hardened Image available: hardened-nginx"));
assertFalse("Should not contain UBI recommendation",
message.contains("Replace your image with RedHat UBI"));
}
@@ -69,18 +70,18 @@ public void testGenerateMessageWithHardenedRecommendation() {
@Test
public void testGenerateMessageWithBothRecommendations() {
AnalysisReport report = new AnalysisReport();
- String message = DockerfileAnnotator.generateMessage("nginx:latest", report, "ubi9/ubi", "hardened-nginx");
+ String message = DockerfileAnnotator.generateMessage("nginx:latest", report, "ubi9/ubi", List.of("hardened-nginx"));
assertTrue("Should contain UBI recommendation",
message.contains("Replace your image with RedHat UBI: ubi9/ubi"));
assertTrue("Should contain hardened recommendation",
- message.contains("A Red Hat Hardened Image is available: hardened-nginx"));
+ message.contains("Red Hat Hardened Image available: hardened-nginx"));
}
@Test
public void testGenerateMessageWithNoRecommendations() {
AnalysisReport report = new AnalysisReport();
- String message = DockerfileAnnotator.generateMessage("nginx:latest", report, null, null);
+ String message = DockerfileAnnotator.generateMessage("nginx:latest", report, null, List.of());
assertEquals("Should only contain image name", "nginx:latest", message);
}
@@ -90,27 +91,27 @@ public void testGenerateMessageWithNoRecommendations() {
@Test
public void testGenerateTooltipWithHardenedRecommendation() {
AnalysisReport report = new AnalysisReport();
- String tooltip = DockerfileAnnotator.generateTooltip("nginx:latest", report, null, "hardened-nginx");
+ String tooltip = DockerfileAnnotator.generateTooltip("nginx:latest", report, null, List.of("hardened-nginx"));
assertTrue("Should contain hardened recommendation",
- tooltip.contains("A Red Hat Hardened Image is available: hardened-nginx"));
+ tooltip.contains("Red Hat Hardened Image available: hardened-nginx"));
}
@Test
public void testGenerateTooltipWithBothRecommendations() {
AnalysisReport report = new AnalysisReport();
- String tooltip = DockerfileAnnotator.generateTooltip("nginx:latest", report, "ubi9/ubi", "hardened-nginx");
+ String tooltip = DockerfileAnnotator.generateTooltip("nginx:latest", report, "ubi9/ubi", List.of("hardened-nginx"));
assertTrue("Should contain UBI recommendation",
tooltip.contains("Replace your image with RedHat UBI: ubi9/ubi"));
assertTrue("Should contain hardened recommendation",
- tooltip.contains("A Red Hat Hardened Image is available: hardened-nginx"));
+ tooltip.contains("Red Hat Hardened Image available: hardened-nginx"));
}
@Test
public void testGenerateTooltipWithNoRecommendations() {
AnalysisReport report = new AnalysisReport();
- String tooltip = DockerfileAnnotator.generateTooltip("nginx:latest", report, null, null);
+ String tooltip = DockerfileAnnotator.generateTooltip("nginx:latest", report, null, List.of());
assertFalse("Should not contain UBI text", tooltip.contains("Replace your image"));
assertFalse("Should not contain hardened text", tooltip.contains("Hardened Image"));
@@ -163,24 +164,24 @@ public void testGetRecommendationHandlesNullSourceValue() throws MalformedPackag
assertNull("Should return null for null source value", recommendation);
}
- // ── getHardenedRecommendation tests (reads from provider-level recommendations) ──
+ // ── getHardenedRecommendations tests (reads from provider-level recommendations) ──
@Test
- public void testGetHardenedRecommendationFromProviderRecommendations() throws MalformedPackageURLException {
+ public void testGetHardenedRecommendationsFromProviderRecommendations() throws MalformedPackageURLException {
PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
ImageRef imageRef = new ImageRef(imagePurl);
AnalysisReport report = buildReportWithHardenedRecommendation(imagePurl,
buildOciPurl("hardened-nginx", HARDENED_DIGEST, "registry.access.redhat.com/hardened/nginx"));
- String recommendation = DockerfileAnnotator.getHardenedRecommendation(report, imageRef);
+ List recommendations = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
- assertNotNull("Hardened recommendation should be present", recommendation);
- assertTrue("Should contain hardened path", recommendation.contains("hardened"));
+ assertFalse("Hardened recommendations should not be empty", recommendations.isEmpty());
+ assertTrue("Should contain hardened path", recommendations.get(0).contains("hardened"));
}
@Test
- public void testGetHardenedRecommendationReturnsNullWhenOnlySourcesExist() throws MalformedPackageURLException {
+ public void testGetHardenedRecommendationsReturnsEmptyWhenOnlySourcesExist() throws MalformedPackageURLException {
PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
ImageRef imageRef = new ImageRef(imagePurl);
@@ -188,9 +189,9 @@ public void testGetHardenedRecommendationReturnsNullWhenOnlySourcesExist() throw
AnalysisReport report = buildReportWithUbiSource(imagePurl,
buildOciPurl("ubi", UBI_DIGEST, "registry.access.redhat.com/ubi9/ubi"));
- String recommendation = DockerfileAnnotator.getHardenedRecommendation(report, imageRef);
+ List recommendations = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
- assertNull("Hardened recommendation should be null when only sources exist", recommendation);
+ assertTrue("Hardened recommendations should be empty when only sources exist", recommendations.isEmpty());
}
@Test
@@ -222,25 +223,25 @@ public void testGetBothRecommendationsFromMixedReport() throws MalformedPackageU
report.putProvidersItem("rhtpa", providerReport);
String ubiRec = DockerfileAnnotator.getRecommendation(report, imageRef);
- String hardenedRec = DockerfileAnnotator.getHardenedRecommendation(report, imageRef);
+ List hardenedRecs = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
assertNotNull("UBI recommendation should be present", ubiRec);
assertTrue("Should contain ubi path", ubiRec.contains("ubi9/ubi"));
- assertNotNull("Hardened recommendation should be present", hardenedRec);
- assertTrue("Should contain hardened path", hardenedRec.contains("hardened"));
+ assertFalse("Hardened recommendations should not be empty", hardenedRecs.isEmpty());
+ assertTrue("Should contain hardened path", hardenedRecs.get(0).contains("hardened"));
}
@Test
- public void testGetRecommendationsReturnNullForEmptyReport() throws MalformedPackageURLException {
+ public void testGetRecommendationsReturnEmptyForEmptyReport() throws MalformedPackageURLException {
PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
ImageRef imageRef = new ImageRef(imagePurl);
AnalysisReport report = new AnalysisReport();
String ubiRec = DockerfileAnnotator.getRecommendation(report, imageRef);
- String hardenedRec = DockerfileAnnotator.getHardenedRecommendation(report, imageRef);
+ List hardenedRecs = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
assertNull("UBI recommendation should be null for empty report", ubiRec);
- assertNull("Hardened recommendation should be null for empty report", hardenedRec);
+ assertTrue("Hardened recommendations should be empty for empty report", hardenedRecs.isEmpty());
}
// ── isReportAvailable tests ──────────────────────────────────────────────
@@ -301,7 +302,7 @@ public void testIsReportAvailableReturnsFalseForZeroVulnsAndNoRecommendations()
// ── Double-encoded PURL tests ─────────────────────────────────────────────
@Test
- public void testGetHardenedRecommendationWithDoubleEncodedPurl() throws MalformedPackageURLException {
+ public void testGetHardenedRecommendationsWithDoubleEncodedPurl() throws MalformedPackageURLException {
PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
ImageRef imageRef = new ImageRef(imagePurl);
@@ -312,10 +313,10 @@ public void testGetHardenedRecommendationWithDoubleEncodedPurl() throws Malforme
AnalysisReport report = buildReportWithHardenedRecommendation(imagePurl, hardenedPurl);
- String recommendation = DockerfileAnnotator.getHardenedRecommendation(report, imageRef);
+ List recommendations = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
- assertNotNull("Should parse double-encoded PURL successfully", recommendation);
- assertTrue("Should contain decoded path", recommendation.contains("quay.io/hummingbird/go"));
+ assertFalse("Should parse double-encoded PURL successfully", recommendations.isEmpty());
+ assertTrue("Should contain decoded path", recommendations.get(0).contains("quay.io/hummingbird/go"));
}
// ── Helper methods ───────────────────────────────────────────────────────
diff --git a/src/test/java/org/jboss/tools/intellij/image/HardenedImageIntentionActionTest.java b/src/test/java/org/jboss/tools/intellij/image/HardenedImageIntentionActionTest.java
new file mode 100644
index 0000000..b0d7961
--- /dev/null
+++ b/src/test/java/org/jboss/tools/intellij/image/HardenedImageIntentionActionTest.java
@@ -0,0 +1,346 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Red Hat, Inc.
+ * Distributed under license by Red Hat, Inc. All rights reserved.
+ * This program is made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v20.html
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ ******************************************************************************/
+
+package org.jboss.tools.intellij.image;
+
+import com.github.packageurl.MalformedPackageURLException;
+import com.github.packageurl.PackageURL;
+import io.github.guacsec.trustifyda.api.PackageRef;
+import io.github.guacsec.trustifyda.api.v5.AnalysisReport;
+import io.github.guacsec.trustifyda.api.v5.DependencyReport;
+import io.github.guacsec.trustifyda.api.v5.ProviderReport;
+import io.github.guacsec.trustifyda.api.v5.RecommendationReport;
+import io.github.guacsec.trustifyda.api.v5.RecommendationSource;
+import io.github.guacsec.trustifyda.api.v5.Source;
+import io.github.guacsec.trustifyda.api.v5.SourceSummary;
+import io.github.guacsec.trustifyda.image.ImageRef;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.TreeMap;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for {@link HardenedImageIntentionAction} and the hardened recommendation
+ * extraction logic in {@link DockerfileAnnotator}.
+ */
+public class HardenedImageIntentionActionTest {
+
+ private static final String IMAGE_DIGEST = "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
+ private static final String HARDENED_DIGEST_1 = "sha256:c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b20000";
+ private static final String HARDENED_DIGEST_2 = "sha256:d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2000000";
+ private static final String UBI_DIGEST = "sha256:b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b200";
+
+ // ── HardenedImageIntentionAction unit tests ─────────────────────────────
+
+ /** Verifies that getText() includes the target image reference. */
+ @Test
+ public void testGetText_containsImageReference() {
+ var action = new HardenedImageIntentionAction("quay.io/hardened/nginx");
+ assertEquals("Replace with Red Hat Hardened Image: quay.io/hardened/nginx", action.getText());
+ }
+
+ /** Verifies that the family name matches the RHDA convention. */
+ @Test
+ public void testGetFamilyName() {
+ var action = new HardenedImageIntentionAction("quay.io/hardened/nginx");
+ assertEquals("RHDA", action.getFamilyName());
+ }
+
+ /** Verifies that startInWriteAction returns true since the action modifies the document. */
+ @Test
+ public void testStartInWriteAction() {
+ var action = new HardenedImageIntentionAction("quay.io/hardened/nginx");
+ assertTrue("Should return true for write action", action.startInWriteAction());
+ }
+
+ /** Verifies that different image references produce distinct action text. */
+ @Test
+ public void testGetText_distinctForDifferentImages() {
+ var action1 = new HardenedImageIntentionAction("quay.io/hardened/nginx");
+ var action2 = new HardenedImageIntentionAction("quay.io/hardened/golang");
+ assertFalse("Different images should produce different text",
+ action1.getText().equals(action2.getText()));
+ }
+
+ // ── getHardenedRecommendations tests ────────────────────────────────────
+
+ /** Verifies extraction of a single hardened recommendation from provider-level data. */
+ @Test
+ public void testGetHardenedRecommendations_singleRecommendation() throws MalformedPackageURLException {
+ // Given an image with one hardened recommendation
+ PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
+ ImageRef imageRef = new ImageRef(imagePurl);
+ AnalysisReport report = buildReportWithHardenedRecommendation(imagePurl,
+ buildOciPurl("hardened-nginx", HARDENED_DIGEST_1, "quay.io/hardened/nginx"));
+
+ // When extracting hardened recommendations
+ List recommendations = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
+
+ // Then one recommendation should be present
+ assertNotNull("Recommendations list should not be null", recommendations);
+ assertEquals("Should have exactly one recommendation", 1, recommendations.size());
+ assertTrue("Should contain hardened image path",
+ recommendations.get(0).contains("quay.io/hardened/nginx"));
+ }
+
+ /** Verifies extraction of multiple hardened recommendations (1→N case). */
+ @Test
+ public void testGetHardenedRecommendations_multipleRecommendations() throws MalformedPackageURLException {
+ // Given an image with two hardened recommendations
+ PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
+ ImageRef imageRef = new ImageRef(imagePurl);
+
+ RecommendationReport rec1 = new RecommendationReport();
+ rec1.setRef(new PackageRef(imagePurl));
+ rec1.setRecommendation(new PackageRef(
+ buildOciPurl("hardened-nginx", HARDENED_DIGEST_1, "quay.io/hardened/nginx")));
+
+ RecommendationReport rec2 = new RecommendationReport();
+ rec2.setRef(new PackageRef(imagePurl));
+ rec2.setRecommendation(new PackageRef(
+ buildOciPurl("hardened-nginx-alt", HARDENED_DIGEST_2, "quay.io/hardened/nginx-alt")));
+
+ RecommendationSource recSource = new RecommendationSource();
+ recSource.addDependenciesItem(rec1);
+ recSource.addDependenciesItem(rec2);
+
+ ProviderReport providerReport = new ProviderReport();
+ providerReport.putRecommendationsItem("hardened", recSource);
+
+ AnalysisReport report = new AnalysisReport();
+ report.putProvidersItem("rhtpa", providerReport);
+
+ // When extracting hardened recommendations
+ List recommendations = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
+
+ // Then two recommendations should be present
+ assertEquals("Should have two recommendations", 2, recommendations.size());
+ assertTrue("Should contain first hardened image",
+ recommendations.stream().anyMatch(r -> r.contains("quay.io/hardened/nginx")));
+ assertTrue("Should contain second hardened image",
+ recommendations.stream().anyMatch(r -> r.contains("quay.io/hardened/nginx-alt")));
+ }
+
+ /** Verifies that duplicate hardened recommendations are deduplicated. */
+ @Test
+ public void testGetHardenedRecommendations_deduplicatesSameImage() throws MalformedPackageURLException {
+ // Given an image with the same hardened recommendation appearing in two providers
+ PackageURL imagePurl = buildOciPurl("golang", IMAGE_DIGEST, "docker.io/library/golang");
+ ImageRef imageRef = new ImageRef(imagePurl);
+
+ PackageURL hardenedPurl = buildOciPurl("go", HARDENED_DIGEST_1, "quay.io/hummingbird/go");
+
+ // First provider recommends the same image
+ RecommendationReport rec1 = new RecommendationReport();
+ rec1.setRef(new PackageRef(imagePurl));
+ rec1.setRecommendation(new PackageRef(hardenedPurl));
+ RecommendationSource recSource1 = new RecommendationSource();
+ recSource1.addDependenciesItem(rec1);
+
+ ProviderReport provider1 = new ProviderReport();
+ provider1.putRecommendationsItem("hardened", recSource1);
+
+ // Second provider also recommends the same image
+ RecommendationReport rec2 = new RecommendationReport();
+ rec2.setRef(new PackageRef(imagePurl));
+ rec2.setRecommendation(new PackageRef(hardenedPurl));
+ RecommendationSource recSource2 = new RecommendationSource();
+ recSource2.addDependenciesItem(rec2);
+
+ ProviderReport provider2 = new ProviderReport();
+ provider2.putRecommendationsItem("hardened", recSource2);
+
+ AnalysisReport report = new AnalysisReport();
+ report.putProvidersItem("provider1", provider1);
+ report.putProvidersItem("provider2", provider2);
+
+ // When extracting hardened recommendations
+ List recommendations = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
+
+ // Then only one unique recommendation should be present
+ assertEquals("Should deduplicate identical recommendations", 1, recommendations.size());
+ assertTrue("Should contain hardened image path",
+ recommendations.get(0).contains("quay.io/hummingbird/go"));
+ }
+
+ /** Verifies that an empty list is returned when no provider-level recommendations exist. */
+ @Test
+ public void testGetHardenedRecommendations_emptyWhenNoRecommendations() throws MalformedPackageURLException {
+ // Given an image with only source-level UBI recommendation, no provider-level
+ PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
+ ImageRef imageRef = new ImageRef(imagePurl);
+
+ DependencyReport dep = new DependencyReport();
+ dep.setRef(new PackageRef(imagePurl));
+ dep.setRecommendation(new PackageRef(
+ buildOciPurl("ubi", UBI_DIGEST, "registry.access.redhat.com/ubi9/ubi")));
+ Source source = new Source();
+ source.addDependenciesItem(dep);
+ ProviderReport providerReport = new ProviderReport();
+ providerReport.putSourcesItem("ubi", source);
+ AnalysisReport report = new AnalysisReport();
+ report.putProvidersItem("rhtpa", providerReport);
+
+ // When extracting hardened recommendations
+ List recommendations = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
+
+ // Then the list should be empty
+ assertTrue("Should return empty list when no hardened recommendations",
+ recommendations.isEmpty());
+ }
+
+ /** Verifies that an empty list is returned for an empty report. */
+ @Test
+ public void testGetHardenedRecommendations_emptyForEmptyReport() throws MalformedPackageURLException {
+ PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
+ ImageRef imageRef = new ImageRef(imagePurl);
+ AnalysisReport report = new AnalysisReport();
+
+ List recommendations = DockerfileAnnotator.getHardenedRecommendations(report, imageRef);
+
+ assertTrue("Should return empty list for empty report", recommendations.isEmpty());
+ }
+
+ // ── isReportAvailable with recommendations tests ────────────────────────
+
+ /** Verifies that isReportAvailable returns true when only hardened recommendations exist. */
+ @Test
+ public void testIsReportAvailable_trueForRecommendationsOnly() throws MalformedPackageURLException {
+ PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
+ AnalysisReport report = buildReportWithHardenedRecommendation(imagePurl,
+ buildOciPurl("hardened-nginx", HARDENED_DIGEST_1, "quay.io/hardened/nginx"));
+
+ assertTrue("Should be available when provider-level recommendations exist",
+ DockerfileAnnotator.isReportAvailable(report));
+ }
+
+ /** Verifies that isReportAvailable returns true when both vulnerabilities and recommendations exist. */
+ @Test
+ public void testIsReportAvailable_trueForBothVulnsAndRecommendations() throws MalformedPackageURLException {
+ // Given a report with both vulnerabilities and recommendations
+ PackageURL imagePurl = buildOciPurl("nginx", IMAGE_DIGEST, "docker.io/library/nginx");
+
+ SourceSummary summary = new SourceSummary();
+ summary.setTotal(3);
+ Source source = new Source();
+ source.setSummary(summary);
+
+ RecommendationReport recReport = new RecommendationReport();
+ recReport.setRef(new PackageRef(imagePurl));
+ recReport.setRecommendation(new PackageRef(
+ buildOciPurl("hardened-nginx", HARDENED_DIGEST_1, "quay.io/hardened/nginx")));
+ RecommendationSource recSource = new RecommendationSource();
+ recSource.addDependenciesItem(recReport);
+
+ ProviderReport providerReport = new ProviderReport();
+ providerReport.putSourcesItem("snyk", source);
+ providerReport.putRecommendationsItem("hardened", recSource);
+
+ AnalysisReport report = new AnalysisReport();
+ report.putProvidersItem("rhtpa", providerReport);
+
+ assertTrue("Should be available when both vulns and recommendations exist",
+ DockerfileAnnotator.isReportAvailable(report));
+ }
+
+ // ── generateMessage with hardened recommendations tests ─────────────────
+
+ /** Verifies message includes hardened recommendation text. */
+ @Test
+ public void testGenerateMessage_withHardenedRecommendation() {
+ AnalysisReport report = new AnalysisReport();
+ String message = DockerfileAnnotator.generateMessage("nginx:latest", report,
+ null, List.of("quay.io/hardened/nginx"));
+
+ assertTrue("Should contain hardened recommendation",
+ message.contains("Red Hat Hardened Image available: quay.io/hardened/nginx"));
+ }
+
+ /** Verifies message includes both UBI and hardened recommendation text. */
+ @Test
+ public void testGenerateMessage_withBothRecommendations() {
+ AnalysisReport report = new AnalysisReport();
+ String message = DockerfileAnnotator.generateMessage("nginx:latest", report,
+ "ubi9/ubi", List.of("quay.io/hardened/nginx"));
+
+ assertTrue("Should contain UBI recommendation",
+ message.contains("Replace your image with RedHat UBI: ubi9/ubi"));
+ assertTrue("Should contain hardened recommendation",
+ message.contains("Red Hat Hardened Image available: quay.io/hardened/nginx"));
+ }
+
+ /** Verifies message with multiple hardened recommendations lists them comma-separated. */
+ @Test
+ public void testGenerateMessage_withMultipleHardenedRecommendations() {
+ AnalysisReport report = new AnalysisReport();
+ String message = DockerfileAnnotator.generateMessage("nginx:latest", report,
+ null, List.of("quay.io/hardened/nginx", "quay.io/hardened/nginx-alt"));
+
+ assertTrue("Should contain both hardened recommendations",
+ message.contains("quay.io/hardened/nginx, quay.io/hardened/nginx-alt"));
+ }
+
+ /** Verifies message contains only image name when no recommendations exist. */
+ @Test
+ public void testGenerateMessage_withNoRecommendations() {
+ AnalysisReport report = new AnalysisReport();
+ String message = DockerfileAnnotator.generateMessage("nginx:latest", report,
+ null, List.of());
+
+ assertEquals("Should only contain image name", "nginx:latest", message);
+ }
+
+ // ── generateTooltip with hardened recommendations tests ──────────────────
+
+ /** Verifies tooltip includes hardened recommendation HTML. */
+ @Test
+ public void testGenerateTooltip_withHardenedRecommendation() {
+ AnalysisReport report = new AnalysisReport();
+ String tooltip = DockerfileAnnotator.generateTooltip("nginx:latest", report,
+ null, List.of("quay.io/hardened/nginx"));
+
+ assertTrue("Should contain hardened recommendation",
+ tooltip.contains("Red Hat Hardened Image available: quay.io/hardened/nginx"));
+ }
+
+ // ── Helper methods ──────────────────────────────────────────────────────
+
+ private static PackageURL buildOciPurl(String name, String digest, String repositoryUrl)
+ throws MalformedPackageURLException {
+ TreeMap qualifiers = new TreeMap<>();
+ if (repositoryUrl != null && !repositoryUrl.equalsIgnoreCase(name)) {
+ qualifiers.put("repository_url", repositoryUrl.toLowerCase());
+ }
+ return new PackageURL("oci", null, name.toLowerCase(), digest, qualifiers, null);
+ }
+
+ private static AnalysisReport buildReportWithHardenedRecommendation(
+ PackageURL imagePurl, PackageURL hardenedPurl) {
+ RecommendationReport recReport = new RecommendationReport();
+ recReport.setRef(new PackageRef(imagePurl));
+ recReport.setRecommendation(new PackageRef(hardenedPurl));
+
+ RecommendationSource recSource = new RecommendationSource();
+ recSource.addDependenciesItem(recReport);
+
+ ProviderReport providerReport = new ProviderReport();
+ providerReport.putRecommendationsItem("hardened", recSource);
+
+ AnalysisReport report = new AnalysisReport();
+ report.putProvidersItem("rhtpa", providerReport);
+ return report;
+ }
+}