go-scm/agentci/security.go
Virgil a54bd834ff fix(agentci): restore security helpers and map attack vectors
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-24 13:15:55 +00:00

172 lines
5.2 KiB
Go

package agentci
import (
"os/exec"
"path"
"path/filepath"
"regexp"
"strings"
coreerr "dappco.re/go/core/log"
)
var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`)
// SanitizePath ensures a filename or directory name is safe and prevents path traversal.
// Returns filepath.Base of the input after validation.
func SanitizePath(input string) (string, error) {
base := filepath.Base(input)
safeBase, err := ValidatePathElement(base)
if err != nil {
return "", coreerr.E("agentci.SanitizePath", "invalid path element", err)
}
return safeBase, nil
}
// ValidatePathElement validates a single filesystem or URL path element.
func ValidatePathElement(input string) (string, error) {
if input == "" {
return "", coreerr.E("agentci.ValidatePathElement", "path element is empty", nil)
}
if strings.TrimSpace(input) != input {
return "", coreerr.E("agentci.ValidatePathElement", "path element has leading or trailing whitespace", nil)
}
if input == "." || input == ".." {
return "", coreerr.E("agentci.ValidatePathElement", "invalid path element: "+input, nil)
}
if strings.ContainsAny(input, `/\`) || filepath.IsAbs(input) {
return "", coreerr.E("agentci.ValidatePathElement", "path element must not contain path separators", nil)
}
if !safeNameRegex.MatchString(input) {
return "", coreerr.E("agentci.ValidatePathElement", "invalid characters in path element: "+input, nil)
}
return input, nil
}
// ResolvePathWithinRoot returns a validated name and an absolute path constrained to the given root.
func ResolvePathWithinRoot(root, name string) (string, string, error) {
if root == "" {
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "root is empty", nil)
}
safeName, err := ValidatePathElement(name)
if err != nil {
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "invalid path element", err)
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "resolve root path", err)
}
rootAbs = filepath.Clean(rootAbs)
resolved := filepath.Clean(filepath.Join(rootAbs, safeName))
rel, err := filepath.Rel(rootAbs, resolved)
if err != nil {
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "compute relative path", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "resolved path escapes root", nil)
}
return safeName, resolved, nil
}
// ValidateRemoteDir validates a remote POSIX directory path for SSH command usage.
func ValidateRemoteDir(input string) (string, error) {
if input == "" {
return "", coreerr.E("agentci.ValidateRemoteDir", "remote dir is empty", nil)
}
if strings.TrimSpace(input) != input {
return "", coreerr.E("agentci.ValidateRemoteDir", "remote dir has leading or trailing whitespace", nil)
}
if strings.ContainsAny(input, "\x00\r\n") {
return "", coreerr.E("agentci.ValidateRemoteDir", "remote dir contains control characters", nil)
}
if input == "/" || input == "~" {
return input, nil
}
var (
parts []string
rest string
)
switch {
case strings.HasPrefix(input, "/"):
rest = strings.TrimPrefix(input, "/")
case strings.HasPrefix(input, "~/"):
rest = strings.TrimPrefix(input, "~/")
default:
return "", coreerr.E("agentci.ValidateRemoteDir", "remote dir must be absolute or home-relative", nil)
}
for _, part := range strings.Split(rest, "/") {
if part == "" {
continue
}
safePart, err := ValidatePathElement(part)
if err != nil {
return "", coreerr.E("agentci.ValidateRemoteDir", "invalid remote dir segment", err)
}
parts = append(parts, safePart)
}
switch {
case strings.HasPrefix(input, "/"):
if len(parts) == 0 {
return "/", nil
}
return "/" + strings.Join(parts, "/"), nil
default:
if len(parts) == 0 {
return "~", nil
}
return "~/" + strings.Join(parts, "/"), nil
}
}
// JoinRemotePath joins a validated remote directory with validated path elements.
func JoinRemotePath(base string, elems ...string) (string, error) {
safeBase, err := ValidateRemoteDir(base)
if err != nil {
return "", coreerr.E("agentci.JoinRemotePath", "invalid base path", err)
}
segments := make([]string, 0, len(elems)+1)
segments = append(segments, safeBase)
for _, elem := range elems {
safeElem, err := ValidatePathElement(elem)
if err != nil {
return "", coreerr.E("agentci.JoinRemotePath", "invalid path element", err)
}
segments = append(segments, safeElem)
}
return path.Join(segments...), nil
}
// EscapeShellArg wraps a string in single quotes for safe remote shell insertion.
// Prefer exec.Command arguments over constructing shell strings where possible.
func EscapeShellArg(arg string) string {
return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
// SecureSSHCommand creates an SSH exec.Cmd with strict host key checking and batch mode.
func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd {
return exec.Command("ssh",
"-o", "StrictHostKeyChecking=yes",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
host,
remoteCmd,
)
}
// MaskToken returns a masked version of a token for safe logging.
func MaskToken(token string) string {
if len(token) < 8 {
return "*****"
}
return token[:4] + "****" + token[len(token)-4:]
}