diff --git a/sast-engine/cmd/ci.go b/sast-engine/cmd/ci.go index ee6f463d..a026e987 100644 --- a/sast-engine/cmd/ci.go +++ b/sast-engine/cmd/ci.go @@ -79,16 +79,18 @@ Examples: } // Build code graph (AST) - logger.Progress("Building code graph from %s...", projectPath) + logger.StartProgress("Building code graph", -1) codeGraph := graph.Initialize(projectPath) + logger.FinishProgress() if len(codeGraph.Nodes) == 0 { return fmt.Errorf("no source files found in project") } logger.Statistic("Code graph built: %d nodes", len(codeGraph.Nodes)) // Build module registry - logger.Progress("Building module registry...") + logger.StartProgress("Building module registry", -1) moduleRegistry, err := registry.BuildModuleRegistry(projectPath, skipTests) + logger.FinishProgress() if err != nil { logger.Warning("failed to build module registry: %v", err) moduleRegistry = core.NewModuleRegistry() @@ -98,8 +100,9 @@ Examples: } // Build callgraph - logger.Progress("Building callgraph...") + logger.StartProgress("Building callgraph", -1) cg, err := builder.BuildCallGraph(codeGraph, moduleRegistry, projectPath, logger) + logger.FinishProgress() if err != nil { return fmt.Errorf("failed to build callgraph: %w", err) } @@ -107,9 +110,10 @@ Examples: len(cg.Functions), countTotalCallSites(cg)) // Load Python DSL rules - logger.Progress("Loading rules from %s...", rulesPath) + logger.StartProgress("Loading rules", -1) loader := dsl.NewRuleLoader(rulesPath) rules, err := loader.LoadRules(logger) + logger.FinishProgress() if err != nil { return fmt.Errorf("failed to load rules: %w", err) } @@ -130,6 +134,7 @@ Examples: var scanErrors []string hadErrors := false + logger.StartProgress("Executing rules", len(rules)) for _, rule := range rules { detections, err := loader.ExecuteRule(&rule, cg) if err != nil { @@ -137,6 +142,7 @@ Examples: logger.Warning("%s", errMsg) scanErrors = append(scanErrors, errMsg) hadErrors = true + logger.UpdateProgress(1) continue } @@ -145,7 +151,9 @@ Examples: enriched, _ := enricher.EnrichAll(detections, rule) allEnriched = append(allEnriched, enriched...) } + logger.UpdateProgress(1) } + logger.FinishProgress() logger.Statistic("Scan complete. Found %d vulnerabilities", len(allEnriched)) logger.Progress("Generating %s output...", outputFormat) diff --git a/sast-engine/cmd/scan.go b/sast-engine/cmd/scan.go index f1d25762..37a12cc5 100644 --- a/sast-engine/cmd/scan.go +++ b/sast-engine/cmd/scan.go @@ -130,8 +130,9 @@ Examples: loader := dsl.NewRuleLoader(rulesPath) // Step 1: Build code graph (AST) - logger.Progress("Building code graph from %s...", projectPath) + logger.StartProgress("Building code graph", -1) codeGraph := graph.Initialize(projectPath) + logger.FinishProgress() if len(codeGraph.Nodes) == 0 { return fmt.Errorf("no source files found in project") } @@ -160,8 +161,9 @@ Examples: } // Step 2: Build module registry - logger.Progress("Building module registry...") + logger.StartProgress("Building module registry", -1) moduleRegistry, err := registry.BuildModuleRegistry(projectPath, skipTests) + logger.FinishProgress() if err != nil { logger.Warning("failed to build module registry: %v", err) // Create empty registry as fallback @@ -172,8 +174,9 @@ Examples: } // Step 3: Build callgraph - logger.Progress("Building callgraph...") + logger.StartProgress("Building callgraph", -1) cg, err := builder.BuildCallGraph(codeGraph, moduleRegistry, projectPath, logger) + logger.FinishProgress() if err != nil { return fmt.Errorf("failed to build callgraph: %w", err) } @@ -181,8 +184,9 @@ Examples: len(cg.Functions), countTotalCallSites(cg)) // Step 4: Load Python DSL rules - logger.Progress("Loading rules from %s...", rulesPath) + logger.StartProgress("Loading rules", -1) rules, err := loader.LoadRules(logger) + logger.FinishProgress() if err != nil { return fmt.Errorf("failed to load rules: %w", err) } @@ -201,11 +205,13 @@ Examples: // Execute all rules and collect enriched detections var allEnriched []*dsl.EnrichedDetection var scanErrors bool + logger.StartProgress("Executing rules", len(rules)) for _, rule := range rules { detections, err := loader.ExecuteRule(&rule, cg) if err != nil { logger.Warning("Error executing rule %s: %v", rule.Rule.ID, err) scanErrors = true + logger.UpdateProgress(1) continue } @@ -213,7 +219,9 @@ Examples: enriched, _ := enricher.EnrichAll(detections, rule) allEnriched = append(allEnriched, enriched...) } + logger.UpdateProgress(1) } + logger.FinishProgress() // Merge container detections with code analysis detections allEnriched = append(allEnriched, containerDetections...) diff --git a/sast-engine/go.mod b/sast-engine/go.mod index fce7d00f..edea6d60 100644 --- a/sast-engine/go.mod +++ b/sast-engine/go.mod @@ -12,18 +12,21 @@ require ( ) require ( + github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be github.com/owenrumney/go-sarif/v2 v2.3.3 github.com/stretchr/testify v1.10.0 + golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/schollz/progressbar/v3 v3.19.0 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect ) diff --git a/sast-engine/go.sum b/sast-engine/go.sum index e9f24e45..a5c77d8a 100644 --- a/sast-engine/go.sum +++ b/sast-engine/go.sum @@ -21,6 +21,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/owenrumney/go-sarif v1.1.1/go.mod h1:dNDiPlF04ESR/6fHlPyq7gHKmrM0sHUvAGjsoh8ZH0U= github.com/owenrumney/go-sarif/v2 v2.3.3 h1:ubWDJcF5i3L/EIOER+ZyQ03IfplbSU1BLOE26uKQIIU= github.com/owenrumney/go-sarif/v2 v2.3.3/go.mod h1:MSqMMx9WqlBSY7pXoOZWgEsVB4FDNfhcaXDA1j6Sr+w= @@ -28,7 +30,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posthog/posthog-go v1.6.11 h1:5G8Y3pxnOpc3S4+PK1z1dCmZRuldiWxBsqqvvSfC2+w= github.com/posthog/posthog-go v1.6.11/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= diff --git a/sast-engine/output/logger.go b/sast-engine/output/logger.go index ca46c73e..79f151a0 100644 --- a/sast-engine/output/logger.go +++ b/sast-engine/output/logger.go @@ -5,39 +5,47 @@ import ( "io" "os" "time" + + "github.com/schollz/progressbar/v3" ) // Logger provides structured logging with verbosity control. type Logger struct { - verbosity VerbosityLevel - writer io.Writer - startTime time.Time - timings map[string]time.Duration - isTTY bool + verbosity VerbosityLevel + writer io.Writer + startTime time.Time + timings map[string]time.Duration + isTTY bool + progressBar *progressbar.ProgressBar + showProgress bool } // NewLogger creates a logger with the specified verbosity. // Output goes to stderr to keep stdout clean for results. func NewLogger(verbosity VerbosityLevel) *Logger { writer := os.Stderr + isTTY := IsTTY(writer) return &Logger{ - verbosity: verbosity, - writer: writer, - startTime: time.Now(), - timings: make(map[string]time.Duration), - isTTY: IsTTY(writer), + verbosity: verbosity, + writer: writer, + startTime: time.Now(), + timings: make(map[string]time.Duration), + isTTY: isTTY, + showProgress: isTTY, } } // NewLoggerWithWriter creates a logger with custom output writer. // Primarily used for testing. func NewLoggerWithWriter(verbosity VerbosityLevel, w io.Writer) *Logger { + isTTY := IsTTY(w) return &Logger{ - verbosity: verbosity, - writer: w, - startTime: time.Now(), - timings: make(map[string]time.Duration), - isTTY: IsTTY(w), + verbosity: verbosity, + writer: w, + startTime: time.Now(), + timings: make(map[string]time.Duration), + isTTY: isTTY, + showProgress: isTTY, } } @@ -142,3 +150,82 @@ func (l *Logger) IsTTY() bool { func (l *Logger) GetWriter() io.Writer { return l.writer } + +// StartProgress creates and displays a progress bar. +// For indeterminate operations (total = -1), shows a spinner. +// For determinate operations (total > 0), shows percentage progress. +func (l *Logger) StartProgress(description string, total int) error { + if !l.showProgress || !l.isTTY { + // In non-TTY mode, just print the description + l.Progress("%s...", description) + return nil + } + + // Clear any existing progress bar + if l.progressBar != nil { + _ = l.progressBar.Finish() + } + + if total < 0 { + // Indeterminate progress (spinner) + l.progressBar = progressbar.NewOptions(-1, + progressbar.OptionSetDescription(description), + progressbar.OptionSetWriter(l.writer), + progressbar.OptionSetWidth(40), + progressbar.OptionThrottle(65*time.Millisecond), + progressbar.OptionSpinnerType(14), + progressbar.OptionOnCompletion(func() { + fmt.Fprintf(l.writer, "\n") + }), + ) + } else { + // Determinate progress (percentage bar) + l.progressBar = progressbar.NewOptions(total, + progressbar.OptionSetDescription(description), + progressbar.OptionSetWriter(l.writer), + progressbar.OptionSetWidth(40), + progressbar.OptionThrottle(65*time.Millisecond), + progressbar.OptionShowCount(), + progressbar.OptionOnCompletion(func() { + fmt.Fprintf(l.writer, "\n") + }), + progressbar.OptionSetRenderBlankState(true), + ) + } + + return nil +} + +// UpdateProgress increments the progress bar by delta. +func (l *Logger) UpdateProgress(delta int) error { + if !l.showProgress || !l.isTTY || l.progressBar == nil { + return nil + } + + return l.progressBar.Add(delta) +} + +// FinishProgress completes and clears the progress bar. +func (l *Logger) FinishProgress() error { + if !l.showProgress || !l.isTTY || l.progressBar == nil { + return nil + } + + err := l.progressBar.Finish() + l.progressBar = nil + return err +} + +// SetProgressDescription updates the progress bar description. +func (l *Logger) SetProgressDescription(description string) { + if !l.showProgress || !l.isTTY || l.progressBar == nil { + return + } + + l.progressBar.Describe(description) +} + +// IsProgressEnabled returns true if progress bars are enabled. +func (l *Logger) IsProgressEnabled() bool { + return l.showProgress && l.isTTY +} diff --git a/sast-engine/output/logger_test.go b/sast-engine/output/logger_test.go index 14c0a719..045a8219 100644 --- a/sast-engine/output/logger_test.go +++ b/sast-engine/output/logger_test.go @@ -330,3 +330,137 @@ func TestNewLoggerWithWriter_TTYDetection(t *testing.T) { }) } } + +func TestLoggerStartProgress_NonTTY(t *testing.T) { + var buf bytes.Buffer + l := NewLoggerWithWriter(VerbosityVerbose, &buf) + + // Non-TTY logger should fallback to Progress() message + err := l.StartProgress("Test operation", 10) + if err != nil { + t.Errorf("StartProgress returned error: %v", err) + } + + // Should have printed a progress message + if !strings.Contains(buf.String(), "Test operation") { + t.Errorf("Expected progress message, got: %s", buf.String()) + } +} + +func TestLoggerStartProgress_Indeterminate(t *testing.T) { + var buf bytes.Buffer + l := NewLoggerWithWriter(VerbosityDefault, &buf) + + // Test indeterminate progress (total = -1) + err := l.StartProgress("Building", -1) + if err != nil { + t.Errorf("StartProgress returned error: %v", err) + } + + // Should handle finish without error + err = l.FinishProgress() + if err != nil { + t.Errorf("FinishProgress returned error: %v", err) + } +} + +func TestLoggerStartProgress_Determinate(t *testing.T) { + var buf bytes.Buffer + l := NewLoggerWithWriter(VerbosityDefault, &buf) + + // Test determinate progress (total > 0) + err := l.StartProgress("Processing", 100) + if err != nil { + t.Errorf("StartProgress returned error: %v", err) + } + + // Update progress + err = l.UpdateProgress(50) + if err != nil { + t.Errorf("UpdateProgress returned error: %v", err) + } + + // Finish progress + err = l.FinishProgress() + if err != nil { + t.Errorf("FinishProgress returned error: %v", err) + } +} + +func TestLoggerUpdateProgress_WithoutStart(t *testing.T) { + var buf bytes.Buffer + l := NewLoggerWithWriter(VerbosityDefault, &buf) + + // Updating without starting should not error + err := l.UpdateProgress(10) + if err != nil { + t.Errorf("UpdateProgress without start returned error: %v", err) + } +} + +func TestLoggerFinishProgress_WithoutStart(t *testing.T) { + var buf bytes.Buffer + l := NewLoggerWithWriter(VerbosityDefault, &buf) + + // Finishing without starting should not error + err := l.FinishProgress() + if err != nil { + t.Errorf("FinishProgress without start returned error: %v", err) + } +} + +func TestLoggerSetProgressDescription(t *testing.T) { + var buf bytes.Buffer + l := NewLoggerWithWriter(VerbosityDefault, &buf) + + // Start progress + _ = l.StartProgress("Initial", 10) + + // Set new description (should not panic) + l.SetProgressDescription("Updated") + + // Cleanup + _ = l.FinishProgress() +} + +func TestLoggerIsProgressEnabled(t *testing.T) { + tests := []struct { + name string + isTTY bool + expected bool + }{ + {"TTY enabled", true, false}, // bytes.Buffer is not TTY + {"Non-TTY disabled", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + l := NewLoggerWithWriter(VerbosityDefault, &buf) + + got := l.IsProgressEnabled() + if got != tt.expected { + t.Errorf("IsProgressEnabled() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestLoggerProgressBar_MultipleOperations(t *testing.T) { + var buf bytes.Buffer + l := NewLoggerWithWriter(VerbosityDefault, &buf) + + // Start first operation + _ = l.StartProgress("Operation 1", -1) + _ = l.FinishProgress() + + // Start second operation (should clear first) + _ = l.StartProgress("Operation 2", 10) + _ = l.UpdateProgress(5) + _ = l.FinishProgress() + + // Progress bar should be nil after finish + if l.progressBar != nil { + t.Error("Progress bar should be nil after FinishProgress") + } +}