Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> proxyUrlOpt = getProxyUrl();
if (proxyUrlOpt.isPresent()) {
Expand Down
197 changes: 160 additions & 37 deletions src/main/java/org/jboss/tools/intellij/image/DockerfileAnnotator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<DockerfileAnnotator.Info, Map<BaseImage, DockerfileAnnotator.Result>> {

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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("<html>").append("<p>").append(image).append("</p>");

Optional.ofNullable(report.getProviders())
Expand Down Expand Up @@ -188,6 +217,12 @@ static String generateTooltip(String image, AnalysisReport report, String recomm
.append(recommendation)
.append("</p>");
}
if (hardenedRecommendation != null) {
tooltipBuilder.append("<p/>")
.append("<p>A Red Hat Hardened Image is available: ")
.append(hardenedRecommendation)
.append("</p>");
}

return tooltipBuilder.toString();
}
Expand All @@ -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 <T> String findMatchingRecommendation(Stream<T> items, ImageRef imageRef,
Function<T, PackageRef> refExtractor,
Function<T, PackageRef> 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup — redundant guard condition

!hasIssue && !hasIssue(report) is redundant: at the call site (line 443), hasIssue is computed as hasIssue(report), so both operands are always equal. Simplify to just !hasIssue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as code change request — addressed in commit 8f58a16 (simplified to !hasIssue). No sub-task created.

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) {
Expand Down Expand Up @@ -335,20 +443,35 @@ public void apply(@NotNull PsiFile file, Map<BaseImage, Result> annotationResult
&& elements != null && !elements.isEmpty()) {
if (isReportAvailable(report)) {
var hasIssue = hasIssue(report);
var recommendation = getRecommendation(report, value.getImageRef());
boolean recommendationsEnabled = ApiSettingsState.getInstance().recommendationsEnabled;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design — single toggle gates two independent features

One recommendationsEnabled boolean controls both UBI recommendations (line 446) and hardened image recommendations (line 448). Users may want UBI suggestions without hardened-image suggestions, or vice versa. Consider separate toggles, or at minimum document that this is intentional.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I won't change that now, since I'm removing the UBI recommendations.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as suggestion — proposes separate toggles for UBI vs hardened recommendations. No matching convention in CONVENTIONS.md; existing codebase uses coarse-grained toggles (e.g., licenseCheckEnabled). Author declined, reviewer accepted. No sub-task created.

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();
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ public class ApiSettingsComponent {
+ "<br>Leave empty to use temporary files only.</html>";
private final static String licenseCheckEnabledLabel = "<html>Component Analysis > License Check"
+ "<br>Enables license compatibility checking and notifications for incompatible dependencies.</html>";
private final static String recommendationsEnabledLabel = "<html>Component Analysis > Recommendations"
+ "<br>Enables package version and image recommendations from Red Hat.</html>";

private final JPanel mainPanel;

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
}
Loading
Loading