-
Notifications
You must be signed in to change notification settings - Fork 46
Move package updaters from Frogbot into jfrog-cli-security #775
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
Open
orto17
wants to merge
10
commits into
jfrog:dev
Choose a base branch
from
orto17:move-package-updaters
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
30a8b68
Move package updaters from Frogbot into jfrog-cli-security
orto17 2bd8408
Fix Poetry/Pipenv factory routing, add factory test, tidy go.mod
orto17 afe5bb2
after cr
orto17 496ab8f
Fix typos: manger→manager, occured→occurred in error strings
orto17 6239d9c
Add nosec suppressions for gosec findings in commonpackageupdater
orto17 1b80b27
Fix gocritic captLocal: lowercase parameter names in BackupModuleFiles
orto17 f7c34f1
Add nosec suppressions for G122/G703 in copyDir test helper
orto17 fd6cdbd
Move package updaters to remediation/sca and testdata to tests/testdata
orto17 08d88b5
Add remediation integration test suite with CI job
orto17 043eb5e
Fix gosec nosec annotations in remediation_test.go
orto17 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
312 changes: 312 additions & 0 deletions
312
remediation/sca/packageupdaters/commonpackageupdater.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we add it to Xray release gate verifications?