Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ require (
github.com/owenrumney/go-sarif/v3 v3.2.3
github.com/package-url/packageurl-go v0.1.3
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/urfave/cli v1.22.17
github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3
Expand Down Expand Up @@ -124,6 +126,8 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/vbatts/tar-split v0.12.2 // indirect
github.com/vbauerster/mpb/v8 v8.12.1 // indirect
Expand Down
9 changes: 9 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,15 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo=
github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
Expand Down
312 changes: 312 additions & 0 deletions utils/remediation/packageupdaters/commonpackageupdater.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
package packageupdaters

import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"

git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/jfrog/gofrog/datastructures"
"github.com/jfrog/jfrog-cli-security/utils/techutils"
"github.com/jfrog/jfrog-client-go/utils/log"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"golang.org/x/exp/slices"
)

const (
NodePackageJSONFileName = "package.json"
NodeModulesDirName = "node_modules"
nodeDependenciesSection = "dependencies"
nodeDevDependenciesSection = "devDependencies"
nodeOptionalDependenciesSection = "optionalDependencies"
nodeOverridesSection = "overrides"
nodePackageManagerInstallTimeout = 15 * time.Minute
)

var nodePackageManifestSections = []string{
nodeDependenciesSection,
nodeDevDependenciesSection,
nodeOptionalDependenciesSection,
nodeOverridesSection,
}

var SupportedFixTechnologies = []techutils.Technology{
techutils.Npm,
techutils.Maven,
techutils.Pip,
techutils.Poetry,
techutils.Pipenv,
techutils.Go,
techutils.Pnpm,
}

func GetCompatiblePackageUpdater(fixDetails *FixDetails) (PackageUpdater, bool) {
switch fixDetails.Technology {
case techutils.Go:
return &GoPackageUpdater{}, true
case techutils.Pip, techutils.Poetry, techutils.Pipenv:
return &PythonPackageUpdater{pipRequirementsFile: defaultRequirementFile}, true
case techutils.Npm:
return &NpmPackageUpdater{}, true
case techutils.Maven:
return &MavenPackageUpdater{}, true
case techutils.Pnpm:
return &PnpmPackageUpdater{}, true
default:
return nil, false
}
}

type CommonPackageUpdater struct{}

func EvidencePathLooksLikeNpmPackageCoordinate(evidenceFile string) bool {
dir := filepath.Dir(evidenceFile)
if dir == "." || dir == "" {
return false
}
for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
if part == "" || part == "." {
continue
}
if strings.Contains(part, "@") && !strings.HasPrefix(part, "@") {
return true
}
}
return false
}

func (cph *CommonPackageUpdater) CollectVulnerabilityDescriptorPaths(fixDetails *FixDetails, namesFilters []string, ignoreFilters []string) []string {
pathsSet := datastructures.MakeSet[string]()
for _, component := range fixDetails.Components {
for _, evidence := range component.Evidences {
if evidence.File == "" || techutils.IsTechnologyDescriptor(evidence.File) == techutils.NoTech || slices.ContainsFunc(ignoreFilters, func(pattern string) bool { return strings.Contains(evidence.File, pattern) }) {
continue
}
if len(namesFilters) == 0 || slices.Contains(namesFilters, filepath.Base(evidence.File)) {
pathsSet.Add(evidence.File)
}
}
}
return pathsSet.ToSlice()
}

func (cph *CommonPackageUpdater) BuildPackageDependencyLineRegex(impactedName, impactedVersion, dependencyLineFormat string) *regexp.Regexp {
regexpFitImpactedName := strings.ToLower(regexp.QuoteMeta(impactedName))
regexpFitImpactedVersion := strings.ToLower(regexp.QuoteMeta(impactedVersion))
regexpCompleteFormat := fmt.Sprintf(strings.ToLower(dependencyLineFormat), regexpFitImpactedName, regexpFitImpactedVersion)
return regexp.MustCompile(regexpCompleteFormat)
}

func EscapeJsonPathKey(key string) string {
r := strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
return r.Replace(key)
}

func (cph *CommonPackageUpdater) GetFixedPackageJSONManifest(content []byte, packageName, newVersion, descriptorPath string) ([]byte, error) {
updated := false
escapedName := EscapeJsonPathKey(packageName)

for _, section := range nodePackageManifestSections {
path := section + "." + escapedName
if gjson.GetBytes(content, path).Exists() {
var err error
content, err = sjson.SetBytes(content, path, newVersion)
if err != nil {
return nil, fmt.Errorf("failed to set version for '%s' in section '%s': %w", packageName, section, err)
}
updated = true
}
}

if !updated {
return nil, fmt.Errorf("package '%s' not found in allowed sections [%s] in '%s'", packageName, strings.Join(nodePackageManifestSections, ", "), descriptorPath)
}
return content, nil
}

func (cph *CommonPackageUpdater) UpdatePackageJSONDescriptor(descriptorPath, packageName, newVersion string) ([]byte, error) {
//#nosec G304 -- descriptorPath comes from descriptor discovery in the scanned repository.
descriptorContent, err := os.ReadFile(descriptorPath)
if err != nil {
return nil, fmt.Errorf("failed to read file '%s': %w", descriptorPath, err)
}

backupContent := make([]byte, len(descriptorContent))
copy(backupContent, descriptorContent)

updatedContent, err := cph.GetFixedPackageJSONManifest(descriptorContent, packageName, newVersion, descriptorPath)
if err != nil {
return nil, fmt.Errorf("failed to update version in descriptor: %w", err)
}

//#nosec G703 G306 -- descriptorPath from scan workflow; 0644 for VCS-tracked sources.
if err = os.WriteFile(descriptorPath, updatedContent, 0644); err != nil {
return nil, fmt.Errorf("failed to write updated descriptor '%s': %w", descriptorPath, err)
}
return backupContent, nil
}

func (cph *CommonPackageUpdater) withDescriptorWorkingDir(descriptorPath, originalWd string, fn func() error) (err error) {
descriptorDir := filepath.Dir(descriptorPath)
if err = os.Chdir(descriptorDir); err != nil {
return fmt.Errorf("failed to change directory to '%s': %w", descriptorDir, err)
}
defer func() {
if chErr := os.Chdir(originalWd); chErr != nil {
err = errors.Join(err, fmt.Errorf("failed to return to original directory: %w", chErr))
}
}()
return fn()
}

func (cph *CommonPackageUpdater) BuildEnvWithOverrides(overrides map[string]string) []string {
env := make([]string, 0, len(os.Environ())+len(overrides))
for _, e := range os.Environ() {
key := strings.SplitN(e, "=", 2)[0]
if _, shouldOverride := overrides[key]; !shouldOverride {
env = append(env, e)
}
}
for key, value := range overrides {
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
return env
}

func (cph *CommonPackageUpdater) UpdateDependency(fixDetails *FixDetails, installationCommand string, extraArgs ...string) (err error) {
impactedPackage := strings.ToLower(fixDetails.ImpactedDependencyName)
commandArgs := []string{installationCommand}
commandArgs = append(commandArgs, extraArgs...)
versionOperator := fixDetails.Technology.GetPackageVersionOperator()
fixedPackageArgs := GetFixedPackage(impactedPackage, versionOperator, fixDetails.SuggestedFixedVersion)
commandArgs = append(commandArgs, fixedPackageArgs...)
return runPackageMangerCommand(fixDetails.Technology.GetExecCommandName(), fixDetails.Technology.String(), commandArgs)
}

func runPackageMangerCommand(commandName string, techName string, commandArgs []string) error {
fullCommand := commandName + " " + strings.Join(commandArgs, " ")
log.Debug(fmt.Sprintf("Running '%s'", fullCommand))
//#nosec G204 -- commandName is a known package manager binary, not user-controlled input.
cmd := exec.Command(commandName, commandArgs...)
if commandName == "pnpm" {
cmd.Env = EnvWithCorepackIntegrityWorkaround(os.Environ())
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to update %s dependency: '%s' command failed: %s\n%s", techName, fullCommand, err.Error(), output)
}
return nil
}

func EnvWithCorepackIntegrityWorkaround(base []string) []string {
const key = "COREPACK_INTEGRITY_KEYS"
prefix := key + "="
out := make([]string, 0, len(base)+1)
for _, e := range base {
if !strings.HasPrefix(e, prefix) {
out = append(out, e)
}
}
return append(out, prefix+"0")
}

func GetFixedPackage(impactedPackage string, versionOperator string, suggestedFixedVersion string) (fixedPackageArgs []string) {
fixedPackageString := strings.TrimSpace(impactedPackage) + versionOperator + strings.TrimSpace(suggestedFixedVersion)
fixedPackageArgs = strings.Split(fixedPackageString, " ")
return
}

func (cph *CommonPackageUpdater) GetAllDescriptorFilesFullPaths(descriptorFilesSuffixes []string, patternsToExclude ...string) (descriptorFilesFullPaths []string, err error) {
if len(descriptorFilesSuffixes) == 0 {
return
}

var regexpPatternsCompilers []*regexp.Regexp
for _, patternToExclude := range patternsToExclude {
regexpPatternsCompilers = append(regexpPatternsCompilers, regexp.MustCompile(patternToExclude))
}

err = filepath.WalkDir(".", func(path string, d fs.DirEntry, innerErr error) error {
if innerErr != nil {
return fmt.Errorf("an error has occurred when attempting to access or traverse the file system: %w", innerErr)
}

for _, regexpCompiler := range regexpPatternsCompilers {
if match := regexpCompiler.FindString(path); match != "" {
return filepath.SkipDir
}
}

for _, assetFileSuffix := range descriptorFilesSuffixes {
if strings.HasSuffix(path, assetFileSuffix) {
var absFilePath string
absFilePath, innerErr = filepath.Abs(path)
if innerErr != nil {
return fmt.Errorf("couldn't retrieve file's absolute path for './%s': %w", path, innerErr)
}
descriptorFilesFullPaths = append(descriptorFilesFullPaths, absFilePath)
}
}
return nil
})
if err != nil {
err = fmt.Errorf("failed to get descriptor files absolute paths: %w", err)
}
return
}

func BuildPackageWithVersionRegex(impactedName, impactedVersion, dependencyLineFormat string) *regexp.Regexp {
var c CommonPackageUpdater
return c.BuildPackageDependencyLineRegex(impactedName, impactedVersion, dependencyLineFormat)
}

func GetVulnerabilityLocations(fixDetails *FixDetails, namesFilters []string, ignoreFilters []string) []string {
var c CommonPackageUpdater
return c.CollectVulnerabilityDescriptorPaths(fixDetails, namesFilters, ignoreFilters)
}

// IsFileTrackedByGit returns true if the given file is tracked by the git repository
// rooted at repoRootDir. filePath may be absolute or relative.
func IsFileTrackedByGit(filePath, repoRootDir string) (bool, error) {
repo, err := git.PlainOpen(repoRootDir)
if err != nil {
return false, fmt.Errorf("failed to open git repository at '%s': %w", repoRootDir, err)
}

head, err := repo.Head()
if err != nil {
return false, fmt.Errorf("failed to get HEAD reference: %w", err)
}

commit, err := repo.CommitObject(head.Hash())
if err != nil {
return false, fmt.Errorf("failed to get HEAD commit: %w", err)
}

tree, err := commit.Tree()
if err != nil {
return false, fmt.Errorf("failed to get commit tree: %w", err)
}

// tree.File expects a slash-separated path relative to the repo root.
relPath, err := filepath.Rel(repoRootDir, filePath)
if err != nil {
return false, fmt.Errorf("failed to make path relative: %w", err)
}
_, err = tree.File(filepath.ToSlash(relPath))
if errors.Is(err, object.ErrFileNotFound) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check file in commit tree: %w", err)
}
return true, nil
}
Loading
Loading