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