diff --git a/docs/static-code-analysis-tool/README.md b/docs/static-code-analysis-tool/README.md index 6cb25d60..e26f5c34 100644 --- a/docs/static-code-analysis-tool/README.md +++ b/docs/static-code-analysis-tool/README.md @@ -74,41 +74,50 @@ bal scan --target-dir="results" ``` 4. Run analysis and generate a HTML analysis report. + ```bash bal scan --scan-report ``` -5. View all available rules. +5. Run analysis and specify the output format (ballerina or sarif). + +```bash +bal scan --format=sarif +``` + +> Note: The default format is ballerina. The tool supports both ballerina and sarif formats for analysis results. + +6. View all available rules. ```bash bal scan --list-rules ``` -6. Run analysis for a specific rule. +7. Run analysis for a specific rule. ```bash bal scan --include-rules="ballerina:101" ``` -7. Run analysis for a specific set of rules. +8. Run analysis for a specific set of rules. ```bash bal scan --include-rules="ballerina:101, ballerina/io:101" ``` -8. Exclude analysis for a specific rule. +9. Exclude analysis for a specific rule. ```bash bal scan --exclude-rules="ballerina:101" ``` -9. Exclude analysis for a specific set of rules. +10. Exclude analysis for a specific set of rules. ```bash bal scan --exclude-rules="ballerina:101, ballerina/io:101" ``` -10. Run analysis and report to a static analysis platform (e.g., SonarQube). +11. Run analysis and report to a static analysis platform (e.g., SonarQube). ```bash bal scan --platforms=sonarqube @@ -116,13 +125,13 @@ bal scan --platforms=sonarqube > Note: If the Platform Plugin path is not provided in a `Scan.toml` file, the tool will attempt to download the Platform Plugin for plugins developed by the Ballerina team. -11. Run analysis and report to multiple static analysis platforms. +12. Run analysis and report to multiple static analysis platforms. ```bash bal scan --platforms="sonarqube, semgrep, codeql" ``` -12. Configuring the tool's behavior using a configuration file. (e.g., `Scan.toml`) +13. Configuring the tool's behavior using a configuration file. (e.g., `Scan.toml`) ```md 📦ballerina_project diff --git a/gradle.properties b/gradle.properties index fc58f398..30575174 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group=io.ballerina.scan -version=0.10.1-SNAPSHOT +version=0.11.0-SNAPSHOT # Plugin versions spotbugsPluginVersion=6.1.5 diff --git a/scan-command-test-utils/src/main/java/io/ballerina/scan/test/TestOptions.java b/scan-command-test-utils/src/main/java/io/ballerina/scan/test/TestOptions.java index deac2bb9..2d80325f 100644 --- a/scan-command-test-utils/src/main/java/io/ballerina/scan/test/TestOptions.java +++ b/scan-command-test-utils/src/main/java/io/ballerina/scan/test/TestOptions.java @@ -19,6 +19,7 @@ package io.ballerina.scan.test; import io.ballerina.projects.Project; +import io.ballerina.scan.ReportFormat; import io.ballerina.scan.Rule; import java.io.PrintStream; @@ -37,20 +38,22 @@ public class TestOptions { private final boolean platformTriggered; private final String targetDir; private final boolean scanReport; + private final ReportFormat format; private final boolean listRules; private final List includeRules; private final List excludeRules; private final List platforms; private TestOptions(Project project, PrintStream outputStream, boolean helpFlag, boolean platformTriggered, - String targetDir, boolean scanReport, boolean listRules, List includeRules, - List excludeRules, List platforms) { + String targetDir, boolean scanReport, ReportFormat format, boolean listRules, + List includeRules, List excludeRules, List platforms) { this.project = project; this.outputStream = outputStream; this.helpFlag = helpFlag; this.platformTriggered = platformTriggered; this.targetDir = targetDir; this.scanReport = scanReport; + this.format = format; this.listRules = listRules; this.includeRules = includeRules; this.excludeRules = excludeRules; @@ -72,9 +75,9 @@ public static TestOptionsBuilder builder(Project project) { * * @return the project to be scanned */ - Project project() { + Project project() { return project; - } + } /** * Get the output stream. @@ -121,6 +124,15 @@ boolean scanReport() { return scanReport; } + /** + * Get the format of the report. + * + * @return the format of the report + */ + ReportFormat format() { + return format; + } + /** * Get if the rules should be listed or not. * @@ -164,6 +176,7 @@ public static class TestOptionsBuilder { private boolean platformTriggered; private String targetDir; private boolean scanReport; + private ReportFormat format; private boolean listRules; private List includeRules = List.of(); private List excludeRules = List.of(); @@ -179,6 +192,7 @@ private TestOptionsBuilder(Project project) { * @param outputStream the output stream * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setOutputStream(PrintStream outputStream) { this.outputStream = outputStream; return this; @@ -190,6 +204,7 @@ public TestOptionsBuilder setOutputStream(PrintStream outputStream) { * @param helpFlag true if the help flag needs to be enabled, false otherwise * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setHelpFlag(boolean helpFlag) { this.helpFlag = helpFlag; return this; @@ -198,9 +213,11 @@ public TestOptionsBuilder setHelpFlag(boolean helpFlag) { /** * Set if the scan is triggered by a platform. * - * @param platformTriggered true if the scan is triggered by a platform, false otherwise + * @param platformTriggered true if the scan is triggered by a platform, false + * otherwise * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setPlatformTriggered(boolean platformTriggered) { this.platformTriggered = platformTriggered; return this; @@ -212,6 +229,7 @@ public TestOptionsBuilder setPlatformTriggered(boolean platformTriggered) { * @param targetDir the target directory * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setTargetDir(String targetDir) { this.targetDir = targetDir; return this; @@ -220,20 +238,47 @@ public TestOptionsBuilder setTargetDir(String targetDir) { /** * Set if the scan report needs to be enabled. * - * @param scanReport true if the scan report needs to be enabled, false otherwise + * @param scanReport true if the scan report needs to be enabled, false + * otherwise * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setScanReport(boolean scanReport) { this.scanReport = scanReport; return this; } + /** + * Set the format of the report. + * + * @param format the format of the report + * @return this builder + */ + @SuppressWarnings("unused") + public TestOptionsBuilder setFormat(ReportFormat format) { + this.format = format; + return this; + } + + /** + * Set the format of the report using string value. + * + * @param format the format string value + * @return this builder + */ + @SuppressWarnings("unused") + public TestOptionsBuilder setFormat(String format) { + this.format = ReportFormat.fromString(format); + return this; + } + /** * Set if the rules should be listed. * * @param listRules true if the rules should be listed, false otherwise * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setListRules(boolean listRules) { this.listRules = listRules; return this; @@ -245,6 +290,7 @@ public TestOptionsBuilder setListRules(boolean listRules) { * @param includeRules the list of rules to be included * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setIncludeRules(List includeRules) { this.includeRules = Collections.unmodifiableList(includeRules); return this; @@ -256,6 +302,7 @@ public TestOptionsBuilder setIncludeRules(List includeRules) { * @param excludeRules the list of rules to be excluded * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setExcludeRules(List excludeRules) { this.excludeRules = Collections.unmodifiableList(excludeRules); return this; @@ -267,6 +314,7 @@ public TestOptionsBuilder setExcludeRules(List excludeRules) { * @param platforms the list of platforms * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setPlatforms(List platforms) { this.platforms = Collections.unmodifiableList(platforms); return this; @@ -277,9 +325,10 @@ public TestOptionsBuilder setPlatforms(List platforms) { * * @return the built {@code TestOptions} instance */ + @SuppressWarnings("unused") public TestOptions build() { return new TestOptions(project, outputStream, helpFlag, platformTriggered, - targetDir, scanReport, listRules, includeRules, excludeRules, platforms); + targetDir, scanReport, format, listRules, includeRules, excludeRules, platforms); } } } diff --git a/scan-command-test-utils/src/main/java/io/ballerina/scan/test/TestScanCmd.java b/scan-command-test-utils/src/main/java/io/ballerina/scan/test/TestScanCmd.java index bac6b550..79351836 100644 --- a/scan-command-test-utils/src/main/java/io/ballerina/scan/test/TestScanCmd.java +++ b/scan-command-test-utils/src/main/java/io/ballerina/scan/test/TestScanCmd.java @@ -32,6 +32,8 @@ import java.util.Collections; import java.util.Optional; +import static io.ballerina.scan.ReportFormat.BALLERINA; + /** * TestScanCmd extends ScanCmd to extend it for testing purposes. * @@ -49,11 +51,11 @@ public class TestScanCmd extends ScanCmd { options.platformTriggered(), options.targetDir(), options.scanReport(), + options.format(), options.listRules(), options.includeRules(), options.excludeRules(), - options.platforms() - ); + options.platforms()); this.project = options.project(); } @@ -65,11 +67,11 @@ public class TestScanCmd extends ScanCmd { false, null, false, + BALLERINA, false, Collections.emptyList(), Collections.emptyList(), - Collections.emptyList() - ); + Collections.emptyList()); if (projectPath.toFile().isDirectory()) { project = BuildProject.load(getEnvironmentBuilder(distributionPath), projectPath); } else { diff --git a/scan-command/build.gradle b/scan-command/build.gradle index 3bea904b..3a0bfbab 100644 --- a/scan-command/build.gradle +++ b/scan-command/build.gradle @@ -86,6 +86,18 @@ tasks.withType(JavaExec).configureEach { systemProperty 'ballerina.home', System.getenv("BALLERINA_HOME") } +// Inject version into properties file during build +processResources { + filesMatching('**/version.properties') { + expand(project.properties) + } + doLast { + def versionPropsFile = new File(sourceSets.main.output.resourcesDir, 'version.properties') + versionPropsFile.parentFile.mkdirs() + versionPropsFile.text = "app.version=${version}\n" + } +} + // Setting up checkstyles tasks.register('downloadCheckstyleRuleFiles', Download) { src([ diff --git a/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java b/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java new file mode 100644 index 00000000..5e0f55b3 --- /dev/null +++ b/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package io.ballerina.scan; + +/** + * Enum representing the report formats supported by the scan command. + * + * @since 0.11.0 + */ +public enum ReportFormat { + BALLERINA("ballerina"), + SARIF("sarif"); + + private final String format; + + ReportFormat(String format) { + this.format = format; + } + + /** + * Returns the format string of the report format. + * + * @return the format string + */ + public String getFormat() { + return format; + } + + /** + * Returns the ReportFormat enum constant corresponding to the given format string. + * + * @param format the format string + * @return the corresponding ReportFormat enum constant + * @throws IllegalArgumentException if the format string does not match any known format + */ + public static ReportFormat fromString(String format) { + for (ReportFormat reportFormat : values()) { + if (reportFormat.getFormat().equalsIgnoreCase(format)) { + return reportFormat; + } + } + throw new IllegalArgumentException("Unknown report format: " + format); + } +} diff --git a/scan-command/src/main/java/io/ballerina/scan/internal/ScanCmd.java b/scan-command/src/main/java/io/ballerina/scan/internal/ScanCmd.java index 545720c5..087e4615 100644 --- a/scan-command/src/main/java/io/ballerina/scan/internal/ScanCmd.java +++ b/scan-command/src/main/java/io/ballerina/scan/internal/ScanCmd.java @@ -27,6 +27,7 @@ import io.ballerina.projects.util.ProjectUtils; import io.ballerina.scan.Issue; import io.ballerina.scan.PlatformPluginContext; +import io.ballerina.scan.ReportFormat; import io.ballerina.scan.Rule; import io.ballerina.scan.StaticCodeAnalysisPlatformPlugin; import io.ballerina.scan.utils.DiagnosticCode; @@ -62,15 +63,15 @@ * Represents the "bal scan" command. * * @since 0.1.0 - * */ + */ @CommandLine.Command(name = SCAN_COMMAND, description = "Perform static code analysis for Ballerina packages") public class ScanCmd implements BLauncherCmd { private final PrintStream outputStream; - @CommandLine.Parameters (arity = "0..1") + @CommandLine.Parameters(arity = "0..1") private final Path projectPath; - @CommandLine.Option(names = {"--help", "-h", "?"}, hidden = true) + @CommandLine.Option(names = { "--help", "-h", "?" }, hidden = true) private boolean helpFlag; @CommandLine.Option(names = "--platform-triggered", @@ -84,22 +85,23 @@ public class ScanCmd implements BLauncherCmd { @CommandLine.Option(names = "--scan-report", description = "Enable HTML scan report generation") private boolean scanReport; - @CommandLine.Option(names = "--list-rules", - description = "List the rules available in the Ballerina scan tool") + @CommandLine.Option(names = "--format", + description = "Specify the format of the report (ballerina/sarif). Default is ballerina", + converter = ReportFormatConverter.class) + private ReportFormat format = ReportFormat.BALLERINA; + + @CommandLine.Option(names = "--list-rules", description = "List the rules available in the Ballerina scan tool") private boolean listRules; - @CommandLine.Option(names = "--include-rules", - converter = StringToListConverter.class, + @CommandLine.Option(names = "--include-rules", converter = StringToListConverter.class, description = "Specify the comma separated list of rules to include specific analysis issues") private List includeRules = new ArrayList<>(); - @CommandLine.Option(names = "--exclude-rules", - converter = StringToListConverter.class, + @CommandLine.Option(names = "--exclude-rules", converter = StringToListConverter.class, description = "Specify the comma separated list of rules to exclude specific analysis issues") private List excludeRules = new ArrayList<>(); - @CommandLine.Option(names = "--platforms", - converter = StringToListConverter.class, + @CommandLine.Option(names = "--platforms", converter = StringToListConverter.class, description = "Specify the comma separated list of static code analysis platforms to report issues") private List platforms = new ArrayList<>(); @@ -121,17 +123,18 @@ protected ScanCmd( boolean platformTriggered, String targetDir, boolean scanReport, + ReportFormat format, boolean listRules, List includeRules, List excludeRules, - List platforms - ) { + List platforms) { this.projectPath = projectPath; this.outputStream = outputStream; this.helpFlag = helpFlag; this.platformTriggered = platformTriggered; this.targetDir = targetDir; this.scanReport = scanReport; + this.format = format; this.listRules = listRules; this.includeRules.addAll(includeRules.stream().map(Rule::id).toList()); this.excludeRules.addAll(excludeRules.stream().map(Rule::id).toList()); @@ -179,7 +182,7 @@ public void execute() { Optional scanTomlFile = ScanUtils.loadScanTomlConfigurations(project.get(), outputStream); if (scanTomlFile.isEmpty()) { - return; + return; } ProjectAnalyzer projectAnalyzer = getProjectAnalyzer(project.get(), scanTomlFile.get()); @@ -208,7 +211,7 @@ public void execute() { platform.arguments().forEach((key, value) -> platformArgs.put(key, value.toString())); platformContexts.put(platformName, new PlatformPluginContextImpl(platformArgs, platformTriggered)); if (!platformTriggered || platforms.size() != 1 || !platforms.contains(platformName)) { - platforms.add(platformName); + platforms.add(platformName); } }); @@ -244,12 +247,19 @@ public void execute() { } if (platforms.isEmpty() && !platformTriggered) { - ScanUtils.printToConsole(issues, outputStream); + boolean isSarifFormat = ReportFormat.SARIF.equals(format); + ScanUtils.printToConsole(issues, outputStream, isSarifFormat, project.get()); if (project.get().kind().equals(ProjectKind.BUILD_PROJECT)) { - Path reportPath = ScanUtils.saveToDirectory(issues, project.get(), targetDir); + Path reportPath; + if (isSarifFormat) { + reportPath = ScanUtils.saveSarifToDirectory(issues, project.get(), targetDir); + } else { + reportPath = ScanUtils.saveToDirectory(issues, project.get(), targetDir); + } outputStream.println(); outputStream.println("View scan results at:"); - outputStream.println("\t" + reportPath.toUri() + System.lineSeparator()); + outputStream.println("\t" + reportPath.toUri()); + outputStream.println(); if (scanReport) { Path scanReportPath = ScanUtils.generateScanReport(issues, project.get(), targetDir); outputStream.println(); @@ -289,8 +299,7 @@ private StringBuilder helpMessage() { if (inputStream != null) { try ( InputStreamReader inputStreamReader = new InputStreamReader(inputStream, StandardCharsets.UTF_8); - BufferedReader br = new BufferedReader(inputStreamReader) - ) { + BufferedReader br = new BufferedReader(inputStreamReader)) { String content = br.readLine(); builder.append(content); while ((content = br.readLine()) != null) { @@ -347,4 +356,11 @@ public List convert(String value) { return Arrays.stream(value.split(",", -1)).map(String::trim).toList(); } } + + private static class ReportFormatConverter implements CommandLine.ITypeConverter { + @Override + public ReportFormat convert(String value) { + return ReportFormat.fromString(value); + } + } } diff --git a/scan-command/src/main/java/io/ballerina/scan/utils/Constants.java b/scan-command/src/main/java/io/ballerina/scan/utils/Constants.java index 8f1edfc2..113883ac 100644 --- a/scan-command/src/main/java/io/ballerina/scan/utils/Constants.java +++ b/scan-command/src/main/java/io/ballerina/scan/utils/Constants.java @@ -18,6 +18,10 @@ package io.ballerina.scan.utils; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + /** * {@code Constants} contains the constants used by the scan tool utilities. * @@ -25,6 +29,7 @@ */ public class Constants { static final String RESULTS_JSON_FILE = "scan_results.json"; + static final String RESULTS_SARIF_FILE = "scan_results.sarif"; static final String RESULTS_HTML_FILE = "index.html"; static final String REPORT_DATA_PLACEHOLDER = "__data__"; static final String SCAN_REPORT_PROJECT_NAME = "projectName"; @@ -63,6 +68,27 @@ public class Constants { static final String[] RULE_PRIORITY_LIST = {"ballerina", "ballerinax", "wso2"}; public static final String MAIN_FUNCTION = "main"; public static final String INIT_FUNCTION = "init"; + public static final String SARIF_VERSION = "2.1.0"; + public static final String SARIF_SCHEMA = "https://www.schemastore.org/schemas/json/" + + "sarif-2.1.0.json"; + public static final String SARIF_TOOL_NAME = "Ballerina Scan Tool"; + public static final String SARIF_TOOL_ORGANIZATION = "WSO2"; + public static final String SARIF_TOOL_VERSION = getAppVersion(); + public static final String SARIF_TOOL_URI = "https://central.ballerina.io/ballerina/tool_scan/"; + + private static String getAppVersion() { + try (InputStream input = Constants.class.getClassLoader().getResourceAsStream("version.properties")) { + if (input != null) { + Properties props = new Properties(); + props.load(input); + return props.getProperty("app.version", "0.1.0"); + } + } catch (IOException ex) { + DiagnosticLog.error(DiagnosticCode.FAILED_TO_LOAD_VERSION_PROPERTIES, + ex.getMessage()); + } + return System.getProperty("app.version", "0.1.0"); + } public static class Token { public static final String FLOAT = "float"; @@ -75,6 +101,7 @@ public static class Token { public static final String ZERO = "0"; public static final String ONE = "1"; public static final String MINUS_ONE = "-1"; + private Token() { } } diff --git a/scan-command/src/main/java/io/ballerina/scan/utils/DiagnosticCode.java b/scan-command/src/main/java/io/ballerina/scan/utils/DiagnosticCode.java index bb735071..59bec505 100644 --- a/scan-command/src/main/java/io/ballerina/scan/utils/DiagnosticCode.java +++ b/scan-command/src/main/java/io/ballerina/scan/utils/DiagnosticCode.java @@ -40,6 +40,8 @@ public enum DiagnosticCode { FAILED_TO_COPY_SCAN_REPORT("STATIC_ANALYSIS_ERROR_014", "failed.to.copy.scan.report"), RULE_NOT_FOUND("STATIC_ANALYSIS_ERROR_015", "rule.not.found"), ATTEMPT_TO_INCLUDE_AND_EXCLUDE("STATIC_ANALYSIS_ERROR_016", "attempt.to.include.and.exclude"), + INVALID_FORMAT("STATIC_ANALYSIS_ERROR_017", "invalid.format"), + FAILED_TO_LOAD_VERSION_PROPERTIES("STATIC_ANALYSIS_ERROR_018", "failed.to.load.version.properties"), REPORT_NOT_SUPPORTED("STATIC_ANALYSIS_WARNING_001", "report.not.supported"), SCAN_REPORT_NOT_SUPPORTED("STATIC_ANALYSIS_WARNING_002", "scan.report.not.supported"); diff --git a/scan-command/src/main/java/io/ballerina/scan/utils/ScanUtils.java b/scan-command/src/main/java/io/ballerina/scan/utils/ScanUtils.java index 9e63220d..6a9f88de 100644 --- a/scan-command/src/main/java/io/ballerina/scan/utils/ScanUtils.java +++ b/scan-command/src/main/java/io/ballerina/scan/utils/ScanUtils.java @@ -29,12 +29,14 @@ import io.ballerina.projects.internal.model.Target; import io.ballerina.scan.Issue; import io.ballerina.scan.Rule; +import io.ballerina.scan.RuleKind; import io.ballerina.scan.internal.IssueImpl; import io.ballerina.toml.api.Toml; import io.ballerina.toml.semantic.TomlType; import io.ballerina.toml.semantic.ast.TomlArrayValueNode; import io.ballerina.toml.semantic.ast.TomlValueNode; import io.ballerina.tools.text.LineRange; +import io.ballerina.tools.text.TextRange; import org.apache.commons.io.FileUtils; import java.io.File; @@ -56,9 +58,11 @@ import java.util.AbstractMap; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.zip.ZipEntry; @@ -79,17 +83,23 @@ import static io.ballerina.scan.utils.Constants.REPORT_DATA_PLACEHOLDER; import static io.ballerina.scan.utils.Constants.RESULTS_HTML_FILE; import static io.ballerina.scan.utils.Constants.RESULTS_JSON_FILE; -import static io.ballerina.scan.utils.Constants.RULES_TABLE; +import static io.ballerina.scan.utils.Constants.RESULTS_SARIF_FILE; import static io.ballerina.scan.utils.Constants.RULE_DESCRIPTION_COLUMN; import static io.ballerina.scan.utils.Constants.RULE_ID_COLUMN; -import static io.ballerina.scan.utils.Constants.RULE_PRIORITY_LIST; import static io.ballerina.scan.utils.Constants.RULE_KIND_COLUMN; +import static io.ballerina.scan.utils.Constants.RULE_PRIORITY_LIST; +import static io.ballerina.scan.utils.Constants.RULES_TABLE; +import static io.ballerina.scan.utils.Constants.SARIF_SCHEMA; +import static io.ballerina.scan.utils.Constants.SARIF_TOOL_NAME; +import static io.ballerina.scan.utils.Constants.SARIF_TOOL_ORGANIZATION; +import static io.ballerina.scan.utils.Constants.SARIF_TOOL_URI; +import static io.ballerina.scan.utils.Constants.SARIF_TOOL_VERSION; +import static io.ballerina.scan.utils.Constants.SARIF_VERSION; import static io.ballerina.scan.utils.Constants.SCAN_FILE; import static io.ballerina.scan.utils.Constants.SCAN_FILE_FIELD; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_FILE_CONTENT; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_FILE_NAME; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_FILE_PATH; -import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ISSUES; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ISSUE_MESSAGE; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ISSUE_RULE_ID; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ISSUE_SEVERITY; @@ -99,16 +109,18 @@ import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ISSUE_TEXT_RANGE_START_LINE; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ISSUE_TEXT_RANGE_START_LINE_OFFSET; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ISSUE_TYPE; +import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ISSUES; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_PROJECT_NAME; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_SCANNED_FILES; import static io.ballerina.scan.utils.Constants.SCAN_REPORT_ZIP_FILE; import static io.ballerina.scan.utils.Constants.SCAN_TABLE; +import static java.util.Locale.ROOT; /** * {@code ScanUtils} contains all the utility functions used by the scan tool. * * @since 0.1.0 - * */ + */ public final class ScanUtils { private ScanUtils() { } @@ -118,11 +130,29 @@ private ScanUtils() { * * @param issues generated issues * @param outputStream print stream - * */ + */ public static void printToConsole(List issues, PrintStream outputStream) { - String jsonOutput = convertIssuesToJsonString(issues); + printToConsole(issues, outputStream, false, null); + } + + /** + * Prints issues generated via static code analyzers to the console. + * + * @param issues generated issues + * @param outputStream print stream + * @param printSarif whether to print SARIF format instead of default BALLERINA format + * @param project Ballerina project (required for SARIF format) + */ + public static void printToConsole(List issues, PrintStream outputStream, boolean printSarif, + Project project) { + String output; + if (printSarif && project != null) { + output = convertIssuesToSarifString(issues, project); + } else { + output = convertIssuesToJsonString(issues); + } outputStream.println(); - outputStream.println(jsonOutput); + outputStream.println(output); } /** @@ -130,7 +160,7 @@ public static void printToConsole(List issues, PrintStream outputStream) * * @param issues generated issues * @return json string array of generated issues - * */ + */ private static String convertIssuesToJsonString(List issues) { Gson gson = new GsonBuilder().setPrettyPrinting().create(); JsonArray issuesAsJson = gson.toJsonTree(issues).getAsJsonArray(); @@ -138,26 +168,195 @@ private static String convertIssuesToJsonString(List issues) { } /** - * Returns the {@link Path} of the json analysis report where generated issues are saved. + * Returns the SARIF (Static Analysis Results Interchange Format) {@link String} + * representation of generated issues. + * + * @param issues generated issues + * @param project Ballerina project + * @return SARIF string representation of generated issues + */ + private static String convertIssuesToSarifString(List issues, Project project) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + // Create SARIF root object + JsonObject sarif = new JsonObject(); + sarif.addProperty("version", SARIF_VERSION); + sarif.addProperty("$schema", SARIF_SCHEMA); + + // Create runs array + JsonArray runs = new JsonArray(); + JsonObject run = new JsonObject(); + + // Create tool information + JsonObject tool = new JsonObject(); + JsonObject driver = new JsonObject(); + driver.addProperty("name", SARIF_TOOL_NAME); + driver.addProperty("organization", SARIF_TOOL_ORGANIZATION); + driver.addProperty("semanticVersion", SARIF_TOOL_VERSION); + driver.addProperty("informationUri", SARIF_TOOL_URI + SARIF_TOOL_VERSION); + + // Create rules array for the tool + JsonArray rules = new JsonArray(); + Map ruleMap = new HashMap<>(); + Set addedRuleIds = new HashSet<>(); + + // Collect unique rules from issues + for (Issue issue : issues) { + IssueImpl issueImpl = (IssueImpl) issue; + String ruleId = issueImpl.rule().id(); + + JsonObject ruleObject = ruleMap.computeIfAbsent(ruleId, id -> { + JsonObject obj = new JsonObject(); + obj.addProperty("id", id); + + // Construct helpUri based on rule ID and description + String helpUri = constructHelpUri(id, issueImpl.rule().description()); + obj.addProperty("helpUri", helpUri); + + JsonObject shortDescription = new JsonObject(); + shortDescription.addProperty("text", issueImpl.rule().description()); + obj.add("shortDescription", shortDescription); + + String level = mapRuleKindToSarifLevel(issueImpl.rule().kind()); + JsonObject defaultConfiguration = new JsonObject(); + defaultConfiguration.addProperty("level", level); + obj.add("defaultConfiguration", defaultConfiguration); + + return obj; + }); + if (addedRuleIds.add(ruleId)) { + rules.add(ruleObject); + } + } + + driver.add("rules", rules); + tool.add("driver", driver); + run.add("tool", tool); + + // Create results array + JsonArray results = new JsonArray(); + + for (Issue issue : issues) { + IssueImpl issueImpl = (IssueImpl) issue; + JsonObject result = new JsonObject(); + + result.addProperty("ruleId", issueImpl.rule().id()); + result.addProperty("level", mapRuleKindToSarifLevel(issueImpl.rule().kind())); + + JsonObject message = new JsonObject(); + message.addProperty("text", issueImpl.rule().description()); + result.add("message", message); + + // Create locations array + JsonArray locations = new JsonArray(); + JsonObject location = new JsonObject(); + JsonObject physicalLocation = new JsonObject(); + + JsonObject artifactLocation = new JsonObject(); + String relativePath = getRelativePath(issueImpl.filePath(), project.sourceRoot().toString()); + artifactLocation.addProperty("uri", relativePath.replace("\\", "/")); + physicalLocation.add("artifactLocation", artifactLocation); + + JsonObject region = new JsonObject(); + LineRange lineRange = issueImpl.location().lineRange(); + TextRange textRange = issueImpl.location().textRange(); + region.addProperty("startLine", lineRange.startLine().line() + 1); + region.addProperty("startColumn", lineRange.startLine().offset() + 1); + region.addProperty("endLine", lineRange.endLine().line() + 1); + region.addProperty("endColumn", lineRange.endLine().offset() + 1); + region.addProperty("charOffset", textRange.startOffset()); + region.addProperty("charLength", textRange.length()); + physicalLocation.add("region", region); + + location.add("physicalLocation", physicalLocation); + locations.add(location); + result.add("locations", locations); + + results.add(result); + } + + run.add("results", results); + runs.add(run); + sarif.add("runs", runs); + + return gson.toJson(sarif); + } + + /** + * Constructs the helpUri based on rule ID and description. + * + * @param ruleId the rule ID + * @param description the rule description + * @return the constructed helpUri + */ + private static String constructHelpUri(String ruleId, String description) { + String baseUri = SARIF_TOOL_URI + SARIF_TOOL_VERSION; + String anchor; + String idPart = ruleId.replace(":", "").replace("/", ""); + String descPart = description.toLowerCase(ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("-$", "") + .replaceAll("^-", ""); + anchor = "#" + idPart + "---" + descPart; + return baseUri + anchor; + } + + /** + * Maps RuleKind to SARIF level. + * + * @param ruleKind the rule kind + * @return corresponding SARIF level + */ + private static String mapRuleKindToSarifLevel(RuleKind ruleKind) { + return switch (ruleKind) { + case BUG -> "error"; + case CODE_SMELL -> "note"; + case VULNERABILITY -> "warning"; + }; + } + + /** + * Returns the relative path from the source root. + * + * @param filePath the absolute file path + * @param sourceRoot the source root path + * @return relative path + */ + private static String getRelativePath(String filePath, String sourceRoot) { + Path path = Paths.get(filePath); + try { + Path source = Paths.get(sourceRoot); + return source.relativize(path).toString(); + } catch (IllegalArgumentException e) { + Path fileName = path.getFileName(); + return fileName != null ? fileName.toString() : filePath; + } + } + + /** + * Returns the {@link Path} of the json analysis report where generated issues + * are saved. + * Optionally generates a SARIF format report in the same directory. * * @param issues generated issues * @param project Ballerina project * @param directoryName target directory name * @return path of the json analysis report where generated issues are saved - * */ + */ public static Path saveToDirectory(List issues, Project project, String directoryName) { Target target = getTargetPath(project, directoryName); Path jsonFilePath; try { Path reportPath = target.getReportPath(); + + // Save JSON report String jsonOutput = convertIssuesToJsonString(issues); File jsonFile = new File(reportPath.resolve(RESULTS_JSON_FILE).toString()); - try (FileOutputStream fileOutputStream = new FileOutputStream(jsonFile); Writer writer = new OutputStreamWriter(fileOutputStream, StandardCharsets.UTF_8)) { - writer.write(new String(jsonOutput.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); - jsonFilePath = jsonFile.toPath(); + writer.write(new String(jsonOutput.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); + jsonFilePath = jsonFile.toPath(); } } catch (IOException ex) { throw new IllegalStateException(ex); @@ -166,12 +365,37 @@ public static Path saveToDirectory(List issues, Project project, String d } /** - * Returns the generated {@link Target} directory where analysis reports are saved. + * Saves only the SARIF analysis report to the target directory. + * + * @param issues generated issues + * @param project Ballerina project + * @param directoryName target directory name + * @return path of the SARIF analysis report + */ + public static Path saveSarifToDirectory(List issues, Project project, String directoryName) { + Target target = getTargetPath(project, directoryName); + try { + Path reportPath = target.getReportPath(); + String sarifOutput = convertIssuesToSarifString(issues, project); + File sarifFile = new File(reportPath.resolve(RESULTS_SARIF_FILE).toString()); + try (FileOutputStream fos = new FileOutputStream(sarifFile); + Writer writer = new OutputStreamWriter(fos, StandardCharsets.UTF_8)) { + writer.write(new String(sarifOutput.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); + } + return sarifFile.toPath(); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + /** + * Returns the generated {@link Target} directory where analysis reports are + * saved. * * @param project Ballerina project * @param directoryName target directory name * @return generated target directory - * */ + */ private static Target getTargetPath(Project project, String directoryName) { try { if (directoryName == null) { @@ -189,7 +413,8 @@ private static Target getTargetPath(Project project, String directoryName) { } /** - * Returns the {@link Path} of the html analysis report where generated issues are saved. + * Returns the {@link Path} of the html analysis report where generated issues + * are saved. * * @param issues generated issues * @param project Ballerina project @@ -211,27 +436,24 @@ public static Path generateScanReport(List issues, Project project, Strin JsonObject issueObject = getJsonIssue(issueImpl); issuesArray.add(issueObject); scanReportFile.add(SCAN_REPORT_ISSUES, issuesArray); + } else { + JsonObject scanReportFile = new JsonObject(); + scanReportFile.addProperty(SCAN_REPORT_FILE_NAME, issueImpl.fileName()); + scanReportFile.addProperty(SCAN_REPORT_FILE_PATH, filePath); + String fileContent; + try { + fileContent = Files.readString(Path.of(filePath)); + } catch (IOException ex) { + throw new ScanToolException(DiagnosticLog.error(DiagnosticCode.FAILED_TO_READ_BALLERINA_FILE, + ex.getMessage())); + } + scanReportFile.addProperty(SCAN_REPORT_FILE_CONTENT, fileContent); + JsonArray issuesArray = new JsonArray(); + JsonObject issueObject = getJsonIssue(issueImpl); + issuesArray.add(issueObject); + scanReportFile.add(SCAN_REPORT_ISSUES, issuesArray); scanReportPathAndFile.put(filePath, scanReportFile); - continue; } - - JsonObject scanReportFile = new JsonObject(); - scanReportFile.addProperty(SCAN_REPORT_FILE_NAME, issueImpl.fileName()); - scanReportFile.addProperty(SCAN_REPORT_FILE_PATH, filePath); - String fileContent; - try { - fileContent = Files.readString(Path.of(filePath)); - } catch (IOException ex) { - throw new ScanToolException(DiagnosticLog.error(DiagnosticCode.FAILED_TO_READ_BALLERINA_FILE, - ex.getMessage())); - } - scanReportFile.addProperty(SCAN_REPORT_FILE_CONTENT, fileContent); - JsonArray issuesArray = new JsonArray(); - JsonObject issueObject = getJsonIssue(issueImpl); - issuesArray.add(issueObject); - scanReportFile.add(SCAN_REPORT_ISSUES, issuesArray); - - scanReportPathAndFile.put(filePath, scanReportFile); } JsonArray scannedFiles = new JsonArray(); @@ -262,9 +484,11 @@ public static Path generateScanReport(List issues, Project project, Strin } /** - * Returns the {@link JsonObject} representation of the static code analysis issue. + * Returns the {@link JsonObject} representation of the static code analysis + * issue. * - * @param issueImpl the {@link IssueImpl} representing the issue, to be converted to a JSON object + * @param issueImpl the {@link IssueImpl} representing the issue, to be + * converted to a JSON object * @return json object representation of the static code analysis issue */ private static JsonObject getJsonIssue(IssueImpl issueImpl) { @@ -288,7 +512,8 @@ private static JsonObject getJsonIssue(IssueImpl issueImpl) { } /** - * Extracts the HTML report template zip from a provided resource stream to the provided destination. + * Extracts the HTML report template zip from a provided resource stream to the + * provided destination. * * @param source resource stream that contains the zip * @param target destination to extract contents of the zip @@ -320,7 +545,8 @@ private static void unzipReportResources(InputStream source, File target) throws } /** - * Returns the {@link Optional} representation of a local/remote Scan.toml file. + * Returns the {@link Optional} representation of a local/remote + * Scan.toml file. * * @param project Ballerina project * @param outputStream print stream @@ -378,7 +604,8 @@ private static Optional convertConfigurationPath(String path) { } /** - * Returns the {@link Optional} representation a remote Scan.toml file from the provided URL. + * Returns the {@link Optional} representation a remote Scan.toml + * file from the provided URL. * * @param targetDir the target directory * @param scanTomlPath the remote Scan.toml file path @@ -407,7 +634,8 @@ private static Optional loadRemoteScanFile(Path targetDir, String } /** - * Returns the {@link Optional} representation of the Scan.toml file. + * Returns the {@link Optional} representation of the Scan.toml + * file. * * @param targetDir the target directory * @param scanTomlFilePath the local Scan.toml file path @@ -439,7 +667,8 @@ private static Optional loadScanFile(Path targetDir, Path scanToml } /** - * Loads an analyzer to the {@link ScanTomlFile} representation of the Scan.toml file. + * Loads an analyzer to the {@link ScanTomlFile} representation of the Scan.toml + * file. * * @param scanTomlFile the in-memory representation of the Scan.toml file */ @@ -469,46 +698,49 @@ private static Consumer loadAnalyzer(ScanTomlFile scanTomlFile) { } /** - * Returns the boolean value indicating whether the scan process should be continued. + * Returns the boolean value indicating whether the scan process should be + * continued. * * @param scanTomlFile the in-memory representation of the Scan.toml file * @param targetDir the target directory * @param outputStream the print stream - * @return the boolean value indicating whether the scan process should be continued + * @return the boolean value indicating whether the scan process should be + * continued */ private static boolean loadPlatform(Toml platformTable, ScanTomlFile scanTomlFile, Path targetDir, PrintStream outputStream) { - Map properties = platformTable.toMap(); - Object platformName = properties.remove(PLATFORM_NAME); - Optional name = (platformName instanceof String) ? Optional.of(platformName.toString()) - : Optional.empty(); - Object platformPath = properties.remove(PLATFORM_PATH); - Optional path = (platformPath instanceof String) ? Optional.of(platformPath.toString()) - : Optional.empty(); - - if (name.isEmpty() || name.get().isEmpty() || path.isEmpty()) { - return true; - } - - // Download remote JAR if the path is a URL and set the path to the downloaded JAR - if (!(new File(path.get()).exists())) { - try { - URL url = new URL(path.get()); - path = loadRemoteJAR(targetDir, name.get(), url, outputStream); - } catch (MalformedURLException ex) { - outputStream.println(DiagnosticLog.error(DiagnosticCode.LOADING_REMOTE_PLATFORM_FILE, - ex.getMessage())); - return false; - } - } + Map properties = platformTable.toMap(); + Object platformName = properties.remove(PLATFORM_NAME); + Optional name = (platformName instanceof String) ? Optional.of(platformName.toString()) + : Optional.empty(); + Object platformPath = properties.remove(PLATFORM_PATH); + Optional path = (platformPath instanceof String) ? Optional.of(platformPath.toString()) + : Optional.empty(); + + if (name.isEmpty() || name.get().isEmpty() || path.isEmpty()) { + return true; + } - if (path.isEmpty() || !Files.exists(Path.of(path.get()))) { + // Download remote JAR if the path is a URL and set the path to the downloaded + // JAR + if (!(new File(path.get()).exists())) { + try { + URL url = new URL(path.get()); + path = loadRemoteJAR(targetDir, name.get(), url, outputStream); + } catch (MalformedURLException ex) { + outputStream.println(DiagnosticLog.error(DiagnosticCode.LOADING_REMOTE_PLATFORM_FILE, + ex.getMessage())); return false; } + } - ScanTomlFile.Platform platform = new ScanTomlFile.Platform(name.get(), path.get(), properties); - scanTomlFile.setPlatform(platform); - return true; + if (path.isEmpty() || !Files.exists(Path.of(path.get()))) { + return false; + } + + ScanTomlFile.Platform platform = new ScanTomlFile.Platform(name.get(), path.get(), properties); + scanTomlFile.setPlatform(platform); + return true; } /** @@ -582,8 +814,10 @@ public static void printRulesToConsole(List rules, PrintStream outputStrea + "s%n"; outputStream.printf(format, RULE_ID_COLUMN, RULE_KIND_COLUMN, RULE_DESCRIPTION_COLUMN); - outputStream.printf("\t" + "-".repeat(maxRuleIDLength + 1) + "--" + - "-".repeat(maxSeverityLength + 1) + "--" + "-".repeat(maxDescriptionLength + 1) + "%n"); + outputStream.printf("\t%s--%s--%s%n", + "-".repeat(maxRuleIDLength + 1), + "-".repeat(maxSeverityLength + 1), + "-".repeat(maxDescriptionLength + 1)); sortRules(rules); for (Rule rule : rules) { @@ -594,14 +828,17 @@ public static void printRulesToConsole(List rules, PrintStream outputStrea /** *

- * Sorts a list of rules based on their priorities. The priorities are determined by the rule's id and a - * predefined list of priorities. It achieves this with the following steps: + * Sorts a list of rules based on their priorities. The priorities are + * determined by the rule's id and a + * predefined list of priorities. It achieves this with the following steps: *

*
    - *
  1. Get the priority of each rule by comparing the rule's id with the predefined list of priorities.
  2. - *
  3. Sort the rules based on their priorities.
  4. - *
  5. If both rules have the same priority, the one with an exact match in the priority list comes first
  6. - *
  7. Otherwise, they are sorted based on the fully qualified rule id.
  8. + *
  9. Get the priority of each rule by comparing the rule's id with the + * predefined list of priorities.
  10. + *
  11. Sort the rules based on their priorities.
  12. + *
  13. If both rules have the same priority, the one with an exact match in the + * priority list comes first
  14. + *
  15. Otherwise, they are sorted based on the fully qualified rule id.
  16. *
* * @param rules The list of rules to be sorted. @@ -631,11 +868,13 @@ public static void sortRules(List rules) { } /** - * Returns the priority of the rule as a {@link AbstractMap.SimpleEntry} pair. + * Returns the priority of a rule as a pair of the priority index and whether + * the rule is a direct match. * * @param ruleId the rule id * @param priorities the list of priorities - * @return the priority of the rule as a pair of the priority index and whether the rule is a direct match. + * @return the priority of the rule as a pair of the priority index and whether + * the rule is a direct match. */ private static AbstractMap.SimpleEntry getPriority(String ruleId, List priorities) { for (int i = 0; i < priorities.size(); i++) { diff --git a/scan-command/src/main/resources/cli-help/ballerina-scan.help b/scan-command/src/main/resources/cli-help/ballerina-scan.help index fe203d4b..83d3576e 100644 --- a/scan-command/src/main/resources/cli-help/ballerina-scan.help +++ b/scan-command/src/main/resources/cli-help/ballerina-scan.help @@ -20,6 +20,9 @@ OPTIONS --scan-report Generate an HTML report containing the analysis results (only for Ballerina projects). + --format= + Specify the format of the report. Default is json. + --list-rules List all available rules. @@ -47,6 +50,12 @@ EXAMPLES Run analysis and generate an HTML report in the target directory. $ bal scan --scan-report + Run analysis and generate report in JSON format (default). + $ bal scan --format=json + + Run analysis and generate report in SARIF format. + $ bal scan --format=sarif + View all available rules. $ bal scan --list-rules diff --git a/scan-command/src/main/resources/scan.properties b/scan-command/src/main/resources/scan.properties index be5b05b6..98cc2a86 100644 --- a/scan-command/src/main/resources/scan.properties +++ b/scan-command/src/main/resources/scan.properties @@ -71,6 +71,9 @@ error.rule.not.found=\ error.attempt.to.include.and.exclude=\ cannot include and exclude rules at the same time +error.invalid.format=\ + invalid format ''{0}''. Supported formats are: json, sarif + # -------------------------- # Scan Tool warning messages # -------------------------- diff --git a/scan-command/src/test/java/io/ballerina/scan/internal/ScanCmdTest.java b/scan-command/src/test/java/io/ballerina/scan/internal/ScanCmdTest.java index e8a940ca..de1991c1 100644 --- a/scan-command/src/test/java/io/ballerina/scan/internal/ScanCmdTest.java +++ b/scan-command/src/test/java/io/ballerina/scan/internal/ScanCmdTest.java @@ -27,8 +27,6 @@ import io.ballerina.scan.Rule; import io.ballerina.scan.RuleKind; import io.ballerina.scan.Source; -import io.ballerina.scan.utils.DiagnosticCode; -import io.ballerina.scan.utils.DiagnosticLog; import io.ballerina.scan.utils.ScanTomlFile; import io.ballerina.scan.utils.ScanUtils; import org.testng.Assert; @@ -51,6 +49,8 @@ import static io.ballerina.scan.TestConstants.WINDOWS_LINE_SEPARATOR; import static io.ballerina.scan.internal.ScanToolConstants.BALLERINAX_ORG; import static io.ballerina.scan.internal.ScanToolConstants.BALLERINA_ORG; +import static io.ballerina.scan.utils.DiagnosticCode.EMPTY_PACKAGE; +import static io.ballerina.scan.utils.DiagnosticLog.error; /** * Scan command tests. @@ -120,7 +120,7 @@ void testScanCommandEmptyProject() throws IOException { ScanCmd scanCmd = new ScanCmd(printStream); scanCmd.execute(); System.setProperty("user.dir", userDir); - String expected = DiagnosticLog.error(DiagnosticCode.EMPTY_PACKAGE); + String expected = error(EMPTY_PACKAGE); Assert.assertEquals(readOutput(true).trim().split("\n")[0], expected); } @@ -500,4 +500,101 @@ void testScanCommandWithInvalidPlatformPluginConfigurations() throws IOException String expected = getExpectedOutput("invalid-platform-plugin-configurations.txt"); Assert.assertEquals(readOutput(true).trim(), expected); } + + @Test(description = "test scan command with valid json format flag") + void testScanCommandWithValidJsonFormatFlag() throws IOException { + System.setProperty("user.dir", validBalProject.toString()); + ScanCmd scanCmd = new ScanCmd(printStream); + String[] args = {"--format=ballerina"}; + new CommandLine(scanCmd).parseArgs(args); + scanCmd.execute(); + + System.setProperty("user.dir", userDir); + String expected = "Running Scans"; + Assert.assertEquals(readOutput(true).trim().split("\n")[0], expected); + Path jsonReport = validBalProject.resolve("target").resolve("report").resolve("scan_results.json"); + Assert.assertTrue(Files.exists(jsonReport), "JSON report file should be created"); + } + + @Test(description = "test scan command with valid sarif format flag") + void testScanCommandWithValidSarifFormatFlag() throws IOException { + System.setProperty("user.dir", validBalProject.toString()); + ScanCmd scanCmd = new ScanCmd(printStream); + String[] args = {"--format=sarif"}; + new CommandLine(scanCmd).parseArgs(args); + scanCmd.execute(); + + System.setProperty("user.dir", userDir); + String expected = "Running Scans"; + Assert.assertEquals(readOutput(true).trim().split("\n")[0], expected); + Path sarifReport = validBalProject.resolve("target").resolve("report").resolve("scan_results.sarif"); + Assert.assertTrue(Files.exists(sarifReport), "SARIF report file should be created"); + } + + @Test(description = "test scan command with invalid format flag") + void testScanCommandWithInvalidFormatFlag() { + System.setProperty("user.dir", validBalProject.toString()); + ScanCmd scanCmd = new ScanCmd(printStream); + String[] args = {"--format=xml"}; + try { + new CommandLine(scanCmd).parseArgs(args); + Assert.fail("Expected ParameterException to be thrown for invalid format"); + } catch (CommandLine.ParameterException e) { + Assert.assertTrue(e.getMessage().contains("xml"), "Error message should mention the invalid format"); + Assert.assertTrue(e.getMessage().contains("Unknown report format"), + "Error message should indicate unknown format"); + } + System.setProperty("user.dir", userDir); + } + + @Test(description = "test scan command with format flag case insensitive") + void testScanCommandWithFormatFlagCaseInsensitive() throws IOException { + System.setProperty("user.dir", validBalProject.toString()); + ScanCmd scanCmd = new ScanCmd(printStream); + String[] args = {"--format=BALLERINA"}; + new CommandLine(scanCmd).parseArgs(args); + scanCmd.execute(); + + System.setProperty("user.dir", userDir); + String expected = "Running Scans"; + Assert.assertEquals(readOutput(true).trim().split("\n")[0], expected); + System.setProperty("user.dir", validBalProject.toString()); + scanCmd = new ScanCmd(printStream); + String[] sarifArgs = {"--format=SARIF"}; + new CommandLine(scanCmd).parseArgs(sarifArgs); + scanCmd.execute(); + System.setProperty("user.dir", userDir); + expected = "Running Scans"; + Assert.assertEquals(readOutput(true).trim().split("\n")[0], expected); + } + + @Test(description = "test scan command with format flag combined with target-dir") + void testScanCommandWithFormatFlagAndTargetDir() throws IOException { + System.setProperty("user.dir", validBalProject.toString()); + ScanCmd scanCmd = new ScanCmd(printStream); + String[] args = {"--format=sarif", "--target-dir=custom-results"}; + new CommandLine(scanCmd).parseArgs(args); + scanCmd.execute(); + + System.setProperty("user.dir", userDir); + String expected = "Running Scans"; + Assert.assertEquals(readOutput(true).trim().split("\n")[0], expected); + Path sarifReport = validBalProject.resolve("custom-results").resolve("report") + .resolve("scan_results.sarif"); + Assert.assertTrue(Files.exists(sarifReport), "SARIF report file should be created in custom directory"); + removeFile(validBalProject.resolve("custom-results")); + } + + @Test(description = "test scan command default format behavior") + void testScanCommandDefaultFormatBehavior() throws IOException { + System.setProperty("user.dir", validBalProject.toString()); + ScanCmd scanCmd = new ScanCmd(printStream); + scanCmd.execute(); + + System.setProperty("user.dir", userDir); + String expected = "Running Scans"; + Assert.assertEquals(readOutput(true).trim().split("\n")[0], expected); + Path jsonReport = validBalProject.resolve("target").resolve("report").resolve("scan_results.json"); + Assert.assertTrue(Files.exists(jsonReport), "JSON report file should be created by default"); + } } diff --git a/scan-command/src/test/resources/command-outputs/common/tool-help.txt b/scan-command/src/test/resources/command-outputs/common/tool-help.txt index fe203d4b..83d3576e 100644 --- a/scan-command/src/test/resources/command-outputs/common/tool-help.txt +++ b/scan-command/src/test/resources/command-outputs/common/tool-help.txt @@ -20,6 +20,9 @@ OPTIONS --scan-report Generate an HTML report containing the analysis results (only for Ballerina projects). + --format= + Specify the format of the report. Default is json. + --list-rules List all available rules. @@ -47,6 +50,12 @@ EXAMPLES Run analysis and generate an HTML report in the target directory. $ bal scan --scan-report + Run analysis and generate report in JSON format (default). + $ bal scan --format=json + + Run analysis and generate report in SARIF format. + $ bal scan --format=sarif + View all available rules. $ bal scan --list-rules diff --git a/scan-command/tool-scan/BalTool.toml b/scan-command/tool-scan/BalTool.toml index 773954e3..4c635c7f 100644 --- a/scan-command/tool-scan/BalTool.toml +++ b/scan-command/tool-scan/BalTool.toml @@ -2,4 +2,4 @@ id = "scan" [[dependency]] -path = "../build/libs/scan-command-0.10.0.jar" +path = "../build/libs/scan-command-0.11.0-SNAPSHOT.jar" diff --git a/scan-command/tool-scan/Ballerina.toml b/scan-command/tool-scan/Ballerina.toml index 27f8f905..a9d15b7f 100644 --- a/scan-command/tool-scan/Ballerina.toml +++ b/scan-command/tool-scan/Ballerina.toml @@ -1,7 +1,7 @@ [package] org = "ballerina" name = "tool_scan" -version = "0.10.0" +version = "0.11.0-SNAPSHOT" distribution = "2201.12.0" authors = ["Ballerina"] keywords = ["scan", "static code analysis"] diff --git a/scan-command/tool-scan/Dependencies.toml b/scan-command/tool-scan/Dependencies.toml index d357cff3..842a8393 100644 --- a/scan-command/tool-scan/Dependencies.toml +++ b/scan-command/tool-scan/Dependencies.toml @@ -5,12 +5,12 @@ [ballerina] dependencies-toml-version = "2" -distribution-version = "2201.12.0" +distribution-version = "2201.12.7" [[package]] org = "ballerina" name = "tool_scan" -version = "0.10.0" +version = "0.11.0-SNAPSHOT" modules = [ {org = "ballerina", packageName = "tool_scan", moduleName = "tool_scan"} ] diff --git a/scan-command/tool-scan/README.md b/scan-command/tool-scan/README.md index 6f0f05da..5a666190 100644 --- a/scan-command/tool-scan/README.md +++ b/scan-command/tool-scan/README.md @@ -25,6 +25,12 @@ bal scan [OPTIONS] [|] --scan-report ``` +- Specify the output format (ballerina or sarif). Default is ballerina. + +```text +--format= +``` + - List all available rules ```text @@ -75,6 +81,12 @@ bal scan --target-dir="results" bal scan --scan-report ``` +- Run analysis and specify the output format as SARIF. + +```bash +bal scan --format=sarif +``` + - View all available rules. ```bash