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