-
Notifications
You must be signed in to change notification settings - Fork 22
feat: add hardened image recommendations to DockerfileAnnotator (TC-4811) #261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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>> { | ||
|
|
||
|
|
@@ -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("<html>").append("<p>").append(image).append("</p>"); | ||
|
|
||
| Optional.ofNullable(report.getProviders()) | ||
|
|
@@ -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(); | ||
| } | ||
|
|
@@ -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) | ||
| 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<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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Design — single toggle gates two independent features One
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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., |
||
| 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(); | ||
| } | ||
| }); | ||
|
|
||
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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),hasIssueis computed ashasIssue(report), so both operands are always equal. Simplify to just!hasIssue.There was a problem hiding this comment.
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.