Skip to content

Detect dependency locations at files #310

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 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
13 changes: 7 additions & 6 deletions commands/audit/sca/pnpm/pnpm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,38 +99,39 @@ func TestBuildDependencyTree(t *testing.T) {
expectedUniqueDeps: []string{
"npm://jfrog-cli-tests:v1.0.0",
"npm://xml:1.0.1",
"npm://json:9.0.6",
"npm://json:9.0.3",
},
expectedTree: &xrayUtils.GraphNode{
Id: "npm://jfrog-cli-tests:v1.0.0",
Nodes: []*xrayUtils.GraphNode{
{Id: "npm://xml:1.0.1"},
{Id: "npm://json:9.0.6"},
{Id: "npm://json:9.0.3"},
},
},
},
{
name: "Prod",
depType: "prodOnly",

expectedUniqueDeps: []string{
"npm://jfrog-cli-tests:v1.0.0",
"npm://xml:1.0.1",
"npm://json:9.0.3",
},
expectedTree: &xrayUtils.GraphNode{
Id: "npm://jfrog-cli-tests:v1.0.0",
Nodes: []*xrayUtils.GraphNode{{Id: "npm://xml:1.0.1"}},
Nodes: []*xrayUtils.GraphNode{{Id: "npm://json:9.0.3"}},
},
},
{
name: "Dev",
depType: "devOnly",
expectedUniqueDeps: []string{
"npm://jfrog-cli-tests:v1.0.0",
"npm://json:9.0.6",
"npm://xml:1.0.1",
},
expectedTree: &xrayUtils.GraphNode{
Id: "npm://jfrog-cli-tests:v1.0.0",
Nodes: []*xrayUtils.GraphNode{{Id: "npm://json:9.0.6"}},
Nodes: []*xrayUtils.GraphNode{{Id: "npm://xml:1.0.1"}},
},
},
}
Expand Down
93 changes: 93 additions & 0 deletions sca/npm/npmhandler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package npm

import (
"fmt"
"regexp"
"strings"

"github.com/jfrog/jfrog-cli-security/utils/formats/sarifutils"
"github.com/jfrog/jfrog-client-go/utils/io/fileutils"
"github.com/owenrumney/go-sarif/v2/sarif"
)

const (
PackageJson = "package.json"
)

type NpmHandler struct{}

func (nh *NpmHandler) GetTechDependencyLocations(directDependencyName, directDependencyVersion string, filesToSearch ...string) (locations []*sarif.Location, err error) {
for _, file := range getFilesToSearch(filesToSearch...) {
fileLocations, err := getDependencyLocations(file, directDependencyName, directDependencyVersion)
if err != nil {
return nil, err
}
locations = append(locations, fileLocations...)
}
return
}

// getFilesToSearch returns the npm related files to search for the dependency
// If no files are provided, the default is to search for package.json at the current directory
func getFilesToSearch(filesToSearch ...string) (out []string) {
if len(filesToSearch) == 0 {
return []string{PackageJson}
}
for _, file := range filesToSearch {
if strings.HasSuffix(strings.TrimSuffix(file, "/"), PackageJson) {
out = append(out, file)
}
}
return out
}

// getDependencyLocations returns the locations of the dependency in the file
func getDependencyLocations(file, directDependencyName, dependencyVersion string) (locations []*sarif.Location, err error) {
content, err := fileutils.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("failed to read file '%s': %v", file, err)
}

// Prepare regular expression to match all possible ways to specify a version.
pattern := fmt.Sprintf(`"%s"\s*:\s*"([~^]?\d+(?:\.\d+)?(?:\.\d+)?)"`, regexp.QuoteMeta(directDependencyName))

// Compile the regex.
re := regexp.MustCompile(pattern)

// Split the contents into lines for processing.
lines := strings.Split(string(content), "\n")

for lineNumber, line := range lines {
// Find all match locations in the line.
matches := re.FindStringSubmatch(line)
if matches != nil {
detectedVersion := matches[1] // Extract detected version from match

// Normalize the given dependency version by allowing optional ~ or ^ prefix
if dependencyVersion != "" {
allowedPrefixes := []string{"", "~", "^"}
matchFound := false
for _, prefix := range allowedPrefixes {
if strings.HasPrefix(prefix+dependencyVersion, detectedVersion) {
matchFound = true
break
}
}
if !matchFound {
continue // Skip if the provided version does not match the detected version
}
}

// Get the matched snippet
matchIndex := re.FindStringIndex(line)
matchedSnippet := line[matchIndex[0]:matchIndex[1]]

// Rows and Cols are 1-indexed
row := lineNumber + 1
startCol := matchIndex[0] + 1
locations = append(locations, sarifutils.CreateLocation(file, row, startCol, row, startCol+len(matchedSnippet), matchedSnippet))
}
}

return locations, nil
}
78 changes: 78 additions & 0 deletions sca/npm/npmhandler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package npm

import (
"path/filepath"
"testing"

"github.com/owenrumney/go-sarif/v2/sarif"
"github.com/stretchr/testify/assert"

securityTestUtils "github.com/jfrog/jfrog-cli-security/tests/utils"
"github.com/jfrog/jfrog-cli-security/utils/formats/sarifutils"
)

var (
testDataDir = filepath.Join("..", "..", "tests", "testdata", "projects", "package-managers", "npm")
)

func TestNpmGetTechDependencyLocations(t *testing.T) {
cleanUp := securityTestUtils.ChangeWDWithCallback(t, filepath.Join(testDataDir, "npm"))
defer cleanUp()

testCases := []struct {
name string
directDependencyName string
directDependencyVersion string
filesToSearch []string
expectedLocations []*sarif.Location
expectedError error
}{
{
name: "dependency not found",
directDependencyName: "json",
filesToSearch: []string{filepath.Join("..", "npm-scripts", "package.json")},
},
{
name: "dependency all versions",
directDependencyName: "json",
filesToSearch: []string{
"package.json",
filepath.Join("..", "npm-big-tree", "package.json"),
filepath.Join("..", "npm-no-lock", "package.json"),
},
expectedLocations: []*sarif.Location{
sarifutils.CreateLocation("package.json", 15, 5, 15, 20, "\"json\": \"9.0.6\""),
sarifutils.CreateLocation(filepath.Join("..", "npm-no-lock", "package.json"), 12, 5, 12, 20, "\"json\": \"9.0.3\""),
},
},
{
name: "dependency specific version",
directDependencyName: "json",
directDependencyVersion: "9.0.6",
filesToSearch: []string{
"package.json",
filepath.Join("..", "npm-scripts", "package.json"),
filepath.Join("..", "npm-no-lock", "package.json"),
},
expectedLocations: []*sarif.Location{
sarifutils.CreateLocation("package.json", 15, 5, 15, 20, "\"json\": \"9.0.6\""),
},
},
{
name: "search at cwd (no files to search)",
directDependencyName: "xml",
expectedLocations: []*sarif.Location{
sarifutils.CreateLocation("package.json", 12, 5, 12, 19, "\"xml\": \"1.0.1\""),
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
nh := NpmHandler{}
locations, err := nh.GetTechDependencyLocations(tc.directDependencyName, tc.directDependencyVersion, tc.filesToSearch...)
assert.ElementsMatch(t, tc.expectedLocations, locations)
assert.Equal(t, tc.expectedError, err)
})
}
}
17 changes: 17 additions & 0 deletions sca/sca.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package sca

import (
"github.com/owenrumney/go-sarif/v2/sarif"

"github.com/jfrog/jfrog-cli-security/sca/npm"
"github.com/jfrog/jfrog-cli-security/utils/techutils"
)

func GetTechDependencyLocations(tech techutils.Technology, directDependencyName, directDependencyVersion string, filesToSearch ...string) ([]*sarif.Location, error) {
switch tech {

Check failure on line 11 in sca/sca.go

View workflow job for this annotation

GitHub Actions / Static-Check

singleCaseSwitch: should rewrite switch statement to if statement (gocritic)
case techutils.Npm:
nh := npm.NpmHandler{}
return nh.GetTechDependencyLocations(directDependencyName, directDependencyVersion, filesToSearch...)
}
return nil, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
"author": "",
"license": "ISC",
"dependencies": {
"xml": "1.0.1"
"json": "9.0.3"
},
"devDependencies": {
"json": "9.0.6"
"xml": "1.0.1"
}
}
31 changes: 23 additions & 8 deletions utils/results/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/jfrog/gofrog/datastructures"
"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
"github.com/jfrog/jfrog-cli-security/sca"
"github.com/jfrog/jfrog-cli-security/utils"
"github.com/jfrog/jfrog-cli-security/utils/formats"
"github.com/jfrog/jfrog-cli-security/utils/formats/sarifutils"
Expand Down Expand Up @@ -98,7 +99,7 @@ func ApplyHandlerToScaVulnerabilities(target ScanTarget, vulnerabilities []servi
return nil
}
for _, vulnerability := range vulnerabilities {
impactedPackagesNames, impactedPackagesVersions, impactedPackagesTypes, fixedVersions, directComponents, impactPaths, err := SplitComponents(target.Target, vulnerability.Components)
impactedPackagesNames, impactedPackagesVersions, impactedPackagesTypes, fixedVersions, directComponents, impactPaths, err := SplitComponents(target, vulnerability.Components)
if err != nil {
return err
}
Expand Down Expand Up @@ -131,7 +132,7 @@ func ApplyHandlerToScaViolations(target ScanTarget, violations []services.Violat
watchesSet.Add(violation.WatchName)
failBuild = failBuild || violation.FailBuild
// Prepare violation information
impactedPackagesNames, impactedPackagesVersions, impactedPackagesTypes, fixedVersions, directComponents, impactPaths, e := SplitComponents(target.Target, violation.Components)
impactedPackagesNames, impactedPackagesVersions, impactedPackagesTypes, fixedVersions, directComponents, impactPaths, e := SplitComponents(target, violation.Components)
if e != nil {
err = errors.Join(err, e)
continue
Expand Down Expand Up @@ -212,7 +213,7 @@ func ApplyHandlerToLicenses(target ScanTarget, licenses []services.License, hand
return nil
}
for _, license := range licenses {
impactedPackagesNames, impactedPackagesVersions, impactedPackagesTypes, _, directComponents, impactPaths, err := SplitComponents(target.Target, license.Components)
impactedPackagesNames, impactedPackagesVersions, impactedPackagesTypes, _, directComponents, impactPaths, err := SplitComponents(target, license.Components)
if err != nil {
return err
}
Expand All @@ -227,7 +228,7 @@ func ApplyHandlerToLicenses(target ScanTarget, licenses []services.License, hand
return nil
}

func SplitComponents(target string, impactedPackages map[string]services.Component) (impactedPackagesNames, impactedPackagesVersions, impactedPackagesTypes []string, fixedVersions [][]string, directComponents [][]formats.ComponentRow, impactPaths [][][]formats.ComponentRow, err error) {
func SplitComponents(target ScanTarget, impactedPackages map[string]services.Component) (impactedPackagesNames, impactedPackagesVersions, impactedPackagesTypes []string, fixedVersions [][]string, directComponents [][]formats.ComponentRow, impactPaths [][][]formats.ComponentRow, err error) {
if len(impactedPackages) == 0 {
err = errorutils.CheckErrorf("failed while parsing the response from Xray: violation doesn't have any components")
return
Expand All @@ -246,7 +247,7 @@ func SplitComponents(target string, impactedPackages map[string]services.Compone
}

// Gets a slice of the direct dependencies or packages of the scanned component, that depends on the vulnerable package, and converts the impact paths.
func getDirectComponentsAndImpactPaths(target string, impactPaths [][]services.ImpactPathNode) (components []formats.ComponentRow, impactPathsRows [][]formats.ComponentRow) {
func getDirectComponentsAndImpactPaths(target ScanTarget, impactPaths [][]services.ImpactPathNode) (components []formats.ComponentRow, impactPathsRows [][]formats.ComponentRow) {
componentsMap := make(map[string]formats.ComponentRow)

// The first node in the impact path is the scanned component itself. The second one is the direct dependency.
Expand All @@ -259,7 +260,7 @@ func getDirectComponentsAndImpactPaths(target string, impactPaths [][]services.I
componentId := impactPath[impactPathIndex].ComponentId
if _, exist := componentsMap[componentId]; !exist {
compName, compVersion, _ := techutils.SplitComponentId(componentId)
componentsMap[componentId] = formats.ComponentRow{Name: compName, Version: compVersion, Location: getComponentLocation(impactPath[impactPathIndex].FullPath, target)}
componentsMap[componentId] = formats.ComponentRow{Name: compName, Version: compVersion, Location: getComponentLocation(target.Technology, compName, compVersion, impactPath[impactPathIndex].FullPath, target.Target)}
}

// Convert the impact path
Expand All @@ -269,7 +270,7 @@ func getDirectComponentsAndImpactPaths(target string, impactPaths [][]services.I
compImpactPathRows = append(compImpactPathRows, formats.ComponentRow{
Name: nodeCompName,
Version: nodeCompVersion,
Location: getComponentLocation(pathNode.FullPath),
Location: getComponentLocation(target.Technology, nodeCompName, nodeCompVersion, pathNode.FullPath),
})
}
impactPathsRows = append(impactPathsRows, compImpactPathRows)
Expand All @@ -281,7 +282,21 @@ func getDirectComponentsAndImpactPaths(target string, impactPaths [][]services.I
return
}

func getComponentLocation(pathsByPriority ...string) *formats.Location {
func getComponentLocation(tech techutils.Technology, compName, compVersion string, pathsByPriority ...string) *formats.Location {
// Search location at descriptors
locations, err := sca.GetTechDependencyLocations(tech, compName, compVersion, pathsByPriority...)
if err == nil && len(locations) > 0 {
location := locations[0]
return &formats.Location{
File: sarifutils.GetLocationFileName(location),
StartLine: sarifutils.GetLocationStartLine(location),
StartColumn: sarifutils.GetLocationStartColumn(location),
EndLine: sarifutils.GetLocationEndLine(location),
EndColumn: sarifutils.GetLocationEndColumn(location),
Snippet: sarifutils.GetLocationSnippetText(location),
}
}
// Fallback to given paths without region
for _, path := range pathsByPriority {
if path != "" {
return &formats.Location{File: path}
Expand Down
6 changes: 3 additions & 3 deletions utils/results/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,7 @@ func TestShouldDisqualifyEvidence(t *testing.T) {
func TestGetDirectComponents(t *testing.T) {
tests := []struct {
name string
target string
target ScanTarget
impactPaths [][]services.ImpactPathNode
expectedDirectComponentRows []formats.ComponentRow
expectedConvImpactPaths [][]formats.ComponentRow
Expand All @@ -638,14 +638,14 @@ func TestGetDirectComponents(t *testing.T) {
},
{
name: "one direct component with target",
target: filepath.Join("root", "dir", "file"),
target: ScanTarget{Target: filepath.Join("root", "dir", "file")},
impactPaths: [][]services.ImpactPathNode{{services.ImpactPathNode{ComponentId: "gav://jfrog:pack1:1.2.3"}, services.ImpactPathNode{ComponentId: "gav://jfrog:pack2:1.2.3"}}},
expectedDirectComponentRows: []formats.ComponentRow{{Name: "jfrog:pack2", Version: "1.2.3", Location: &formats.Location{File: filepath.Join("root", "dir", "file")}}},
expectedConvImpactPaths: [][]formats.ComponentRow{{{Name: "jfrog:pack1", Version: "1.2.3"}, {Name: "jfrog:pack2", Version: "1.2.3"}}},
},
{
name: "multiple direct components",
target: filepath.Join("root", "dir", "file"),
target: ScanTarget{Target: filepath.Join("root", "dir", "file")},
impactPaths: [][]services.ImpactPathNode{{services.ImpactPathNode{ComponentId: "gav://jfrog:pack1:1.2.3"}, services.ImpactPathNode{ComponentId: "gav://jfrog:pack21:1.2.3"}, services.ImpactPathNode{ComponentId: "gav://jfrog:pack3:1.2.3"}}, {services.ImpactPathNode{ComponentId: "gav://jfrog:pack1:1.2.3"}, services.ImpactPathNode{ComponentId: "gav://jfrog:pack22:1.2.3"}, services.ImpactPathNode{ComponentId: "gav://jfrog:pack3:1.2.3"}}},
expectedDirectComponentRows: []formats.ComponentRow{
{Name: "jfrog:pack21", Version: "1.2.3", Location: &formats.Location{File: filepath.Join("root", "dir", "file")}},
Expand Down
6 changes: 4 additions & 2 deletions utils/results/conversion/sarifparser/sarifparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,10 @@ func getComponentSarifLocation(cmtType utils.CommandType, component formats.Comp
logicalLocations = append(logicalLocations, logicalLocation)
}
}
return sarif.NewLocation().
WithPhysicalLocation(sarif.NewPhysicalLocation().WithArtifactLocation(sarif.NewArtifactLocation().WithUri("file://" + filePath))).WithLogicalLocations(logicalLocations)

return sarifutils.CreateLocation(fmt.Sprintf("file://%s", filePath), component.Location.StartLine, component.Location.StartColumn, component.Location.EndLine, component.Location.EndColumn, component.Location.Snippet).WithLogicalLocations(logicalLocations)
// return sarif.NewLocation().
// WithPhysicalLocation(sarif.NewPhysicalLocation().WithArtifactLocation(sarif.NewArtifactLocation().WithUri("file://" + filePath))).WithLogicalLocations(logicalLocations)
}

func getScaIssueMarkdownDescription(directDependencies []formats.ComponentRow, cveScore string, applicableStatus jasutils.ApplicabilityStatus, fixedVersions []string) (string, error) {
Expand Down
Loading
Loading