From bd058d9af14763c0df21a64968bb7a41cac69d6c Mon Sep 17 00:00:00 2001 From: Or Toren Date: Mon, 13 Jul 2026 09:37:38 +0300 Subject: [PATCH 1/3] feat: add auto-fix as a third Frogbot command mode - New `autofix` package: SBOM-based descriptor locator, dependency updater via jfrog-cli-security package updaters, git branch/commit/ push via frogbot GitManager, VCS-agnostic PR creation via vcsclient - Supports Maven, Npm, Pnpm, Go, Pip; technology inferred from PURL - New `autofix/action.yml` subdirectory action (`uses: jfrog/frogbot/autofix@v3`) with dedicated inputs: component-name, affected-version, fix-version, branch-name, commit-hash - New `.github/workflows/frogbot-auto-fix.yml` workflow_dispatch template - Registered `auto-fix` (alias `af`) command in commands.go - action/src/main.ts routes to execAutoFix() when command input is auto-fix Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/frogbot-auto-fix.yml | 40 +++++ action/src/main.ts | 6 + action/src/utils.ts | 32 ++++ autofix/action.yml | 39 +++++ autofix/autofix.go | 194 +++++++++++++++++++++++++ autofix/locator.go | 88 +++++++++++ commands.go | 10 ++ utils/utils.go | 2 +- 8 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/frogbot-auto-fix.yml create mode 100644 autofix/action.yml create mode 100644 autofix/autofix.go create mode 100644 autofix/locator.go diff --git a/.github/workflows/frogbot-auto-fix.yml b/.github/workflows/frogbot-auto-fix.yml new file mode 100644 index 000000000..0848cc49d --- /dev/null +++ b/.github/workflows/frogbot-auto-fix.yml @@ -0,0 +1,40 @@ +name: "Frogbot Auto-Fix" +on: + workflow_dispatch: + inputs: + component-name: + description: "Dependency to fix." + required: true + affected-version: + description: "Currently installed vulnerable version." + required: true + fix-version: + description: "Version to upgrade to." + required: true + branch-name: + description: "Branch to base the fix PR on. Defaults to the default branch." + required: false + commit-hash: + description: "Exact commit the scan ran against." + required: false +permissions: + pull-requests: write + contents: write +jobs: + auto-fix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit-hash || inputs.branch-name || github.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: jfrog/frogbot/autofix@v3 + env: + JF_GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + component-name: ${{ inputs.component-name }} + affected-version: ${{ inputs.affected-version }} + fix-version: ${{ inputs.fix-version }} + branch-name: ${{ inputs.branch-name }} + commit-hash: ${{ inputs.commit-hash }} diff --git a/action/src/main.ts b/action/src/main.ts index 252e396a0..8a132204f 100644 --- a/action/src/main.ts +++ b/action/src/main.ts @@ -8,6 +8,12 @@ async function main() { await Utils.setupOidcTokenIfNeeded(jfrogUrl); const eventName: string = await Utils.setFrogbotEnv(); await Utils.addToPath(); + + if (core.getInput('command') === 'auto-fix') { + await Utils.execAutoFix(); + return; + } + switch (eventName) { case 'pull_request': case 'pull_request_target': diff --git a/action/src/utils.ts b/action/src/utils.ts index fb0608f68..dc838263d 100644 --- a/action/src/utils.ts +++ b/action/src/utils.ts @@ -125,6 +125,38 @@ export class Utils { } } + /** + * Execute frogbot auto-fix command. + */ + public static async execAutoFix() { + core.exportVariable('JF_COMPONENT_NAME', core.getInput('component-name')); + core.exportVariable('JF_AFFECTED_VERSION', core.getInput('affected-version')); + core.exportVariable('JF_FIX_VERSION', core.getInput('fix-version')); + + const commitHash: string = core.getInput('commit-hash'); + if (commitHash) { + core.exportVariable('JF_COMMIT_HASH', commitHash); + } + + const branchName: string = core.getInput('branch-name'); + if (branchName) { + core.exportVariable('JF_GIT_BASE_BRANCH', branchName); + } else if (!process.env.JF_GIT_BASE_BRANCH) { + const git: SimpleGit = simpleGit(); + try { + const currentBranch: BranchSummary = await git.branch(); + core.exportVariable('JF_GIT_BASE_BRANCH', currentBranch.current); + } catch (error) { + throw new Error('Error getting current branch from the .git folder: ' + error); + } + } + + const res: number = await exec(Utils.getExecutableName(), ['auto-fix']); + if (res !== core.ExitCode.Success) { + throw new Error('Frogbot exited with exit code ' + res); + } + } + /** * Try to load the Frogbot executables from cache. * diff --git a/autofix/action.yml b/autofix/action.yml new file mode 100644 index 000000000..b83f44456 --- /dev/null +++ b/autofix/action.yml @@ -0,0 +1,39 @@ +name: "Frogbot Auto-Fix by JFrog" +description: "Automatically creates a fix PR for a known vulnerable dependency using JFrog Frogbot." +author: "JFrog" +inputs: + component-name: + description: "Dependency to fix." + required: true + affected-version: + description: "Currently installed vulnerable version." + required: true + fix-version: + description: "Version to upgrade to." + required: true + branch-name: + description: "Branch the vulnerability was detected on. Used as the PR base. Defaults to the current branch." + required: false + commit-hash: + description: "Exact commit the scan ran against." + required: false + command: + description: "Frogbot command to run." + default: "auto-fix" + required: false + version: + description: "Frogbot version to use." + default: "latest" + required: false + oidc-provider-name: + description: "Provider Name's value that was set in OpenId Connect integration in the JFrog platform." + required: false + oidc-audience: + description: "By default, this is the URL of the GitHub repository owner, such as the organization that owns the repository." + required: false +runs: + using: "node24" + main: "../action/lib/main.js" +branding: + icon: "terminal" + color: "green" diff --git a/autofix/autofix.go b/autofix/autofix.go new file mode 100644 index 000000000..2c20ff5af --- /dev/null +++ b/autofix/autofix.go @@ -0,0 +1,194 @@ +package autofix + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/jfrog/froggit-go/vcsclient" + securitypkgupdaters "github.com/jfrog/jfrog-cli-security/remediation/sca/packageupdaters" + "github.com/jfrog/jfrog-cli-security/utils/formats" + "github.com/jfrog/jfrog-cli-security/utils/techutils" + "github.com/jfrog/jfrog-client-go/utils/log" + + "github.com/jfrog/frogbot/v3/utils" +) + +const ( + componentNameEnv = "JF_COMPONENT_NAME" + affectedVersionEnv = "JF_AFFECTED_VERSION" + fixVersionEnv = "JF_FIX_VERSION" + commitHashEnv = "JF_COMMIT_HASH" + autoFixBranchPrefix = "jfrog-auto-fix" +) + +type AutoFixCmd struct{} + +func (a *AutoFixCmd) Run(repository utils.Repository, client vcsclient.VcsClient) error { + componentName := os.Getenv(componentNameEnv) + affectedVersion := os.Getenv(affectedVersionEnv) + fixVersion := os.Getenv(fixVersionEnv) + + if err := validateInputs(componentName, affectedVersion, fixVersion); err != nil { + return err + } + + log.Info(fmt.Sprintf("Starting auto-fix for component '%s' (%s → %s) in %s/%s", + componentName, affectedVersion, fixVersion, + repository.Params.Git.RepoOwner, repository.Params.Git.RepoName)) + + fixed, err := fixDependency(componentName, affectedVersion, fixVersion) + if err != nil { + return err + } + if !fixed { + return nil + } + + return openFixPullRequest(repository, client, componentName, affectedVersion, fixVersion) +} + +func fixDependency(componentName, affectedVersion, fixVersion string) (bool, error) { + workspaceDir, err := os.Getwd() + if err != nil { + return false, fmt.Errorf("failed to get current working directory: %w", err) + } + + log.Info(fmt.Sprintf("Scanning project to locate '%s@%s' in dependency tree...", componentName, affectedVersion)) + descriptorPaths, tech, err := findDescriptorPaths(workspaceDir, componentName, affectedVersion) + if err != nil { + return false, err + } + if len(descriptorPaths) == 0 { + return false, nil + } + + updater, err := newUpdater(tech) + if err != nil { + return false, err + } + + log.Info(fmt.Sprintf("Updating '%s' from %s to %s in %d file(s): %v", + componentName, affectedVersion, fixVersion, len(descriptorPaths), descriptorPaths)) + if err = updater.UpdateDependency(buildFixDetails(componentName, affectedVersion, fixVersion, tech, descriptorPaths)); err != nil { + return false, fmt.Errorf("failed to update dependency: %w", err) + } + log.Info(fmt.Sprintf("Successfully updated '%s' to %s", componentName, fixVersion)) + return true, nil +} + +func openFixPullRequest(repository utils.Repository, client vcsclient.VcsClient, componentName, affectedVersion, fixVersion string) error { + gitManager, err := utils.NewGitManager(). + SetAuth(repository.Params.Git.Username, repository.Params.Git.Token). + SetLocalRepositoryAndRemoteName() + if err != nil { + return fmt.Errorf("failed to initialize git manager: %w", err) + } + gitManager = gitManager.SetGitParams(&repository.Params.Git) + + fixBranchName := generateAutoFixBranchName(componentName, fixVersion) + if err = gitManager.CreateBranchAndCheckout(fixBranchName, true); err != nil { + return fmt.Errorf("failed to create fix branch '%s': %w", fixBranchName, err) + } + + commitMessage := fmt.Sprintf("fix: update %s from %s to %s", componentName, affectedVersion, fixVersion) + if err = gitManager.AddAllAndCommit(commitMessage, componentName); err != nil { + return fmt.Errorf("failed to commit changes: %w", err) + } + + if err = gitManager.Push(true, fixBranchName); err != nil { + return fmt.Errorf("failed to push branch '%s': %w", fixBranchName, err) + } + log.Info(fmt.Sprintf("Branch '%s' pushed to origin", fixBranchName)) + + baseBranch := repository.Params.Git.Branches[0] + prTitle := fmt.Sprintf("[Auto-Fix] Update %s to %s", componentName, fixVersion) + log.Info(fmt.Sprintf("Creating pull request from '%s' to '%s'", fixBranchName, baseBranch)) + if err = client.CreatePullRequest(context.Background(), + repository.Params.Git.RepoOwner, repository.Params.Git.RepoName, + fixBranchName, baseBranch, prTitle, buildPRBody(componentName, affectedVersion, fixVersion)); err != nil { + return fmt.Errorf("failed to create pull request: %w", err) + } + + log.Info("Pull request created successfully") + return nil +} + +func validateInputs(componentName, affectedVersion, fixVersion string) error { + var missing []string + if componentName == "" { + missing = append(missing, componentNameEnv) + } + if affectedVersion == "" { + missing = append(missing, affectedVersionEnv) + } + if fixVersion == "" { + missing = append(missing, fixVersionEnv) + } + if len(missing) > 0 { + return fmt.Errorf("missing required environment variable(s): %s", strings.Join(missing, ", ")) + } + return nil +} + +func newUpdater(tech techutils.Technology) (securitypkgupdaters.PackageUpdater, error) { + switch tech { + case techutils.Maven: + return &securitypkgupdaters.MavenPackageUpdater{}, nil + case techutils.Npm: + return &securitypkgupdaters.NpmPackageUpdater{}, nil + case techutils.Pnpm: + return &securitypkgupdaters.PnpmPackageUpdater{}, nil + case techutils.Go: + return &securitypkgupdaters.GoPackageUpdater{}, nil + case techutils.Pip: + return &securitypkgupdaters.PythonPackageUpdater{}, nil + default: + return nil, fmt.Errorf("unsupported technology '%s' for auto-fix", tech) + } +} + +func buildFixDetails(componentName, affectedVersion, fixVersion string, tech techutils.Technology, descriptorPaths []string) *securitypkgupdaters.FixDetails { + evidences := make([]formats.Location, len(descriptorPaths)) + for i, path := range descriptorPaths { + evidences[i] = formats.Location{File: path} + } + return &securitypkgupdaters.FixDetails{ + ImpactedDependencyName: componentName, + ImpactedDependencyVersion: affectedVersion, + SuggestedFixedVersion: fixVersion, + IsDirectDependency: true, + Technology: tech, + Components: []formats.ComponentRow{ + { + Name: componentName, + Version: affectedVersion, + Evidences: evidences, + }, + }, + } +} + +func buildPRBody(componentName, affectedVersion, fixVersion string) string { + body := fmt.Sprintf( + "This PR was automatically created by JFrog Frogbot auto-fix.\n\n"+ + "**Component:** `%s`\n"+ + "**Affected version:** `%s`\n"+ + "**Fix version:** `%s`\n", + componentName, affectedVersion, fixVersion, + ) + if commitHash := os.Getenv(commitHashEnv); commitHash != "" { + body += fmt.Sprintf("**Scanned commit:** `%s`\n", commitHash) + } + return body +} + +func generateAutoFixBranchName(componentName, fixVersion string) string { + safe := strings.NewReplacer(":", "-", "/", "-", "@", "", " ", "-").Replace(componentName) + branchName := fmt.Sprintf("%s/%s-%s", autoFixBranchPrefix, safe, fixVersion) + if len(branchName) > 255 { + branchName = branchName[:255] + } + return branchName +} diff --git a/autofix/locator.go b/autofix/locator.go new file mode 100644 index 000000000..1e38b249c --- /dev/null +++ b/autofix/locator.go @@ -0,0 +1,88 @@ +package autofix + +import ( + "fmt" + "strings" + + "github.com/CycloneDX/cyclonedx-go" + "github.com/jfrog/jfrog-cli-security/sca/bom/xrayplugin" + "github.com/jfrog/jfrog-cli-security/utils/results" + "github.com/jfrog/jfrog-cli-security/utils/techutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +func findDescriptorPaths(workspaceDir, componentName, affectedVersion string) ([]string, techutils.Technology, error) { + log.Debug("Preparing Xray-Lib plugin for dependency tree analysis") + generator := xrayplugin.NewXrayLibBomGenerator() + if err := generator.PrepareGenerator(); err != nil { + return nil, techutils.NoTech, fmt.Errorf("failed to prepare Xray-Lib plugin: %w", err) + } + + log.Debug(fmt.Sprintf("Generating SBOM for workspace: %s", workspaceDir)) + sbom, err := generator.GenerateSbom(results.ScanTarget{Target: workspaceDir}) + if err != nil { + return nil, techutils.NoTech, fmt.Errorf("failed to generate SBOM: %w", err) + } + if sbom.Components != nil { + log.Debug(fmt.Sprintf("SBOM generated with %d components", len(*sbom.Components))) + } + + log.Debug(fmt.Sprintf("Searching SBOM for '%s@%s'", componentName, affectedVersion)) + paths, tech, err := extractDescriptorPaths(sbom, componentName, affectedVersion) + if err != nil { + return nil, techutils.NoTech, err + } + if len(paths) == 0 { + log.Warn(fmt.Sprintf("Component '%s@%s' was not found in the SBOM", componentName, affectedVersion)) + return nil, techutils.NoTech, nil + } + log.Info(fmt.Sprintf("Found '%s@%s' (%s) in %d descriptor file(s): %v", componentName, affectedVersion, tech, len(paths), paths)) + return paths, tech, nil +} + +func extractDescriptorPaths(sbom *cyclonedx.BOM, componentName, affectedVersion string) ([]string, techutils.Technology, error) { + if sbom == nil || sbom.Components == nil { + return nil, techutils.NoTech, fmt.Errorf("SBOM is empty") + } + + normalise := func(name string) string { + return strings.ReplaceAll(name, ":", "/") + } + normaliseComponentName := normalise(componentName) + + seen := map[string]bool{} + var paths []string + tech := techutils.NoTech + + for _, component := range *sbom.Components { + compName, compVersion, compType := techutils.SplitPackageURL(component.PackageURL) + log.Debug(fmt.Sprintf("Inspecting SBOM component: %s@%s (type: %s)", compName, compVersion, compType)) + + if normalise(compName) != normaliseComponentName || compVersion != affectedVersion { + continue + } + log.Debug(fmt.Sprintf("Matched component '%s@%s' — checking evidence occurrences", compName, compVersion)) + + if tech == techutils.NoTech { + tech = techutils.ToTechnology(compType) + } + + if component.Evidence == nil || component.Evidence.Occurrences == nil { + log.Debug(fmt.Sprintf("Component '%s@%s' has no evidence occurrences, skipping", compName, compVersion)) + continue + } + for _, occurrence := range *component.Evidence.Occurrences { + if occurrence.Location == "" { + continue + } + if seen[occurrence.Location] { + log.Debug(fmt.Sprintf("Skipping duplicate occurrence location: %s", occurrence.Location)) + continue + } + seen[occurrence.Location] = true + paths = append(paths, occurrence.Location) + log.Debug(fmt.Sprintf("Found descriptor: %s", occurrence.Location)) + } + } + return paths, tech, nil +} diff --git a/commands.go b/commands.go index 821f005e3..5fe26c46a 100644 --- a/commands.go +++ b/commands.go @@ -10,6 +10,7 @@ import ( "github.com/jfrog/jfrog-client-go/utils/io/fileutils" "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/jfrog/frogbot/v3/autofix" "github.com/jfrog/frogbot/v3/scanpullrequest" "github.com/jfrog/frogbot/v3/scanrepository" "github.com/jfrog/frogbot/v3/utils" @@ -42,6 +43,15 @@ func GetCommands() []*clitool.Command { }, Flags: []clitool.Flag{}, }, + { + Name: utils.AutoFix, + Aliases: []string{"af"}, + Usage: "Fix a known vulnerable dependency and open a pull request with the fix", + Action: func(ctx *clitool.Context) error { + return Exec(&autofix.AutoFixCmd{}, ctx.Command.Name) + }, + Flags: []clitool.Flag{}, + }, } } diff --git a/utils/utils.go b/utils/utils.go index fe385cdc1..b7e3cc2d3 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -39,7 +39,7 @@ import ( const ( ScanPullRequest = "scan-pull-request" ScanRepository = "scan-repository" - RootDir = "." + AutoFix = "auto-fix" branchNameRegex = `[~^:?\\\[\]@{}*]` dependencySubmissionFrogbotDetector = "JFrog Frogbot" frogbotUrl = "https://github.com/jfrog/frogbot" From 42fa6a7a76bfe3685e06565595a54ed1fa41262b Mon Sep 17 00:00:00 2001 From: Or Toren Date: Mon, 13 Jul 2026 09:50:05 +0300 Subject: [PATCH 2/3] with tests --- .github/workflows/test.yml | 3 + autofix/autofix_test.go | 178 +++++++++++++++++++++++++++++++++++++ autofix/locator_test.go | 114 ++++++++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 autofix/autofix_test.go create mode 100644 autofix/locator_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73f9de9c5..76489daa2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,6 +77,9 @@ jobs: - name: 'Scan Pull Request' package: 'scanpullrequest' + - name: 'Auto Fix' + package: 'autofix' + os: [ ubuntu, windows, macos ] steps: # Configure prerequisites diff --git a/autofix/autofix_test.go b/autofix/autofix_test.go new file mode 100644 index 000000000..bc0a463f0 --- /dev/null +++ b/autofix/autofix_test.go @@ -0,0 +1,178 @@ +package autofix + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/jfrog-cli-security/utils/techutils" +) + +func TestValidateInputs(t *testing.T) { + tests := []struct { + name string + componentName string + affectedVersion string + fixVersion string + expectError bool + errorContains []string + errorMissing []string + }{ + { + name: "all present", + componentName: "com.example:lib", + affectedVersion: "1.0.0", + fixVersion: "1.0.1", + }, + { + name: "missing all", + expectError: true, + errorContains: []string{componentNameEnv, affectedVersionEnv, fixVersionEnv}, + }, + { + name: "missing fix version only", + componentName: "com.example:lib", + affectedVersion: "1.0.0", + expectError: true, + errorContains: []string{fixVersionEnv}, + errorMissing: []string{componentNameEnv}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateInputs(tc.componentName, tc.affectedVersion, tc.fixVersion) + if tc.expectError { + require.Error(t, err) + for _, s := range tc.errorContains { + assert.Contains(t, err.Error(), s) + } + for _, s := range tc.errorMissing { + assert.NotContains(t, err.Error(), s) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGenerateAutoFixBranchName(t *testing.T) { + tests := []struct { + name string + componentName string + fixVersion string + notContains []string + maxLen int + }{ + { + name: "basic — colon replaced", + componentName: "com.example:lib", + fixVersion: "1.0.1", + notContains: []string{":"}, + }, + { + name: "special chars sanitized", + componentName: "@scope/pkg", + fixVersion: "2.0.0", + notContains: []string{"@", "/scope/"}, + }, + { + name: "long name truncated", + componentName: strings.Repeat("a", 300), + fixVersion: "1.0.0", + maxLen: 255, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + name := generateAutoFixBranchName(tc.componentName, tc.fixVersion) + assert.True(t, strings.HasPrefix(name, autoFixBranchPrefix+"/")) + for _, s := range tc.notContains { + assert.NotContains(t, name, s) + } + if tc.maxLen > 0 { + assert.LessOrEqual(t, len(name), tc.maxLen) + } + }) + } +} + +func TestBuildPRBody(t *testing.T) { + tests := []struct { + name string + commitHash string + contains []string + notContains []string + }{ + { + name: "no commit hash", + contains: []string{"com.example:lib", "1.0.0", "1.0.1"}, + notContains: []string{"Scanned commit"}, + }, + { + name: "with commit hash", + commitHash: "abc123", + contains: []string{"abc123", "Scanned commit"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.commitHash != "" { + t.Setenv(commitHashEnv, tc.commitHash) + } else { + t.Setenv(commitHashEnv, "") + } + body := buildPRBody("com.example:lib", "1.0.0", "1.0.1") + for _, s := range tc.contains { + assert.Contains(t, body, s) + } + for _, s := range tc.notContains { + assert.NotContains(t, body, s) + } + }) + } +} + +func TestNewUpdater(t *testing.T) { + tests := []struct { + name string + tech techutils.Technology + expectError bool + }{ + {name: "maven", tech: techutils.Maven}, + {name: "npm", tech: techutils.Npm}, + {name: "pnpm", tech: techutils.Pnpm}, + {name: "go", tech: techutils.Go}, + {name: "pip", tech: techutils.Pip}, + {name: "unsupported yarn", tech: techutils.Yarn, expectError: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + updater, err := newUpdater(tc.tech) + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported technology") + } else { + assert.NoError(t, err) + assert.NotNil(t, updater) + } + }) + } +} + +func TestBuildFixDetails(t *testing.T) { + paths := []string{"pom.xml", "module/pom.xml"} + details := buildFixDetails("com.example:lib", "1.0.0", "1.0.1", techutils.Maven, paths) + + assert.Equal(t, "com.example:lib", details.ImpactedDependencyName) + assert.Equal(t, "1.0.0", details.ImpactedDependencyVersion) + assert.Equal(t, "1.0.1", details.SuggestedFixedVersion) + assert.True(t, details.IsDirectDependency) + assert.Equal(t, techutils.Maven, details.Technology) + require.Len(t, details.Components, 1) + require.Len(t, details.Components[0].Evidences, 2) + assert.Equal(t, "pom.xml", details.Components[0].Evidences[0].File) + assert.Equal(t, "module/pom.xml", details.Components[0].Evidences[1].File) +} diff --git a/autofix/locator_test.go b/autofix/locator_test.go new file mode 100644 index 000000000..179c58d25 --- /dev/null +++ b/autofix/locator_test.go @@ -0,0 +1,114 @@ +package autofix + +import ( + "testing" + + cyclonedx "github.com/CycloneDX/cyclonedx-go" + "github.com/jfrog/jfrog-cli-security/utils/techutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func makeComponent(purl string, locations ...string) cyclonedx.Component { + c := cyclonedx.Component{PackageURL: purl} + if len(locations) > 0 { + occurrences := make([]cyclonedx.EvidenceOccurrence, len(locations)) + for i, loc := range locations { + occurrences[i] = cyclonedx.EvidenceOccurrence{Location: loc} + } + c.Evidence = &cyclonedx.Evidence{Occurrences: &occurrences} + } + return c +} + +func TestExtractDescriptorPaths(t *testing.T) { + tests := []struct { + name string + sbom *cyclonedx.BOM + componentName string + affectedVersion string + expectedPaths []string + expectedTech techutils.Technology + expectError bool + }{ + { + name: "maven match", + sbom: &cyclonedx.BOM{Components: &[]cyclonedx.Component{ + makeComponent("pkg:maven/com.example/lib@1.0.0", "pom.xml"), + makeComponent("pkg:maven/com.example/other@2.0.0", "other/pom.xml"), + }}, + componentName: "com.example/lib", + affectedVersion: "1.0.0", + expectedPaths: []string{"pom.xml"}, + expectedTech: techutils.Maven, + }, + { + name: "npm match", + sbom: &cyclonedx.BOM{Components: &[]cyclonedx.Component{ + makeComponent("pkg:npm/lodash@4.17.20", "package.json"), + }}, + componentName: "lodash", + affectedVersion: "4.17.20", + expectedPaths: []string{"package.json"}, + expectedTech: techutils.Npm, + }, + { + name: "component not found", + sbom: &cyclonedx.BOM{Components: &[]cyclonedx.Component{ + makeComponent("pkg:maven/com.example/lib@1.0.0", "pom.xml"), + }}, + componentName: "com.example/other", + affectedVersion: "1.0.0", + expectedPaths: nil, + expectedTech: techutils.NoTech, + }, + { + name: "wrong version", + sbom: &cyclonedx.BOM{Components: &[]cyclonedx.Component{ + makeComponent("pkg:maven/com.example/lib@1.0.0", "pom.xml"), + }}, + componentName: "com.example/lib", + affectedVersion: "2.0.0", + expectedPaths: nil, + expectedTech: techutils.NoTech, + }, + { + name: "nil SBOM returns error", + sbom: nil, + componentName: "lib", + expectError: true, + }, + { + name: "duplicate locations deduplicated", + sbom: &cyclonedx.BOM{Components: &[]cyclonedx.Component{ + makeComponent("pkg:maven/com.example/lib@1.0.0", "pom.xml", "pom.xml"), + }}, + componentName: "com.example/lib", + affectedVersion: "1.0.0", + expectedPaths: []string{"pom.xml"}, + expectedTech: techutils.Maven, + }, + { + name: "match with no evidence — tech detected, paths empty", + sbom: &cyclonedx.BOM{Components: &[]cyclonedx.Component{ + {PackageURL: "pkg:maven/com.example/lib@1.0.0"}, + }}, + componentName: "com.example/lib", + affectedVersion: "1.0.0", + expectedPaths: nil, + expectedTech: techutils.Maven, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + paths, tech, err := extractDescriptorPaths(tc.sbom, tc.componentName, tc.affectedVersion) + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedTech, tech) + assert.Equal(t, tc.expectedPaths, paths) + }) + } +} From 2a677a05716db0090d4308f632aef2dc7d58615a Mon Sep 17 00:00:00 2001 From: Or Toren Date: Mon, 13 Jul 2026 10:03:10 +0300 Subject: [PATCH 3/3] after code review --- .github/workflows/frogbot-auto-fix.yml | 2 ++ action/lib/main.js | 4 ++++ action/lib/utils.js | 32 ++++++++++++++++++++++++++ autofix/autofix.go | 26 ++++++++++++++++++--- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/.github/workflows/frogbot-auto-fix.yml b/.github/workflows/frogbot-auto-fix.yml index 0848cc49d..7b69a5e62 100644 --- a/.github/workflows/frogbot-auto-fix.yml +++ b/.github/workflows/frogbot-auto-fix.yml @@ -31,6 +31,8 @@ jobs: - uses: jfrog/frogbot/autofix@v3 env: + JF_URL: ${{ secrets.FROGBOT_URL }} + JF_ACCESS_TOKEN: ${{ secrets.FROGBOT_ACCESS_TOKEN }} JF_GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: component-name: ${{ inputs.component-name }} diff --git a/action/lib/main.js b/action/lib/main.js index b6f18643c..c80432a67 100644 --- a/action/lib/main.js +++ b/action/lib/main.js @@ -42,6 +42,10 @@ function main() { yield utils_1.Utils.setupOidcTokenIfNeeded(jfrogUrl); const eventName = yield utils_1.Utils.setFrogbotEnv(); yield utils_1.Utils.addToPath(); + if (core.getInput('command') === 'auto-fix') { + yield utils_1.Utils.execAutoFix(); + return; + } switch (eventName) { case 'pull_request': case 'pull_request_target': diff --git a/action/lib/utils.js b/action/lib/utils.js index 1e7921c40..b12d778a5 100644 --- a/action/lib/utils.js +++ b/action/lib/utils.js @@ -149,6 +149,38 @@ class Utils { } }); } + /** + * Execute frogbot auto-fix command. + */ + static execAutoFix() { + return __awaiter(this, void 0, void 0, function* () { + core.exportVariable('JF_COMPONENT_NAME', core.getInput('component-name')); + core.exportVariable('JF_AFFECTED_VERSION', core.getInput('affected-version')); + core.exportVariable('JF_FIX_VERSION', core.getInput('fix-version')); + const commitHash = core.getInput('commit-hash'); + if (commitHash) { + core.exportVariable('JF_COMMIT_HASH', commitHash); + } + const branchName = core.getInput('branch-name'); + if (branchName) { + core.exportVariable('JF_GIT_BASE_BRANCH', branchName); + } + else if (!process.env.JF_GIT_BASE_BRANCH) { + const git = (0, simple_git_1.simpleGit)(); + try { + const currentBranch = yield git.branch(); + core.exportVariable('JF_GIT_BASE_BRANCH', currentBranch.current); + } + catch (error) { + throw new Error('Error getting current branch from the .git folder: ' + error); + } + } + const res = yield (0, exec_1.exec)(Utils.getExecutableName(), ['auto-fix']); + if (res !== core.ExitCode.Success) { + throw new Error('Frogbot exited with exit code ' + res); + } + }); + } /** * Try to load the Frogbot executables from cache. * diff --git a/autofix/autofix.go b/autofix/autofix.go index 2c20ff5af..0b705cac5 100644 --- a/autofix/autofix.go +++ b/autofix/autofix.go @@ -2,6 +2,7 @@ package autofix import ( "context" + "errors" "fmt" "os" "strings" @@ -61,7 +62,10 @@ func fixDependency(componentName, affectedVersion, fixVersion string) (bool, err return false, err } if len(descriptorPaths) == 0 { - return false, nil + return false, fmt.Errorf("component '%s@%s' was not found in the project dependency tree", componentName, affectedVersion) + } + if tech == techutils.NoTech { + return false, fmt.Errorf("could not determine package manager for component '%s@%s'", componentName, affectedVersion) } updater, err := newUpdater(tech) @@ -88,12 +92,28 @@ func openFixPullRequest(repository utils.Repository, client vcsclient.VcsClient, gitManager = gitManager.SetGitParams(&repository.Params.Git) fixBranchName := generateAutoFixBranchName(componentName, fixVersion) + + existsInRemote, err := gitManager.BranchExistsInRemote(fixBranchName) + if err != nil { + return fmt.Errorf("failed to check if fix branch '%s' exists: %w", fixBranchName, err) + } + if existsInRemote { + log.Info(fmt.Sprintf("Skipping fix pull request for dependency '%s' to version '%s': a fix branch already exists. If the pull request was previously closed, delete the fix branch to allow a new one to be created.", + componentName, fixVersion)) + return nil + } + if err = gitManager.CreateBranchAndCheckout(fixBranchName, true); err != nil { return fmt.Errorf("failed to create fix branch '%s': %w", fixBranchName, err) } commitMessage := fmt.Sprintf("fix: update %s from %s to %s", componentName, affectedVersion, fixVersion) if err = gitManager.AddAllAndCommit(commitMessage, componentName); err != nil { + var errNoChanges *utils.ErrNothingToCommit + if errors.As(err, &errNoChanges) { + log.Info(err.Error()) + return nil + } return fmt.Errorf("failed to commit changes: %w", err) } @@ -187,8 +207,8 @@ func buildPRBody(componentName, affectedVersion, fixVersion string) string { func generateAutoFixBranchName(componentName, fixVersion string) string { safe := strings.NewReplacer(":", "-", "/", "-", "@", "", " ", "-").Replace(componentName) branchName := fmt.Sprintf("%s/%s-%s", autoFixBranchPrefix, safe, fixVersion) - if len(branchName) > 255 { - branchName = branchName[:255] + if len(branchName) > 100 { + branchName = branchName[:100] } return branchName }