diff --git a/internal/executor/executor.go b/internal/executor/executor.go index a0c1aa3..f62c3c9 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -2904,22 +2904,40 @@ func (e *Executor) executeDecisionCase(ctx context.Context, dc *core.DecisionCas // evaluateConditions evaluates condition-based decision entries. // All matching conditions execute their inline commands/functions (no short-circuit). // Returns the last matched goto target (if any) and collected inline results. +// +// Condition `if` expressions support both template and bare variable syntax: +// - "{{enable_extra}} && {{target}} != ”" (template syntax — works with params/exports) +// - "enable_extra && target != ”" (bare JS variables — also works) +// +// Template {{var}} references outside string literals are converted to bare JS variable +// names so that params and exports are injected into the JS context with proper types. +// Template {{var}} inside string literals ('{{path}}/file') are still template-rendered. func (e *Executor) evaluateConditions(ctx context.Context, conditions []core.DecisionCondition, execCtx *core.ExecutionContext) (string, []*core.StepResult) { vars := execCtx.GetVariables() + + // Normalize string "true"/"false" to actual booleans for JS evaluation. + // This ensures string params (type: string, default: "true") behave correctly + // in boolean conditions — matching the old template rendering behavior where + // {{var}} rendered to the literal true/false. + jsVars := normalizeBoolStringsForJS(vars) + var lastGoto string var allResults []*core.StepResult for i := range conditions { cond := &conditions[i] - // Render template variables in the if expression - rendered, err := e.templateEngine.Render(cond.If, vars) + // Convert {{var}} outside quotes to bare JS variable names, + // then template-render any remaining {{var}} inside quotes. + jsExpr := stripTemplateVarsForJS(cond.If) + rendered, err := e.templateEngine.Render(jsExpr, vars) if err != nil { - continue + // Fallback: if template render fails, use the stripped expression as-is + rendered = jsExpr } // Evaluate the rendered expression as a JS boolean - ok, err := e.functionRegistry.EvaluateCondition(rendered, vars) + ok, err := e.functionRegistry.EvaluateCondition(rendered, jsVars) if err != nil || !ok { continue } @@ -2939,6 +2957,80 @@ func (e *Executor) evaluateConditions(ctx context.Context, conditions []core.Dec return lastGoto, allResults } +// stripTemplateVarsForJS converts {{variable}} patterns to bare JS variable names +// when they appear outside string literals. Variables inside quoted strings are +// left as {{var}} for subsequent template rendering. +// +// Examples: +// +// "{{flag}} && {{target}} != ''" → "flag && target != ''" +// "file_exists('{{output}}/f.txt')" → "file_exists('{{output}}/f.txt')" +// "{{depth}} > 2" → "depth > 2" +func stripTemplateVarsForJS(expr string) string { + if !strings.Contains(expr, "{{") { + return expr + } + + var result strings.Builder + result.Grow(len(expr)) + inQuote := byte(0) // 0 = not in quote, '\'' or '"' = in quote + + for i := 0; i < len(expr); { + ch := expr[i] + + // Track quote state + if (ch == '\'' || ch == '"') && inQuote == 0 { + inQuote = ch + result.WriteByte(ch) + i++ + continue + } + if ch == inQuote { + inQuote = 0 + result.WriteByte(ch) + i++ + continue + } + + // Outside quotes: strip {{var}} to bare variable name + if inQuote == 0 && ch == '{' && i+1 < len(expr) && expr[i+1] == '{' { + end := strings.Index(expr[i+2:], "}}") + if end >= 0 { + varName := strings.TrimSpace(expr[i+2 : i+2+end]) + result.WriteString(varName) + i += 2 + end + 2 + continue + } + } + + result.WriteByte(ch) + i++ + } + + return result.String() +} + +// normalizeBoolStringsForJS converts string "true"/"false" values to actual Go booleans. +// This ensures correct JS evaluation where string "false" is truthy but bool false is falsy. +func normalizeBoolStringsForJS(vars map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(vars)) + for k, v := range vars { + if s, ok := v.(string); ok { + switch strings.ToLower(s) { + case "true": + result[k] = true + case "false": + result[k] = false + default: + result[k] = v + } + } else { + result[k] = v + } + } + return result +} + // executeDecisionCondition executes inline commands/functions from a matched DecisionCondition. func (e *Executor) executeDecisionCondition(ctx context.Context, dc *core.DecisionCondition, execCtx *core.ExecutionContext) []*core.StepResult { var results []*core.StepResult diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 1f76bba..fbbe8e0 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -2410,3 +2410,166 @@ func TestExecutor_Decision_ConditionWithSwitchCase(t *testing.T) { assert.Equal(t, "decision-condition-function", result.Steps[2].StepName) assert.Equal(t, "final-step", result.Steps[3].StepName) } + +func TestStripTemplateVarsForJS(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "bare variables outside quotes", + input: "{{enable_extra}} && {{target}} != ''", + expected: "enable_extra && target != ''", + }, + { + name: "variable inside single quotes preserved", + input: "file_exists('{{output}}/results.txt')", + expected: "file_exists('{{output}}/results.txt')", + }, + { + name: "variable inside double quotes preserved", + input: `file_exists("{{output}}/results.txt")`, + expected: `file_exists("{{output}}/results.txt")`, + }, + { + name: "mixed inside and outside quotes", + input: "{{flag}} && file_exists('{{path}}/f.txt')", + expected: "flag && file_exists('{{path}}/f.txt')", + }, + { + name: "no template variables", + input: "enable_extra && target != ''", + expected: "enable_extra && target != ''", + }, + { + name: "simple boolean variable", + input: "{{flag_a}}", + expected: "flag_a", + }, + { + name: "numeric comparison", + input: "{{scan_depth}} > 2", + expected: "scan_depth > 2", + }, + { + name: "variable with spaces in braces", + input: "{{ flag_a }} && {{ target }}", + expected: "flag_a && target", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := stripTemplateVarsForJS(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestExecutor_Decision_ConditionWithParams(t *testing.T) { + ctx := context.Background() + cfg := testConfig(t) + + module := &core.Workflow{ + Name: "test-decision-cond-params", + Kind: core.KindModule, + Params: []core.Param{ + {Name: "target", Required: true}, + {Name: "enable_extra", Type: "string", Default: "true"}, + }, + Steps: []core.Step{ + { + Name: "check-step", + Type: core.StepTypeBash, + Command: "echo 'check'", + // No exports — condition uses params directly + Decision: &core.DecisionConfig{ + Conditions: []core.DecisionCondition{ + { + If: "{{enable_extra}} && {{target}} != ''", + Function: "log_info('param condition matched')", + }, + }, + }, + }, + { + Name: "final-step", + Type: core.StepTypeBash, + Command: "echo 'done'", + }, + }, + } + + executor := NewExecutor() + executor.SetDryRun(false) + executor.SetSpinner(false) + + result, err := executor.ExecuteModule(ctx, module, map[string]string{ + "target": "example.com", + }, cfg) + + require.NoError(t, err) + assert.Equal(t, core.RunStatusCompleted, result.Status) + // Should execute: check-step -> inline condition function -> final-step + assert.GreaterOrEqual(t, len(result.Steps), 3) + assert.Equal(t, "check-step", result.Steps[0].StepName) + assert.Equal(t, "decision-condition-function", result.Steps[1].StepName) + assert.Equal(t, "final-step", result.Steps[2].StepName) +} + +func TestExecutor_Decision_ConditionWithParams_Disabled(t *testing.T) { + ctx := context.Background() + cfg := testConfig(t) + + module := &core.Workflow{ + Name: "test-decision-cond-params-disabled", + Kind: core.KindModule, + Params: []core.Param{ + {Name: "target", Required: true}, + {Name: "enable_extra", Type: "string", Default: "true"}, + }, + Steps: []core.Step{ + { + Name: "check-step", + Type: core.StepTypeBash, + Command: "echo 'check'", + Decision: &core.DecisionConfig{ + Conditions: []core.DecisionCondition{ + { + If: "{{enable_extra}} && {{target}} != ''", + Function: "log_info('should not run')", + }, + }, + }, + }, + { + Name: "final-step", + Type: core.StepTypeBash, + Command: "echo 'done'", + }, + }, + } + + executor := NewExecutor() + executor.SetDryRun(false) + executor.SetSpinner(false) + + // Pass enable_extra=false to disable the condition + result, err := executor.ExecuteModule(ctx, module, map[string]string{ + "target": "example.com", + "enable_extra": "false", + }, cfg) + + require.NoError(t, err) + assert.Equal(t, core.RunStatusCompleted, result.Status) + // Should execute: check-step -> final-step (condition skipped) + assert.Equal(t, 2, len(result.Steps)) + assert.Equal(t, "check-step", result.Steps[0].StepName) + assert.Equal(t, "final-step", result.Steps[1].StepName) +} diff --git a/internal/functions/db_functions.go b/internal/functions/db_functions.go index 31f6744..a353143 100644 --- a/internal/functions/db_functions.go +++ b/internal/functions/db_functions.go @@ -2004,11 +2004,14 @@ func (vf *vmFunc) dbImportAssetFromFile(call goja.FunctionCall) goja.Value { workspace := call.Argument(0).String() filePath := call.Argument(1).String() - // Parse optional source (3rd arg) + // Parse optional source (3rd arg), default to "web" var defaultSource string if len(call.Arguments) >= 3 { defaultSource = call.Argument(2).String() } + if defaultSource == "" || defaultSource == "undefined" { + defaultSource = "web" + } if workspace == "" || workspace == "undefined" { return vf.errorValue("workspace cannot be empty") @@ -2283,6 +2286,20 @@ func mapJSONToAsset(data map[string]interface{}, workspace, rawLine string) data asset.AssetValue = asset.URL } + // Fallback: if asset_value is still empty, derive from URL + if asset.AssetValue == "" && asset.URL != "" { + asset.AssetValue = asset.URL + } + + // Auto-classify asset_type based on asset_value + if asset.AssetType == "" && asset.AssetValue != "" { + asset.AssetType = database.ClassifyAssetType(asset.AssetValue) + } + // Refine: URL with HTTP response data -> "http" + if asset.AssetType == "url" && (asset.StatusCode > 0 || asset.ContentLength > 0) { + asset.AssetType = "http" + } + return asset } diff --git a/pkg/cli/db.go b/pkg/cli/db.go index 379ed77..1931ca5 100644 --- a/pkg/cli/db.go +++ b/pkg/cli/db.go @@ -11,6 +11,8 @@ import ( "syscall" "time" + "golang.org/x/term" + "github.com/j3ssie/osmedeus/v5/internal/config" "github.com/j3ssie/osmedeus/v5/internal/database" "github.com/j3ssie/osmedeus/v5/internal/terminal" @@ -47,7 +49,7 @@ var tableDefaultColumns = map[string][]string{ "runs": {"run_uuid", "workflow_name", "target", "workspace", "trigger_type", "status", "completed_steps", "total_steps"}, "step_results": {"step_name", "step_type", "status", "duration_ms", "command"}, "artifacts": {"name", "path", "type", "size_bytes", "line_count"}, - "assets": {"asset_value", "status_code", "title", "tech", "host_ip", "source", "asset_type", "url"}, + "assets": {"asset_value", "status_code", "title", "tech", "host_ip", "source", "asset_type"}, "event_logs": {"topic", "source", "processed", "data_type", "workspace", "data"}, "schedules": {"name", "workflow_name", "workflow_kind", "target", "trigger_type", "schedule", "is_enabled", "run_count"}, "workspaces": {"name", "data_source", "total_assets", "total_ips", "total_vulns", "risk_score"}, @@ -65,6 +67,23 @@ var tableColumnMinWidths = map[string]map[string]int{ }, } +// tableColumnWeights defines per-column weights for surplus width distribution (default weight = 1) +var tableColumnWeights = map[string]map[string]int{ + "assets": { + "asset_value": 8, + "title": 3, + "tech": 3, + "host_ip": 2, + }, +} + +// tableColumnDisplayNames maps internal column names to shorter display names +var tableColumnDisplayNames = map[string]map[string]string{ + "assets": { + "status_code": "status", + }, +} + // dbCmd - parent command for database management var dbCmd = &cobra.Command{ Use: "db", @@ -624,18 +643,27 @@ func listTableRecordsOnce(ctx context.Context, cfg *config.Config, printer *term startRecord = 0 } - printer.Info("Table: %s", records.Table) - fmt.Printf("Showing records %d-%d of %d\n\n", startRecord, endRecord, records.TotalCount) + statsLine := fmt.Sprintf("%s Table: %s | Showing %s-%s of %s", + terminal.InfoSymbol(), + records.Table, + terminal.HiCyan(fmt.Sprintf("%d", startRecord)), + terminal.HiCyan(fmt.Sprintf("%d", endRecord)), + terminal.HiCyan(fmt.Sprintf("%d", records.TotalCount)), + ) + if records.TotalCount > endRecord { + nextOffset := records.Offset + records.Limit + statsLine += fmt.Sprintf(" | Next: osmedeus db list -t %s --offset %s --limit %s", + dbTable, + terminal.HiCyan(fmt.Sprintf("%d", nextOffset)), + terminal.HiCyan(fmt.Sprintf("%d", dbLimit)), + ) + } + fmt.Println(statsLine) + fmt.Println() // Render table using tablewriter renderTableWithTablewriter(dbTable, records.Records, columns, globalWidth, hideDefaultColumns, excludeColumns) - // Show pagination hints - if records.TotalCount > endRecord { - nextOffset := records.Offset + records.Limit - printer.Info("Next page: osmedeus db list -t %s --offset %d --limit %d", dbTable, nextOffset, dbLimit) - } - return nil } @@ -784,28 +812,99 @@ func renderTableWithTablewriter(tableName string, records interface{}, columns [ tablewriter.WithHeaderAutoWrap(tw.WrapNone), tablewriter.WithTrimSpace(tw.On), } - if maxWidth > 0 { - opts = append(opts, tablewriter.WithMaxWidth(maxWidth)) - } - - // Apply per-column minimum widths if defined for this table - if colWidths, ok := tableColumnMinWidths[tableName]; ok { - widths := tw.NewMapper[int, int]() - for i, h := range headers { - if minW, exists := colWidths[h]; exists { - widths[i] = minW - } + // Auto-detect terminal width if maxWidth is 0 + effectiveWidth := maxWidth + if effectiveWidth == 0 { + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + effectiveWidth = w } - if len(widths) > 0 { + } + if effectiveWidth > 0 && len(headers) > 0 { + // Compute per-column widths that fill the terminal width. + // WithMaxWidth only caps columns — short-content columns don't expand to fill space. + // Using WithColumnWidths forces each column to the allocated width. + numCols := len(headers) + // Overhead: " │ " (3 chars) between columns, plus leading/trailing space + overhead := (numCols-1)*3 + 2 + available := effectiveWidth - overhead + + widths := tw.NewMapper[int, int]() + displayNames := tableColumnDisplayNames[tableName] + if available >= numCols*4 { + // Base width per column = display header length + 2 (padding), minimum 8 + mins := make([]int, numCols) + totalMin := 0 + for i, h := range headers { + hLen := len(h) + if displayNames != nil { + if alias, ok := displayNames[h]; ok { + hLen = len(alias) + } + } + mins[i] = hLen + 2 + if mins[i] < 8 { + mins[i] = 8 + } + totalMin += mins[i] + } + if totalMin >= available { + // Terminal too narrow for headers — equal distribution + perCol := available / numCols + for i := range headers { + widths[i] = max(perCol, 4) + } + } else { + // Distribute surplus using per-column weights (default weight = 1) + surplus := available - totalMin + colWeights := tableColumnWeights[tableName] + totalWeight := 0 + weights := make([]int, numCols) + for i, h := range headers { + w := 1 + if colWeights != nil { + if cw, ok := colWeights[h]; ok { + w = cw + } + } + weights[i] = w + totalWeight += w + } + for i := range headers { + widths[i] = mins[i] + surplus*weights[i]/totalWeight + } + } opts = append(opts, tablewriter.WithColumnWidths(widths)) + } else { + opts = append(opts, tablewriter.WithMaxWidth(effectiveWidth)) + } + opts = append(opts, tablewriter.WithRowAutoWrap(tw.WrapBreak)) + } else if effectiveWidth == 0 { + // No width constraint (piped output) — apply per-column minimums if defined + if colWidths, ok := tableColumnMinWidths[tableName]; ok { + widths := tw.NewMapper[int, int]() + for i, h := range headers { + if minW, exists := colWidths[h]; exists { + widths[i] = minW + } + } + if len(widths) > 0 { + opts = append(opts, tablewriter.WithColumnWidths(widths)) + } } } table := tablewriter.NewTable(os.Stdout, opts...) - // Convert headers to []any for variadic call + // Convert headers to []any for variadic call, applying display name aliases + displayNames := tableColumnDisplayNames[tableName] headerArgs := make([]any, len(headers)) for i, h := range headers { + if displayNames != nil { + if alias, ok := displayNames[h]; ok { + headerArgs[i] = alias + continue + } + } headerArgs[i] = h } table.Header(headerArgs...) diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 669d667..fdebfdd 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -285,7 +285,7 @@ func init() { // Global flags available to all subcommands rootCmd.PersistentFlags().BoolVar(&globalForce, "force", false, "skip confirmation prompts and force operations") rootCmd.PersistentFlags().BoolVar(&globalJSON, "json", false, "output in JSON format") - rootCmd.PersistentFlags().IntVar(&globalWidth, "width", 70, "max column width for table display (0 = no limit)") + rootCmd.PersistentFlags().IntVar(&globalWidth, "width", 0, "max table width for display (0 = auto-detect terminal width)") // Suppress usage display and default error output (we handle errors in Execute()) rootCmd.SilenceUsage = true diff --git a/test/testdata/workflows/test-decision-conditions.yaml b/test/testdata/workflows/test-decision-conditions.yaml index e8aab25..32dcdcc 100644 --- a/test/testdata/workflows/test-decision-conditions.yaml +++ b/test/testdata/workflows/test-decision-conditions.yaml @@ -14,15 +14,13 @@ params: default: "3" steps: - # Step 1: Single condition match with function + # Step 1: Single condition match — uses params directly (no exports needed) - name: check-target type: bash command: echo "Checking target" - exports: - has_target: "true" decision: conditions: - - if: "{{has_target}}" + - if: "{{enable_extra}} && {{target}} != ''" function: "log_info('[COND] Target is present')" # Step 2: Multiple conditions - all matching ones execute