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 a double quote. */ 105 private static final Pattern A_QUOTE_PATTERN = Pattern.compile("\""); 106 107 /** A pattern for two backslashes. */ 108 private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES); 109 110 /** A pattern to match a file with a Windows drive letter. */ 111 private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN = 112 Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE); 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 IllegalArgumentException if outputStreamOptions is null 165 * @throws IOException if there is reading errors. 166 * @noinspection deprecation 167 * @noinspectionreason We are forced to keep AutomaticBean compatability 168 * because of maven-checkstyle-plugin. Until #12873. 169 */ 170 public SarifLogger( 171 OutputStream outputStream, 172 AutomaticBean.OutputStreamOptions outputStreamOptions) throws IOException { 173 this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name())); 174 } 175 176 /** 177 * Creates a new {@code SarifLogger} instance. 178 * 179 * @param outputStream where to log audit events 180 * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished() 181 * @throws IllegalArgumentException if outputStreamOptions is null 182 * @throws IOException if there is reading errors. 183 */ 184 public SarifLogger( 185 OutputStream outputStream, 186 OutputStreamOptions outputStreamOptions) throws IOException { 187 if (outputStreamOptions == null) { 188 throw new IllegalArgumentException("Parameter outputStreamOptions can not be null"); 189 } 190 writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); 191 closeStream = outputStreamOptions == OutputStreamOptions.CLOSE; 192 loadModuleMetadata(); 193 report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template"); 194 resultLineColumn = 195 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template"); 196 resultLineOnly = 197 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template"); 198 resultFileOnly = 199 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template"); 200 resultErrorOnly = 201 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template"); 202 rule = readResource("/com/puppycrawl/tools/checkstyle/sarif/Rule.template"); 203 messageStrings = 204 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageStrings.template"); 205 messageTextOnly = 206 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageTextOnly.template"); 207 messageWithId = 208 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageWithId.template"); 209 } 210 211 /** 212 * Loads all available module metadata from XML files. 213 */ 214 private void loadModuleMetadata() { 215 final List<ModuleDetails> allModules = 216 XmlMetaReader.readAllModulesIncludingThirdPartyIfAny(); 217 for (ModuleDetails module : allModules) { 218 allModuleMetadata.put(module.getFullQualifiedName(), module); 219 } 220 } 221 222 @Override 223 protected void finishLocalSetup() { 224 // No code by default 225 } 226 227 @Override 228 public void auditStarted(AuditEvent event) { 229 // No code by default 230 } 231 232 @Override 233 public void auditFinished(AuditEvent event) { 234 String rendered = replaceVersionString(report); 235 rendered = rendered 236 .replace(RESULTS_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, results)) 237 .replace(RULES_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, generateRules())); 238 writer.print(rendered); 239 if (closeStream) { 240 writer.close(); 241 } 242 else { 243 writer.flush(); 244 } 245 } 246 247 /** 248 * Generates rules from cached rule metadata. 249 * 250 * @return list of rules 251 */ 252 private List<String> generateRules() { 253 final List<String> result = new ArrayList<>(); 254 for (Map.Entry<RuleKey, ModuleDetails> entry : ruleMetadata.entrySet()) { 255 final RuleKey ruleKey = entry.getKey(); 256 final ModuleDetails module = entry.getValue(); 257 final String shortDescription; 258 final String fullDescription; 259 final String messageStringsFragment; 260 if (module == null) { 261 shortDescription = CommonUtil.baseClassName(ruleKey.sourceName()); 262 fullDescription = "No description available"; 263 messageStringsFragment = ""; 264 } 265 else { 266 shortDescription = module.getName(); 267 fullDescription = module.getDescription(); 268 messageStringsFragment = String.join(COMMA_LINE_SEPARATOR, 269 generateMessageStrings(module)); 270 } 271 result.add(rule 272 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId()) 273 .replace("${shortDescription}", shortDescription) 274 .replace("${fullDescription}", escape(fullDescription)) 275 .replace("${messageStrings}", messageStringsFragment)); 276 } 277 return result; 278 } 279 280 /** 281 * Generates message strings for a given module. 282 * 283 * @param module the module 284 * @return the generated message strings 285 */ 286 private List<String> generateMessageStrings(ModuleDetails module) { 287 final Map<String, String> messages = getMessages(module); 288 return module.getViolationMessageKeys().stream() 289 .filter(messages::containsKey) 290 .map(key -> { 291 final String message = messages.get(key); 292 return messageStrings 293 .replace("${key}", key) 294 .replace("${text}", escape(message)); 295 }) 296 .toList(); 297 } 298 299 /** 300 * Gets a map of message keys to their message strings for a module. 301 * 302 * @param moduleDetails the module details 303 * @return map of message keys to message strings 304 */ 305 private static Map<String, String> getMessages(ModuleDetails moduleDetails) { 306 final String fullQualifiedName = moduleDetails.getFullQualifiedName(); 307 final Map<String, String> result = new LinkedHashMap<>(); 308 try { 309 final int lastDot = fullQualifiedName.lastIndexOf('.'); 310 final String packageName = fullQualifiedName.substring(0, lastDot); 311 final String bundleName = packageName + ".messages"; 312 final Class<?> moduleClass = Class.forName(fullQualifiedName); 313 final ResourceBundle bundle = ResourceBundle.getBundle( 314 bundleName, 315 Locale.ROOT, 316 moduleClass.getClassLoader(), 317 new LocalizedMessage.Utf8Control() 318 ); 319 for (String key : moduleDetails.getViolationMessageKeys()) { 320 result.put(key, bundle.getString(key)); 321 } 322 } 323 catch (ClassNotFoundException | MissingResourceException ignored) { 324 // Return empty map when module class or resource bundle is not on classpath. 325 // Occurs with third-party modules that have XML metadata but missing implementation. 326 } 327 return result; 328 } 329 330 /** 331 * Returns the version string. 332 * 333 * @param report report content where replace should happen 334 * @return a version string based on the package implementation version 335 */ 336 private static String replaceVersionString(String report) { 337 final String version = SarifLogger.class.getPackage().getImplementationVersion(); 338 return report.replace(VERSION_PLACEHOLDER, Objects.toString(version, "null")); 339 } 340 341 @Override 342 public void addError(AuditEvent event) { 343 final RuleKey ruleKey = cacheRuleMetadata(event); 344 final String message = generateMessage(ruleKey, event); 345 if (event.getColumn() > 0) { 346 results.add(resultLineColumn 347 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel())) 348 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName())) 349 .replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn())) 350 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine())) 351 .replace(MESSAGE_PLACEHOLDER, message) 352 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId()) 353 ); 354 } 355 else { 356 results.add(resultLineOnly 357 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel())) 358 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName())) 359 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine())) 360 .replace(MESSAGE_PLACEHOLDER, message) 361 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId()) 362 ); 363 } 364 } 365 366 /** 367 * Caches rule metadata for a given audit event. 368 * 369 * @param event the audit event 370 * @return the composite key for the rule 371 */ 372 private RuleKey cacheRuleMetadata(AuditEvent event) { 373 final String sourceName = event.getSourceName(); 374 final RuleKey key = new RuleKey(sourceName, event.getModuleId()); 375 final ModuleDetails module = allModuleMetadata.get(sourceName); 376 ruleMetadata.putIfAbsent(key, module); 377 return key; 378 } 379 380 /** 381 * Generate message for the given rule key and audit event. 382 * 383 * @param ruleKey the rule key 384 * @param event the audit event 385 * @return the generated message 386 */ 387 private String generateMessage(RuleKey ruleKey, AuditEvent event) { 388 final String violationKey = event.getViolation().getKey(); 389 final ModuleDetails module = ruleMetadata.get(ruleKey); 390 final String result; 391 if (module != null && module.getViolationMessageKeys().contains(violationKey)) { 392 result = messageWithId 393 .replace(MESSAGE_ID_PLACEHOLDER, violationKey) 394 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage())); 395 } 396 else { 397 result = messageTextOnly 398 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage())); 399 } 400 return result; 401 } 402 403 @Override 404 public void addException(AuditEvent event, Throwable throwable) { 405 final StringWriter stringWriter = new StringWriter(); 406 final PrintWriter printer = new PrintWriter(stringWriter); 407 throwable.printStackTrace(printer); 408 final String message = messageTextOnly 409 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(stringWriter.toString())); 410 if (event.getFileName() == null) { 411 results.add(resultErrorOnly 412 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel())) 413 .replace(MESSAGE_PLACEHOLDER, message) 414 ); 415 } 416 else { 417 results.add(resultFileOnly 418 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel())) 419 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName())) 420 .replace(MESSAGE_PLACEHOLDER, message) 421 ); 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 * Render the file name URI for the given file name. 437 * 438 * @param fileName the file name to render the URI for 439 * @return the rendered URI for the given file name 440 */ 441 private static String renderFileNameUri(final String fileName) { 442 final String withoutSpaces = 443 A_SPACE_PATTERN 444 .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/")) 445 .replaceAll("%20"); 446 String normalized = A_QUOTE_PATTERN.matcher(withoutSpaces).replaceAll("%22"); 447 if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) { 448 normalized = '/' + normalized; 449 } 450 return "file:" + normalized; 451 } 452 453 /** 454 * Render the severity level into SARIF severity level. 455 * 456 * @param severityLevel the Severity level. 457 * @return the rendered severity level in string. 458 */ 459 private static String renderSeverityLevel(SeverityLevel severityLevel) { 460 return switch (severityLevel) { 461 case IGNORE -> "none"; 462 case INFO -> "note"; 463 case WARNING -> "warning"; 464 case ERROR -> "error"; 465 }; 466 } 467 468 /** 469 * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F. 470 * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings 471 * 472 * @param value the value to escape. 473 * @return the escaped value if necessary. 474 */ 475 public static String escape(String value) { 476 final int length = value.length(); 477 final StringBuilder sb = new StringBuilder(length); 478 for (int i = 0; i < length; i++) { 479 final char chr = value.charAt(i); 480 final String replacement = switch (chr) { 481 case '"' -> "\\\""; 482 case '\\' -> TWO_BACKSLASHES; 483 case '\b' -> "\\b"; 484 case '\f' -> "\\f"; 485 case '\n' -> "\\n"; 486 case '\r' -> "\\r"; 487 case '\t' -> "\\t"; 488 case '/' -> "\\/"; 489 default -> { 490 if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) { 491 yield escapeUnicode1F(chr); 492 } 493 yield Character.toString(chr); 494 } 495 }; 496 sb.append(replacement); 497 } 498 499 return sb.toString(); 500 } 501 502 /** 503 * Escape the character between 0x00 to 0x1F in JSON. 504 * 505 * @param chr the character to be escaped. 506 * @return the escaped string. 507 */ 508 private static String escapeUnicode1F(char chr) { 509 final String hexString = Integer.toHexString(chr); 510 return "\\u" 511 + "0".repeat(UNICODE_LENGTH - hexString.length()) 512 + hexString.toUpperCase(Locale.US); 513 } 514 515 /** 516 * Read string from given resource. 517 * 518 * @param name name of the desired resource 519 * @return the string content from the give resource 520 * @throws IOException if there is reading errors 521 */ 522 public static String readResource(String name) throws IOException { 523 try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name); 524 ByteArrayOutputStream result = new ByteArrayOutputStream()) { 525 if (inputStream == null) { 526 throw new IOException("Cannot find the resource " + name); 527 } 528 final byte[] buffer = new byte[BUFFER_SIZE]; 529 int length = 0; 530 while (length != -1) { 531 result.write(buffer, 0, length); 532 length = inputStream.read(buffer); 533 } 534 return result.toString(StandardCharsets.UTF_8); 535 } 536 } 537 538 /** 539 * Composite key for uniquely identifying a rule by source name and module ID. 540 * 541 * @param sourceName The fully qualified source class name. 542 * @param moduleId The module ID from configuration (can be null). 543 */ 544 private record RuleKey(String sourceName, String moduleId) { 545 /** 546 * Converts this key to a SARIF rule ID string. 547 * 548 * @return rule ID in format: sourceName[#moduleId] 549 */ 550 private String toRuleId() { 551 final String result; 552 if (moduleId == null) { 553 result = sourceName; 554 } 555 else { 556 result = sourceName + '#' + moduleId; 557 } 558 return result; 559 } 560 } 561}