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 IllegalArgumentException if outputStreamOptions is null 169 * @throws IOException if there is reading errors. 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 IllegalArgumentException if outputStreamOptions is null 186 * @throws IOException if there is reading errors. 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}