Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions pkg/models/dedup_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
63 changes: 63 additions & 0 deletions pkg/models/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
62 changes: 1 addition & 61 deletions pkg/reporter/reporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
117 changes: 0 additions & 117 deletions pkg/reporter/reporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading