diff --git a/.gitignore b/.gitignore index 17072500..9ea4c179 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ target # Ballerina velocity.log* *Ballerina.lock + +compiler-plugin-test/**/target diff --git a/ballerina/Ballerina.toml b/ballerina/Ballerina.toml index 0ee76d97..76ffbfec 100644 --- a/ballerina/Ballerina.toml +++ b/ballerina/Ballerina.toml @@ -1,7 +1,7 @@ [package] org = "ballerina" name = "file" -version = "1.11.0" +version = "1.11.1" authors = ["Ballerina"] keywords = ["file", "path", "directory", "filepath"] repository = "https://github.com/ballerina-platform/module-ballerina-file" @@ -15,12 +15,12 @@ graalvmCompatible = true [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "file-native" -version = "1.11.0" -path = "../native/build/libs/file-native-1.11.0.jar" +version = "1.11.1" +path = "../native/build/libs/file-native-1.11.1-SNAPSHOT.jar" [[platform.java21.dependency]] path = "./lib/org.wso2.transport.local-file-system-6.0.55.jar" [[platform.java21.dependency]] -path = "../test-utils/build/libs/file-test-utils-1.11.0.jar" +path = "../test-utils/build/libs/file-test-utils-1.11.1-SNAPSHOT.jar" scope = "testOnly" diff --git a/ballerina/CompilerPlugin.toml b/ballerina/CompilerPlugin.toml index 79f29417..4f3f1024 100644 --- a/ballerina/CompilerPlugin.toml +++ b/ballerina/CompilerPlugin.toml @@ -3,4 +3,4 @@ id = "file-compiler-plugin" class = "io.ballerina.stdlib.file.compiler.FileCompilerPlugin" [[dependency]] -path = "../compiler-plugin/build/libs/file-compiler-plugin-1.11.0.jar" +path = "../compiler-plugin/build/libs/file-compiler-plugin-1.11.1-SNAPSHOT.jar" diff --git a/ballerina/Dependencies.toml b/ballerina/Dependencies.toml index 24fbb595..45d9a491 100644 --- a/ballerina/Dependencies.toml +++ b/ballerina/Dependencies.toml @@ -10,7 +10,7 @@ distribution-version = "2201.11.0" [[package]] org = "ballerina" name = "file" -version = "1.11.0" +version = "1.11.1" dependencies = [ {org = "ballerina", name = "io"}, {org = "ballerina", name = "jballerina.java"}, diff --git a/changelog.md b/changelog.md index ba038d30..a23f50e5 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- [Add static code rules](https://github.com/ballerina-platform/ballerina-library/issues/7283) + + ### Changed - [Change the listener configuration as an included parameter](https://github.com/ballerina-platform/ballerina-library/issues/7494) diff --git a/compiler-plugin-test/build.gradle b/compiler-plugin-test/build.gradle index 01b22c8c..c358e095 100644 --- a/compiler-plugin-test/build.gradle +++ b/compiler-plugin-test/build.gradle @@ -20,10 +20,25 @@ plugins { id 'java' id 'checkstyle' id 'com.github.spotbugs' + id 'jacoco' } description = 'Ballerina - file Compiler Plugin Test' +jacoco { + toolVersion = "${jacocoVersion}" + reportsDirectory = file("$project.buildDir/reports/jacoco") +} + +jacocoTestReport { + reports { + xml.required = true + csv.required = false + html.required = false + } + sourceSets project(':file-compiler-plugin').sourceSets.main +} + def ballerinaDist = "${project.rootDir}/target/ballerina-runtime" dependencies { diff --git a/compiler-plugin-test/src/test/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/ProcessOutputGobbler.java b/compiler-plugin-test/src/test/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/ProcessOutputGobbler.java new file mode 100644 index 00000000..54eb2acb --- /dev/null +++ b/compiler-plugin-test/src/test/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/ProcessOutputGobbler.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler.staticcodeanalyzer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Helper class to consume the process streams. + */ +class ProcessOutputGobbler implements Runnable { + private final InputStream inputStream; + private final StringBuilder output; + private int exitCode; + + public ProcessOutputGobbler(InputStream inputStream) { + this.inputStream = inputStream; + this.output = new StringBuilder(); + } + + @Override + public void run() { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + } catch (IOException e) { + this.output.append(e.getMessage()); + } + } + + public String getOutput() { + return output.toString(); + } + + public int getExitCode() { + return exitCode; + } + + public void setExitCode(int exitCode) { + this.exitCode = exitCode; + } +} diff --git a/compiler-plugin-test/src/test/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/StaticCodeAnalyzerTest.java b/compiler-plugin-test/src/test/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/StaticCodeAnalyzerTest.java new file mode 100644 index 00000000..ffdd2285 --- /dev/null +++ b/compiler-plugin-test/src/test/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/StaticCodeAnalyzerTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler.staticcodeanalyzer; + +import org.testng.Assert; +import org.testng.annotations.BeforeSuite; +import org.testng.annotations.Test; +import org.testng.internal.ExitCode; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Locale; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class StaticCodeAnalyzerTest { + private static final Path RESOURCE_PACKAGES_DIRECTORY = Paths + .get("src", "test", "resources", "static_code_analyzer", "ballerina_packages").toAbsolutePath(); + private static final Path EXPECTED_JSON_OUTPUT_DIRECTORY = Paths + .get("src", "test", "resources", "static_code_analyzer", "expected_output").toAbsolutePath(); + private static final Path BALLERINA_PATH = getBalCommandPath(); + private static final Path JSON_RULES_FILE_PATH = Paths + .get("../", "compiler-plugin", "src", "main", "resources", "rules.json").toAbsolutePath(); + private static final String SCAN_COMMAND = "scan"; + + private static Path getBalCommandPath() { + String balCommand = isWindows() ? "bal.bat" : "bal"; + return Paths.get("../", "target", "ballerina-runtime", "bin", balCommand).toAbsolutePath(); + } + + @BeforeSuite + public void pullScanTool() throws IOException, InterruptedException { + ProcessBuilder processBuilder = new ProcessBuilder(BALLERINA_PATH.toString(), "tool", "pull", SCAN_COMMAND); + ProcessOutputGobbler output = getOutput(processBuilder.start()); + if (Pattern.compile("tool 'scan:.+\\..+\\..+' successfully set as the active version\\.") + .matcher(output.getOutput()).find() || Pattern.compile("tool 'scan:.+\\..+\\..+' is already active\\.") + .matcher(output.getOutput()).find()) { + return; + } + Assert.assertFalse(ExitCode.hasFailure(output.getExitCode())); + } + + @Test + public void validateRulesJson() throws IOException { + String expectedRules = "[" + Arrays.stream(FileRule.values()) + .map(FileRule::toString).collect(Collectors.joining(",")) + "]"; + String actualRules = Files.readString(JSON_RULES_FILE_PATH); + assertJsonEqual(normalizeJson(actualRules), normalizeJson(expectedRules)); + } + + @Test + public void testStaticCodeRules() throws IOException, InterruptedException { + for (FileRule rule : FileRule.values()) { + String targetPackageName = "rule" + rule.getId(); + String actualJsonReport = StaticCodeAnalyzerTest.executeScanProcess(targetPackageName); + String expectedJsonReport = Files + .readString(EXPECTED_JSON_OUTPUT_DIRECTORY.resolve(targetPackageName + ".json")); + assertJsonEqual(actualJsonReport, expectedJsonReport); + } + } + + private static String executeScanProcess(String targetPackage) throws IOException, InterruptedException { + ProcessBuilder processBuilder = new ProcessBuilder(BALLERINA_PATH.toString(), SCAN_COMMAND); + processBuilder.directory(RESOURCE_PACKAGES_DIRECTORY.resolve(targetPackage).toFile()); + ProcessOutputGobbler output = getOutput(processBuilder.start()); + Assert.assertFalse(ExitCode.hasFailure(output.getExitCode())); + return Files.readString(RESOURCE_PACKAGES_DIRECTORY.resolve(targetPackage) + .resolve("target").resolve("report").resolve("scan_results.json")); + } + + private static ProcessOutputGobbler getOutput(Process process) throws InterruptedException { + ProcessOutputGobbler outputGobbler = new ProcessOutputGobbler(process.getInputStream()); + ProcessOutputGobbler errorGobbler = new ProcessOutputGobbler(process.getErrorStream()); + Thread outputThread = new Thread(outputGobbler); + Thread errorThread = new Thread(errorGobbler); + outputThread.start(); + errorThread.start(); + int exitCode = process.waitFor(); + outputGobbler.setExitCode(exitCode); + errorGobbler.setExitCode(exitCode); + outputThread.join(); + errorThread.join(); + return outputGobbler; + } + + private void assertJsonEqual(String actual, String expected) { + Assert.assertEquals(normalizeJson(actual), normalizeJson(expected)); + } + + private static String normalizeJson(String json) { + String normalizedJson = json.replaceAll("\\s*\"\\s*", "\"") + .replaceAll("\\s*:\\s*", ":") + .replaceAll("\\s*,\\s*", ",") + .replaceAll("\\s*\\{\\s*", "{") + .replaceAll("\\s*}\\s*", "}") + .replaceAll("\\s*\\[\\s*", "[") + .replaceAll("\\s*]\\s*", "]") + .replaceAll("\n", "") + .replaceAll(":\".*module-ballerina-file", ":\"module-ballerina-file"); + return isWindows() ? normalizedJson.replaceAll("/", "\\\\\\\\") : normalizedJson; + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase(Locale.ENGLISH).startsWith("windows"); + } +} diff --git a/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/Ballerina.toml b/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/Ballerina.toml new file mode 100644 index 00000000..80ffe815 --- /dev/null +++ b/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/Ballerina.toml @@ -0,0 +1,8 @@ +[package] +org = "sachink" +name = "rule1" +version = "0.1.0" +distribution = "2201.11.0-20250127-101700-a4b67fe5" + +[build-options] +observabilityIncluded = true diff --git a/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/main.bal b/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/main.bal new file mode 100644 index 00000000..0b7e47fe --- /dev/null +++ b/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/main.bal @@ -0,0 +1,30 @@ +// Copyright (c) 2025 WSO2 LLC. (http://www.wso2.org) +// +// 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. + +import ballerina/file; +import ballerina/os; +import ballerina/io; + +public function main() { + string tempFolderPath = os:getEnv("TMP"); + error? output = file:create(tempFolderPath + "/" + "myfile.txt"); + + if (output is error) { + io:println("Error occurred: " + output.message()); + } else { + io:println("File created successfully: " + tempFolderPath + "/" + "myfile.txt"); + } +} diff --git a/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule2/Ballerina.toml b/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule2/Ballerina.toml new file mode 100644 index 00000000..979729b2 --- /dev/null +++ b/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule2/Ballerina.toml @@ -0,0 +1,8 @@ +[package] +org = "sachink" +name = "rule2" +version = "0.1.0" +distribution = "2201.11.0-20250127-101700-a4b67fe5" + +[build-options] +observabilityIncluded = true diff --git a/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule2/main.bal b/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule2/main.bal new file mode 100644 index 00000000..5379270a --- /dev/null +++ b/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule2/main.bal @@ -0,0 +1,30 @@ +// Copyright (c) 2025 WSO2 LLC. (http://www.wso2.org) +// +// 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. + +import ballerina/file; +import ballerina/io; + +public function executeCommand(string fileName) returns file:Error? { + string unsafeFilePath = "./target/" + fileName; + file:Error? remove = file:remove(unsafeFilePath); + + return remove; +} + +public function main() { + file:Error? result = executeCommand("sample.json"); + io:println("Result: ", result); +} diff --git a/compiler-plugin-test/src/test/resources/static_code_analyzer/expected_output/rule1.json b/compiler-plugin-test/src/test/resources/static_code_analyzer/expected_output/rule1.json new file mode 100644 index 00000000..3025f772 --- /dev/null +++ b/compiler-plugin-test/src/test/resources/static_code_analyzer/expected_output/rule1.json @@ -0,0 +1,58 @@ +[ { + "location" : { + "filePath" : "main.bal", + "startLine" : 22, + "endLine" : 22, + "startColumn" : 20, + "endColumn" : 68, + "startOffset" : 800, + "length" : 48 + }, + "rule" : { + "id" : "ballerina/file:1", + "numericId" : 1, + "description" : "Avoid using publicly writable directories for file operations without proper access controls", + "ruleKind" : "VULNERABILITY" + }, + "source" : "BUILT_IN", + "fileName" : "rule1/main.bal", + "filePath" : "/Users/sachink/Desktop/module-ballerina-file/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/main.bal" +}, { + "location" : { + "filePath" : "main.bal", + "startLine" : 25, + "endLine" : 25, + "startColumn" : 8, + "endColumn" : 57, + "startOffset" : 886, + "length" : 49 + }, + "rule" : { + "id" : "ballerina/file:1", + "numericId" : 1, + "description" : "Avoid using publicly writable directories for file operations without proper access controls", + "ruleKind" : "VULNERABILITY" + }, + "source" : "BUILT_IN", + "fileName" : "rule1/main.bal", + "filePath" : "/Users/sachink/Desktop/module-ballerina-file/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/main.bal" +}, { + "location" : { + "filePath" : "main.bal", + "startLine" : 27, + "endLine" : 27, + "startColumn" : 8, + "endColumn" : 87, + "startOffset" : 958, + "length" : 79 + }, + "rule" : { + "id" : "ballerina/file:1", + "numericId" : 1, + "description" : "Avoid using publicly writable directories for file operations without proper access controls", + "ruleKind" : "VULNERABILITY" + }, + "source" : "BUILT_IN", + "fileName" : "rule1/main.bal", + "filePath" : "/Users/sachink/Desktop/module-ballerina-file/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule1/main.bal" +} ] diff --git a/compiler-plugin-test/src/test/resources/static_code_analyzer/expected_output/rule2.json b/compiler-plugin-test/src/test/resources/static_code_analyzer/expected_output/rule2.json new file mode 100644 index 00000000..5df553c3 --- /dev/null +++ b/compiler-plugin-test/src/test/resources/static_code_analyzer/expected_output/rule2.json @@ -0,0 +1,20 @@ +[ { + "location" : { + "filePath" : "main.bal", + "startLine" : 21, + "endLine" : 21, + "startColumn" : 28, + "endColumn" : 44, + "startOffset" : 762, + "length" : 16 + }, + "rule" : { + "id" : "ballerina/file:2", + "numericId" : 2, + "description" : "I/O function calls should not be vulnerable to path injection attacks", + "ruleKind" : "VULNERABILITY" + }, + "source" : "BUILT_IN", + "fileName" : "rule2/main.bal", + "filePath" : "/Users/sachink/Desktop/module-ballerina-file/compiler-plugin-test/src/test/resources/static_code_analyzer/ballerina_packages/rule2/main.bal" +} ] diff --git a/compiler-plugin-test/src/test/resources/testng.xml b/compiler-plugin-test/src/test/resources/testng.xml index cbd2756c..8af0568d 100644 --- a/compiler-plugin-test/src/test/resources/testng.xml +++ b/compiler-plugin-test/src/test/resources/testng.xml @@ -19,10 +19,14 @@ - + + + + + diff --git a/compiler-plugin/build.gradle b/compiler-plugin/build.gradle index dc9efe39..4fd1eae3 100644 --- a/compiler-plugin/build.gradle +++ b/compiler-plugin/build.gradle @@ -30,6 +30,8 @@ dependencies { implementation group: 'org.ballerinalang', name: 'ballerina-lang', version: "${ballerinaLangVersion}" implementation group: 'org.ballerinalang', name: 'ballerina-tools-api', version: "${ballerinaLangVersion}" implementation group: 'org.ballerinalang', name: 'ballerina-parser', version: "${ballerinaLangVersion}" + implementation group: 'io.ballerina.scan', name: 'scan-command', version: "${balScanVersion}" + implementation project(":file-native") } def excludePattern = '**/module-info.java' @@ -70,3 +72,5 @@ compileJava { classpath = files() } } + +build.dependsOn ":file-native:build" diff --git a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/Constants.java b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/Constants.java new file mode 100644 index 00000000..65d53104 --- /dev/null +++ b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/Constants.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler; + +import java.util.List; + +/** + * Constants related to compiler plugin implementation. + */ +public class Constants { + private Constants() {} + + public static final String SCANNER_CONTEXT = "ScannerContext"; + public static final String OS = "os"; + public static final String GET_ENV = "getEnv"; + public static final String FILE = "file"; + public static final String BALLERINA_ORG = "ballerina"; + + public static final List FILE_FUNCTIONS = List.of( + "getAbsolutePath", + "isAbsolutePath", + "basename", + "parentPath", + "normalizePath", + "splitPath", + "joinPath", + "relativePath", + "joinPath", + "test", + "copy", + "readDir", + "read", + "write", + "remove", + "create", + "getMetaData", + "createTemp", + "createTempDir" + ); + + public static final List PUBLIC_DIRECTORIES = List.of( + "\"TMP\"", + "\"TEMP\"", + "\"TMPDIR\"" + ); +} diff --git a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/FileCompilerPlugin.java b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/FileCompilerPlugin.java index da8a2083..80f4320a 100644 --- a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/FileCompilerPlugin.java +++ b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/FileCompilerPlugin.java @@ -20,6 +20,10 @@ import io.ballerina.projects.plugins.CompilerPlugin; import io.ballerina.projects.plugins.CompilerPluginContext; +import io.ballerina.scan.ScannerContext; +import io.ballerina.stdlib.file.compiler.staticcodeanalyzer.FileStaticCodeAnalyzer; + +import static io.ballerina.stdlib.file.compiler.Constants.SCANNER_CONTEXT; /** * File compiler plugin. @@ -27,7 +31,11 @@ public class FileCompilerPlugin extends CompilerPlugin { @Override - public void init(CompilerPluginContext compilerPluginContext) { - compilerPluginContext.addCodeAnalyzer(new FileCodeAnalyzer()); + public void init(CompilerPluginContext context) { + context.addCodeAnalyzer(new FileCodeAnalyzer()); + Object object = context.userData().get(SCANNER_CONTEXT); + if (object instanceof ScannerContext scannerContext) { + context.addCodeAnalyzer(new FileStaticCodeAnalyzer(scannerContext.getReporter())); + } } } diff --git a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FilePathInjectionAnalyzer.java b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FilePathInjectionAnalyzer.java new file mode 100644 index 00000000..1c70feb3 --- /dev/null +++ b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FilePathInjectionAnalyzer.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler.staticcodeanalyzer; + +import io.ballerina.compiler.syntax.tree.BinaryExpressionNode; +import io.ballerina.compiler.syntax.tree.CaptureBindingPatternNode; +import io.ballerina.compiler.syntax.tree.ExpressionNode; +import io.ballerina.compiler.syntax.tree.FunctionArgumentNode; +import io.ballerina.compiler.syntax.tree.FunctionBodyBlockNode; +import io.ballerina.compiler.syntax.tree.FunctionCallExpressionNode; +import io.ballerina.compiler.syntax.tree.FunctionDefinitionNode; +import io.ballerina.compiler.syntax.tree.ImportOrgNameNode; +import io.ballerina.compiler.syntax.tree.ImportPrefixNode; +import io.ballerina.compiler.syntax.tree.ModulePartNode; +import io.ballerina.compiler.syntax.tree.Node; +import io.ballerina.compiler.syntax.tree.NodeList; +import io.ballerina.compiler.syntax.tree.ParameterNode; +import io.ballerina.compiler.syntax.tree.PositionalArgumentNode; +import io.ballerina.compiler.syntax.tree.RequiredParameterNode; +import io.ballerina.compiler.syntax.tree.SimpleNameReferenceNode; +import io.ballerina.compiler.syntax.tree.StatementNode; +import io.ballerina.compiler.syntax.tree.SyntaxKind; +import io.ballerina.compiler.syntax.tree.VariableDeclarationNode; +import io.ballerina.projects.Document; +import io.ballerina.projects.plugins.AnalysisTask; +import io.ballerina.projects.plugins.SyntaxNodeAnalysisContext; +import io.ballerina.scan.Reporter; + +import java.util.ArrayList; +import java.util.List; + +import static io.ballerina.stdlib.file.compiler.Constants.BALLERINA_ORG; +import static io.ballerina.stdlib.file.compiler.Constants.FILE; +import static io.ballerina.stdlib.file.compiler.Constants.FILE_FUNCTIONS; +import static io.ballerina.stdlib.file.compiler.staticcodeanalyzer.FileRule.AVOID_PATH_INJECTION; + +/** + * Analyzer to detect potential file path injection vulnerabilities. + */ +public class FilePathInjectionAnalyzer implements AnalysisTask { + + private final Reporter reporter; + + public FilePathInjectionAnalyzer(Reporter reporter) { + this.reporter = reporter; + } + + @Override + public void perform(SyntaxNodeAnalysisContext context) { + if (!(context.node() instanceof FunctionCallExpressionNode functionCall)) { + return; + } + + Document document = getDocument(context); + List importPrefix = new ArrayList<>(); + if (document.syntaxTree().rootNode() instanceof ModulePartNode modulePartNode) { + importPrefix = modulePartNode.imports().stream() + .filter(importDeclarationNode -> { + ImportOrgNameNode importOrgNameNode = importDeclarationNode.orgName().orElse(null); + return importOrgNameNode != null && BALLERINA_ORG.equals(importOrgNameNode.orgName().text()); + }) + .filter(importDeclarationNode -> importDeclarationNode.moduleName().stream().anyMatch( + moduleNameNode -> FILE.equals(moduleNameNode.text()))) + .map(importDeclarationNode -> { + ImportPrefixNode importPrefixNode = importDeclarationNode.prefix().orElse(null); + return importPrefixNode != null ? importPrefixNode.prefix().text() : FILE; + }).toList(); + } + + String functionName = functionCall.functionName().toString(); + + boolean isFileOperation = importPrefix.stream().anyMatch(prefix -> + FILE_FUNCTIONS.stream().anyMatch(func -> functionName.equals(prefix + ":" + func)) + ); + + if (isFileOperation && !isSafePath(functionCall)) { + this.reporter.reportIssue(document, functionCall.location(), AVOID_PATH_INJECTION.getId()); + } + } + + public static Document getDocument(SyntaxNodeAnalysisContext context) { + return context.currentPackage().module(context.moduleId()).document(context.documentId()); + } + + private boolean isSafePath(FunctionCallExpressionNode functionCall) { + NodeList arguments = functionCall.arguments(); + if (arguments.isEmpty()) { + return true; + } + + FunctionArgumentNode firstArg = arguments.get(0); + ExpressionNode argument; + + if (firstArg instanceof PositionalArgumentNode) { + argument = ((PositionalArgumentNode) firstArg).expression(); + } else { + return true; + } + + if (argument instanceof BinaryExpressionNode binaryExpression && + binaryExpression.operator().kind() == SyntaxKind.PLUS_TOKEN) { + return false; + } + + if (argument instanceof SimpleNameReferenceNode variableRef) { + return isVariableSafe(variableRef); + } + return true; + } + + private boolean isVariableSafe(SimpleNameReferenceNode variableRef) { + String variableName = variableRef.name().text(); + Node currentNode = variableRef.parent(); + + while (currentNode != null) { + if (currentNode instanceof FunctionBodyBlockNode functionBody) { + for (StatementNode statement : functionBody.statements()) { + if (statement instanceof VariableDeclarationNode varDecl && + isMatchingVariable(varDecl, variableName)) { + if (hasConcatenationAssignment(varDecl) || isAssignedFromFunctionParameter(varDecl)) { + return isFunctionParameter(variableRef); + } + return true; + } + } + } + currentNode = currentNode.parent(); + } + return true; + } + + private boolean isMatchingVariable(VariableDeclarationNode varDecl, String variableName) { + return varDecl.typedBindingPattern().bindingPattern() instanceof CaptureBindingPatternNode bindingPattern + && bindingPattern.variableName().text().equals(variableName); + } + + private boolean hasConcatenationAssignment(VariableDeclarationNode varDecl) { + return varDecl.initializer().orElse(null) instanceof BinaryExpressionNode binaryExpr + && binaryExpr.operator().kind() == SyntaxKind.PLUS_TOKEN; + } + + private boolean isAssignedFromFunctionParameter(VariableDeclarationNode varDecl) { + return varDecl.initializer().orElse(null) instanceof SimpleNameReferenceNode; + } + + private boolean isFunctionParameter(SimpleNameReferenceNode variableRef) { + String paramName = variableRef.name().text(); + Node currentNode = variableRef.parent(); + + while (currentNode != null) { + if (currentNode instanceof FunctionDefinitionNode functionDef + && (hasDirectParameterReference(functionDef, paramName) || + hasIndirectParameterReference(variableRef, functionDef))) { + return false; + } + currentNode = currentNode.parent(); + } + return true; + } + + private boolean hasDirectParameterReference(FunctionDefinitionNode functionDef, String paramName) { + for (ParameterNode param : functionDef.functionSignature().parameters()) { + if (param instanceof RequiredParameterNode reqParam && reqParam.paramName().isPresent() + && reqParam.paramName().get().text().equals(paramName)) { + return true; + } + } + return false; + } + + private boolean hasIndirectParameterReference(SimpleNameReferenceNode variableRef, + FunctionDefinitionNode functionDef) { + for (ParameterNode param : functionDef.functionSignature().parameters()) { + if (param instanceof RequiredParameterNode reqParam && isIndirectFunctionParameter(variableRef, reqParam)) { + return true; + } + } + return false; + } + + private boolean isIndirectFunctionParameter(SimpleNameReferenceNode variableRef, RequiredParameterNode reqParam) { + Node currentNode = variableRef.parent(); + + while (currentNode != null) { + if (currentNode instanceof FunctionBodyBlockNode functionBody) { + for (StatementNode statement : functionBody.statements()) { + if (statement instanceof VariableDeclarationNode varDecl + && isBindingPatternMatch(varDecl, variableRef)) { + return checkVariableInitializer(varDecl, reqParam); + } + } + } + currentNode = currentNode.parent(); + } + return false; + } + + private boolean isBindingPatternMatch(VariableDeclarationNode varDecl, SimpleNameReferenceNode variableRef) { + return varDecl.typedBindingPattern().bindingPattern() instanceof CaptureBindingPatternNode bindingPattern + && bindingPattern.variableName().text().equals(variableRef.name().text()); + } + + private boolean checkVariableInitializer(VariableDeclarationNode varDecl, RequiredParameterNode reqParam) { + ExpressionNode initializer = varDecl.initializer().orElse(null); + return switch (initializer) { + case null -> false; + case SimpleNameReferenceNode initializerRef -> + initializerRef.name().text().equals(reqParam.paramName().get().text()); + case BinaryExpressionNode binaryExpr -> binaryExpr.operator().kind() == SyntaxKind.PLUS_TOKEN + && isIndirectFunctionParameterFromBinary(binaryExpr, reqParam); + default -> false; + }; + } + + private boolean isIndirectFunctionParameterFromBinary(BinaryExpressionNode binaryExpr, + RequiredParameterNode reqParam) { + if (binaryExpr.lhsExpr() instanceof SimpleNameReferenceNode leftRef && + leftRef.name().text().equals(reqParam.paramName().get().text())) { + return true; + } + return binaryExpr.rhsExpr() instanceof SimpleNameReferenceNode rightRef && + rightRef.name().text().equals(reqParam.paramName().get().text()); + } +} diff --git a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FileRule.java b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FileRule.java new file mode 100644 index 00000000..bb20dd5f --- /dev/null +++ b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FileRule.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler.staticcodeanalyzer; + +import io.ballerina.scan.Rule; + +import static io.ballerina.scan.RuleKind.VULNERABILITY; +import static io.ballerina.stdlib.file.compiler.staticcodeanalyzer.RuleFactory.createRule; + +/** + * Represents static code rules specific to the Ballerina File package. + */ +public enum FileRule { + AVOID_INSECURE_DIRECTORY_ACCESS(createRule(1, "Avoid using publicly writable directories for file " + + "operations without proper access controls", VULNERABILITY)), + AVOID_PATH_INJECTION(createRule(2, "I/O function calls should not be vulnerable to path injection " + + "attacks", VULNERABILITY)); + + private final Rule rule; + + FileRule(Rule rule) { + this.rule = rule; + } + + public int getId() { + return this.rule.numericId(); + } + + public Rule getRule() { + return this.rule; + } + + @Override + public String toString() { + return "{\"id\":" + this.getId() + ", \"kind\":\"" + this.rule.kind() + "\"," + + " \"description\" : \"" + this.rule.description() + "\"}"; + } +} diff --git a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FileStaticCodeAnalyzer.java b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FileStaticCodeAnalyzer.java new file mode 100644 index 00000000..e4cc1c4e --- /dev/null +++ b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/FileStaticCodeAnalyzer.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler.staticcodeanalyzer; + +import io.ballerina.compiler.syntax.tree.SyntaxKind; +import io.ballerina.projects.plugins.CodeAnalysisContext; +import io.ballerina.projects.plugins.CodeAnalyzer; +import io.ballerina.scan.Reporter; + +/** + * File static code analyser. + */ +public class FileStaticCodeAnalyzer extends CodeAnalyzer { + private final Reporter reporter; + + public FileStaticCodeAnalyzer(Reporter reporter) { + this.reporter = reporter; + } + + @Override + public void init(CodeAnalysisContext codeAnalysisContext) { + codeAnalysisContext.addSyntaxNodeAnalysisTask(new InsecureDirectoryAccessAnalyzer(reporter), + SyntaxKind.FUNCTION_CALL); + codeAnalysisContext.addSyntaxNodeAnalysisTask(new FilePathInjectionAnalyzer(reporter), + SyntaxKind.FUNCTION_CALL); + } +} diff --git a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/InsecureDirectoryAccessAnalyzer.java b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/InsecureDirectoryAccessAnalyzer.java new file mode 100644 index 00000000..0fbde346 --- /dev/null +++ b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/InsecureDirectoryAccessAnalyzer.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler.staticcodeanalyzer; + +import io.ballerina.compiler.syntax.tree.BasicLiteralNode; +import io.ballerina.compiler.syntax.tree.ExpressionNode; +import io.ballerina.compiler.syntax.tree.FunctionArgumentNode; +import io.ballerina.compiler.syntax.tree.FunctionCallExpressionNode; +import io.ballerina.compiler.syntax.tree.Node; +import io.ballerina.compiler.syntax.tree.PositionalArgumentNode; +import io.ballerina.compiler.syntax.tree.QualifiedNameReferenceNode; +import io.ballerina.compiler.syntax.tree.SeparatedNodeList; +import io.ballerina.projects.Document; +import io.ballerina.projects.plugins.AnalysisTask; +import io.ballerina.projects.plugins.SyntaxNodeAnalysisContext; +import io.ballerina.scan.Reporter; +import io.ballerina.tools.diagnostics.Location; +import org.ballerinalang.model.tree.expressions.StringTemplateLiteralNode; + +import static io.ballerina.stdlib.file.compiler.Constants.FILE_FUNCTIONS; +import static io.ballerina.stdlib.file.compiler.Constants.OS; +import static io.ballerina.stdlib.file.compiler.Constants.GET_ENV; +import static io.ballerina.stdlib.file.compiler.Constants.FILE; +import static io.ballerina.stdlib.file.compiler.Constants.PUBLIC_DIRECTORIES; +import static io.ballerina.stdlib.file.compiler.staticcodeanalyzer.FileRule.AVOID_INSECURE_DIRECTORY_ACCESS; + +public class InsecureDirectoryAccessAnalyzer implements AnalysisTask { + private final Reporter reporter; + + public InsecureDirectoryAccessAnalyzer(Reporter reporter) { + this.reporter = reporter; + } + + @Override + public void perform(SyntaxNodeAnalysisContext context) { + if (!(context.node() instanceof FunctionCallExpressionNode functionCall)) { + return; + } + + if (!isFileMethodCall(functionCall)) { + return; + } + + Document document = getDocument(context); + Location location = functionCall.location(); + this.reporter.reportIssue(document, location, AVOID_INSECURE_DIRECTORY_ACCESS.getId()); + } + + public static Document getDocument(SyntaxNodeAnalysisContext context) { + return context.currentPackage().module(context.moduleId()).document(context.documentId()); + } + + private boolean isFileMethodCall(FunctionCallExpressionNode functionCall) { + Node currentNode = functionCall; + while (currentNode != null) { + if (currentNode instanceof FunctionCallExpressionNode currentMethodCall) { + if (currentMethodCall.functionName() instanceof QualifiedNameReferenceNode qNode) { + String modulePrefix = qNode.modulePrefix().text(); + String functionName = qNode.identifier().text(); + if (modulePrefix.equals(OS) && functionName.equals(GET_ENV)) { + String envVarName = currentMethodCall.arguments().get(0).toString(); + if (PUBLIC_DIRECTORIES.contains(envVarName)) { + return true; + } + } + } + + if (currentMethodCall.functionName() instanceof QualifiedNameReferenceNode fileCallNode) { + String fileModulePrefix = fileCallNode.modulePrefix().text(); + String fileFunctionName = fileCallNode.identifier().text(); + if (fileModulePrefix.equals(FILE) && FILE_FUNCTIONS.contains(fileFunctionName)) { + SeparatedNodeList arguments = currentMethodCall.arguments(); + if (arguments != null && !arguments.isEmpty()) { + FunctionArgumentNode pathArg = arguments.get(0); + if (pathArg instanceof PositionalArgumentNode posArg) { + ExpressionNode pathExpr = posArg.expression(); + if (pathExpr instanceof BasicLiteralNode pathLiteral) { + String filePath = pathLiteral.toString().trim(); + if (isInsecureDirectory(filePath)) { + return true; + } + } else if (pathExpr instanceof StringTemplateLiteralNode templatePath) { + String filePath = templatePath.toString().trim(); + if (isInsecureDirectory(filePath)) { + return true; + } + } + } + } + } + } + } + currentNode = currentNode.parent(); + } + return false; + } + + private boolean isInsecureDirectory(String filePath) { + return PUBLIC_DIRECTORIES.contains(filePath); + } +} diff --git a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/RuleFactory.java b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/RuleFactory.java new file mode 100644 index 00000000..cb0b7136 --- /dev/null +++ b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/RuleFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler.staticcodeanalyzer; + +import io.ballerina.scan.Rule; +import io.ballerina.scan.RuleKind; + +/** + * {@code RuleFactory} contains the logic to create a {@link Rule}. + */ +public class RuleFactory { + + private RuleFactory() { + } + + public static Rule createRule(int id, String description, RuleKind kind) { + return new RuleImpl(id, description, kind); + } +} diff --git a/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/RuleImpl.java b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/RuleImpl.java new file mode 100644 index 00000000..e4628b86 --- /dev/null +++ b/compiler-plugin/src/main/java/io/ballerina/stdlib/file/compiler/staticcodeanalyzer/RuleImpl.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) + * + * 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.stdlib.file.compiler.staticcodeanalyzer; + +import io.ballerina.scan.Rule; +import io.ballerina.scan.RuleKind; + +public class RuleImpl implements Rule { + private final int id; + private final String description; + private final RuleKind kind; + + RuleImpl(int id, String description, RuleKind kind) { + this.id = id; + this.description = description; + this.kind = kind; + } + + @Override + public String id() { + return Integer.toString(this.id); + } + + @Override + public int numericId() { + return this.id; + } + + @Override + public String description() { + return this.description; + } + + @Override + public RuleKind kind() { + return this.kind; + } +} diff --git a/compiler-plugin/src/main/java/module-info.java b/compiler-plugin/src/main/java/module-info.java index 8140c1b5..4adeb76a 100644 --- a/compiler-plugin/src/main/java/module-info.java +++ b/compiler-plugin/src/main/java/module-info.java @@ -17,8 +17,11 @@ */ module io.ballerina.stdlib.file.plugin { + requires io.ballerina.scan; requires io.ballerina.lang; requires io.ballerina.parser; requires io.ballerina.tools.api; + requires java.xml; exports io.ballerina.stdlib.file.compiler; + exports io.ballerina.stdlib.file.compiler.staticcodeanalyzer; } diff --git a/compiler-plugin/src/main/resources/rules.json b/compiler-plugin/src/main/resources/rules.json new file mode 100644 index 00000000..74f2815d --- /dev/null +++ b/compiler-plugin/src/main/resources/rules.json @@ -0,0 +1,12 @@ +[ + { + "id": 1, + "kind": "VULNERABILITY", + "description": "Avoid using publicly writable directories for file operations without proper access controls" + }, + { + "id": 2, + "kind": "VULNERABILITY", + "description": "I/O function calls should not be vulnerable to path injection attacks" + } +] diff --git a/gradle.properties b/gradle.properties index 8c8b7145..c64cc698 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,3 +19,6 @@ stdlibLogVersion=2.11.0 stdlibOsVersion=1.9.0 observeVersion=1.4.0 observeInternalVersion=1.4.0 + +jacocoVersion=0.8.10 +balScanVersion=0.5.0