Skip to content

Commit d38a838

Browse files
committed
feat: add hardened image recommendations to DockerfileAnnotator (TC-4811)
Add support for Red Hat Hardened Image recommendations alongside existing UBI recommendations. Hardened recommendations are read from provider-level recommendations in the analysis report, while UBI recommendations continue to come from source-level dependencies. Key changes: - Add getHardenedRecommendation() to read from ProviderReport.getRecommendations() - Add toImageName() with URL decoding to handle encoded PURLs from the backend - Show blue (INFORMATION) annotations when only recommendations exist (no vulns) - Add HardenedImageIntentionAction for opening Red Hat Hardened Images catalog - Add recommendations toggle in plugin settings - Fix isReportAvailable() to also check provider-level recommendations - Fix NPE when source entry value is null in getRecommendation stream - Add comprehensive tests for recommendation and tooltip generation Implements TC-4811 Assisted-by: Claude Code
1 parent ce32ebc commit d38a838

8 files changed

Lines changed: 639 additions & 19 deletions

File tree

src/main/java/org/jboss/tools/intellij/exhort/ApiService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,11 @@ public static void setCommonRequestProperties() {
314314
} else {
315315
System.setProperty("TRUSTIFY_DA_LICENSE_CHECK", "false");
316316
}
317+
if (settings.recommendationsEnabled) {
318+
System.setProperty("TRUSTIFY_DA_RECOMMENDATIONS_ENABLED", "true");
319+
} else {
320+
System.setProperty("TRUSTIFY_DA_RECOMMENDATIONS_ENABLED", "false");
321+
}
317322

318323
Optional<String> proxyUrlOpt = getProxyUrl();
319324
if (proxyUrlOpt.isPresent()) {

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

Lines changed: 138 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,22 @@
2121
import com.intellij.lang.annotation.HighlightSeverity;
2222
import com.intellij.openapi.application.ApplicationManager;
2323
import com.intellij.openapi.diagnostic.Logger;
24+
import com.intellij.openapi.editor.markup.EffectType;
25+
import com.intellij.openapi.editor.markup.TextAttributes;
2426
import com.intellij.profile.codeInspection.InspectionProjectProfileManager;
2527
import com.intellij.psi.PsiElement;
2628
import com.intellij.psi.PsiFile;
2729
import com.intellij.serviceContainer.AlreadyDisposedException;
30+
import com.intellij.ui.JBColor;
2831
import io.github.guacsec.trustifyda.api.v5.AnalysisReport;
2932
import io.github.guacsec.trustifyda.api.v5.DependencyReport;
3033
import io.github.guacsec.trustifyda.api.v5.ProviderReport;
34+
import io.github.guacsec.trustifyda.api.v5.RecommendationSource;
3135
import io.github.guacsec.trustifyda.api.v5.Severity;
3236
import io.github.guacsec.trustifyda.api.v5.Source;
3337
import io.github.guacsec.trustifyda.image.ImageRef;
3438
import org.jboss.tools.intellij.image.build.filetype.DockerfileFileType;
39+
import org.jboss.tools.intellij.settings.ApiSettingsState;
3540
import org.jetbrains.annotations.NotNull;
3641
import org.jetbrains.annotations.Nullable;
3742

@@ -84,18 +89,32 @@ static boolean isReportAvailable(AnalysisReport report) {
8489
.map(providers -> providers
8590
.values()
8691
.stream()
87-
.map(ProviderReport::getSources)
92+
.anyMatch(provider -> hasVulnerabilities(provider) || hasProviderRecommendations(provider)))
93+
.orElse(false);
94+
}
95+
96+
private static boolean hasVulnerabilities(ProviderReport provider) {
97+
return Optional.ofNullable(provider.getSources())
98+
.map(sources -> sources.values().stream()
8899
.filter(Objects::nonNull)
89-
.map(Map::entrySet)
90-
.flatMap(Collection::stream)
91-
.map(Map.Entry::getValue)
92100
.map(Source::getSummary)
93101
.filter(Objects::nonNull)
94102
.anyMatch(s -> s.getTotal() != null && s.getTotal() > 0))
95103
.orElse(false);
96104
}
97105

98-
static String generateMessage(String image, AnalysisReport report, String recommendation) {
106+
private static boolean hasProviderRecommendations(ProviderReport provider) {
107+
return Optional.ofNullable(provider.getRecommendations())
108+
.map(recs -> recs.values().stream()
109+
.filter(Objects::nonNull)
110+
.map(RecommendationSource::getDependencies)
111+
.filter(Objects::nonNull)
112+
.anyMatch(deps -> !deps.isEmpty()))
113+
.orElse(false);
114+
}
115+
116+
static String generateMessage(String image, AnalysisReport report, String recommendation,
117+
String hardenedRecommendation) {
99118
var messageBuilder = new StringBuilder(image);
100119

101120
Optional.ofNullable(report.getProviders())
@@ -137,11 +156,17 @@ static String generateMessage(String image, AnalysisReport report, String recomm
137156
.append("Replace your image with RedHat UBI: ")
138157
.append(recommendation);
139158
}
159+
if (hardenedRecommendation != null) {
160+
messageBuilder.append(System.lineSeparator())
161+
.append("A Red Hat Hardened Image is available: ")
162+
.append(hardenedRecommendation);
163+
}
140164

141165
return messageBuilder.toString();
142166
}
143167

144-
static String generateTooltip(String image, AnalysisReport report, String recommendation) {
168+
static String generateTooltip(String image, AnalysisReport report, String recommendation,
169+
String hardenedRecommendation) {
145170
var tooltipBuilder = new StringBuilder("<html>").append("<p>").append(image).append("</p>");
146171

147172
Optional.ofNullable(report.getProviders())
@@ -188,6 +213,12 @@ static String generateTooltip(String image, AnalysisReport report, String recomm
188213
.append(recommendation)
189214
.append("</p>");
190215
}
216+
if (hardenedRecommendation != null) {
217+
tooltipBuilder.append("<p/>")
218+
.append("<p>A Red Hat Hardened Image is available: ")
219+
.append(hardenedRecommendation)
220+
.append("</p>");
221+
}
191222

192223
return tooltipBuilder.toString();
193224
}
@@ -210,6 +241,7 @@ static boolean hasIssue(AnalysisReport report) {
210241
.orElse(false);
211242
}
212243

244+
/** Returns the UBI image recommendation (from source-level dependencies), or null if none. */
213245
static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
214246
return Optional.ofNullable(report.getProviders())
215247
.flatMap(provider -> provider.values()
@@ -219,6 +251,7 @@ static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
219251
.filter(Objects::nonNull)
220252
.map(Map::values)
221253
.flatMap(Collection::stream)
254+
.filter(Objects::nonNull)
222255
.map(Source::getDependencies)
223256
.filter(Objects::nonNull)
224257
.flatMap(Collection::stream)
@@ -233,12 +266,85 @@ static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
233266
.map(DependencyReport::getRecommendation)
234267
.filter(Objects::nonNull)
235268
.findAny())
236-
.map(r -> new ImageRef(r.purl()).getImage().getNameWithoutTag())
269+
.map(DockerfileAnnotator::toImageName)
237270
.orElse(null);
238271
}
239272

273+
/** Returns the hardened image recommendation (from provider-level recommendations), or null if none. */
274+
static String getHardenedRecommendation(AnalysisReport report, ImageRef imageRef) {
275+
return Optional.ofNullable(report.getProviders())
276+
.flatMap(provider -> provider.values()
277+
.stream()
278+
.filter(Objects::nonNull)
279+
.map(ProviderReport::getRecommendations)
280+
.filter(Objects::nonNull)
281+
.flatMap(recs -> recs.entrySet().stream())
282+
.filter(entry -> entry.getValue() != null)
283+
.map(entry -> entry.getValue().getDependencies())
284+
.filter(Objects::nonNull)
285+
.flatMap(Collection::stream)
286+
.filter(r -> r.getRef() != null)
287+
.filter(r -> {
288+
try {
289+
return imageRef.getPackageURL().equals(r.getRef().purl());
290+
} catch (MalformedPackageURLException e) {
291+
throw new RuntimeException(e);
292+
}
293+
})
294+
.map(r -> r.getRecommendation())
295+
.filter(Objects::nonNull)
296+
.findAny())
297+
.map(DockerfileAnnotator::toImageName)
298+
.orElse(null);
299+
}
300+
301+
private static String toImageName(io.github.guacsec.trustifyda.api.PackageRef ref) {
302+
try {
303+
var purl = ref.purl();
304+
var qualifiers = purl.getQualifiers();
305+
if (qualifiers != null && qualifiers.containsKey(ImageRef.REPOSITORY_QUALIFIER)) {
306+
String repoUrl = qualifiers.get(ImageRef.REPOSITORY_QUALIFIER);
307+
String decoded = fullyDecode(repoUrl);
308+
if (!decoded.equals(repoUrl)) {
309+
var decodedQualifiers = new java.util.TreeMap<>(qualifiers);
310+
decodedQualifiers.put(ImageRef.REPOSITORY_QUALIFIER, decoded);
311+
purl = new com.github.packageurl.PackageURL(
312+
purl.getType(), purl.getNamespace(), purl.getName(),
313+
purl.getVersion(), decodedQualifiers, purl.getSubpath());
314+
}
315+
}
316+
return new ImageRef(purl).getImage().getNameWithoutTag();
317+
} catch (IllegalArgumentException | com.github.packageurl.MalformedPackageURLException e) {
318+
LOG.warn("Failed to parse recommendation image from PURL: " + ref.ref(), e);
319+
return null;
320+
}
321+
}
322+
323+
/** Repeatedly URL-decodes until the value stabilizes (handles double/triple encoding). */
324+
private static String fullyDecode(String value) {
325+
String previous = value;
326+
for (int i = 0; i < 5; i++) {
327+
String decoded = java.net.URLDecoder.decode(previous, java.nio.charset.StandardCharsets.UTF_8);
328+
if (decoded.equals(previous)) {
329+
break;
330+
}
331+
previous = decoded;
332+
}
333+
return previous;
334+
}
335+
240336
@NotNull
241-
private static HighlightSeverity getHighlightSeverity(AnalysisReport report, String recommendation, boolean hasIssue, @NotNull PsiElement context) {
337+
private static HighlightSeverity getHighlightSeverity(AnalysisReport report, String recommendation,
338+
String hardenedRecommendation, boolean hasIssue,
339+
@NotNull PsiElement context) {
340+
// Recommendation-only (no vulnerabilities): use INFORMATION severity (blue)
341+
if (!hasIssue && !hasIssue(report)) {
342+
boolean hasAnyRecommendation = recommendation != null || hardenedRecommendation != null;
343+
if (hasAnyRecommendation) {
344+
return HighlightSeverity.INFORMATION;
345+
}
346+
}
347+
242348
// Get the configured severity from the inspection settings
243349
final InspectionProfileEntry inspection = getInspection(context);
244350
if (inspection != null) {
@@ -335,20 +441,37 @@ public void apply(@NotNull PsiFile file, Map<BaseImage, Result> annotationResult
335441
&& elements != null && !elements.isEmpty()) {
336442
if (isReportAvailable(report)) {
337443
var hasIssue = hasIssue(report);
338-
var recommendation = getRecommendation(report, value.getImageRef());
444+
boolean recommendationsEnabled = ApiSettingsState.getInstance().recommendationsEnabled;
445+
var recommendation = recommendationsEnabled
446+
? getRecommendation(report, value.getImageRef()) : null;
447+
var hardenedRecommendation = recommendationsEnabled
448+
? getHardenedRecommendation(report, value.getImageRef()) : null;
339449

340-
var message = generateMessage(key.getImageName(), report, recommendation);
341-
var tooltip = generateTooltip(key.getImageName(), report, recommendation);
450+
var message = generateMessage(key.getImageName(), report,
451+
recommendation, hardenedRecommendation);
452+
var tooltip = generateTooltip(key.getImageName(), report,
453+
recommendation, hardenedRecommendation);
342454

343455
elements.forEach(e -> {
344-
var severity = getHighlightSeverity(report, recommendation, hasIssue, e);
456+
var severity = getHighlightSeverity(report, recommendation, hardenedRecommendation, hasIssue, e);
345457
if (e != null) {
346458
var builder = holder
347459
.newAnnotation(severity, message)
348460
.tooltip(tooltip)
349-
.range(e)
350-
.withFix(new ImageReportIntentionAction())
351-
.withFix(new UBIIntentionAction());
461+
.range(e);
462+
if (severity == HighlightSeverity.INFORMATION) {
463+
var attrs = new TextAttributes();
464+
attrs.setEffectType(EffectType.WAVE_UNDERSCORE);
465+
attrs.setEffectColor(JBColor.BLUE);
466+
builder = builder.enforcedTextAttributes(attrs);
467+
}
468+
builder = builder.withFix(new ImageReportIntentionAction());
469+
if (recommendation != null) {
470+
builder.withFix(new UBIIntentionAction());
471+
}
472+
if (hardenedRecommendation != null) {
473+
builder.withFix(new HardenedImageIntentionAction());
474+
}
352475
builder.create();
353476
}
354477
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
12+
package org.jboss.tools.intellij.image;
13+
14+
import com.intellij.codeInsight.intention.IntentionAction;
15+
import com.intellij.codeInspection.util.IntentionFamilyName;
16+
import com.intellij.codeInspection.util.IntentionName;
17+
import org.jboss.tools.intellij.image.build.filetype.DockerfileFileType;
18+
import com.intellij.ide.BrowserUtil;
19+
import com.intellij.openapi.editor.Editor;
20+
import com.intellij.openapi.project.Project;
21+
import com.intellij.psi.PsiFile;
22+
import com.intellij.util.IncorrectOperationException;
23+
import org.jetbrains.annotations.NotNull;
24+
25+
import java.net.URI;
26+
27+
/** Intention action that directs users to the Red Hat Hardened Container Images catalog. */
28+
public class HardenedImageIntentionAction implements IntentionAction {
29+
30+
public static final String HARDENED_IMAGE_LINK = "https://catalog.redhat.com/software/containers/search?gs&q=hardened";
31+
32+
@Override
33+
public @IntentionName @NotNull String getText() {
34+
return "Switch to a Red Hat Hardened Image for enhanced security";
35+
}
36+
37+
@Override
38+
public @NotNull @IntentionFamilyName String getFamilyName() {
39+
return "RHDA";
40+
}
41+
42+
@Override
43+
public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile psiFile) {
44+
return DockerfileFileType.isDockerfile(psiFile);
45+
}
46+
47+
@Override
48+
public void invoke(@NotNull Project project, Editor editor, PsiFile psiFile) throws IncorrectOperationException {
49+
BrowserUtil.browse(URI.create(HARDENED_IMAGE_LINK));
50+
}
51+
52+
@Override
53+
public boolean startInWriteAction() {
54+
return false;
55+
}
56+
}

src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ public class ApiSettingsComponent {
9494
+ "<br>Leave empty to use temporary files only.</html>";
9595
private final static String licenseCheckEnabledLabel = "<html>Component Analysis > License Check"
9696
+ "<br>Enables license compatibility checking and notifications for incompatible dependencies.</html>";
97+
private final static String recommendationsEnabledLabel = "<html>Component Analysis > Recommendations"
98+
+ "<br>Enables package version and image recommendations from Red Hat.</html>";
9799

98100
private final JPanel mainPanel;
99101

@@ -131,6 +133,7 @@ public class ApiSettingsComponent {
131133
private final JBScrollPane manifestExclusionPatternsScrollPane;
132134
private final TextFieldWithBrowseButton reportFilePathText;
133135
private final JBCheckBox licenseCheckEnabledCheck;
136+
private final JBCheckBox recommendationsEnabledCheck;
134137

135138

136139
public ApiSettingsComponent() {
@@ -279,6 +282,7 @@ public ApiSettingsComponent() {
279282
batchMetadataCheck = new JBCheckBox("Include metadata in batch results");
280283

281284
licenseCheckEnabledCheck = new JBCheckBox("Enable license compatibility checking");
285+
recommendationsEnabledCheck = new JBCheckBox("Enable package and image recommendations");
282286

283287
manifestExclusionPatternsText = new JBTextArea();
284288
manifestExclusionPatternsText.setRows(5);
@@ -357,6 +361,8 @@ public ApiSettingsComponent() {
357361
.addSeparator(10)
358362
.addVerticalGap(10)
359363
.addLabeledComponent(new JBLabel(licenseCheckEnabledLabel), licenseCheckEnabledCheck, 1, true)
364+
.addVerticalGap(10)
365+
.addLabeledComponent(new JBLabel(recommendationsEnabledLabel), recommendationsEnabledCheck, 1, true)
360366
.addSeparator(10)
361367
.addVerticalGap(10)
362368
.addLabeledComponent(new JBLabel(manifestExclusionPatternsLabel), manifestExclusionPatternsScrollPane, 1, true)
@@ -643,4 +649,12 @@ public boolean getLicenseCheckEnabledCheck() {
643649
public void setLicenseCheckEnabledCheck(boolean selected) {
644650
licenseCheckEnabledCheck.setSelected(selected);
645651
}
652+
653+
public boolean getRecommendationsEnabledCheck() {
654+
return recommendationsEnabledCheck.isSelected();
655+
}
656+
657+
public void setRecommendationsEnabledCheck(boolean selected) {
658+
recommendationsEnabledCheck.setSelected(selected);
659+
}
646660
}

0 commit comments

Comments
 (0)