Skip to content
Open
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
21 changes: 21 additions & 0 deletions packageupdaters/commonpackageupdater.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package packageupdaters
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -135,6 +136,26 @@ func BuildPackageWithVersionRegex(impactedName, impactedVersion, dependencyLineF
return regexp.MustCompile(regexpCompleteFormat)
}

func getAbsolutePathUnderWd(path string) (string, error) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add unit test

absPath, err := filepath.Abs(filepath.Clean(path))
if err != nil {
return "", fmt.Errorf("failed to resolve path %q: %w", path, err)
}
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
wdAbs, err := filepath.Abs(wd)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Getwd already returns absolute path so calling filepath.Abs is redundant

if err != nil {
return "", fmt.Errorf("failed to resolve working directory: %w", err)
}
rel, err := filepath.Rel(wdAbs, absPath)
if err != nil || strings.HasPrefix(rel, "..") || rel == ".." {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strings.HasPrefix(rel, "..") already covers the case of "rel == ".."" so you dont need the ' rel == ".." '

return "", fmt.Errorf("path %q is outside project directory", path)
}
return absPath, nil
}

func GetVulnerabilityLocations(vulnDetails *utils.VulnerabilityDetails, namesFilters []string, ignoreFilters []string) []string {
pathsSet := datastructures.MakeSet[string]()
for _, component := range vulnDetails.Components {
Expand Down
3 changes: 2 additions & 1 deletion packageupdaters/gradlepackageupdater.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ package packageupdaters

import (
"fmt"
"github.com/jfrog/frogbot/v2/utils"
"os"
"regexp"
"strings"

"github.com/jfrog/frogbot/v2/utils"
)

const (
Expand Down
7 changes: 6 additions & 1 deletion packageupdaters/npmpackageupdater.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,12 @@ func (npm *NpmPackageUpdater) RegenerateLockfile(vulnDetails *utils.Vulnerabilit

if err = npm.regenerateLockFileWithRetry(); err != nil {
log.Warn(fmt.Sprintf("Failed to regenerate lock file after updating '%s' to version '%s': %s. Rolling back...", vulnDetails.ImpactedDependencyName, vulnDetails.SuggestedFixedVersion, err.Error()))
if rollbackErr := os.WriteFile(descriptorPath, backupContent, 0644); rollbackErr != nil {
safePath, pathErr := getAbsolutePathUnderWd(descriptorPath)
if pathErr != nil {
return fmt.Errorf("failed to rollback descriptor: %w (original error: %v)", pathErr, err)
}
// #nosec G703 -- path validated under working directory by getAbsolutePathUnderWd
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nosec G703 annotation is incorrect.
gosec G703 is "Deferring a method which returns an error" — that's unrelated.
The gosec rule being triggered here is G304 ("File path provided as taint input").

if rollbackErr := os.WriteFile(safePath, backupContent, 0644); rollbackErr != nil {
return fmt.Errorf("failed to rollback descriptor after lock file regeneration failure: %w (original error: %v)", rollbackErr, err)
}
return err
Expand Down
75 changes: 71 additions & 4 deletions utils/outputwriter/outputcontent.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,9 @@ func getSecurityViolationsContent(issues issues.ScansIssuesCollection, writer Ou
if len(issues.ScaViolations) == 0 {
return []string{}
}
content = append(content, getSecurityViolationsSummaryTable(issues.ScaViolations, writer))
content = append(content, getScaSecurityIssueDetailsContent(issues.ScaViolations, true, writer)...)
aggregated := aggregateVulnerabilitiesOrViolationsByCve(issues.ScaViolations)
content = append(content, getSecurityViolationsSummaryTable(aggregated, writer))
content = append(content, getScaSecurityIssueDetailsContent(aggregated, true, writer)...)
return ConvertContentToComments(content, writer, getDecoratorWithSecurityViolationTitle(writer))
}

Expand Down Expand Up @@ -422,12 +423,78 @@ func getScaLicenseViolationDetails(violation formats.LicenseViolationRow, writer

// Sca Vulnerabilities

func vulnerabilitySummaryKey(v formats.VulnerabilityOrViolationRow) string {
ids := make([]string, 0, len(v.Cves))
for _, cve := range v.Cves {
ids = append(ids, cve.Id)
}
sort.Strings(ids)
if len(ids) == 0 {
return v.IssueId
}
return strings.Join(ids, ",")
}

func uniqueStrings(s []string) []string {
seen := make(map[string]struct{}, len(s))
out := make([]string, 0, len(s))
for _, v := range s {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}

func aggregateVulnerabilitiesOrViolationsByCve(vulnerabilities []formats.VulnerabilityOrViolationRow) []formats.VulnerabilityOrViolationRow {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The violations aggregation is drops important data when two rows for
the same CVE have different Watch names (the "Watch Name" column in the
violations table is kept from the first occurrence, silently discarding
the others). If two different Watch policies trigger on the same CVE,
merging them may cause confusion.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another issue with the violation aggregation is when two violations rows for the same CVE have different impacted dependency versions (e.g., lib:1.5.15 and lib:1.5.1), the merged row will only show lib:1.5.15 in the "Impacted Dependency" column. The second version is silently dropped — it won't appear in the table at all (unlike vulnerabilities where ImpactPaths carries that info implicitly).

The Components field (used for "Direct Dependencies" via getDirectDependenciesCellData) has the same problem — it's also only kept from the first occurrence in the aggregation merge step.

if len(vulnerabilities) == 0 {
return vulnerabilities
}
byKey := make(map[string]*formats.VulnerabilityOrViolationRow)
for i := range vulnerabilities {
v := &vulnerabilities[i]
key := vulnerabilitySummaryKey(*v)
if existing, ok := byKey[key]; ok {
existing.ImpactPaths = append(existing.ImpactPaths, v.ImpactPaths...)
if len(v.FixedVersions) > 0 {
existing.FixedVersions = uniqueStrings(append(existing.FixedVersions, v.FixedVersions...))
}
} else {
agg := *v
agg.ImpactPaths = append([][]formats.ComponentRow(nil), v.ImpactPaths...)
agg.FixedVersions = append([]string(nil), v.FixedVersions...)
heapCopy := new(formats.VulnerabilityOrViolationRow)
*heapCopy = agg
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need this second copy? this is similar to the copy made here 'agg := *v'

byKey[key] = heapCopy
}
}
result := make([]formats.VulnerabilityOrViolationRow, 0, len(byKey))
for _, v := range byKey {
result = append(result, *v)
}
// Preserve relative order by first occurrence
order := make(map[string]int)
for i, v := range vulnerabilities {
key := vulnerabilitySummaryKey(v)
if _, ok := order[key]; !ok {
order[key] = i
}
}
sort.Slice(result, func(i, j int) bool {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order-preservation pass re-iterates vulnerabilities to build the
order map after the aggregation map is already built. This can be
folded into the first loop (track first-seen index inside the main loop)
to avoid the second O(n) scan — minor but cleaner.

return order[vulnerabilitySummaryKey(result[i])] < order[vulnerabilitySummaryKey(result[j])]
})
return result
}

func GetVulnerabilitiesContent(vulnerabilities []formats.VulnerabilityOrViolationRow, writer OutputWriter) (content []string) {
if len(vulnerabilities) == 0 {
return []string{}
}
content = append(content, getVulnerabilitiesSummaryTable(vulnerabilities, writer))
content = append(content, getScaSecurityIssueDetailsContent(vulnerabilities, false, writer)...)
aggregated := aggregateVulnerabilitiesOrViolationsByCve(vulnerabilities)
content = append(content, getVulnerabilitiesSummaryTable(aggregated, writer))
content = append(content, getScaSecurityIssueDetailsContent(aggregated, false, writer)...)
return ConvertContentToComments(content, writer, getDecoratorWithScaVulnerabilitiesTitle(writer))
}

Expand Down
87 changes: 87 additions & 0 deletions utils/outputwriter/outputcontent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package outputwriter

import (
"path/filepath"
"strings"
"testing"

"github.com/jfrog/froggit-go/vcsutils"
Expand Down Expand Up @@ -431,6 +432,92 @@ func TestVulnerabilitiesContent(t *testing.T) {
}
}

func TestAggregateVulnerabilitiesByCve(t *testing.T) {
t.Run("empty returns empty", func(t *testing.T) {
got := aggregateVulnerabilitiesOrViolationsByCve(nil)
assert.Nil(t, got)
got = aggregateVulnerabilitiesOrViolationsByCve([]formats.VulnerabilityOrViolationRow{})
assert.Empty(t, got)
})

t.Run("same CVE merged into one row with combined paths", func(t *testing.T) {
cve := "CVE-2017-1000487"
in := []formats.VulnerabilityOrViolationRow{
{
ImpactedDependencyDetails: formats.ImpactedDependencyDetails{
ImpactedDependencyName: "pkg", ImpactedDependencyVersion: "1.0",
},
ImpactPaths: [][]formats.ComponentRow{{{Name: "root", Version: "1.0"}, {Name: "pkg", Version: "1.0"}}},
FixedVersions: []string{"2.0", "3.0"},
Cves: []formats.CveRow{{Id: cve}},
IssueId: "XRAY-111",
},
{
ImpactedDependencyDetails: formats.ImpactedDependencyDetails{
ImpactedDependencyName: "pkg", ImpactedDependencyVersion: "2.0",
},
ImpactPaths: [][]formats.ComponentRow{{{Name: "root", Version: "1.0"}, {Name: "other", Version: "1.0"}, {Name: "pkg", Version: "2.0"}}},
FixedVersions: []string{"2.0", "4.0"}, // 2.0 duplicate
Cves: []formats.CveRow{{Id: cve}},
IssueId: "XRAY-222",
},
}
got := aggregateVulnerabilitiesOrViolationsByCve(in)
assert.Len(t, got, 1)
assert.Len(t, got[0].ImpactPaths, 2)
assert.Equal(t, []string{"2.0", "3.0", "4.0"}, got[0].FixedVersions) // deduplicated
assert.Equal(t, "pkg", got[0].ImpactedDependencyName)
assert.Equal(t, "1.0", got[0].ImpactedDependencyVersion) // first occurrence kept
assert.Equal(t, "XRAY-111", got[0].IssueId)
})

t.Run("different CVEs stay separate", func(t *testing.T) {
in := []formats.VulnerabilityOrViolationRow{
{Cves: []formats.CveRow{{Id: "CVE-A"}}, ImpactPaths: [][]formats.ComponentRow{{{Name: "a", Version: "1"}}}},
{Cves: []formats.CveRow{{Id: "CVE-B"}}, ImpactPaths: [][]formats.ComponentRow{{{Name: "b", Version: "1"}}}},
}
got := aggregateVulnerabilitiesOrViolationsByCve(in)
assert.Len(t, got, 2)
})
}

func TestVulnerabilitiesContent_aggregatesSameCveIntoOneRow(t *testing.T) {
sameCve := "CVE-2017-1000487"
vulnerabilities := []formats.VulnerabilityOrViolationRow{
{
ImpactedDependencyDetails: formats.ImpactedDependencyDetails{
SeverityDetails: formats.SeverityDetails{Severity: "Critical"},
ImpactedDependencyName: "org.codehaus.plexus:plexus-utils",
ImpactedDependencyVersion: "1.5.15",
},
Applicable: "Not Applicable",
ImpactPaths: [][]formats.ComponentRow{
{{Name: "root", Version: "1.0.0"}, {Name: "some-dep", Version: "1.0.0"}, {Name: "org.codehaus.plexus:plexus-utils", Version: "1.5.15"}},
},
Cves: []formats.CveRow{{Id: sameCve}},
},
{
ImpactedDependencyDetails: formats.ImpactedDependencyDetails{
SeverityDetails: formats.SeverityDetails{Severity: "Critical"},
ImpactedDependencyName: "org.codehaus.plexus:plexus-utils",
ImpactedDependencyVersion: "1.5.1",
},
Applicable: "Not Applicable",
ImpactPaths: [][]formats.ComponentRow{
{{Name: "root", Version: "1.0.0"}, {Name: "other-dep", Version: "1.0.0"}, {Name: "org.codehaus.plexus:plexus-utils", Version: "1.5.1"}},
},
Cves: []formats.CveRow{{Id: sameCve}},
},
}
content := GetVulnerabilitiesContent(vulnerabilities, &SimplifiedOutput{})
assert.NotEmpty(t, content)
tableSection := content[0]
assert.Contains(t, tableSection, sameCve, "output should mention the CVE")
assert.Contains(t, tableSection, "2 Transitive", "same CVE in two transitive paths should be aggregated and show 2 Transitive")
// CVE should appear only once in the table (one row), not twice
assert.Equal(t, 1, strings.Count(tableSection, sameCve), "CVE should appear once in the summary table (one aggregated row)")
}

func TestSecurityViolationsContent(t *testing.T) {
testCases := []struct {
name string
Expand Down
Loading