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