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