Skip to content

Commit e954ce9

Browse files
a-orenclaude
andauthored
fix: deduplicate hardened image recommendations in DockerfileAnnotator (TC-4934) (#263)
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 <noreply@anthropic.com>
1 parent 1db7556 commit e954ce9

4 files changed

Lines changed: 449 additions & 62 deletions

File tree

src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ private static boolean hasProviderRecommendations(ProviderReport provider) {
118118
}
119119

120120
static String generateMessage(String image, AnalysisReport report, String recommendation,
121-
String hardenedRecommendation) {
121+
List<String> hardenedRecommendations) {
122122
var messageBuilder = new StringBuilder(image);
123123

124124
Optional.ofNullable(report.getProviders())
@@ -160,17 +160,17 @@ static String generateMessage(String image, AnalysisReport report, String recomm
160160
.append("Replace your image with RedHat UBI: ")
161161
.append(recommendation);
162162
}
163-
if (hardenedRecommendation != null) {
163+
if (hardenedRecommendations != null && !hardenedRecommendations.isEmpty()) {
164164
messageBuilder.append(System.lineSeparator())
165-
.append("A Red Hat Hardened Image is available: ")
166-
.append(hardenedRecommendation);
165+
.append("Red Hat Hardened Image available: ")
166+
.append(String.join(", ", hardenedRecommendations));
167167
}
168168

169169
return messageBuilder.toString();
170170
}
171171

172172
static String generateTooltip(String image, AnalysisReport report, String recommendation,
173-
String hardenedRecommendation) {
173+
List<String> hardenedRecommendations) {
174174
var tooltipBuilder = new StringBuilder("<html>").append("<p>").append(image).append("</p>");
175175

176176
Optional.ofNullable(report.getProviders())
@@ -217,10 +217,10 @@ static String generateTooltip(String image, AnalysisReport report, String recomm
217217
.append(recommendation)
218218
.append("</p>");
219219
}
220-
if (hardenedRecommendation != null) {
220+
if (hardenedRecommendations != null && !hardenedRecommendations.isEmpty()) {
221221
tooltipBuilder.append("<p/>")
222-
.append("<p>A Red Hat Hardened Image is available: ")
223-
.append(hardenedRecommendation)
222+
.append("<p>Red Hat Hardened Image available: ")
223+
.append(String.join(", ", hardenedRecommendations))
224224
.append("</p>");
225225
}
226226

@@ -262,9 +262,13 @@ static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
262262
return findMatchingRecommendation(deps, imageRef, DependencyReport::getRef, DependencyReport::getRecommendation);
263263
}
264264

265-
/** Returns the hardened image recommendation (from provider-level recommendations), or null if none. */
266-
static String getHardenedRecommendation(AnalysisReport report, ImageRef imageRef) {
267-
var deps = Optional.ofNullable(report.getProviders())
265+
/**
266+
* Returns hardened image references from provider-level recommendations matching the given image.
267+
* Each entry is a displayable image reference suitable for Dockerfile FROM line replacement.
268+
* Supports the 1→N case where multiple hardened alternatives exist for a single image.
269+
*/
270+
static List<String> getHardenedRecommendations(AnalysisReport report, ImageRef imageRef) {
271+
return Optional.ofNullable(report.getProviders())
268272
.stream()
269273
.flatMap(provider -> provider.values().stream())
270274
.filter(Objects::nonNull)
@@ -274,10 +278,22 @@ static String getHardenedRecommendation(AnalysisReport report, ImageRef imageRef
274278
.filter(entry -> entry.getValue() != null)
275279
.map(entry -> entry.getValue().getDependencies())
276280
.filter(Objects::nonNull)
277-
.flatMap(Collection::stream);
278-
return findMatchingRecommendation(deps, imageRef,
279-
io.github.guacsec.trustifyda.api.v5.RecommendationReport::getRef,
280-
io.github.guacsec.trustifyda.api.v5.RecommendationReport::getRecommendation);
281+
.flatMap(Collection::stream)
282+
.filter(r -> r.getRef() != null)
283+
.filter(r -> {
284+
try {
285+
return imageRef.getPackageURL().equals(r.getRef().purl());
286+
} catch (MalformedPackageURLException e) {
287+
LOG.warn("Skipping recommendation with malformed PURL", e);
288+
return false;
289+
}
290+
})
291+
.map(io.github.guacsec.trustifyda.api.v5.RecommendationReport::getRecommendation)
292+
.filter(Objects::nonNull)
293+
.map(DockerfileAnnotator::toImageName)
294+
.filter(Objects::nonNull)
295+
.distinct()
296+
.collect(Collectors.toList());
281297
}
282298

283299
private static <T> String findMatchingRecommendation(Stream<T> items, ImageRef imageRef,
@@ -337,11 +353,12 @@ private static String fullyDecode(String value) {
337353

338354
@NotNull
339355
private static HighlightSeverity getHighlightSeverity(AnalysisReport report, String recommendation,
340-
String hardenedRecommendation, boolean hasIssue,
356+
List<String> hardenedRecommendations, boolean hasIssue,
341357
@NotNull PsiElement context) {
342358
// Recommendation-only (no vulnerabilities): use INFORMATION severity (blue)
343359
if (!hasIssue) {
344-
boolean hasAnyRecommendation = recommendation != null || hardenedRecommendation != null;
360+
boolean hasAnyRecommendation = recommendation != null
361+
|| (hardenedRecommendations != null && !hardenedRecommendations.isEmpty());
345362
if (hasAnyRecommendation) {
346363
return HighlightSeverity.INFORMATION;
347364
}
@@ -446,16 +463,17 @@ public void apply(@NotNull PsiFile file, Map<BaseImage, Result> annotationResult
446463
boolean recommendationsEnabled = ApiSettingsState.getInstance().recommendationsEnabled;
447464
var recommendation = recommendationsEnabled
448465
? getRecommendation(report, value.getImageRef()) : null;
449-
var hardenedRecommendation = recommendationsEnabled
450-
? getHardenedRecommendation(report, value.getImageRef()) : null;
466+
var hardenedRecommendations = recommendationsEnabled
467+
? getHardenedRecommendations(report, value.getImageRef()) : List.<String>of();
451468

452469
var message = generateMessage(key.getImageName(), report,
453-
recommendation, hardenedRecommendation);
470+
recommendation, hardenedRecommendations);
454471
var tooltip = generateTooltip(key.getImageName(), report,
455-
recommendation, hardenedRecommendation);
472+
recommendation, hardenedRecommendations);
456473

457474
elements.forEach(e -> {
458-
var severity = getHighlightSeverity(report, recommendation, hardenedRecommendation, hasIssue, e);
475+
var severity = getHighlightSeverity(report, recommendation,
476+
hardenedRecommendations, hasIssue, e);
459477
if (e != null) {
460478
var builder = holder
461479
.newAnnotation(severity, message)
@@ -469,8 +487,9 @@ public void apply(@NotNull PsiFile file, Map<BaseImage, Result> annotationResult
469487
}
470488
builder = builder.withFix(new ImageReportIntentionAction());
471489
builder = builder.withFix(new UBIIntentionAction());
472-
if (hardenedRecommendation != null) {
473-
builder = builder.withFix(new HardenedImageIntentionAction());
490+
for (String hardenedImage : hardenedRecommendations) {
491+
builder = builder.withFix(
492+
new HardenedImageIntentionAction(hardenedImage));
474493
}
475494
builder.create();
476495
}

src/main/java/org/jboss/tools/intellij/image/HardenedImageIntentionAction.java

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,34 @@
1414
import com.intellij.codeInsight.intention.IntentionAction;
1515
import com.intellij.codeInspection.util.IntentionFamilyName;
1616
import com.intellij.codeInspection.util.IntentionName;
17-
import org.jboss.tools.intellij.image.build.filetype.DockerfileFileType;
18-
import com.intellij.ide.BrowserUtil;
17+
import com.intellij.openapi.editor.Document;
1918
import com.intellij.openapi.editor.Editor;
2019
import com.intellij.openapi.project.Project;
20+
import com.intellij.openapi.util.TextRange;
21+
import com.intellij.psi.PsiElement;
2122
import com.intellij.psi.PsiFile;
23+
import com.intellij.psi.util.PsiTreeUtil;
2224
import com.intellij.util.IncorrectOperationException;
25+
import org.jboss.tools.intellij.image.build.filetype.DockerfileFileType;
26+
import org.jboss.tools.intellij.image.build.psi.DockerfileFromInstruction;
27+
import org.jboss.tools.intellij.image.build.psi.DockerfileImageName;
2328
import org.jetbrains.annotations.NotNull;
2429

25-
import java.net.URI;
26-
27-
/** Intention action that directs users to the Red Hat Hardened Container Images catalog. */
30+
/**
31+
* Intention action that replaces a Dockerfile FROM line's image reference
32+
* with a recommended Red Hat Hardened Image.
33+
*/
2834
public class HardenedImageIntentionAction implements IntentionAction {
2935

30-
public static final String HARDENED_IMAGE_LINK = "https://catalog.redhat.com/software/containers/search?gs&q=hardened";
36+
private final String imageReference;
37+
38+
public HardenedImageIntentionAction(String imageReference) {
39+
this.imageReference = imageReference;
40+
}
3141

3242
@Override
3343
public @IntentionName @NotNull String getText() {
34-
return "Switch to a Red Hat Hardened Image for enhanced security";
44+
return "Replace with Red Hat Hardened Image: " + imageReference;
3545
}
3646

3747
@Override
@@ -46,11 +56,22 @@ public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile psiF
4656

4757
@Override
4858
public void invoke(@NotNull Project project, Editor editor, PsiFile psiFile) throws IncorrectOperationException {
49-
BrowserUtil.browse(URI.create(HARDENED_IMAGE_LINK));
59+
int offset = editor.getCaretModel().getOffset();
60+
PsiElement element = psiFile.findElementAt(offset);
61+
DockerfileFromInstruction fromInstruction =
62+
PsiTreeUtil.getParentOfType(element, DockerfileFromInstruction.class, false);
63+
if (fromInstruction != null) {
64+
DockerfileImageName imageName = fromInstruction.getImageName();
65+
if (imageName != null) {
66+
Document document = editor.getDocument();
67+
TextRange range = imageName.getTextRange();
68+
document.replaceString(range.getStartOffset(), range.getEndOffset(), imageReference);
69+
}
70+
}
5071
}
5172

5273
@Override
5374
public boolean startInWriteAction() {
54-
return false;
75+
return true;
5576
}
5677
}

0 commit comments

Comments
 (0)