diff --git a/pkg/models/dedup_test.go b/pkg/models/dedup_test.go new file mode 100644 index 0000000..5b77702 --- /dev/null +++ b/pkg/models/dedup_test.go @@ -0,0 +1,127 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeduplicateFindings(t *testing.T) { + t.Run("no findings", func(t *testing.T) { + result := &ScanResult{ + Findings: []Finding{}, + } + deduped := DeduplicateFindings(result) + assert.Empty(t, deduped.Findings) + }) + + t.Run("nil result", func(t *testing.T) { + deduped := DeduplicateFindings(nil) + assert.Nil(t, deduped) + }) + + t.Run("no duplicates", func(t *testing.T) { + result := &ScanResult{ + Findings: []Finding{ + { + ID: "finding-1", + Fingerprint: "abc123", + Path: "file1.txt", + }, + { + ID: "finding-2", + Fingerprint: "def456", + Path: "file2.txt", + }, + }, + } + deduped := DeduplicateFindings(result) + require.Len(t, deduped.Findings, 2) + assert.Nil(t, deduped.Findings[0].Locations) + assert.Nil(t, deduped.Findings[1].Locations) + }) + + t.Run("duplicate fingerprints consolidated", func(t *testing.T) { + result := &ScanResult{ + Findings: []Finding{ + { + ID: "finding-1", + Fingerprint: "same-fingerprint", + Path: "env:GITHUB_TOKEN", + }, + { + ID: "finding-1", + Fingerprint: "same-fingerprint", + Path: "file:.bashrc", + Metadata: map[string]interface{}{ + "line_number": 42, + }, + }, + { + ID: "finding-1", + Fingerprint: "same-fingerprint", + Path: "file:.zshrc", + Metadata: map[string]interface{}{ + "line_number": 10, + }, + }, + }, + } + deduped := DeduplicateFindings(result) + require.Len(t, deduped.Findings, 1) + require.Len(t, deduped.Findings[0].Locations, 3) + assert.Equal(t, "env:GITHUB_TOKEN", deduped.Findings[0].Locations[0]) + assert.Equal(t, "file:.bashrc:42", deduped.Findings[0].Locations[1]) + assert.Equal(t, "file:.zshrc:10", deduped.Findings[0].Locations[2]) + }) + + t.Run("findings without fingerprint not deduplicated", func(t *testing.T) { + result := &ScanResult{ + Findings: []Finding{ + { + ID: "finding-1", + Path: "file1.txt", + Metadata: map[string]interface{}{}, + }, + { + ID: "finding-2", + Path: "file2.txt", + Metadata: nil, + }, + }, + } + deduped := DeduplicateFindings(result) + require.Len(t, deduped.Findings, 2) + }) + + t.Run("mixed findings with and without fingerprints", func(t *testing.T) { + result := &ScanResult{ + Findings: []Finding{ + { + ID: "secret-1", + Fingerprint: "fp1", + Path: "file1.txt", + }, + { + ID: "config-issue", + Path: "config.yaml", + }, + { + ID: "secret-1", + Fingerprint: "fp1", + Path: "file2.txt", + }, + }, + } + deduped := DeduplicateFindings(result) + require.Len(t, deduped.Findings, 2) + // First finding should have 2 locations (deduplicated). + require.Len(t, deduped.Findings[0].Locations, 2) + // Second finding should have no locations (not deduplicated). + assert.Nil(t, deduped.Findings[1].Locations) + }) +} diff --git a/pkg/models/result.go b/pkg/models/result.go index eb37677..60ad8f3 100644 --- a/pkg/models/result.go +++ b/pkg/models/result.go @@ -207,3 +207,66 @@ func (f Finding) MarshalJSON() ([]byte, error) { } return data, nil } + +// DeduplicateFindings groups findings by fingerprint and consolidates duplicate +// occurrences onto the first finding for that fingerprint, accumulating each +// location ("path:line") in Locations. Findings without a fingerprint can't be +// deduplicated and are kept as-is. The input is not mutated; a new ScanResult +// is returned. This is the canonical finding-identity semantics shared by every +// consumer (the reporter, and library consumers via this function). +func DeduplicateFindings(result *ScanResult) *ScanResult { + if result == nil || len(result.Findings) == 0 { + return result + } + + seen := make(map[string]int) // fingerprint -> index in dedupedFindings + var dedupedFindings []Finding + + for _, finding := range result.Findings { + fingerprint := finding.Fingerprint + + // If no fingerprint, keep the finding as-is (no dedup possible). + if fingerprint == "" { + dedupedFindings = append(dedupedFindings, finding) + continue + } + + location := FindingLocation(finding) + + if idx, exists := seen[fingerprint]; exists { + // Add this location to the existing finding. + if dedupedFindings[idx].Locations == nil { + // First duplicate - add the original path as first location. + dedupedFindings[idx].Locations = []string{ + FindingLocation(dedupedFindings[idx]), + } + } + dedupedFindings[idx].Locations = append(dedupedFindings[idx].Locations, location) + } else { + // First occurrence of this fingerprint. + seen[fingerprint] = len(dedupedFindings) + dedupedFindings = append(dedupedFindings, finding) + } + } + + return &ScanResult{ + Metadata: result.Metadata, + Host: result.Host, + Findings: dedupedFindings, + } +} + +// FindingLocation renders a finding's location as "path" or "path:line" (when a +// line_number is present in metadata), or "-" when there is no path. +func FindingLocation(finding Finding) string { + if finding.Path == "" { + return "-" + } + + location := finding.Path + if lineNum, ok := finding.Metadata["line_number"]; ok { + location = fmt.Sprintf("%s:%v", location, lineNum) + } + + return location +} diff --git a/pkg/reporter/reporter.go b/pkg/reporter/reporter.go index cb5efd4..5607cbf 100644 --- a/pkg/reporter/reporter.go +++ b/pkg/reporter/reporter.go @@ -73,7 +73,7 @@ func New(format Format, output io.Writer) *Reporter { // Report outputs the scan results in the configured format func (r *Reporter) Report(result *models.ScanResult) error { // Deduplicate findings based on fingerprint - dedupedResult := deduplicateFindings(result) + dedupedResult := models.DeduplicateFindings(result) switch r.format { case FormatJSON: @@ -202,66 +202,6 @@ func containsEnvVar(location, envVar string) bool { return len(location) >= len("env:"+envVar) && location[4:4+len(envVar)] == envVar } -// deduplicateFindings groups findings by fingerprint and consolidates locations -func deduplicateFindings(result *models.ScanResult) *models.ScanResult { - if result == nil || len(result.Findings) == 0 { - return result - } - - // Map fingerprint -> first finding with that fingerprint - seen := make(map[string]int) // fingerprint -> index in dedupedFindings - var dedupedFindings []models.Finding - - for _, finding := range result.Findings { - fingerprint := finding.Fingerprint - - // If no fingerprint, keep the finding as-is (no dedup possible) - if fingerprint == "" { - dedupedFindings = append(dedupedFindings, finding) - continue - } - - location := formatLocationFromFinding(finding) - - if idx, exists := seen[fingerprint]; exists { - // Add this location to the existing finding - if dedupedFindings[idx].Locations == nil { - // First duplicate - add the original path as first location - dedupedFindings[idx].Locations = []string{ - formatLocationFromFinding(dedupedFindings[idx]), - } - } - dedupedFindings[idx].Locations = append(dedupedFindings[idx].Locations, location) - } else { - // First occurrence of this fingerprint - seen[fingerprint] = len(dedupedFindings) - dedupedFindings = append(dedupedFindings, finding) - } - } - - return &models.ScanResult{ - Metadata: result.Metadata, - Host: result.Host, - Findings: dedupedFindings, - } -} - -// formatLocationFromFinding creates a location string from a finding's path and metadata -func formatLocationFromFinding(finding models.Finding) string { - if finding.Path == "" { - return "-" - } - - location := finding.Path - - // Append line number if present - if lineNum, ok := finding.Metadata["line_number"]; ok { - location = fmt.Sprintf("%s:%v", location, lineNum) - } - - return location -} - // renderSystemInfo displays extended system information in table format func (r *Reporter) renderSystemInfo(sys *models.SystemInfo, cfg tablewriter.Config) error { fmt.Fprintf(r.output, "System Information:\n") diff --git a/pkg/reporter/reporter_test.go b/pkg/reporter/reporter_test.go index 78e0a84..487763f 100644 --- a/pkg/reporter/reporter_test.go +++ b/pkg/reporter/reporter_test.go @@ -8,125 +8,8 @@ import ( "github.com/boostsecurityio/bagel/pkg/models" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestDeduplicateFindings(t *testing.T) { - t.Run("no findings", func(t *testing.T) { - result := &models.ScanResult{ - Findings: []models.Finding{}, - } - deduped := deduplicateFindings(result) - assert.Empty(t, deduped.Findings) - }) - - t.Run("nil result", func(t *testing.T) { - deduped := deduplicateFindings(nil) - assert.Nil(t, deduped) - }) - - t.Run("no duplicates", func(t *testing.T) { - result := &models.ScanResult{ - Findings: []models.Finding{ - { - ID: "finding-1", - Fingerprint: "abc123", - Path: "file1.txt", - }, - { - ID: "finding-2", - Fingerprint: "def456", - Path: "file2.txt", - }, - }, - } - deduped := deduplicateFindings(result) - require.Len(t, deduped.Findings, 2) - assert.Nil(t, deduped.Findings[0].Locations) - assert.Nil(t, deduped.Findings[1].Locations) - }) - - t.Run("duplicate fingerprints consolidated", func(t *testing.T) { - result := &models.ScanResult{ - Findings: []models.Finding{ - { - ID: "finding-1", - Fingerprint: "same-fingerprint", - Path: "env:GITHUB_TOKEN", - }, - { - ID: "finding-1", - Fingerprint: "same-fingerprint", - Path: "file:.bashrc", - Metadata: map[string]interface{}{ - "line_number": 42, - }, - }, - { - ID: "finding-1", - Fingerprint: "same-fingerprint", - Path: "file:.zshrc", - Metadata: map[string]interface{}{ - "line_number": 10, - }, - }, - }, - } - deduped := deduplicateFindings(result) - require.Len(t, deduped.Findings, 1) - require.Len(t, deduped.Findings[0].Locations, 3) - assert.Equal(t, "env:GITHUB_TOKEN", deduped.Findings[0].Locations[0]) - assert.Equal(t, "file:.bashrc:42", deduped.Findings[0].Locations[1]) - assert.Equal(t, "file:.zshrc:10", deduped.Findings[0].Locations[2]) - }) - - t.Run("findings without fingerprint not deduplicated", func(t *testing.T) { - result := &models.ScanResult{ - Findings: []models.Finding{ - { - ID: "finding-1", - Path: "file1.txt", - Metadata: map[string]interface{}{}, - }, - { - ID: "finding-2", - Path: "file2.txt", - Metadata: nil, - }, - }, - } - deduped := deduplicateFindings(result) - require.Len(t, deduped.Findings, 2) - }) - - t.Run("mixed findings with and without fingerprints", func(t *testing.T) { - result := &models.ScanResult{ - Findings: []models.Finding{ - { - ID: "secret-1", - Fingerprint: "fp1", - Path: "file1.txt", - }, - { - ID: "config-issue", - Path: "config.yaml", - }, - { - ID: "secret-1", - Fingerprint: "fp1", - Path: "file2.txt", - }, - }, - } - deduped := deduplicateFindings(result) - require.Len(t, deduped.Findings, 2) - // First finding should have 2 locations (deduplicated) - require.Len(t, deduped.Findings[0].Locations, 2) - // Second finding should have no locations (not deduplicated) - assert.Nil(t, deduped.Findings[1].Locations) - }) -} - func TestFormatLocation(t *testing.T) { t.Run("single location with line number", func(t *testing.T) { finding := models.Finding{