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