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 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}