Skip to content
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

Audit - Support Pnpm SCA scan #18

Merged
merged 11 commits into from
Feb 18, 2024
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: "16"
- name: Setup Pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install Java
uses: actions/setup-java@v3
with:
Expand Down
26 changes: 26 additions & 0 deletions audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,32 @@ func testXrayAuditNpm(t *testing.T, format string) string {
return securityTests.PlatformCli.RunCliCmdWithOutput(t, "audit", "--npm", "--licenses", "--format="+format)
}

func TestXrayAuditPnpmJson(t *testing.T) {
output := testXrayAuditPnpm(t, string(format.Json))
securityTestUtils.VerifyJsonScanResults(t, output, 0, 1, 1)
}

func TestXrayAuditPnpmSimpleJson(t *testing.T) {
output := testXrayAuditPnpm(t, string(format.SimpleJson))
securityTestUtils.VerifySimpleJsonScanResults(t, output, 1, 1)
}

func testXrayAuditPnpm(t *testing.T, format string) string {
securityTestUtils.InitSecurityTest(t, scangraph.GraphScanMinXrayVersion)
tempDirPath, createTempDirCallback := coreTests.CreateTempDirWithCallbackAndAssert(t)
defer createTempDirCallback()
npmProjectPath := filepath.Join(filepath.FromSlash(securityTestUtils.GetTestResourcesPath()), "projects", "package-managers", "npm", "npm-no-lock")
// Copy the npm project from the testdata to a temp dir
assert.NoError(t, biutils.CopyDir(npmProjectPath, tempDirPath, true, nil))
prevWd := securityTestUtils.ChangeWD(t, tempDirPath)
defer clientTests.ChangeDirAndAssert(t, prevWd)
// Run pnpm install before executing audit
assert.NoError(t, exec.Command("pnpm", "install").Run())
// Add dummy descriptor file to check that we run only specific audit
addDummyPackageDescriptor(t, true)
return securityTests.PlatformCli.RunCliCmdWithOutput(t, "audit", "--pnpm", "--licenses", "--format="+format)
}

func TestXrayAuditYarnV2Json(t *testing.T) {
testXrayAuditYarn(t, "yarn-v2", func() {
output := runXrayAuditYarnWithOutput(t, string(format.Json))
Expand Down
6 changes: 4 additions & 2 deletions cli/docs/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const (
Mvn = "mvn"
Gradle = "gradle"
Npm = "npm"
Pnpm = "pnpm"
Yarn = "yarn"
Nuget = "nuget"
Go = "go"
Expand Down Expand Up @@ -124,7 +125,7 @@ var commandFlags = map[string][]string{
},
Audit: {
url, user, password, accessToken, ServerId, InsecureTls, Project, Watches, RepoPath, Licenses, OutputFormat, ExcludeTestDeps,
useWrapperAudit, DepType, RequirementsFile, Fail, ExtendedTable, WorkingDirs, ExclusionsAudit, Mvn, Gradle, Npm, Yarn, Go, Nuget, Pip, Pipenv, Poetry, MinSeverity, FixableOnly, ThirdPartyContextualAnalysis,
useWrapperAudit, DepType, RequirementsFile, Fail, ExtendedTable, WorkingDirs, ExclusionsAudit, Mvn, Gradle, Npm, Pnpm, Yarn, Go, Nuget, Pip, Pipenv, Poetry, MinSeverity, FixableOnly, ThirdPartyContextualAnalysis,
},
CurationAudit: {
CurationOutput, WorkingDirs, CurationThreads,
Expand Down Expand Up @@ -203,7 +204,8 @@ var flagsMap = map[string]components.Flag{
),
Mvn: components.NewBoolFlag(Mvn, "Set to true to request audit for a Maven project."),
Gradle: components.NewBoolFlag(Gradle, "Set to true to request audit for a Gradle project."),
Npm: components.NewBoolFlag(Npm, "Set to true to request audit for an npm project."),
Npm: components.NewBoolFlag(Npm, "Set to true to request audit for a npm project."),
Pnpm: components.NewBoolFlag(Pnpm, "Set to true to request audit for a Pnpm project."),
Yarn: components.NewBoolFlag(Yarn, "Set to true to request audit for a Yarn project."),
Nuget: components.NewBoolFlag(Nuget, "Set to true to request audit for a .NET project."),
Pip: components.NewBoolFlag(Pip, "Set to true to request audit for a Pip project."),
Expand Down
2 changes: 1 addition & 1 deletion commands/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func RunAudit(auditParams *AuditParams) (results *xrayutils.Results, err error)
}

// The sca scan doesn't require the analyzer manager, so it can run separately from the analyzer manager download routine.
results.ScaError = runScaScan(auditParams, results) // runScaScan(auditParams, results)
results.ScaError = runScaScan(auditParams, results)

// Wait for the Download of the AnalyzerManager to complete.
if err = errGroup.Wait(); err != nil {
Expand Down
9 changes: 5 additions & 4 deletions commands/audit/sca/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package sca

import (
"fmt"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
"github.com/jfrog/jfrog-cli-core/v2/utils/tests"
"github.com/jfrog/jfrog-cli-security/scangraph"
Expand All @@ -11,10 +16,6 @@ import (
"github.com/jfrog/jfrog-client-go/utils/log"
"github.com/jfrog/jfrog-client-go/xray/services"
xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func RunXrayDependenciesTreeScanGraph(dependencyTree *xrayUtils.GraphNode, progress ioUtils.ProgressMgr, technology coreutils.Technology, scanGraphParams *scangraph.ScanGraphParams) (results []services.ScanResponse, err error) {
Expand Down
6 changes: 3 additions & 3 deletions commands/audit/sca/npm/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
)

const (
ignoreScriptsFlag = "--ignore-scripts"
IgnoreScriptsFlag = "--ignore-scripts"
)

func BuildDependencyTree(params utils.AuditParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) {
Expand Down Expand Up @@ -97,8 +97,8 @@ func createTreeDepsParam(params utils.AuditParams) biutils.NpmTreeDepListParam {

// Add the --ignore-scripts to prevent execution of npm scripts during npm install.
func addIgnoreScriptsFlag(npmArgs []string) []string {
if !slices.Contains(npmArgs, ignoreScriptsFlag) {
return append(npmArgs, ignoreScriptsFlag)
if !slices.Contains(npmArgs, IgnoreScriptsFlag) {
return append(npmArgs, IgnoreScriptsFlag)
}
return npmArgs
}
Expand Down
166 changes: 166 additions & 0 deletions commands/audit/sca/pnpm/pnpm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package pnpm

import (
"encoding/json"
"errors"
"os/exec"
"path/filepath"

"github.com/jfrog/gofrog/datastructures"
"github.com/jfrog/gofrog/io"
"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"

"github.com/jfrog/jfrog-cli-security/commands/audit/sca/npm"
"github.com/jfrog/jfrog-cli-security/utils"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
"github.com/jfrog/jfrog-client-go/utils/io/fileutils"
"github.com/jfrog/jfrog-client-go/utils/log"

coreXray "github.com/jfrog/jfrog-cli-core/v2/utils/xray"
xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils"
)

type pnpmLsDependency struct {
From string `json:"from"`
Version string `json:"version"`
Dependencies map[string]pnpmLsDependency `json:"dependencies,omitempty"`
}

type pnpmLsProject struct {
Name string `json:"name"`
Version string `json:"version"`
Dependencies map[string]pnpmLsDependency `json:"dependencies,omitempty"`
DevDependencies map[string]pnpmLsDependency `json:"devDependencies,omitempty"`
}

func BuildDependencyTree(params utils.AuditParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) {
// Prepare
currentDir, err := coreutils.GetWorkingDirectory()
if err != nil {
return
}
pnpmExecPath, err := getPnpmExecPath()
if err != nil {
return
}
// Build
if err = installProjectIfNeeded(pnpmExecPath, currentDir); errorutils.CheckError(err) != nil {
return
}
return calculateDependencies(pnpmExecPath, currentDir, params)
}

func getPnpmExecPath() (pnpmExecPath string, err error) {
if pnpmExecPath, err = exec.LookPath("pnpm"); errorutils.CheckError(err) != nil {
return
}
if pnpmExecPath == "" {
err = errors.New("could not find the 'pnpm' executable in the system PATH")
return
}
log.Debug("Using Pnpm executable:", pnpmExecPath)
// Validate pnpm version command
version, err := getPnpmCmd(pnpmExecPath, "", "--version").RunWithOutput()
if errorutils.CheckError(err) != nil {
return
}
log.Debug("Pnpm version:", string(version))
return
}

func getPnpmCmd(pnpmExecPath, workingDir, cmd string, args ...string) *io.Command {
command := io.NewCommand(pnpmExecPath, cmd, args)
command.Dir = workingDir
return command
}

// Install is required when "pnpm-lock.yaml" lock file or "node_modules/.pnpm" directory not exists.
func installProjectIfNeeded(pnpmExecPath, workingDir string) (err error) {
lockFileExists, err := fileutils.IsFileExists(filepath.Join(workingDir, "pnpm-lock.yaml"), false)
if err != nil {
return
}
pnpmDirExists, err := fileutils.IsDirExists(filepath.Join(workingDir, "node_modules", ".pnpm"), false)
if err != nil || (lockFileExists && pnpmDirExists) {
return
}
// Install is needed
log.Debug("Installing Pnpm project:", workingDir)
return getPnpmCmd(pnpmExecPath, workingDir, "install", npm.IgnoreScriptsFlag).GetCmd().Run()
}

// Run 'pnpm ls ...' command (project must be installed) and parse the returned result to create a dependencies trees for the projects.
func calculateDependencies(executablePath, workingDir string, params utils.AuditParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) {
lsArgs := append([]string{"--depth", "Infinity", "--json", "--long"}, params.Args()...)
npmLsCmdContent, err := getPnpmCmd(executablePath, workingDir, "ls", lsArgs...).RunWithOutput()
if err != nil {
return
}
log.Debug("Pnpm ls command output:\n", string(npmLsCmdContent))
output := &[]pnpmLsProject{}
if err = json.Unmarshal(npmLsCmdContent, output); err != nil {
return
}
dependencyTrees, uniqueDeps = parsePnpmLSContent(*output)
return
}

func parsePnpmLSContent(projectInfo []pnpmLsProject) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string) {
uniqueDepsSet := datastructures.MakeSet[string]()
for _, project := range projectInfo {
// Parse the dependencies into Xray dependency tree format
dependencyTree, uniqueProjectDeps := coreXray.BuildXrayDependencyTree(createProjectDependenciesTree(project), getDependencyId(project.Name, project.Version))
// Add results
dependencyTrees = append(dependencyTrees, dependencyTree)
uniqueDepsSet.AddElements(maps.Keys(uniqueProjectDeps)...)
}
uniqueDeps = uniqueDepsSet.ToSlice()
return
}

func createProjectDependenciesTree(project pnpmLsProject) map[string]coreXray.DepTreeNode {
treeMap := make(map[string]coreXray.DepTreeNode)
directDependencies := []string{}
// Handle production-dependencies
for depName, dependency := range project.Dependencies {
directDependency := getDependencyId(depName, dependency.Version)
directDependencies = append(directDependencies, directDependency)
appendTransitiveDependencies(directDependency, dependency.Dependencies, treeMap)
}
// Handle dev-dependencies
for depName, dependency := range project.DevDependencies {
directDependency := getDependencyId(depName, dependency.Version)
directDependencies = append(directDependencies, directDependency)
appendTransitiveDependencies(directDependency, dependency.Dependencies, treeMap)
}
if len(directDependencies) > 0 {
treeMap[getDependencyId(project.Name, project.Version)] = coreXray.DepTreeNode{Children: directDependencies}
attiasas marked this conversation as resolved.
Show resolved Hide resolved
}
return treeMap
}

// Return npm://<name>:<version> of a dependency
func getDependencyId(depName, version string) string {
return utils.NpmPackageTypeIdentifier + depName + ":" + version
}

func appendTransitiveDependencies(parent string, dependencies map[string]pnpmLsDependency, result map[string]coreXray.DepTreeNode) {
attiasas marked this conversation as resolved.
Show resolved Hide resolved
for depName, dependency := range dependencies {
dependencyId := getDependencyId(depName, dependency.Version)
if node, ok := result[parent]; ok {
node.Children = appendUniqueChild(node.Children, dependencyId)
} else {
result[parent] = coreXray.DepTreeNode{Children: []string{dependencyId}}
}
appendTransitiveDependencies(dependencyId, dependency.Dependencies, result)
}
}

func appendUniqueChild(children []string, candidateDependency string) []string {
if slices.Contains(children, candidateDependency) {
return children
}
return append(children, candidateDependency)
}
86 changes: 86 additions & 0 deletions commands/audit/sca/pnpm/pnpm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package pnpm

import (
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/jfrog/jfrog-cli-core/v2/utils/tests"
xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils"

"github.com/jfrog/jfrog-cli-security/commands/audit/sca"
"github.com/jfrog/jfrog-cli-security/utils"
)

func TestBuildDependencyTree(t *testing.T) {
// Create and change directory to test workspace
_, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-no-lock"))
defer cleanUp()

testCases := []struct {
name string
depType string
expectedUniqueDeps []string
expectedTree *xrayUtils.GraphNode
}{
{
name: "All",
depType: "all",
expectedUniqueDeps: []string{
"npm://jfrog-cli-tests:v1.0.0",
"npm://xml:1.0.1",
"npm://json:9.0.6",
},
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"},
},
},
},
{
name: "Prod",
depType: "prodOnly",
expectedUniqueDeps: []string{
"npm://jfrog-cli-tests:v1.0.0",
"npm://xml:1.0.1",
},
expectedTree: &xrayUtils.GraphNode{
Id: "npm://jfrog-cli-tests:v1.0.0",
Nodes: []*xrayUtils.GraphNode{{Id: "npm://xml:1.0.1"}},
},
},
{
name: "Dev",
depType: "devOnly",
expectedUniqueDeps: []string{
"npm://jfrog-cli-tests:v1.0.0",
"npm://json:9.0.6",
},
expectedTree: &xrayUtils.GraphNode{
Id: "npm://jfrog-cli-tests:v1.0.0",
Nodes: []*xrayUtils.GraphNode{{Id: "npm://json:9.0.6"}},
},
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
// Build dependency tree
params := &utils.AuditBasicParams{}
rootNode, uniqueDeps, err := BuildDependencyTree(params.SetNpmScope(testCase.depType))
require.NoError(t, err)
// Validations
assert.ElementsMatch(t, uniqueDeps, testCase.expectedUniqueDeps, "First is actual, Second is Expected")
if assert.Len(t, rootNode, 1) {
assert.Equal(t, rootNode[0].Id, testCase.expectedTree.Id)
if !tests.CompareTree(testCase.expectedTree, rootNode[0]) {
t.Error("expected:", testCase.expectedTree.Nodes, "got:", rootNode[0].Nodes)
}
}
})
}
}
Loading
Loading