From 9595bbdb1ce46bfbb3052bd41425f0e3abf53614 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Wed, 30 Sep 2026 09:40:51 +0300 Subject: [PATCH 1/2] Fix git checkout failure when base branch has no local ref CI systems that check out a non-default branch commonly leave the workspace on a detached HEAD with only a remote-tracking ref for that branch, causing Frogbot to fail with "reference not found" when it tries to switch back to the base branch after a fix attempt. Checkout now falls back to recreating the local branch from the corresponding remote-tracking ref in that case. Co-Authored-By: Claude Sonnet 5 --- utils/git.go | 33 +++++++++++++++++++++++++++++++-- utils/git_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/utils/git.go b/utils/git.go index e7b0d54dc..c312549f4 100644 --- a/utils/git.go +++ b/utils/git.go @@ -174,9 +174,38 @@ func (gm *GitManager) SetDryRun(dryRun bool, dryRunRepoPath string) *GitManager func (gm *GitManager) Checkout(branchName string) error { log.Debug("Running git checkout to branch:", branchName) - if err := gm.createBranchAndCheckout(branchName, false, false); err != nil { - return fmt.Errorf("'git checkout %s' failed with error: %s", branchName, err.Error()) + err := gm.createBranchAndCheckout(branchName, false, false) + if err == nil { + return nil + } + if errors.Is(err, plumbing.ErrReferenceNotFound) { + if trackErr := gm.checkoutFromRemoteTrackingBranch(branchName); trackErr == nil { + return nil + } + } + return fmt.Errorf("'git checkout %s' failed with error: %s", branchName, err.Error()) +} + +// checkoutFromRemoteTrackingBranch creates a local branch named branchName from the corresponding +// remote-tracking ref (e.g. refs/remotes/origin/) and checks it out. +func (gm *GitManager) checkoutFromRemoteTrackingBranch(branchName string) error { + remoteRefName := plumbing.NewRemoteReferenceName(gm.remoteName, plumbing.ReferenceName(branchName).Short()) + remoteRef, err := gm.localGitRepository.Reference(remoteRefName, true) + if err != nil { + return err + } + localRefName := GetFullBranchName(branchName) + if err = gm.localGitRepository.Storer.SetReference(plumbing.NewHashReference(localRefName, remoteRef.Hash())); err != nil { + return err + } + worktree, err := gm.localGitRepository.Worktree() + if err != nil { + return err + } + if err = worktree.Checkout(&git.CheckoutOptions{Branch: localRefName, Force: true}); err != nil { + return err } + log.Debug(fmt.Sprintf("Local branch '%s' was missing, recreated it from remote-tracking ref '%s'", branchName, remoteRefName)) return nil } diff --git a/utils/git_test.go b/utils/git_test.go index 300f736c6..df93f05e1 100644 --- a/utils/git_test.go +++ b/utils/git_test.go @@ -9,6 +9,7 @@ import ( "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/jfrog/froggit-go/vcsutils" "github.com/jfrog/jfrog-cli-security/utils/techutils" @@ -337,6 +338,38 @@ func TestGitManager_Checkout(t *testing.T) { } } +func TestGitManager_Checkout_DetachedHeadFallsBackToRemoteTrackingBranch(t *testing.T) { + tmpDir, err := fileutils.CreateTempDir() + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + restoreWd, err := Chdir(tmpDir) + assert.NoError(t, err) + defer func() { + assert.NoError(t, restoreWd()) + }() + gitManager := createFakeDotGit(t, tmpDir) + + masterRef, err := gitManager.localGitRepository.Reference(plumbing.NewBranchReferenceName("master"), true) + assert.NoError(t, err) + + remoteRefName := plumbing.NewRemoteReferenceName(vcsutils.RemoteName, "master") + assert.NoError(t, gitManager.localGitRepository.Storer.SetReference(plumbing.NewHashReference(remoteRefName, masterRef.Hash()))) + assert.NoError(t, gitManager.localGitRepository.Storer.RemoveReference(plumbing.NewBranchReferenceName("master"))) + worktree, err := gitManager.localGitRepository.Worktree() + assert.NoError(t, err) + assert.NoError(t, worktree.Checkout(&git.CheckoutOptions{Hash: masterRef.Hash(), Force: true})) + + _, err = gitManager.localGitRepository.Reference(plumbing.NewBranchReferenceName("master"), true) + assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound) + + assert.NoError(t, gitManager.Checkout("master")) + currBranch, err := getCurrentBranch(gitManager.localGitRepository) + assert.NoError(t, err) + assert.Equal(t, "master", currBranch) +} + func createFakeDotGit(t *testing.T, testPath string) *GitManager { // Initialize a new in-memory repository repo, err := git.PlainInit(testPath, false) From 06d850c78108906653af42961e37934d918c8605 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Sun, 4 Oct 2026 15:35:24 +0300 Subject: [PATCH 2/2] Regenerate multi-dir PR comment fixture for new brace-expansion CVEs Three new CVEs (CVE-2026-102276/102277/102278) against the brace-expansion:1.1.12 transitive dependency were published to Xray since this fixture was last captured, bumping the multi-dir test project's issue count from 8 to 11 and breaking TestScanPullRequest/ScanPullRequestMultiWorkDir(NoFail) in CI. Regenerated from the live scan output, no code changes. Co-Authored-By: Claude Sonnet 5 --- .../expected_response_multi_dir.md | 218 +++++++++++++++++- 1 file changed, 216 insertions(+), 2 deletions(-) diff --git a/testdata/scanpullrequest/expected_response_multi_dir.md b/testdata/scanpullrequest/expected_response_multi_dir.md index e3262314a..13cf696f7 100644 --- a/testdata/scanpullrequest/expected_response_multi_dir.md +++ b/testdata/scanpullrequest/expected_response_multi_dir.md @@ -11,11 +11,11 @@ ## 📗 Scan Summary -- Frogbot scanned for vulnerabilities and found 8 issues +- Frogbot scanned for vulnerabilities and found 11 issues | Scan Category | Status | Security Issues | | --------------------- | :-----------------------------------: | ----------------------------------- | -| **Software Composition Analysis** | ✅ Done |
8 Issues Found 8 High
| +| **Software Composition Analysis** | ✅ Done |
11 Issues Found 10 High
1 Medium
| | **Contextual Analysis** | ✅ Done | - | | **Static Application Security Testing (SAST)** | ✅ Done | Not Found | | **Secrets** | ✅ Done | - | @@ -26,6 +26,7 @@ | Severity | ID | Contextual Analysis | Dependency Path | | :---------------------: | :-----------------------------------: | :-----------------------------------: | ----------------------------------- | +| ![medium](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/applicableMediumSeverity.png)
Medium | CVE-2026-102277 | Not Covered |
1 Transitivebrace-expansion:1.1.12
| | ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2026-69152 | Not Applicable |
1 Transitivebrace-expansion:1.1.12
| | ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2026-33750 | Not Applicable |
1 Transitivebrace-expansion:1.1.12
| | ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2026-27904 | Not Applicable |
1 Directminimatch:3.0.4
| @@ -33,11 +34,87 @@ | ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2026-26996 | Not Applicable |
1 Directminimatch:3.0.4
| | ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2026-14257 | Not Applicable |
1 Transitivebrace-expansion:1.1.12
| | ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2026-13149 | Not Applicable |
1 Transitivebrace-expansion:1.1.12
| +| ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2026-102278 | Not Applicable |
1 Transitivebrace-expansion:1.1.12
| +| ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2026-102276 | Not Applicable |
1 Transitivebrace-expansion:1.1.12
| | ![high (not applicable)](https://raw.githubusercontent.com/jfrog/frogbot/master/resources/v2/notApplicableHigh.png)
High | CVE-2022-3517 | Not Applicable |
1 Directminimatch:3.0.4
| ### 🔖 Details +
[ CVE-2026-102277 ] brace-expansion 1.1.12 + +### Vulnerability Details +| | | +| --------------------- | :-----------------------------------: | +| **Contextual Analysis:** | Not Covered | +| **CVSS V3:** | 5.3 | +| **Dependency Path:** |
brace-expansion: 1.1.12 (Transitive)Fix Version: 1.1.21
| + +### Summary + +Expanding `{a},b}`-shaped input takes time quadratic in the number of literal `}` characters, blocking the event loop. + +Bash preserves a quirk where a brace group followed by a comma set still expands (`{a},b}`). The parser implements this by rewriting the string and restarting the scan. Each pass absorbs exactly one `}` and re-scans from the beginning, so `n` trailing braces cost `n` full passes. + +### Reproduction + +```js +const build = n => '{a}' + '}'.repeat(n) + ',z}' + +for (const n of [8000, 16000, 32000, 64000, 128000]) { + const t = Date.now() + expand(build(n)) + console.log(n, Date.now() - t + 'ms') +} +``` + +| n | input | time | results | +|---|---|---|---| +| 8,000 | 8 KB | 110 ms | 2 | +| 16,000 | 16 KB | 446 ms | 2 | +| 32,000 | 32 KB | 1.7 s | 2 | +| 64,000 | 64 KB | 6.9 s | 2 | +| 128,000 | 128 KB | **27.7 s** | 2 | + +`ms/n^2` is flat at ~1.7 and each doubling of `n` costs exactly 4.0x - quadratic. 128 KB of input blocks the event loop for nearly half a minute to produce two results. + +### Mechanism + +Instrumenting the rewrite branch confirms it runs exactly `n + 1` times, once per literal `}`, each re-scanning the whole string. + +There is a second multiplier. The rewrite replaces the group's closing `}` with the internal `escClose` sentinel, which is `'\0CLOSE' + Math.random() + '\0'` - about 25 characters. The working string therefore *grows* by ~25 characters on every pass: + +| n | input length | final string length | +|---|---|---| +| 1,000 | 1,006 | 26,006 | +| 8,000 | 8,006 | 208,006 | + +So the input is inflated roughly 26x, and that factor multiplies both the quadratic constant and peak memory. This makes it partly a memory-pressure issue as well as a CPU one. + +### Why `max` and `maxLength` do not help + +The cost is in parsing, before the result set exists. The payload yields 2 results regardless of size, so neither bound is ever reached. + +### Impact + +An application passing an untrusted pattern to `expand()`, directly or through `minimatch` / `glob`, can have its event loop blocked for tens of seconds by a payload well under minimatch's 65,536-character cap. For a single-threaded Node server that is a full stall, not just a slow request. + +Degraded availability rather than a crash - the process recovers once the expansion completes. + +### Affected versions + +Verified affected on 1.1.18, 2.1.4, 3.0.6 and 5.0.9, all within a few percent of each other (~460-490 ms at n=16,000). + +### Patch + +The rewrite loop gets an iteration bound. Past the cap the remaining string is treated as non-expanding and returned literally, consistent with the existing `max` / `maxLength` caps, which truncate rather than throw. + +Note this bounds the number of passes, not the cost of each: worst-case work remains proportional to `cap x input length`. The cap is set low enough that the residual is bounded in practice, and far above what any realistic `{a},b}` input needs. + +### Severity note + +Scored 5.3 Medium (`A:L`) for consistency with GHSA-3jxr-9vmj-r5cp, the other algorithmic-complexity advisory on this package (CWE-407), which uses the same vector. The stack-exhaustion advisories on this package score `A:H` because they crash the process outright; this one stalls it.
+
[ CVE-2026-69152 ] brace-expansion 1.1.12 ### Vulnerability Details @@ -733,6 +810,143 @@ Verified: the PoC drops from ~2 min to 0.55 ms, 5,000 groups complete in ~344 ms If immediate upgrade isn't possible, avoid passing untrusted input to expand() / glob brace patterns, or run such expansion under a timeout/worker.
+
[ CVE-2026-102278 ] brace-expansion 1.1.12 + +### Vulnerability Details +| | | +| --------------------- | :-----------------------------------: | +| **Contextual Analysis:** | Not Applicable | +| **CVSS V3:** | 7.5 | +| **Dependency Path:** |
brace-expansion: 1.1.12 (Transitive)Fix Version: 1.1.20
| + +### Summary + +`expand_()` recurses once per level of brace *nesting*. Deeply nested input exhausts the native stack and crashes the process. + +This is distinct from CVE-2026-14257 / GHSA-mh99-v99m-4gvg, which made the *tail* iterative (recursion on `m.post`, driven by how many groups are chained). Nesting depth drives a different recursion that the tail fix never touched, so the documented constant-stack-depth guarantee only ever covered chained input, not nested input. + +It is also distinct from GHSA-6j4f-fj2g-mc7p, which fixed recursion in `parseCommaParts()`. Both payloads below still crash with that fix applied. + +### Two recursion sites + +**Comma members.** Each alternative of a brace set is expanded by a recursive call, so nesting a set inside every alternative recurses once per level: + +```js +expand('{a,'.repeat(4000) + 'z' + '}'.repeat(4000)) +// RangeError: Maximum call stack size exceeded +``` + +Crashes at depth 3,907 - about **15.6 KB** of input. + +**Single set.** A brace set whose body parses to a single part is expanded by a recursive call before being re-wrapped (`x{{a,b}}y` -> `x{a}y x{b}y`), which recurses once per nesting level: + +```js +expand('{'.repeat(3200) + 'a,b' + '}'.repeat(3200)) +// RangeError: Maximum call stack size exceeded +``` + +Crashes at depth 3,125 - about **6.25 KB** of input. This is the cheapest stack-exhaustion payload known against this package: roughly a quarter the input of GHSA-6j4f-fj2g-mc7p (29 KB), and about a tenth of minimatch's `MAX_PATTERN_LENGTH` (65,536). + +### Why `max` and `maxLength` do not help + +Both crashes happen while recursing into sub-expansions, before the result set grows. The payloads produce almost no output - the single-set case yields 2 results - so neither bound is ever the limiter. `expand(payload, { max: 1, maxLength: 1 })` still overflows. + +### Impact + +Any application passing an untrusted string to `expand()`, directly or through `minimatch` / `glob` as a user-supplied glob pattern, can be crashed. In Node a `RangeError` the application does not catch terminates the process, so a server globbing user input is exposed to remote unauthenticated denial of service. + +Availability only. No code execution, no data exposure. + +### Affected versions + +Verified affected on 1.1.18, 2.1.4, 3.0.6 and 5.0.9, at near-identical depths on every line (single set: 3,125 on all four; comma members: 3,907-4,102). Not a regression from any recent fix - the gap predates them. + +### Patch + +A `maxDepth` bound (default `EXPANSION_MAX_DEPTH`) is threaded through `expand_()`. Past the cap a group is treated as non-expanding and returned literally, which is how the parser already handles a group that cannot expand. This matches the existing `max` / `maxLength` caps, which truncate rather than throw, so `expand()` continues never to throw on any input. + +The default sits far above any realistic nesting depth and well below the crash threshold.
+ +
[ CVE-2026-102276 ] brace-expansion 1.1.12 + +### Vulnerability Details +| | | +| --------------------- | :-----------------------------------: | +| **Contextual Analysis:** | Not Applicable | +| **CVSS V3:** | 7.5 | +| **Dependency Path:** |
brace-expansion: 1.1.12 (Transitive)Fix Version: 1.1.19
| + +### Summary + +`parseCommaParts()` can exhaust the native stack and crash the process. There are two distinct ways to trigger it, both reachable from a single untrusted pattern string. + +This is the parsing-side counterpart to CVE-2026-14257 / GHSA-mh99-v99m-4gvg. That fix made `expand_()` iterative and documented a constant-stack-depth guarantee, but `parseCommaParts()` was left recursive, so the guarantee only held for one of the two parsing paths. + +### Vector 1 - unbounded recursion on `post` + +`parseCommaParts()` recursed on the remainder of the string once per brace group: + +```js +const postParts = parseCommaParts(post) // unbounded +``` + +A brace group containing many comma-separated groups drives one recursion level per group: + +```js +expand('{' + '{a},'.repeat(7000) + 'b}') +// RangeError: Maximum call stack size exceeded +``` + +About 7,300 repetitions - roughly 29 KB of input - is enough on Node 24; roughly 6,300 (25 KB) on Node 18. The threshold is identical on every affected release line. + +### Vector 2 - `push.apply` with an unbounded array + +Even with the recursion removed, `parseCommaParts()` spread whole arrays into an argument list: + +```js +p.push.apply(p, postParts) +parts.push.apply(parts, p) +``` + +`Function.prototype.apply` places one argument per element on the stack, so a single large array overflows it. This needs **no recursion depth at all** - the following reaches a recursion depth of exactly 1: + +```js +expand('{{x},' + 'a,'.repeat(125000) + 'b}') +// RangeError: Maximum call stack size exceeded +``` + +Threshold is about 124,300 repetitions (~249 KB). This vector was not part of the original report; it was found while verifying the fix. A patch that only de-recurses but keeps `push.apply` leaves a working denial of service behind. + +### Why `max` and `maxLength` do not help + +Both crashes happen during **parsing**, before any expansion. The payloads produce one result per group, so output size grows linearly with input and is never the limiter. `expand(payload, { max: 1, maxLength: 1 })` still overflows. + +### Impact + +Any application that passes an untrusted string to `expand()` - directly, or through `minimatch` / `glob` where it is a user-supplied glob pattern - can be crashed. In Node, a `RangeError` that the application does not catch terminates the process, so a server that globs user input is exposed to remote unauthenticated denial of service. + +`minimatch`'s own `MAX_PATTERN_LENGTH` cap (65,536) does not help against vector 1: the overflow threshold sits well below it. Confirmed on minimatch 10.2.6 - a 64,003-byte pattern passes the length check and overflows both `minimatch.braceExpand()` and `new minimatch.Minimatch()`. + +This is an availability-only issue. No code execution and no data exposure. + +### Not a regression + +5.0.8 and 5.0.9 overflow at the same repetition count, so the gap predates the recent advisories; those fixes simply did not reach it. Verified affected on 1.1.18, 2.1.4, 3.0.6, 5.0.8 and 5.0.9, all at an identical threshold. + +### Patch + +`parseCommaParts()` is rewritten as a loop that carries the partial part across chunks, and every array append uses an element-by-element loop rather than `push.apply`. The redundant `if (!str) return ['']` guard is dropped - the loop returns `['']` for the empty string on its own. + +Equivalence of the old and new implementations was checked by differential testing: exhaustive over every string of `{`, `}`, `,`, `a` up to length 7 plus 300,000 random inputs - 322,000 cases, zero mismatches. + +### Severity note + +Scored 7.5 High under CVSS 3.1 for consistency with the other availability advisories on this package (GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895), which use the same vector. The reporter self-assessed 6.9 Medium under CVSS 4.0 (`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N`). + +### Credit + +Reported by baeseungwon1010, with a working proof of concept and a proposed patch. Vector 2 was identified during maintainer verification.
+
[ CVE-2022-3517 ] minimatch 3.0.4 ### Vulnerability Details