View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2026 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ///////////////////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle;
21  
22  import java.io.ByteArrayOutputStream;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.io.OutputStream;
26  import java.io.OutputStreamWriter;
27  import java.io.PrintWriter;
28  import java.io.StringWriter;
29  import java.nio.charset.StandardCharsets;
30  import java.util.ArrayList;
31  import java.util.HashMap;
32  import java.util.LinkedHashMap;
33  import java.util.List;
34  import java.util.Locale;
35  import java.util.Map;
36  import java.util.MissingResourceException;
37  import java.util.Objects;
38  import java.util.ResourceBundle;
39  import java.util.regex.Pattern;
40  
41  import com.puppycrawl.tools.checkstyle.api.AuditEvent;
42  import com.puppycrawl.tools.checkstyle.api.AuditListener;
43  import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
44  import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
45  import com.puppycrawl.tools.checkstyle.meta.ModuleDetails;
46  import com.puppycrawl.tools.checkstyle.meta.XmlMetaReader;
47  import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
48  
49  /**
50   * Simple SARIF logger.
51   * SARIF stands for the static analysis results interchange format.
52   * See <a href="https://sarifweb.azurewebsites.net/">reference</a>
53   */
54  public final class SarifLogger extends AbstractAutomaticBean implements AuditListener {
55  
56      /** The length of unicode placeholder. */
57      private static final int UNICODE_LENGTH = 4;
58  
59      /** Unicode escaping upper limit. */
60      private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F;
61  
62      /** Input stream buffer size. */
63      private static final int BUFFER_SIZE = 1024;
64  
65      /** The placeholder for message. */
66      private static final String MESSAGE_PLACEHOLDER = "${message}";
67  
68      /** The placeholder for message text. */
69      private static final String MESSAGE_TEXT_PLACEHOLDER = "${messageText}";
70  
71      /** The placeholder for message id. */
72      private static final String MESSAGE_ID_PLACEHOLDER = "${messageId}";
73  
74      /** The placeholder for severity level. */
75      private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}";
76  
77      /** The placeholder for uri. */
78      private static final String URI_PLACEHOLDER = "${uri}";
79  
80      /** The placeholder for line. */
81      private static final String LINE_PLACEHOLDER = "${line}";
82  
83      /** The placeholder for column. */
84      private static final String COLUMN_PLACEHOLDER = "${column}";
85  
86      /** The placeholder for rule id. */
87      private static final String RULE_ID_PLACEHOLDER = "${ruleId}";
88  
89      /** The placeholder for version. */
90      private static final String VERSION_PLACEHOLDER = "${version}";
91  
92      /** The placeholder for results. */
93      private static final String RESULTS_PLACEHOLDER = "${results}";
94  
95      /** The placeholder for rules. */
96      private static final String RULES_PLACEHOLDER = "${rules}";
97  
98      /** Two backslashes to not duplicate strings. */
99      private static final String TWO_BACKSLASHES = "\\\\";
100 
101     /** A pattern for two backslashes. */
102     private static final Pattern A_SPACE_PATTERN = Pattern.compile(" ");
103 
104     /** A pattern for two backslashes. */
105     private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES);
106 
107     /** A pattern to match a file with a Windows drive letter. */
108     private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN =
109             Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE);
110 
111     /** Comma and line separator. */
112     private static final String COMMA_LINE_SEPARATOR = ",\n";
113 
114     /** Helper writer that allows easy encoding and printing. */
115     private final PrintWriter writer;
116 
117     /** Close output stream in auditFinished. */
118     private final boolean closeStream;
119 
120     /** The results. */
121     private final List<String> results = new ArrayList<>();
122 
123     /** Map of all available module metadata by fully qualified name. */
124     private final Map<String, ModuleDetails> allModuleMetadata = new HashMap<>();
125 
126     /** Map to store rule metadata by composite key (sourceName, moduleId). */
127     private final Map<RuleKey, ModuleDetails> ruleMetadata = new LinkedHashMap<>();
128 
129     /** Content for the entire report. */
130     private final String report;
131 
132     /** Content for result representing an error with source line and column. */
133     private final String resultLineColumn;
134 
135     /** Content for result representing an error with source line only. */
136     private final String resultLineOnly;
137 
138     /** Content for result representing an error with filename only and without source location. */
139     private final String resultFileOnly;
140 
141     /** Content for result representing an error without filename or location. */
142     private final String resultErrorOnly;
143 
144     /** Content for rule. */
145     private final String rule;
146 
147     /** Content for messageStrings. */
148     private final String messageStrings;
149 
150     /** Content for message with text only. */
151     private final String messageTextOnly;
152 
153     /** Content for message with id. */
154     private final String messageWithId;
155 
156     /**
157      * Creates a new {@code SarifLogger} instance.
158      *
159      * @param outputStream where to log audit events
160      * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
161      * @throws IllegalArgumentException if outputStreamOptions is null
162      * @throws IOException if there is reading errors.
163      * @noinspection deprecation
164      * @noinspectionreason We are forced to keep AutomaticBean compatability
165      *     because of maven-checkstyle-plugin. Until #12873.
166      */
167     public SarifLogger(
168         OutputStream outputStream,
169         AutomaticBean.OutputStreamOptions outputStreamOptions) throws IOException {
170         this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
171     }
172 
173     /**
174      * Creates a new {@code SarifLogger} instance.
175      *
176      * @param outputStream where to log audit events
177      * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
178      * @throws IllegalArgumentException if outputStreamOptions is null
179      * @throws IOException if there is reading errors.
180      */
181     public SarifLogger(
182         OutputStream outputStream,
183         OutputStreamOptions outputStreamOptions) throws IOException {
184         if (outputStreamOptions == null) {
185             throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
186         }
187         writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
188         closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
189         loadModuleMetadata();
190         report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template");
191         resultLineColumn =
192             readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template");
193         resultLineOnly =
194             readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template");
195         resultFileOnly =
196             readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template");
197         resultErrorOnly =
198             readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template");
199         rule = readResource("/com/puppycrawl/tools/checkstyle/sarif/Rule.template");
200         messageStrings =
201             readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageStrings.template");
202         messageTextOnly =
203             readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageTextOnly.template");
204         messageWithId =
205             readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageWithId.template");
206     }
207 
208     /**
209      * Loads all available module metadata from XML files.
210      */
211     private void loadModuleMetadata() {
212         final List<ModuleDetails> allModules =
213                 XmlMetaReader.readAllModulesIncludingThirdPartyIfAny();
214         for (ModuleDetails module : allModules) {
215             allModuleMetadata.put(module.getFullQualifiedName(), module);
216         }
217     }
218 
219     @Override
220     protected void finishLocalSetup() {
221         // No code by default
222     }
223 
224     @Override
225     public void auditStarted(AuditEvent event) {
226         // No code by default
227     }
228 
229     @Override
230     public void auditFinished(AuditEvent event) {
231         String rendered = replaceVersionString(report);
232         rendered = rendered
233                 .replace(RESULTS_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, results))
234                 .replace(RULES_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, generateRules()));
235         writer.print(rendered);
236         if (closeStream) {
237             writer.close();
238         }
239         else {
240             writer.flush();
241         }
242     }
243 
244     /**
245      * Generates rules from cached rule metadata.
246      *
247      * @return list of rules
248      */
249     private List<String> generateRules() {
250         final List<String> result = new ArrayList<>();
251         for (Map.Entry<RuleKey, ModuleDetails> entry : ruleMetadata.entrySet()) {
252             final RuleKey ruleKey = entry.getKey();
253             final ModuleDetails module = entry.getValue();
254             final String shortDescription;
255             final String fullDescription;
256             final String messageStringsFragment;
257             if (module == null) {
258                 shortDescription = CommonUtil.baseClassName(ruleKey.sourceName());
259                 fullDescription = "No description available";
260                 messageStringsFragment = "";
261             }
262             else {
263                 shortDescription = module.getName();
264                 fullDescription = module.getDescription();
265                 messageStringsFragment = String.join(COMMA_LINE_SEPARATOR,
266                         generateMessageStrings(module));
267             }
268             result.add(rule
269                     .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
270                     .replace("${shortDescription}", shortDescription)
271                     .replace("${fullDescription}", escape(fullDescription))
272                     .replace("${messageStrings}", messageStringsFragment));
273         }
274         return result;
275     }
276 
277     /**
278      * Generates message strings for a given module.
279      *
280      * @param module the module
281      * @return the generated message strings
282      */
283     private List<String> generateMessageStrings(ModuleDetails module) {
284         final Map<String, String> messages = getMessages(module);
285         return module.getViolationMessageKeys().stream()
286                 .filter(messages::containsKey).map(key -> {
287                     final String message = messages.get(key);
288                     return messageStrings
289                             .replace("${key}", key)
290                             .replace("${text}", escape(message));
291                 }).toList();
292     }
293 
294     /**
295      * Gets a map of message keys to their message strings for a module.
296      *
297      * @param moduleDetails the module details
298      * @return map of message keys to message strings
299      */
300     private static Map<String, String> getMessages(ModuleDetails moduleDetails) {
301         final String fullQualifiedName = moduleDetails.getFullQualifiedName();
302         final Map<String, String> result = new LinkedHashMap<>();
303         try {
304             final int lastDot = fullQualifiedName.lastIndexOf('.');
305             final String packageName = fullQualifiedName.substring(0, lastDot);
306             final String bundleName = packageName + ".messages";
307             final Class<?> moduleClass = Class.forName(fullQualifiedName);
308             final ResourceBundle bundle = ResourceBundle.getBundle(
309                     bundleName,
310                     Locale.ROOT,
311                     moduleClass.getClassLoader(),
312                     new LocalizedMessage.Utf8Control()
313             );
314             for (String key : moduleDetails.getViolationMessageKeys()) {
315                 result.put(key, bundle.getString(key));
316             }
317         }
318         catch (ClassNotFoundException | MissingResourceException ignored) {
319             // Return empty map when module class or resource bundle is not on classpath.
320             // Occurs with third-party modules that have XML metadata but missing implementation.
321         }
322         return result;
323     }
324 
325     /**
326      * Returns the version string.
327      *
328      * @param report report content where replace should happen
329      * @return a version string based on the package implementation version
330      */
331     private static String replaceVersionString(String report) {
332         final String version = SarifLogger.class.getPackage().getImplementationVersion();
333         return report.replace(VERSION_PLACEHOLDER, Objects.toString(version, "null"));
334     }
335 
336     @Override
337     public void addError(AuditEvent event) {
338         final RuleKey ruleKey = cacheRuleMetadata(event);
339         final String message = generateMessage(ruleKey, event);
340         if (event.getColumn() > 0) {
341             results.add(resultLineColumn
342                 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
343                 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
344                 .replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()))
345                 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
346                 .replace(MESSAGE_PLACEHOLDER, message)
347                 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
348             );
349         }
350         else {
351             results.add(resultLineOnly
352                 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
353                 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
354                 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
355                 .replace(MESSAGE_PLACEHOLDER, message)
356                 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
357             );
358         }
359     }
360 
361     /**
362      * Caches rule metadata for a given audit event.
363      *
364      * @param event the audit event
365      * @return the composite key for the rule
366      */
367     private RuleKey cacheRuleMetadata(AuditEvent event) {
368         final String sourceName = event.getSourceName();
369         final RuleKey key = new RuleKey(sourceName, event.getModuleId());
370         final ModuleDetails module = allModuleMetadata.get(sourceName);
371         ruleMetadata.putIfAbsent(key, module);
372         return key;
373     }
374 
375     /**
376      * Generate message for the given rule key and audit event.
377      *
378      * @param ruleKey the rule key
379      * @param event the audit event
380      * @return the generated message
381      */
382     private String generateMessage(RuleKey ruleKey, AuditEvent event) {
383         final String violationKey = event.getViolation().getKey();
384         final ModuleDetails module = ruleMetadata.get(ruleKey);
385         final String result;
386         if (module != null && module.getViolationMessageKeys().contains(violationKey)) {
387             result = messageWithId
388                     .replace(MESSAGE_ID_PLACEHOLDER, violationKey)
389                     .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
390         }
391         else {
392             result = messageTextOnly
393                     .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
394         }
395         return result;
396     }
397 
398     @Override
399     public void addException(AuditEvent event, Throwable throwable) {
400         final StringWriter stringWriter = new StringWriter();
401         final PrintWriter printer = new PrintWriter(stringWriter);
402         throwable.printStackTrace(printer);
403         final String message = messageTextOnly
404                 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(stringWriter.toString()));
405         if (event.getFileName() == null) {
406             results.add(resultErrorOnly
407                 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
408                 .replace(MESSAGE_PLACEHOLDER, message)
409             );
410         }
411         else {
412             results.add(resultFileOnly
413                 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
414                 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
415                 .replace(MESSAGE_PLACEHOLDER, message)
416             );
417         }
418     }
419 
420     @Override
421     public void fileStarted(AuditEvent event) {
422         // No need to implement this method in this class
423     }
424 
425     @Override
426     public void fileFinished(AuditEvent event) {
427         // No need to implement this method in this class
428     }
429 
430     /**
431      * Render the file name URI for the given file name.
432      *
433      * @param fileName the file name to render the URI for
434      * @return the rendered URI for the given file name
435      */
436     private static String renderFileNameUri(final String fileName) {
437         String normalized =
438                 A_SPACE_PATTERN
439                         .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/"))
440                         .replaceAll("%20");
441         if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) {
442             normalized = '/' + normalized;
443         }
444         return "file:" + normalized;
445     }
446 
447     /**
448      * Render the severity level into SARIF severity level.
449      *
450      * @param severityLevel the Severity level.
451      * @return the rendered severity level in string.
452      */
453     private static String renderSeverityLevel(SeverityLevel severityLevel) {
454         return switch (severityLevel) {
455             case IGNORE -> "none";
456             case INFO -> "note";
457             case WARNING -> "warning";
458             case ERROR -> "error";
459         };
460     }
461 
462     /**
463      * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F.
464      * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings
465      *
466      * @param value the value to escape.
467      * @return the escaped value if necessary.
468      */
469     public static String escape(String value) {
470         final int length = value.length();
471         final StringBuilder sb = new StringBuilder(length);
472         for (int i = 0; i < length; i++) {
473             final char chr = value.charAt(i);
474             final String replacement = switch (chr) {
475                 case '"' -> "\\\"";
476                 case '\\' -> TWO_BACKSLASHES;
477                 case '\b' -> "\\b";
478                 case '\f' -> "\\f";
479                 case '\n' -> "\\n";
480                 case '\r' -> "\\r";
481                 case '\t' -> "\\t";
482                 case '/' -> "\\/";
483                 default -> {
484                     if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) {
485                         yield escapeUnicode1F(chr);
486                     }
487                     yield Character.toString(chr);
488                 }
489             };
490             sb.append(replacement);
491         }
492 
493         return sb.toString();
494     }
495 
496     /**
497      * Escape the character between 0x00 to 0x1F in JSON.
498      *
499      * @param chr the character to be escaped.
500      * @return the escaped string.
501      */
502     private static String escapeUnicode1F(char chr) {
503         final String hexString = Integer.toHexString(chr);
504         return "\\u"
505                 + "0".repeat(UNICODE_LENGTH - hexString.length())
506                 + hexString.toUpperCase(Locale.US);
507     }
508 
509     /**
510      * Read string from given resource.
511      *
512      * @param name name of the desired resource
513      * @return the string content from the give resource
514      * @throws IOException if there is reading errors
515      */
516     public static String readResource(String name) throws IOException {
517         try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name);
518              ByteArrayOutputStream result = new ByteArrayOutputStream()) {
519             if (inputStream == null) {
520                 throw new IOException("Cannot find the resource " + name);
521             }
522             final byte[] buffer = new byte[BUFFER_SIZE];
523             int length = 0;
524             while (length != -1) {
525                 result.write(buffer, 0, length);
526                 length = inputStream.read(buffer);
527             }
528             return result.toString(StandardCharsets.UTF_8);
529         }
530     }
531 
532     /**
533      * Composite key for uniquely identifying a rule by source name and module ID.
534      *
535      * @param sourceName  The fully qualified source class name.
536      * @param moduleId  The module ID from configuration (can be null).
537      */
538     private record RuleKey(String sourceName, String moduleId) {
539         /**
540          * Converts this key to a SARIF rule ID string.
541          *
542          * @return rule ID in format: sourceName[#moduleId]
543          */
544         private String toRuleId() {
545             final String result;
546             if (moduleId == null) {
547                 result = sourceName;
548             }
549             else {
550                 result = sourceName + '#' + moduleId;
551             }
552             return result;
553         }
554     }
555 }