001/////////////////////////////////////////////////////////////////////////////////////////////// 002// checkstyle: Checks Java source code and other text files for adherence to a set of rules. 003// Copyright (C) 2001-2026 the original author or authors. 004// 005// This library is free software; you can redistribute it and/or 006// modify it under the terms of the GNU Lesser General Public 007// License as published by the Free Software Foundation; either 008// version 2.1 of the License, or (at your option) any later version. 009// 010// This library is distributed in the hope that it will be useful, 011// but WITHOUT ANY WARRANTY; without even the implied warranty of 012// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 013// Lesser General Public License for more details. 014// 015// You should have received a copy of the GNU Lesser General Public 016// License along with this library; if not, write to the Free Software 017// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 018/////////////////////////////////////////////////////////////////////////////////////////////// 019 020package com.puppycrawl.tools.checkstyle; 021 022import java.io.ByteArrayOutputStream; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.OutputStream; 026import java.io.OutputStreamWriter; 027import java.io.PrintWriter; 028import java.io.StringWriter; 029import java.nio.charset.StandardCharsets; 030import java.util.ArrayList; 031import java.util.HashMap; 032import java.util.LinkedHashMap; 033import java.util.List; 034import java.util.Locale; 035import java.util.Map; 036import java.util.MissingResourceException; 037import java.util.Objects; 038import java.util.ResourceBundle; 039import java.util.regex.Matcher; 040import java.util.regex.Pattern; 041 042import com.puppycrawl.tools.checkstyle.api.AuditEvent; 043import com.puppycrawl.tools.checkstyle.api.AuditListener; 044import com.puppycrawl.tools.checkstyle.api.AutomaticBean; 045import com.puppycrawl.tools.checkstyle.api.SeverityLevel; 046import com.puppycrawl.tools.checkstyle.meta.ModuleDetails; 047import com.puppycrawl.tools.checkstyle.meta.XmlMetaReader; 048import com.puppycrawl.tools.checkstyle.utils.CommonUtil; 049 050/** 051 * Simple SARIF logger. 052 * SARIF stands for the static analysis results interchange format. 053 * See <a href="https://sarifweb.azurewebsites.net/">reference</a> 054 */ 055public final class SarifLogger extends AbstractAutomaticBean implements AuditListener { 056 057 /** The length of unicode placeholder. */ 058 private static final int UNICODE_LENGTH = 4; 059 060 /** Unicode escaping upper limit. */ 061 private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F; 062 063 /** Input stream buffer size. */ 064 private static final int BUFFER_SIZE = 1024; 065 066 /** The placeholder for message. */ 067 private static final String MESSAGE_PLACEHOLDER = "${message}"; 068 069 /** The placeholder for message text. */ 070 private static final String MESSAGE_TEXT_PLACEHOLDER = "${messageText}"; 071 072 /** The placeholder for message id. */ 073 private static final String MESSAGE_ID_PLACEHOLDER = "${messageId}"; 074 075 /** The placeholder for severity level. */ 076 private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}"; 077 078 /** The placeholder for uri. */ 079 private static final String URI_PLACEHOLDER = "${uri}"; 080 081 /** The placeholder for line. */ 082 private static final String LINE_PLACEHOLDER = "${line}"; 083 084 /** The placeholder for column. */ 085 private static final String COLUMN_PLACEHOLDER = "${column}"; 086 087 /** The placeholder for rule id. */ 088 private static final String RULE_ID_PLACEHOLDER = "${ruleId}"; 089 090 /** The placeholder for version. */ 091 private static final String VERSION_PLACEHOLDER = "${version}"; 092 093 /** The placeholder for results. */ 094 private static final String RESULTS_PLACEHOLDER = "${results}"; 095 096 /** The placeholder for rules. */ 097 private static final String RULES_PLACEHOLDER = "${rules}"; 098 099 /** 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) 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 new LocalizedMessage.Utf8Control() 324 ); 325 for (String key : moduleDetails.getViolationMessageKeys()) { 326 result.put(key, bundle.getString(key)); 327 } 328 } 329 catch (ClassNotFoundException | MissingResourceException ignored) { 330 // Return empty map when module class or resource bundle is not on classpath. 331 // Occurs with third-party modules that have XML metadata but missing implementation. 332 } 333 return result; 334 } 335 336 /** 337 * Returns the version string. 338 * 339 * @param report report content where replace should happen 340 * @return a version string based on the package implementation version 341 */ 342 private static String replaceVersionString(String report) { 343 final String version = SarifLogger.class.getPackage().getImplementationVersion(); 344 return report.replace(VERSION_PLACEHOLDER, Objects.toString(version, "null")); 345 } 346 347 @Override 348 public void addError(AuditEvent event) { 349 final RuleKey ruleKey = cacheRuleMetadata(event); 350 final String message = generateMessage(ruleKey, event); 351 if (event.getColumn() > 0) { 352 results.add(fillTemplate(resultLineColumn, Map.of( 353 SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()), 354 URI_PLACEHOLDER, renderFileNameUri(event.getFileName()), 355 COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()), 356 LINE_PLACEHOLDER, Integer.toString(event.getLine()), 357 MESSAGE_PLACEHOLDER, message, 358 RULE_ID_PLACEHOLDER, ruleKey.toRuleId()))); 359 } 360 else { 361 results.add(fillTemplate(resultLineOnly, Map.of( 362 SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()), 363 URI_PLACEHOLDER, renderFileNameUri(event.getFileName()), 364 LINE_PLACEHOLDER, Integer.toString(event.getLine()), 365 MESSAGE_PLACEHOLDER, message, 366 RULE_ID_PLACEHOLDER, ruleKey.toRuleId()))); 367 } 368 } 369 370 /** 371 * Caches rule metadata for a given audit event. 372 * 373 * @param event the audit event 374 * @return the composite key for the rule 375 */ 376 private RuleKey cacheRuleMetadata(AuditEvent event) { 377 final String sourceName = event.getSourceName(); 378 final RuleKey key = new RuleKey(sourceName, event.getModuleId()); 379 final ModuleDetails module = allModuleMetadata.get(sourceName); 380 ruleMetadata.putIfAbsent(key, module); 381 return key; 382 } 383 384 /** 385 * Generate message for the given rule key and audit event. 386 * 387 * @param ruleKey the rule key 388 * @param event the audit event 389 * @return the generated message 390 */ 391 private String generateMessage(RuleKey ruleKey, AuditEvent event) { 392 final String violationKey = event.getViolation().getKey(); 393 final ModuleDetails module = ruleMetadata.get(ruleKey); 394 final String result; 395 if (module != null && module.getViolationMessageKeys().contains(violationKey)) { 396 result = messageWithId 397 .replace(MESSAGE_ID_PLACEHOLDER, violationKey) 398 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage())); 399 } 400 else { 401 result = messageTextOnly 402 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage())); 403 } 404 return result; 405 } 406 407 @Override 408 public void addException(AuditEvent event, Throwable throwable) { 409 final StringWriter stringWriter = new StringWriter(); 410 final PrintWriter printer = new PrintWriter(stringWriter); 411 throwable.printStackTrace(printer); 412 final String message = messageTextOnly 413 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(stringWriter.toString())); 414 if (event.getFileName() == null) { 415 results.add(fillTemplate(resultErrorOnly, Map.of( 416 SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()), 417 MESSAGE_PLACEHOLDER, message))); 418 } 419 else { 420 results.add(fillTemplate(resultFileOnly, Map.of( 421 SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()), 422 URI_PLACEHOLDER, renderFileNameUri(event.getFileName()), 423 MESSAGE_PLACEHOLDER, message))); 424 } 425 } 426 427 @Override 428 public void fileStarted(AuditEvent event) { 429 // No need to implement this method in this class 430 } 431 432 @Override 433 public void fileFinished(AuditEvent event) { 434 // No need to implement this method in this class 435 } 436 437 /** 438 * Fill a template with its values in a single pass, so a value substituted for one 439 * placeholder is never scanned again and taken for another. A file name or message that 440 * happens to carry placeholder text is therefore kept verbatim instead of pulling 441 * another value into it. 442 * 443 * @param template the template to fill 444 * @param values the value to substitute for each placeholder 445 * @return the filled template 446 */ 447 private static String fillTemplate(String template, Map<String, String> values) { 448 final Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); 449 final StringBuilder result = new StringBuilder(256); 450 while (matcher.find()) { 451 final String placeholder = matcher.group(); 452 final String value = values.getOrDefault(placeholder, placeholder); 453 matcher.appendReplacement(result, Matcher.quoteReplacement(value)); 454 } 455 matcher.appendTail(result); 456 return result.toString(); 457 } 458 459 /** 460 * Render the file name URI for the given file name. 461 * 462 * @param fileName the file name to render the URI for 463 * @return the rendered URI for the given file name 464 */ 465 private static String renderFileNameUri(final String fileName) { 466 final String withoutSpaces = 467 A_SPACE_PATTERN 468 .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/")) 469 .replaceAll("%20"); 470 String normalized = A_QUOTE_PATTERN.matcher(withoutSpaces).replaceAll("%22"); 471 if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) { 472 normalized = '/' + normalized; 473 } 474 return "file:" + normalized; 475 } 476 477 /** 478 * Render the severity level into SARIF severity level. 479 * 480 * @param severityLevel the Severity level. 481 * @return the rendered severity level in string. 482 */ 483 private static String renderSeverityLevel(SeverityLevel severityLevel) { 484 return switch (severityLevel) { 485 case IGNORE -> "none"; 486 case INFO -> "note"; 487 case WARNING -> "warning"; 488 case ERROR -> "error"; 489 }; 490 } 491 492 /** 493 * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F. 494 * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings 495 * 496 * @param value the value to escape. 497 * @return the escaped value if necessary. 498 */ 499 public static String escape(String value) { 500 final int length = value.length(); 501 final StringBuilder sb = new StringBuilder(length); 502 for (int index = 0; index < length; index++) { 503 final char chr = value.charAt(index); 504 final String replacement = switch (chr) { 505 case '"' -> "\\\""; 506 case '\\' -> TWO_BACKSLASHES; 507 case '\b' -> "\\b"; 508 case '\f' -> "\\f"; 509 case '\n' -> "\\n"; 510 case '\r' -> "\\r"; 511 case '\t' -> "\\t"; 512 case '/' -> "\\/"; 513 default -> { 514 if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) { 515 yield escapeUnicode1F(chr); 516 } 517 yield Character.toString(chr); 518 } 519 }; 520 sb.append(replacement); 521 } 522 523 return sb.toString(); 524 } 525 526 /** 527 * Escape the character between 0x00 to 0x1F in JSON. 528 * 529 * @param chr the character to be escaped. 530 * @return the escaped string. 531 */ 532 private static String escapeUnicode1F(char chr) { 533 final String hexString = Integer.toHexString(chr); 534 return "\\u" 535 + "0".repeat(UNICODE_LENGTH - hexString.length()) 536 + hexString.toUpperCase(Locale.US); 537 } 538 539 /** 540 * Read string from given resource. 541 * 542 * @param name name of the desired resource 543 * @return the string content from the give resource 544 * @throws IOException if there is reading errors 545 */ 546 public static String readResource(String name) throws IOException { 547 try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name); 548 ByteArrayOutputStream result = new ByteArrayOutputStream()) { 549 if (inputStream == null) { 550 throw new IOException("Cannot find the resource " + name); 551 } 552 final byte[] buffer = new byte[BUFFER_SIZE]; 553 int length = 0; 554 while (length != -1) { 555 result.write(buffer, 0, length); 556 length = inputStream.read(buffer); 557 } 558 return result.toString(StandardCharsets.UTF_8); 559 } 560 } 561 562 /** 563 * Composite key for uniquely identifying a rule by source name and module ID. 564 * 565 * @param sourceName The fully qualified source class name. 566 * @param moduleId The module ID from configuration (can be null). 567 */ 568 private record RuleKey(String sourceName, String moduleId) { 569 /** 570 * Converts this key to a SARIF rule ID string. 571 * 572 * @return rule ID in format: sourceName[#moduleId] 573 */ 574 private String toRuleId() { 575 final String result; 576 if (moduleId == null) { 577 result = sourceName; 578 } 579 else { 580 result = sourceName + '#' + moduleId; 581 } 582 return result; 583 } 584 } 585 586}