Skip to content

Commit caa0845

Browse files
authored
Support PNPM Auto Fix (#1296)
* init * pnpm fix * pnpm fix * delete test files * after code review * after code review * static analysis * after cr * test fix * test fix
1 parent c030e32 commit caa0845

13 files changed

Lines changed: 432 additions & 241 deletions

packageupdaters/commonpackageupdater.go

Lines changed: 164 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,44 @@
11
package packageupdaters
22

33
import (
4+
"errors"
45
"fmt"
56
"io/fs"
7+
"os"
68
"os/exec"
79
"path/filepath"
810
"regexp"
911
"strings"
12+
"time"
1013

1114
"github.com/jfrog/gofrog/datastructures"
1215
"github.com/jfrog/jfrog-cli-security/utils/techutils"
1316
"github.com/jfrog/jfrog-client-go/utils/log"
17+
"github.com/tidwall/gjson"
18+
"github.com/tidwall/sjson"
1419
"golang.org/x/exp/slices"
1520

1621
"github.com/jfrog/frogbot/v2/utils"
1722
)
1823

24+
// Node
25+
const (
26+
nodePackageJSONFileName = "package.json"
27+
nodeModulesDirName = "node_modules"
28+
nodeDependenciesSection = "dependencies"
29+
nodeDevDependenciesSection = "devDependencies"
30+
nodeOptionalDependenciesSection = "optionalDependencies"
31+
nodeOverridesSection = "overrides"
32+
nodePackageManagerInstallTimeout = 15 * time.Minute
33+
)
34+
35+
var nodePackageManifestSections = []string{
36+
nodeDependenciesSection,
37+
nodeDevDependenciesSection,
38+
nodeOptionalDependenciesSection,
39+
nodeOverridesSection,
40+
}
41+
1942
// PackageUpdater interface to hold operations on packages
2043
type PackageUpdater interface {
2144
UpdateDependency(details *utils.VulnerabilityDetails) error
@@ -54,6 +77,124 @@ func GetCompatiblePackageUpdater(vulnDetails *utils.VulnerabilityDetails, detail
5477
// TODO can be deleted if not needed after refactoring all package updaters
5578
type CommonPackageUpdater struct{}
5679

80+
// evidencePathLooksLikeNpmPackageCoordinate detects scanner evidence paths like "lodash@4.17.19/package.json" (not real paths). Pnpm filters these; npm does not.
81+
func evidencePathLooksLikeNpmPackageCoordinate(evidenceFile string) bool {
82+
dir := filepath.Dir(evidenceFile)
83+
if dir == "." || dir == "" {
84+
return false
85+
}
86+
for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
87+
if part == "" || part == "." {
88+
continue
89+
}
90+
if strings.Contains(part, "@") && !strings.HasPrefix(part, "@") {
91+
return true
92+
}
93+
}
94+
return false
95+
}
96+
97+
func (cph *CommonPackageUpdater) CollectVulnerabilityDescriptorPaths(vulnDetails *utils.VulnerabilityDetails, namesFilters []string, ignoreFilters []string) []string {
98+
pathsSet := datastructures.MakeSet[string]()
99+
for _, component := range vulnDetails.Components {
100+
for _, evidence := range component.Evidences {
101+
if evidence.File == "" || techutils.IsTechnologyDescriptor(evidence.File) == techutils.NoTech || slices.ContainsFunc(ignoreFilters, func(pattern string) bool { return strings.Contains(evidence.File, pattern) }) {
102+
continue
103+
}
104+
if len(namesFilters) == 0 || slices.Contains(namesFilters, filepath.Base(evidence.File)) {
105+
pathsSet.Add(evidence.File)
106+
}
107+
}
108+
}
109+
return pathsSet.ToSlice()
110+
}
111+
112+
// BuildPackageDependencyLineRegex builds a regexp for matching a dependency line in a manifest.
113+
func (cph *CommonPackageUpdater) BuildPackageDependencyLineRegex(impactedName, impactedVersion, dependencyLineFormat string) *regexp.Regexp {
114+
regexpFitImpactedName := strings.ToLower(regexp.QuoteMeta(impactedName))
115+
regexpFitImpactedVersion := strings.ToLower(regexp.QuoteMeta(impactedVersion))
116+
regexpCompleteFormat := fmt.Sprintf(strings.ToLower(dependencyLineFormat), regexpFitImpactedName, regexpFitImpactedVersion)
117+
return regexp.MustCompile(regexpCompleteFormat)
118+
}
119+
120+
func escapeJsonPathKey(key string) string {
121+
r := strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
122+
return r.Replace(key)
123+
}
124+
125+
// GetFixedPackageJSONManifest returns manifest bytes with packageName set to newVersion in allowed sections.
126+
func (cph *CommonPackageUpdater) GetFixedPackageJSONManifest(content []byte, packageName, newVersion, descriptorPath string) ([]byte, error) {
127+
updated := false
128+
escapedName := escapeJsonPathKey(packageName)
129+
130+
for _, section := range nodePackageManifestSections {
131+
path := section + "." + escapedName
132+
if gjson.GetBytes(content, path).Exists() {
133+
var err error
134+
content, err = sjson.SetBytes(content, path, newVersion)
135+
if err != nil {
136+
return nil, fmt.Errorf("failed to set version for '%s' in section '%s': %w", packageName, section, err)
137+
}
138+
updated = true
139+
}
140+
}
141+
142+
if !updated {
143+
return nil, fmt.Errorf("package '%s' not found in allowed sections [%s] in '%s'", packageName, strings.Join(nodePackageManifestSections, ", "), descriptorPath)
144+
}
145+
return content, nil
146+
}
147+
148+
// UpdatePackageJSONDescriptor writes the fixed version for packageName to descriptorPath and returns original file bytes for rollback.
149+
func (cph *CommonPackageUpdater) UpdatePackageJSONDescriptor(descriptorPath, packageName, newVersion string) ([]byte, error) {
150+
//#nosec G304 -- descriptorPath comes from vulnerability evidence in the scanned repository.
151+
descriptorContent, err := os.ReadFile(descriptorPath)
152+
if err != nil {
153+
return nil, fmt.Errorf("failed to read file '%s': %w", descriptorPath, err)
154+
}
155+
156+
backupContent := make([]byte, len(descriptorContent))
157+
copy(backupContent, descriptorContent)
158+
159+
updatedContent, err := cph.GetFixedPackageJSONManifest(descriptorContent, packageName, newVersion, descriptorPath)
160+
if err != nil {
161+
return nil, fmt.Errorf("failed to update version in descriptor: %w", err)
162+
}
163+
164+
//#nosec G306 G703 -- 0644 for checked-out source; path same trusted source as ReadFile above.
165+
if err = os.WriteFile(descriptorPath, updatedContent, 0644); err != nil {
166+
return nil, fmt.Errorf("failed to write updated descriptor '%s': %w", descriptorPath, err)
167+
}
168+
return backupContent, nil
169+
}
170+
171+
func (cph *CommonPackageUpdater) withDescriptorWorkingDir(descriptorPath, originalWd string, fn func() error) (err error) {
172+
descriptorDir := filepath.Dir(descriptorPath)
173+
if err = os.Chdir(descriptorDir); err != nil {
174+
return fmt.Errorf("failed to change directory to '%s': %w", descriptorDir, err)
175+
}
176+
defer func() {
177+
if chErr := os.Chdir(originalWd); chErr != nil {
178+
err = errors.Join(err, fmt.Errorf("failed to return to original directory: %w", chErr))
179+
}
180+
}()
181+
return fn()
182+
}
183+
184+
func (cph *CommonPackageUpdater) buildEnvWithOverrides(overrides map[string]string) []string {
185+
env := make([]string, 0, len(os.Environ())+len(overrides))
186+
for _, e := range os.Environ() {
187+
key := strings.SplitN(e, "=", 2)[0]
188+
if _, shouldOverride := overrides[key]; !shouldOverride {
189+
env = append(env, e)
190+
}
191+
}
192+
for key, value := range overrides {
193+
env = append(env, fmt.Sprintf("%s=%s", key, value))
194+
}
195+
return env
196+
}
197+
57198
// UpdateDependency updates the impacted package to the fixed version
58199
func (cph *CommonPackageUpdater) UpdateDependency(vulnDetails *utils.VulnerabilityDetails, installationCommand string, extraArgs ...string) (err error) {
59200
// Lower the package name to avoid duplicates
@@ -70,13 +211,31 @@ func runPackageMangerCommand(commandName string, techName string, commandArgs []
70211
fullCommand := commandName + " " + strings.Join(commandArgs, " ")
71212
log.Debug(fmt.Sprintf("Running '%s'", fullCommand))
72213
//#nosec G204 -- False positive - the subprocess only runs after the user's approval.
73-
output, err := exec.Command(commandName, commandArgs...).CombinedOutput()
214+
cmd := exec.Command(commandName, commandArgs...)
215+
if commandName == "pnpm" {
216+
cmd.Env = envWithCorepackIntegrityWorkaround(os.Environ())
217+
}
218+
output, err := cmd.CombinedOutput()
74219
if err != nil {
75220
return fmt.Errorf("failed to update %s dependency: '%s' command failed: %s\n%s", techName, fullCommand, err.Error(), output)
76221
}
77222
return nil
78223
}
79224

225+
// envWithCorepackIntegrityWorkaround sets COREPACK_INTEGRITY_KEYS=0 for older Node/Corepack (e.g. corepack#612).
226+
// Also applied after buildEnvWithOverrides for pnpm lockfile regeneration so Corepack-invoked pnpm matches runPackageMangerCommand behavior.
227+
func envWithCorepackIntegrityWorkaround(base []string) []string {
228+
const key = "COREPACK_INTEGRITY_KEYS"
229+
prefix := key + "="
230+
out := make([]string, 0, len(base)+1)
231+
for _, e := range base {
232+
if !strings.HasPrefix(e, prefix) {
233+
out = append(out, e)
234+
}
235+
}
236+
return append(out, prefix+"0")
237+
}
238+
80239
// Returns the updated package and version as it should be run in the update command:
81240
// If the package manager expects a single string (example: <packName>@<version>) it returns []string{<packName>@<version>}
82241
// If the command args suppose to be seperated by spaces (example: <packName> -v <version>) it returns []string{<packName>, "-v", <version>}
@@ -129,23 +288,11 @@ func (cph *CommonPackageUpdater) GetAllDescriptorFilesFullPaths(descriptorFilesS
129288
}
130289

131290
func BuildPackageWithVersionRegex(impactedName, impactedVersion, dependencyLineFormat string) *regexp.Regexp {
132-
regexpFitImpactedName := strings.ToLower(regexp.QuoteMeta(impactedName))
133-
regexpFitImpactedVersion := strings.ToLower(regexp.QuoteMeta(impactedVersion))
134-
regexpCompleteFormat := fmt.Sprintf(strings.ToLower(dependencyLineFormat), regexpFitImpactedName, regexpFitImpactedVersion)
135-
return regexp.MustCompile(regexpCompleteFormat)
291+
var c CommonPackageUpdater
292+
return c.BuildPackageDependencyLineRegex(impactedName, impactedVersion, dependencyLineFormat)
136293
}
137294

138295
func GetVulnerabilityLocations(vulnDetails *utils.VulnerabilityDetails, namesFilters []string, ignoreFilters []string) []string {
139-
pathsSet := datastructures.MakeSet[string]()
140-
for _, component := range vulnDetails.Components {
141-
for _, evidence := range component.Evidences {
142-
if evidence.File == "" || techutils.IsTechnologyDescriptor(evidence.File) == techutils.NoTech || slices.ContainsFunc(ignoreFilters, func(pattern string) bool { return strings.Contains(evidence.File, pattern) }) {
143-
continue
144-
}
145-
if len(namesFilters) == 0 || slices.Contains(namesFilters, filepath.Base(evidence.File)) {
146-
pathsSet.Add(evidence.File)
147-
}
148-
}
149-
}
150-
return pathsSet.ToSlice()
296+
var c CommonPackageUpdater
297+
return c.CollectVulnerabilityDescriptorPaths(vulnDetails, namesFilters, ignoreFilters)
151298
}

0 commit comments

Comments
 (0)