Skip to content

Commit b0736ab

Browse files
committed
feat: add skip() function and fuzzy module exclusion support
- Add skip() function to terminate remaining steps in current module while continuing to next module, with optional message parameter - Implement isFuzzyModuleExcluded() for substring-based module filtering in ExecuteFlow - Add fuzzy_exclude_modules CLI flag (-X) to both run and scan commands for flexible module exclusion - Handle ErrSkipModule sentinel error throughout executor (executeStep, executeStepsDAG, ExecuteModule, ExecuteFlow) with proper status propagation - Update function registry and Goja runtime to register skip() function - Add comprehensive unit tests for skip() behavior, SkipModuleError, and fuzzy module matching - Update snapshot tests to use generic example.com instead of shopee.vn
1 parent b6e9d12 commit b0736ab

12 files changed

Lines changed: 343 additions & 16 deletions

File tree

internal/executor/executor.go

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,16 @@ func isModuleExcluded(moduleName string, excludeList []string) bool {
568568
return false
569569
}
570570

571+
// isFuzzyModuleExcluded checks if a module name contains any of the fuzzy exclude patterns
572+
func isFuzzyModuleExcluded(moduleName string, fuzzyList []string) bool {
573+
for _, pattern := range fuzzyList {
574+
if strings.Contains(moduleName, pattern) {
575+
return true
576+
}
577+
}
578+
return false
579+
}
580+
571581
// formatDuration formats a duration in human-readable format
572582
func formatDuration(d time.Duration) string {
573583
if d < time.Second {
@@ -1233,6 +1243,25 @@ func (e *Executor) ExecuteModule(ctx context.Context, module *core.Workflow, par
12331243
metrics.RecordStepDuration(string(step.Type), string(stepResult.Status), stepResult.Duration.Seconds())
12341244

12351245
if err != nil {
1246+
// Check if this is a skip-module signal
1247+
if errors.Is(err, functions.ErrSkipModule) {
1248+
msg := "skip() called"
1249+
var skipErr *functions.SkipModuleError
1250+
if errors.As(err, &skipErr) {
1251+
msg = skipErr.Message
1252+
}
1253+
execCtx.Logger.Info("Module skipped",
1254+
zap.String("step", step.Name),
1255+
zap.String("message", msg),
1256+
)
1257+
result.Status = core.RunStatusSkipped
1258+
result.Message = msg
1259+
result.Exports = execCtx.Exports
1260+
result.EndTime = time.Now()
1261+
metrics.RecordWorkflowEnd(module.Name, string(core.KindModule), string(result.Status), result.EndTime.Sub(result.StartTime).Seconds())
1262+
return result, nil
1263+
}
1264+
12361265
execCtx.Logger.Error("Step failed",
12371266
zap.String("step", step.Name),
12381267
zap.Error(err),
@@ -1475,6 +1504,21 @@ func (e *Executor) executeStepsDAG(ctx context.Context, steps []core.Step, execC
14751504
}
14761505

14771506
if err != nil {
1507+
// Check if this is a skip-module signal
1508+
if errors.Is(err, functions.ErrSkipModule) {
1509+
if firstError == nil {
1510+
firstError = err
1511+
}
1512+
// Mark all remaining steps as completed to break the main loop
1513+
for name := range stepMap {
1514+
if !executed[name] {
1515+
executed[name] = true
1516+
atomic.AddInt32(&completedCount, 1)
1517+
}
1518+
}
1519+
cond.Signal()
1520+
return
1521+
}
14781522
failed[sName] = true
14791523
if firstError == nil && !e.shouldContinueOnError(s) {
14801524
firstError = err
@@ -1502,6 +1546,18 @@ func (e *Executor) executeStepsDAG(ctx context.Context, steps []core.Step, execC
15021546
result.Steps = collector.Results()
15031547

15041548
if firstError != nil {
1549+
// Check if the error was a skip-module signal (not a failure)
1550+
if errors.Is(firstError, functions.ErrSkipModule) {
1551+
msg := "skip() called"
1552+
var skipErr *functions.SkipModuleError
1553+
if errors.As(firstError, &skipErr) {
1554+
msg = skipErr.Message
1555+
}
1556+
result.Status = core.RunStatusSkipped
1557+
result.Message = msg
1558+
result.EndTime = time.Now()
1559+
return nil
1560+
}
15051561
result.Status = core.RunStatusFailed
15061562
result.Error = firstError
15071563
result.EndTime = time.Now()
@@ -1762,6 +1818,7 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params
17621818

17631819
// Parse excluded modules
17641820
excludeList := parseExcludeList(params["exclude_modules"])
1821+
fuzzyExcludeList := parseExcludeList(params["fuzzy_exclude_modules"])
17651822

17661823
// Pre-load all modules in parallel for faster startup
17671824
execCtx.Logger.Debug("Pre-loading modules", zap.Int("count", len(flow.Modules)))
@@ -1812,8 +1869,8 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params
18121869

18131870
modRef := moduleMap[modName]
18141871

1815-
// Check if module is excluded
1816-
if isModuleExcluded(modRef.Name, excludeList) {
1872+
// Check if module is excluded (exact match or fuzzy substring match)
1873+
if isModuleExcluded(modRef.Name, excludeList) || isFuzzyModuleExcluded(modRef.Name, fuzzyExcludeList) {
18171874
execCtx.Logger.Info("Skipping excluded module", zap.String("module", modRef.Name))
18181875
executed[modRef.Name] = true
18191876
// Unblock dependents even for excluded modules
@@ -2110,6 +2167,16 @@ func (e *Executor) executeStep(ctx context.Context, step *core.Step, execCtx *co
21102167
)
21112168
ok, err := e.functionRegistry.EvaluateCondition(renderedCondition, execCtx.GetVariables())
21122169
if err != nil {
2170+
// Check if skip() was called inside a pre_condition
2171+
if errors.Is(err, functions.ErrSkipModule) {
2172+
result.Status = core.StepStatusSkipped
2173+
result.Error = err
2174+
result.EndTime = time.Now()
2175+
if e.progressBar == nil {
2176+
e.printer.StepSkipped(step.Name)
2177+
}
2178+
return result, err
2179+
}
21132180
stepLogger.Debug("Pre-condition evaluation failed", zap.Error(err))
21142181
result.Status = core.StepStatusFailed
21152182
result.Error = fmt.Errorf("pre-condition evaluation failed: %w", err)
@@ -2243,6 +2310,18 @@ func (e *Executor) executeStep(ctx context.Context, step *core.Step, execCtx *co
22432310
sp.Stop()
22442311
}
22452312
if err != nil {
2313+
// Check if this is a skip-module signal (not a failure)
2314+
if errors.Is(err, functions.ErrSkipModule) {
2315+
result.Status = core.StepStatusSkipped
2316+
result.Error = err
2317+
result.EndTime = time.Now()
2318+
result.Duration = result.EndTime.Sub(result.StartTime)
2319+
if e.progressBar == nil {
2320+
e.printer.StepSkipped(step.Name)
2321+
}
2322+
return result, err
2323+
}
2324+
22462325
result.Status = core.StepStatusFailed
22472326
result.Error = err
22482327
result.EndTime = time.Now()

internal/executor/executor_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/j3ssie/osmedeus/v5/internal/config"
99
"github.com/j3ssie/osmedeus/v5/internal/core"
10+
"github.com/j3ssie/osmedeus/v5/internal/parser"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213
)
@@ -1677,3 +1678,143 @@ func TestExecutor_StepDependencies_FailedDep_SkipsDependent(t *testing.T) {
16771678
assert.NotEqual(t, core.StepStatusSuccess, stepBExists)
16781679
}
16791680
}
1681+
1682+
func TestExecutor_SkipModule(t *testing.T) {
1683+
ctx := context.Background()
1684+
cfg := testConfig(t)
1685+
1686+
module := &core.Workflow{
1687+
Name: "test-skip",
1688+
Kind: core.KindModule,
1689+
Steps: []core.Step{
1690+
{
1691+
Name: "step-before",
1692+
Type: core.StepTypeFunction,
1693+
Function: "log_info('before skip')",
1694+
},
1695+
{
1696+
Name: "step-skip",
1697+
Type: core.StepTypeFunction,
1698+
Function: "skip('target not applicable')",
1699+
},
1700+
{
1701+
Name: "step-after",
1702+
Type: core.StepTypeFunction,
1703+
Function: "log_info('after skip')",
1704+
},
1705+
},
1706+
}
1707+
1708+
executor := NewExecutor()
1709+
executor.SetDryRun(false)
1710+
executor.SetSpinner(false)
1711+
1712+
result, err := executor.ExecuteModule(ctx, module, map[string]string{
1713+
"target": "test",
1714+
}, cfg)
1715+
1716+
// skip() returns nil error (not a failure)
1717+
require.NoError(t, err)
1718+
assert.Equal(t, core.RunStatusSkipped, result.Status)
1719+
assert.Equal(t, "target not applicable", result.Message)
1720+
1721+
// step-before should have executed, step-after should NOT
1722+
assert.GreaterOrEqual(t, len(result.Steps), 2, "should have at least step-before and step-skip")
1723+
1724+
// Find step-after in results - it should not be present
1725+
for _, s := range result.Steps {
1726+
assert.NotEqual(t, "step-after", s.StepName, "step-after should not have executed")
1727+
}
1728+
}
1729+
1730+
func TestExecutor_SkipModulePreservesExports(t *testing.T) {
1731+
ctx := context.Background()
1732+
cfg := testConfig(t)
1733+
1734+
module := &core.Workflow{
1735+
Name: "test-skip-exports",
1736+
Kind: core.KindModule,
1737+
Steps: []core.Step{
1738+
{
1739+
Name: "set-var",
1740+
Type: core.StepTypeFunction,
1741+
Function: "set_var('my_key', 'my_value')",
1742+
},
1743+
{
1744+
Name: "do-skip",
1745+
Type: core.StepTypeFunction,
1746+
Function: "skip('done early')",
1747+
},
1748+
},
1749+
}
1750+
1751+
executor := NewExecutor()
1752+
executor.SetDryRun(false)
1753+
executor.SetSpinner(false)
1754+
1755+
result, err := executor.ExecuteModule(ctx, module, map[string]string{
1756+
"target": "test",
1757+
}, cfg)
1758+
1759+
require.NoError(t, err)
1760+
assert.Equal(t, core.RunStatusSkipped, result.Status)
1761+
assert.Equal(t, "done early", result.Message)
1762+
}
1763+
1764+
func TestIsFuzzyModuleExcluded(t *testing.T) {
1765+
tests := []struct {
1766+
name string
1767+
moduleName string
1768+
fuzzyList []string
1769+
expected bool
1770+
}{
1771+
{"exact substring match", "recon-spider", []string{"spider"}, true},
1772+
{"prefix match", "spider-crawl", []string{"spider"}, true},
1773+
{"no match", "recon-dns", []string{"spider"}, false},
1774+
{"empty list", "recon-spider", nil, false},
1775+
{"empty pattern list", "recon-spider", []string{}, false},
1776+
{"multiple patterns first matches", "recon-spider", []string{"spider", "dns"}, true},
1777+
{"multiple patterns second matches", "recon-dns", []string{"spider", "dns"}, true},
1778+
{"multiple patterns none match", "recon-http", []string{"spider", "dns"}, false},
1779+
{"full name as pattern", "recon-spider", []string{"recon-spider"}, true},
1780+
}
1781+
1782+
for _, tt := range tests {
1783+
t.Run(tt.name, func(t *testing.T) {
1784+
result := isFuzzyModuleExcluded(tt.moduleName, tt.fuzzyList)
1785+
assert.Equal(t, tt.expected, result)
1786+
})
1787+
}
1788+
}
1789+
1790+
func TestExecutor_FuzzyExcludeModules(t *testing.T) {
1791+
ctx := context.Background()
1792+
cfg := testConfig(t)
1793+
1794+
// Create a flow where all modules will be excluded by fuzzy match
1795+
flow := &core.Workflow{
1796+
Name: "test-fuzzy-exclude",
1797+
Kind: core.KindFlow,
1798+
Modules: []core.ModuleRef{
1799+
{Name: "recon-spider", Path: ""},
1800+
{Name: "spider-crawl", Path: ""},
1801+
},
1802+
}
1803+
1804+
loader := parser.NewLoader(cfg.WorkflowsPath)
1805+
1806+
exec := NewExecutor()
1807+
exec.SetDryRun(true)
1808+
exec.SetSpinner(false)
1809+
exec.SetLoader(loader)
1810+
1811+
// fuzzy_exclude_modules=spider should skip both recon-spider and spider-crawl
1812+
result, err := exec.ExecuteFlow(ctx, flow, map[string]string{
1813+
"target": "test.example.com",
1814+
"fuzzy_exclude_modules": "spider",
1815+
}, cfg)
1816+
1817+
require.NoError(t, err)
1818+
assert.NotNil(t, result)
1819+
assert.Equal(t, core.RunStatusCompleted, result.Status)
1820+
}

internal/executor/function_executor.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package executor
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"sync"
78
"time"
@@ -68,6 +69,11 @@ func (e *FunctionExecutor) Execute(ctx context.Context, step *core.Step, execCtx
6869
result.Duration = result.EndTime.Sub(result.StartTime)
6970

7071
if err != nil {
72+
if errors.Is(err, functions.ErrSkipModule) {
73+
result.Status = core.StepStatusSkipped
74+
result.Error = err
75+
return result, err
76+
}
7177
result.Status = core.StepStatusFailed
7278
result.Error = err
7379
return result, err

internal/functions/constants.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
package functions
22

3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
// ErrSkipModule is a sentinel error used by skip() to signal that the
9+
// remaining steps in the current module should be skipped. The flow
10+
// continues to the next module.
11+
var ErrSkipModule = errors.New("skip module")
12+
13+
// SkipModuleError carries an optional message and wraps ErrSkipModule
14+
// so that errors.Is(err, ErrSkipModule) works through the chain.
15+
type SkipModuleError struct {
16+
Message string
17+
}
18+
19+
func (e *SkipModuleError) Error() string {
20+
return fmt.Sprintf("skip module: %s", e.Message)
21+
}
22+
23+
func (e *SkipModuleError) Unwrap() error {
24+
return ErrSkipModule
25+
}
26+
327
// Function name constants for easy reference and consistency
428
// This file serves as a central reference for all available workflow functions
529

@@ -79,6 +103,7 @@ const (
79103
FnPrintf = "printf" // printf(message) -> void (print message to stdout)
80104
FnCatFile = "cat_file" // cat_file(path) -> void (print file content to stdout)
81105
FnExit = "exit" // exit(code) -> void (exit scan with code)
106+
FnSkip = "skip" // skip(message?) -> void (skip remaining steps in current module)
82107
FnExecCmd = "exec_cmd" // exec_cmd(command) -> string (alias for bash)
83108
FnBash = "bash"
84109
FnSleep = "sleep" // sleep(seconds) -> void (pause for n seconds)
@@ -391,6 +416,7 @@ func AllFunctions() []string {
391416
FnPrintf,
392417
FnCatFile,
393418
FnExit,
419+
FnSkip,
394420
FnExecCmd,
395421
FnBash,
396422
FnSleep,
@@ -727,6 +753,7 @@ func FunctionRegistry() map[string][]FunctionInfo {
727753
{FnPrintf, "printf(message)", "Print message to stdout", "void", "printf('Scan started')"},
728754
{FnCatFile, "cat_file(path)", "Print file content to stdout", "void", "cat_file('{{Output}}/results.txt')"},
729755
{FnExit, "exit(code)", "Exit scan with code", "void", "exit(1)"},
756+
{FnSkip, "skip(message?)", "Skip remaining steps in current module and continue to next module", "void", "skip('target not applicable')"},
730757
{FnBash, "bash(command)", "Execute bash command and return output", "string", "bash('whoami')"},
731758
{FnExecCmd, "exec_cmd(command)", "Alias for bash(command)", "string", "exec_cmd('whoami')"},
732759
{FnSleep, "sleep(seconds)", "Pause for n seconds", "void", "sleep(5)"},

internal/functions/goja_runtime.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ func (r *GojaRuntime) registerFunctionsOnVM(vm *goja.Runtime) {
112112
_ = vm.Set(FnPrintf, vf.printf)
113113
_ = vm.Set(FnCatFile, vf.catFile)
114114
_ = vm.Set(FnExit, vf.exit)
115+
_ = vm.Set(FnSkip, vf.skip)
115116
_ = vm.Set(FnExecCmd, vf.execCmd)
116117
_ = vm.Set(FnBash, vf.bash)
117118
_ = vm.Set(FnSleep, vf.sleep)

internal/functions/util_functions.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,18 @@ func (vf *vmFunc) exit(call goja.FunctionCall) goja.Value {
708708
return goja.Undefined()
709709
}
710710

711+
// skip stops execution of the remaining steps in the current module.
712+
// The flow continues to the next module. Accepts an optional message.
713+
// Usage: skip(message?) -> void
714+
func (vf *vmFunc) skip(call goja.FunctionCall) goja.Value {
715+
msg := call.Argument(0).String()
716+
if msg == "undefined" || msg == "" {
717+
msg = "skip() called"
718+
}
719+
logger.Get().Info("Module skip requested: " + msg)
720+
panic(vf.vm.NewGoError(&SkipModuleError{Message: msg}))
721+
}
722+
711723
// execCmd executes a bash command and returns the stdout output
712724
// Usage: exec_cmd(command) -> string
713725
func (vf *vmFunc) execCmd(call goja.FunctionCall) goja.Value {

0 commit comments

Comments
 (0)