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