From db3d995ce7e4464becd2ac8c19b058f94501099e Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Tue, 24 Jun 2025 14:10:49 +0530 Subject: [PATCH 01/11] Add SARIF report generation --- .../io/ballerina/scan/internal/ScanCmd.java | 4 +- .../io/ballerina/scan/utils/Constants.java | 2 + .../io/ballerina/scan/utils/ScanUtils.java | 262 ++++++++++++++---- 3 files changed, 217 insertions(+), 51 deletions(-) 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 545720c..c8c4b50 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 @@ -247,9 +247,11 @@ public void execute() { ScanUtils.printToConsole(issues, outputStream); if (project.get().kind().equals(ProjectKind.BUILD_PROJECT)) { Path reportPath = ScanUtils.saveToDirectory(issues, project.get(), targetDir); + Path sarifReportPath = reportPath.getParent().resolve("scan_results.sarif"); outputStream.println(); outputStream.println("View scan results at:"); - outputStream.println("\t" + reportPath.toUri() + System.lineSeparator()); + outputStream.println("\t" + reportPath.toUri()); + outputStream.println("\t" + sarifReportPath.toUri() + System.lineSeparator()); if (scanReport) { Path scanReportPath = ScanUtils.generateScanReport(issues, project.get(), targetDir); outputStream.println(); 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 8f1edfc..b01650c 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 @@ -25,6 +25,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"; @@ -75,6 +76,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/ScanUtils.java b/scan-command/src/main/java/io/ballerina/scan/utils/ScanUtils.java index 9e63220..58daa8d 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,6 +29,7 @@ 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; @@ -79,6 +80,7 @@ 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.RESULTS_SARIF_FILE; import static io.ballerina.scan.utils.Constants.RULES_TABLE; import static io.ballerina.scan.utils.Constants.RULE_DESCRIPTION_COLUMN; import static io.ballerina.scan.utils.Constants.RULE_ID_COLUMN; @@ -108,7 +110,7 @@ * {@code ScanUtils} contains all the utility functions used by the scan tool. * * @since 0.1.0 - * */ + */ public final class ScanUtils { private ScanUtils() { } @@ -118,7 +120,7 @@ private ScanUtils() { * * @param issues generated issues * @param outputStream print stream - * */ + */ public static void printToConsole(List issues, PrintStream outputStream) { String jsonOutput = convertIssuesToJsonString(issues); outputStream.println(); @@ -130,7 +132,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 +140,180 @@ 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", "2.1.0"); + sarif.addProperty("$schema", + "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.4.json"); + + // 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", "Ballerina Static Code Analysis Tool"); + driver.addProperty("version", "1.0.0"); + driver.addProperty("informationUri", "https://ballerina.io/"); + + // Create rules array for the tool + JsonArray rules = new JsonArray(); + Map ruleMap = new HashMap<>(); + + // 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); + + JsonObject shortDescription = new JsonObject(); + shortDescription.addProperty("text", issueImpl.rule().description()); + obj.add("shortDescription", shortDescription); + + JsonObject fullDescription = new JsonObject(); + fullDescription.addProperty("text", issueImpl.rule().description()); + obj.add("fullDescription", fullDescription); + + String level = mapRuleKindToSarifLevel(issueImpl.rule().kind()); + JsonObject defaultConfiguration = new JsonObject(); + defaultConfiguration.addProperty("level", level); + obj.add("defaultConfiguration", defaultConfiguration); + + return obj; + }); + if (!rules.contains(ruleObject)) { + 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(); + region.addProperty("startLine", lineRange.startLine().line()); + region.addProperty("startColumn", lineRange.startLine().offset() + 1); // SARIF uses 1-based columns + region.addProperty("endLine", lineRange.endLine().line()); + region.addProperty("endColumn", lineRange.endLine().offset() + 1); + 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); + } + + /** + * 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. + * Also 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(); + } + + // Save SARIF report + String sarifOutput = convertIssuesToSarifString(issues, project); + File sarifFile = new File(reportPath.resolve(RESULTS_SARIF_FILE).toString()); + try (FileOutputStream fileOutputStream = new FileOutputStream(sarifFile); + Writer writer = new OutputStreamWriter(fileOutputStream, StandardCharsets.UTF_8)) { + writer.write(new String(sarifOutput.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); } } catch (IOException ex) { throw new IllegalStateException(ex); @@ -166,12 +322,13 @@ public static Path saveToDirectory(List issues, Project project, String d } /** - * Returns the generated {@link Target} directory where analysis reports are saved. + * 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 +346,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 @@ -205,13 +363,14 @@ public static Path generateScanReport(List issues, Project project, Strin IssueImpl issueImpl = (IssueImpl) issue; String filePath = issueImpl.filePath(); - if (scanReportPathAndFile.containsKey(filePath)) { - JsonObject scanReportFile = scanReportPathAndFile.get(filePath); + scanReportPathAndFile.computeIfPresent(filePath, (key, scanReportFile) -> { JsonArray issuesArray = scanReportFile.getAsJsonArray(SCAN_REPORT_ISSUES); JsonObject issueObject = getJsonIssue(issueImpl); issuesArray.add(issueObject); scanReportFile.add(SCAN_REPORT_ISSUES, issuesArray); - scanReportPathAndFile.put(filePath, scanReportFile); + return scanReportFile; + }); + if (scanReportPathAndFile.containsKey(filePath)) { continue; } @@ -478,37 +637,37 @@ private static Consumer loadAnalyzer(ScanTomlFile scanTomlFile) { */ 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 +741,11 @@ 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 +756,14 @@ 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,7 +793,7 @@ 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 From 311376f0a252a9f941b3f4dd5cb065203c66596f Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Fri, 4 Jul 2025 12:44:36 +0530 Subject: [PATCH 02/11] Introduce --format flag to conditionally generate SARIF report --- .../io/ballerina/scan/test/TestOptions.java | 39 +++- .../io/ballerina/scan/test/TestScanCmd.java | 8 +- .../io/ballerina/scan/internal/ScanCmd.java | 53 +++--- .../io/ballerina/scan/utils/Constants.java | 7 + .../ballerina/scan/utils/DiagnosticCode.java | 1 + .../io/ballerina/scan/utils/ScanUtils.java | 169 +++++++++++++----- .../resources/cli-help/ballerina-scan.help | 9 + .../src/main/resources/scan.properties | 3 + .../command-outputs/common/tool-help.txt | 9 + scan-command/tool-scan/BalTool.toml | 2 +- scan-command/tool-scan/Ballerina.toml | 2 +- scan-command/tool-scan/Dependencies.toml | 4 +- 12 files changed, 230 insertions(+), 76 deletions(-) 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 deac2bb..d749902 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 @@ -37,20 +37,22 @@ public class TestOptions { private final boolean platformTriggered; private final String targetDir; private final boolean scanReport; + private final String 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, String 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 +74,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 +123,15 @@ boolean scanReport() { return scanReport; } + /** + * Get the format of the report. + * + * @return the format of the report + */ + String format() { + return format; + } + /** * Get if the rules should be listed or not. * @@ -164,6 +175,7 @@ public static class TestOptionsBuilder { private boolean platformTriggered; private String targetDir; private boolean scanReport; + private String format; private boolean listRules; private List includeRules = List.of(); private List excludeRules = List.of(); @@ -198,7 +210,8 @@ 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 */ public TestOptionsBuilder setPlatformTriggered(boolean platformTriggered) { @@ -220,7 +233,8 @@ 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 */ public TestOptionsBuilder setScanReport(boolean scanReport) { @@ -228,6 +242,17 @@ public TestOptionsBuilder setScanReport(boolean scanReport) { return this; } + /** + * Set the format of the report. + * + * @param format the format of the report + * @return this builder + */ + public TestOptionsBuilder setFormat(String format) { + this.format = format; + return this; + } + /** * Set if the rules should be listed. * @@ -279,7 +304,7 @@ public TestOptionsBuilder setPlatforms(List platforms) { */ 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 bac6b55..ef27495 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 @@ -49,11 +49,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 +65,11 @@ public class TestScanCmd extends ScanCmd { false, null, false, + "json", 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/src/main/java/io/ballerina/scan/internal/ScanCmd.java b/scan-command/src/main/java/io/ballerina/scan/internal/ScanCmd.java index c8c4b50..df839f3 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 @@ -62,15 +62,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 +84,22 @@ 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 (json/sarif). Default is json") + private String format = "json"; + + @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 +121,18 @@ protected ScanCmd( boolean platformTriggered, String targetDir, boolean scanReport, + String 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()); @@ -167,6 +168,12 @@ public void execute() { return; } + // Validate format parameter + if (!format.equalsIgnoreCase("json") && !format.equalsIgnoreCase("sarif")) { + outputStream.println(DiagnosticLog.error(DiagnosticCode.INVALID_FORMAT, format)); + return; + } + Optional project = getProject(); if (project.isEmpty()) { return; @@ -179,7 +186,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 +215,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,14 +251,19 @@ public void execute() { } if (platforms.isEmpty() && !platformTriggered) { - ScanUtils.printToConsole(issues, outputStream); + boolean isSarifFormat = "sarif".equalsIgnoreCase(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 sarifReportPath = reportPath.getParent().resolve("scan_results.sarif"); + 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()); - outputStream.println("\t" + sarifReportPath.toUri() + System.lineSeparator()); + outputStream.println(); if (scanReport) { Path scanReportPath = ScanUtils.generateScanReport(issues, project.get(), targetDir); outputStream.println(); @@ -291,8 +303,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) { 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 b01650c..9da2ef6 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 @@ -64,6 +64,13 @@ 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 = "0.10.0"; + public static final String SARIF_TOOL_URI = "https://central.ballerina.io/ballerina/tool_scan/"; public static class Token { public static final String FLOAT = "float"; 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 bb73507..bb311c5 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,7 @@ 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"), 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 58daa8d..1b6acc1 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 @@ -36,6 +36,7 @@ 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; @@ -81,17 +82,22 @@ 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.RESULTS_SARIF_FILE; -import static io.ballerina.scan.utils.Constants.RULES_TABLE; 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; @@ -101,6 +107,7 @@ 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; @@ -122,9 +129,27 @@ private ScanUtils() { * @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 JSON + * @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); } /** @@ -152,9 +177,8 @@ private static String convertIssuesToSarifString(List issues, Project pro // Create SARIF root object JsonObject sarif = new JsonObject(); - sarif.addProperty("version", "2.1.0"); - sarif.addProperty("$schema", - "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.4.json"); + sarif.addProperty("version", SARIF_VERSION); + sarif.addProperty("$schema", SARIF_SCHEMA); // Create runs array JsonArray runs = new JsonArray(); @@ -163,9 +187,10 @@ private static String convertIssuesToSarifString(List issues, Project pro // Create tool information JsonObject tool = new JsonObject(); JsonObject driver = new JsonObject(); - driver.addProperty("name", "Ballerina Static Code Analysis Tool"); - driver.addProperty("version", "1.0.0"); - driver.addProperty("informationUri", "https://ballerina.io/"); + 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(); @@ -180,14 +205,14 @@ private static String convertIssuesToSarifString(List issues, Project pro JsonObject obj = new JsonObject(); obj.addProperty("id", id); + // Construct helpUri based on rule ID format + String helpUri = constructHelpUri(id, issueImpl.rule().numericId()); + obj.addProperty("helpUri", helpUri); + JsonObject shortDescription = new JsonObject(); shortDescription.addProperty("text", issueImpl.rule().description()); obj.add("shortDescription", shortDescription); - JsonObject fullDescription = new JsonObject(); - fullDescription.addProperty("text", issueImpl.rule().description()); - obj.add("fullDescription", fullDescription); - String level = mapRuleKindToSarifLevel(issueImpl.rule().kind()); JsonObject defaultConfiguration = new JsonObject(); defaultConfiguration.addProperty("level", level); @@ -230,10 +255,13 @@ private static String convertIssuesToSarifString(List issues, Project pro JsonObject region = new JsonObject(); LineRange lineRange = issueImpl.location().lineRange(); - region.addProperty("startLine", lineRange.startLine().line()); - region.addProperty("startColumn", lineRange.startLine().offset() + 1); // SARIF uses 1-based columns - region.addProperty("endLine", lineRange.endLine().line()); + 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); @@ -250,6 +278,37 @@ private static String convertIssuesToSarifString(List issues, Project pro return gson.toJson(sarif); } + /** + * Constructs the helpUri based on rule ID format. + * + * @param ruleId the rule ID + * @param numericId the numeric ID of the rule + * @return the constructed helpUri + */ + private static String constructHelpUri(String ruleId, int numericId) { + String baseUri = SARIF_TOOL_URI + SARIF_TOOL_VERSION; + String[] parts = ruleId.split(":"); + + if (ruleId.contains("/")) { + // Standard library rule format: ballerina/module:numericId -> #rules-ballerina-module-numericId + if (parts.length == 2) { + String libPart = parts[0].replace("/", "-"); + String numericPart = parts[1]; + return baseUri + "#rules-" + libPart + "-" + numericPart; + } + } else { + // Core rule format: ballerina:numericId -> #rules-ballerina-numericId + if (parts.length == 2) { + String corePart = parts[0]; + String numericPart = parts[1]; + return baseUri + "#rules-" + corePart + "-" + numericPart; + } + } + + // Fallback to original format if parsing fails + return baseUri + numericId; + } + /** * Maps RuleKind to SARIF level. * @@ -285,7 +344,7 @@ private static String getRelativePath(String filePath, String sourceRoot) { /** * Returns the {@link Path} of the json analysis report where generated issues * are saved. - * Also generates a SARIF format report in the same directory. + * Optionally generates a SARIF format report in the same directory. * * @param issues generated issues * @param project Ballerina project @@ -307,18 +366,34 @@ public static Path saveToDirectory(List issues, Project project, String d writer.write(new String(jsonOutput.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); jsonFilePath = jsonFile.toPath(); } + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + return jsonFilePath; + } - // Save SARIF report + /** + * 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 fileOutputStream = new FileOutputStream(sarifFile); - Writer writer = new OutputStreamWriter(fileOutputStream, StandardCharsets.UTF_8)) { + 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); } - return jsonFilePath; } /** @@ -421,9 +496,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) { @@ -447,7 +524,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 @@ -479,7 +557,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 @@ -537,7 +616,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 @@ -566,7 +646,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 @@ -598,7 +679,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 */ @@ -628,12 +710,14 @@ 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) { @@ -649,7 +733,8 @@ private static boolean loadPlatform(Toml platformTable, ScanTomlFile scanTomlFil return true; } - // Download remote JAR if the path is a URL and set the path to the downloaded JAR + // 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()); @@ -744,8 +829,7 @@ public static void printRulesToConsole(List rules, PrintStream outputStrea outputStream.printf("\t%s--%s--%s%n", "-".repeat(maxRuleIDLength + 1), "-".repeat(maxSeverityLength + 1), - "-".repeat(maxDescriptionLength + 1) - ); + "-".repeat(maxDescriptionLength + 1)); sortRules(rules); for (Rule rule : rules) { @@ -756,13 +840,16 @@ 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 + * 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. Get the priority of each rule by comparing the rule's id with the + * predefined list of priorities.
  4. *
  5. Sort the rules based on their priorities.
  6. - *
  7. If both rules have the same priority, the one with an exact match in the priority list comes first
  8. + *
  9. If both rules have the same priority, the one with an exact match in the + * priority list comes first
  10. *
  11. Otherwise, they are sorted based on the fully qualified rule id.
  12. *
* @@ -793,11 +880,13 @@ public static void sortRules(List rules) { } /** - * Returns the priority of a rule as a pair of the priority index and whether the rule is a direct match. + * 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 fe203d4..83d3576 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 be5b05b..98cc2a8 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/resources/command-outputs/common/tool-help.txt b/scan-command/src/test/resources/command-outputs/common/tool-help.txt index fe203d4..83d3576 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 773954e..4d57701 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.10.1-SNAPSHOT.jar" diff --git a/scan-command/tool-scan/Ballerina.toml b/scan-command/tool-scan/Ballerina.toml index 27f8f90..ea387e4 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.10.1-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 d357cff..8bb4840 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.10.1-SNAPSHOT" modules = [ {org = "ballerina", packageName = "tool_scan", moduleName = "tool_scan"} ] From 1816be012851f665e1c54d5f7097f4f38ecaaf87 Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Fri, 4 Jul 2025 13:49:09 +0530 Subject: [PATCH 03/11] Add tests for scan command format flag handling --- .../ballerina/scan/internal/ScanCmdTest.java | 102 +++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) 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 e8a940c..605dcc9 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,9 @@ 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.DiagnosticCode.INVALID_FORMAT; +import static io.ballerina.scan.utils.DiagnosticLog.error; /** * Scan command tests. @@ -120,7 +121,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 +501,99 @@ 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=json"}; + 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() throws IOException { + System.setProperty("user.dir", validBalProject.toString()); + ScanCmd scanCmd = new ScanCmd(printStream); + String[] args = {"--format=xml"}; + new CommandLine(scanCmd).parseArgs(args); + scanCmd.execute(); + + System.setProperty("user.dir", userDir); + String output = readOutput(true).trim(); + Assert.assertTrue(output.contains(error(INVALID_FORMAT, "xml")), "Should show invalid format error"); + Assert.assertTrue(output.contains("xml"), "Error message should mention the invalid format"); + } + + @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=JSON"}; + 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"); + } } From 31498b7cc463fa9e1c2d5ea19ed82eefe123bb57 Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Fri, 4 Jul 2025 14:18:29 +0530 Subject: [PATCH 04/11] Update README to include --format flag information --- docs/static-code-analysis-tool/README.md | 25 ++++++++++++++++-------- scan-command/tool-scan/README.md | 12 ++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/static-code-analysis-tool/README.md b/docs/static-code-analysis-tool/README.md index 6cb25d6..7a1efa8 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 (json or sarif). + +```bash +bal scan --format=sarif +``` + +> Note: The default format is json. The tool supports both json 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/scan-command/tool-scan/README.md b/scan-command/tool-scan/README.md index 6f0f05d..a263a38 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 (json or sarif). Default is json. + +```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 From 2dd2ff95fbacf46b5312510c50da751742370602 Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Mon, 7 Jul 2025 12:37:08 +0530 Subject: [PATCH 05/11] Refactor report format handling to use enum for scan command --- .../io/ballerina/scan/test/TestOptions.java | 36 +++++++++-- .../io/ballerina/scan/test/TestScanCmd.java | 4 +- .../java/io/ballerina/scan/ReportFormat.java | 60 +++++++++++++++++++ .../io/ballerina/scan/internal/ScanCmd.java | 23 +++---- .../ballerina/scan/internal/ScanCmdTest.java | 17 +++--- 5 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 scan-command/src/main/java/io/ballerina/scan/ReportFormat.java 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 d749902..2d80325 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,15 +38,15 @@ public class TestOptions { private final boolean platformTriggered; private final String targetDir; private final boolean scanReport; - private final String format; + 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, String format, 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; @@ -128,7 +129,7 @@ boolean scanReport() { * * @return the format of the report */ - String format() { + ReportFormat format() { return format; } @@ -175,7 +176,7 @@ public static class TestOptionsBuilder { private boolean platformTriggered; private String targetDir; private boolean scanReport; - private String format; + private ReportFormat format; private boolean listRules; private List includeRules = List.of(); private List excludeRules = List.of(); @@ -191,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; @@ -202,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; @@ -214,6 +217,7 @@ public TestOptionsBuilder setHelpFlag(boolean helpFlag) { * otherwise * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setPlatformTriggered(boolean platformTriggered) { this.platformTriggered = platformTriggered; return this; @@ -225,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; @@ -237,6 +242,7 @@ public TestOptionsBuilder setTargetDir(String targetDir) { * otherwise * @return this builder */ + @SuppressWarnings("unused") public TestOptionsBuilder setScanReport(boolean scanReport) { this.scanReport = scanReport; return this; @@ -248,17 +254,31 @@ public TestOptionsBuilder setScanReport(boolean scanReport) { * @param format the format of the report * @return this builder */ - public TestOptionsBuilder setFormat(String format) { + @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; @@ -270,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; @@ -281,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; @@ -292,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; @@ -302,6 +325,7 @@ 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, 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 ef27495..452e366 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.JSON; + /** * TestScanCmd extends ScanCmd to extend it for testing purposes. * @@ -65,7 +67,7 @@ public class TestScanCmd extends ScanCmd { false, null, false, - "json", + JSON, false, Collections.emptyList(), Collections.emptyList(), 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 0000000..cebe98b --- /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.10.0 + */ +public enum ReportFormat { + JSON("json"), + 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 df839f3..36d5f46 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; @@ -85,8 +86,9 @@ public class ScanCmd implements BLauncherCmd { private boolean scanReport; @CommandLine.Option(names = "--format", - description = "Specify the format of the report (json/sarif). Default is json") - private String format = "json"; + description = "Specify the format of the report (json/sarif). Default is json", + converter = ReportFormatConverter.class) + private ReportFormat format = ReportFormat.JSON; @CommandLine.Option(names = "--list-rules", description = "List the rules available in the Ballerina scan tool") private boolean listRules; @@ -121,7 +123,7 @@ protected ScanCmd( boolean platformTriggered, String targetDir, boolean scanReport, - String format, + ReportFormat format, boolean listRules, List includeRules, List excludeRules, @@ -168,12 +170,6 @@ public void execute() { return; } - // Validate format parameter - if (!format.equalsIgnoreCase("json") && !format.equalsIgnoreCase("sarif")) { - outputStream.println(DiagnosticLog.error(DiagnosticCode.INVALID_FORMAT, format)); - return; - } - Optional project = getProject(); if (project.isEmpty()) { return; @@ -251,7 +247,7 @@ public void execute() { } if (platforms.isEmpty() && !platformTriggered) { - boolean isSarifFormat = "sarif".equalsIgnoreCase(format); + boolean isSarifFormat = ReportFormat.SARIF.equals(format); ScanUtils.printToConsole(issues, outputStream, isSarifFormat, project.get()); if (project.get().kind().equals(ProjectKind.BUILD_PROJECT)) { Path reportPath; @@ -360,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/test/java/io/ballerina/scan/internal/ScanCmdTest.java b/scan-command/src/test/java/io/ballerina/scan/internal/ScanCmdTest.java index 605dcc9..9163a43 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 @@ -50,7 +50,6 @@ 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.DiagnosticCode.INVALID_FORMAT; import static io.ballerina.scan.utils.DiagnosticLog.error; /** @@ -533,17 +532,19 @@ void testScanCommandWithValidSarifFormatFlag() throws IOException { } @Test(description = "test scan command with invalid format flag") - void testScanCommandWithInvalidFormatFlag() throws IOException { + void testScanCommandWithInvalidFormatFlag() { System.setProperty("user.dir", validBalProject.toString()); ScanCmd scanCmd = new ScanCmd(printStream); String[] args = {"--format=xml"}; - new CommandLine(scanCmd).parseArgs(args); - scanCmd.execute(); - + 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); - String output = readOutput(true).trim(); - Assert.assertTrue(output.contains(error(INVALID_FORMAT, "xml")), "Should show invalid format error"); - Assert.assertTrue(output.contains("xml"), "Error message should mention the invalid format"); } @Test(description = "test scan command with format flag case insensitive") From e814c20af11ee26af97c222a236107a54df26bd0 Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Thu, 17 Jul 2025 10:55:17 +0530 Subject: [PATCH 06/11] Update SARIF helpUri construction to follow current central doc anchor format --- .../io/ballerina/scan/utils/ScanUtils.java | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) 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 1b6acc1..c9b9018 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 @@ -112,6 +112,7 @@ 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. @@ -205,8 +206,8 @@ private static String convertIssuesToSarifString(List issues, Project pro JsonObject obj = new JsonObject(); obj.addProperty("id", id); - // Construct helpUri based on rule ID format - String helpUri = constructHelpUri(id, issueImpl.rule().numericId()); + // Construct helpUri based on rule ID and description + String helpUri = constructHelpUri(id, issueImpl.rule().description()); obj.addProperty("helpUri", helpUri); JsonObject shortDescription = new JsonObject(); @@ -279,34 +280,22 @@ private static String convertIssuesToSarifString(List issues, Project pro } /** - * Constructs the helpUri based on rule ID format. + * Constructs the helpUri based on rule ID and description. * * @param ruleId the rule ID - * @param numericId the numeric ID of the rule + * @param description the rule description * @return the constructed helpUri */ - private static String constructHelpUri(String ruleId, int numericId) { + private static String constructHelpUri(String ruleId, String description) { String baseUri = SARIF_TOOL_URI + SARIF_TOOL_VERSION; - String[] parts = ruleId.split(":"); - - if (ruleId.contains("/")) { - // Standard library rule format: ballerina/module:numericId -> #rules-ballerina-module-numericId - if (parts.length == 2) { - String libPart = parts[0].replace("/", "-"); - String numericPart = parts[1]; - return baseUri + "#rules-" + libPart + "-" + numericPart; - } - } else { - // Core rule format: ballerina:numericId -> #rules-ballerina-numericId - if (parts.length == 2) { - String corePart = parts[0]; - String numericPart = parts[1]; - return baseUri + "#rules-" + corePart + "-" + numericPart; - } - } - - // Fallback to original format if parsing fails - return baseUri + numericId; + String anchor; + String idPart = ruleId.replace(":", "").replace("/", ""); + String descPart = description.toLowerCase(ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("-$", "") + .replaceAll("^-", ""); + anchor = "#" + idPart + "---" + descPart; + return baseUri + anchor; } /** From 1cb73a356ab4fc3126d912c4e6e625eb959bc1f5 Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Tue, 5 Aug 2025 14:40:24 +0530 Subject: [PATCH 07/11] Update README and code to reflect changes in output format from json to ballerina --- docs/static-code-analysis-tool/README.md | 4 ++-- .../src/main/java/io/ballerina/scan/test/TestScanCmd.java | 4 ++-- .../src/main/java/io/ballerina/scan/ReportFormat.java | 2 +- .../src/main/java/io/ballerina/scan/internal/ScanCmd.java | 4 ++-- .../src/main/java/io/ballerina/scan/utils/ScanUtils.java | 2 +- .../src/test/java/io/ballerina/scan/internal/ScanCmdTest.java | 4 ++-- scan-command/tool-scan/README.md | 4 ++-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/static-code-analysis-tool/README.md b/docs/static-code-analysis-tool/README.md index 7a1efa8..e26f5c3 100644 --- a/docs/static-code-analysis-tool/README.md +++ b/docs/static-code-analysis-tool/README.md @@ -79,13 +79,13 @@ bal scan --target-dir="results" bal scan --scan-report ``` -5. Run analysis and specify the output format (json or sarif). +5. Run analysis and specify the output format (ballerina or sarif). ```bash bal scan --format=sarif ``` -> Note: The default format is json. The tool supports both json and sarif formats for analysis results. +> Note: The default format is ballerina. The tool supports both ballerina and sarif formats for analysis results. 6. View all available rules. 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 452e366..7935183 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,7 +32,7 @@ import java.util.Collections; import java.util.Optional; -import static io.ballerina.scan.ReportFormat.JSON; +import static io.ballerina.scan.ReportFormat.BALLERINA; /** * TestScanCmd extends ScanCmd to extend it for testing purposes. @@ -67,7 +67,7 @@ public class TestScanCmd extends ScanCmd { false, null, false, - JSON, + BALLERINA, false, Collections.emptyList(), Collections.emptyList(), diff --git a/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java b/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java index cebe98b..1b98ac4 100644 --- a/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java +++ b/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java @@ -24,7 +24,7 @@ * @since 0.10.0 */ public enum ReportFormat { - JSON("json"), + BALLERINA("ballerina"), SARIF("sarif"); private final String 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 36d5f46..087e461 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 @@ -86,9 +86,9 @@ public class ScanCmd implements BLauncherCmd { private boolean scanReport; @CommandLine.Option(names = "--format", - description = "Specify the format of the report (json/sarif). Default is json", + description = "Specify the format of the report (ballerina/sarif). Default is ballerina", converter = ReportFormatConverter.class) - private ReportFormat format = ReportFormat.JSON; + private ReportFormat format = ReportFormat.BALLERINA; @CommandLine.Option(names = "--list-rules", description = "List the rules available in the Ballerina scan tool") private boolean listRules; 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 c9b9018..2cce88d 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 @@ -138,7 +138,7 @@ public static void printToConsole(List issues, PrintStream outputStream) * * @param issues generated issues * @param outputStream print stream - * @param printSarif whether to print SARIF format instead of JSON + * @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, 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 9163a43..de1991c 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 @@ -505,7 +505,7 @@ void testScanCommandWithInvalidPlatformPluginConfigurations() throws IOException void testScanCommandWithValidJsonFormatFlag() throws IOException { System.setProperty("user.dir", validBalProject.toString()); ScanCmd scanCmd = new ScanCmd(printStream); - String[] args = {"--format=json"}; + String[] args = {"--format=ballerina"}; new CommandLine(scanCmd).parseArgs(args); scanCmd.execute(); @@ -551,7 +551,7 @@ void testScanCommandWithInvalidFormatFlag() { void testScanCommandWithFormatFlagCaseInsensitive() throws IOException { System.setProperty("user.dir", validBalProject.toString()); ScanCmd scanCmd = new ScanCmd(printStream); - String[] args = {"--format=JSON"}; + String[] args = {"--format=BALLERINA"}; new CommandLine(scanCmd).parseArgs(args); scanCmd.execute(); diff --git a/scan-command/tool-scan/README.md b/scan-command/tool-scan/README.md index a263a38..5a66619 100644 --- a/scan-command/tool-scan/README.md +++ b/scan-command/tool-scan/README.md @@ -25,10 +25,10 @@ bal scan [OPTIONS] [|] --scan-report ``` -- Specify the output format (json or sarif). Default is json. +- Specify the output format (ballerina or sarif). Default is ballerina. ```text ---format= +--format= ``` - List all available rules From 1d84b08625bb738d787f792b6766964ed2cd790f Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Tue, 5 Aug 2025 15:56:04 +0530 Subject: [PATCH 08/11] Inject application version into properties file during build --- scan-command/build.gradle | 12 +++++++++++ .../io/ballerina/scan/utils/Constants.java | 20 ++++++++++++++++++- .../ballerina/scan/utils/DiagnosticCode.java | 1 + 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/scan-command/build.gradle b/scan-command/build.gradle index 3bea904..3a0bfba 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/utils/Constants.java b/scan-command/src/main/java/io/ballerina/scan/utils/Constants.java index 9da2ef6..113883a 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. * @@ -69,9 +73,23 @@ public class Constants { "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 = "0.10.0"; + 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"; public static final String INT = "int"; 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 bb311c5..59bec50 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 @@ -41,6 +41,7 @@ public enum DiagnosticCode { 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"); From b954ce6f4455bf9e283bf8e83486f88710ad3010 Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Tue, 5 Aug 2025 20:25:08 +0530 Subject: [PATCH 09/11] Optimize rule collection by using a Set to ensure unique rule IDs --- .../src/main/java/io/ballerina/scan/utils/ScanUtils.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 2cce88d..e880598 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 @@ -58,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; @@ -196,6 +198,7 @@ private static String convertIssuesToSarifString(List issues, Project pro // 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) { @@ -221,7 +224,7 @@ private static String convertIssuesToSarifString(List issues, Project pro return obj; }); - if (!rules.contains(ruleObject)) { + if (addedRuleIds.add(ruleId)) { rules.add(ruleObject); } } From ef908e6efdebe1150f818b6a78985fc0f38b4e2f Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Tue, 5 Aug 2025 20:39:13 +0530 Subject: [PATCH 10/11] Refactor scan report file generation to use if-else logic for updating or creating entries --- .../io/ballerina/scan/utils/ScanUtils.java | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) 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 e880598..6a9f88d 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 @@ -285,7 +285,7 @@ private static String convertIssuesToSarifString(List issues, Project pro /** * Constructs the helpUri based on rule ID and description. * - * @param ruleId the rule ID + * @param ruleId the rule ID * @param description the rule description * @return the constructed helpUri */ @@ -430,34 +430,30 @@ public static Path generateScanReport(List issues, Project project, Strin IssueImpl issueImpl = (IssueImpl) issue; String filePath = issueImpl.filePath(); - scanReportPathAndFile.computeIfPresent(filePath, (key, scanReportFile) -> { + if (scanReportPathAndFile.containsKey(filePath)) { + JsonObject scanReportFile = scanReportPathAndFile.get(filePath); JsonArray issuesArray = scanReportFile.getAsJsonArray(SCAN_REPORT_ISSUES); JsonObject issueObject = getJsonIssue(issueImpl); issuesArray.add(issueObject); scanReportFile.add(SCAN_REPORT_ISSUES, issuesArray); - return scanReportFile; - }); - if (scanReportPathAndFile.containsKey(filePath)) { - 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())); + } 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); } - 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(); From 0ae1acad55389a7b17f3c46f446c25e319c56dd7 Mon Sep 17 00:00:00 2001 From: Nureka Rodrigo Date: Tue, 5 Aug 2025 20:54:10 +0530 Subject: [PATCH 11/11] Bump version to 0.11.0 --- gradle.properties | 2 +- scan-command/src/main/java/io/ballerina/scan/ReportFormat.java | 2 +- scan-command/tool-scan/BalTool.toml | 2 +- scan-command/tool-scan/Ballerina.toml | 2 +- scan-command/tool-scan/Dependencies.toml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle.properties b/gradle.properties index fc58f39..3057517 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/src/main/java/io/ballerina/scan/ReportFormat.java b/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java index 1b98ac4..5e0f55b 100644 --- a/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java +++ b/scan-command/src/main/java/io/ballerina/scan/ReportFormat.java @@ -21,7 +21,7 @@ /** * Enum representing the report formats supported by the scan command. * - * @since 0.10.0 + * @since 0.11.0 */ public enum ReportFormat { BALLERINA("ballerina"), diff --git a/scan-command/tool-scan/BalTool.toml b/scan-command/tool-scan/BalTool.toml index 4d57701..4c635c7 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.1-SNAPSHOT.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 ea387e4..a9d15b7 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.1-SNAPSHOT" +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 8bb4840..842a839 100644 --- a/scan-command/tool-scan/Dependencies.toml +++ b/scan-command/tool-scan/Dependencies.toml @@ -10,7 +10,7 @@ distribution-version = "2201.12.7" [[package]] org = "ballerina" name = "tool_scan" -version = "0.10.1-SNAPSHOT" +version = "0.11.0-SNAPSHOT" modules = [ {org = "ballerina", packageName = "tool_scan", moduleName = "tool_scan"} ]