diff --git a/README.md b/README.md index f12f822..31205f6 100644 --- a/README.md +++ b/README.md @@ -135,19 +135,19 @@ For more information about the architecture, refer to the [Architecture Document The high-level ambitious plan for the project, in order: -| # | Step | Status | -| :-: | ----------------------------------------------------------------------------- | :----: | -| 1 | Osmedeus Engine reforged with a next-generation architecture | ✅ | -| 2 | Flexible workflows and step types | ✅ | -| 3 | Event-driven architectural model and the different trigger event categories | ✅ | -| 4 | Beautiful UI for visualize results and workflow diagram | ✅ | -| 5 | Rewriting the workflow to adapt to new architecture and syntax | ✅ | -| 6 | Testing more utility functions like notifications | ✅ | -| 7 | SAST integration with SARIF parsing (Semgrep, Trivy, etc.) | ✅ | -| 8 | Generate diff reports showing new/removed/unchanged assets between runs. | ❌ | -| 9 | Adding step type from cloud provider that can be run via serverless | ❌ | -| N | Fancy features (to be discussed later) | ❌ | - +| # | Step | Status | +| :-: | --------------------------------------------------------------------------- | :----: | +| 1 | Osmedeus Engine reforged with a next-generation architecture | ✅ | +| 2 | Flexible workflows and step types | ✅ | +| 3 | Event-driven architectural model and the different trigger event categories | ✅ | +| 4 | Beautiful UI for visualize results and workflow diagram | ✅ | +| 5 | Rewriting the workflow to adapt to new architecture and syntax | ✅ | +| 6 | Testing more utility functions like notifications | ✅ | +| 7 | SAST integration with SARIF parsing (Semgrep, Trivy, etc.) | ✅ | +| 8 | Cloud integration, which supports running the scan on the cloud provider. | ❌ | +| 9 | Generate diff reports showing new/removed/unchanged assets between runs. | ❌ | +| 10 | Adding step type from cloud provider that can be run via serverless | ❌ | +| N | Fancy features (to be discussed later) | ❌ | ## Documentation | Topic | Link | @@ -164,7 +164,9 @@ The high-level ambitious plan for the project, in order: ## Disclaimer -Osmedeus is designed to execute arbitrary code and commands from user supplied input via CLI, API, and workflow definitions. This flexibility is intentional and central to how the engine operates see [Security Warning](https://docs.osmedeus.org/others/security-warning) page for more details. +**Osmedeus** is designed to execute arbitrary code and commands from user supplied input via CLI, API, and workflow definitions. This flexibility is intentional and central to how the engine operates. + +Please refer to the [Security Warning](https://docs.osmedeus.org/others/security-warning) page for more information on how to stay safe. **Think twice before you:** - Run workflows downloaded from untrusted sources diff --git a/internal/client/types.go b/internal/client/types.go index 6acd384..2a8df7f 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -124,6 +124,9 @@ type Asset struct { HostIP string `json:"host_ip,omitempty"` TechStack []string `json:"tech_stack,omitempty"` ContentType string `json:"content_type,omitempty"` + IsCDN bool `json:"is_cdn,omitempty"` + IsCloud bool `json:"is_cloud,omitempty"` + IsWAF bool `json:"is_waf,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/core/agent_tool_presets.go b/internal/core/agent_tool_presets.go index 30035f3..53ebc5f 100644 --- a/internal/core/agent_tool_presets.go +++ b/internal/core/agent_tool_presets.go @@ -230,7 +230,7 @@ var PresetToolRegistry = map[string]PresetToolDef{ }, }, "exec_python_file": { - Description: "Run a Python file and return stdout (prefers python3)", + Description: "Run a Python file and return stdout (prefers uv, then python3)", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -242,6 +242,32 @@ var PresetToolRegistry = map[string]PresetToolDef{ "required": []string{"path"}, }, }, + "exec_ts": { + Description: "Run inline TypeScript code via bun and return stdout", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "code": map[string]interface{}{ + "type": "string", + "description": "TypeScript code to execute", + }, + }, + "required": []string{"code"}, + }, + }, + "exec_ts_file": { + Description: "Run a TypeScript file via bun and return stdout", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Path to the TypeScript file to execute", + }, + }, + "required": []string{"path"}, + }, + }, "run_module": { Description: "Run an osmedeus module as a subprocess", Parameters: map[string]interface{}{ diff --git a/internal/database/database.go b/internal/database/database.go index b0501a6..b34ee7d 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -282,6 +282,11 @@ func Migrate(ctx context.Context) error { return err } + // Add CDN/WAF classification columns to assets table if they don't exist + if err := addAssetCDNColumns(ctx); err != nil { + return err + } + return nil } @@ -429,6 +434,28 @@ func addAssetExternalURLColumn(ctx context.Context) error { return nil } +// addAssetCDNColumns adds CDN/WAF classification columns to assets table for existing databases +func addAssetCDNColumns(ctx context.Context) error { + columns := []string{ + "ALTER TABLE assets ADD COLUMN is_cdn BOOLEAN DEFAULT FALSE", + "ALTER TABLE assets ADD COLUMN is_cloud BOOLEAN DEFAULT FALSE", + "ALTER TABLE assets ADD COLUMN is_waf BOOLEAN DEFAULT FALSE", + } + for _, ddl := range columns { + _, err := db.ExecContext(ctx, ddl) + if err != nil { + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "duplicate column") || + strings.Contains(errStr, "already exists") || + strings.Contains(errStr, "sqlstate 42701") { + continue + } + return fmt.Errorf("failed to add CDN column: %w", err) + } + } + return nil +} + // createAssetIndexes creates indexes for the assets table func createAssetIndexes(ctx context.Context) error { indexes := []string{ diff --git a/internal/database/jsonl.go b/internal/database/jsonl.go index b4a573b..6edd54b 100644 --- a/internal/database/jsonl.go +++ b/internal/database/jsonl.go @@ -151,6 +151,9 @@ func (i *JSONLImporter) insertAssetBatch(ctx context.Context, assets []*Asset) ( Set("blob_content = CASE WHEN EXCLUDED.blob_content != '' THEN EXCLUDED.blob_content ELSE assets.blob_content END"). Set("raw_data = CASE WHEN EXCLUDED.raw_data != '' THEN EXCLUDED.raw_data ELSE assets.raw_data END"). Set("asset_type = CASE WHEN EXCLUDED.asset_type != '' THEN EXCLUDED.asset_type ELSE assets.asset_type END"). + Set("is_cdn = CASE WHEN EXCLUDED.is_cdn OR assets.is_cdn THEN TRUE ELSE FALSE END"). + Set("is_cloud = CASE WHEN EXCLUDED.is_cloud OR assets.is_cloud THEN TRUE ELSE FALSE END"). + Set("is_waf = CASE WHEN EXCLUDED.is_waf OR assets.is_waf THEN TRUE ELSE FALSE END"). Set("updated_at = EXCLUDED.updated_at"). Exec(ctx) @@ -162,6 +165,56 @@ func (i *JSONLImporter) insertAssetBatch(ctx context.Context, assets []*Asset) ( return int(rowsAffected), nil } +// cloudProviderPatterns are substrings used to detect cloud-hosted CDNs. +var cloudProviderPatterns = []string{ + "aws", "amazon", "cloudfront", + "google", "gcp", "cloud cdn", + "azure", "microsoft", + "akamai", "fastly", + "oracle", + "alibaba", "aliyun", + "tencent", + "digitalocean", +} + +// DetectCDNFlags derives is_cdn, is_cloud, and is_waf booleans from raw httpx JSON data. +// +// Rules: +// - is_cdn = "cdn" is true OR "cdn_name" is non-empty +// - is_cloud = cdn_name matches a cloud provider pattern +// - is_waf = "cdn_type" equals "waf" +func DetectCDNFlags(raw map[string]interface{}) (isCDN, isCloud, isWAF bool) { + // Check "cdn" boolean field + if v, ok := raw["cdn"].(bool); ok && v { + isCDN = true + } + + // Check "cdn_name" string field + cdnName := "" + if v, ok := raw["cdn_name"].(string); ok && v != "" { + cdnName = v + isCDN = true + } + + // Cloud detection from cdn_name + if cdnName != "" { + lower := strings.ToLower(cdnName) + for _, pattern := range cloudProviderPatterns { + if strings.Contains(lower, pattern) { + isCloud = true + break + } + } + } + + // WAF detection from cdn_type + if v, ok := raw["cdn_type"].(string); ok && strings.EqualFold(v, "waf") { + isWAF = true + } + + return +} + // ParseAssetLine parses a single JSONL line into an Asset func ParseAssetLine(line []byte, defaultWorkspace, source string) (*Asset, error) { var raw map[string]interface{} @@ -267,6 +320,9 @@ func ParseAssetLine(line []byte, defaultWorkspace, source string) (*Asset, error asset.AssetType = v } + // CDN/WAF classification + asset.IsCDN, asset.IsCloud, asset.IsWAF = DetectCDNFlags(raw) + // Validate required fields if asset.AssetValue == "" { return nil, fmt.Errorf("asset_value is required") diff --git a/internal/database/models.go b/internal/database/models.go index 76ca8db..f47e2dd 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -235,6 +235,11 @@ type Asset struct { RawResponse string `bun:"raw_response" json:"raw_response,omitempty"` ScreenshotBase64Data string `bun:"screenshot_base64_data" json:"screenshot_base64_data,omitempty"` + // CDN/WAF classification + IsCDN bool `bun:"is_cdn" json:"is_cdn,omitempty"` + IsCloud bool `bun:"is_cloud" json:"is_cloud,omitempty"` + IsWAF bool `bun:"is_waf" json:"is_waf,omitempty"` + // for repository/file assets Language string `bun:"language" json:"language,omitempty"` Size int64 `bun:"size" json:"size,omitempty"` diff --git a/internal/database/repository/asset_repo.go b/internal/database/repository/asset_repo.go index fa4c316..09ba8aa 100644 --- a/internal/database/repository/asset_repo.go +++ b/internal/database/repository/asset_repo.go @@ -299,6 +299,9 @@ func (r *AssetRepository) Upsert(ctx context.Context, asset *database.Asset) err Set("loc = CASE WHEN EXCLUDED.loc != 0 THEN EXCLUDED.loc ELSE assets.loc END"). Set("blob_content = CASE WHEN EXCLUDED.blob_content != '' THEN EXCLUDED.blob_content ELSE assets.blob_content END"). Set("raw_data = CASE WHEN EXCLUDED.raw_data != '' THEN EXCLUDED.raw_data ELSE assets.raw_data END"). + Set("is_cdn = CASE WHEN EXCLUDED.is_cdn OR assets.is_cdn THEN TRUE ELSE FALSE END"). + Set("is_cloud = CASE WHEN EXCLUDED.is_cloud OR assets.is_cloud THEN TRUE ELSE FALSE END"). + Set("is_waf = CASE WHEN EXCLUDED.is_waf OR assets.is_waf THEN TRUE ELSE FALSE END"). Set("updated_at = EXCLUDED.updated_at"). Exec(ctx) return err @@ -330,6 +333,9 @@ func (r *AssetRepository) BulkUpsert(ctx context.Context, assets []*database.Ass Set("loc = CASE WHEN EXCLUDED.loc != 0 THEN EXCLUDED.loc ELSE assets.loc END"). Set("blob_content = CASE WHEN EXCLUDED.blob_content != '' THEN EXCLUDED.blob_content ELSE assets.blob_content END"). Set("raw_data = CASE WHEN EXCLUDED.raw_data != '' THEN EXCLUDED.raw_data ELSE assets.raw_data END"). + Set("is_cdn = CASE WHEN EXCLUDED.is_cdn OR assets.is_cdn THEN TRUE ELSE FALSE END"). + Set("is_cloud = CASE WHEN EXCLUDED.is_cloud OR assets.is_cloud THEN TRUE ELSE FALSE END"). + Set("is_waf = CASE WHEN EXCLUDED.is_waf OR assets.is_waf THEN TRUE ELSE FALSE END"). Set("updated_at = EXCLUDED.updated_at"). Exec(ctx) return err diff --git a/internal/executor/agent_executor.go b/internal/executor/agent_executor.go index 8dc65d8..0063ce9 100644 --- a/internal/executor/agent_executor.go +++ b/internal/executor/agent_executor.go @@ -973,6 +973,10 @@ func buildPresetCallExpr(funcName string, args map[string]interface{}) string { return fmt.Sprintf("exec_python(%s)", jsQuote(getStringArg(args, "code"))) case "exec_python_file": return fmt.Sprintf("exec_python_file(%s)", jsQuote(getStringArg(args, "path"))) + case "exec_ts": + return fmt.Sprintf("exec_ts(%s)", jsQuote(getStringArg(args, "code"))) + case "exec_ts_file": + return fmt.Sprintf("exec_ts_file(%s)", jsQuote(getStringArg(args, "path"))) case "run_module": return fmt.Sprintf("run_module(%s, %s, %s)", jsQuote(getStringArg(args, "module")), jsQuote(getStringArg(args, "target")), jsQuote(getStringArg(args, "params"))) case "run_flow": diff --git a/internal/functions/constants.go b/internal/functions/constants.go index ed5208b..f754058 100644 --- a/internal/functions/constants.go +++ b/internal/functions/constants.go @@ -113,8 +113,10 @@ const ( FnRunFlow = "run_flow" // run_flow(flow, target, params?) -> string (run osmedeus flow) FnRunOnMaster = "run_on_master" // run_on_master(action, ...args) -> bool (execute on master node) FnRunOnWorker = "run_on_worker" // run_on_worker(scope, action, ...args) -> bool (execute on worker nodes) - FnExecPython = "exec_python" // exec_python(code) -> string (run inline Python, prefer python3) - FnExecPythonFile = "exec_python_file" // exec_python_file(path) -> string (run Python file, prefer python3) + FnExecPython = "exec_python" // exec_python(code) -> string (run inline Python, prefer uv → python3 → python) + FnExecPythonFile = "exec_python_file" // exec_python_file(path) -> string (run Python file, prefer uv → python3 → python) + FnExecTS = "exec_ts" // exec_ts(code) -> string (run inline TypeScript via bun) + FnExecTSFile = "exec_ts_file" // exec_ts_file(path) -> string (run TypeScript file via bun) ) // Logging Functions - Log messages with level prefixes @@ -439,6 +441,8 @@ func AllFunctions() []string { FnRunOnWorker, FnExecPython, FnExecPythonFile, + FnExecTS, + FnExecTSFile, // Logging Functions FnLogDebug, @@ -785,8 +789,10 @@ func FunctionRegistry() map[string][]FunctionInfo { {FnPickValid, "pick_valid(v1, v2, ..., v10)", "Return first valid value from up to 10 arguments", "any", "pick_valid('', '', 'hello', 'world')"}, {FnRunModule, "run_module(module, target, params?)", "Run osmedeus module as subprocess, optional comma-separated key=value params", "string", "run_module('subdomain', 'example.com', 'threads=10,deep=true')"}, {FnRunFlow, "run_flow(flow, target, params?)", "Run osmedeus flow as subprocess, optional comma-separated key=value params", "string", "run_flow('general', 'example.com')"}, - {FnExecPython, "exec_python(code)", "Run inline Python code via python3 -c (falls back to python)", "string", "exec_python('print(2+2)')"}, - {FnExecPythonFile, "exec_python_file(path)", "Run a Python file via python3 (falls back to python)", "string", "exec_python_file('/tmp/script.py')"}, + {FnExecPython, "exec_python(code)", "Run inline Python code (prefers uv, falls back to python3/python)", "string", "exec_python('print(2+2)')"}, + {FnExecPythonFile, "exec_python_file(path)", "Run a Python file (prefers uv, falls back to python3/python)", "string", "exec_python_file('/tmp/script.py')"}, + {FnExecTS, "exec_ts(code)", "Run inline TypeScript code via bun -e", "string", "exec_ts('console.log(2+2)')"}, + {FnExecTSFile, "exec_ts_file(path)", "Run a TypeScript file via bun run", "string", "exec_ts_file('/tmp/script.ts')"}, }, CategoryLogging: { {FnLogDebug, "log_debug(message)", "Log debug message with [DEBUG] prefix", "void", "log_debug('Processing target')"}, diff --git a/internal/functions/db_functions.go b/internal/functions/db_functions.go index 00b9f5c..abfc25c 100644 --- a/internal/functions/db_functions.go +++ b/internal/functions/db_functions.go @@ -218,6 +218,11 @@ func unmarshalAssetJSON(rawJSON []byte, asset *database.Asset) error { } } } + // CDN/WAF classification (OR into existing values) + isCDN, isCloud, isWAF := database.DetectCDNFlags(raw) + asset.IsCDN = asset.IsCDN || isCDN + asset.IsCloud = asset.IsCloud || isCloud + asset.IsWAF = asset.IsWAF || isWAF } return nil } @@ -2139,6 +2144,9 @@ func hasAssetChanged(existing, new *database.Asset) bool { existing.TLS != new.TLS || existing.Words != new.Words || existing.Lines != new.Lines || + existing.IsCDN != new.IsCDN || + existing.IsCloud != new.IsCloud || + existing.IsWAF != new.IsWAF || !slicesEqual(existing.Technologies, new.Technologies) } @@ -2260,6 +2268,9 @@ func mapJSONToAsset(data map[string]interface{}, workspace, rawLine string) data asset.Remarks = append(asset.Remarks, v) } + // CDN/WAF classification + asset.IsCDN, asset.IsCloud, asset.IsWAF = database.DetectCDNFlags(data) + // Prefer URL over raw IP for asset_value (more descriptive identifier) if asset.AssetValue != "" && asset.URL != "" && net.ParseIP(asset.AssetValue) != nil { asset.AssetValue = asset.URL @@ -2497,6 +2508,11 @@ func (vf *vmFunc) dbImportCustomAsset(call goja.FunctionCall) goja.Value { if ws, ok := rawData["webserver"].(string); ok && ws != "" { asset.Remarks = append(asset.Remarks, ws) } + // CDN/WAF classification (OR into existing values) + isCDN, isCloud, isWAF := database.DetectCDNFlags(rawData) + asset.IsCDN = asset.IsCDN || isCDN + asset.IsCloud = asset.IsCloud || isCloud + asset.IsWAF = asset.IsWAF || isWAF } // Apply optional defaults when the JSONL line has no value diff --git a/internal/functions/db_merge.go b/internal/functions/db_merge.go index c0d2cab..7d59c76 100644 --- a/internal/functions/db_merge.go +++ b/internal/functions/db_merge.go @@ -26,6 +26,11 @@ func mergeInt64(existing, incoming int64) int64 { return existing } +// mergeBool returns true if either value is true (once true, stays true). +func mergeBool(existing, incoming bool) bool { + return existing || incoming +} + // mergeStringSlice returns incoming if non-nil and non-empty, otherwise existing. func mergeStringSlice(existing, incoming []string) []string { if len(incoming) > 0 { @@ -74,6 +79,11 @@ func mergeAssetFields(existing, incoming *database.Asset) { incoming.RawResponse = mergeString(existing.RawResponse, incoming.RawResponse) incoming.ScreenshotBase64Data = mergeString(existing.ScreenshotBase64Data, incoming.ScreenshotBase64Data) + // CDN/WAF classification + incoming.IsCDN = mergeBool(existing.IsCDN, incoming.IsCDN) + incoming.IsCloud = mergeBool(existing.IsCloud, incoming.IsCloud) + incoming.IsWAF = mergeBool(existing.IsWAF, incoming.IsWAF) + // Repository/file assets incoming.Language = mergeString(existing.Language, incoming.Language) incoming.Size = mergeInt64(existing.Size, incoming.Size) diff --git a/internal/functions/goja_runtime.go b/internal/functions/goja_runtime.go index 5d374c6..56ef2d1 100644 --- a/internal/functions/goja_runtime.go +++ b/internal/functions/goja_runtime.go @@ -124,6 +124,8 @@ func (r *GojaRuntime) registerFunctionsOnVM(vm *goja.Runtime) { _ = vm.Set(FnRunOnWorker, vf.runOnWorker) _ = vm.Set(FnExecPython, vf.execPython) _ = vm.Set(FnExecPythonFile, vf.execPythonFile) + _ = vm.Set(FnExecTS, vf.execTypeScript) + _ = vm.Set(FnExecTSFile, vf.execTypeScriptFile) // Logging functions _ = vm.Set(FnLogDebug, vf.logDebug) diff --git a/internal/functions/util_functions.go b/internal/functions/util_functions.go index 938bf05..30093f3 100644 --- a/internal/functions/util_functions.go +++ b/internal/functions/util_functions.go @@ -748,15 +748,24 @@ func (vf *vmFunc) bash(call goja.FunctionCall) goja.Value { return vf.execCmd(call) } -// findPythonBin returns "python3" if available, otherwise "python". -func findPythonBin() string { - if _, err := exec.LookPath("python3"); err == nil { - return "python3" - } - return "python" +// pythonRunner holds the detected Python binary and whether it's uv. +type pythonRunner struct { + bin string // "uv", "python3", or "python" + isUV bool } -// execPython runs inline Python code via `python3 -c ''`. +// findPythonRunner returns the preferred Python runner: uv → python3 → python. +func findPythonRunner() pythonRunner { + if _, err := exec.LookPath("uv"); err == nil { + return pythonRunner{bin: "uv", isUV: true} + } + if _, err := exec.LookPath("python3"); err == nil { + return pythonRunner{bin: "python3"} + } + return pythonRunner{bin: "python"} +} + +// execPython runs inline Python code. Prefers `uv run -` (stdin), falls back to `python3 -c`. // Usage: exec_python(code) -> string func (vf *vmFunc) execPython(call goja.FunctionCall) goja.Value { code := call.Argument(0).String() @@ -767,13 +776,19 @@ func (vf *vmFunc) execPython(call goja.FunctionCall) goja.Value { return vf.vm.ToValue("") } - pythonBin := findPythonBin() + runner := findPythonRunner() // @NOTE: This is intentional - exec_python() is a utility function exposed to workflow // definitions for executing Python code. Input comes from trusted workflow YAML files. - cmd := exec.Command(pythonBin, "-c", code) + var cmd *exec.Cmd + if runner.isUV { + cmd = exec.Command("uv", "run", "-") + cmd.Stdin = strings.NewReader(code) + } else { + cmd = exec.Command(runner.bin, "-c", code) + } output, err := cmd.Output() if err != nil { - logger.Get().Warn("execPython: command failed", zap.String("python", pythonBin), zap.Error(err)) + logger.Get().Warn("execPython: command failed", zap.String("runner", runner.bin), zap.Error(err)) return vf.vm.ToValue("") } @@ -781,7 +796,7 @@ func (vf *vmFunc) execPython(call goja.FunctionCall) goja.Value { return vf.vm.ToValue(strings.TrimSpace(string(output))) } -// execPythonFile runs a Python file via `python3 `. +// execPythonFile runs a Python file. Prefers `uv run `, falls back to `python3 `. // Usage: exec_python_file(path) -> string func (vf *vmFunc) execPythonFile(call goja.FunctionCall) goja.Value { path := call.Argument(0).String() @@ -792,13 +807,18 @@ func (vf *vmFunc) execPythonFile(call goja.FunctionCall) goja.Value { return vf.vm.ToValue("") } - pythonBin := findPythonBin() + runner := findPythonRunner() // @NOTE: This is intentional - exec_python_file() is a utility function exposed to workflow // definitions for executing Python files. Input comes from trusted workflow YAML files. - cmd := exec.Command(pythonBin, path) + var cmd *exec.Cmd + if runner.isUV { + cmd = exec.Command("uv", "run", path) + } else { + cmd = exec.Command(runner.bin, path) + } output, err := cmd.Output() if err != nil { - logger.Get().Warn("execPythonFile: command failed", zap.String("python", pythonBin), zap.String("path", path), zap.Error(err)) + logger.Get().Warn("execPythonFile: command failed", zap.String("runner", runner.bin), zap.String("path", path), zap.Error(err)) return vf.vm.ToValue("") } @@ -806,6 +826,74 @@ func (vf *vmFunc) execPythonFile(call goja.FunctionCall) goja.Value { return vf.vm.ToValue(strings.TrimSpace(string(output))) } +// findBunBin returns "bun" if available in PATH, empty string otherwise. +func findBunBin() string { + if _, err := exec.LookPath("bun"); err == nil { + return "bun" + } + return "" +} + +// execTypeScript runs inline TypeScript code via `bun -e ''`. +// Usage: exec_ts(code) -> string +func (vf *vmFunc) execTypeScript(call goja.FunctionCall) goja.Value { + code := call.Argument(0).String() + logger.Get().Debug("Calling "+terminal.HiGreen("execTypeScript"), zap.Int("codeLength", len(code))) + + if code == "undefined" || code == "" { + logger.Get().Warn("execTypeScript: empty code provided") + return vf.vm.ToValue("") + } + + bunBin := findBunBin() + if bunBin == "" { + logger.Get().Warn("execTypeScript: bun not found in PATH") + return vf.vm.ToValue("") + } + + // @NOTE: This is intentional - exec_ts() is a utility function exposed to workflow + // definitions for executing TypeScript code. Input comes from trusted workflow YAML files. + cmd := exec.Command(bunBin, "-e", code) + output, err := cmd.Output() + if err != nil { + logger.Get().Warn("execTypeScript: command failed", zap.Error(err)) + return vf.vm.ToValue("") + } + + logger.Get().Debug(terminal.HiGreen("execTypeScript")+" result", zap.Int("outputLength", len(output))) + return vf.vm.ToValue(strings.TrimSpace(string(output))) +} + +// execTypeScriptFile runs a TypeScript file via `bun run `. +// Usage: exec_ts_file(path) -> string +func (vf *vmFunc) execTypeScriptFile(call goja.FunctionCall) goja.Value { + path := call.Argument(0).String() + logger.Get().Debug("Calling "+terminal.HiGreen("execTypeScriptFile"), zap.String("path", path)) + + if path == "undefined" || path == "" { + logger.Get().Warn("execTypeScriptFile: empty path provided") + return vf.vm.ToValue("") + } + + bunBin := findBunBin() + if bunBin == "" { + logger.Get().Warn("execTypeScriptFile: bun not found in PATH") + return vf.vm.ToValue("") + } + + // @NOTE: This is intentional - exec_ts_file() is a utility function exposed to workflow + // definitions for executing TypeScript files. Input comes from trusted workflow YAML files. + cmd := exec.Command(bunBin, "run", path) + output, err := cmd.Output() + if err != nil { + logger.Get().Warn("execTypeScriptFile: command failed", zap.String("path", path), zap.Error(err)) + return vf.vm.ToValue("") + } + + logger.Get().Debug(terminal.HiGreen("execTypeScriptFile")+" result", zap.String("path", path), zap.Int("outputLength", len(output))) + return vf.vm.ToValue(strings.TrimSpace(string(output))) +} + // commandExists checks if a command is available in PATH // Usage: commandExists(command) -> bool func (vf *vmFunc) commandExists(call goja.FunctionCall) goja.Value { diff --git a/internal/functions/util_functions_test.go b/internal/functions/util_functions_test.go index 18913d9..9325c68 100644 --- a/internal/functions/util_functions_test.go +++ b/internal/functions/util_functions_test.go @@ -791,6 +791,71 @@ func TestExecPythonFile(t *testing.T) { }) } +func TestExecTypeScript(t *testing.T) { + bunBin := findBunBin() + if bunBin == "" { + t.Skip("bun not found in PATH, skipping TypeScript tests") + } + + runtime := NewOttoRuntime() + + t.Run("simple console.log", func(t *testing.T) { + result, err := runtime.Execute(`exec_ts("console.log('hello')")`, nil) + require.NoError(t, err) + assert.Equal(t, "hello", result) + }) + + t.Run("multiline code", func(t *testing.T) { + result, err := runtime.Execute(`exec_ts("const x = 2 + 3;\nconsole.log(x)")`, nil) + require.NoError(t, err) + assert.Equal(t, "5", result) + }) + + t.Run("empty code returns empty string", func(t *testing.T) { + result, err := runtime.Execute(`exec_ts("")`, nil) + require.NoError(t, err) + assert.Equal(t, "", result) + }) + + t.Run("invalid code returns empty string", func(t *testing.T) { + result, err := runtime.Execute(`exec_ts("process.exit(1)")`, nil) + require.NoError(t, err) + assert.Equal(t, "", result) + }) +} + +func TestExecTypeScriptFile(t *testing.T) { + bunBin := findBunBin() + if bunBin == "" { + t.Skip("bun not found in PATH, skipping TypeScript file tests") + } + + runtime := NewOttoRuntime() + + t.Run("run temp ts file", func(t *testing.T) { + tmpDir := t.TempDir() + tsFile := filepath.Join(tmpDir, "test.ts") + err := os.WriteFile(tsFile, []byte("console.log('from file')"), 0644) + require.NoError(t, err) + + result, err := runtime.Execute(`exec_ts_file("`+tsFile+`")`, nil) + require.NoError(t, err) + assert.Equal(t, "from file", result) + }) + + t.Run("empty path returns empty string", func(t *testing.T) { + result, err := runtime.Execute(`exec_ts_file("")`, nil) + require.NoError(t, err) + assert.Equal(t, "", result) + }) + + t.Run("nonexistent file returns empty string", func(t *testing.T) { + result, err := runtime.Execute(`exec_ts_file("/tmp/nonexistent_ts_file_12345.ts")`, nil) + require.NoError(t, err) + assert.Equal(t, "", result) + }) +} + func TestMoveFile(t *testing.T) { runtime := NewOttoRuntime()