SarifLogger.java
////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code for adherence to a set of rules.
// Copyright (C) 2001-2021 the original author or authors.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
////////////////////////////////////////////////////////////////////////////////
package com.puppycrawl.tools.checkstyle;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.puppycrawl.tools.checkstyle.api.AuditEvent;
import com.puppycrawl.tools.checkstyle.api.AuditListener;
import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
/**
* Simple SARIF logger.
* SARIF stands for the static analysis results interchange format.
* Reference: https://sarifweb.azurewebsites.net/
*/
public class SarifLogger extends AutomaticBean implements AuditListener {
/** The length of unicode placeholder. */
private static final int UNICODE_LENGTH = 4;
/** Unicode escaping upper limit. */
private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F;
/** Input stream buffer size. */
private static final int BUFFER_SIZE = 1024;
/** The placeholder for message. */
private static final String MESSAGE_PLACEHOLDER = "${message}";
/** The placeholder for severity level. */
private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}";
/** The placeholder for uri. */
private static final String URI_PLACEHOLDER = "${uri}";
/** The placeholder for line. */
private static final String LINE_PLACEHOLDER = "${line}";
/** The placeholder for column. */
private static final String COLUMN_PLACEHOLDER = "${column}";
/** The placeholder for rule id. */
private static final String RULE_ID_PLACEHOLDER = "${ruleId}";
/** The placeholder for version. */
private static final String VERSION_PLACEHOLDER = "${version}";
/** The placeholder for results. */
private static final String RESULTS_PLACEHOLDER = "${results}";
/** Helper writer that allows easy encoding and printing. */
private final PrintWriter writer;
/** Close output stream in auditFinished. */
private final boolean closeStream;
/** The results. */
private final List<String> results = new ArrayList<>();
/** Content for the entire report. */
private final String report;
/** Content for result representing an error with source line and column. */
private final String resultLineColumn;
/** Content for result representing an error with source line only. */
private final String resultLineOnly;
/** Content for result representing an error with filename only and without source location. */
private final String resultFileOnly;
/** Content for result representing an error without filename or location. */
private final String resultErrorOnly;
/**
* Creates a new {@code SarifLogger} instance.
*
* @param outputStream where to log audit events
* @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
* @throws IllegalArgumentException if outputStreamOptions is null
* @throws IOException if there is reading errors.
*/
public SarifLogger(
OutputStream outputStream,
OutputStreamOptions outputStreamOptions) throws IOException {
if (outputStreamOptions == null) {
throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
}
writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template");
resultLineColumn =
readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template");
resultLineOnly =
readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template");
resultFileOnly =
readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template");
resultErrorOnly =
readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template");
}
@Override
protected void finishLocalSetup() {
// No code by default
}
@Override
public void auditStarted(AuditEvent event) {
// No code by default
}
/**
* {@inheritDoc}
* Following idea suppressions are false positives
*
* @noinspection DynamicRegexReplaceableByCompiledPattern
*/
@Override
public void auditFinished(AuditEvent event) {
final String version = SarifLogger.class.getPackage().getImplementationVersion();
final String rendered = report
.replace(VERSION_PLACEHOLDER, String.valueOf(version))
.replace(RESULTS_PLACEHOLDER, String.join(",\n", results));
writer.print(rendered);
if (closeStream) {
writer.close();
}
else {
writer.flush();
}
}
/**
* {@inheritDoc}
* Following idea suppressions are false positives
*
* @noinspection DynamicRegexReplaceableByCompiledPattern
*/
@Override
public void addError(AuditEvent event) {
if (event.getColumn() > 0) {
results.add(resultLineColumn
.replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
.replace(URI_PLACEHOLDER, event.getFileName())
.replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()))
.replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
.replace(MESSAGE_PLACEHOLDER, escape(event.getMessage()))
.replace(RULE_ID_PLACEHOLDER, event.getViolation().getKey())
);
}
else {
results.add(resultLineOnly
.replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
.replace(URI_PLACEHOLDER, event.getFileName())
.replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
.replace(MESSAGE_PLACEHOLDER, escape(event.getMessage()))
.replace(RULE_ID_PLACEHOLDER, event.getViolation().getKey())
);
}
}
/**
* {@inheritDoc}
* Following idea suppressions are false positives
*
* @noinspection DynamicRegexReplaceableByCompiledPattern
*/
@Override
public void addException(AuditEvent event, Throwable throwable) {
final StringWriter stringWriter = new StringWriter();
final PrintWriter printer = new PrintWriter(stringWriter);
throwable.printStackTrace(printer);
if (event.getFileName() == null) {
results.add(resultErrorOnly
.replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
.replace(MESSAGE_PLACEHOLDER, escape(stringWriter.toString()))
);
}
else {
results.add(resultFileOnly
.replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
.replace(URI_PLACEHOLDER, event.getFileName())
.replace(MESSAGE_PLACEHOLDER, escape(stringWriter.toString()))
);
}
}
@Override
public void fileStarted(AuditEvent event) {
// No need to implement this method in this class
}
@Override
public void fileFinished(AuditEvent event) {
// No need to implement this method in this class
}
/**
* Render the severity level into SARIF severity level.
*
* @param severityLevel the Severity level.
* @return the rendered severity level in string.
*/
private static String renderSeverityLevel(SeverityLevel severityLevel) {
final String renderedSeverityLevel;
switch (severityLevel) {
case IGNORE:
renderedSeverityLevel = "none";
break;
case INFO:
renderedSeverityLevel = "note";
break;
case WARNING:
renderedSeverityLevel = "warning";
break;
case ERROR:
default:
renderedSeverityLevel = "error";
break;
}
return renderedSeverityLevel;
}
/**
* Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F.
* Reference: https://www.ietf.org/rfc/rfc4627.txt - 2.5. Strings
*
* @param value the value to escape.
* @return the escaped value if necessary.
*/
public static String escape(String value) {
final StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
final char chr = value.charAt(i);
switch (chr) {
case '"':
sb.append("\\\"");
break;
case '\\':
sb.append("\\\\");
break;
case '\b':
sb.append("\\b");
break;
case '\f':
sb.append("\\f");
break;
case '\n':
sb.append("\\n");
break;
case '\r':
sb.append("\\r");
break;
case '\t':
sb.append("\\t");
break;
case '/':
sb.append("\\/");
break;
default:
if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) {
sb.append(escapeUnicode1F(chr));
}
else {
sb.append(chr);
}
break;
}
}
return sb.toString();
}
/**
* Escape the character between 0x00 to 0x1F in JSON.
*
* @param chr the character to be escaped.
* @return the escaped string.
*/
private static String escapeUnicode1F(char chr) {
final StringBuilder stringBuilder = new StringBuilder(UNICODE_LENGTH + 1);
stringBuilder.append("\\u");
final String hexString = Integer.toHexString(chr);
for (int i = 0; i < UNICODE_LENGTH - hexString.length(); i++) {
stringBuilder.append('0');
}
stringBuilder.append(hexString.toUpperCase(Locale.US));
return stringBuilder.toString();
}
/**
* Read string from given resource.
*
* @param name name of the desired resource
* @return the string content from the give resource
* @throws IOException if there is reading errors
*/
public static String readResource(String name) throws IOException {
try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name);
ByteArrayOutputStream result = new ByteArrayOutputStream()) {
if (inputStream == null) {
throw new IOException("Cannot find the resource " + name);
}
final byte[] buffer = new byte[BUFFER_SIZE];
int length = inputStream.read(buffer);
while (length != -1) {
result.write(buffer, 0, length);
length = inputStream.read(buffer);
}
return result.toString(StandardCharsets.UTF_8.name());
}
}
}