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