Skip to content

Commit 1db7556

Browse files
a-orenclaude
andauthored
feat: add hardened image recommendations to DockerfileAnnotator (TC-4811) (#261)
* 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 * fix: address PR review feedback for DockerfileAnnotator (TC-4811) - Catch MalformedPackageURLException in PURL filters instead of re-throwing - Restore UBIIntentionAction as unconditional quick-fix (regression fix) - Fix inconsistent withFix() builder pattern (missing reassignment) - Remove redundant hasIssue(report) guard condition - Extract shared PURL matching logic into generic findMatchingRecommendation() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: align recommendation property name with Java client (TC-4811) Use TRUSTIFY_DA_RECOMMEND to match what trustify-da-java-client reads, instead of the incorrect TRUSTIFY_DA_RECOMMENDATIONS_ENABLED. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ce32ebc commit 1db7556

8 files changed

Lines changed: 661 additions & 41 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_RECOMMEND", "true");
319+
} else {
320+
System.setProperty("TRUSTIFY_DA_RECOMMEND", "false");
321+
}
317322

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

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

Lines changed: 160 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,36 @@
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

43+
import io.github.guacsec.trustifyda.api.PackageRef;
44+
3845
import java.util.Collection;
3946
import java.util.HashSet;
4047
import java.util.List;
4148
import java.util.Map;
4249
import java.util.Objects;
4350
import java.util.Optional;
51+
import java.util.function.Function;
4452
import java.util.stream.Collectors;
53+
import java.util.stream.Stream;
4554

4655
public class DockerfileAnnotator extends ExternalAnnotator<DockerfileAnnotator.Info, Map<BaseImage, DockerfileAnnotator.Result>> {
4756

@@ -84,18 +93,32 @@ static boolean isReportAvailable(AnalysisReport report) {
8493
.map(providers -> providers
8594
.values()
8695
.stream()
87-
.map(ProviderReport::getSources)
96+
.anyMatch(provider -> hasVulnerabilities(provider) || hasProviderRecommendations(provider)))
97+
.orElse(false);
98+
}
99+
100+
private static boolean hasVulnerabilities(ProviderReport provider) {
101+
return Optional.ofNullable(provider.getSources())
102+
.map(sources -> sources.values().stream()
88103
.filter(Objects::nonNull)
89-
.map(Map::entrySet)
90-
.flatMap(Collection::stream)
91-
.map(Map.Entry::getValue)
92104
.map(Source::getSummary)
93105
.filter(Objects::nonNull)
94106
.anyMatch(s -> s.getTotal() != null && s.getTotal() > 0))
95107
.orElse(false);
96108
}
97109

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

101124
Optional.ofNullable(report.getProviders())
@@ -137,11 +160,17 @@ static String generateMessage(String image, AnalysisReport report, String recomm
137160
.append("Replace your image with RedHat UBI: ")
138161
.append(recommendation);
139162
}
163+
if (hardenedRecommendation != null) {
164+
messageBuilder.append(System.lineSeparator())
165+
.append("A Red Hat Hardened Image is available: ")
166+
.append(hardenedRecommendation);
167+
}
140168

141169
return messageBuilder.toString();
142170
}
143171

144-
static String generateTooltip(String image, AnalysisReport report, String recommendation) {
172+
static String generateTooltip(String image, AnalysisReport report, String recommendation,
173+
String hardenedRecommendation) {
145174
var tooltipBuilder = new StringBuilder("<html>").append("<p>").append(image).append("</p>");
146175

147176
Optional.ofNullable(report.getProviders())
@@ -188,6 +217,12 @@ static String generateTooltip(String image, AnalysisReport report, String recomm
188217
.append(recommendation)
189218
.append("</p>");
190219
}
220+
if (hardenedRecommendation != null) {
221+
tooltipBuilder.append("<p/>")
222+
.append("<p>A Red Hat Hardened Image is available: ")
223+
.append(hardenedRecommendation)
224+
.append("</p>");
225+
}
191226

192227
return tooltipBuilder.toString();
193228
}
@@ -210,35 +245,108 @@ static boolean hasIssue(AnalysisReport report) {
210245
.orElse(false);
211246
}
212247

248+
/** Returns the UBI image recommendation (from source-level dependencies), or null if none. */
213249
static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
214-
return Optional.ofNullable(report.getProviders())
215-
.flatMap(provider -> provider.values()
216-
.stream()
217-
.filter(Objects::nonNull)
218-
.map(ProviderReport::getSources)
219-
.filter(Objects::nonNull)
220-
.map(Map::values)
221-
.flatMap(Collection::stream)
222-
.map(Source::getDependencies)
223-
.filter(Objects::nonNull)
224-
.flatMap(Collection::stream)
225-
.filter(r -> r.getRef() != null)
226-
.filter(r -> {
227-
try {
228-
return imageRef.getPackageURL().equals(r.getRef().purl());
229-
} catch (MalformedPackageURLException e) {
230-
throw new RuntimeException(e);
231-
}
232-
})
233-
.map(DependencyReport::getRecommendation)
234-
.filter(Objects::nonNull)
235-
.findAny())
236-
.map(r -> new ImageRef(r.purl()).getImage().getNameWithoutTag())
250+
var deps = Optional.ofNullable(report.getProviders())
251+
.stream()
252+
.flatMap(provider -> provider.values().stream())
253+
.filter(Objects::nonNull)
254+
.map(ProviderReport::getSources)
255+
.filter(Objects::nonNull)
256+
.map(Map::values)
257+
.flatMap(Collection::stream)
258+
.filter(Objects::nonNull)
259+
.map(Source::getDependencies)
260+
.filter(Objects::nonNull)
261+
.flatMap(Collection::stream);
262+
return findMatchingRecommendation(deps, imageRef, DependencyReport::getRef, DependencyReport::getRecommendation);
263+
}
264+
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())
268+
.stream()
269+
.flatMap(provider -> provider.values().stream())
270+
.filter(Objects::nonNull)
271+
.map(ProviderReport::getRecommendations)
272+
.filter(Objects::nonNull)
273+
.flatMap(recs -> recs.entrySet().stream())
274+
.filter(entry -> entry.getValue() != null)
275+
.map(entry -> entry.getValue().getDependencies())
276+
.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+
}
282+
283+
private static <T> String findMatchingRecommendation(Stream<T> items, ImageRef imageRef,
284+
Function<T, PackageRef> refExtractor,
285+
Function<T, PackageRef> recommendationExtractor) {
286+
return items
287+
.filter(r -> refExtractor.apply(r) != null)
288+
.filter(r -> {
289+
try {
290+
return imageRef.getPackageURL().equals(refExtractor.apply(r).purl());
291+
} catch (MalformedPackageURLException e) {
292+
LOG.warn("Skipping recommendation with malformed PURL", e);
293+
return false;
294+
}
295+
})
296+
.map(recommendationExtractor)
297+
.filter(Objects::nonNull)
298+
.findAny()
299+
.map(DockerfileAnnotator::toImageName)
237300
.orElse(null);
238301
}
239302

303+
private static String toImageName(io.github.guacsec.trustifyda.api.PackageRef ref) {
304+
try {
305+
var purl = ref.purl();
306+
var qualifiers = purl.getQualifiers();
307+
if (qualifiers != null && qualifiers.containsKey(ImageRef.REPOSITORY_QUALIFIER)) {
308+
String repoUrl = qualifiers.get(ImageRef.REPOSITORY_QUALIFIER);
309+
String decoded = fullyDecode(repoUrl);
310+
if (!decoded.equals(repoUrl)) {
311+
var decodedQualifiers = new java.util.TreeMap<>(qualifiers);
312+
decodedQualifiers.put(ImageRef.REPOSITORY_QUALIFIER, decoded);
313+
purl = new com.github.packageurl.PackageURL(
314+
purl.getType(), purl.getNamespace(), purl.getName(),
315+
purl.getVersion(), decodedQualifiers, purl.getSubpath());
316+
}
317+
}
318+
return new ImageRef(purl).getImage().getNameWithoutTag();
319+
} catch (IllegalArgumentException | com.github.packageurl.MalformedPackageURLException e) {
320+
LOG.warn("Failed to parse recommendation image from PURL: " + ref.ref(), e);
321+
return null;
322+
}
323+
}
324+
325+
/** Repeatedly URL-decodes until the value stabilizes (handles double/triple encoding). */
326+
private static String fullyDecode(String value) {
327+
String previous = value;
328+
for (int i = 0; i < 5; i++) {
329+
String decoded = java.net.URLDecoder.decode(previous, java.nio.charset.StandardCharsets.UTF_8);
330+
if (decoded.equals(previous)) {
331+
break;
332+
}
333+
previous = decoded;
334+
}
335+
return previous;
336+
}
337+
240338
@NotNull
241-
private static HighlightSeverity getHighlightSeverity(AnalysisReport report, String recommendation, boolean hasIssue, @NotNull PsiElement context) {
339+
private static HighlightSeverity getHighlightSeverity(AnalysisReport report, String recommendation,
340+
String hardenedRecommendation, boolean hasIssue,
341+
@NotNull PsiElement context) {
342+
// Recommendation-only (no vulnerabilities): use INFORMATION severity (blue)
343+
if (!hasIssue) {
344+
boolean hasAnyRecommendation = recommendation != null || hardenedRecommendation != null;
345+
if (hasAnyRecommendation) {
346+
return HighlightSeverity.INFORMATION;
347+
}
348+
}
349+
242350
// Get the configured severity from the inspection settings
243351
final InspectionProfileEntry inspection = getInspection(context);
244352
if (inspection != null) {
@@ -335,20 +443,35 @@ public void apply(@NotNull PsiFile file, Map<BaseImage, Result> annotationResult
335443
&& elements != null && !elements.isEmpty()) {
336444
if (isReportAvailable(report)) {
337445
var hasIssue = hasIssue(report);
338-
var recommendation = getRecommendation(report, value.getImageRef());
446+
boolean recommendationsEnabled = ApiSettingsState.getInstance().recommendationsEnabled;
447+
var recommendation = recommendationsEnabled
448+
? getRecommendation(report, value.getImageRef()) : null;
449+
var hardenedRecommendation = recommendationsEnabled
450+
? getHardenedRecommendation(report, value.getImageRef()) : null;
339451

340-
var message = generateMessage(key.getImageName(), report, recommendation);
341-
var tooltip = generateTooltip(key.getImageName(), report, recommendation);
452+
var message = generateMessage(key.getImageName(), report,
453+
recommendation, hardenedRecommendation);
454+
var tooltip = generateTooltip(key.getImageName(), report,
455+
recommendation, hardenedRecommendation);
342456

343457
elements.forEach(e -> {
344-
var severity = getHighlightSeverity(report, recommendation, hasIssue, e);
458+
var severity = getHighlightSeverity(report, recommendation, hardenedRecommendation, hasIssue, e);
345459
if (e != null) {
346460
var builder = holder
347461
.newAnnotation(severity, message)
348462
.tooltip(tooltip)
349-
.range(e)
350-
.withFix(new ImageReportIntentionAction())
351-
.withFix(new UBIIntentionAction());
463+
.range(e);
464+
if (severity == HighlightSeverity.INFORMATION) {
465+
var attrs = new TextAttributes();
466+
attrs.setEffectType(EffectType.WAVE_UNDERSCORE);
467+
attrs.setEffectColor(JBColor.BLUE);
468+
builder = builder.enforcedTextAttributes(attrs);
469+
}
470+
builder = builder.withFix(new ImageReportIntentionAction());
471+
builder = builder.withFix(new UBIIntentionAction());
472+
if (hardenedRecommendation != null) {
473+
builder = 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)