Files
kandev/apps/backend/internal/common/securityutil/git.go
T

198 lines
7.3 KiB
Go

package securityutil
import (
"fmt"
"regexp"
"strings"
)
// validBranchNameRegex matches safe git branch names.
// Allows alphanumeric, hyphens, underscores, slashes, and dots.
// Disallows: spaces, shell metacharacters, and control characters.
var validBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
// IsValidBranchName validates that a branch name is safe to use in git commands.
// Returns true if the branch name:
// - Is not empty and under 256 characters
// - Matches the alphanumeric pattern with safe special chars
// - Does not contain ".." (path traversal prevention)
// - Does not end with ".lock" (git internal file)
func IsValidBranchName(branch string) bool {
if branch == "" || len(branch) > 255 {
return false
}
// Disallow ".." to prevent path traversal
if strings.Contains(branch, "..") {
return false
}
// Disallow ending with ".lock"
if strings.HasSuffix(branch, ".lock") {
return false
}
return validBranchNameRegex.MatchString(branch)
}
// IsValidBaseBranchRef validates a base-branch ref using the IsValidBranchName
// allowlist, transparently stripping an "origin/" prefix (the regex disallows
// "/" as the first character). Shared so service-tier base-branch rejection
// uses the same allowlist as task base-branch overrides and the agentctl-side
// sanitiser.
func IsValidBaseBranchRef(ref string) bool {
rest := ref
if stripped, ok := strings.CutPrefix(ref, "origin/"); ok {
rest = stripped
}
// git check-ref-format also rejects a trailing "/" and consecutive "//",
// which the IsValidBranchName regex (its char class includes "/") permits.
// Reject them here so "main/" / "origin/main/" / "a//b" don't pass.
if strings.HasSuffix(rest, "/") || strings.Contains(rest, "//") {
return false
}
return IsValidBranchName(rest)
}
// gitSymbolicRefs are git's well-known symbolic refs: pure alnum/underscore
// with no leading dash and no "~"/"^", so validBranchNameRegex (and
// therefore IsValidBaseBranchRef) admits them even though none is ever a
// real branch name. A caller that persists one of these as a repository's
// default branch and later feeds it to a local rev-parse fallback (worktree
// FallbackBaseBranch, or internal/delivery's ancestry check) gets whatever
// commit that pseudo-ref currently resolves to in that checkout — silently
// the wrong commit, not a resolution failure.
var gitSymbolicRefs = map[string]struct{}{
"HEAD": {},
"ORIG_HEAD": {},
"FETCH_HEAD": {},
"MERGE_HEAD": {},
}
// IsGitSymbolicRef reports whether ref is one of git's reserved pseudo-refs
// above.
func IsGitSymbolicRef(ref string) bool {
_, ok := gitSymbolicRefs[ref]
return ok
}
// IsValidDefaultBranchName validates a value bound for
// repositories.default_branch: the IsValidBaseBranchRef allowlist (branch
// syntax, no trailing/double slash) plus a reject on git's symbolic
// pseudo-refs, which that allowlist alone admits. Every ingestion site for
// this field (repository create, update, and the FindOrCreateRepository
// backfill) must use this rather than IsValidBranchName or
// IsValidBaseBranchRef directly, since a default branch — unlike an
// ordinary base-branch override — is read back through a local-fallback
// rev-parse with no upstream ref to disambiguate it from a pseudo-ref.
func IsValidDefaultBranchName(branch string) bool {
return IsValidBaseBranchRef(branch) && !IsGitSymbolicRef(branch)
}
// IsValidExpectedBranchName validates a caller-supplied expected branch: the
// IsValidBranchName allowlist plus git check-ref-format's rejection of a
// trailing "/" and consecutive "//", plus a reject on git's symbolic
// pseudo-refs. It deliberately does not route through IsValidBaseBranchRef,
// which strips an "origin/" prefix and so validates a different string than
// the caller supplied. An expected branch is used verbatim, both in the
// comparison against HEAD and in the push refspec, so the value that is
// checked must be the value that is used.
func IsValidExpectedBranchName(branch string) bool {
if strings.HasSuffix(branch, "/") || strings.Contains(branch, "//") {
return false
}
return IsValidBranchName(branch) && !IsGitSymbolicRef(branch)
}
// IsKnownSafeGitFlag returns true if the argument is a known safe git flag used by this codebase.
// This prevents argument injection where user input could introduce malicious flags.
// Only flags actually used by the Kandev codebase are whitelisted.
func IsKnownSafeGitFlag(arg string) bool {
// Flags allowed only as an exact argument, because the prefix match below
// would admit variants this codebase never issues: "--rebase" would let
// through "--rebase-merges" and "--rebase=interactive" (which spawns an
// editor), and "--abort" would let through "--abort-*".
exactFlags := []string{
"--", // Path separator - everything after this is treated as paths, not flags
"--rebase",
"--abort",
}
for _, safe := range exactFlags {
if arg == safe {
return true
}
}
// Whitelist of git flags actually used by our codebase
safeFlags := []string{
"-m", "-M", "-n", "--set-upstream", "--all", "--porcelain", "--short",
"--abbrev-ref", "--symbolic-full-name", "--verify", "--no-patch",
"--format", "--format=", "--stat", "--shortstat", "--numstat", "-p", "-A",
"--amend", "--allow-empty", "--soft", "--mixed", "--hard",
"--cached", "--force", "--source=HEAD", "--staged", "--worktree",
"--dry-run", "--no-verify", "--get-all", "--first-parent", "--is-ancestor",
"--refs",
"--src-prefix=", "--dst-prefix=",
}
for _, safe := range safeFlags {
if strings.HasPrefix(arg, safe) {
return true
}
}
return false
}
// IsKnownSafeGitLiteral returns true for git-specific literals that are safe to use in commands.
// This includes git refs, remotes, and special markers that don't need branch name validation.
func IsKnownSafeGitLiteral(arg string) bool {
// Common safe git literals
safeLiterals := []string{
"HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD",
"origin", "upstream", ".", "--",
}
for _, safe := range safeLiterals {
if arg == safe {
return true
}
}
// HEAD~N, HEAD^N, HEAD@{} patterns are safe
if strings.HasPrefix(arg, "HEAD~") || strings.HasPrefix(arg, "HEAD^") || strings.HasPrefix(arg, "HEAD@{") {
return true
}
return false
}
// LooksLikeCommitSHA returns true if the string looks like a git commit SHA (hex chars only).
// This allows commit SHAs to bypass branch name validation.
// Valid commit SHAs are 7-64 hexadecimal characters (short or full SHA).
func LooksLikeCommitSHA(arg string) bool {
if len(arg) < 7 || len(arg) > 64 {
return false
}
for _, c := range arg {
if !isHexChar(c) {
return false
}
}
return true
}
// ValidateBranchReference validates a branch reference like "origin/branch".
// Returns an error if the reference contains invalid branch or remote names.
func ValidateBranchReference(arg string) error {
parts := strings.SplitN(arg, "/", 2)
if len(parts) != 2 {
return nil // Not a reference format
}
remote, branch := parts[0], parts[1]
if !IsValidBranchName(remote) {
return fmt.Errorf("invalid remote name in reference '%s'", arg)
}
if !IsValidBranchName(branch) {
return fmt.Errorf("invalid branch name in reference '%s'", arg)
}
return nil
}
// isHexChar returns true if the rune is a hexadecimal character (0-9, a-f, A-F).
func isHexChar(r rune) bool {
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
}