diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index dad5453..b0fce4b 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -3,6 +3,7 @@ package lint import ( "fmt" "os" + "path/filepath" "strings" "time" @@ -10,6 +11,128 @@ import ( "gopkg.in/yaml.v3" ) +// resolvePath resolves the given path relative to the working directory and validates +// that it stays within the working directory. Returns the absolute path or an error. +func resolvePath(pathArg string, workingDirectory string) (string, error) { + // Resolve the path relative to working directory + var fullPath string + if filepath.IsAbs(pathArg) { + fullPath = pathArg + } else { + fullPath = filepath.Join(workingDirectory, pathArg) + } + + // Convert both paths to absolute and clean them to resolve any ".." or "." components + absFullPath, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + absFullPath = filepath.Clean(absFullPath) + + absWorkingDir, err := filepath.Abs(workingDirectory) + if err != nil { + return "", fmt.Errorf("failed to resolve working directory: %w", err) + } + absWorkingDir = filepath.Clean(absWorkingDir) + + // Check that the resolved path is within the working directory + if !strings.HasPrefix(absFullPath, absWorkingDir+string(filepath.Separator)) && absFullPath != absWorkingDir { + return "", fmt.Errorf("path %q is outside working directory %q", pathArg, workingDirectory) + } + + return absFullPath, nil +} + +// setupJavascriptVM creates a new sobek VM with the mxlint object exposed. +// The mxlint object provides utility functions for JavaScript rules: +// - mxlint.io.readfile(path): Reads a file and returns its contents as a string. +// The path is resolved relative to the workingDirectory. +// - mxlint.io.listdir(path): Lists the contents of a directory and returns an array of filenames. +// The path is resolved relative to the workingDirectory. +// - mxlint.io.isdir(path): Returns true if the path is a directory, false otherwise. +// The path is resolved relative to the workingDirectory. +func setupJavascriptVM(workingDirectory string) *sobek.Runtime { + vm := sobek.New() + + // Create the mxlint object + mxlint := vm.NewObject() + vm.Set("mxlint", mxlint) + + // Create the io sub-object + io := vm.NewObject() + mxlint.Set("io", io) + + // Set the readfile function + io.Set("readfile", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readfile requires a file path argument"))) + } + filepathArg := call.Argument(0).String() + + absPath, err := resolvePath(filepathArg, workingDirectory) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readfile: %w", err))) + } + + content, err := os.ReadFile(absPath) + if err != nil { + panic(vm.NewGoError(err)) + } + return vm.ToValue(string(content)) + }) + + // Set the listdir function + io.Set("listdir", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.listdir requires a directory path argument"))) + } + dirpathArg := call.Argument(0).String() + + absPath, err := resolvePath(dirpathArg, workingDirectory) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.listdir: %w", err))) + } + + entries, err := os.ReadDir(absPath) + if err != nil { + panic(vm.NewGoError(err)) + } + + // Convert directory entries to a slice of names + names := make([]string, len(entries)) + for i, entry := range entries { + names[i] = entry.Name() + } + + return vm.ToValue(names) + }) + + // Set the isdir function + io.Set("isdir", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.isdir requires a path argument"))) + } + pathArg := call.Argument(0).String() + + absPath, err := resolvePath(pathArg, workingDirectory) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.io.isdir: %w", err))) + } + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return vm.ToValue(false) + } + panic(vm.NewGoError(err)) + } + + return vm.ToValue(info.IsDir()) + }) + + return vm +} + func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) { ruleContent, _ := os.ReadFile(rulePath) log.Debugf("js file: \n%s", ruleContent) @@ -48,7 +171,9 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s startTime := time.Now() - vm := sobek.New() + // Use the directory containing the input file as the working directory + workingDirectory := filepath.Dir(inputFilePath) + vm := setupJavascriptVM(workingDirectory) _, err = vm.RunString(string(ruleContent)) if err != nil { panic(err) @@ -56,7 +181,7 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s ruleFunction, ok := sobek.AssertFunction(vm.Get("rule")) if !ok { - panic("rule(...) function not found") + panic("rule(...) function not found in rule file: " + rulePath) } res, err := ruleFunction(sobek.Undefined(), vm.ToValue(data)) diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go new file mode 100644 index 0000000..15094d7 --- /dev/null +++ b/lint/lint_javascript_test.go @@ -0,0 +1,583 @@ +package lint + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create a test file to read + testContent := "Hello, mxlint!" + testFilePath := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFilePath, []byte(testContent), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + t.Run("read file with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.readfile("test.txt")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != testContent { + t.Errorf("Expected %q, got %q", testContent, result.String()) + } + }) + + t.Run("read file with absolute path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.readfile("` + testFilePath + `")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != testContent { + t.Errorf("Expected %q, got %q", testContent, result.String()) + } + }) + + t.Run("read nonexistent file throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.readfile("nonexistent.txt"); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when reading nonexistent file") + } + }) + + t.Run("path traversal with .. is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.readfile("../../../etc/passwd"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) + + t.Run("absolute path outside working directory is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.readfile("/etc/passwd"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected absolute path outside working dir to be blocked, got: %s", result.String()) + } + }) + + t.Run("readfile without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.readfile(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when calling readfile without argument") + } + }) + + t.Run("read file in subdirectory", func(t *testing.T) { + // Create a subdirectory with a test file + subDir := filepath.Join(tempDir, "subdir") + err := os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + subFileContent := "Content in subdirectory" + subFilePath := filepath.Join(subDir, "subfile.txt") + err = os.WriteFile(subFilePath, []byte(subFileContent), 0644) + if err != nil { + t.Fatalf("Failed to create subfile: %v", err) + } + + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.readfile("subdir/subfile.txt")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != subFileContent { + t.Errorf("Expected %q, got %q", subFileContent, result.String()) + } + }) +} + +func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create some test files and directories + err := os.WriteFile(filepath.Join(tempDir, "file1.txt"), []byte("content1"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + err = os.WriteFile(filepath.Join(tempDir, "file2.txt"), []byte("content2"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + err = os.Mkdir(filepath.Join(tempDir, "subdir"), 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + err = os.WriteFile(filepath.Join(tempDir, "subdir", "nested.txt"), []byte("nested"), 0644) + if err != nil { + t.Fatalf("Failed to create nested file: %v", err) + } + + t.Run("list directory with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `JSON.stringify(mxlint.io.listdir(".").sort())` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + expected := `["file1.txt","file2.txt","subdir"]` + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } + }) + + t.Run("list directory with absolute path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `JSON.stringify(mxlint.io.listdir("` + tempDir + `").sort())` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + expected := `["file1.txt","file2.txt","subdir"]` + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } + }) + + t.Run("list subdirectory", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `JSON.stringify(mxlint.io.listdir("subdir"))` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + expected := `["nested.txt"]` + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } + }) + + t.Run("list nonexistent directory throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.listdir("nonexistent"); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when listing nonexistent directory") + } + }) + + t.Run("path traversal with .. is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.listdir("../../../etc"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) + + t.Run("absolute path outside working directory is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.listdir("/etc"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected absolute path outside working dir to be blocked, got: %s", result.String()) + } + }) + + t.Run("listdir without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.listdir(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when calling listdir without argument") + } + }) + + t.Run("list empty directory", func(t *testing.T) { + // Create an empty subdirectory + emptyDir := filepath.Join(tempDir, "empty") + err := os.Mkdir(emptyDir, 0755) + if err != nil { + t.Fatalf("Failed to create empty directory: %v", err) + } + + vm := setupJavascriptVM(tempDir) + + script := `JSON.stringify(mxlint.io.listdir("empty"))` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + expected := `[]` + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } + }) + + t.Run("listdir on file throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.listdir("file1.txt"); + "no error"; + } catch (e) { + e.message.includes("not a directory") ? "not a directory" : "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when calling listdir on a file") + } + }) +} + +func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create some test files and directories + err := os.WriteFile(filepath.Join(tempDir, "file.txt"), []byte("content"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + err = os.Mkdir(filepath.Join(tempDir, "subdir"), 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + t.Run("isdir returns true for directory with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.isdir("subdir")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != true { + t.Errorf("Expected true for directory, got %v", result.ToBoolean()) + } + }) + + t.Run("isdir returns true for directory with absolute path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.isdir("` + filepath.Join(tempDir, "subdir") + `")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != true { + t.Errorf("Expected true for directory, got %v", result.ToBoolean()) + } + }) + + t.Run("isdir returns false for file", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.isdir("file.txt")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != false { + t.Errorf("Expected false for file, got %v", result.ToBoolean()) + } + }) + + t.Run("isdir returns false for nonexistent path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.isdir("nonexistent")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != false { + t.Errorf("Expected false for nonexistent path, got %v", result.ToBoolean()) + } + }) + + t.Run("isdir returns true for current directory", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.isdir(".")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != true { + t.Errorf("Expected true for current directory, got %v", result.ToBoolean()) + } + }) + + t.Run("path traversal with .. is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.isdir("../../../etc"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) + + t.Run("absolute path outside working directory is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.isdir("/etc"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected absolute path outside working dir to be blocked, got: %s", result.String()) + } + }) + + t.Run("isdir without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.io.isdir(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when calling isdir without argument") + } + }) + + t.Run("isdir with nested directory path", func(t *testing.T) { + // Create a nested directory + nestedDir := filepath.Join(tempDir, "subdir", "nested") + err := os.Mkdir(nestedDir, 0755) + if err != nil { + t.Fatalf("Failed to create nested directory: %v", err) + } + + vm := setupJavascriptVM(tempDir) + + script := `mxlint.io.isdir("subdir/nested")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != true { + t.Errorf("Expected true for nested directory, got %v", result.ToBoolean()) + } + }) +} + +func TestMxlintObjectAvailable(t *testing.T) { + vm := setupJavascriptVM(".") + + // Check that mxlint object is available + script := `typeof mxlint` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "object" { + t.Errorf("Expected mxlint to be an object, got %q", result.String()) + } + + // Check that mxlint.io is an object + script = `typeof mxlint.io` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "object" { + t.Errorf("Expected mxlint.io to be an object, got %q", result.String()) + } + + // Check that mxlint.io.readfile is a function + script = `typeof mxlint.io.readfile` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.io.readfile to be a function, got %q", result.String()) + } + + // Check that mxlint.io.listdir is a function + script = `typeof mxlint.io.listdir` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.io.listdir to be a function, got %q", result.String()) + } + + // Check that mxlint.io.isdir is a function + script = `typeof mxlint.io.isdir` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.io.isdir to be a function, got %q", result.String()) + } +} diff --git a/lint/rules.go b/lint/rules.go index b04ef4d..4b247c8 100644 --- a/lint/rules.go +++ b/lint/rules.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "github.com/grafana/sobek" @@ -106,7 +107,9 @@ func runJavaScriptTestCases(rule Rule) error { return fmt.Errorf("unexpected testCase type: %T", testCase) } - vm := sobek.New() + // Use the directory containing the rule file as the working directory + workingDirectory := filepath.Dir(rule.Path) + vm := setupJavascriptVM(workingDirectory) _, err = vm.RunString(string(ruleContent)) if err != nil { panic(err)