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
@@ -84,18 +89,32 @@ static boolean isReportAvailable(AnalysisReport report) {
8489 .map (providers -> providers
8590 .values ()
8691 .stream ()
87- .map (ProviderReport ::getSources )
92+ .anyMatch (provider -> hasVulnerabilities (provider ) || hasProviderRecommendations (provider )))
93+ .orElse (false );
94+ }
95+
96+ private static boolean hasVulnerabilities (ProviderReport provider ) {
97+ return Optional .ofNullable (provider .getSources ())
98+ .map (sources -> sources .values ().stream ()
8899 .filter (Objects ::nonNull )
89- .map (Map ::entrySet )
90- .flatMap (Collection ::stream )
91- .map (Map .Entry ::getValue )
92100 .map (Source ::getSummary )
93101 .filter (Objects ::nonNull )
94102 .anyMatch (s -> s .getTotal () != null && s .getTotal () > 0 ))
95103 .orElse (false );
96104 }
97105
98- static String generateMessage (String image , AnalysisReport report , String recommendation ) {
106+ private static boolean hasProviderRecommendations (ProviderReport provider ) {
107+ return Optional .ofNullable (provider .getRecommendations ())
108+ .map (recs -> recs .values ().stream ()
109+ .filter (Objects ::nonNull )
110+ .map (RecommendationSource ::getDependencies )
111+ .filter (Objects ::nonNull )
112+ .anyMatch (deps -> !deps .isEmpty ()))
113+ .orElse (false );
114+ }
115+
116+ static String generateMessage (String image , AnalysisReport report , String recommendation ,
117+ String hardenedRecommendation ) {
99118 var messageBuilder = new StringBuilder (image );
100119
101120 Optional .ofNullable (report .getProviders ())
@@ -137,11 +156,17 @@ static String generateMessage(String image, AnalysisReport report, String recomm
137156 .append ("Replace your image with RedHat UBI: " )
138157 .append (recommendation );
139158 }
159+ if (hardenedRecommendation != null ) {
160+ messageBuilder .append (System .lineSeparator ())
161+ .append ("A Red Hat Hardened Image is available: " )
162+ .append (hardenedRecommendation );
163+ }
140164
141165 return messageBuilder .toString ();
142166 }
143167
144- static String generateTooltip (String image , AnalysisReport report , String recommendation ) {
168+ static String generateTooltip (String image , AnalysisReport report , String recommendation ,
169+ String hardenedRecommendation ) {
145170 var tooltipBuilder = new StringBuilder ("<html>" ).append ("<p>" ).append (image ).append ("</p>" );
146171
147172 Optional .ofNullable (report .getProviders ())
@@ -188,6 +213,12 @@ static String generateTooltip(String image, AnalysisReport report, String recomm
188213 .append (recommendation )
189214 .append ("</p>" );
190215 }
216+ if (hardenedRecommendation != null ) {
217+ tooltipBuilder .append ("<p/>" )
218+ .append ("<p>A Red Hat Hardened Image is available: " )
219+ .append (hardenedRecommendation )
220+ .append ("</p>" );
221+ }
191222
192223 return tooltipBuilder .toString ();
193224 }
@@ -210,6 +241,7 @@ static boolean hasIssue(AnalysisReport report) {
210241 .orElse (false );
211242 }
212243
244+ /** Returns the UBI image recommendation (from source-level dependencies), or null if none. */
213245 static String getRecommendation (AnalysisReport report , ImageRef imageRef ) {
214246 return Optional .ofNullable (report .getProviders ())
215247 .flatMap (provider -> provider .values ()
@@ -219,6 +251,7 @@ static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
219251 .filter (Objects ::nonNull )
220252 .map (Map ::values )
221253 .flatMap (Collection ::stream )
254+ .filter (Objects ::nonNull )
222255 .map (Source ::getDependencies )
223256 .filter (Objects ::nonNull )
224257 .flatMap (Collection ::stream )
@@ -233,12 +266,85 @@ static String getRecommendation(AnalysisReport report, ImageRef imageRef) {
233266 .map (DependencyReport ::getRecommendation )
234267 .filter (Objects ::nonNull )
235268 .findAny ())
236- .map (r -> new ImageRef ( r . purl ()). getImage (). getNameWithoutTag () )
269+ .map (DockerfileAnnotator :: toImageName )
237270 .orElse (null );
238271 }
239272
273+ /** Returns the hardened image recommendation (from provider-level recommendations), or null if none. */
274+ static String getHardenedRecommendation (AnalysisReport report , ImageRef imageRef ) {
275+ return Optional .ofNullable (report .getProviders ())
276+ .flatMap (provider -> provider .values ()
277+ .stream ()
278+ .filter (Objects ::nonNull )
279+ .map (ProviderReport ::getRecommendations )
280+ .filter (Objects ::nonNull )
281+ .flatMap (recs -> recs .entrySet ().stream ())
282+ .filter (entry -> entry .getValue () != null )
283+ .map (entry -> entry .getValue ().getDependencies ())
284+ .filter (Objects ::nonNull )
285+ .flatMap (Collection ::stream )
286+ .filter (r -> r .getRef () != null )
287+ .filter (r -> {
288+ try {
289+ return imageRef .getPackageURL ().equals (r .getRef ().purl ());
290+ } catch (MalformedPackageURLException e ) {
291+ throw new RuntimeException (e );
292+ }
293+ })
294+ .map (r -> r .getRecommendation ())
295+ .filter (Objects ::nonNull )
296+ .findAny ())
297+ .map (DockerfileAnnotator ::toImageName )
298+ .orElse (null );
299+ }
300+
301+ private static String toImageName (io .github .guacsec .trustifyda .api .PackageRef ref ) {
302+ try {
303+ var purl = ref .purl ();
304+ var qualifiers = purl .getQualifiers ();
305+ if (qualifiers != null && qualifiers .containsKey (ImageRef .REPOSITORY_QUALIFIER )) {
306+ String repoUrl = qualifiers .get (ImageRef .REPOSITORY_QUALIFIER );
307+ String decoded = fullyDecode (repoUrl );
308+ if (!decoded .equals (repoUrl )) {
309+ var decodedQualifiers = new java .util .TreeMap <>(qualifiers );
310+ decodedQualifiers .put (ImageRef .REPOSITORY_QUALIFIER , decoded );
311+ purl = new com .github .packageurl .PackageURL (
312+ purl .getType (), purl .getNamespace (), purl .getName (),
313+ purl .getVersion (), decodedQualifiers , purl .getSubpath ());
314+ }
315+ }
316+ return new ImageRef (purl ).getImage ().getNameWithoutTag ();
317+ } catch (IllegalArgumentException | com .github .packageurl .MalformedPackageURLException e ) {
318+ LOG .warn ("Failed to parse recommendation image from PURL: " + ref .ref (), e );
319+ return null ;
320+ }
321+ }
322+
323+ /** Repeatedly URL-decodes until the value stabilizes (handles double/triple encoding). */
324+ private static String fullyDecode (String value ) {
325+ String previous = value ;
326+ for (int i = 0 ; i < 5 ; i ++) {
327+ String decoded = java .net .URLDecoder .decode (previous , java .nio .charset .StandardCharsets .UTF_8 );
328+ if (decoded .equals (previous )) {
329+ break ;
330+ }
331+ previous = decoded ;
332+ }
333+ return previous ;
334+ }
335+
240336 @ NotNull
241- private static HighlightSeverity getHighlightSeverity (AnalysisReport report , String recommendation , boolean hasIssue , @ NotNull PsiElement context ) {
337+ private static HighlightSeverity getHighlightSeverity (AnalysisReport report , String recommendation ,
338+ String hardenedRecommendation , boolean hasIssue ,
339+ @ NotNull PsiElement context ) {
340+ // Recommendation-only (no vulnerabilities): use INFORMATION severity (blue)
341+ if (!hasIssue && !hasIssue (report )) {
342+ boolean hasAnyRecommendation = recommendation != null || hardenedRecommendation != null ;
343+ if (hasAnyRecommendation ) {
344+ return HighlightSeverity .INFORMATION ;
345+ }
346+ }
347+
242348 // Get the configured severity from the inspection settings
243349 final InspectionProfileEntry inspection = getInspection (context );
244350 if (inspection != null ) {
@@ -335,20 +441,37 @@ public void apply(@NotNull PsiFile file, Map<BaseImage, Result> annotationResult
335441 && elements != null && !elements .isEmpty ()) {
336442 if (isReportAvailable (report )) {
337443 var hasIssue = hasIssue (report );
338- var recommendation = getRecommendation (report , value .getImageRef ());
444+ boolean recommendationsEnabled = ApiSettingsState .getInstance ().recommendationsEnabled ;
445+ var recommendation = recommendationsEnabled
446+ ? getRecommendation (report , value .getImageRef ()) : null ;
447+ var hardenedRecommendation = recommendationsEnabled
448+ ? getHardenedRecommendation (report , value .getImageRef ()) : null ;
339449
340- var message = generateMessage (key .getImageName (), report , recommendation );
341- var tooltip = generateTooltip (key .getImageName (), report , recommendation );
450+ var message = generateMessage (key .getImageName (), report ,
451+ recommendation , hardenedRecommendation );
452+ var tooltip = generateTooltip (key .getImageName (), report ,
453+ recommendation , hardenedRecommendation );
342454
343455 elements .forEach (e -> {
344- var severity = getHighlightSeverity (report , recommendation , hasIssue , e );
456+ var severity = getHighlightSeverity (report , recommendation , hardenedRecommendation , hasIssue , e );
345457 if (e != null ) {
346458 var builder = holder
347459 .newAnnotation (severity , message )
348460 .tooltip (tooltip )
349- .range (e )
350- .withFix (new ImageReportIntentionAction ())
351- .withFix (new UBIIntentionAction ());
461+ .range (e );
462+ if (severity == HighlightSeverity .INFORMATION ) {
463+ var attrs = new TextAttributes ();
464+ attrs .setEffectType (EffectType .WAVE_UNDERSCORE );
465+ attrs .setEffectColor (JBColor .BLUE );
466+ builder = builder .enforcedTextAttributes (attrs );
467+ }
468+ builder = builder .withFix (new ImageReportIntentionAction ());
469+ if (recommendation != null ) {
470+ builder .withFix (new UBIIntentionAction ());
471+ }
472+ if (hardenedRecommendation != null ) {
473+ builder .withFix (new HardenedImageIntentionAction ());
474+ }
352475 builder .create ();
353476 }
354477 });
0 commit comments