diff --git a/framework/build.gradle b/framework/build.gradle index 662b905a5a86..22313d269f17 100644 --- a/framework/build.gradle +++ b/framework/build.gradle @@ -53,6 +53,8 @@ dependencies { implementation(libs.plume.util) implementation(libs.reflection.util) implementation(libs.classgraph) + implementation "com.fasterxml.jackson.core:jackson-databind:2.15.0" + implementation "com.contrastsecurity:java-sarif:2.0" testImplementation(libs.junit) testImplementation(project(":framework-test")) diff --git a/framework/src/main/java/org/checkerframework/framework/report/SarifReportGenerator.java b/framework/src/main/java/org/checkerframework/framework/report/SarifReportGenerator.java new file mode 100644 index 000000000000..1f6027580133 --- /dev/null +++ b/framework/src/main/java/org/checkerframework/framework/report/SarifReportGenerator.java @@ -0,0 +1,185 @@ +package org.checkerframework.framework.report; + +import com.contrastsecurity.sarif.ArtifactLocation; +import com.contrastsecurity.sarif.Location; +import com.contrastsecurity.sarif.Message; +import com.contrastsecurity.sarif.PhysicalLocation; +import com.contrastsecurity.sarif.Region; +import com.contrastsecurity.sarif.Result; +import com.contrastsecurity.sarif.Result.Level; +import com.contrastsecurity.sarif.Run; +import com.contrastsecurity.sarif.SarifSchema210; +import com.contrastsecurity.sarif.Tool; +import com.contrastsecurity.sarif.ToolComponent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.source.tree.CompilationUnitTree; +import com.sun.source.tree.LineMap; +import com.sun.source.tree.Tree; +import com.sun.source.util.SourcePositions; +import com.sun.source.util.Trees; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import javax.annotation.processing.ProcessingEnvironment; +import javax.tools.Diagnostic; + +/** + * Generates SARIF (Static Analysis Results Interchange Format) report files from checker + * diagnostics. + * + *

This class collects diagnostic messages during type checking and generates a SARIF 2.1.0 + * compliant JSON report file. The report includes error and warning messages with their locations + * in the source code. + */ +public class SarifReportGenerator { + + private final ProcessingEnvironment processingEnv; + private final List results = new ArrayList<>(); + + public SarifReportGenerator(ProcessingEnvironment processingEnv) { + this.processingEnv = processingEnv; + } + + /** + * Converts the source file path to a file URI. + * + * @param root the compilation unit + * @return the file URI as a string, or "file:///unknown" if conversion fails + */ + private String getFileUri(CompilationUnitTree root) { + try { + return root.getSourceFile().toUri().toString(); + } catch (IllegalArgumentException | SecurityException e) { + return "file:///unknown"; + } + } + + /** + * Extracts the source code region (line and column numbers) for a tree node. + * + * @param source the tree node + * @param root the compilation unit containing the tree + * @return a Region with start and end line/column numbers, or default values if position cannot + * be determined + */ + private Region getRegion(Tree source, CompilationUnitTree root) { + Trees trees = Trees.instance(processingEnv); + SourcePositions sourcePositions = trees.getSourcePositions(); + + long startPos = sourcePositions.getStartPosition(root, source); + long endPos = sourcePositions.getEndPosition(root, source); + + if (startPos == Diagnostic.NOPOS || endPos == Diagnostic.NOPOS) { + return new Region().withStartLine(1).withStartColumn(1).withEndLine(1).withEndColumn(1); + } + + LineMap lineMap = root.getLineMap(); + long startLine = lineMap.getLineNumber(startPos); + long startCol = lineMap.getColumnNumber(startPos); + long endLine = lineMap.getLineNumber(endPos); + long endCol = lineMap.getColumnNumber(endPos); + + return new Region() + .withStartLine((int) startLine) + .withStartColumn((int) startCol) + .withEndLine((int) endLine) + .withEndColumn((int) endCol); + } + + /** + * Add a diagnostic result to the report. + * + *

Only ERROR and MANDATORY_WARNING diagnostics are collected. Other diagnostic kinds (NOTE, + * etc.) are ignored. + * + * @param kind the diagnostic kind + * @param message the message text + * @param messageKey the message key (rule ID) + * @param source the source tree node where the diagnostic was reported + * @param root the compilation unit containing the source + */ + public void addResult( + javax.tools.Diagnostic.Kind kind, + String message, + String messageKey, + Tree source, + CompilationUnitTree root) { + // Only collect ERROR and WARNING diagnostics + if (kind != javax.tools.Diagnostic.Kind.ERROR + && kind != javax.tools.Diagnostic.Kind.MANDATORY_WARNING) { + return; + } + String fileUri = getFileUri(root); + + Region region = getRegion(source, root); + + Result result = + new Result() + .withRuleId(messageKey) + .withLevel(kind == javax.tools.Diagnostic.Kind.ERROR ? Level.ERROR : Level.WARNING) + .withMessage(new Message().withText(message)) + .withLocations( + Collections.singletonList( + new Location() + .withPhysicalLocation( + new PhysicalLocation() + .withArtifactLocation(new ArtifactLocation().withUri(fileUri)) + .withRegion(region)))); + results.add(result); + } + + /** + * Returns the Checker Framework version from git.properties resource file. + * + *

If the version cannot be read from git.properties, returns a default fallback version. + * + * @return the Checker Framework version string + */ + private String getCheckerVersion() { + try (InputStream in = getClass().getResourceAsStream("/git.properties")) { + if (in != null) { + Properties gitProperties = new Properties(); + gitProperties.load(in); + String version = gitProperties.getProperty("git.build.version"); + if (version != null && !version.isEmpty()) { + return version; + } + } + } catch (IOException e) { + // Fall through to return default version + } + return "Unknown"; + } + + /** + * Write the SARIF report to file. + * + * @param outputPath the output file path + */ + public void writeReport(String outputPath) throws IOException { + + SarifSchema210 sarifLog = + new SarifSchema210() + .withVersion(SarifSchema210.Version._2_1_0) + .withRuns( + Collections.singletonList( + new Run() + .withTool( + new Tool() + .withDriver( + new ToolComponent() + .withName("Checker Framework") + .withVersion(getCheckerVersion()))) + .withResults(results))); + + // Write SARIF log to JSON file + ObjectMapper mapper = new ObjectMapper(); + Path path = Paths.get(outputPath); + mapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), sarifLog); + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/source/AggregateChecker.java b/framework/src/main/java/org/checkerframework/framework/source/AggregateChecker.java index aba28fb67137..af9548d45c74 100644 --- a/framework/src/main/java/org/checkerframework/framework/source/AggregateChecker.java +++ b/framework/src/main/java/org/checkerframework/framework/source/AggregateChecker.java @@ -18,7 +18,7 @@ *

Though each checker is run on a whole compilation unit before the next checker is run, error * and warning messages are collected and sorted based on the location in the source file before * being printed. (See {@link #printOrStoreMessage(Diagnostic.Kind, String, Tree, - * CompilationUnitTree)}.) + * CompilationUnitTree, String)}.) * *

This class delegates {@code AbstractTypeProcessor} responsibilities to each component checker. * diff --git a/framework/src/main/java/org/checkerframework/framework/source/SourceChecker.java b/framework/src/main/java/org/checkerframework/framework/source/SourceChecker.java index 9e7e55523307..a8dbaf3ad6ba 100644 --- a/framework/src/main/java/org/checkerframework/framework/source/SourceChecker.java +++ b/framework/src/main/java/org/checkerframework/framework/source/SourceChecker.java @@ -76,6 +76,7 @@ import org.checkerframework.common.basetype.BaseTypeChecker; import org.checkerframework.common.reflection.MethodValChecker; import org.checkerframework.framework.qual.AnnotatedFor; +import org.checkerframework.framework.report.SarifReportGenerator; import org.checkerframework.framework.type.AnnotatedTypeFactory; import org.checkerframework.framework.util.CheckerMain; import org.checkerframework.framework.util.OptionConfiguration; @@ -442,7 +443,8 @@ // Converts type argument inference crashes into errors. By default, this option is true. // Use "-AconvertTypeArgInferenceCrashToWarning=false" to turn this option off and allow type // argument inference crashes to crash the type checker. - "convertTypeArgInferenceCrashToWarning" + "convertTypeArgInferenceCrashToWarning", + "sarifOutput" }) public abstract class SourceChecker extends AbstractTypeProcessor implements OptionConfiguration { @@ -627,6 +629,15 @@ public abstract class SourceChecker extends AbstractTypeProcessor implements Opt */ protected @Nullable SourceChecker parentChecker; + /** True if the -AsarifOutput command-line argument was passed. */ + private boolean sarifOutputEnabled = false; + + /** Path to SARIF output file. */ + private @Nullable String sarifOutputPath = null; + + /** SARIF report generator, if enabled. */ + private @Nullable SarifReportGenerator sarifReportGenerator = null; + /** List of upstream checker names. Includes the current checker. */ protected @MonotonicNonNull List<@FullyQualifiedName String> upstreamCheckerNames; @@ -749,6 +760,19 @@ protected void setParentChecker(SourceChecker parentChecker) { return this.parentChecker; } + /** + * Returns the root (ancestor) checker that has no parent. + * + * @return the root checker (this checker if it has no parent, otherwise the ultimate ancestor) + */ + protected SourceChecker getRootChecker() { + SourceChecker root = this; + while (root.parentChecker != null) { + root = root.parentChecker; + } + return root; + } + /** * Invoked when the current compilation unit root changes. * @@ -1080,6 +1104,16 @@ public void typeProcessingOver() { checker.typeProcessingOver(); } + if (parentChecker == null && sarifReportGenerator != null) { + assert sarifOutputPath != null; + try { + sarifReportGenerator.writeReport(sarifOutputPath); + message(Diagnostic.Kind.NOTE, "SARIF report written to: " + sarifOutputPath); + } catch (IOException e) { + message(Diagnostic.Kind.WARNING, "Failed to write SARIF report: " + e.getMessage()); + } + } + super.typeProcessingOver(); } @@ -1129,6 +1163,15 @@ public void initChecker() { requirePrefixInWarningSuppressions = hasOption("requirePrefixInWarningSuppressions"); showPrefixInWarningMessages = hasOption("showPrefixInWarningMessages"); warnUnneededSuppressions = hasOption("warnUnneededSuppressions"); + sarifOutputEnabled = hasOption("sarifOutput"); + // Only create sarifReportGenerator in root checker (no parentChecker) + if (sarifOutputEnabled && parentChecker == null) { + sarifOutputPath = getOption("sarifOutput"); + if (sarifOutputPath == null) { + throw new UserError("Must supply an argument to -AsarifOutput"); + } + sarifReportGenerator = new SarifReportGenerator(processingEnv); + } } /** Output the warning about source level at most once. */ @@ -1506,7 +1549,7 @@ private void report( if (source instanceof Element elem) { messager.printMessage(kind, messageText, elem); } else if (source instanceof Tree sourceTree) { - printOrStoreMessage(kind, messageText, sourceTree, currentRoot); + printOrStoreMessage(kind, messageText, sourceTree, currentRoot, messageKey); } else { throw new BugInCF("invalid position source of class " + source.getClass() + ": " + source); } @@ -1589,15 +1632,25 @@ public Collection getSuppressWarningsPrefixesOfSubcheckers() { * @param root the compilation unit */ protected void printOrStoreMessage( - javax.tools.Diagnostic.Kind kind, String message, Tree source, CompilationUnitTree root) { + javax.tools.Diagnostic.Kind kind, + String message, + Tree source, + CompilationUnitTree root, + String messageKey) { assert this.currentRoot == root; StackTraceElement[] trace = Thread.currentThread().getStackTrace(); if (messageStore == null) { - printOrStoreMessage(kind, message, source, root, trace); + printOrStoreMessage(kind, message, source, root, trace, messageKey); } else { - CheckerMessage checkerMessage = new CheckerMessage(kind, message, source, this, trace); + CheckerMessage checkerMessage = + new CheckerMessage(kind, message, source, this, trace, messageKey); messageStore.add(checkerMessage); } + // Use root checker's sarifReportGenerator so all subcheckers share the same instance + SourceChecker rootChecker = getRootChecker(); + if (rootChecker.sarifReportGenerator != null) { + rootChecker.sarifReportGenerator.addResult(kind, message, messageKey, source, root); + } } /** @@ -1618,7 +1671,8 @@ protected void printOrStoreMessage( String message, Tree source, CompilationUnitTree root, - StackTraceElement[] trace) { + StackTraceElement[] trace, + String messageKey) { Trees.instance(processingEnv).printMessage(kind, message, source, root); printStackTrace(trace); } @@ -2015,7 +2069,7 @@ protected final void setLintOption(String name, boolean val) { *

Though each checker is run on a whole compilation unit before the next checker is run, error * and warning messages are collected and sorted based on the location in the source file before * being printed. (See {@link #printOrStoreMessage(Diagnostic.Kind, String, Tree, - * CompilationUnitTree)}.) + * CompilationUnitTree, String)}.) * *

WARNING: Circular dependencies are not supported. (In other words, if checker A depends on * checker B, checker B cannot depend on checker A.) The Checker Framework does not check for @@ -2299,7 +2353,7 @@ protected void printStoredMessages(CompilationUnitTree unit) { return; } for (CheckerMessage msg : messageStore) { - printOrStoreMessage(msg.kind, msg.message, msg.source, unit, msg.trace); + printOrStoreMessage(msg.kind, msg.message, msg.source, unit, msg.trace, msg.messageKey); } } @@ -3529,6 +3583,9 @@ protected static class CheckerMessage implements Comparable { /** The stack trace when the message was created. */ final StackTraceElement[] trace; + /** The message key (rule ID) for this message. */ + final String messageKey; + /** * Create a new CheckerMessage. * @@ -3537,18 +3594,21 @@ protected static class CheckerMessage implements Comparable { * @param source tree node causing the error * @param checker the type-checker in use * @param trace the stack trace when the message is created + * @param messageKey the message key (rule ID) */ protected CheckerMessage( Diagnostic.Kind kind, String message, @FindDistinct Tree source, @FindDistinct SourceChecker checker, - StackTraceElement[] trace) { + StackTraceElement[] trace, + String messageKey) { this.kind = kind; this.message = message; this.source = source; this.checker = checker; this.trace = trace; + this.messageKey = messageKey; } @Override