package engineeringdashboard import ( "fmt" "os" "path/filepath" "strings" ) var sensitivePathParts = []string{ "/.git/", "/.git", "/.env", "/.secrets/", "/.secrets", "/data/", } // ResolveRepoRoot returns an absolute, symlink-resolved git root. func ResolveRepoRoot(repo string) (string, error) { if repo == "" { wd, err := os.Getwd() if err != nil { return "", err } repo = wd } abs, err := filepath.Abs(repo) if err != nil { return "", err } resolved, err := filepath.EvalSymlinks(abs) if err != nil { return "", err } fi, err := os.Stat(resolved) if err != nil { return "", err } if !fi.IsDir() { return "", fmt.Errorf("repo root is not a directory: %s", resolved) } return resolved, nil } // SafeDocPath validates a repo-relative markdown path under .doc (or allowlisted root md). func SafeDocPath(repoRoot, rel string) (abs string, err error) { rel = filepath.ToSlash(rel) rel = strings.TrimPrefix(rel, "./") if rel == "" || strings.Contains(rel, "\x00") { return "", fmt.Errorf("empty path") } if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") { return "", fmt.Errorf("absolute path denied: %s", rel) } if strings.Contains(rel, "..") { return "", fmt.Errorf("path traversal denied: %s", rel) } allowRootMD := !strings.Contains(rel, "/") && strings.HasSuffix(strings.ToLower(rel), ".md") underDoc := strings.HasPrefix(rel, ".doc/") if !underDoc && !allowRootMD { return "", fmt.Errorf("path outside allowlist: %s", rel) } if strings.Contains(rel, "/assets/") || strings.HasSuffix(rel, "/assets") { return "", fmt.Errorf("assets body denied: %s", rel) } joined := filepath.Join(repoRoot, filepath.FromSlash(rel)) // Reject if the path itself is a symlink (before resolution). fi, err := os.Lstat(joined) if err != nil { return "", err } if fi.Mode()&os.ModeSymlink != 0 { return "", fmt.Errorf("symlink denied: %s", rel) } resolved, err := filepath.EvalSymlinks(joined) if err != nil { return "", err } repoResolved, err := filepath.EvalSymlinks(repoRoot) if err != nil { repoResolved = repoRoot } if !strings.HasPrefix(resolved, repoResolved+string(os.PathSeparator)) && resolved != repoResolved { return "", fmt.Errorf("resolved path escapes repo: %s", rel) } lower := filepath.ToSlash(strings.ToLower(resolved)) for _, part := range sensitivePathParts { if strings.Contains(lower, part) || strings.HasSuffix(lower, strings.TrimSuffix(part, "/")) { // allow .doc paths that happen to mention words; only block real sensitive dirs if strings.Contains(lower, "/.git") || strings.Contains(lower, "/.env") || strings.Contains(lower, "/.secrets") || strings.Contains(lower, "/data/") { if !strings.Contains(filepath.ToSlash(rel), ".doc/") { return "", fmt.Errorf("sensitive path denied: %s", rel) } // still deny .git/.env/.secrets even under mistaken .doc if strings.Contains(lower, "/.git") || strings.Contains(lower, "/.secrets") || strings.Contains(filepath.Base(lower), ".env") { return "", fmt.Errorf("sensitive path denied: %s", rel) } } } } if !strings.HasSuffix(strings.ToLower(resolved), ".md") { return "", fmt.Errorf("extension not allowed: %s", rel) } return resolved, nil } // IsAssetsPath reports whether a relative path is under an assets directory. func IsAssetsPath(rel string) bool { rel = filepath.ToSlash(rel) return strings.Contains(rel, "/assets/") || strings.HasSuffix(rel, "/assets") || strings.HasPrefix(rel, "assets/") }