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..93712d4 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_RECOMMEND", "true"); + } else { + System.setProperty("TRUSTIFY_DA_RECOMMEND", "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..133bdfe 100644 --- a/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java @@ -21,27 +21,36 @@ 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; +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> { @@ -84,18 +93,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 +160,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 +217,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,35 +245,108 @@ 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() - .stream() - .filter(Objects::nonNull) - .map(ProviderReport::getSources) - .filter(Objects::nonNull) - .map(Map::values) - .flatMap(Collection::stream) - .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(r -> new ImageRef(r.purl()).getImage().getNameWithoutTag()) + 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) { + 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); } + 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) { + 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 +443,35 @@ 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()); + builder = builder.withFix(new UBIIntentionAction()); + if (hardenedRecommendation != null) { + builder = 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); + } +}