2121import com .intellij .lang .annotation .HighlightSeverity ;
2222import com .intellij .openapi .application .ApplicationManager ;
2323import com .intellij .openapi .diagnostic .Logger ;
24+ import com .intellij .openapi .editor .markup .EffectType ;
25+ import com .intellij .openapi .editor .markup .TextAttributes ;
2426import com .intellij .profile .codeInspection .InspectionProjectProfileManager ;
2527import com .intellij .psi .PsiElement ;
2628import com .intellij .psi .PsiFile ;
2729import com .intellij .serviceContainer .AlreadyDisposedException ;
30+ import com .intellij .ui .JBColor ;
2831import io .github .guacsec .trustifyda .api .v5 .AnalysisReport ;
2932import io .github .guacsec .trustifyda .api .v5 .DependencyReport ;
3033import io .github .guacsec .trustifyda .api .v5 .ProviderReport ;
34+ import io .github .guacsec .trustifyda .api .v5 .RecommendationSource ;
3135import io .github .guacsec .trustifyda .api .v5 .Severity ;
3236import io .github .guacsec .trustifyda .api .v5 .Source ;
3337import io .github .guacsec .trustifyda .image .ImageRef ;
3438import org .jboss .tools .intellij .image .build .filetype .DockerfileFileType ;
39+ import org .jboss .tools .intellij .settings .ApiSettingsState ;
3540import org .jetbrains .annotations .NotNull ;
3641import org .jetbrains .annotations .Nullable ;
3742
43+ import io .github .guacsec .trustifyda .api .PackageRef ;
44+
3845import java .util .Collection ;
3946import java .util .HashSet ;
4047import java .util .List ;
4148import java .util .Map ;
4249import java .util .Objects ;
4350import java .util .Optional ;
51+ import java .util .function .Function ;
4452import java .util .stream .Collectors ;
53+ import java .util .stream .Stream ;
4554
4655public 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 });
0 commit comments