df3c9142ab
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
198 lines
7.3 KiB
Go
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')
|
|
}
|