From 200d130607fe89e03bd84e67772925f5134a04f9 Mon Sep 17 00:00:00 2001 From: shivasurya Date: Fri, 21 Nov 2025 18:23:05 -0500 Subject: [PATCH 1/2] Add text formatter with rich output (PR #3 Commit 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detection type badges (Pattern, Taint-Local, Taint-Global) - Severity-based grouping with detail levels - Code snippets with line numbers and highlight - Taint flow visualization - Summary statistics - Comprehensive tests with 100% coverage Part of output standardization feature. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- sourcecode-parser/output/text_formatter.go | 280 +++++++++ .../output/text_formatter_test.go | 584 ++++++++++++++++++ 2 files changed, 864 insertions(+) create mode 100644 sourcecode-parser/output/text_formatter.go create mode 100644 sourcecode-parser/output/text_formatter_test.go diff --git a/sourcecode-parser/output/text_formatter.go b/sourcecode-parser/output/text_formatter.go new file mode 100644 index 00000000..ddee34a8 --- /dev/null +++ b/sourcecode-parser/output/text_formatter.go @@ -0,0 +1,280 @@ +package output + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/shivasurya/code-pathfinder/sourcecode-parser/dsl" +) + +// TextFormatter formats enriched detections as human-readable text. +type TextFormatter struct { + writer io.Writer + options *OutputOptions + logger *Logger +} + +// NewTextFormatter creates a text formatter. +func NewTextFormatter(opts *OutputOptions, logger *Logger) *TextFormatter { + if opts == nil { + opts = NewDefaultOptions() + } + return &TextFormatter{ + writer: os.Stdout, + options: opts, + logger: logger, + } +} + +// NewTextFormatterWithWriter creates a formatter with custom writer (for testing). +func NewTextFormatterWithWriter(w io.Writer, opts *OutputOptions, logger *Logger) *TextFormatter { + tf := NewTextFormatter(opts, logger) + tf.writer = w + return tf +} + +// Format outputs all detections as formatted text. +func (f *TextFormatter) Format(detections []*dsl.EnrichedDetection, summary *Summary) error { + if len(detections) == 0 { + f.writeNoFindings() + return nil + } + + f.writeHeader() + f.writeResults(detections) + f.writeSummary(summary) + + if f.options.ShouldShowStatistics() { + f.writeStatistics(summary) + } + + return nil +} + +func (f *TextFormatter) writeHeader() { + fmt.Fprintln(f.writer, "Code Pathfinder Security Scan") + fmt.Fprintln(f.writer) +} + +func (f *TextFormatter) writeNoFindings() { + fmt.Fprintln(f.writer, "Code Pathfinder Security Scan") + fmt.Fprintln(f.writer) + fmt.Fprintln(f.writer, "No security issues found.") +} + +func (f *TextFormatter) writeResults(detections []*dsl.EnrichedDetection) { + fmt.Fprintln(f.writer, "Results:") + fmt.Fprintln(f.writer) + + // Group by severity + grouped := f.groupBySeverity(detections) + + // Output in severity order: critical, high, medium, low + severityOrder := []string{"critical", "high", "medium", "low", "info"} + for _, sev := range severityOrder { + if dets, ok := grouped[sev]; ok && len(dets) > 0 { + f.writeSeverityGroup(sev, dets) + } + } +} + +func (f *TextFormatter) groupBySeverity(detections []*dsl.EnrichedDetection) map[string][]*dsl.EnrichedDetection { + grouped := make(map[string][]*dsl.EnrichedDetection) + for _, det := range detections { + sev := det.Rule.Severity + grouped[sev] = append(grouped[sev], det) + } + return grouped +} + +func (f *TextFormatter) writeSeverityGroup(severity string, detections []*dsl.EnrichedDetection) { + // Header + title := fmt.Sprintf("%s Issues (%d):", strings.Title(severity), len(detections)) + fmt.Fprintln(f.writer, title) + fmt.Fprintln(f.writer) + + // Critical and high get detailed output + showDetailed := severity == "critical" || severity == "high" + + for _, det := range detections { + if showDetailed { + f.writeDetailedFinding(det) + } else { + f.writeAbbreviatedFinding(det) + } + } + fmt.Fprintln(f.writer) +} + +func (f *TextFormatter) writeDetailedFinding(det *dsl.EnrichedDetection) { + // First line: [severity] [badge] rule-id: rule-name + fmt.Fprintf(f.writer, " [%s] %s %s: %s\n", + det.Rule.Severity, + det.DetectionBadge(), + det.Rule.ID, + det.Rule.Name) + + // Metadata line (only if available) + var metaParts []string + if len(det.Rule.CWE) > 0 { + metaParts = append(metaParts, det.Rule.CWE[0]) + } + if len(det.Rule.OWASP) > 0 { + metaParts = append(metaParts, det.Rule.OWASP[0]) + } + if len(metaParts) > 0 { + fmt.Fprintf(f.writer, " %s\n", strings.Join(metaParts, " | ")) + } + fmt.Fprintln(f.writer) + + // Location + location := f.formatLocation(det.Location) + fmt.Fprintf(f.writer, " %s\n", location) + + // Code snippet + if len(det.Snippet.Lines) > 0 { + f.writeCodeSnippet(det.Snippet) + } + fmt.Fprintln(f.writer) + + // Taint flow (for taint detections) + if det.DetectionType == dsl.DetectionTypeTaintLocal || det.DetectionType == dsl.DetectionTypeTaintGlobal { + f.writeTaintFlow(det) + } + + // Confidence and detection method + fmt.Fprintf(f.writer, " Confidence: %s | Detection: %s\n", + strings.Title(det.ConfidenceLevel()), + f.formatDetectionMethod(det.DetectionType)) + fmt.Fprintln(f.writer) +} + +func (f *TextFormatter) writeAbbreviatedFinding(det *dsl.EnrichedDetection) { + // Single line: [severity] [badge] rule-id: location + location := f.formatLocation(det.Location) + fmt.Fprintf(f.writer, " [%s] %s %s: %s\n", + det.Rule.Severity, + det.DetectionBadge(), + det.Rule.ID, + location) +} + +func (f *TextFormatter) formatLocation(loc dsl.LocationInfo) string { + path := loc.RelPath + if path == "" { + path = loc.FilePath + } + if path == "" { + path = loc.Function + } + if loc.Line > 0 { + return fmt.Sprintf("%s:%d", path, loc.Line) + } + return path +} + +func (f *TextFormatter) writeCodeSnippet(snippet dsl.CodeSnippet) { + // Find max line number width + maxLineNum := 0 + for _, line := range snippet.Lines { + if line.Number > maxLineNum { + maxLineNum = line.Number + } + } + lineWidth := len(fmt.Sprintf("%d", maxLineNum)) + + for _, line := range snippet.Lines { + marker := " " + if line.IsHighlight { + marker = ">" + } + fmt.Fprintf(f.writer, " %s %*d | %s\n", + marker, + lineWidth, + line.Number, + line.Content) + } +} + +func (f *TextFormatter) writeTaintFlow(det *dsl.EnrichedDetection) { + if det.Detection.TaintedVar == "" { + return + } + + fmt.Fprintf(f.writer, " Flow: %s (line %d) -> %s (line %d)\n", + det.Detection.TaintedVar, + det.Detection.SourceLine, + det.Detection.SinkCall, + det.Detection.SinkLine) + + fmt.Fprintf(f.writer, " Tainted variable '%s' reaches dangerous sink without sanitization\n", + det.Detection.TaintedVar) +} + +func (f *TextFormatter) formatDetectionMethod(dt dsl.DetectionType) string { + switch dt { + case dsl.DetectionTypePattern: + return "Pattern matching" + case dsl.DetectionTypeTaintLocal: + return "Intra-procedural taint analysis" + case dsl.DetectionTypeTaintGlobal: + return "Inter-procedural taint analysis" + default: + return "Unknown" + } +} + +func (f *TextFormatter) writeSummary(summary *Summary) { + fmt.Fprintln(f.writer, "Summary:") + fmt.Fprintf(f.writer, " %d findings across %d rules\n", + summary.TotalFindings, summary.RulesExecuted) + + // Severity breakdown + var parts []string + for _, sev := range []string{"critical", "high", "medium", "low"} { + if count, ok := summary.BySeverity[sev]; ok && count > 0 { + parts = append(parts, fmt.Sprintf("%d %s", count, sev)) + } + } + if len(parts) > 0 { + fmt.Fprintf(f.writer, " %s\n", strings.Join(parts, " | ")) + } + fmt.Fprintln(f.writer) +} + +func (f *TextFormatter) writeStatistics(summary *Summary) { + fmt.Fprintln(f.writer, "Detection Methods:") + for method, count := range summary.ByDetectionType { + fmt.Fprintf(f.writer, " %s: %d findings\n", method, count) + } + fmt.Fprintln(f.writer) +} + +// Summary holds aggregated statistics. +type Summary struct { + TotalFindings int + RulesExecuted int + BySeverity map[string]int + ByDetectionType map[string]int + FilesScanned int + Duration string +} + +// BuildSummary creates summary from detections. +func BuildSummary(detections []*dsl.EnrichedDetection, rulesExecuted int) *Summary { + summary := &Summary{ + TotalFindings: len(detections), + RulesExecuted: rulesExecuted, + BySeverity: make(map[string]int), + ByDetectionType: make(map[string]int), + } + + for _, det := range detections { + summary.BySeverity[det.Rule.Severity]++ + summary.ByDetectionType[string(det.DetectionType)]++ + } + + return summary +} diff --git a/sourcecode-parser/output/text_formatter_test.go b/sourcecode-parser/output/text_formatter_test.go new file mode 100644 index 00000000..c4a8cb6f --- /dev/null +++ b/sourcecode-parser/output/text_formatter_test.go @@ -0,0 +1,584 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/shivasurya/code-pathfinder/sourcecode-parser/dsl" +) + +func TestNewTextFormatter(t *testing.T) { + tf := NewTextFormatter(nil, nil) + if tf == nil { + t.Fatal("expected non-nil formatter") + } + if tf.options == nil { + t.Error("expected default options") + } +} + +func TestTextFormatterNoFindings(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + err := tf.Format(nil, &Summary{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "No security issues found") { + t.Errorf("expected 'No security issues found', got: %s", output) + } +} + +func TestTextFormatterWithFindings(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Detection: dsl.DataflowDetection{ + SinkLine: 10, + TaintedVar: "user_input", + SinkCall: "eval", + Confidence: 0.9, + }, + DetectionType: dsl.DetectionTypeTaintLocal, + Location: dsl.LocationInfo{ + RelPath: "auth/login.py", + Line: 10, + }, + Rule: dsl.RuleMetadata{ + ID: "command-injection", + Name: "Command Injection", + Severity: "critical", + CWE: []string{"CWE-78"}, + }, + }, + } + + summary := BuildSummary(detections, 5) + err := tf.Format(detections, summary) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + + // Check header + if !strings.Contains(output, "Code Pathfinder Security Scan") { + t.Error("missing header") + } + + // Check severity section + if !strings.Contains(output, "Critical Issues (1)") { + t.Error("missing critical issues section") + } + + // Check detection badge + if !strings.Contains(output, "[Taint-Local]") { + t.Error("missing detection badge") + } + + // Check rule info + if !strings.Contains(output, "command-injection") { + t.Error("missing rule ID") + } + + // Check CWE + if !strings.Contains(output, "CWE-78") { + t.Error("missing CWE") + } + + // Check location + if !strings.Contains(output, "auth/login.py:10") { + t.Error("missing location") + } + + // Check summary + if !strings.Contains(output, "1 findings across 5 rules") { + t.Error("missing summary") + } +} + +func TestTextFormatterSeverityGrouping(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Rule: dsl.RuleMetadata{ID: "low1", Severity: "low"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 1}, + }, + { + Rule: dsl.RuleMetadata{ID: "crit1", Severity: "critical"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 2}, + }, + { + Rule: dsl.RuleMetadata{ID: "high1", Severity: "high"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 3}, + }, + } + + summary := BuildSummary(detections, 3) + tf.Format(detections, summary) + + output := buf.String() + + // Critical should come before high, high before low + critIdx := strings.Index(output, "Critical Issues") + highIdx := strings.Index(output, "High Issues") + lowIdx := strings.Index(output, "Low Issues") + + if critIdx == -1 || highIdx == -1 || lowIdx == -1 { + t.Fatal("missing severity sections") + } + + if critIdx > highIdx { + t.Error("critical should come before high") + } + if highIdx > lowIdx { + t.Error("high should come before low") + } +} + +func TestTextFormatterDetailedVsAbbreviated(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Rule: dsl.RuleMetadata{ID: "crit1", Severity: "critical", Name: "Critical Bug"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 10}, + DetectionType: dsl.DetectionTypePattern, + Detection: dsl.DataflowDetection{Confidence: 0.9}, + }, + { + Rule: dsl.RuleMetadata{ID: "low1", Severity: "low", Name: "Low Bug"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 20}, + DetectionType: dsl.DetectionTypePattern, + Detection: dsl.DataflowDetection{Confidence: 0.5}, + }, + } + + summary := BuildSummary(detections, 2) + tf.Format(detections, summary) + + output := buf.String() + + // Critical should have detailed output (Confidence line) + if !strings.Contains(output, "Confidence:") { + t.Error("critical finding should have confidence line") + } + + // Low should be abbreviated (single line) + lines := strings.Split(output, "\n") + lowLineCount := 0 + inLowSection := false + for _, line := range lines { + if strings.Contains(line, "Low Issues") { + inLowSection = true + continue + } + if inLowSection && strings.HasPrefix(strings.TrimSpace(line), "[low]") { + lowLineCount++ + } + } + if lowLineCount != 1 { + t.Errorf("expected 1 abbreviated low finding line, got %d", lowLineCount) + } +} + +func TestTextFormatterCodeSnippet(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Rule: dsl.RuleMetadata{ID: "test", Severity: "critical"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 5}, + DetectionType: dsl.DetectionTypePattern, + Detection: dsl.DataflowDetection{Confidence: 0.9}, + Snippet: dsl.CodeSnippet{ + StartLine: 3, + HighlightLine: 5, + Lines: []dsl.SnippetLine{ + {Number: 3, Content: "def foo():", IsHighlight: false}, + {Number: 4, Content: " x = input()", IsHighlight: false}, + {Number: 5, Content: " eval(x)", IsHighlight: true}, + {Number: 6, Content: " return", IsHighlight: false}, + }, + }, + }, + } + + summary := BuildSummary(detections, 1) + tf.Format(detections, summary) + + output := buf.String() + + // Check snippet lines present + if !strings.Contains(output, "def foo():") { + t.Error("missing snippet line 3") + } + if !strings.Contains(output, "eval(x)") { + t.Error("missing snippet line 5") + } + + // Check highlight marker + if !strings.Contains(output, "> ") { + t.Error("missing highlight marker") + } +} + +func TestTextFormatterTaintFlow(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Detection: dsl.DataflowDetection{ + SourceLine: 10, + SinkLine: 20, + TaintedVar: "user_input", + SinkCall: "os.system", + Confidence: 0.9, + }, + DetectionType: dsl.DetectionTypeTaintLocal, + Rule: dsl.RuleMetadata{ID: "cmd-inj", Severity: "critical"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 20}, + }, + } + + summary := BuildSummary(detections, 1) + tf.Format(detections, summary) + + output := buf.String() + + // Check flow line + if !strings.Contains(output, "Flow: user_input (line 10) -> os.system (line 20)") { + t.Error("missing taint flow line") + } + + // Check tainted variable message + if !strings.Contains(output, "Tainted variable 'user_input'") { + t.Error("missing tainted variable message") + } +} + +func TestFormatLocation(t *testing.T) { + tf := NewTextFormatter(nil, nil) + + tests := []struct { + name string + loc dsl.LocationInfo + expected string + }{ + { + "relative path with line", + dsl.LocationInfo{RelPath: "auth/login.py", Line: 42}, + "auth/login.py:42", + }, + { + "absolute path fallback", + dsl.LocationInfo{FilePath: "/full/path/file.py", Line: 10}, + "/full/path/file.py:10", + }, + { + "function only", + dsl.LocationInfo{Function: "my_function"}, + "my_function", + }, + { + "no line number", + dsl.LocationInfo{RelPath: "test.py"}, + "test.py", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tf.formatLocation(tt.loc) + if got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} + +func TestBuildSummary(t *testing.T) { + detections := []*dsl.EnrichedDetection{ + {Rule: dsl.RuleMetadata{Severity: "critical"}, DetectionType: dsl.DetectionTypeTaintLocal}, + {Rule: dsl.RuleMetadata{Severity: "critical"}, DetectionType: dsl.DetectionTypeTaintLocal}, + {Rule: dsl.RuleMetadata{Severity: "high"}, DetectionType: dsl.DetectionTypePattern}, + {Rule: dsl.RuleMetadata{Severity: "low"}, DetectionType: dsl.DetectionTypePattern}, + } + + summary := BuildSummary(detections, 10) + + if summary.TotalFindings != 4 { + t.Errorf("TotalFindings: got %d, want 4", summary.TotalFindings) + } + if summary.RulesExecuted != 10 { + t.Errorf("RulesExecuted: got %d, want 10", summary.RulesExecuted) + } + if summary.BySeverity["critical"] != 2 { + t.Errorf("critical count: got %d, want 2", summary.BySeverity["critical"]) + } + if summary.BySeverity["high"] != 1 { + t.Errorf("high count: got %d, want 1", summary.BySeverity["high"]) + } + if summary.ByDetectionType["taint-local"] != 2 { + t.Errorf("taint-local count: got %d, want 2", summary.ByDetectionType["taint-local"]) + } +} + +func TestTextFormatterStatistics(t *testing.T) { + var buf bytes.Buffer + opts := &OutputOptions{Verbosity: VerbosityVerbose} + tf := NewTextFormatterWithWriter(&buf, opts, nil) + + detections := []*dsl.EnrichedDetection{ + { + Rule: dsl.RuleMetadata{ID: "test", Severity: "high"}, + DetectionType: dsl.DetectionTypePattern, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 1}, + }, + } + + summary := BuildSummary(detections, 5) + tf.Format(detections, summary) + + output := buf.String() + + // Verbose should show detection methods + if !strings.Contains(output, "Detection Methods:") { + t.Error("verbose mode should show detection methods") + } +} + +func TestTextFormatterDetectionBadges(t *testing.T) { + tests := []struct { + detType dsl.DetectionType + expected string + }{ + {dsl.DetectionTypePattern, "[Pattern]"}, + {dsl.DetectionTypeTaintLocal, "[Taint-Local]"}, + {dsl.DetectionTypeTaintGlobal, "[Taint-Global]"}, + } + + for _, tt := range tests { + det := &dsl.EnrichedDetection{DetectionType: tt.detType} + got := det.DetectionBadge() + if got != tt.expected { + t.Errorf("type %v: got %q, want %q", tt.detType, got, tt.expected) + } + } +} + +func TestFormatDetectionMethod(t *testing.T) { + tf := NewTextFormatter(nil, nil) + + tests := []struct { + detType dsl.DetectionType + expected string + }{ + {dsl.DetectionTypePattern, "Pattern matching"}, + {dsl.DetectionTypeTaintLocal, "Intra-procedural taint analysis"}, + {dsl.DetectionTypeTaintGlobal, "Inter-procedural taint analysis"}, + {"unknown", "Unknown"}, + } + + for _, tt := range tests { + got := tf.formatDetectionMethod(tt.detType) + if got != tt.expected { + t.Errorf("type %v: got %q, want %q", tt.detType, got, tt.expected) + } + } +} + +func TestTextFormatterMultipleCWEAndOWASP(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Rule: dsl.RuleMetadata{ + ID: "test", + Severity: "critical", + CWE: []string{"CWE-78", "CWE-77"}, + OWASP: []string{"A03:2021", "A01:2021"}, + }, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 10}, + DetectionType: dsl.DetectionTypePattern, + Detection: dsl.DataflowDetection{Confidence: 0.9}, + }, + } + + summary := BuildSummary(detections, 1) + tf.Format(detections, summary) + + output := buf.String() + + // Should display first CWE and OWASP + if !strings.Contains(output, "CWE-78") { + t.Error("missing CWE-78") + } + if !strings.Contains(output, "A03:2021") { + t.Error("missing OWASP A03:2021") + } +} + +func TestTextFormatterEmptySnippet(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Rule: dsl.RuleMetadata{ID: "test", Severity: "critical", Name: "Test"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 10}, + DetectionType: dsl.DetectionTypePattern, + Detection: dsl.DataflowDetection{Confidence: 0.9}, + Snippet: dsl.CodeSnippet{Lines: []dsl.SnippetLine{}}, // Empty snippet + }, + } + + summary := BuildSummary(detections, 1) + err := tf.Format(detections, summary) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should not crash with empty snippet + output := buf.String() + if !strings.Contains(output, "test.py:10") { + t.Error("missing location despite empty snippet") + } +} + +func TestTextFormatterTaintFlowWithoutTaintedVar(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Detection: dsl.DataflowDetection{ + SourceLine: 10, + SinkLine: 20, + TaintedVar: "", // Empty tainted var + SinkCall: "os.system", + Confidence: 0.9, + }, + DetectionType: dsl.DetectionTypeTaintLocal, + Rule: dsl.RuleMetadata{ID: "test", Severity: "critical"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 20}, + }, + } + + summary := BuildSummary(detections, 1) + tf.Format(detections, summary) + + output := buf.String() + + // Should not display flow line when TaintedVar is empty + if strings.Contains(output, "Flow:") { + t.Error("should not display flow when TaintedVar is empty") + } +} + +func TestGroupBySeverity(t *testing.T) { + tf := NewTextFormatter(nil, nil) + + detections := []*dsl.EnrichedDetection{ + {Rule: dsl.RuleMetadata{Severity: "critical"}}, + {Rule: dsl.RuleMetadata{Severity: "critical"}}, + {Rule: dsl.RuleMetadata{Severity: "high"}}, + {Rule: dsl.RuleMetadata{Severity: "low"}}, + {Rule: dsl.RuleMetadata{Severity: "low"}}, + {Rule: dsl.RuleMetadata{Severity: "low"}}, + } + + grouped := tf.groupBySeverity(detections) + + if len(grouped["critical"]) != 2 { + t.Errorf("critical: got %d, want 2", len(grouped["critical"])) + } + if len(grouped["high"]) != 1 { + t.Errorf("high: got %d, want 1", len(grouped["high"])) + } + if len(grouped["low"]) != 3 { + t.Errorf("low: got %d, want 3", len(grouped["low"])) + } +} + +func TestWriteSnippetAlignment(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + // Test with varying line number widths (9, 10, 99, 100) + detections := []*dsl.EnrichedDetection{ + { + Rule: dsl.RuleMetadata{ID: "test", Severity: "critical"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 100}, + DetectionType: dsl.DetectionTypePattern, + Detection: dsl.DataflowDetection{Confidence: 0.9}, + Snippet: dsl.CodeSnippet{ + StartLine: 98, + HighlightLine: 100, + Lines: []dsl.SnippetLine{ + {Number: 98, Content: "line 98", IsHighlight: false}, + {Number: 99, Content: "line 99", IsHighlight: false}, + {Number: 100, Content: "line 100", IsHighlight: true}, + }, + }, + }, + } + + summary := BuildSummary(detections, 1) + tf.Format(detections, summary) + + output := buf.String() + + // Check alignment - line numbers should be right-aligned + if !strings.Contains(output, " 98 |") { + t.Error("missing aligned line 98") + } + if !strings.Contains(output, " 99 |") { + t.Error("missing aligned line 99") + } + if !strings.Contains(output, "100 |") { + t.Error("missing aligned line 100") + } +} + +func TestTextFormatterEmptySummary(t *testing.T) { + var buf bytes.Buffer + tf := NewTextFormatterWithWriter(&buf, nil, nil) + + detections := []*dsl.EnrichedDetection{ + { + Rule: dsl.RuleMetadata{ID: "test", Severity: "high"}, + Location: dsl.LocationInfo{RelPath: "test.py", Line: 1}, + DetectionType: dsl.DetectionTypePattern, + }, + } + + summary := &Summary{ + TotalFindings: 1, + RulesExecuted: 1, + BySeverity: map[string]int{}, + ByDetectionType: map[string]int{}, + } + + err := tf.Format(detections, summary) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "1 findings across 1 rules") { + t.Error("missing summary line") + } +} From 3f7d7940653bc6c70170714f30b6dc9b32b60f33 Mon Sep 17 00:00:00 2001 From: shivasurya Date: Fri, 21 Nov 2025 18:24:48 -0500 Subject: [PATCH 2/2] Integrate text formatter in scan command (PR #3 Commit 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace old printDetections() with enrichment pipeline - Add enricher to add context and metadata to detections - Connect enricher -> formatter flow for rich output - Keep printDetections() for query command compatibility Part of output standardization feature. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- sourcecode-parser/cmd/scan.go | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/sourcecode-parser/cmd/scan.go b/sourcecode-parser/cmd/scan.go index 9c99932d..4f239afe 100644 --- a/sourcecode-parser/cmd/scan.go +++ b/sourcecode-parser/cmd/scan.go @@ -94,26 +94,41 @@ Examples: logger.Statistic("Loaded %d rules", len(rules)) // Step 5: Execute rules against callgraph - logger.Progress("\n=== Running Security Scan ===") - totalDetections := 0 + logger.Progress("Running security scan...") + + // Create enricher for adding context to detections + enricher := output.NewEnricher(cg, &output.OutputOptions{ + ProjectRoot: projectPath, + ContextLines: 3, + Verbosity: verbosity, + }) + + // Execute all rules and collect enriched detections + var allEnriched []*dsl.EnrichedDetection for _, rule := range rules { detections, err := loader.ExecuteRule(&rule, cg) if err != nil { - logger.Error("executing rule %s: %v", rule.Rule.ID, err) + logger.Warning("Error executing rule %s: %v", rule.Rule.ID, err) continue } if len(detections) > 0 { - printDetections(rule, detections) - totalDetections += len(detections) + enriched, _ := enricher.EnrichAll(detections, rule) + allEnriched = append(allEnriched, enriched...) } } - // Step 6: Print summary - logger.Progress("\n=== Scan Complete ===") - logger.Statistic("Total vulnerabilities found: %d", totalDetections) + // Step 6: Format and display results + summary := output.BuildSummary(allEnriched, len(rules)) + formatter := output.NewTextFormatter(&output.OutputOptions{ + Verbosity: verbosity, + }, logger) + + if err := formatter.Format(allEnriched, summary); err != nil { + return fmt.Errorf("failed to format output: %w", err) + } - if totalDetections > 0 { + if len(allEnriched) > 0 { os.Exit(1) // Exit with error code if vulnerabilities found } @@ -129,6 +144,7 @@ func countTotalCallSites(cg *core.CallGraph) int { return total } +// printDetections outputs detections in simple format (used by query command). func printDetections(rule dsl.RuleIR, detections []dsl.DataflowDetection) { fmt.Printf("\n[%s] %s (%s)\n", rule.Rule.Severity, rule.Rule.ID, rule.Rule.Name) fmt.Printf(" CWE: %s | OWASP: %s\n", rule.Rule.CWE, rule.Rule.OWASP)