Files
kandev/apps/backend/internal/agent/runtime/lifecycle/default_scripts_test.go
T
João Salavisa 8f23b8a19e fix: prevent command injection via untrusted git branch names (#1791)
* fix: prevent command injection via untrusted git branch names

Untrusted git branch names (e.g. a fork PR head branch) were substituted
unescaped into executor prepare scripts and run via `eval` in a shell,
yielding command injection (CRITICAL RCE) in the Docker/Sprites/remote
executors. git check-ref-format permits `;`, `|`, `$`, backticks and
`()`, so a branch like `$(touch pwned)` or `main;touch pwned` executed.

- scriptengine: shell-single-quote every data placeholder that lands in
  shell text (repository.path/name/branch/clone_url/ssh_url,
  workspace.path, worktree.base_path/path/branch/base_branch). Script
  fragments (setup_script, git.identity_setup, github.auth_setup,
  kandev.*) are left untouched.
- default_scripts: wrap each data placeholder in single quotes in the
  postlude, clone lines, and the Sprites echo (double quotes still allow
  $(...) substitution). `'<escaped>'` round-trips to the literal.
- orchestrator: defense-in-depth — reject a PR head branch that fails
  securityutil.IsValidBranchName before it reaches CheckoutBranch, while
  still creating the review task without a checkout.
- tests: PoC-derived regression asserting the injected command no longer
  runs through the real sh -c/eval sink, provider escaping unit tests,
  and ingestion-layer validation coverage.

* fix: harden branch-name escaping for legacy stored prepare scripts

Automated review (Codex, CodeRabbit) flagged that shell-single-quoting the
value while relying on the templates to add the surrounding quotes only
protects the templates we ship. Profiles snapshot the default prepare
script at creation time, so an OLD stored script referencing a placeholder
bare (e.g. `git clone --depth=1 --branch {{repository.branch}} ...`) still
resolved with an unquoted, injectable branch value.

- scriptengine: add shellQuote, which emits a fully self-contained
  single-quoted shell token (surrounding quotes included). Every data
  placeholder (repository.path/name/branch/clone_url/ssh_url,
  workspace.path, worktree.base_path/path/branch/base_branch) now carries
  its own quoting, so a bare `{{placeholder}}` in any template — including
  legacy stored scripts — is safe. shellSingleQuote is kept for the git
  identity fragment, which supplies its own quotes.
- default_scripts: reference data placeholders bare (the value self-quotes);
  the Sprites echo becomes printf with the values as separate args so a
  hostile URL/branch can't expand inside a double-quoted string.
- tests: assert providers emit fully-quoted tokens that round-trip through a
  real shell; cover base_path/path/repository.path/name/ssh_url; add a
  regression proving a legacy stored script with bare placeholders no longer
  executes injected commands.

* test: strengthen branch-injection regression assertions

Address Greptile review feedback on the new tests.

- assertNoUnquotedPayload now tracks real single-quote state char-by-char
  instead of "some quote before and after", so a payload in an unquoted
  context on a line that happens to contain other quotes is caught.
- Drop the unused wantHasRepoRow table field; repo-row presence is already
  asserted via len(got) != 1.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-19 10:33:29 +01:00

215 lines
8.0 KiB
Go

package lifecycle
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestKandevBranchCheckoutPostlude_HasInvariantSteps asserts the kandev-
// managed postlude contains the steps needed to land on the session's
// feature branch. The postlude is appended to every user prepare script so
// stale stored scripts (created before the worktree-branch checkout was
// part of the default) still get the checkout.
func TestKandevBranchCheckoutPostlude_HasInvariantSteps(t *testing.T) {
postlude := KandevBranchCheckoutPostlude()
// Data placeholders are referenced BARE; the scriptengine providers
// substitute a self-contained single-quoted token (shellQuote), so a
// hostile branch name resolves to a quoted literal. The placeholders must
// NOT be double-quoted here (double quotes would re-expose $(...)).
want := []string{
`if [ -d {{workspace.path}}/.git ]`,
`[ -n {{worktree.branch}} ]`,
`[ {{worktree.branch}} != {{repository.branch}} ]`,
`cd {{workspace.path}}`,
`git rev-parse --verify {{worktree.branch}}`,
`git fetch --depth=1 origin {{worktree.branch}}`,
`git checkout -b {{worktree.branch}} origin/{{worktree.branch}}`,
`git checkout -b {{worktree.branch}}`,
`|| true`,
}
for _, w := range want {
if !strings.Contains(postlude, w) {
t.Errorf("postlude missing %q", w)
}
}
// Data placeholders must never be double-quoted: double quotes do not stop
// $(...) / backtick command substitution, which is the RCE we fixed.
if strings.Contains(postlude, `"{{worktree.branch}}"`) ||
strings.Contains(postlude, `"{{repository.branch}}"`) ||
strings.Contains(postlude, `"{{workspace.path}}`) {
t.Errorf("postlude must not double-quote data placeholders:\n%s", postlude)
}
// The destructive `-B branch origin/branch` form orphaned local commits on
// resume — verify it does NOT come back.
forbidden := []string{
`git checkout -B {{worktree.branch}} origin/{{worktree.branch}}`,
}
for _, f := range forbidden {
if strings.Contains(postlude, f) {
t.Errorf("postlude must not contain destructive form %q", f)
}
}
}
// TestKandevBranchCheckoutPostlude_LandsOnFeatureBranch executes the
// postlude as a real shell script against a temp git repo. It is the
// behaviour test that pairs with the static-content test above:
// it catches regressions where the snippet still parses as bash but
// no longer does the right thing (wrong refspec, missing -B, swallowed
// exit code, etc.).
//
// The three cases mirror the three branches inside the postlude:
// - remote feature branch exists → tracks origin/<branch>
// - no remote, no local → creates local branch off HEAD
// - local branch already checked out, no remote → idempotent (no error, same branch)
func TestKandevBranchCheckoutPostlude_LandsOnFeatureBranch(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
cases := []struct {
name string
seedRemote bool // create a "feature" branch on origin before running
seedLocal bool // pre-create the local branch (idempotency check)
featureName string // branch name passed to the postlude
baseName string // base branch name (must match repository.branch)
want string // expected current branch after running
}{
{
name: "remote tip exists",
seedRemote: true,
featureName: "feature/from-remote",
baseName: "main",
want: "feature/from-remote",
},
{
name: "no remote, no local",
seedRemote: false,
featureName: "feature/from-scratch",
baseName: "main",
want: "feature/from-scratch",
},
{
name: "local already checked out",
seedLocal: true,
featureName: "feature/already-here",
baseName: "main",
want: "feature/already-here",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
workspace, originDir := setupPostludeRepo(t, tc.baseName)
if tc.seedRemote {
seedOriginBranch(t, originDir, tc.featureName)
}
if tc.seedLocal {
runIn(t, workspace, "git", "checkout", "-b", tc.featureName)
}
script := strings.NewReplacer(
"{{workspace.path}}", workspace,
"{{worktree.branch}}", tc.featureName,
"{{repository.branch}}", tc.baseName,
).Replace(KandevBranchCheckoutPostlude())
cmd := exec.Command("bash", "-e", "-c", script)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bash -e postlude failed: %v\n%s", err, out)
}
gotBranch := strings.TrimSpace(string(runIn(t, workspace, "git", "branch", "--show-current")))
if gotBranch != tc.want {
t.Fatalf("after postlude branch = %q, want %q\nscript output:\n%s", gotBranch, tc.want, out)
}
})
}
}
// setupPostludeRepo creates a fake origin (bare repo with one commit on
// baseBranch) and a workspace cloned from it, then leaves the workspace on
// baseBranch. Returns workspace path + origin (bare) path.
func setupPostludeRepo(t *testing.T, baseBranch string) (workspace, origin string) {
t.Helper()
root := t.TempDir()
origin = filepath.Join(root, "origin.git")
seed := filepath.Join(root, "seed")
workspace = filepath.Join(root, "workspace")
runIn(t, root, "git", "init", "--quiet", "--bare", "--initial-branch="+baseBranch, origin)
runIn(t, root, "git", "init", "--quiet", "--initial-branch="+baseBranch, seed)
runIn(t, seed, "git", "config", "user.email", "test@example.com")
runIn(t, seed, "git", "config", "user.name", "Test")
runIn(t, seed, "git", "commit", "--allow-empty", "-m", "init")
runIn(t, seed, "git", "remote", "add", "origin", origin)
runIn(t, seed, "git", "push", "--quiet", "origin", baseBranch)
runIn(t, root, "git", "clone", "--quiet", "--branch", baseBranch, origin, workspace)
runIn(t, workspace, "git", "config", "user.email", "test@example.com")
runIn(t, workspace, "git", "config", "user.name", "Test")
return workspace, origin
}
// seedOriginBranch creates `branch` on `origin` (the bare repo) so the
// postlude's `git fetch origin <branch>` succeeds. Uses a temporary clone of
// origin to push the new branch in.
func seedOriginBranch(t *testing.T, origin, branch string) {
t.Helper()
tmp := filepath.Join(t.TempDir(), "seed-origin-branch")
runIn(t, "", "git", "clone", "--quiet", origin, tmp)
runIn(t, tmp, "git", "config", "user.email", "test@example.com")
runIn(t, tmp, "git", "config", "user.name", "Test")
runIn(t, tmp, "git", "checkout", "-b", branch)
runIn(t, tmp, "git", "commit", "--allow-empty", "-m", "feature")
runIn(t, tmp, "git", "push", "--quiet", "origin", branch)
}
// runIn runs cmd with args in dir (or current dir when dir == "") and fails
// the test on non-zero exit. Returns stdout for callers that need it.
func runIn(t *testing.T, dir string, cmd string, args ...string) []byte {
t.Helper()
c := exec.Command(cmd, args...)
if dir != "" {
c.Dir = dir
}
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("%s %v: %v\n%s", cmd, args, err, out)
}
return out
}
// TestDefaultPrepareScripts_NoInlineFeatureBranchCheckout asserts that the
// clone-based remote default scripts no longer carry an inline worktree-
// branch checkout. The checkout is owned exclusively by the postlude
// (KandevBranchCheckoutPostlude) so old stored profiles and the current
// default can never disagree about how the feature branch is materialised.
func TestDefaultPrepareScripts_NoInlineFeatureBranchCheckout(t *testing.T) {
executors := []string{"local_docker", "remote_docker", "sprites"}
forbidden := []string{
`if [ -n {{worktree.branch}} ] && [ {{worktree.branch}} != {{repository.branch}} ]; then`,
`git checkout -B {{worktree.branch}} origin/{{worktree.branch}}`,
}
for _, executorType := range executors {
t.Run(executorType, func(t *testing.T) {
script := DefaultPrepareScript(executorType)
if script == "" {
t.Fatalf("DefaultPrepareScript(%q) returned empty", executorType)
}
for _, bad := range forbidden {
if strings.Contains(script, bad) {
t.Errorf("script for %q must not contain inline checkout %q (postlude owns it)", executorType, bad)
}
}
})
}
}