From d38a8381a6c99064603ca670f155cdd349a87bc0 Mon Sep 17 00:00:00 2001
From: Adva Oren
Date: Tue, 23 Jun 2026 16:13:10 +0300
Subject: [PATCH 1/3] 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
---
.../tools/intellij/exhort/ApiService.java | 5 +
.../intellij/image/DockerfileAnnotator.java | 153 +++++++-
.../image/HardenedImageIntentionAction.java | 56 +++
.../settings/ApiSettingsComponent.java | 14 +
.../settings/ApiSettingsConfigurable.java | 12 +-
.../intellij/settings/ApiSettingsState.java | 2 +
...DockerfileAnnotatorRecommendationTest.java | 365 ++++++++++++++++++
.../settings/ApiSettingsStateTest.java | 51 +++
8 files changed, 639 insertions(+), 19 deletions(-)
create mode 100644 src/main/java/org/jboss/tools/intellij/image/HardenedImageIntentionAction.java
create mode 100644 src/test/java/org/jboss/tools/intellij/image/DockerfileAnnotatorRecommendationTest.java
create mode 100644 src/test/java/org/jboss/tools/intellij/settings/ApiSettingsStateTest.java
diff --git a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
index a0e47f6..2eeb044 100644
--- a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
+++ b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
@@ -314,6 +314,11 @@ public static void setCommonRequestProperties() {
} else {
System.setProperty("TRUSTIFY_DA_LICENSE_CHECK", "false");
}
+ if (settings.recommendationsEnabled) {
+ System.setProperty("TRUSTIFY_DA_RECOMMENDATIONS_ENABLED", "true");
+ } else {
+ System.setProperty("TRUSTIFY_DA_RECOMMENDATIONS_ENABLED", "false");
+ }
Optional proxyUrlOpt = getProxyUrl();
if (proxyUrlOpt.isPresent()) {
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 0910f87..b3e0e3f 100644
--- a/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java
+++ b/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java
@@ -21,17 +21,22 @@
import com.intellij.lang.annotation.HighlightSeverity;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
+import com.intellij.openapi.editor.markup.EffectType;
+import com.intellij.openapi.editor.markup.TextAttributes;
import com.intellij.profile.codeInspection.InspectionProjectProfileManager;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiFile;
import com.intellij.serviceContainer.AlreadyDisposedException;
+import com.intellij.ui.JBColor;
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.RecommendationSource;
import io.github.guacsec.trustifyda.api.v5.Severity;
import io.github.guacsec.trustifyda.api.v5.Source;
import io.github.guacsec.trustifyda.image.ImageRef;
import org.jboss.tools.intellij.image.build.filetype.DockerfileFileType;
+import org.jboss.tools.intellij.settings.ApiSettingsState;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -84,18 +89,32 @@ static boolean isReportAvailable(AnalysisReport report) {
.map(providers -> providers
.values()
.stream()
- .map(ProviderReport::getSources)
+ .anyMatch(provider -> hasVulnerabilities(provider) || hasProviderRecommendations(provider)))
+ .orElse(false);
+ }
+
+ private static boolean hasVulnerabilities(ProviderReport provider) {
+ return Optional.ofNullable(provider.getSources())
+ .map(sources -> sources.values().stream()
.filter(Objects::nonNull)
- .map(Map::entrySet)
- .flatMap(Collection::stream)
- .map(Map.Entry::getValue)
.map(Source::getSummary)
.filter(Objects::nonNull)
.anyMatch(s -> s.getTotal() != null && s.getTotal() > 0))
.orElse(false);
}
- static String generateMessage(String image, AnalysisReport report, String recommendation) {
+ private static boolean hasProviderRecommendations(ProviderReport provider) {
+ return Optional.ofNullable(provider.getRecommendations())
+ .map(recs -> recs.values().stream()
+ .filter(Objects::nonNull)
+ .map(RecommendationSource::getDependencies)
+ .filter(Objects::nonNull)
+ .anyMatch(deps -> !deps.isEmpty()))
+ .orElse(false);
+ }
+
+ static String generateMessage(String image, AnalysisReport report, String recommendation,
+ String hardenedRecommendation) {
var messageBuilder = new StringBuilder(image);
Optional.ofNullable(report.getProviders())
@@ -137,11 +156,17 @@ static String generateMessage(String image, AnalysisReport report, String recomm
.append("Replace your image with RedHat UBI: ")
.append(recommendation);
}
+ if (hardenedRecommendation != null) {
+ messageBuilder.append(System.lineSeparator())
+ .append("A Red Hat Hardened Image is available: ")
+ .append(hardenedRecommendation);
+ }
return messageBuilder.toString();
}
- static String generateTooltip(String image, AnalysisReport report, String recommendation) {
+ static String generateTooltip(String image, AnalysisReport report, String recommendation,
+ String hardenedRecommendation) {
var tooltipBuilder = new StringBuilder("").append("").append(image).append("
");
Optional.ofNullable(report.getProviders())
@@ -188,6 +213,12 @@ static String generateTooltip(String image, AnalysisReport report, String recomm
.append(recommendation)
.append("
");
}
+ if (hardenedRecommendation != null) {
+ tooltipBuilder.append("")
+ .append("A Red Hat Hardened Image is available: ")
+ .append(hardenedRecommendation)
+ .append("
");
+ }
return tooltipBuilder.toString();
}
@@ -210,6 +241,7 @@ static boolean hasIssue(AnalysisReport report) {
.orElse(false);
}
+ /** Returns the UBI image recommendation (from source-level dependencies), or null if none. */
static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
return Optional.ofNullable(report.getProviders())
.flatMap(provider -> provider.values()
@@ -219,6 +251,7 @@ static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
.filter(Objects::nonNull)
.map(Map::values)
.flatMap(Collection::stream)
+ .filter(Objects::nonNull)
.map(Source::getDependencies)
.filter(Objects::nonNull)
.flatMap(Collection::stream)
@@ -233,12 +266,85 @@ static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
.map(DependencyReport::getRecommendation)
.filter(Objects::nonNull)
.findAny())
- .map(r -> new ImageRef(r.purl()).getImage().getNameWithoutTag())
+ .map(DockerfileAnnotator::toImageName)
.orElse(null);
}
+ /** Returns the hardened image recommendation (from provider-level recommendations), or null if none. */
+ static String getHardenedRecommendation(AnalysisReport report, ImageRef imageRef) {
+ return Optional.ofNullable(report.getProviders())
+ .flatMap(provider -> provider.values()
+ .stream()
+ .filter(Objects::nonNull)
+ .map(ProviderReport::getRecommendations)
+ .filter(Objects::nonNull)
+ .flatMap(recs -> recs.entrySet().stream())
+ .filter(entry -> entry.getValue() != null)
+ .map(entry -> entry.getValue().getDependencies())
+ .filter(Objects::nonNull)
+ .flatMap(Collection::stream)
+ .filter(r -> r.getRef() != null)
+ .filter(r -> {
+ try {
+ return imageRef.getPackageURL().equals(r.getRef().purl());
+ } catch (MalformedPackageURLException e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .map(r -> r.getRecommendation())
+ .filter(Objects::nonNull)
+ .findAny())
+ .map(DockerfileAnnotator::toImageName)
+ .orElse(null);
+ }
+
+ private static String toImageName(io.github.guacsec.trustifyda.api.PackageRef ref) {
+ try {
+ var purl = ref.purl();
+ var qualifiers = purl.getQualifiers();
+ if (qualifiers != null && qualifiers.containsKey(ImageRef.REPOSITORY_QUALIFIER)) {
+ String repoUrl = qualifiers.get(ImageRef.REPOSITORY_QUALIFIER);
+ String decoded = fullyDecode(repoUrl);
+ if (!decoded.equals(repoUrl)) {
+ var decodedQualifiers = new java.util.TreeMap<>(qualifiers);
+ decodedQualifiers.put(ImageRef.REPOSITORY_QUALIFIER, decoded);
+ purl = new com.github.packageurl.PackageURL(
+ purl.getType(), purl.getNamespace(), purl.getName(),
+ purl.getVersion(), decodedQualifiers, purl.getSubpath());
+ }
+ }
+ return new ImageRef(purl).getImage().getNameWithoutTag();
+ } catch (IllegalArgumentException | com.github.packageurl.MalformedPackageURLException e) {
+ LOG.warn("Failed to parse recommendation image from PURL: " + ref.ref(), e);
+ return null;
+ }
+ }
+
+ /** Repeatedly URL-decodes until the value stabilizes (handles double/triple encoding). */
+ private static String fullyDecode(String value) {
+ String previous = value;
+ for (int i = 0; i < 5; i++) {
+ String decoded = java.net.URLDecoder.decode(previous, java.nio.charset.StandardCharsets.UTF_8);
+ if (decoded.equals(previous)) {
+ break;
+ }
+ previous = decoded;
+ }
+ return previous;
+ }
+
@NotNull
- private static HighlightSeverity getHighlightSeverity(AnalysisReport report, String recommendation, boolean hasIssue, @NotNull PsiElement context) {
+ private static HighlightSeverity getHighlightSeverity(AnalysisReport report, String recommendation,
+ String hardenedRecommendation, boolean hasIssue,
+ @NotNull PsiElement context) {
+ // Recommendation-only (no vulnerabilities): use INFORMATION severity (blue)
+ if (!hasIssue && !hasIssue(report)) {
+ boolean hasAnyRecommendation = recommendation != null || hardenedRecommendation != null;
+ if (hasAnyRecommendation) {
+ return HighlightSeverity.INFORMATION;
+ }
+ }
+
// Get the configured severity from the inspection settings
final InspectionProfileEntry inspection = getInspection(context);
if (inspection != null) {
@@ -335,20 +441,37 @@ public void apply(@NotNull PsiFile file, Map annotationResult
&& elements != null && !elements.isEmpty()) {
if (isReportAvailable(report)) {
var hasIssue = hasIssue(report);
- var recommendation = getRecommendation(report, value.getImageRef());
+ boolean recommendationsEnabled = ApiSettingsState.getInstance().recommendationsEnabled;
+ var recommendation = recommendationsEnabled
+ ? getRecommendation(report, value.getImageRef()) : null;
+ var hardenedRecommendation = recommendationsEnabled
+ ? getHardenedRecommendation(report, value.getImageRef()) : null;
- var message = generateMessage(key.getImageName(), report, recommendation);
- var tooltip = generateTooltip(key.getImageName(), report, recommendation);
+ var message = generateMessage(key.getImageName(), report,
+ recommendation, hardenedRecommendation);
+ var tooltip = generateTooltip(key.getImageName(), report,
+ recommendation, hardenedRecommendation);
elements.forEach(e -> {
- var severity = getHighlightSeverity(report, recommendation, hasIssue, e);
+ var severity = getHighlightSeverity(report, recommendation, hardenedRecommendation, hasIssue, e);
if (e != null) {
var builder = holder
.newAnnotation(severity, message)
.tooltip(tooltip)
- .range(e)
- .withFix(new ImageReportIntentionAction())
- .withFix(new UBIIntentionAction());
+ .range(e);
+ if (severity == HighlightSeverity.INFORMATION) {
+ var attrs = new TextAttributes();
+ attrs.setEffectType(EffectType.WAVE_UNDERSCORE);
+ attrs.setEffectColor(JBColor.BLUE);
+ builder = builder.enforcedTextAttributes(attrs);
+ }
+ builder = builder.withFix(new ImageReportIntentionAction());
+ if (recommendation != null) {
+ builder.withFix(new UBIIntentionAction());
+ }
+ if (hardenedRecommendation != null) {
+ builder.withFix(new HardenedImageIntentionAction());
+ }
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
new file mode 100644
index 0000000..b7b672d
--- /dev/null
+++ b/src/main/java/org/jboss/tools/intellij/image/HardenedImageIntentionAction.java
@@ -0,0 +1,56 @@
+/*******************************************************************************
+ * 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.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.Editor;
+import com.intellij.openapi.project.Project;
+import com.intellij.psi.PsiFile;
+import com.intellij.util.IncorrectOperationException;
+import org.jetbrains.annotations.NotNull;
+
+import java.net.URI;
+
+/** Intention action that directs users to the Red Hat Hardened Container Images catalog. */
+public class HardenedImageIntentionAction implements IntentionAction {
+
+ public static final String HARDENED_IMAGE_LINK = "https://catalog.redhat.com/software/containers/search?gs&q=hardened";
+
+ @Override
+ public @IntentionName @NotNull String getText() {
+ return "Switch to a Red Hat Hardened Image for enhanced security";
+ }
+
+ @Override
+ public @NotNull @IntentionFamilyName String getFamilyName() {
+ return "RHDA";
+ }
+
+ @Override
+ public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile psiFile) {
+ return DockerfileFileType.isDockerfile(psiFile);
+ }
+
+ @Override
+ public void invoke(@NotNull Project project, Editor editor, PsiFile psiFile) throws IncorrectOperationException {
+ BrowserUtil.browse(URI.create(HARDENED_IMAGE_LINK));
+ }
+
+ @Override
+ public boolean startInWriteAction() {
+ return false;
+ }
+}
diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java
index 00801ab..62024e1 100644
--- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java
+++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java
@@ -94,6 +94,8 @@ public class ApiSettingsComponent {
+ "
Leave empty to use temporary files only.";
private final static String licenseCheckEnabledLabel = "Component Analysis > License Check"
+ "
Enables license compatibility checking and notifications for incompatible dependencies.";
+ private final static String recommendationsEnabledLabel = "Component Analysis > Recommendations"
+ + "
Enables package version and image recommendations from Red Hat.";
private final JPanel mainPanel;
@@ -131,6 +133,7 @@ public class ApiSettingsComponent {
private final JBScrollPane manifestExclusionPatternsScrollPane;
private final TextFieldWithBrowseButton reportFilePathText;
private final JBCheckBox licenseCheckEnabledCheck;
+ private final JBCheckBox recommendationsEnabledCheck;
public ApiSettingsComponent() {
@@ -279,6 +282,7 @@ public ApiSettingsComponent() {
batchMetadataCheck = new JBCheckBox("Include metadata in batch results");
licenseCheckEnabledCheck = new JBCheckBox("Enable license compatibility checking");
+ recommendationsEnabledCheck = new JBCheckBox("Enable package and image recommendations");
manifestExclusionPatternsText = new JBTextArea();
manifestExclusionPatternsText.setRows(5);
@@ -357,6 +361,8 @@ public ApiSettingsComponent() {
.addSeparator(10)
.addVerticalGap(10)
.addLabeledComponent(new JBLabel(licenseCheckEnabledLabel), licenseCheckEnabledCheck, 1, true)
+ .addVerticalGap(10)
+ .addLabeledComponent(new JBLabel(recommendationsEnabledLabel), recommendationsEnabledCheck, 1, true)
.addSeparator(10)
.addVerticalGap(10)
.addLabeledComponent(new JBLabel(manifestExclusionPatternsLabel), manifestExclusionPatternsScrollPane, 1, true)
@@ -643,4 +649,12 @@ public boolean getLicenseCheckEnabledCheck() {
public void setLicenseCheckEnabledCheck(boolean selected) {
licenseCheckEnabledCheck.setSelected(selected);
}
+
+ public boolean getRecommendationsEnabledCheck() {
+ return recommendationsEnabledCheck.isSelected();
+ }
+
+ public void setRecommendationsEnabledCheck(boolean selected) {
+ recommendationsEnabledCheck.setSelected(selected);
+ }
}
diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java
index e973c3b..418a137 100644
--- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java
+++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java
@@ -77,6 +77,7 @@ public boolean isModified() {
modified |= !settingsComponent.getManifestExclusionPatternsText().equals(settings.manifestExclusionPatterns);
modified |= !settingsComponent.getReportFilePathText().equals(settings.reportFilePath);
modified |= settingsComponent.getLicenseCheckEnabledCheck() != settings.licenseCheckEnabled;
+ modified |= settingsComponent.getRecommendationsEnabledCheck() != settings.recommendationsEnabled;
return modified;
}
@@ -114,9 +115,11 @@ public void apply() {
settings.batchContinueOnError = settingsComponent.getBatchContinueOnErrorCheck();
settings.batchMetadata = settingsComponent.getBatchMetadataCheck();
- // Check if license check setting changed
+ // Check if license check or recommendations setting changed
boolean licenseCheckChanged = settingsComponent.getLicenseCheckEnabledCheck() != settings.licenseCheckEnabled;
settings.licenseCheckEnabled = settingsComponent.getLicenseCheckEnabledCheck();
+ boolean recommendationsChanged = settingsComponent.getRecommendationsEnabledCheck() != settings.recommendationsEnabled;
+ settings.recommendationsEnabled = settingsComponent.getRecommendationsEnabledCheck();
// Check if exclusion patterns changed
String oldPatterns = settings.manifestExclusionPatterns;
@@ -124,9 +127,9 @@ public void apply() {
boolean patternsChanged = !Objects.equals(oldPatterns, newPatterns);
settings.manifestExclusionPatterns = newPatterns;
- // Trigger re-analysis if exclusion patterns or license check changed
- if (patternsChanged || licenseCheckChanged) {
- if (licenseCheckChanged) {
+ // Trigger re-analysis if exclusion patterns, license check, or recommendations changed
+ if (patternsChanged || licenseCheckChanged || recommendationsChanged) {
+ if (licenseCheckChanged || recommendationsChanged) {
CAService.invalidateAllCaches();
}
refreshComponentAnalysis();
@@ -182,6 +185,7 @@ public void reset() {
settingsComponent.setManifestExclusionPatternsText(settings.manifestExclusionPatterns != null ? settings.manifestExclusionPatterns : "");
settingsComponent.setReportFilePathText(settings.reportFilePath != null ? settings.reportFilePath : "");
settingsComponent.setLicenseCheckEnabledCheck(settings.licenseCheckEnabled);
+ settingsComponent.setRecommendationsEnabledCheck(settings.recommendationsEnabled);
}
@Override
diff --git a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java
index 66f68ab..ede64c9 100644
--- a/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java
+++ b/src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java
@@ -76,6 +76,8 @@ public final class ApiSettingsState implements PersistentStateComponent qualifiers = new TreeMap<>();
+ qualifiers.put("repository_url", "quay.io%2Fhummingbird%2Fgo");
+ PackageURL hardenedPurl = new PackageURL("oci", null, "go", HARDENED_DIGEST, qualifiers, null);
+
+ AnalysisReport report = buildReportWithHardenedRecommendation(imagePurl, hardenedPurl);
+
+ String recommendation = DockerfileAnnotator.getHardenedRecommendation(report, imageRef);
+
+ assertNotNull("Should parse double-encoded PURL successfully", recommendation);
+ assertTrue("Should contain decoded path", recommendation.contains("quay.io/hummingbird/go"));
+ }
+
+ // ── 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);
+ }
+
+ /** Builds a report with a UBI recommendation in source-level dependencies. */
+ private static AnalysisReport buildReportWithUbiSource(PackageURL imagePurl, PackageURL ubiPurl) {
+ DependencyReport dep = new DependencyReport();
+ dep.setRef(new PackageRef(imagePurl));
+ dep.setRecommendation(new PackageRef(ubiPurl));
+
+ Source source = new Source();
+ source.addDependenciesItem(dep);
+
+ ProviderReport providerReport = new ProviderReport();
+ providerReport.putSourcesItem("ubi", source);
+
+ AnalysisReport report = new AnalysisReport();
+ report.putProvidersItem("rhtpa", providerReport);
+ return report;
+ }
+
+ /** Builds a report with a hardened recommendation in provider-level recommendations. */
+ 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;
+ }
+}
diff --git a/src/test/java/org/jboss/tools/intellij/settings/ApiSettingsStateTest.java b/src/test/java/org/jboss/tools/intellij/settings/ApiSettingsStateTest.java
new file mode 100644
index 0000000..8dd1036
--- /dev/null
+++ b/src/test/java/org/jboss/tools/intellij/settings/ApiSettingsStateTest.java
@@ -0,0 +1,51 @@
+/*******************************************************************************
+ * 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.settings;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for ApiSettingsState field defaults and persistence behavior.
+ * Uses direct instantiation to avoid IntelliJ platform dependencies.
+ */
+public class ApiSettingsStateTest {
+
+ /** Verifies that recommendationsEnabled defaults to true. */
+ @Test
+ public void testRecommendationsEnabledDefaultsToTrue() {
+ ApiSettingsState state = new ApiSettingsState();
+ assertTrue("recommendationsEnabled should default to true", state.recommendationsEnabled);
+ }
+
+ /** Verifies that recommendationsEnabled can be toggled to false. */
+ @Test
+ public void testRecommendationsEnabledCanBeDisabled() {
+ // Given a settings state with defaults
+ ApiSettingsState state = new ApiSettingsState();
+
+ // When disabling recommendations
+ state.recommendationsEnabled = false;
+
+ // Then the value is persisted
+ assertFalse("recommendationsEnabled should be false after disabling", state.recommendationsEnabled);
+ }
+
+ /** Verifies that licenseCheckEnabled defaults to true (existing behavior). */
+ @Test
+ public void testLicenseCheckEnabledDefaultsToTrue() {
+ ApiSettingsState state = new ApiSettingsState();
+ assertTrue("licenseCheckEnabled should default to true", state.licenseCheckEnabled);
+ }
+}
From 8f58a161fcc130eaded3976333657c4c19405d3e Mon Sep 17 00:00:00 2001
From: Adva Oren
Date: Thu, 2 Jul 2026 10:21:48 +0300
Subject: [PATCH 2/3] 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
---
.../intellij/image/DockerfileAnnotator.java | 104 +++++++++---------
1 file changed, 52 insertions(+), 52 deletions(-)
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 b3e0e3f..133bdfe 100644
--- a/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java
+++ b/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java
@@ -40,13 +40,17 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import io.github.guacsec.trustifyda.api.PackageRef;
+
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.function.Function;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
public class DockerfileAnnotator extends ExternalAnnotator> {
@@ -243,57 +247,55 @@ static boolean hasIssue(AnalysisReport report) {
/** Returns the UBI image recommendation (from source-level dependencies), or null if none. */
static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
- return Optional.ofNullable(report.getProviders())
- .flatMap(provider -> provider.values()
- .stream()
- .filter(Objects::nonNull)
- .map(ProviderReport::getSources)
- .filter(Objects::nonNull)
- .map(Map::values)
- .flatMap(Collection::stream)
- .filter(Objects::nonNull)
- .map(Source::getDependencies)
- .filter(Objects::nonNull)
- .flatMap(Collection::stream)
- .filter(r -> r.getRef() != null)
- .filter(r -> {
- try {
- return imageRef.getPackageURL().equals(r.getRef().purl());
- } catch (MalformedPackageURLException e) {
- throw new RuntimeException(e);
- }
- })
- .map(DependencyReport::getRecommendation)
- .filter(Objects::nonNull)
- .findAny())
- .map(DockerfileAnnotator::toImageName)
- .orElse(null);
+ var deps = Optional.ofNullable(report.getProviders())
+ .stream()
+ .flatMap(provider -> provider.values().stream())
+ .filter(Objects::nonNull)
+ .map(ProviderReport::getSources)
+ .filter(Objects::nonNull)
+ .map(Map::values)
+ .flatMap(Collection::stream)
+ .filter(Objects::nonNull)
+ .map(Source::getDependencies)
+ .filter(Objects::nonNull)
+ .flatMap(Collection::stream);
+ 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) {
- return Optional.ofNullable(report.getProviders())
- .flatMap(provider -> provider.values()
- .stream()
- .filter(Objects::nonNull)
- .map(ProviderReport::getRecommendations)
- .filter(Objects::nonNull)
- .flatMap(recs -> recs.entrySet().stream())
- .filter(entry -> entry.getValue() != null)
- .map(entry -> entry.getValue().getDependencies())
- .filter(Objects::nonNull)
- .flatMap(Collection::stream)
- .filter(r -> r.getRef() != null)
- .filter(r -> {
- try {
- return imageRef.getPackageURL().equals(r.getRef().purl());
- } catch (MalformedPackageURLException e) {
- throw new RuntimeException(e);
- }
- })
- .map(r -> r.getRecommendation())
- .filter(Objects::nonNull)
- .findAny())
+ var deps = Optional.ofNullable(report.getProviders())
+ .stream()
+ .flatMap(provider -> provider.values().stream())
+ .filter(Objects::nonNull)
+ .map(ProviderReport::getRecommendations)
+ .filter(Objects::nonNull)
+ .flatMap(recs -> recs.entrySet().stream())
+ .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);
+ }
+
+ private static String findMatchingRecommendation(Stream items, ImageRef imageRef,
+ Function refExtractor,
+ Function recommendationExtractor) {
+ return items
+ .filter(r -> refExtractor.apply(r) != null)
+ .filter(r -> {
+ try {
+ return imageRef.getPackageURL().equals(refExtractor.apply(r).purl());
+ } catch (MalformedPackageURLException e) {
+ LOG.warn("Skipping recommendation with malformed PURL", e);
+ return false;
+ }
+ })
+ .map(recommendationExtractor)
+ .filter(Objects::nonNull)
+ .findAny()
.map(DockerfileAnnotator::toImageName)
.orElse(null);
}
@@ -338,7 +340,7 @@ private static HighlightSeverity getHighlightSeverity(AnalysisReport report, Str
String hardenedRecommendation, boolean hasIssue,
@NotNull PsiElement context) {
// Recommendation-only (no vulnerabilities): use INFORMATION severity (blue)
- if (!hasIssue && !hasIssue(report)) {
+ if (!hasIssue) {
boolean hasAnyRecommendation = recommendation != null || hardenedRecommendation != null;
if (hasAnyRecommendation) {
return HighlightSeverity.INFORMATION;
@@ -466,11 +468,9 @@ public void apply(@NotNull PsiFile file, Map annotationResult
builder = builder.enforcedTextAttributes(attrs);
}
builder = builder.withFix(new ImageReportIntentionAction());
- if (recommendation != null) {
- builder.withFix(new UBIIntentionAction());
- }
+ builder = builder.withFix(new UBIIntentionAction());
if (hardenedRecommendation != null) {
- builder.withFix(new HardenedImageIntentionAction());
+ builder = builder.withFix(new HardenedImageIntentionAction());
}
builder.create();
}
From 3333b9c10eec42def1fe1622121243c94be6c162 Mon Sep 17 00:00:00 2001
From: Adva Oren
Date: Thu, 2 Jul 2026 12:45:37 +0300
Subject: [PATCH 3/3] 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
---
src/main/java/org/jboss/tools/intellij/exhort/ApiService.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
index 2eeb044..93712d4 100644
--- a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
+++ b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
@@ -315,9 +315,9 @@ public static void setCommonRequestProperties() {
System.setProperty("TRUSTIFY_DA_LICENSE_CHECK", "false");
}
if (settings.recommendationsEnabled) {
- System.setProperty("TRUSTIFY_DA_RECOMMENDATIONS_ENABLED", "true");
+ System.setProperty("TRUSTIFY_DA_RECOMMEND", "true");
} else {
- System.setProperty("TRUSTIFY_DA_RECOMMENDATIONS_ENABLED", "false");
+ System.setProperty("TRUSTIFY_DA_RECOMMEND", "false");
}
Optional proxyUrlOpt = getProxyUrl();