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; + } +}